2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 20:16:44 +00:00

Build Order Updates (#4855)

* Add new BuildLine model

- Represents an instance of a BOM item against a BuildOrder

* Create BuildLine instances automatically

When a new Build is created, automatically generate new BuildLine items

* Improve logic for handling exchange rate backends

* logic fixes

* Adds API endpoints

Add list and detail API endpoints for new BuildLine model

* update users/models.py

- Add new model to roles definition

* bulk-create on auto_allocate

Save database hits by performing a bulk-create

* Add skeleton data migration

* Create BuildLines for existing orders

* Working on building out BuildLine table

* Adds link for "BuildLine" to "BuildItem"

- A "BuildItem" will now be tracked against a BuildLine
- Not tracked directly against a build
- Not tracked directly against a BomItem
- Add schema migration
- Add data migration to update links

* Adjust migration 0045

- bom_item and build fields are about to be removed
- Set them to "nullable" so the data doesn't get removed

* Remove old fields from BuildItem model

- build fk
- bom_item fk
- A lot of other required changes too

* Update BuildLine.bom_item field

- Delete the BuildLine if the BomItem is removed
- This is closer to current behaviour

* Cleanup for Build model

- tracked_bom_items -> tracked_line_items
- untracked_bom_items -> tracked_bom_items
- remove build.can_complete
- move bom_item specific methods to the BuildLine model
- Cleanup / consolidation

* front-end work

- Update javascript
- Cleanup HTML templates

* Add serializer annotation and filtering

- Annotate 'allocated' quantity
- Filter by allocated / trackable / optional / consumable

* Make table sortable

* Add buttons

* Add callback for building new stock

* Fix Part annotation

* Adds callback to order parts

* Allocation works again

* template cleanup

* Fix allocate / unallocate actions

- Also turns out "unallocate" is not a word..

* auto-allocate works again

* Fix call to build.is_over_allocated

* Refactoring updates

* Bump API version

* Cleaner implementation of allocation sub-table

* Fix rendering in build output table

* Improvements to StockItem list API

- Refactor very old code
- Add option to include test results to queryset

* Add TODO for later me

* Fix for serializers.py

* Working on cleaner implementation of build output table

* Add function to determine if a single output is fully allocated

* Updates to build.js

- Button callbacks
- Table rendering

* Revert previous changes to build.serializers.py

* Fix for forms.js

* Rearrange code in build.js

* Rebuild "allocated lines" for output table

* Fix allocation calculation

* Show or hide column for tracked parts

* Improve debug messages

* Refactor "loadBuildLineTable"

- Allow it to also be used as output sub-table

* Refactor "completed tests" column

* Remove old javascript

- Cleans up a *lot* of crusty old code

* Annotate the available stock quantity to BuildLine serializer

- Similar pattern to BomItem serializer
- Needs refactoring in the future

* Update available column

* Fix build allocation table

- Bug fix
- Make pretty

* linting fixes

* Allow sorting by available stock

* Tweak for "required tests" column

* Bug fix for completing a build output

* Fix for consumable stock

* Fix for trim_allocated_stock

* Fix for creating new build

* Migration fix

- Ensure initial django_q migrations are applied
- Why on earth is this failing now?

* Catch exception

* Update for exception handling

* Update migrations

- Ensure inventreesetting is added

* Catch all exceptions when getting default currency code

* Bug fix for currency exchange rates update

* Working on unit tests

* Unit test fixes

* More work on unit tests

* Use bulk_create in unit test

* Update required quantity when a BuildOrder is saved

* Tweak overage display in BOM table

* Fix icon in BOM table

* Fix spelling error

* More unit test fixes

* Build reports

- Add line_items
- Update docs
- Cleanup

* Reimplement is_partially_allocated method

* Update docs about overage

* Unit testing for data migration

* Add "required_for_build_orders" annotation

- Makes API query *much* faster now
- remove old "required_parts_to_complete_build" method
- Cleanup part API filter code

* Adjust order of fixture loading

* Fix unit test

* Prevent "schedule_pricing_update" in unit tests

- Should cut down on DB hits significantly

* Unit test updates

* Improvements for unit test

- Don't hard-code pk values
- postgresql no likey

* Better unit test
This commit is contained in:
Oliver 2023-06-13 20:18:32 +10:00 committed by GitHub
parent 98bddd32d0
commit 6ba777d363
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 2193 additions and 1903 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 119 INVENTREE_API_VERSION = 120
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v120 -> 2023-06-07 : https://github.com/inventree/InvenTree/pull/4855
- Major overhaul of the build order API
- Adds new BuildLine model
v119 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4898 v119 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4898
- Add Metadata to: Part test templates, Part parameters, Part category parameter templates, BOM item substitute, Part relateds, Stock item test result - Add Metadata to: Part test templates, Part parameters, Part category parameter templates, BOM item substitute, Part relateds, Stock item test result

View File

@ -126,7 +126,10 @@ class InvenTreeConfig(AppConfig):
update = False update = False
try: try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange') backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
if backend.exists():
backend = backend.first()
last_update = backend.last_update last_update = backend.last_update

View File

@ -223,8 +223,7 @@ main {
} }
.sub-table { .sub-table {
margin-left: 45px; margin-left: 60px;
margin-right: 45px;
} }
.detail-icon .glyphicon { .detail-icon .glyphicon {

View File

@ -497,7 +497,7 @@ def check_for_updates():
def update_exchange_rates(): def update_exchange_rates():
"""Update currency exchange rates.""" """Update currency exchange rates."""
try: try:
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import Rate
from common.settings import currency_code_default, currency_codes from common.settings import currency_code_default, currency_codes
from InvenTree.exchange import InvenTreeExchange from InvenTree.exchange import InvenTreeExchange
@ -509,22 +509,9 @@ def update_exchange_rates():
# Other error? # Other error?
return return
# Test to see if the database is ready yet
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
except ExchangeBackend.DoesNotExist:
pass
except Exception: # pragma: no cover
# Some other error
logger.warning("update_exchange_rates: Database not ready")
return
backend = InvenTreeExchange() backend = InvenTreeExchange()
logger.info(f"Updating exchange rates from {backend.url}")
base = currency_code_default() base = currency_code_default()
logger.info(f"Updating exchange rates using base currency '{base}'")
logger.info(f"Using base currency '{base}'")
try: try:
backend.update_rates(base_currency=base) backend.update_rates(base_currency=base)

View File

@ -14,18 +14,18 @@ class URLTest(TestCase):
# Need fixture data in the database # Need fixture data in the database
fixtures = [ fixtures = [
'settings', 'settings',
'build',
'company', 'company',
'manufacturer_part', 'manufacturer_part',
'price_breaks', 'price_breaks',
'supplier_part', 'supplier_part',
'order', 'order',
'sales_order', 'sales_order',
'bom',
'category', 'category',
'params', 'params',
'part_pricebreaks', 'part_pricebreaks',
'part', 'part',
'bom',
'build',
'test_templates', 'test_templates',
'location', 'location',
'stock_tests', 'stock_tests',

View File

@ -525,7 +525,9 @@ class SettingsView(TemplateView):
# When were the rates last updated? # When were the rates last updated?
try: try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange') backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
if backend.exists():
backend = backend.first()
ctx["rates_updated"] = backend.last_update ctx["rates_updated"] = backend.last_update
except Exception: except Exception:
ctx["rates_updated"] = None ctx["rates_updated"] = None

View File

@ -6,7 +6,7 @@ from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field from import_export.fields import Field
from import_export import widgets from import_export import widgets
from build.models import Build, BuildItem from build.models import Build, BuildLine, BuildItem
from InvenTree.admin import InvenTreeResource from InvenTree.admin import InvenTreeResource
import part.models import part.models
@ -87,18 +87,33 @@ class BuildItemAdmin(admin.ModelAdmin):
"""Class for managing the BuildItem model via the admin interface""" """Class for managing the BuildItem model via the admin interface"""
list_display = ( list_display = (
'build',
'stock_item', 'stock_item',
'quantity' 'quantity'
) )
autocomplete_fields = [ autocomplete_fields = [
'build', 'build_line',
'bom_item',
'stock_item', 'stock_item',
'install_into', 'install_into',
] ]
class BuildLineAdmin(admin.ModelAdmin):
"""Class for managing the BuildLine model via the admin interface"""
list_display = (
'build',
'bom_item',
'quantity',
)
search_fields = [
'build__title',
'build__reference',
'bom_item__sub_part__name',
]
admin.site.register(Build, BuildAdmin) admin.site.register(Build, BuildAdmin)
admin.site.register(BuildItem, BuildItemAdmin) admin.site.register(BuildItem, BuildItemAdmin)
admin.site.register(BuildLine, BuildLineAdmin)

View File

@ -1,5 +1,6 @@
"""JSON API for the Build app.""" """JSON API for the Build app."""
from django.db.models import F
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -17,7 +18,7 @@ from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
import build.admin import build.admin
import build.serializers import build.serializers
from build.models import Build, BuildItem, BuildOrderAttachment from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment
import part.models import part.models
from users.models import Owner from users.models import Owner
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS
@ -251,6 +252,88 @@ class BuildUnallocate(CreateAPI):
return ctx return ctx
class BuildLineFilter(rest_filters.FilterSet):
"""Custom filterset for the BuildLine API endpoint."""
class Meta:
"""Meta information for the BuildLineFilter class."""
model = BuildLine
fields = [
'build',
'bom_item',
]
# Fields on related models
consumable = rest_filters.BooleanFilter(label=_('Consumable'), field_name='bom_item__consumable')
optional = rest_filters.BooleanFilter(label=_('Optional'), field_name='bom_item__optional')
tracked = rest_filters.BooleanFilter(label=_('Tracked'), field_name='bom_item__sub_part__trackable')
allocated = rest_filters.BooleanFilter(label=_('Allocated'), method='filter_allocated')
def filter_allocated(self, queryset, name, value):
"""Filter by whether each BuildLine is fully allocated"""
if str2bool(value):
return queryset.filter(allocated__gte=F('quantity'))
else:
return queryset.filter(allocated__lt=F('quantity'))
class BuildLineEndpoint:
"""Mixin class for BuildLine API endpoints."""
queryset = BuildLine.objects.all()
serializer_class = build.serializers.BuildLineSerializer
def get_queryset(self):
"""Override queryset to select-related and annotate"""
queryset = super().get_queryset()
queryset = queryset.select_related(
'build', 'bom_item',
)
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset)
return queryset
class BuildLineList(BuildLineEndpoint, ListCreateAPI):
"""API endpoint for accessing a list of BuildLine objects"""
filterset_class = BuildLineFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = [
'part',
'allocated',
'reference',
'quantity',
'consumable',
'optional',
'unit_quantity',
'available_stock',
]
ordering_field_aliases = {
'part': 'bom_item__sub_part__name',
'reference': 'bom_item__reference',
'unit_quantity': 'bom_item__quantity',
'consumable': 'bom_item__consumable',
'optional': 'bom_item__optional',
}
search_fields = [
'bom_item__sub_part__name',
'bom_item__reference',
]
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a BuildLine object."""
pass
class BuildOrderContextMixin: class BuildOrderContextMixin:
"""Mixin class which adds build order as serializer context variable.""" """Mixin class which adds build order as serializer context variable."""
@ -373,9 +456,8 @@ class BuildItemFilter(rest_filters.FilterSet):
"""Metaclass option""" """Metaclass option"""
model = BuildItem model = BuildItem
fields = [ fields = [
'build', 'build_line',
'stock_item', 'stock_item',
'bom_item',
'install_into', 'install_into',
] ]
@ -384,6 +466,11 @@ class BuildItemFilter(rest_filters.FilterSet):
field_name='stock_item__part', field_name='stock_item__part',
) )
build = rest_filters.ModelChoiceFilter(
queryset=build.models.Build.objects.all(),
field_name='build_line__build',
)
tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked') tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked')
def filter_tracked(self, queryset, name, value): def filter_tracked(self, queryset, name, value):
@ -409,10 +496,9 @@ class BuildItemList(ListCreateAPI):
try: try:
params = self.request.query_params params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', False)) for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']:
kwargs['build_detail'] = str2bool(params.get('build_detail', False)) if key in params:
kwargs['location_detail'] = str2bool(params.get('location_detail', False)) kwargs[key] = str2bool(params.get(key, False))
kwargs['stock_detail'] = str2bool(params.get('stock_detail', True))
except AttributeError: except AttributeError:
pass pass
@ -423,9 +509,8 @@ class BuildItemList(ListCreateAPI):
queryset = BuildItem.objects.all() queryset = BuildItem.objects.all()
queryset = queryset.select_related( queryset = queryset.select_related(
'bom_item', 'build_line',
'bom_item__sub_part', 'build_line__build',
'build',
'install_into', 'install_into',
'stock_item', 'stock_item',
'stock_item__location', 'stock_item__location',
@ -435,7 +520,7 @@ class BuildItemList(ListCreateAPI):
return queryset return queryset
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""Customm query filtering for the BuildItem list.""" """Custom query filtering for the BuildItem list."""
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
params = self.request.query_params params = self.request.query_params
@ -487,6 +572,12 @@ build_api_urls = [
re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'),
])), ])),
# Build lines
re_path(r'^line/', include([
path(r'<int:pk>/', BuildLineDetail.as_view(), name='api-build-line-detail'),
re_path(r'^.*$', BuildLineList.as_view(), name='api-build-line-list'),
])),
# Build Items # Build Items
re_path(r'^item/', include([ re_path(r'^item/', include([
path(r'<int:pk>/', include([ path(r'<int:pk>/', include([

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2.19 on 2023-05-19 06:04
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0109_auto_20230517_1048'),
('build', '0042_alter_build_notes'),
]
operations = [
migrations.CreateModel(
name='BuildLine',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=5, default=1, help_text='Required quantity for build order', max_digits=15, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Quantity')),
('bom_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='build_lines', to='part.bomitem')),
('build', models.ForeignKey(help_text='Build object', on_delete=django.db.models.deletion.CASCADE, related_name='build_lines', to='build.build')),
],
options={
'unique_together': {('build', 'bom_item')},
},
),
]

View File

@ -0,0 +1,97 @@
# Generated by Django 3.2.19 on 2023-05-28 14:10
from django.db import migrations
def get_bom_items_for_part(part, Part, BomItem):
""" Return a list of all BOM items for a given part.
Note that we cannot use the ORM here (as we are inside a data migration),
so we *copy* the logic from the Part class.
This is a snapshot of the Part.get_bom_items() method as of 2023-05-29
"""
bom_items = set()
# Get all BOM items which directly reference the part
for bom_item in BomItem.objects.filter(part=part):
bom_items.add(bom_item)
# Get all BOM items which are inherited by the part
parents = Part.objects.filter(
tree_id=part.tree_id,
level__lt=part.level,
lft__lt=part.lft,
rght__gt=part.rght
)
for bom_item in BomItem.objects.filter(part__in=parents, inherited=True):
bom_items.add(bom_item)
return list(bom_items)
def add_lines_to_builds(apps, schema_editor):
"""Create BuildOrderLine objects for existing build orders"""
# Get database models
Build = apps.get_model("build", "Build")
BuildLine = apps.get_model("build", "BuildLine")
Part = apps.get_model("part", "Part")
BomItem = apps.get_model("part", "BomItem")
build_lines = []
builds = Build.objects.all()
if builds.count() > 0:
print(f"Creating BuildOrderLine objects for {builds.count()} existing builds")
for build in builds:
# Create a BuildOrderLine for each BuildItem
bom_items = get_bom_items_for_part(build.part, Part, BomItem)
for item in bom_items:
build_lines.append(
BuildLine(
build=build,
bom_item=item,
quantity=item.quantity * build.quantity,
)
)
if len(build_lines) > 0:
# Construct the new BuildLine objects
BuildLine.objects.bulk_create(build_lines)
print(f"Created {len(build_lines)} BuildOrderLine objects for existing builds")
def remove_build_lines(apps, schema_editor):
"""Remove BuildOrderLine objects from the database"""
# Get database models
BuildLine = apps.get_model("build", "BuildLine")
n = BuildLine.objects.all().count()
BuildLine.objects.all().delete()
if n > 0:
print(f"Removed {n} BuildOrderLine objects")
class Migration(migrations.Migration):
dependencies = [
('build', '0043_buildline'),
]
operations = [
migrations.RunPython(
add_lines_to_builds,
reverse_code=remove_build_lines,
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.19 on 2023-06-06 10:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('build', '0044_auto_20230528_1410'),
]
operations = [
migrations.AddField(
model_name='builditem',
name='build_line',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='allocations', to='build.buildline'),
),
]

View File

@ -0,0 +1,95 @@
# Generated by Django 3.2.19 on 2023-06-06 10:33
import logging
from django.db import migrations
logger = logging.getLogger('inventree')
def add_build_line_links(apps, schema_editor):
"""Data migration to add links between BuildLine and BuildItem objects.
Associated model types:
Build: A "Build Order"
BomItem: An individual line in the BOM for Build.part
BuildItem: An individual stock allocation against the Build Order
BuildLine: (new model) an individual line in the Build Order
Goals:
- Find all BuildItem objects which are associated with a Build
- Link them against the relevant BuildLine object
- The BuildLine objects should have been created in 0044_auto_20230528_1410.py
"""
BuildItem = apps.get_model("build", "BuildItem")
BuildLine = apps.get_model("build", "BuildLine")
# Find any existing BuildItem objects
build_items = BuildItem.objects.all()
n_missing = 0
for item in build_items:
# Find the relevant BuildLine object
line = BuildLine.objects.filter(
build=item.build,
bom_item=item.bom_item
).first()
if line is None:
logger.warning(f"BuildLine does not exist for BuildItem {item.pk}")
n_missing += 1
if item.build is None or item.bom_item is None:
continue
# Create one!
line = BuildLine.objects.create(
build=item.build,
bom_item=item.bom_item,
quantity=item.bom_item.quantity * item.build.quantity
)
# Link the BuildItem to the BuildLine
# In the next data migration, we remove the 'build' and 'bom_item' fields from BuildItem
item.build_line = line
item.save()
if build_items.count() > 0:
logger.info(f"add_build_line_links: Updated {build_items.count()} BuildItem objects (added {n_missing})")
def reverse_build_links(apps, schema_editor):
"""Reverse data migration from add_build_line_links
Basically, iterate through each BuildItem and update the links based on the BuildLine
"""
BuildItem = apps.get_model("build", "BuildItem")
items = BuildItem.objects.all()
for item in items:
item.build = item.build_line.build
item.bom_item = item.build_line.bom_item
item.save()
if items.count() > 0:
logger.info(f"reverse_build_links: Updated {items.count()} BuildItem objects")
class Migration(migrations.Migration):
dependencies = [
('build', '0045_builditem_build_line'),
]
operations = [
migrations.RunPython(
add_build_line_links,
reverse_code=reverse_build_links,
)
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.2.19 on 2023-06-06 10:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0101_stockitemtestresult_metadata'),
('build', '0046_auto_20230606_1033'),
]
operations = [
migrations.AlterUniqueTogether(
name='builditem',
unique_together={('build_line', 'stock_item', 'install_into')},
),
migrations.RemoveField(
model_name='builditem',
name='bom_item',
),
migrations.RemoveField(
model_name='builditem',
name='build',
),
]

View File

@ -1,7 +1,7 @@
"""Build database model definitions.""" """Build database model definitions."""
import decimal import decimal
import logging
import os import os
from datetime import datetime from datetime import datetime
@ -40,6 +40,9 @@ import stock.models
import users.models import users.models
logger = logging.getLogger('inventree')
class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin): class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects. """A Build object organises the creation of new StockItem objects from other existing StockItem objects.
@ -334,33 +337,24 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
return self.status in BuildStatusGroups.ACTIVE_CODES return self.status in BuildStatusGroups.ACTIVE_CODES
@property @property
def bom_items(self): def tracked_line_items(self):
"""Returns the BOM items for the part referenced by this BuildOrder.""" """Returns the "trackable" BOM lines for this BuildOrder."""
return self.part.get_bom_items()
@property return self.build_lines.filter(bom_item__sub_part__trackable=True)
def tracked_bom_items(self):
"""Returns the "trackable" BOM items for this BuildOrder."""
items = self.bom_items
items = items.filter(sub_part__trackable=True)
return items def has_tracked_line_items(self):
def has_tracked_bom_items(self):
"""Returns True if this BuildOrder has trackable BomItems.""" """Returns True if this BuildOrder has trackable BomItems."""
return self.tracked_bom_items.count() > 0 return self.tracked_line_items.count() > 0
@property @property
def untracked_bom_items(self): def untracked_line_items(self):
"""Returns the "non trackable" BOM items for this BuildOrder.""" """Returns the "non trackable" BOM items for this BuildOrder."""
items = self.bom_items
items = items.filter(sub_part__trackable=False)
return items return self.build_lines.filter(bom_item__sub_part__trackable=False)
def has_untracked_bom_items(self): def has_untracked_line_items(self):
"""Returns True if this BuildOrder has non trackable BomItems.""" """Returns True if this BuildOrder has non trackable BomItems."""
return self.untracked_bom_items.count() > 0 return self.has_untracked_line_items.count() > 0
@property @property
def remaining(self): def remaining(self):
@ -422,6 +416,11 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
return quantity return quantity
def is_partially_allocated(self):
"""Test is this build order has any stock allocated against it"""
return self.allocated_stock.count() > 0
@property @property
def incomplete_outputs(self): def incomplete_outputs(self):
"""Return all the "incomplete" build outputs.""" """Return all the "incomplete" build outputs."""
@ -478,21 +477,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@property @property
def can_complete(self): def can_complete(self):
"""Returns True if this build can be "completed". """Returns True if this BuildOrder is ready to be completed
- Must not have any outstanding build outputs - Must not have any outstanding build outputs
- 'completed' value must meet (or exceed) the 'quantity' value - Completed count must meet the required quantity
- Untracked parts must be allocated
""" """
if self.incomplete_count > 0: if self.incomplete_count > 0:
return False return False
if self.remaining > 0: if self.remaining > 0:
return False return False
if not self.are_untracked_parts_allocated(): if not self.is_fully_allocated(tracked=False):
return False return False
# No issues!
return True return True
@transaction.atomic @transaction.atomic
@ -511,7 +511,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# Ensure that there are no longer any BuildItem objects # Ensure that there are no longer any BuildItem objects
# which point to this Build Order # which point to this Build Order
self.allocated_stock.all().delete() self.allocated_stock.delete()
# Register an event # Register an event
trigger_event('build.completed', id=self.pk) trigger_event('build.completed', id=self.pk)
@ -566,13 +566,14 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
remove_allocated_stock = kwargs.get('remove_allocated_stock', False) remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False) remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
# Handle stock allocations # Find all BuildItem objects associated with this Build
for build_item in self.allocated_stock.all(): items = self.allocated_stock
if remove_allocated_stock: if remove_allocated_stock:
build_item.complete_allocation(user) for item in items:
item.complete_allocation(user)
build_item.delete() items.delete()
# Remove incomplete outputs (if required) # Remove incomplete outputs (if required)
if remove_incomplete_outputs: if remove_incomplete_outputs:
@ -591,20 +592,19 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
trigger_event('build.cancelled', id=self.pk) trigger_event('build.cancelled', id=self.pk)
@transaction.atomic @transaction.atomic
def unallocateStock(self, bom_item=None, output=None): def deallocate_stock(self, build_line=None, output=None):
"""Unallocate stock from this Build. """Deallocate stock from this Build.
Args: Args:
bom_item: Specify a particular BomItem to unallocate stock against build_line: Specify a particular BuildLine instance to un-allocate stock against
output: Specify a particular StockItem (output) to unallocate stock against output: Specify a particular StockItem (output) to un-allocate stock against
""" """
allocations = BuildItem.objects.filter( allocations = self.allocated_stock.filter(
build=self,
install_into=output install_into=output
) )
if bom_item: if build_line:
allocations = allocations.filter(bom_item=bom_item) allocations = allocations.filter(build_line=build_line)
allocations.delete() allocations.delete()
@ -737,7 +737,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
"""Remove a build output from the database. """Remove a build output from the database.
Executes: Executes:
- Unallocate any build items against the output - Deallocate any build items against the output
- Delete the output StockItem - Delete the output StockItem
""" """
if not output: if not output:
@ -749,8 +749,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
if output.build != self: if output.build != self:
raise ValidationError(_("Build output does not match Build Order")) raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output # Deallocate all build items against the output
self.unallocateStock(output=output) self.deallocate_stock(output=output)
# Remove the build output from the database # Remove the build output from the database
output.delete() output.delete()
@ -758,36 +758,47 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
@transaction.atomic @transaction.atomic
def trim_allocated_stock(self): def trim_allocated_stock(self):
"""Called after save to reduce allocated stock if the build order is now overallocated.""" """Called after save to reduce allocated stock if the build order is now overallocated."""
allocations = BuildItem.objects.filter(build=self)
# Only need to worry about untracked stock here # Only need to worry about untracked stock here
for bom_item in self.untracked_bom_items: for build_line in self.untracked_line_items:
reduce_by = self.allocated_quantity(bom_item) - self.required_quantity(bom_item)
if reduce_by <= 0: reduce_by = build_line.allocated_quantity() - build_line.quantity
continue # all OK
if reduce_by <= 0:
continue
# Find BuildItem objects to trim
for item in BuildItem.objects.filter(build_line=build_line):
# find builditem(s) to trim
for a in allocations.filter(bom_item=bom_item):
# Previous item completed the job # Previous item completed the job
if reduce_by == 0: if reduce_by <= 0:
break break
# Easy case - this item can just be reduced. # Easy case - this item can just be reduced.
if a.quantity > reduce_by: if item.quantity > reduce_by:
a.quantity -= reduce_by item.quantity -= reduce_by
a.save() item.save()
break break
# Harder case, this item needs to be deleted, and any remainder # Harder case, this item needs to be deleted, and any remainder
# taken from the next items in the list. # taken from the next items in the list.
reduce_by -= a.quantity reduce_by -= item.quantity
a.delete() item.delete()
@property
def allocated_stock(self):
"""Returns a QuerySet object of all BuildItem objects which point back to this Build"""
return BuildItem.objects.filter(
build_line__build=self
)
@transaction.atomic @transaction.atomic
def subtract_allocated_stock(self, user): def subtract_allocated_stock(self, user):
"""Called when the Build is marked as "complete", this function removes the allocated untracked items from stock.""" """Called when the Build is marked as "complete", this function removes the allocated untracked items from stock."""
# Find all BuildItem objects which point to this build
items = self.allocated_stock.filter( items = self.allocated_stock.filter(
stock_item__part__trackable=False build_line__bom_item__sub_part__trackable=False
) )
# Remove stock # Remove stock
@ -934,8 +945,13 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
else: else:
return 3 return 3
# Get a list of all 'untracked' BOM items new_items = []
for bom_item in self.untracked_bom_items:
# Auto-allocation is only possible for "untracked" line items
for line_item in self.untracked_line_items.all():
# Find the referenced BomItem
bom_item = line_item.bom_item
if bom_item.consumable: if bom_item.consumable:
# Do not auto-allocate stock to consumable BOM items # Do not auto-allocate stock to consumable BOM items
@ -947,7 +963,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
variant_parts = bom_item.sub_part.get_descendants(include_self=False) variant_parts = bom_item.sub_part.get_descendants(include_self=False)
unallocated_quantity = self.unallocated_quantity(bom_item) unallocated_quantity = line_item.unallocated_quantity()
if unallocated_quantity <= 0: if unallocated_quantity <= 0:
# This BomItem is fully allocated, we can continue # This BomItem is fully allocated, we can continue
@ -998,18 +1014,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# or all items are "interchangeable" and we don't care where we take stock from # or all items are "interchangeable" and we don't care where we take stock from
for stock_item in available_stock: for stock_item in available_stock:
# Skip inactive parts
if not stock_item.part.active:
continue
# How much of the stock item is "available" for allocation? # How much of the stock item is "available" for allocation?
quantity = min(unallocated_quantity, stock_item.unallocated_quantity()) quantity = min(unallocated_quantity, stock_item.unallocated_quantity())
if quantity > 0: if quantity > 0:
try: try:
BuildItem.objects.create( new_items.append(BuildItem(
build=self, build_line=line_item,
bom_item=bom_item,
stock_item=stock_item, stock_item=stock_item,
quantity=quantity, quantity=quantity,
) ))
# Subtract the required quantity # Subtract the required quantity
unallocated_quantity -= quantity unallocated_quantity -= quantity
@ -1022,163 +1042,83 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
# We have now fully-allocated this BomItem - no need to continue! # We have now fully-allocated this BomItem - no need to continue!
break break
def required_quantity(self, bom_item, output=None): # Bulk-create the new BuildItem objects
"""Get the quantity of a part required to complete the particular build output. BuildItem.objects.bulk_create(new_items)
def unallocated_lines(self, tracked=None):
"""Returns a list of BuildLine objects which have not been fully allocated."""
lines = self.build_lines.all()
if tracked is True:
lines = lines.filter(bom_item__sub_part__trackable=True)
elif tracked is False:
lines = lines.filter(bom_item__sub_part__trackable=False)
unallocated_lines = []
for line in lines:
if not line.is_fully_allocated():
unallocated_lines.append(line)
return unallocated_lines
def is_fully_allocated(self, tracked=None):
"""Test if the BuildOrder has been fully allocated.
This is *true* if *all* associated BuildLine items have sufficient allocation
Arguments:
tracked: If True, only consider tracked BuildLine items. If False, only consider untracked BuildLine items.
Returns:
True if the BuildOrder has been fully allocated, otherwise False
"""
lines = self.unallocated_lines(tracked=tracked)
return len(lines) == 0
def is_output_fully_allocated(self, output):
"""Determine if the specified output (StockItem) has been fully allocated for this build
Args: Args:
bom_item: The Part object output: StockItem object
output: The particular build output (StockItem)
To determine if the output has been fully allocated,
we need to test all "trackable" BuildLine objects
""" """
quantity = bom_item.quantity
if output: for line in self.build_lines.filter(bom_item__sub_part__trackable=True):
quantity *= output.quantity # Grab all BuildItem objects which point to this output
else:
quantity *= self.quantity
return quantity
def allocated_bom_items(self, bom_item, output=None):
"""Return all BuildItem objects which allocate stock of <bom_item> to <output>.
Note that the bom_item may allow variants, or direct substitutes,
making things difficult.
Args:
bom_item: The BomItem object
output: Build output (StockItem).
"""
allocations = BuildItem.objects.filter( allocations = BuildItem.objects.filter(
build=self, build_line=line,
bom_item=bom_item,
install_into=output, install_into=output,
) )
return allocations
def allocated_quantity(self, bom_item, output=None):
"""Return the total quantity of given part allocated to a given build output."""
allocations = self.allocated_bom_items(bom_item, output)
allocated = allocations.aggregate( allocated = allocations.aggregate(
q=Coalesce( q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
Sum('quantity'),
0,
output_field=models.DecimalField(),
)
) )
return allocated['q'] # The amount allocated against an output must at least equal the BOM quantity
if allocated['q'] < line.bom_item.quantity:
def unallocated_quantity(self, bom_item, output=None):
"""Return the total unallocated (remaining) quantity of a part against a particular output."""
required = self.required_quantity(bom_item, output)
allocated = self.allocated_quantity(bom_item, output)
return max(required - allocated, 0)
def is_bom_item_allocated(self, bom_item, output=None):
"""Test if the supplied BomItem has been fully allocated"""
if bom_item.consumable:
# Consumable BOM items do not need to be allocated
return True
return self.unallocated_quantity(bom_item, output) == 0
def is_fully_allocated(self, output):
"""Returns True if the particular build output is fully allocated."""
# If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
if not self.is_bom_item_allocated(bom_item, output):
return False return False
# All parts must be fully allocated! # At this stage, we can assume that the output is fully allocated
return True return True
def is_partially_allocated(self, output): def is_overallocated(self):
"""Returns True if the particular build output is (at least) partially allocated.""" """Test if the BuildOrder has been over-allocated.
# If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items: Returns:
True if any BuildLine has been over-allocated.
if self.allocated_quantity(bom_item, output) > 0:
return True
return False
def are_untracked_parts_allocated(self):
"""Returns True if the un-tracked parts are fully allocated for this BuildOrder."""
return self.is_fully_allocated(None)
def has_overallocated_parts(self, output=None):
"""Check if parts have been 'over-allocated' against the specified output.
Note: If output=None, test un-tracked parts
""" """
bom_items = self.tracked_bom_items if output else self.untracked_bom_items for line in self.build_lines.all():
if line.is_overallocated():
for bom_item in bom_items:
if self.allocated_quantity(bom_item, output) > self.required_quantity(bom_item, output):
return True return True
return False return False
def unallocated_bom_items(self, output):
"""Return a list of bom items which have *not* been fully allocated against a particular output."""
unallocated = []
# If output is not specified, we are talking about "untracked" items
if output is None:
bom_items = self.untracked_bom_items
else:
bom_items = self.tracked_bom_items
for bom_item in bom_items:
if not self.is_bom_item_allocated(bom_item, output):
unallocated.append(bom_item)
return unallocated
@property
def required_parts(self):
"""Returns a list of parts required to build this part (BOM)."""
parts = []
for item in self.bom_items:
parts.append(item.sub_part)
return parts
@property
def required_parts_to_complete_build(self):
"""Returns a list of parts required to complete the full build.
TODO: 2022-01-06 : This method needs to be improved, it is very inefficient in terms of DB hits!
"""
parts = []
for bom_item in self.bom_items:
# Get remaining quantity needed
required_quantity_to_complete_build = self.remaining * bom_item.quantity - self.allocated_quantity(bom_item)
# Compare to net stock
if bom_item.sub_part.net_stock < required_quantity_to_complete_build:
parts.append(bom_item.sub_part)
return parts
@property @property
def is_active(self): def is_active(self):
"""Is this build active? """Is this build active?
@ -1194,6 +1134,52 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.
"""Returns True if the build status is COMPLETE.""" """Returns True if the build status is COMPLETE."""
return self.status == BuildStatus.COMPLETE return self.status == BuildStatus.COMPLETE
@transaction.atomic
def create_build_line_items(self, prevent_duplicates=True):
"""Create BuildLine objects for each BOM line in this BuildOrder."""
lines = []
bom_items = self.part.get_bom_items()
logger.info(f"Creating BuildLine objects for BuildOrder {self.pk} ({len(bom_items)} items))")
# Iterate through each part required to build the parent part
for bom_item in bom_items:
if prevent_duplicates:
if BuildLine.objects.filter(build=self, bom_item=bom_item).exists():
logger.info(f"BuildLine already exists for BuildOrder {self.pk} and BomItem {bom_item.pk}")
continue
# Calculate required quantity
quantity = bom_item.get_required_quantity(self.quantity)
lines.append(
BuildLine(
build=self,
bom_item=bom_item,
quantity=quantity
)
)
BuildLine.objects.bulk_create(lines)
logger.info(f"Created {len(lines)} BuildLine objects for BuildOrder")
@transaction.atomic
def update_build_line_items(self):
"""Rebuild required quantity field for each BuildLine object"""
lines_to_update = []
for line in self.build_lines.all():
line.quantity = line.bom_item.get_required_quantity(self.quantity)
lines_to_update.append(line)
BuildLine.objects.bulk_update(lines_to_update, ['quantity'])
logger.info(f"Updated {len(lines_to_update)} BuildLine objects for BuildOrder")
@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log') @receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
def after_save_build(sender, instance: Build, created: bool, **kwargs): def after_save_build(sender, instance: Build, created: bool, **kwargs):
@ -1204,15 +1190,24 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
from . import tasks as build_tasks from . import tasks as build_tasks
if instance:
if created: if created:
# A new Build has just been created # A new Build has just been created
# Generate initial BuildLine objects for the Build
instance.create_build_line_items()
# Run checks on required parts # Run checks on required parts
InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance) InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance)
# Notify the responsible users that the build order has been created # Notify the responsible users that the build order has been created
InvenTree.helpers_model.notify_responsible(instance, sender, exclude=instance.issued_by) InvenTree.helpers_model.notify_responsible(instance, sender, exclude=instance.issued_by)
else:
# Update BuildLine objects if the Build quantity has changed
instance.update_build_line_items()
class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment): class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
"""Model for storing file attachments against a BuildOrder object.""" """Model for storing file attachments against a BuildOrder object."""
@ -1224,6 +1219,87 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments') build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildLine(models.Model):
"""A BuildLine object links a BOMItem to a Build.
When a new Build is created, the BuildLine objects are created automatically.
- A BuildLine entry is created for each BOM item associated with the part
- The quantity is set to the quantity required to build the part (including overage)
- BuildItem objects are associated with a particular BuildLine
Once a build has been created, BuildLines can (optionally) be removed from the Build
Attributes:
build: Link to a Build object
bom_item: Link to a BomItem object
quantity: Number of units required for the Build
"""
class Meta:
"""Model meta options"""
unique_together = [
('build', 'bom_item'),
]
@staticmethod
def get_api_url():
"""Return the API URL used to access this model"""
return reverse('api-build-line-list')
build = models.ForeignKey(
Build, on_delete=models.CASCADE,
related_name='build_lines', help_text=_('Build object')
)
bom_item = models.ForeignKey(
part.models.BomItem,
on_delete=models.CASCADE,
related_name='build_lines',
)
quantity = models.DecimalField(
decimal_places=5,
max_digits=15,
default=1,
validators=[MinValueValidator(0)],
verbose_name=_('Quantity'),
help_text=_('Required quantity for build order'),
)
@property
def part(self):
"""Return the sub_part reference from the link bom_item"""
return self.bom_item.sub_part
def allocated_quantity(self):
"""Calculate the total allocated quantity for this BuildLine"""
# Queryset containing all BuildItem objects allocated against this BuildLine
allocations = self.allocations.all()
allocated = allocations.aggregate(
q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
)
return allocated['q']
def unallocated_quantity(self):
"""Return the unallocated quantity for this BuildLine"""
return max(self.quantity - self.allocated_quantity(), 0)
def is_fully_allocated(self):
"""Return True if this BuildLine is fully allocated"""
if self.bom_item.consumable:
return True
return self.allocated_quantity() >= self.quantity
def is_overallocated(self):
"""Return True if this BuildLine is over-allocated"""
return self.allocated_quantity() > self.quantity
class BuildItem(InvenTree.models.MetadataMixin, models.Model): class BuildItem(InvenTree.models.MetadataMixin, models.Model):
"""A BuildItem links multiple StockItem objects to a Build. """A BuildItem links multiple StockItem objects to a Build.
@ -1231,16 +1307,16 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
Attributes: Attributes:
build: Link to a Build object build: Link to a Build object
bom_item: Link to a BomItem object (may or may not point to the same part as the build) build_line: Link to a BuildLine object (this is a "line item" within a build)
stock_item: Link to a StockItem object stock_item: Link to a StockItem object
quantity: Number of units allocated quantity: Number of units allocated
install_into: Destination stock item (or None) install_into: Destination stock item (or None)
""" """
class Meta: class Meta:
"""Serializer metaclass""" """Model meta options"""
unique_together = [ unique_together = [
('build', 'stock_item', 'install_into'), ('build_line', 'stock_item', 'install_into'),
] ]
@staticmethod @staticmethod
@ -1303,8 +1379,10 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
'quantity': _('Quantity must be 1 for serialized stock') 'quantity': _('Quantity must be 1 for serialized stock')
}) })
except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist): except stock.models.StockItem.DoesNotExist:
pass raise ValidationError("Stock item must be specified")
except part.models.Part.DoesNotExist:
raise ValidationError("Part must be specified")
""" """
Attempt to find the "BomItem" which links this BuildItem to the build. Attempt to find the "BomItem" which links this BuildItem to the build.
@ -1312,7 +1390,7 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
- If a BomItem is already set, and it is valid, then we are ok! - If a BomItem is already set, and it is valid, then we are ok!
""" """
bom_item_valid = False valid = False
if self.bom_item and self.build: if self.bom_item and self.build:
""" """
@ -1327,39 +1405,51 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
""" """
if self.build.part == self.bom_item.part: if self.build.part == self.bom_item.part:
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item) valid = self.bom_item.is_stock_item_valid(self.stock_item)
elif self.bom_item.inherited: elif self.bom_item.inherited:
if self.build.part in self.bom_item.part.get_descendants(include_self=False): if self.build.part in self.bom_item.part.get_descendants(include_self=False):
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item) valid = self.bom_item.is_stock_item_valid(self.stock_item)
# If the existing BomItem is *not* valid, try to find a match # If the existing BomItem is *not* valid, try to find a match
if not bom_item_valid: if not valid:
if self.build and self.stock_item: if self.build and self.stock_item:
ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True) ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True)
for idx, ancestor in enumerate(ancestors): for idx, ancestor in enumerate(ancestors):
try: build_line = BuildLine.objects.filter(
bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor) build=self.build,
except part.models.BomItem.DoesNotExist: bom_item__part=ancestor,
continue )
# A matching BOM item has been found! if build_line.exists():
if idx == 0 or bom_item.allow_variants: line = build_line.first()
bom_item_valid = True
self.bom_item = bom_item if idx == 0 or line.bom_item.allow_variants:
valid = True
self.build_line = line
break break
# BomItem did not exist or could not be validated. # BomItem did not exist or could not be validated.
# Search for a new one # Search for a new one
if not bom_item_valid: if not valid:
raise ValidationError({ raise ValidationError({
'stock_item': _("Selected stock item not found in BOM") 'stock_item': _("Selected stock item does not match BOM line")
}) })
@property
def build(self):
"""Return the BuildOrder associated with this BuildItem"""
return self.build_line.build if self.build_line else None
@property
def bom_item(self):
"""Return the BomItem associated with this BuildItem"""
return self.build_line.bom_item if self.build_line else None
@transaction.atomic @transaction.atomic
def complete_allocation(self, user, notes=''): def complete_allocation(self, user, notes=''):
"""Complete the allocation of this BuildItem into the output stock item. """Complete the allocation of this BuildItem into the output stock item.
@ -1431,21 +1521,10 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model):
else: else:
return InvenTree.helpers.getBlankThumbnail() return InvenTree.helpers.getBlankThumbnail()
build = models.ForeignKey( build_line = models.ForeignKey(
Build, BuildLine,
on_delete=models.CASCADE, on_delete=models.SET_NULL, null=True,
related_name='allocated_stock', related_name='allocations',
verbose_name=_('Build'),
help_text=_('Build to allocate parts')
)
# Internal model which links part <-> sub_part
# We need to track this separately, to allow for "variant' stock
bom_item = models.ForeignKey(
part.models.BomItem,
on_delete=models.CASCADE,
related_name='allocate_build_items',
blank=True, null=True,
) )
stock_item = models.ForeignKey( stock_item = models.ForeignKey(

View File

@ -4,8 +4,11 @@ from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.db.models import Case, When, Value from django.db import models
from django.db.models import ExpressionWrapper, F, FloatField
from django.db.models import Case, Sum, When, Value
from django.db.models import BooleanField from django.db.models import BooleanField
from django.db.models.functions import Coalesce
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@ -20,11 +23,11 @@ from InvenTree.status_codes import StockStatus
from stock.models import generate_batch_code, StockItem, StockLocation from stock.models import generate_batch_code, StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationSerializer from stock.serializers import StockItemSerializerBrief, LocationSerializer
from part.models import BomItem import part.filters
from part.serializers import PartSerializer, PartBriefSerializer from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer
from users.serializers import OwnerSerializer from users.serializers import OwnerSerializer
from .models import Build, BuildItem, BuildOrderAttachment from .models import Build, BuildLine, BuildItem, BuildOrderAttachment
class BuildSerializer(InvenTreeModelSerializer): class BuildSerializer(InvenTreeModelSerializer):
@ -170,7 +173,7 @@ class BuildOutputSerializer(serializers.Serializer):
if to_complete: if to_complete:
# The build output must have all tracked parts allocated # The build output must have all tracked parts allocated
if not build.is_fully_allocated(output): if not build.is_output_fully_allocated(output):
# Check if the user has specified that incomplete allocations are ok # Check if the user has specified that incomplete allocations are ok
accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False)) accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False))
@ -562,7 +565,7 @@ class BuildCancelSerializer(serializers.Serializer):
build = self.context['build'] build = self.context['build']
return { return {
'has_allocated_stock': build.is_partially_allocated(None), 'has_allocated_stock': build.is_partially_allocated(),
'incomplete_outputs': build.incomplete_count, 'incomplete_outputs': build.incomplete_count,
'completed_outputs': build.complete_count, 'completed_outputs': build.complete_count,
} }
@ -621,8 +624,8 @@ class BuildCompleteSerializer(serializers.Serializer):
build = self.context['build'] build = self.context['build']
return { return {
'overallocated': build.has_overallocated_parts(), 'overallocated': build.is_overallocated(),
'allocated': build.are_untracked_parts_allocated(), 'allocated': build.is_fully_allocated(),
'remaining': build.remaining, 'remaining': build.remaining,
'incomplete': build.incomplete_count, 'incomplete': build.incomplete_count,
} }
@ -639,7 +642,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_overallocated' field is required""" """Check if the 'accept_overallocated' field is required"""
build = self.context['build'] build = self.context['build']
if build.has_overallocated_parts(output=None) and value == OverallocationChoice.REJECT: if build.is_overallocated() and value == OverallocationChoice.REJECT:
raise ValidationError(_('Some stock items have been overallocated')) raise ValidationError(_('Some stock items have been overallocated'))
return value return value
@ -655,7 +658,7 @@ class BuildCompleteSerializer(serializers.Serializer):
"""Check if the 'accept_unallocated' field is required""" """Check if the 'accept_unallocated' field is required"""
build = self.context['build'] build = self.context['build']
if not build.are_untracked_parts_allocated() and not value: if not build.is_fully_allocated() and not value:
raise ValidationError(_('Required stock has not been fully allocated')) raise ValidationError(_('Required stock has not been fully allocated'))
return value return value
@ -706,12 +709,12 @@ class BuildUnallocationSerializer(serializers.Serializer):
- bom_item: Filter against a particular BOM line item - bom_item: Filter against a particular BOM line item
""" """
bom_item = serializers.PrimaryKeyRelatedField( build_line = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(), queryset=BuildLine.objects.all(),
many=False, many=False,
allow_null=True, allow_null=True,
required=False, required=False,
label=_('BOM Item'), label=_('Build Line'),
) )
output = serializers.PrimaryKeyRelatedField( output = serializers.PrimaryKeyRelatedField(
@ -742,8 +745,8 @@ class BuildUnallocationSerializer(serializers.Serializer):
data = self.validated_data data = self.validated_data
build.unallocateStock( build.deallocate_stock(
bom_item=data['bom_item'], build_line=data['build_line'],
output=data['output'] output=data['output']
) )
@ -754,34 +757,34 @@ class BuildAllocationItemSerializer(serializers.Serializer):
class Meta: class Meta:
"""Serializer metaclass""" """Serializer metaclass"""
fields = [ fields = [
'bom_item', 'build_item',
'stock_item', 'stock_item',
'quantity', 'quantity',
'output', 'output',
] ]
bom_item = serializers.PrimaryKeyRelatedField( build_line = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(), queryset=BuildLine.objects.all(),
many=False, many=False,
allow_null=False, allow_null=False,
required=True, required=True,
label=_('BOM Item'), label=_('Build Line Item'),
) )
def validate_bom_item(self, bom_item): def validate_build_line(self, build_line):
"""Check if the parts match""" """Check if the parts match"""
build = self.context['build'] build = self.context['build']
# BomItem should point to the same 'part' as the parent build # BomItem should point to the same 'part' as the parent build
if build.part != bom_item.part: if build.part != build_line.bom_item.part:
# If not, it may be marked as "inherited" from a parent part # If not, it may be marked as "inherited" from a parent part
if bom_item.inherited and build.part in bom_item.part.get_descendants(include_self=False): if build_line.bom_item.inherited and build.part in build_line.bom_item.part.get_descendants(include_self=False):
pass pass
else: else:
raise ValidationError(_("bom_item.part must point to the same part as the build order")) raise ValidationError(_("bom_item.part must point to the same part as the build order"))
return bom_item return build_line
stock_item = serializers.PrimaryKeyRelatedField( stock_item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(), queryset=StockItem.objects.all(),
@ -824,8 +827,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
"""Perform data validation for this item""" """Perform data validation for this item"""
super().validate(data) super().validate(data)
build = self.context['build'] build_line = data['build_line']
bom_item = data['bom_item']
stock_item = data['stock_item'] stock_item = data['stock_item']
quantity = data['quantity'] quantity = data['quantity']
output = data.get('output', None) output = data.get('output', None)
@ -847,20 +849,20 @@ class BuildAllocationItemSerializer(serializers.Serializer):
}) })
# Output *must* be set for trackable parts # Output *must* be set for trackable parts
if output is None and bom_item.sub_part.trackable: if output is None and build_line.bom_item.sub_part.trackable:
raise ValidationError({ raise ValidationError({
'output': _('Build output must be specified for allocation of tracked parts'), 'output': _('Build output must be specified for allocation of tracked parts'),
}) })
# Output *cannot* be set for un-tracked parts # Output *cannot* be set for un-tracked parts
if output is not None and not bom_item.sub_part.trackable: if output is not None and not build_line.bom_item.sub_part.trackable:
raise ValidationError({ raise ValidationError({
'output': _('Build output cannot be specified for allocation of untracked parts'), 'output': _('Build output cannot be specified for allocation of untracked parts'),
}) })
# Check if this allocation would be unique # Check if this allocation would be unique
if BuildItem.objects.filter(build=build, stock_item=stock_item, install_into=output).exists(): if BuildItem.objects.filter(build_line=build_line, stock_item=stock_item, install_into=output).exists():
raise ValidationError(_('This stock item has already been allocated to this build output')) raise ValidationError(_('This stock item has already been allocated to this build output'))
return data return data
@ -894,24 +896,21 @@ class BuildAllocationSerializer(serializers.Serializer):
items = data.get('items', []) items = data.get('items', [])
build = self.context['build']
with transaction.atomic(): with transaction.atomic():
for item in items: for item in items:
bom_item = item['bom_item'] build_line = item['build_line']
stock_item = item['stock_item'] stock_item = item['stock_item']
quantity = item['quantity'] quantity = item['quantity']
output = item.get('output', None) output = item.get('output', None)
# Ignore allocation for consumable BOM items # Ignore allocation for consumable BOM items
if bom_item.consumable: if build_line.bom_item.consumable:
continue continue
try: try:
# Create a new BuildItem to allocate stock # Create a new BuildItem to allocate stock
BuildItem.objects.create( BuildItem.objects.create(
build=build, build_line=build_line,
bom_item=bom_item,
stock_item=stock_item, stock_item=stock_item,
quantity=quantity, quantity=quantity,
install_into=output install_into=output
@ -993,43 +992,37 @@ class BuildItemSerializer(InvenTreeModelSerializer):
model = BuildItem model = BuildItem
fields = [ fields = [
'pk', 'pk',
'bom_part',
'build', 'build',
'build_detail', 'build_line',
'install_into', 'install_into',
'location',
'location_detail',
'part',
'part_detail',
'stock_item', 'stock_item',
'quantity',
'location_detail',
'part_detail',
'stock_item_detail', 'stock_item_detail',
'quantity' 'build_detail',
] ]
bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True) # Annotated fields
part = serializers.IntegerField(source='stock_item.part.pk', read_only=True) build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True)
location = serializers.IntegerField(source='stock_item.location.pk', read_only=True)
# Extra (optional) detail fields # Extra (optional) detail fields
part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True) part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True)
build_detail = BuildSerializer(source='build', many=False, read_only=True)
stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True) stock_item_detail = StockItemSerializerBrief(source='stock_item', read_only=True)
location_detail = LocationSerializer(source='stock_item.location', read_only=True) location_detail = LocationSerializer(source='stock_item.location', read_only=True)
build_detail = BuildSerializer(source='build_line.build', many=False, read_only=True)
quantity = InvenTreeDecimalField() quantity = InvenTreeDecimalField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Determine which extra details fields should be included""" """Determine which extra details fields should be included"""
build_detail = kwargs.pop('build_detail', False) part_detail = kwargs.pop('part_detail', True)
part_detail = kwargs.pop('part_detail', False) location_detail = kwargs.pop('location_detail', True)
location_detail = kwargs.pop('location_detail', False)
stock_detail = kwargs.pop('stock_detail', False) stock_detail = kwargs.pop('stock_detail', False)
build_detail = kwargs.pop('build_detail', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not build_detail:
self.fields.pop('build_detail')
if not part_detail: if not part_detail:
self.fields.pop('part_detail') self.fields.pop('part_detail')
@ -1039,6 +1032,144 @@ class BuildItemSerializer(InvenTreeModelSerializer):
if not stock_detail: if not stock_detail:
self.fields.pop('stock_item_detail') self.fields.pop('stock_item_detail')
if not build_detail:
self.fields.pop('build_detail')
class BuildLineSerializer(InvenTreeModelSerializer):
"""Serializer for a BuildItem object."""
class Meta:
"""Serializer metaclass"""
model = BuildLine
fields = [
'pk',
'build',
'bom_item',
'bom_item_detail',
'part_detail',
'quantity',
'allocations',
# Annotated fields
'allocated',
'on_order',
'available_stock',
'available_substitute_stock',
'available_variant_stock',
]
read_only_fields = [
'build',
'bom_item',
'allocations',
]
quantity = serializers.FloatField()
# Foreign key fields
bom_item_detail = BomItemSerializer(source='bom_item', many=False, read_only=True)
part_detail = PartSerializer(source='bom_item.sub_part', many=False, read_only=True)
allocations = BuildItemSerializer(many=True, read_only=True)
# Annotated (calculated) fields
allocated = serializers.FloatField(read_only=True)
on_order = serializers.FloatField(read_only=True)
available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
@staticmethod
def annotate_queryset(queryset):
"""Add extra annotations to the queryset:
- allocated: Total stock quantity allocated against this build line
- available: Total stock available for allocation against this build line
- on_order: Total stock on order for this build line
"""
# Pre-fetch related fields
queryset = queryset.prefetch_related(
'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations',
'bom_item__substitutes',
'bom_item__substitutes__part__stock_items',
'bom_item__substitutes__part__stock_items__allocations',
'bom_item__substitutes__part__stock_items__sales_order_allocations',
)
# Annotate the "allocated" quantity
# Difficulty: Easy
queryset = queryset.annotate(
allocated=Coalesce(
Sum('allocations__quantity'), 0,
output_field=models.DecimalField()
),
)
ref = 'bom_item__sub_part__'
# Annotate the "on_order" quantity
# Difficulty: Medium
queryset = queryset.annotate(
on_order=part.filters.annotate_on_order_quantity(reference=ref),
)
# Annotate the "available" quantity
# TODO: In the future, this should be refactored.
# TODO: Note that part.serializers.BomItemSerializer also has a similar annotation
queryset = queryset.alias(
total_stock=part.filters.annotate_total_stock(reference=ref),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref),
)
# Calculate 'available_stock' based on previously annotated fields
queryset = queryset.annotate(
available_stock=ExpressionWrapper(
F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
output_field=models.DecimalField(),
)
)
ref = 'bom_item__substitutes__part__'
# Extract similar information for any 'substitute' parts
queryset = queryset.alias(
substitute_stock=part.filters.annotate_total_stock(reference=ref),
substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref),
substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref)
)
# Calculate 'available_substitute_stock' field
queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
output_field=models.DecimalField(),
)
)
# Annotate the queryset with 'available variant stock' information
variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__')
queryset = queryset.alias(
variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
variant_bo_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='sales_order_allocations__quantity'),
variant_so_allocations=part.filters.annotate_variant_quantity(variant_stock_query, reference='allocations__quantity'),
)
queryset = queryset.annotate(
available_variant_stock=ExpressionWrapper(
F('variant_stock_total') - F('variant_bo_allocations') - F('variant_so_allocations'),
output_field=FloatField(),
)
)
return queryset
class BuildAttachmentSerializer(InvenTreeAttachmentSerializer): class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
"""Serializer for a BuildAttachment.""" """Serializer for a BuildAttachment."""

View File

@ -174,7 +174,7 @@ src="{% static 'img/blank_image.png' %}"
{% else %} {% else %}
<span class='fa fa-times-circle icon-red'></span> <span class='fa fa-times-circle icon-red'></span>
{% endif %} {% endif %}
<td>{% trans "Completed" %}</td> <td>{% trans "Completed Outputs" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td> <td>{% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %}</td>
</tr> </tr>
{% if build.parent %} {% if build.parent %}

View File

@ -64,10 +64,10 @@
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-check-circle'></span></td> <td><span class='fas fa-check-circle'></span></td>
<td>{% trans "Completed" %}</td> <td>{% trans "Completed Outputs" %}</td>
<td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td> <td>{% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %}</td>
</tr> </tr>
{% if build.active and has_untracked_bom_items %} {% if build.active %}
<tr> <tr>
<td><span class='fas fa-list'></span></td> <td><span class='fas fa-list'></span></td>
<td>{% trans "Allocated Parts" %}</td> <td>{% trans "Allocated Parts" %}</td>
@ -179,9 +179,9 @@
<h4>{% trans "Allocate Stock to Build" %}</h4> <h4>{% trans "Allocate Stock to Build" %}</h4>
{% include "spacer.html" %} {% include "spacer.html" %}
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% if roles.build.add and build.active and has_untracked_bom_items %} {% if roles.build.add and build.active %}
<button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Unallocate stock" %}'> <button class='btn btn-danger' type='button' id='btn-unallocate' title='{% trans "Deallocate stock" %}'>
<span class='fas fa-minus-circle'></span> {% trans "Unallocate Stock" %} <span class='fas fa-minus-circle'></span> {% trans "Deallocate Stock" %}
</button> </button>
<button class='btn btn-primary' type='button' id='btn-auto-allocate' title='{% trans "Automatically allocate stock to build" %}'> <button class='btn btn-primary' type='button' id='btn-auto-allocate' title='{% trans "Automatically allocate stock to build" %}'>
<span class='fas fa-magic'></span> {% trans "Auto Allocate" %} <span class='fas fa-magic'></span> {% trans "Auto Allocate" %}
@ -199,9 +199,8 @@
</div> </div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
{% if has_untracked_bom_items %}
{% if build.active %} {% if build.active %}
{% if build.are_untracked_parts_allocated %} {% if build.is_fully_allocated %}
<div class='alert alert-block alert-success'> <div class='alert alert-block alert-success'>
{% trans "Untracked stock has been fully allocated for this Build Order" %} {% trans "Untracked stock has been fully allocated for this Build Order" %}
</div> </div>
@ -211,22 +210,17 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<div id='unallocated-toolbar'> <div id='build-lines-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group'> <div class='btn-group'>
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'> <button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span> <span class='fas fa-sign-in-alt'></span>
</button> </button>
{% include "filter_list.html" with id='builditems' %} {% include "filter_list.html" with id='buildlines' %}
</div> </div>
</div> </div>
</div> </div>
<table class='table table-striped table-condensed' id='allocation-table-untracked' data-toolbar='#unallocated-toolbar'></table> <table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table>
{% else %}
<div class='alert alert-block alert-info'>
{% trans "This Build Order does not have any associated untracked BOM items" %}
</div>
{% endif %}
</div> </div>
</div> </div>
@ -427,38 +421,15 @@ onPanelLoad('outputs', function() {
{% endif %} {% endif %}
}); });
{% if build.active and has_untracked_bom_items %}
function loadUntrackedStockTable() {
var build_info = {
pk: {{ build.pk }},
part: {{ build.part.pk }},
quantity: {{ build.quantity }},
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
tracked_parts: false,
};
$('#allocation-table-untracked').bootstrapTable('destroy');
// Load allocation table for un-tracked parts
loadBuildOutputAllocationTable(
build_info,
null,
{
search: true,
}
);
}
onPanelLoad('allocate', function() { onPanelLoad('allocate', function() {
loadUntrackedStockTable(); // Load the table of line items for this build order
loadBuildLineTable(
"#build-lines-table",
{{ build.pk }},
{}
);
}); });
{% endif %}
$('#btn-create-output').click(function() { $('#btn-create-output').click(function() {
createBuildOutput( createBuildOutput(
@ -480,66 +451,62 @@ $("#btn-auto-allocate").on('click', function() {
{% if build.take_from %} {% if build.take_from %}
location: {{ build.take_from.pk }}, location: {{ build.take_from.pk }},
{% endif %} {% endif %}
onSuccess: loadUntrackedStockTable, onSuccess: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
} }
); );
}); });
$("#btn-allocate").on('click', function() { function allocateSelectedLines() {
var bom_items = $("#allocation-table-untracked").bootstrapTable("getData"); let data = getTableData('#build-lines-table');
var incomplete_bom_items = []; let unallocated_lines = [];
bom_items.forEach(function(bom_item) { data.forEach(function(line) {
if (bom_item.required > bom_item.allocated) { if (line.allocated < line.quantity) {
incomplete_bom_items.push(bom_item); unallocated_lines.push(line);
} }
}); });
if (incomplete_bom_items.length == 0) { if (unallocated_lines.length == 0) {
showAlertDialog( showAlertDialog(
'{% trans "Allocation Complete" %}', '{% trans "Allocation Complete" %}',
'{% trans "All untracked stock items have been allocated" %}', '{% trans "All lines have been fully allocated" %}',
); );
} else { } else {
allocateStockToBuild( allocateStockToBuild(
{{ build.pk }}, {{ build.pk }},
{{ build.part.pk }}, unallocated_lines,
incomplete_bom_items,
{ {
{% if build.take_from %} {% if build.take_from %}
source_location: {{ build.take_from.pk }}, source_location: {{ build.take_from.pk }},
{% endif %} {% endif %}
success: loadUntrackedStockTable, success: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
} }
); );
} }
}); }
$('#btn-unallocate').on('click', function() { $('#btn-unallocate').on('click', function() {
unallocateStock({{ build.id }}, { deallocateStock({{ build.id }}, {
table: '#allocation-table-untracked', table: '#allocation-table-untracked',
onSuccess: loadUntrackedStockTable, onSuccess: function() {
$('#build-lines-table').bootstrapTable('refresh');
},
}); });
}); });
$('#allocate-selected-items').click(function() { $('#allocate-selected-items').click(function() {
allocateSelectedLines();
});
var bom_items = getTableData('#allocation-table-untracked'); $("#btn-allocate").on('click', function() {
allocateSelectedLines();
allocateStockToBuild(
{{ build.pk }},
{{ build.part.pk }},
bom_items,
{
{% if build.take_from %}
source_location: {{ build.take_from.pk }},
{% endif %}
success: loadUntrackedStockTable,
}
);
}); });
{% endif %} {% endif %}

View File

@ -4,18 +4,16 @@
{% trans "Build Order Details" as text %} {% trans "Build Order Details" as text %}
{% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %} {% include "sidebar_item.html" with label='details' text=text icon="fa-info-circle" %}
{% if build.active %} {% if build.is_active %}
{% trans "Allocate Stock" as text %} {% trans "Allocate Stock" as text %}
{% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %} {% include "sidebar_item.html" with label='allocate' text=text icon="fa-tasks" %}
{% endif %}
{% trans "Consumed Stock" as text %}
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
{% if build.is_active %}
{% trans "Incomplete Outputs" as text %} {% trans "Incomplete Outputs" as text %}
{% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %} {% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %}
{% endif %} {% endif %}
{% trans "Completed Outputs" as text %} {% trans "Completed Outputs" as text %}
{% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %} {% include "sidebar_item.html" with label='completed' text=text icon="fa-boxes" %}
{% trans "Consumed Stock" as text %}
{% include "sidebar_item.html" with label='consumed' text=text icon="fa-list" %}
{% trans "Child Build Orders" as text %} {% trans "Child Build Orders" as text %}
{% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %} {% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %}
{% trans "Attachments" as text %} {% trans "Attachments" as text %}

View File

@ -582,6 +582,9 @@ class BuildAllocationTest(BuildAPITest):
self.build = Build.objects.get(pk=1) self.build = Build.objects.get(pk=1)
# Regenerate BuildLine objects
self.build.create_build_line_items()
# Record number of build items which exist at the start of each test # Record number of build items which exist at the start of each test
self.n = BuildItem.objects.count() self.n = BuildItem.objects.count()
@ -593,7 +596,7 @@ class BuildAllocationTest(BuildAPITest):
self.assertEqual(self.build.part.bom_items.count(), 4) self.assertEqual(self.build.part.bom_items.count(), 4)
# No items yet allocated to this build # No items yet allocated to this build
self.assertEqual(self.build.allocated_stock.count(), 0) self.assertEqual(BuildItem.objects.filter(build_line__build=self.build).count(), 0)
def test_get(self): def test_get(self):
"""A GET request to the endpoint should return an error.""" """A GET request to the endpoint should return an error."""
@ -634,7 +637,7 @@ class BuildAllocationTest(BuildAPITest):
{ {
"items": [ "items": [
{ {
"bom_item": 1, # M2x4 LPHS "build_line": 1, # M2x4 LPHS
"stock_item": 2, # 5,000 screws available "stock_item": 2, # 5,000 screws available
} }
] ]
@ -658,7 +661,7 @@ class BuildAllocationTest(BuildAPITest):
expected_code=400 expected_code=400
).data ).data
self.assertIn("This field is required", str(data["items"][0]["bom_item"])) self.assertIn("This field is required", str(data["items"][0]["build_line"]))
# Missing stock_item # Missing stock_item
data = self.post( data = self.post(
@ -666,7 +669,7 @@ class BuildAllocationTest(BuildAPITest):
{ {
"items": [ "items": [
{ {
"bom_item": 1, "build_line": 1,
"quantity": 5000, "quantity": 5000,
} }
] ]
@ -681,12 +684,25 @@ class BuildAllocationTest(BuildAPITest):
def test_invalid_bom_item(self): def test_invalid_bom_item(self):
"""Test by passing an invalid BOM item.""" """Test by passing an invalid BOM item."""
# Find the right (in this case, wrong) BuildLine instance
si = StockItem.objects.get(pk=11)
lines = self.build.build_lines.all()
wrong_line = None
for line in lines:
if line.bom_item.sub_part.pk != si.pk:
wrong_line = line
break
data = self.post( data = self.post(
self.url, self.url,
{ {
"items": [ "items": [
{ {
"bom_item": 5, "build_line": wrong_line.pk,
"stock_item": 11, "stock_item": 11,
"quantity": 500, "quantity": 500,
} }
@ -695,19 +711,31 @@ class BuildAllocationTest(BuildAPITest):
expected_code=400 expected_code=400
).data ).data
self.assertIn('must point to the same part', str(data)) self.assertIn('Selected stock item does not match BOM line', str(data))
def test_valid_data(self): def test_valid_data(self):
"""Test with valid data. """Test with valid data.
This should result in creation of a new BuildItem object This should result in creation of a new BuildItem object
""" """
# Find the correct BuildLine
si = StockItem.objects.get(pk=2)
right_line = None
for line in self.build.build_lines.all():
if line.bom_item.sub_part.pk == si.part.pk:
right_line = line
break
self.post( self.post(
self.url, self.url,
{ {
"items": [ "items": [
{ {
"bom_item": 1, "build_line": right_line.pk,
"stock_item": 2, "stock_item": 2,
"quantity": 5000, "quantity": 5000,
} }
@ -749,16 +777,22 @@ class BuildOverallocationTest(BuildAPITest):
cls.state = {} cls.state = {}
cls.allocation = {} cls.allocation = {}
for i, bi in enumerate(cls.build.part.bom_items.all()): items_to_create = []
rq = cls.build.required_quantity(bi, None) + i + 1
si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first()
cls.state[bi.sub_part] = (si, si.quantity, rq) for idx, build_line in enumerate(cls.build.build_lines.all()):
BuildItem.objects.create( required = build_line.quantity + idx + 1
build=cls.build, sub_part = build_line.bom_item.sub_part
si = StockItem.objects.filter(part=sub_part, quantity__gte=required).first()
cls.state[sub_part] = (si, si.quantity, required)
items_to_create.append(BuildItem(
build_line=build_line,
stock_item=si, stock_item=si,
quantity=rq, quantity=required,
) ))
BuildItem.objects.bulk_create(items_to_create)
# create and complete outputs # create and complete outputs
cls.build.create_build_output(cls.build.quantity) cls.build.create_build_output(cls.build.quantity)
@ -822,9 +856,10 @@ class BuildOverallocationTest(BuildAPITest):
self.assertTrue(self.build.is_complete) self.assertTrue(self.build.is_complete)
# Check stock items have reduced only by bom requirement (overallocation trimmed) # Check stock items have reduced only by bom requirement (overallocation trimmed)
for bi in self.build.part.bom_items.all(): for line in self.build.build_lines.all():
si, oq, _ = self.state[bi.sub_part]
rq = self.build.required_quantity(bi, None) si, oq, _ = self.state[line.bom_item.sub_part]
rq = line.quantity
si.refresh_from_db() si.refresh_from_db()
self.assertEqual(si.quantity, oq - rq) self.assertEqual(si.quantity, oq - rq)

View File

@ -13,7 +13,7 @@ from InvenTree import status_codes as status
import common.models import common.models
import build.tasks import build.tasks
from build.models import Build, BuildItem, generate_next_build_reference from build.models import Build, BuildItem, BuildLine, generate_next_build_reference
from part.models import Part, BomItem, BomItemSubstitute from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem from stock.models import StockItem
from users.models import Owner from users.models import Owner
@ -107,6 +107,11 @@ class BuildTestBase(TestCase):
issued_by=get_user_model().objects.get(pk=1), issued_by=get_user_model().objects.get(pk=1),
) )
# Create some BuildLine items we can use later on
cls.line_1 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_1)
cls.line_2 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_2)
cls.line_3 = BuildLine.objects.get(build=cls.build, bom_item=cls.bom_item_3)
# Create some build output (StockItem) objects # Create some build output (StockItem) objects
cls.output_1 = StockItem.objects.create( cls.output_1 = StockItem.objects.create(
part=cls.assembly, part=cls.assembly,
@ -248,13 +253,10 @@ class BuildTest(BuildTestBase):
for output in self.build.get_build_outputs().all(): for output in self.build.get_build_outputs().all():
self.assertFalse(self.build.is_fully_allocated(output)) self.assertFalse(self.build.is_fully_allocated(output))
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1, self.output_1)) self.assertFalse(self.line_1.is_fully_allocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2, self.output_2)) self.assertFalse(self.line_2.is_overallocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15) self.assertEqual(self.line_1.allocated_quantity(), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_2), 35)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_1), 9)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2, self.output_2), 21)
self.assertFalse(self.build.is_complete) self.assertFalse(self.build.is_complete)
@ -264,25 +266,25 @@ class BuildTest(BuildTestBase):
stock = StockItem.objects.create(part=self.assembly, quantity=99) stock = StockItem.objects.create(part=self.assembly, quantity=99)
# Create a BuiltItem which points to an invalid StockItem # Create a BuiltItem which points to an invalid StockItem
b = BuildItem(stock_item=stock, build=self.build, quantity=10) b = BuildItem(stock_item=stock, build_line=self.line_2, quantity=10)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
b.save() b.save()
# Create a BuildItem which has too much stock assigned # Create a BuildItem which has too much stock assigned
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=9999999) b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=9999999)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
b.clean() b.clean()
# Negative stock? Not on my watch! # Negative stock? Not on my watch!
b = BuildItem(stock_item=self.stock_1_1, build=self.build, quantity=-99) b = BuildItem(stock_item=self.stock_1_1, build_line=self.line_1, quantity=-99)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
b.clean() b.clean()
# Ok, what about we make one that does *not* fail? # Ok, what about we make one that does *not* fail?
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10) b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b.save() b.save()
def test_duplicate_bom_line(self): def test_duplicate_bom_line(self):
@ -302,13 +304,24 @@ class BuildTest(BuildTestBase):
allocations: Map of {StockItem: quantity} allocations: Map of {StockItem: quantity}
""" """
items_to_create = []
for item, quantity in allocations.items(): for item, quantity in allocations.items():
BuildItem.objects.create(
# Find an appropriate BuildLine to allocate against
line = BuildLine.objects.filter(
build=self.build, build=self.build,
bom_item__sub_part=item.part
).first()
items_to_create.append(BuildItem(
build_line=line,
stock_item=item, stock_item=item,
quantity=quantity, quantity=quantity,
install_into=output install_into=output
) ))
BuildItem.objects.bulk_create(items_to_create)
def test_partial_allocation(self): def test_partial_allocation(self):
"""Test partial allocation of stock""" """Test partial allocation of stock"""
@ -321,7 +334,7 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertTrue(self.build.is_fully_allocated(self.output_1)) self.assertTrue(self.build.is_output_fully_allocated(self.output_1))
# Partially allocate tracked stock against build output 2 # Partially allocate tracked stock against build output 2
self.allocate_stock( self.allocate_stock(
@ -331,7 +344,7 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertFalse(self.build.is_fully_allocated(self.output_2)) self.assertFalse(self.build.is_output_fully_allocated(self.output_2))
# Partially allocate untracked stock against build # Partially allocate untracked stock against build
self.allocate_stock( self.allocate_stock(
@ -342,11 +355,12 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertFalse(self.build.is_fully_allocated(None)) self.assertFalse(self.build.is_output_fully_allocated(None))
unallocated = self.build.unallocated_bom_items(None) # Find lines which are *not* fully allocated
unallocated = self.build.unallocated_lines()
self.assertEqual(len(unallocated), 2) self.assertEqual(len(unallocated), 3)
self.allocate_stock( self.allocate_stock(
None, None,
@ -357,17 +371,17 @@ class BuildTest(BuildTestBase):
self.assertFalse(self.build.is_fully_allocated(None)) self.assertFalse(self.build.is_fully_allocated(None))
unallocated = self.build.unallocated_bom_items(None) unallocated = self.build.unallocated_lines()
self.assertEqual(len(unallocated), 1)
self.build.unallocateStock()
unallocated = self.build.unallocated_bom_items(None)
self.assertEqual(len(unallocated), 2) self.assertEqual(len(unallocated), 2)
self.assertFalse(self.build.are_untracked_parts_allocated()) self.build.deallocate_stock()
unallocated = self.build.unallocated_lines(None)
self.assertEqual(len(unallocated), 3)
self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.stock_2_1.quantity = 500 self.stock_2_1.quantity = 500
self.stock_2_1.save() self.stock_2_1.save()
@ -381,7 +395,7 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertTrue(self.build.are_untracked_parts_allocated()) self.assertTrue(self.build.is_fully_allocated(tracked=False))
def test_overallocation_and_trim(self): def test_overallocation_and_trim(self):
"""Test overallocation of stock and trim function""" """Test overallocation of stock and trim function"""
@ -425,10 +439,10 @@ class BuildTest(BuildTestBase):
} }
) )
self.assertTrue(self.build.has_overallocated_parts(None)) self.assertTrue(self.build.is_overallocated())
self.build.trim_allocated_stock() self.build.trim_allocated_stock()
self.assertFalse(self.build.has_overallocated_parts(None)) self.assertFalse(self.build.is_overallocated())
self.build.complete_build_output(self.output_1, None) self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None) self.build.complete_build_output(self.output_2, None)
@ -587,7 +601,7 @@ class BuildTest(BuildTestBase):
"""Unit tests for the metadata field.""" """Unit tests for the metadata field."""
# Make sure a BuildItem exists before trying to run this test # Make sure a BuildItem exists before trying to run this test
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10) b = BuildItem(stock_item=self.stock_1_2, build_line=self.line_1, install_into=self.output_1, quantity=10)
b.save() b.save()
for model in [Build, BuildItem]: for model in [Build, BuildItem]:
@ -644,7 +658,7 @@ class AutoAllocationTests(BuildTestBase):
# No build item allocations have been made against the build # No build item allocations have been made against the build
self.assertEqual(self.build.allocated_stock.count(), 0) self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.are_untracked_parts_allocated()) self.assertFalse(self.build.is_fully_allocated(tracked=False))
# Stock is not interchangeable, nothing will happen # Stock is not interchangeable, nothing will happen
self.build.auto_allocate_stock( self.build.auto_allocate_stock(
@ -652,15 +666,15 @@ class AutoAllocationTests(BuildTestBase):
substitutes=False, substitutes=False,
) )
self.assertFalse(self.build.are_untracked_parts_allocated()) self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertEqual(self.build.allocated_stock.count(), 0) self.assertEqual(self.build.allocated_stock.count(), 0)
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1)) self.assertFalse(self.line_1.is_fully_allocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) self.assertFalse(self.line_2.is_fully_allocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50) self.assertEqual(self.line_1.unallocated_quantity(), 50)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30) self.assertEqual(self.line_2.unallocated_quantity(), 30)
# This time we expect stock to be allocated! # This time we expect stock to be allocated!
self.build.auto_allocate_stock( self.build.auto_allocate_stock(
@ -669,15 +683,15 @@ class AutoAllocationTests(BuildTestBase):
optional_items=True, optional_items=True,
) )
self.assertFalse(self.build.are_untracked_parts_allocated()) self.assertFalse(self.build.is_fully_allocated(tracked=False))
self.assertEqual(self.build.allocated_stock.count(), 7) self.assertEqual(self.build.allocated_stock.count(), 7)
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1)) self.assertTrue(self.line_1.is_fully_allocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) self.assertFalse(self.line_2.is_fully_allocated())
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5) self.assertEqual(self.line_2.unallocated_quantity(), 5)
# This time, allow substitute parts to be used! # This time, allow substitute parts to be used!
self.build.auto_allocate_stock( self.build.auto_allocate_stock(
@ -685,12 +699,11 @@ class AutoAllocationTests(BuildTestBase):
substitutes=True, substitutes=True,
) )
# self.assertEqual(self.build.allocated_stock.count(), 8) self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.line_2.unallocated_quantity(), 5)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5.0)
self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1)) self.assertTrue(self.line_1.is_fully_allocated())
self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) self.assertFalse(self.line_2.is_fully_allocated())
def test_fully_auto(self): def test_fully_auto(self):
"""We should be able to auto-allocate against a build in a single go""" """We should be able to auto-allocate against a build in a single go"""
@ -701,7 +714,7 @@ class AutoAllocationTests(BuildTestBase):
optional_items=True, optional_items=True,
) )
self.assertTrue(self.build.are_untracked_parts_allocated()) self.assertTrue(self.build.is_fully_allocated(tracked=False))
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) self.assertEqual(self.line_1.unallocated_quantity(), 0)
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0) self.assertEqual(self.line_2.unallocated_quantity(), 0)

View File

@ -158,3 +158,139 @@ class TestReferencePatternMigration(MigratorTestCase):
pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN') pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN')
self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}') self.assertEqual(pattern.value, 'BuildOrder-{ref:04d}')
class TestBuildLineCreation(MigratorTestCase):
"""Test that build lines are correctly created for existing builds.
Ref: https://github.com/inventree/InvenTree/pull/4855
This PR added the 'BuildLine' model, which acts as a link between a Build and a BomItem.
- Migration 0044 creates BuildLine objects for existing builds.
- Migration 0046 links any existing BuildItem objects to corresponding BuildLine
"""
migrate_from = ('build', '0041_alter_build_title')
migrate_to = ('build', '0047_auto_20230606_1058')
def prepare(self):
"""Create data to work with"""
# Model references
Part = self.old_state.apps.get_model('part', 'part')
BomItem = self.old_state.apps.get_model('part', 'bomitem')
Build = self.old_state.apps.get_model('build', 'build')
BuildItem = self.old_state.apps.get_model('build', 'builditem')
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
# The "BuildLine" model does not exist yet
with self.assertRaises(LookupError):
self.old_state.apps.get_model('build', 'buildline')
# Create a part
assembly = Part.objects.create(
name='Assembly',
description='An assembly',
assembly=True,
level=0, lft=0, rght=0, tree_id=0,
)
# Create components
for idx in range(1, 11):
part = Part.objects.create(
name=f"Part {idx}",
description=f"Part {idx}",
level=0, lft=0, rght=0, tree_id=0,
)
# Create plentiful stock
StockItem.objects.create(
part=part,
quantity=1000,
level=0, lft=0, rght=0, tree_id=0,
)
# Create a BOM item
BomItem.objects.create(
part=assembly,
sub_part=part,
quantity=idx,
reference=f"REF-{idx}",
)
# Create some builds
for idx in range(1, 4):
build = Build.objects.create(
part=assembly,
title=f"Build {idx}",
quantity=idx * 10,
reference=f"REF-{idx}",
level=0, lft=0, rght=0, tree_id=0,
)
# Allocate stock to the build
for bom_item in BomItem.objects.all():
stock_item = StockItem.objects.get(part=bom_item.sub_part)
BuildItem.objects.create(
build=build,
bom_item=bom_item,
stock_item=stock_item,
quantity=bom_item.quantity,
)
def test_build_line_creation(self):
"""Test that the BuildLine objects have been created correctly"""
Build = self.new_state.apps.get_model('build', 'build')
BomItem = self.new_state.apps.get_model('part', 'bomitem')
BuildLine = self.new_state.apps.get_model('build', 'buildline')
BuildItem = self.new_state.apps.get_model('build', 'builditem')
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
# There should be 3x builds
self.assertEqual(Build.objects.count(), 3)
# 10x BOMItem objects
self.assertEqual(BomItem.objects.count(), 10)
# 10x StockItem objects
self.assertEqual(StockItem.objects.count(), 10)
# And 30x BuildLine items (1 for each BomItem for each Build)
self.assertEqual(BuildLine.objects.count(), 30)
# And 30x BuildItem objects (1 for each BomItem for each Build)
self.assertEqual(BuildItem.objects.count(), 30)
# Check that each BuildItem has been linked to a BuildLine
for item in BuildItem.objects.all():
self.assertIsNotNone(item.build_line)
self.assertEqual(
item.stock_item.part,
item.build_line.bom_item.sub_part,
)
item = BuildItem.objects.first()
# Check that the "build" field has been removed
with self.assertRaises(AttributeError):
item.build
# Check that the "bom_item" field has been removed
with self.assertRaises(AttributeError):
item.bom_item
# Check that each BuildLine is correctly configured
for line in BuildLine.objects.all():
# Check that the quantity is correct
self.assertEqual(
line.quantity,
line.build.quantity * line.bom_item.quantity,
)
# Check that the linked parts are correct
self.assertEqual(
line.build.part,
line.bom_item.part,
)

View File

@ -39,7 +39,5 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
part = build.part part = build.part
ctx['part'] = part ctx['part'] = part
ctx['has_tracked_bom_items'] = build.has_tracked_bom_items()
ctx['has_untracked_bom_items'] = build.has_untracked_bom_items()
return ctx return ctx

View File

@ -122,8 +122,13 @@ class CurrencyExchangeView(APIView):
# Information on last update # Information on last update
try: try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange') backend = ExchangeBackend.objects.filter(name='InvenTreeExchange')
if backend.exists():
backend = backend.first()
updated = backend.last_update updated = backend.last_update
else:
updated = None
except Exception: except Exception:
updated = None updated = None

View File

@ -1,9 +1,13 @@
"""User-configurable settings for the common app.""" """User-configurable settings for the common app."""
import logging
from django.conf import settings from django.conf import settings
from moneyed import CURRENCIES from moneyed import CURRENCIES
logger = logging.getLogger('inventree')
def currency_code_default(): def currency_code_default():
"""Returns the default currency code (or USD if not specified)""" """Returns the default currency code (or USD if not specified)"""

View File

@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('common', '0004_inventreesetting'),
('company', '0024_unique_name_email_constraint'), ('company', '0024_unique_name_email_constraint'),
] ]

View File

@ -8,6 +8,7 @@ import djmoney.models.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('common', '0004_inventreesetting'),
('company', '0038_manufacturerpartparameter'), ('company', '0038_manufacturerpartparameter'),
] ]

View File

@ -8,6 +8,7 @@ import djmoney.models.validators
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('common', '0004_inventreesetting'),
('company', '0050_alter_company_website'), ('company', '0050_alter_company_website'),
] ]

View File

@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('common', '0004_inventreesetting'),
('order', '0037_auto_20201110_0911'), ('order', '0037_auto_20201110_0911'),
] ]

View File

@ -833,10 +833,10 @@ class SalesOrder(TotalPriceMixin, Order):
return True return True
def is_over_allocated(self): def is_overallocated(self):
"""Return true if any lines in the order are over-allocated.""" """Return true if any lines in the order are over-allocated."""
for line in self.lines.all(): for line in self.lines.all():
if line.is_over_allocated(): if line.is_overallocated():
return True return True
return False return False
@ -1358,7 +1358,7 @@ class SalesOrderLineItem(OrderLineItem):
return self.allocated_quantity() >= self.quantity return self.allocated_quantity() >= self.quantity
def is_over_allocated(self): def is_overallocated(self):
"""Return True if this line item is over allocated.""" """Return True if this line item is over allocated."""
return self.allocated_quantity() > self.quantity return self.allocated_quantity() > self.quantity

View File

@ -102,7 +102,7 @@ class SalesOrderTest(TestCase):
self.assertEqual(self.line.allocated_quantity(), 0) self.assertEqual(self.line.allocated_quantity(), 0)
self.assertEqual(self.line.fulfilled_quantity(), 0) self.assertEqual(self.line.fulfilled_quantity(), 0)
self.assertFalse(self.line.is_fully_allocated()) self.assertFalse(self.line.is_fully_allocated())
self.assertFalse(self.line.is_over_allocated()) self.assertFalse(self.line.is_overallocated())
self.assertTrue(self.order.is_pending) self.assertTrue(self.order.is_pending)
self.assertFalse(self.order.is_fully_allocated()) self.assertFalse(self.order.is_fully_allocated())

View File

@ -350,7 +350,10 @@ class PartTestTemplateDetail(RetrieveUpdateDestroyAPI):
class PartTestTemplateList(ListCreateAPI): class PartTestTemplateList(ListCreateAPI):
"""API endpoint for listing (and creating) a PartTestTemplate.""" """API endpoint for listing (and creating) a PartTestTemplate.
TODO: Add filterset class for this view
"""
queryset = PartTestTemplate.objects.all() queryset = PartTestTemplate.objects.all()
serializer_class = part_serializers.PartTestTemplateSerializer serializer_class = part_serializers.PartTestTemplateSerializer
@ -945,6 +948,28 @@ class PartFilter(rest_filters.FilterSet):
else: else:
return queryset.filter(last_stocktake=None) return queryset.filter(last_stocktake=None)
stock_to_build = rest_filters.BooleanFilter(label='Required for Build Order', method='filter_stock_to_build')
def filter_stock_to_build(self, queryset, name, value):
"""Filter the queryset based on whether part stock is required for a pending BuildOrder"""
if str2bool(value):
# Return parts which are required for a build order, but have not yet been allocated
return queryset.filter(required_for_build_orders__gt=F('allocated_to_build_orders'))
else:
# Return parts which are not required for a build order, or have already been allocated
return queryset.filter(required_for_build_orders__lte=F('allocated_to_build_orders'))
depleted_stock = rest_filters.BooleanFilter(label='Depleted Stock', method='filter_depleted_stock')
def filter_deployed_stock(self, queryset, name, value):
"""Filter the queryset based on whether the part is fully depleted of stock"""
if str2bool(value):
return queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0))
else:
return queryset.exclude(Q(in_stock=0) & ~Q(stock_item_count=0))
is_template = rest_filters.BooleanFilter() is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter() assembly = rest_filters.BooleanFilter()
@ -1181,32 +1206,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
except (ValueError, PartCategory.DoesNotExist): except (ValueError, PartCategory.DoesNotExist):
pass pass
# Filer by 'depleted_stock' status -> has no stock and stock items
depleted_stock = params.get('depleted_stock', None)
if depleted_stock is not None:
depleted_stock = str2bool(depleted_stock)
if depleted_stock:
queryset = queryset.filter(Q(in_stock=0) & ~Q(stock_item_count=0))
# Filter by "parts which need stock to complete build"
stock_to_build = params.get('stock_to_build', None)
# TODO: This is super expensive, database query wise...
# TODO: Need to figure out a cheaper way of making this filter query
if stock_to_build is not None:
# Get active builds
builds = Build.objects.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
# Store parts with builds needing stock
parts_needed_to_complete_builds = []
# Filter required parts
for build in builds:
parts_needed_to_complete_builds += [part.pk for part in build.required_parts_to_complete_build]
queryset = queryset.filter(pk__in=parts_needed_to_complete_builds)
queryset = self.filter_parameteric_data(queryset) queryset = self.filter_parameteric_data(queryset)
return queryset return queryset

View File

@ -99,6 +99,28 @@ def annotate_total_stock(reference: str = ''):
) )
def annotate_build_order_requirements(reference: str = ''):
"""Annotate the total quantity of each part required for build orders.
- Only interested in 'active' build orders
- We are looking for any BuildLine items which required this part (bom_item.sub_part)
- We are interested in the 'quantity' of each BuildLine item
"""
# Active build orders only
build_filter = Q(build__status__in=BuildStatusGroups.ACTIVE_CODES)
return Coalesce(
SubquerySum(
f'{reference}used_in__build_lines__quantity',
filter=build_filter,
),
Decimal(0),
output_field=models.DecimalField(),
)
def annotate_build_order_allocations(reference: str = ''): def annotate_build_order_allocations(reference: str = ''):
"""Annotate the total quantity of each part allocated to build orders: """Annotate the total quantity of each part allocated to build orders:
@ -112,7 +134,7 @@ def annotate_build_order_allocations(reference: str = ''):
""" """
# Build filter only returns 'active' build orders # Build filter only returns 'active' build orders
build_filter = Q(build__status__in=BuildStatusGroups.ACTIVE_CODES) build_filter = Q(build_line__build__status__in=BuildStatusGroups.ACTIVE_CODES)
return Coalesce( return Coalesce(
SubquerySum( SubquerySum(

View File

@ -100,7 +100,7 @@
salable: true salable: true
purchaseable: false purchaseable: false
category: 7 category: 7
active: False active: True
IPN: BOB IPN: BOB
revision: A2 revision: A2
tree_id: 0 tree_id: 0

View File

@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('common', '0004_inventreesetting'),
('part', '0054_auto_20201109_1246'), ('part', '0054_auto_20201109_1246'),
] ]

View File

@ -10,6 +10,7 @@ import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator, MinValueValidator from django.core.validators import MinLengthValidator, MinValueValidator
@ -1747,7 +1748,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
return pricing return pricing
def schedule_pricing_update(self, create: bool = False): def schedule_pricing_update(self, create: bool = False, test: bool = False):
"""Helper function to schedule a pricing update. """Helper function to schedule a pricing update.
Importantly, catches any errors which may occur during deletion of related objects, Importantly, catches any errors which may occur during deletion of related objects,
@ -1757,6 +1758,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
Arguments: Arguments:
create: Whether or not a new PartPricing object should be created if it does not already exist create: Whether or not a new PartPricing object should be created if it does not already exist
test: Whether or not the pricing update is allowed during unit tests
""" """
try: try:
@ -1768,7 +1770,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel)
pricing = self.pricing pricing = self.pricing
if create or pricing.pk: if create or pricing.pk:
pricing.schedule_for_update() pricing.schedule_for_update(test=test)
except IntegrityError: except IntegrityError:
# If this part instance has been deleted, # If this part instance has been deleted,
# some post-delete or post-save signals may still be fired # some post-delete or post-save signals may still be fired
@ -2360,11 +2362,15 @@ class PartPricing(common.models.MetaMixin):
return result return result
def schedule_for_update(self, counter: int = 0): def schedule_for_update(self, counter: int = 0, test: bool = False):
"""Schedule this pricing to be updated""" """Schedule this pricing to be updated"""
import InvenTree.ready import InvenTree.ready
# If we are running within CI, only schedule the update if the test flag is set
if settings.TESTING and not test:
return
# If importing data, skip pricing update # If importing data, skip pricing update
if InvenTree.ready.isImportingData(): if InvenTree.ready.isImportingData():
return return
@ -3720,7 +3726,7 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
Includes: Includes:
- The referenced sub_part - The referenced sub_part
- Any directly specvified substitute parts - Any directly specified substitute parts
- If allow_variants is True, all variants of sub_part - If allow_variants is True, all variants of sub_part
""" """
# Set of parts we will allow # Set of parts we will allow
@ -3741,11 +3747,6 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
valid_parts = [] valid_parts = []
for p in parts: for p in parts:
# Inactive parts cannot be 'auto allocated'
if not p.active:
continue
# Trackable status must be the same as the sub_part # Trackable status must be the same as the sub_part
if p.trackable != self.sub_part.trackable: if p.trackable != self.sub_part.trackable:
continue continue
@ -3990,10 +3991,10 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model):
# Base quantity requirement # Base quantity requirement
base_quantity = self.quantity * build_quantity base_quantity = self.quantity * build_quantity
# Overage requiremet # Overage requirement
ovrg_quantity = self.get_overage_quantity(base_quantity) overage_quantity = self.get_overage_quantity(base_quantity)
required = float(base_quantity) + float(ovrg_quantity) required = float(base_quantity) + float(overage_quantity)
return required return required

View File

@ -410,8 +410,6 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
partial = True partial = True
fields = [ fields = [
'active', 'active',
'allocated_to_build_orders',
'allocated_to_sales_orders',
'assembly', 'assembly',
'barcode_hash', 'barcode_hash',
'category', 'category',
@ -423,9 +421,6 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
'description', 'description',
'full_name', 'full_name',
'image', 'image',
'in_stock',
'ordering',
'building',
'IPN', 'IPN',
'is_template', 'is_template',
'keywords', 'keywords',
@ -441,20 +436,28 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
'revision', 'revision',
'salable', 'salable',
'starred', 'starred',
'stock_item_count',
'suppliers',
'thumbnail', 'thumbnail',
'total_in_stock',
'trackable', 'trackable',
'unallocated_stock',
'units', 'units',
'variant_of', 'variant_of',
'variant_stock',
'virtual', 'virtual',
'pricing_min', 'pricing_min',
'pricing_max', 'pricing_max',
'responsible', 'responsible',
# Annotated fields
'allocated_to_build_orders',
'allocated_to_sales_orders',
'building',
'in_stock',
'ordering',
'required_for_build_orders',
'stock_item_count',
'suppliers',
'total_in_stock',
'unallocated_stock',
'variant_stock',
# Fields only used for Part creation # Fields only used for Part creation
'duplicate', 'duplicate',
'initial_stock', 'initial_stock',
@ -553,6 +556,9 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
), ),
) )
# TODO: This could do with some refactoring
# TODO: Note that BomItemSerializer and BuildLineSerializer have very similar code
queryset = queryset.annotate( queryset = queryset.annotate(
ordering=part.filters.annotate_on_order_quantity(), ordering=part.filters.annotate_on_order_quantity(),
in_stock=part.filters.annotate_total_stock(), in_stock=part.filters.annotate_total_stock(),
@ -578,6 +584,11 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
) )
) )
# Annotate with the total 'required for builds' quantity
queryset = queryset.annotate(
required_for_build_orders=part.filters.annotate_build_order_requirements(),
)
return queryset return queryset
def get_starred(self, part): def get_starred(self, part):
@ -587,17 +598,18 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
# Extra detail for the category # Extra detail for the category
category_detail = CategorySerializer(source='category', many=False, read_only=True) category_detail = CategorySerializer(source='category', many=False, read_only=True)
# Calculated fields # Annotated fields
allocated_to_build_orders = serializers.FloatField(read_only=True) allocated_to_build_orders = serializers.FloatField(read_only=True)
allocated_to_sales_orders = serializers.FloatField(read_only=True) allocated_to_sales_orders = serializers.FloatField(read_only=True)
unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True) building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True)
variant_stock = serializers.FloatField(read_only=True)
total_in_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True) ordering = serializers.FloatField(read_only=True)
required_for_build_orders = serializers.IntegerField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True)
total_in_stock = serializers.FloatField(read_only=True)
unallocated_stock = serializers.FloatField(read_only=True)
variant_stock = serializers.FloatField(read_only=True)
image = InvenTree.serializers.InvenTreeImageSerializerField(required=False, allow_null=True) image = InvenTree.serializers.InvenTreeImageSerializerField(required=False, allow_null=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)

View File

@ -1997,10 +1997,14 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
bom_item = BomItem.objects.get(pk=6) bom_item = BomItem.objects.get(pk=6)
line = build.models.BuildLine.objects.get(
bom_item=bom_item,
build=bo,
)
# Allocate multiple stock items against this build order # Allocate multiple stock items against this build order
build.models.BuildItem.objects.create( build.models.BuildItem.objects.create(
build=bo, build_line=line,
bom_item=bom_item,
stock_item=StockItem.objects.get(pk=1000), stock_item=StockItem.objects.get(pk=1000),
quantity=10, quantity=10,
) )
@ -2021,8 +2025,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
# Allocate further stock against the build # Allocate further stock against the build
build.models.BuildItem.objects.create( build.models.BuildItem.objects.create(
build=bo, build_line=line,
bom_item=bom_item,
stock_item=StockItem.objects.get(pk=1001), stock_item=StockItem.objects.get(pk=1001),
quantity=10, quantity=10,
) )

View File

@ -144,7 +144,15 @@ class CategoryTest(TestCase):
self.assertEqual(self.electronics.partcount(), 3) self.assertEqual(self.electronics.partcount(), 3)
self.assertEqual(self.mechanical.partcount(), 9) self.assertEqual(self.mechanical.partcount(), 9)
self.assertEqual(self.mechanical.partcount(active=True), 9)
# Mark one part as inactive and retry
part = Part.objects.get(pk=1)
part.active = False
part.save()
self.assertEqual(self.mechanical.partcount(active=True), 8) self.assertEqual(self.mechanical.partcount(active=True), 8)
self.assertEqual(self.mechanical.partcount(False), 7) self.assertEqual(self.mechanical.partcount(False), 7)
self.assertEqual(self.electronics.item_count, self.electronics.partcount()) self.assertEqual(self.electronics.item_count, self.electronics.partcount())

View File

@ -444,11 +444,6 @@ class PartPricingTests(InvenTreeTestCase):
# Check that PartPricing objects have been created # Check that PartPricing objects have been created
self.assertEqual(part.models.PartPricing.objects.count(), 101) self.assertEqual(part.models.PartPricing.objects.count(), 101)
# Check that background-tasks have been created
from django_q.models import OrmQ
self.assertEqual(OrmQ.objects.count(), 101)
def test_delete_part_with_stock_items(self): def test_delete_part_with_stock_items(self):
"""Test deleting a part instance with stock items. """Test deleting a part instance with stock items.
@ -473,6 +468,9 @@ class PartPricingTests(InvenTreeTestCase):
purchase_price=Money(10, 'USD') purchase_price=Money(10, 'USD')
) )
# Manually schedule a pricing update (does not happen automatically in testing)
p.schedule_pricing_update(create=True, test=True)
# Check that a PartPricing object exists # Check that a PartPricing object exists
self.assertTrue(part.models.PartPricing.objects.filter(part=p).exists()) self.assertTrue(part.models.PartPricing.objects.filter(part=p).exists())
@ -483,5 +481,5 @@ class PartPricingTests(InvenTreeTestCase):
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists()) self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
# Try to update pricing (should fail gracefully as the Part has been deleted) # Try to update pricing (should fail gracefully as the Part has been deleted)
p.schedule_pricing_update(create=False) p.schedule_pricing_update(create=False, test=True)
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists()) self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())

View File

@ -95,10 +95,14 @@ def allow_table_event(table_name):
We *do not* want events to be fired for some tables! We *do not* want events to be fired for some tables!
""" """
if isImportingData():
# Prevent table events during the data import process # Prevent table events during the data import process
if isImportingData():
return False # pragma: no cover return False # pragma: no cover
# Prevent table events when in testing mode (saves a lot of time)
if settings.TESTING:
return False
table_name = table_name.lower().strip() table_name = table_name.lower().strip()
# Ignore any tables which start with these prefixes # Ignore any tables which start with these prefixes

View File

@ -392,6 +392,8 @@ class BuildReport(ReportTemplateBase):
return { return {
'build': my_build, 'build': my_build,
'part': my_build.part, 'part': my_build.part,
'build_outputs': my_build.build_outputs.all(),
'line_items': my_build.build_lines.all(),
'bom_items': my_build.part.get_bom_items(), 'bom_items': my_build.part.get_bom_items(),
'reference': my_build.reference, 'reference': my_build.reference,
'quantity': my_build.quantity, 'quantity': my_build.quantity,

View File

@ -21,7 +21,7 @@ import stock.serializers as StockSerializers
from build.models import Build from build.models import Build
from build.serializers import BuildSerializer from build.serializers import BuildSerializer
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from company.serializers import CompanySerializer, SupplierPartSerializer from company.serializers import CompanySerializer
from generic.states import StatusView from generic.states import StatusView
from InvenTree.api import (APIDownloadMixin, AttachmentMixin, from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView, MetadataView) ListCreateDestroyAPIView, MetadataView)
@ -553,6 +553,28 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
queryset = StockItem.objects.all() queryset = StockItem.objects.all()
filterset_class = StockFilter filterset_class = StockFilter
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer.
Extra detail may be provided to the serializer via query parameters:
- part_detail: Include detail about the StockItem's part
- location_detail: Include detail about the StockItem's location
- supplier_part_detail: Include detail about the StockItem's supplier_part
- tests: Include detail about the StockItem's test results
"""
try:
params = self.request.query_params
for key in ['part_detail', 'location_detail', 'supplier_part_detail', 'tests']:
kwargs[key] = str2bool(params.get(key, False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def get_serializer_context(self): def get_serializer_context(self):
"""Extend serializer context.""" """Extend serializer context."""
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
@ -743,8 +765,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
""" """
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
params = request.query_params
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
if page is not None: if page is not None:
@ -754,78 +774,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
data = serializer.data data = serializer.data
# Keep track of which related models we need to query
location_ids = set()
part_ids = set()
supplier_part_ids = set()
# Iterate through each StockItem and grab some data
for item in data:
loc = item['location']
if loc:
location_ids.add(loc)
part = item['part']
if part:
part_ids.add(part)
sp = item['supplier_part']
if sp:
supplier_part_ids.add(sp)
# Do we wish to include Part detail?
if str2bool(params.get('part_detail', False)):
# Fetch only the required Part objects from the database
parts = Part.objects.filter(pk__in=part_ids).prefetch_related(
'category',
)
part_map = {}
for part in parts:
part_map[part.pk] = PartBriefSerializer(part).data
# Now update each StockItem with the related Part data
for stock_item in data:
part_id = stock_item['part']
stock_item['part_detail'] = part_map.get(part_id, None)
# Do we wish to include SupplierPart detail?
if str2bool(params.get('supplier_part_detail', False)):
supplier_parts = SupplierPart.objects.filter(pk__in=supplier_part_ids)
supplier_part_map = {}
for part in supplier_parts:
supplier_part_map[part.pk] = SupplierPartSerializer(part).data
for stock_item in data:
part_id = stock_item['supplier_part']
stock_item['supplier_part_detail'] = supplier_part_map.get(part_id, None)
# Do we wish to include StockLocation detail?
if str2bool(params.get('location_detail', False)):
# Fetch only the required StockLocation objects from the database
locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related(
'parent',
'children',
)
location_map = {}
# Serialize each StockLocation object
for location in locations:
location_map[location.pk] = StockSerializers.LocationBriefSerializer(location).data
# Now update each StockItem with the related StockLocation data
for stock_item in data:
loc_id = stock_item['location']
stock_item['location_detail'] = location_map.get(loc_id, None)
""" """
Determine the response type based on the request. Determine the response type based on the request.
a) For HTTP requests (e.g. via the browsable API) return a DRF response a) For HTTP requests (e.g. via the browsable API) return a DRF response
@ -852,6 +800,7 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView):
'part', 'part',
'part__category', 'part__category',
'location', 'location',
'test_results',
'tags', 'tags',
) )

View File

@ -8,6 +8,7 @@ import common.settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('common', '0004_inventreesetting'),
('stock', '0052_stockitem_is_building'), ('stock', '0052_stockitem_is_building'),
] ]

View File

@ -4,6 +4,22 @@ import InvenTree.fields
from django.db import migrations from django.db import migrations
import djmoney.models.fields import djmoney.models.fields
from django.db.migrations.recorder import MigrationRecorder
def show_migrations(apps, schema_editor):
"""Show the latest migrations from each app"""
for app in apps.get_app_configs():
label = app.label
migrations = MigrationRecorder.Migration.objects.filter(app=app).order_by('-applied')[:5]
print(f"{label} migrations:")
for m in migrations:
print(f" - {m.name}")
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -11,7 +27,13 @@ class Migration(migrations.Migration):
('stock', '0064_auto_20210621_1724'), ('stock', '0064_auto_20210621_1724'),
] ]
operations = [ operations = []
xoperations = [
migrations.RunPython(
code=show_migrations,
reverse_code=migrations.RunPython.noop
),
migrations.AlterField( migrations.AlterField(
model_name='stockitem', model_name='stockitem',
name='purchase_price', name='purchase_price',

View File

@ -44,6 +44,50 @@ class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
] ]
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the StockItemTestResult model."""
class Meta:
"""Metaclass options."""
model = StockItemTestResult
fields = [
'pk',
'stock_item',
'key',
'test',
'result',
'value',
'attachment',
'notes',
'user',
'user_detail',
'date'
]
read_only_fields = [
'pk',
'user',
'date',
]
def __init__(self, *args, **kwargs):
"""Add detail fields."""
user_detail = kwargs.pop('user_detail', False)
super().__init__(*args, **kwargs)
if user_detail is not True:
self.fields.pop('user_detail')
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
key = serializers.CharField(read_only=True)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
"""Brief serializers for a StockItem.""" """Brief serializers for a StockItem."""
@ -126,6 +170,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
'purchase_price', 'purchase_price',
'purchase_price_currency', 'purchase_price_currency',
'use_pack_size', 'use_pack_size',
'tests',
'tags', 'tags',
] ]
@ -234,11 +279,11 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
status_text = serializers.CharField(source='get_status_display', read_only=True) status_text = serializers.CharField(source='get_status_display', read_only=True)
# Optional detail fields, which can be appended via query parameters
supplier_part_detail = SupplierPartSerializer(source='supplier_part', many=False, read_only=True) supplier_part_detail = SupplierPartSerializer(source='supplier_part', many=False, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
location_detail = LocationBriefSerializer(source='location', many=False, read_only=True) location_detail = LocationBriefSerializer(source='location', many=False, read_only=True)
tests = StockItemTestResultSerializer(source='test_results', many=True, read_only=True)
quantity = InvenTreeDecimalField() quantity = InvenTreeDecimalField()
@ -266,18 +311,22 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
location_detail = kwargs.pop('location_detail', False) location_detail = kwargs.pop('location_detail', False)
supplier_part_detail = kwargs.pop('supplier_part_detail', False) supplier_part_detail = kwargs.pop('supplier_part_detail', False)
tests = kwargs.pop('tests', False)
super(StockItemSerializer, self).__init__(*args, **kwargs) super(StockItemSerializer, self).__init__(*args, **kwargs)
if part_detail is not True: if not part_detail:
self.fields.pop('part_detail') self.fields.pop('part_detail')
if location_detail is not True: if not location_detail:
self.fields.pop('location_detail') self.fields.pop('location_detail')
if supplier_part_detail is not True: if not supplier_part_detail:
self.fields.pop('supplier_part_detail') self.fields.pop('supplier_part_detail')
if not tests:
self.fields.pop('tests')
class SerializeStockItemSerializer(serializers.Serializer): class SerializeStockItemSerializer(serializers.Serializer):
"""A DRF serializer for "serializing" a StockItem. """A DRF serializer for "serializing" a StockItem.
@ -653,50 +702,6 @@ class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSer
]) ])
class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for the StockItemTestResult model."""
class Meta:
"""Metaclass options."""
model = StockItemTestResult
fields = [
'pk',
'stock_item',
'key',
'test',
'result',
'value',
'attachment',
'notes',
'user',
'user_detail',
'date'
]
read_only_fields = [
'pk',
'user',
'date',
]
def __init__(self, *args, **kwargs):
"""Add detail fields."""
user_detail = kwargs.pop('user_detail', False)
super().__init__(*args, **kwargs)
if user_detail is not True:
self.fields.pop('user_detail')
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
key = serializers.CharField(read_only=True)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for StockItemTracking model.""" """Serializer for StockItemTracking model."""

View File

@ -972,7 +972,7 @@ function loadBomTable(table, options={}) {
} }
if (row.overage) { if (row.overage) {
text += `<small> (${row.overage}) </small>`; text += `<small> (+${row.overage})</small>`;
} }
return text; return text;
@ -1161,6 +1161,8 @@ function loadBomTable(table, options={}) {
} }
} }
text = renderLink(text, url);
if (row.on_order && row.on_order > 0) { if (row.on_order && row.on_order > 0) {
text += makeIconBadge( text += makeIconBadge(
'fa-shopping-cart', 'fa-shopping-cart',
@ -1168,7 +1170,7 @@ function loadBomTable(table, options={}) {
); );
} }
return renderLink(text, url); return text;
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@ -1727,12 +1727,12 @@ function loadSalesOrderLineItemTable(table, options={}) {
options.params = options.params || {}; options.params = options.params || {};
if (!options.order) { if (!options.order) {
console.error('function called without order ID'); console.error('loadSalesOrderLineItemTable called without order ID');
return; return;
} }
if (!options.status) { if (!options.status) {
console.error('function called without order status'); console.error('loadSalesOrderLineItemTable called without order status');
return; return;
} }

View File

@ -480,8 +480,8 @@ function getBuildTableFilters() {
} }
// Return a dictionary of filters for the "build item" table // Return a dictionary of filters for the "build lines" table
function getBuildItemTableFilters() { function getBuildLineTableFilters() {
return { return {
allocated: { allocated: {
type: 'bool', type: 'bool',
@ -491,6 +491,10 @@ function getBuildItemTableFilters() {
type: 'bool', type: 'bool',
title: '{% trans "Available" %}', title: '{% trans "Available" %}',
}, },
tracked: {
type: 'bool',
title: '{% trans "Tracked" %}',
},
consumable: { consumable: {
type: 'bool', type: 'bool',
title: '{% trans "Consumable" %}', title: '{% trans "Consumable" %}',
@ -771,8 +775,8 @@ function getAvailableTableFilters(tableKey) {
return getBOMTableFilters(); return getBOMTableFilters();
case 'build': case 'build':
return getBuildTableFilters(); return getBuildTableFilters();
case 'builditems': case 'buildlines':
return getBuildItemTableFilters(); return getBuildLineTableFilters();
case 'location': case 'location':
return getStockLocationFilters(); return getStockLocationFilters();
case 'parameters': case 'parameters':

View File

@ -131,6 +131,7 @@ class RuleSet(models.Model):
'part_bomitemsubstitute', 'part_bomitemsubstitute',
'build_build', 'build_build',
'build_builditem', 'build_builditem',
'build_buildline',
'build_buildorderattachment', 'build_buildorderattachment',
'stock_stockitem', 'stock_stockitem',
'stock_stocklocation', 'stock_stocklocation',

View File

@ -82,9 +82,9 @@ Stock can be manually allocated to the build as required, using the *Allocate st
Stock allocations can be manually adjusted or deleted using the action buttons available in each row of the allocation table. Stock allocations can be manually adjusted or deleted using the action buttons available in each row of the allocation table.
### Unallocate Stock ### Deallocate Stock
The *Unallocate Stock* button can be used to remove all allocations of untracked stock items against the build order. The *Deallocate Stock* button can be used to remove all allocations of untracked stock items against the build order.
## Automatic Stock Allocation ## Automatic Stock Allocation

View File

@ -23,12 +23,6 @@ A BOM for a particular assembly is comprised of a number (zero or more) of BOM "
| Optional | A boolean field which indicates if this BOM Line Item is "optional" | | Optional | A boolean field which indicates if this BOM Line Item is "optional" |
| Note | Optional note field for additional information | Note | Optional note field for additional information
!!! missing "Overage"
While the overage field exists, it is currently non-functional and has no effect on BOM operation
!!! missing "Optional"
The Optional field is currently for indication only - it does not serve a functional purpose (yet)
### Consumable BOM Line Items ### Consumable BOM Line Items
If a BOM line item is marked as *consumable*, this means that while the part and quantity information is tracked in the BOM, this line item does not get allocated to a [Build Order](./build.md). This may be useful for certain items that the user does not wish to track through the build process, as they may be low value, in abundant stock, or otherwise complicated to track. If a BOM line item is marked as *consumable*, this means that while the part and quantity information is tracked in the BOM, this line item does not get allocated to a [Build Order](./build.md). This may be useful for certain items that the user does not wish to track through the build process, as they may be low value, in abundant stock, or otherwise complicated to track.

View File

@ -26,7 +26,7 @@ To navigate to the Build Order display, select *Build* from the main navigation
{% include "img.html" %} {% include "img.html" %}
{% endwith %} {% endwith %}
#### Tree Vieww #### Tree View
*Tree View* also provides a tabulated view of Build Orders. Orders are displayed in a hierarchical manner, showing any parent / child relationships between different build orders. *Tree View* also provides a tabulated view of Build Orders. Orders are displayed in a hierarchical manner, showing any parent / child relationships between different build orders.

View File

@ -18,8 +18,11 @@ In addition to the default report context variables, the following context varia
| --- | --- | | --- | --- |
| build | The build object the report is being generated against | | build | The build object the report is being generated against |
| part | The [Part](./context_variables.md#part) object that the build references | | part | The [Part](./context_variables.md#part) object that the build references |
| line_items | A shortcut for [build.line_items](#build) |
| bom_items | A shortcut for [build.bom_items](#build) |
| build_outputs | A shortcut for [build.build_outputs](#build) |
| reference | The build order reference string | | reference | The build order reference string |
| quantity | Build order quantity | | quantity | Build order quantity (number of assemblies being built) |
#### build #### build
@ -29,7 +32,9 @@ The following variables are accessed by build.variable
| --- | --- | | --- | --- |
| active | Boolean that tells if the build is active | | active | Boolean that tells if the build is active |
| batch | Batch code transferred to build parts (optional) | | batch | Batch code transferred to build parts (optional) |
| bom_items | A query set with all BOM items for the build | | line_items | A query set with all the build line items associated with the build |
| bom_items | A query set with all BOM items for the part being assembled |
| build_outputs | A queryset containing all build output ([Stock Item](../stock/stock.md)) objects associated with this build |
| can_complete | Boolean that tells if the build can be completed. Means: All material allocated and all parts have been build. | | can_complete | Boolean that tells if the build can be completed. Means: All material allocated and all parts have been build. |
| are_untracked_parts_allocated | Boolean that tells if all bom_items have allocated stock_items. | | are_untracked_parts_allocated | Boolean that tells if all bom_items have allocated stock_items. |
| creation_date | Date where the build has been created | | creation_date | Date where the build has been created |
@ -42,7 +47,8 @@ The following variables are accessed by build.variable
| notes | Text notes | | notes | Text notes |
| parent | Reference to a parent build object if this is a sub build | | parent | Reference to a parent build object if this is a sub build |
| part | The [Part](./context_variables.md#part) to be built (from component BOM items) | | part | The [Part](./context_variables.md#part) to be built (from component BOM items) |
| quantity | Build order quantity | | quantity | Build order quantity (total number of assembly outputs) |
| completed | The number out outputs which have been completed |
| reference | Build order reference (required, must be unique) | | reference | Build order reference (required, must be unique) |
| required_parts | A query set with all parts that are required for the build | | required_parts | A query set with all parts that are required for the build |
| responsible | Owner responsible for completing the build. This can be a user or a group. Depending on that further context variables differ | | responsible | Owner responsible for completing the build. This can be a user or a group. Depending on that further context variables differ |
@ -59,19 +65,38 @@ The following variables are accessed by build.variable
As usual items in a query sets can be selected by adding a .n to the set e.g. build.required_parts.0 As usual items in a query sets can be selected by adding a .n to the set e.g. build.required_parts.0
will result in the first part of the list. Each query set has again its own context variables. will result in the first part of the list. Each query set has again its own context variables.
#### line_items
The `line_items` variable is a list of all build line items associated with the selected build. The following attributes are available for each individual line_item instance:
| Attribute | Description |
| --- | --- |
| .build | A reference back to the parent build order |
| .bom_item | A reference to the BOMItem which defines this line item |
| .quantity | The required quantity which is to be allocated against this line item |
| .part | A shortcut for .bom_item.sub_part |
| .allocations | A list of BuildItem objects which allocate stock items against this line item |
| .allocated_quantity | The total stock quantity which has been allocated against this line |
| .unallocated_quantity | The remaining quantity to allocate |
| .is_fully_allocated | Boolean value, returns True if the line item has sufficient stock allocated against it |
| .is_overallocated | Boolean value, returns True if the line item has more allocated stock than is required |
#### bom_items #### bom_items
| Variable | Description | | Attribute | Description |
| --- | --- | | --- | --- |
| .reference | The reference designators of the components | | .reference | The reference designators of the components |
| .quantity | The number of components | | .quantity | The number of components required to build |
| .overage | The extra amount required to assembly |
| .consumable | Boolean field, True if this is a "consumable" part which is not tracked through builds |
| .sub_part | The part at this position | | .sub_part | The part at this position |
| .substitutes.all | A query set with all allowed substitutes for that part | | .substitutes.all | A query set with all allowed substitutes for that part |
| .note | Extra text field which can contain additional information |
#### allocated_stock.all #### allocated_stock.all
| Variable | Description | | Attribute | Description |
| --- | --- | | --- | --- |
| .bom_item | The bom item where this part belongs to | | .bom_item | The bom item where this part belongs to |
| .stock_item | The allocated [StockItem](./context_variables.md#stockitem) | | .stock_item | The allocated [StockItem](./context_variables.md#stockitem) |