From 6ba777d363170976fb50c93e48b0bab91c32a67d Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 13 Jun 2023 20:18:32 +1000 Subject: [PATCH] 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 --- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/InvenTree/apps.py | 23 +- InvenTree/InvenTree/static/css/inventree.css | 3 +- InvenTree/InvenTree/tasks.py | 17 +- InvenTree/InvenTree/test_urls.py | 4 +- InvenTree/InvenTree/views.py | 6 +- InvenTree/build/admin.py | 23 +- InvenTree/build/api.py | 113 +- InvenTree/build/migrations/0043_buildline.py | 28 + .../migrations/0044_auto_20230528_1410.py | 97 + .../migrations/0045_builditem_build_line.py | 19 + .../migrations/0046_auto_20230606_1033.py | 95 + .../migrations/0047_auto_20230606_1058.py | 26 + InvenTree/build/models.py | 563 ++--- InvenTree/build/serializers.py | 235 +- .../build/templates/build/build_base.html | 2 +- InvenTree/build/templates/build/detail.html | 111 +- InvenTree/build/templates/build/sidebar.html | 8 +- InvenTree/build/test_api.py | 71 +- InvenTree/build/test_build.py | 111 +- InvenTree/build/test_migrations.py | 136 ++ InvenTree/build/views.py | 2 - InvenTree/common/api.py | 9 +- InvenTree/common/settings.py | 4 + .../migrations/0025_auto_20201110_1001.py | 1 + .../migrations/0039_auto_20210701_0509.py | 1 + .../0051_alter_supplierpricebreak_price.py | 1 + .../migrations/0038_auto_20201112_1737.py | 1 + InvenTree/order/models.py | 6 +- InvenTree/order/test_sales_order.py | 2 +- InvenTree/part/api.py | 53 +- InvenTree/part/filters.py | 24 +- InvenTree/part/fixtures/part.yaml | 2 +- .../migrations/0055_auto_20201110_1001.py | 1 + InvenTree/part/models.py | 25 +- InvenTree/part/serializers.py | 40 +- InvenTree/part/test_api.py | 11 +- InvenTree/part/test_category.py | 8 + InvenTree/part/test_pricing.py | 10 +- InvenTree/plugin/base/event/events.py | 6 +- InvenTree/report/models.py | 2 + InvenTree/stock/api.py | 99 +- .../migrations/0053_auto_20201110_0513.py | 1 + .../migrations/0065_auto_20210701_0509.py | 24 +- InvenTree/stock/serializers.py | 103 +- InvenTree/templates/js/translated/bom.js | 6 +- InvenTree/templates/js/translated/build.js | 1892 ++++++----------- .../templates/js/translated/sales_order.js | 4 +- .../templates/js/translated/table_filters.js | 12 +- InvenTree/users/models.py | 1 + docs/docs/build/allocate.md | 4 +- docs/docs/build/bom.md | 6 - docs/docs/build/build.md | 2 +- docs/docs/report/build.md | 37 +- 54 files changed, 2193 insertions(+), 1903 deletions(-) create mode 100644 InvenTree/build/migrations/0043_buildline.py create mode 100644 InvenTree/build/migrations/0044_auto_20230528_1410.py create mode 100644 InvenTree/build/migrations/0045_builditem_build_line.py create mode 100644 InvenTree/build/migrations/0046_auto_20230606_1033.py create mode 100644 InvenTree/build/migrations/0047_auto_20230606_1058.py diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index e98a0adca8..c89fb1b799 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # 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 +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 - Add Metadata to: Part test templates, Part parameters, Part category parameter templates, BOM item substitute, Part relateds, Stock item test result diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 1ba43edee7..4d2705be50 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -126,19 +126,22 @@ class InvenTreeConfig(AppConfig): update = False try: - backend = ExchangeBackend.objects.get(name='InvenTreeExchange') + backend = ExchangeBackend.objects.filter(name='InvenTreeExchange') - last_update = backend.last_update + if backend.exists(): + backend = backend.first() - if last_update is None: - # Never been updated - logger.info("Exchange backend has never been updated") - update = True + last_update = backend.last_update - # Backend currency has changed? - if base_currency != backend.base_currency: - logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}") - update = True + if last_update is None: + # Never been updated + logger.info("Exchange backend has never been updated") + update = True + + # Backend currency has changed? + if base_currency != backend.base_currency: + logger.info(f"Base currency changed from {backend.base_currency} to {base_currency}") + update = True except (ExchangeBackend.DoesNotExist): logger.info("Exchange backend not found - updating") diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 8bc36c3d70..a118a58e22 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -223,8 +223,7 @@ main { } .sub-table { - margin-left: 45px; - margin-right: 45px; + margin-left: 60px; } .detail-icon .glyphicon { diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 41aeddcbf6..6c813731f0 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -497,7 +497,7 @@ def check_for_updates(): def update_exchange_rates(): """Update currency exchange rates.""" 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 InvenTree.exchange import InvenTreeExchange @@ -509,22 +509,9 @@ def update_exchange_rates(): # Other error? 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() - logger.info(f"Updating exchange rates from {backend.url}") - base = currency_code_default() - - logger.info(f"Using base currency '{base}'") + logger.info(f"Updating exchange rates using base currency '{base}'") try: backend.update_rates(base_currency=base) diff --git a/InvenTree/InvenTree/test_urls.py b/InvenTree/InvenTree/test_urls.py index 8586de0e69..2f2db7e2a9 100644 --- a/InvenTree/InvenTree/test_urls.py +++ b/InvenTree/InvenTree/test_urls.py @@ -14,18 +14,18 @@ class URLTest(TestCase): # Need fixture data in the database fixtures = [ 'settings', - 'build', 'company', 'manufacturer_part', 'price_breaks', 'supplier_part', 'order', 'sales_order', - 'bom', 'category', 'params', 'part_pricebreaks', 'part', + 'bom', + 'build', 'test_templates', 'location', 'stock_tests', diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 014b384000..d4e364fcf4 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -525,8 +525,10 @@ class SettingsView(TemplateView): # When were the rates last updated? try: - backend = ExchangeBackend.objects.get(name='InvenTreeExchange') - ctx["rates_updated"] = backend.last_update + backend = ExchangeBackend.objects.filter(name='InvenTreeExchange') + if backend.exists(): + backend = backend.first() + ctx["rates_updated"] = backend.last_update except Exception: ctx["rates_updated"] = None diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index 9bcfc78327..a9f8d538c3 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -6,7 +6,7 @@ from import_export.admin import ImportExportModelAdmin from import_export.fields import Field from import_export import widgets -from build.models import Build, BuildItem +from build.models import Build, BuildLine, BuildItem from InvenTree.admin import InvenTreeResource import part.models @@ -87,18 +87,33 @@ class BuildItemAdmin(admin.ModelAdmin): """Class for managing the BuildItem model via the admin interface""" list_display = ( - 'build', 'stock_item', 'quantity' ) autocomplete_fields = [ - 'build', - 'bom_item', + 'build_line', 'stock_item', '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(BuildItem, BuildItemAdmin) +admin.site.register(BuildLine, BuildLineAdmin) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index e0f9a4f8bb..c973884c9d 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -1,5 +1,6 @@ """JSON API for the Build app.""" +from django.db.models import F from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User @@ -17,7 +18,7 @@ from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI import build.admin import build.serializers -from build.models import Build, BuildItem, BuildOrderAttachment +from build.models import Build, BuildLine, BuildItem, BuildOrderAttachment import part.models from users.models import Owner from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS @@ -251,6 +252,88 @@ class BuildUnallocate(CreateAPI): 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: """Mixin class which adds build order as serializer context variable.""" @@ -373,9 +456,8 @@ class BuildItemFilter(rest_filters.FilterSet): """Metaclass option""" model = BuildItem fields = [ - 'build', + 'build_line', 'stock_item', - 'bom_item', 'install_into', ] @@ -384,6 +466,11 @@ class BuildItemFilter(rest_filters.FilterSet): 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') def filter_tracked(self, queryset, name, value): @@ -409,10 +496,9 @@ class BuildItemList(ListCreateAPI): try: params = self.request.query_params - kwargs['part_detail'] = str2bool(params.get('part_detail', False)) - kwargs['build_detail'] = str2bool(params.get('build_detail', False)) - kwargs['location_detail'] = str2bool(params.get('location_detail', False)) - kwargs['stock_detail'] = str2bool(params.get('stock_detail', True)) + for key in ['part_detail', 'location_detail', 'stock_detail', 'build_detail']: + if key in params: + kwargs[key] = str2bool(params.get(key, False)) except AttributeError: pass @@ -423,9 +509,8 @@ class BuildItemList(ListCreateAPI): queryset = BuildItem.objects.all() queryset = queryset.select_related( - 'bom_item', - 'bom_item__sub_part', - 'build', + 'build_line', + 'build_line__build', 'install_into', 'stock_item', 'stock_item__location', @@ -435,7 +520,7 @@ class BuildItemList(ListCreateAPI): return 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) params = self.request.query_params @@ -487,6 +572,12 @@ build_api_urls = [ re_path(r'^.*$', BuildAttachmentList.as_view(), name='api-build-attachment-list'), ])), + # Build lines + re_path(r'^line/', include([ + path(r'/', BuildLineDetail.as_view(), name='api-build-line-detail'), + re_path(r'^.*$', BuildLineList.as_view(), name='api-build-line-list'), + ])), + # Build Items re_path(r'^item/', include([ path(r'/', include([ diff --git a/InvenTree/build/migrations/0043_buildline.py b/InvenTree/build/migrations/0043_buildline.py new file mode 100644 index 0000000000..8da86bc015 --- /dev/null +++ b/InvenTree/build/migrations/0043_buildline.py @@ -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')}, + }, + ), + ] diff --git a/InvenTree/build/migrations/0044_auto_20230528_1410.py b/InvenTree/build/migrations/0044_auto_20230528_1410.py new file mode 100644 index 0000000000..6b7da23155 --- /dev/null +++ b/InvenTree/build/migrations/0044_auto_20230528_1410.py @@ -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, + ), + ] diff --git a/InvenTree/build/migrations/0045_builditem_build_line.py b/InvenTree/build/migrations/0045_builditem_build_line.py new file mode 100644 index 0000000000..690bb0352d --- /dev/null +++ b/InvenTree/build/migrations/0045_builditem_build_line.py @@ -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'), + ), + ] diff --git a/InvenTree/build/migrations/0046_auto_20230606_1033.py b/InvenTree/build/migrations/0046_auto_20230606_1033.py new file mode 100644 index 0000000000..60d290a70f --- /dev/null +++ b/InvenTree/build/migrations/0046_auto_20230606_1033.py @@ -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, + ) + ] diff --git a/InvenTree/build/migrations/0047_auto_20230606_1058.py b/InvenTree/build/migrations/0047_auto_20230606_1058.py new file mode 100644 index 0000000000..88535c7347 --- /dev/null +++ b/InvenTree/build/migrations/0047_auto_20230606_1058.py @@ -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', + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 726d99187d..b382e65bda 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -1,7 +1,7 @@ """Build database model definitions.""" import decimal - +import logging import os from datetime import datetime @@ -40,6 +40,9 @@ import stock.models import users.models +logger = logging.getLogger('inventree') + + 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. @@ -334,33 +337,24 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. return self.status in BuildStatusGroups.ACTIVE_CODES @property - def bom_items(self): - """Returns the BOM items for the part referenced by this BuildOrder.""" - return self.part.get_bom_items() + def tracked_line_items(self): + """Returns the "trackable" BOM lines for this BuildOrder.""" - @property - 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 self.build_lines.filter(bom_item__sub_part__trackable=True) - return items - - def has_tracked_bom_items(self): + def has_tracked_line_items(self): """Returns True if this BuildOrder has trackable BomItems.""" - return self.tracked_bom_items.count() > 0 + return self.tracked_line_items.count() > 0 @property - def untracked_bom_items(self): + def untracked_line_items(self): """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.""" - return self.untracked_bom_items.count() > 0 + return self.has_untracked_line_items.count() > 0 @property def remaining(self): @@ -422,6 +416,11 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. 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 def incomplete_outputs(self): """Return all the "incomplete" build outputs.""" @@ -478,21 +477,22 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. @property 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 - - '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: return False if self.remaining > 0: return False - if not self.are_untracked_parts_allocated(): + if not self.is_fully_allocated(tracked=False): return False - # No issues! return True @transaction.atomic @@ -511,7 +511,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. # Ensure that there are no longer any BuildItem objects # which point to this Build Order - self.allocated_stock.all().delete() + self.allocated_stock.delete() # Register an event 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_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False) - # Handle stock allocations - for build_item in self.allocated_stock.all(): + # Find all BuildItem objects associated with this Build + items = self.allocated_stock - if remove_allocated_stock: - build_item.complete_allocation(user) + if remove_allocated_stock: + for item in items: + item.complete_allocation(user) - build_item.delete() + items.delete() # Remove incomplete outputs (if required) if remove_incomplete_outputs: @@ -591,20 +592,19 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. trigger_event('build.cancelled', id=self.pk) @transaction.atomic - def unallocateStock(self, bom_item=None, output=None): - """Unallocate stock from this Build. + def deallocate_stock(self, build_line=None, output=None): + """Deallocate stock from this Build. Args: - bom_item: Specify a particular BomItem to unallocate stock against - output: Specify a particular StockItem (output) to unallocate stock against + build_line: Specify a particular BuildLine instance to un-allocate stock against + output: Specify a particular StockItem (output) to un-allocate stock against """ - allocations = BuildItem.objects.filter( - build=self, + allocations = self.allocated_stock.filter( install_into=output ) - if bom_item: - allocations = allocations.filter(bom_item=bom_item) + if build_line: + allocations = allocations.filter(build_line=build_line) allocations.delete() @@ -737,7 +737,7 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. """Remove a build output from the database. Executes: - - Unallocate any build items against the output + - Deallocate any build items against the output - Delete the output StockItem """ if not output: @@ -749,8 +749,8 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. if output.build != self: raise ValidationError(_("Build output does not match Build Order")) - # Unallocate all build items against the output - self.unallocateStock(output=output) + # Deallocate all build items against the output + self.deallocate_stock(output=output) # Remove the build output from the database output.delete() @@ -758,36 +758,47 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. @transaction.atomic def trim_allocated_stock(self): """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 - for bom_item in self.untracked_bom_items: - reduce_by = self.allocated_quantity(bom_item) - self.required_quantity(bom_item) - if reduce_by <= 0: - continue # all OK + for build_line in self.untracked_line_items: + + reduce_by = build_line.allocated_quantity() - build_line.quantity + + 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 - if reduce_by == 0: + if reduce_by <= 0: break # Easy case - this item can just be reduced. - if a.quantity > reduce_by: - a.quantity -= reduce_by - a.save() + if item.quantity > reduce_by: + item.quantity -= reduce_by + item.save() break # Harder case, this item needs to be deleted, and any remainder # taken from the next items in the list. - reduce_by -= a.quantity - a.delete() + reduce_by -= item.quantity + 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 def subtract_allocated_stock(self, user): """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( - stock_item__part__trackable=False + build_line__bom_item__sub_part__trackable=False ) # Remove stock @@ -934,8 +945,13 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. else: return 3 - # Get a list of all 'untracked' BOM items - for bom_item in self.untracked_bom_items: + new_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: # 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) - unallocated_quantity = self.unallocated_quantity(bom_item) + unallocated_quantity = line_item.unallocated_quantity() if unallocated_quantity <= 0: # 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 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? quantity = min(unallocated_quantity, stock_item.unallocated_quantity()) if quantity > 0: try: - BuildItem.objects.create( - build=self, - bom_item=bom_item, + new_items.append(BuildItem( + build_line=line_item, stock_item=stock_item, quantity=quantity, - ) + )) # Subtract the required 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! break - def required_quantity(self, bom_item, output=None): - """Get the quantity of a part required to complete the particular build output. + # Bulk-create the new BuildItem objects + 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: - bom_item: The Part object - output: The particular build output (StockItem) + output: StockItem object + + To determine if the output has been fully allocated, + we need to test all "trackable" BuildLine objects """ - quantity = bom_item.quantity - if output: - quantity *= output.quantity - else: - quantity *= self.quantity - - return quantity - - def allocated_bom_items(self, bom_item, output=None): - """Return all BuildItem objects which allocate stock of to . - - 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( - build=self, - bom_item=bom_item, - 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( - q=Coalesce( - Sum('quantity'), - 0, - output_field=models.DecimalField(), + for line in self.build_lines.filter(bom_item__sub_part__trackable=True): + # Grab all BuildItem objects which point to this output + allocations = BuildItem.objects.filter( + build_line=line, + install_into=output, ) - ) - return allocated['q'] + allocated = allocations.aggregate( + q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField()) + ) - 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): + # The amount allocated against an output must at least equal the BOM quantity + if allocated['q'] < line.bom_item.quantity: return False - # All parts must be fully allocated! + # At this stage, we can assume that the output is fully allocated return True - def is_partially_allocated(self, output): - """Returns True if the particular build output is (at least) partially 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 + def is_overallocated(self): + """Test if the BuildOrder has been over-allocated. - for bom_item in bom_items: + Returns: + True if any BuildLine has been over-allocated. + """ - if self.allocated_quantity(bom_item, output) > 0: + for line in self.build_lines.all(): + if line.is_overallocated(): 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 bom_item in bom_items: - if self.allocated_quantity(bom_item, output) > self.required_quantity(bom_item, output): - return True - - 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 def is_active(self): """Is this build active? @@ -1194,6 +1134,52 @@ class Build(MPTTModel, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models. """Returns True if the build status is 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') def after_save_build(sender, instance: Build, created: bool, **kwargs): @@ -1204,14 +1190,23 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs): from . import tasks as build_tasks - if created: - # A new Build has just been created + if instance: - # Run checks on required parts - InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance) + if created: + # A new Build has just been created - # Notify the responsible users that the build order has been created - InvenTree.helpers_model.notify_responsible(instance, sender, exclude=instance.issued_by) + # Generate initial BuildLine objects for the Build + instance.create_build_line_items() + + # Run checks on required parts + InvenTree.tasks.offload_task(build_tasks.check_build_stock, instance) + + # Notify the responsible users that the build order has been created + 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): @@ -1224,6 +1219,87 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment): 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): """A BuildItem links multiple StockItem objects to a Build. @@ -1231,16 +1307,16 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model): Attributes: 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 quantity: Number of units allocated install_into: Destination stock item (or None) """ class Meta: - """Serializer metaclass""" + """Model meta options""" unique_together = [ - ('build', 'stock_item', 'install_into'), + ('build_line', 'stock_item', 'install_into'), ] @staticmethod @@ -1303,8 +1379,10 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model): 'quantity': _('Quantity must be 1 for serialized stock') }) - except (stock.models.StockItem.DoesNotExist, part.models.Part.DoesNotExist): - pass + except stock.models.StockItem.DoesNotExist: + 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. @@ -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! """ - bom_item_valid = False + valid = False 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: - 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: 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 not bom_item_valid: + if not valid: if self.build and self.stock_item: ancestors = self.stock_item.part.get_ancestors(include_self=True, ascending=True) for idx, ancestor in enumerate(ancestors): - try: - bom_item = part.models.BomItem.objects.get(part=self.build.part, sub_part=ancestor) - except part.models.BomItem.DoesNotExist: - continue + build_line = BuildLine.objects.filter( + build=self.build, + bom_item__part=ancestor, + ) - # A matching BOM item has been found! - if idx == 0 or bom_item.allow_variants: - bom_item_valid = True - self.bom_item = bom_item - break + if build_line.exists(): + line = build_line.first() + + if idx == 0 or line.bom_item.allow_variants: + valid = True + self.build_line = line + break # BomItem did not exist or could not be validated. # Search for a new one - if not bom_item_valid: + if not valid: 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 def complete_allocation(self, user, notes=''): """Complete the allocation of this BuildItem into the output stock item. @@ -1431,21 +1521,10 @@ class BuildItem(InvenTree.models.MetadataMixin, models.Model): else: return InvenTree.helpers.getBlankThumbnail() - build = models.ForeignKey( - Build, - on_delete=models.CASCADE, - related_name='allocated_stock', - 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, + build_line = models.ForeignKey( + BuildLine, + on_delete=models.SET_NULL, null=True, + related_name='allocations', ) stock_item = models.ForeignKey( diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 193b9f6217..1bc881d629 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -4,8 +4,11 @@ from django.db import transaction from django.core.exceptions import ValidationError as DjangoValidationError 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.functions import Coalesce from rest_framework import serializers 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.serializers import StockItemSerializerBrief, LocationSerializer -from part.models import BomItem -from part.serializers import PartSerializer, PartBriefSerializer +import part.filters +from part.serializers import BomItemSerializer, PartSerializer, PartBriefSerializer from users.serializers import OwnerSerializer -from .models import Build, BuildItem, BuildOrderAttachment +from .models import Build, BuildLine, BuildItem, BuildOrderAttachment class BuildSerializer(InvenTreeModelSerializer): @@ -170,7 +173,7 @@ class BuildOutputSerializer(serializers.Serializer): if to_complete: # 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 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'] return { - 'has_allocated_stock': build.is_partially_allocated(None), + 'has_allocated_stock': build.is_partially_allocated(), 'incomplete_outputs': build.incomplete_count, 'completed_outputs': build.complete_count, } @@ -621,8 +624,8 @@ class BuildCompleteSerializer(serializers.Serializer): build = self.context['build'] return { - 'overallocated': build.has_overallocated_parts(), - 'allocated': build.are_untracked_parts_allocated(), + 'overallocated': build.is_overallocated(), + 'allocated': build.is_fully_allocated(), 'remaining': build.remaining, 'incomplete': build.incomplete_count, } @@ -639,7 +642,7 @@ class BuildCompleteSerializer(serializers.Serializer): """Check if the 'accept_overallocated' field is required""" 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')) return value @@ -655,7 +658,7 @@ class BuildCompleteSerializer(serializers.Serializer): """Check if the 'accept_unallocated' field is required""" 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')) return value @@ -706,12 +709,12 @@ class BuildUnallocationSerializer(serializers.Serializer): - bom_item: Filter against a particular BOM line item """ - bom_item = serializers.PrimaryKeyRelatedField( - queryset=BomItem.objects.all(), + build_line = serializers.PrimaryKeyRelatedField( + queryset=BuildLine.objects.all(), many=False, allow_null=True, required=False, - label=_('BOM Item'), + label=_('Build Line'), ) output = serializers.PrimaryKeyRelatedField( @@ -742,8 +745,8 @@ class BuildUnallocationSerializer(serializers.Serializer): data = self.validated_data - build.unallocateStock( - bom_item=data['bom_item'], + build.deallocate_stock( + build_line=data['build_line'], output=data['output'] ) @@ -754,34 +757,34 @@ class BuildAllocationItemSerializer(serializers.Serializer): class Meta: """Serializer metaclass""" fields = [ - 'bom_item', + 'build_item', 'stock_item', 'quantity', 'output', ] - bom_item = serializers.PrimaryKeyRelatedField( - queryset=BomItem.objects.all(), + build_line = serializers.PrimaryKeyRelatedField( + queryset=BuildLine.objects.all(), many=False, allow_null=False, 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""" build = self.context['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 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 else: 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( queryset=StockItem.objects.all(), @@ -824,8 +827,7 @@ class BuildAllocationItemSerializer(serializers.Serializer): """Perform data validation for this item""" super().validate(data) - build = self.context['build'] - bom_item = data['bom_item'] + build_line = data['build_line'] stock_item = data['stock_item'] quantity = data['quantity'] output = data.get('output', None) @@ -847,20 +849,20 @@ class BuildAllocationItemSerializer(serializers.Serializer): }) # 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({ 'output': _('Build output must be specified for allocation of 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({ 'output': _('Build output cannot be specified for allocation of untracked parts'), }) # 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')) return data @@ -894,24 +896,21 @@ class BuildAllocationSerializer(serializers.Serializer): items = data.get('items', []) - build = self.context['build'] - with transaction.atomic(): for item in items: - bom_item = item['bom_item'] + build_line = item['build_line'] stock_item = item['stock_item'] quantity = item['quantity'] output = item.get('output', None) # Ignore allocation for consumable BOM items - if bom_item.consumable: + if build_line.bom_item.consumable: continue try: # Create a new BuildItem to allocate stock BuildItem.objects.create( - build=build, - bom_item=bom_item, + build_line=build_line, stock_item=stock_item, quantity=quantity, install_into=output @@ -993,43 +992,37 @@ class BuildItemSerializer(InvenTreeModelSerializer): model = BuildItem fields = [ 'pk', - 'bom_part', 'build', - 'build_detail', + 'build_line', 'install_into', - 'location', - 'location_detail', - 'part', - 'part_detail', 'stock_item', + 'quantity', + 'location_detail', + 'part_detail', 'stock_item_detail', - 'quantity' + 'build_detail', ] - bom_part = serializers.IntegerField(source='bom_item.sub_part.pk', read_only=True) - part = serializers.IntegerField(source='stock_item.part.pk', read_only=True) - location = serializers.IntegerField(source='stock_item.location.pk', read_only=True) + # Annotated fields + build = serializers.PrimaryKeyRelatedField(source='build_line.build', many=False, read_only=True) # Extra (optional) detail fields - part_detail = PartSerializer(source='stock_item.part', many=False, read_only=True) - build_detail = BuildSerializer(source='build', many=False, read_only=True) + part_detail = PartBriefSerializer(source='stock_item.part', many=False, read_only=True) stock_item_detail = StockItemSerializerBrief(source='stock_item', 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() def __init__(self, *args, **kwargs): """Determine which extra details fields should be included""" - build_detail = kwargs.pop('build_detail', False) - part_detail = kwargs.pop('part_detail', False) - location_detail = kwargs.pop('location_detail', False) + part_detail = kwargs.pop('part_detail', True) + location_detail = kwargs.pop('location_detail', True) stock_detail = kwargs.pop('stock_detail', False) + build_detail = kwargs.pop('build_detail', False) super().__init__(*args, **kwargs) - if not build_detail: - self.fields.pop('build_detail') - if not part_detail: self.fields.pop('part_detail') @@ -1039,6 +1032,144 @@ class BuildItemSerializer(InvenTreeModelSerializer): if not stock_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): """Serializer for a BuildAttachment.""" diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 57c5c23470..fc692408c6 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -174,7 +174,7 @@ src="{% static 'img/blank_image.png' %}" {% else %} {% endif %} - {% trans "Completed" %} + {% trans "Completed Outputs" %} {% progress_bar build.completed build.quantity id='build-completed' max_width='150px' %} {% if build.parent %} diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index ea870d8c44..14c75be201 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -64,10 +64,10 @@ - {% trans "Completed" %} + {% trans "Completed Outputs" %} {% progress_bar build.completed build.quantity id='build-completed-2' max_width='150px' %} - {% if build.active and has_untracked_bom_items %} + {% if build.active %} {% trans "Allocated Parts" %} @@ -179,9 +179,9 @@

{% trans "Allocate Stock to Build" %}

{% include "spacer.html" %}
- {% if roles.build.add and build.active and has_untracked_bom_items %} -
- {% if has_untracked_bom_items %} {% if build.active %} - {% if build.are_untracked_parts_allocated %} + {% if build.is_fully_allocated %}
{% trans "Untracked stock has been fully allocated for this Build Order" %}
@@ -211,22 +210,17 @@
{% endif %} {% endif %} -
+
- {% include "filter_list.html" with id='builditems' %} + {% include "filter_list.html" with id='buildlines' %}
-
- {% else %} -
- {% trans "This Build Order does not have any associated untracked BOM items" %} -
- {% endif %} +
@@ -427,38 +421,15 @@ onPanelLoad('outputs', function() { {% 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() { - loadUntrackedStockTable(); + // Load the table of line items for this build order + loadBuildLineTable( + "#build-lines-table", + {{ build.pk }}, + {} + ); }); -{% endif %} - $('#btn-create-output').click(function() { createBuildOutput( @@ -480,66 +451,62 @@ $("#btn-auto-allocate").on('click', function() { {% if build.take_from %} location: {{ build.take_from.pk }}, {% 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) { - if (bom_item.required > bom_item.allocated) { - incomplete_bom_items.push(bom_item); + data.forEach(function(line) { + if (line.allocated < line.quantity) { + unallocated_lines.push(line); } }); - if (incomplete_bom_items.length == 0) { + if (unallocated_lines.length == 0) { showAlertDialog( '{% trans "Allocation Complete" %}', - '{% trans "All untracked stock items have been allocated" %}', + '{% trans "All lines have been fully allocated" %}', ); } else { allocateStockToBuild( {{ build.pk }}, - {{ build.part.pk }}, - incomplete_bom_items, + unallocated_lines, { {% if build.take_from %} source_location: {{ build.take_from.pk }}, {% endif %} - success: loadUntrackedStockTable, + success: function() { + $('#build-lines-table').bootstrapTable('refresh'); + }, } ); } -}); +} $('#btn-unallocate').on('click', function() { - unallocateStock({{ build.id }}, { + deallocateStock({{ build.id }}, { table: '#allocation-table-untracked', - onSuccess: loadUntrackedStockTable, + onSuccess: function() { + $('#build-lines-table').bootstrapTable('refresh'); + }, }); }); $('#allocate-selected-items').click(function() { + allocateSelectedLines(); +}); - var bom_items = getTableData('#allocation-table-untracked'); - - allocateStockToBuild( - {{ build.pk }}, - {{ build.part.pk }}, - bom_items, - { - {% if build.take_from %} - source_location: {{ build.take_from.pk }}, - {% endif %} - success: loadUntrackedStockTable, - } - ); +$("#btn-allocate").on('click', function() { + allocateSelectedLines(); }); {% endif %} diff --git a/InvenTree/build/templates/build/sidebar.html b/InvenTree/build/templates/build/sidebar.html index a7b7b15df7..c038b7782a 100644 --- a/InvenTree/build/templates/build/sidebar.html +++ b/InvenTree/build/templates/build/sidebar.html @@ -4,18 +4,16 @@ {% trans "Build Order Details" as text %} {% 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 %} {% 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 %} {% include "sidebar_item.html" with label='outputs' text=text icon="fa-tools" %} {% endif %} {% trans "Completed Outputs" as text %} {% 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 %} {% include "sidebar_item.html" with label='children' text=text icon="fa-sitemap" %} {% trans "Attachments" as text %} diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index b998ad3cae..c01f633fad 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -582,6 +582,9 @@ class BuildAllocationTest(BuildAPITest): 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 self.n = BuildItem.objects.count() @@ -593,7 +596,7 @@ class BuildAllocationTest(BuildAPITest): self.assertEqual(self.build.part.bom_items.count(), 4) # 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): """A GET request to the endpoint should return an error.""" @@ -634,7 +637,7 @@ class BuildAllocationTest(BuildAPITest): { "items": [ { - "bom_item": 1, # M2x4 LPHS + "build_line": 1, # M2x4 LPHS "stock_item": 2, # 5,000 screws available } ] @@ -658,7 +661,7 @@ class BuildAllocationTest(BuildAPITest): expected_code=400 ).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 data = self.post( @@ -666,7 +669,7 @@ class BuildAllocationTest(BuildAPITest): { "items": [ { - "bom_item": 1, + "build_line": 1, "quantity": 5000, } ] @@ -681,12 +684,25 @@ class BuildAllocationTest(BuildAPITest): def test_invalid_bom_item(self): """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( self.url, { "items": [ { - "bom_item": 5, + "build_line": wrong_line.pk, "stock_item": 11, "quantity": 500, } @@ -695,19 +711,31 @@ class BuildAllocationTest(BuildAPITest): expected_code=400 ).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): """Test with valid data. 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.url, { "items": [ { - "bom_item": 1, + "build_line": right_line.pk, "stock_item": 2, "quantity": 5000, } @@ -749,16 +777,22 @@ class BuildOverallocationTest(BuildAPITest): cls.state = {} cls.allocation = {} - for i, bi in enumerate(cls.build.part.bom_items.all()): - rq = cls.build.required_quantity(bi, None) + i + 1 - si = StockItem.objects.filter(part=bi.sub_part, quantity__gte=rq).first() + items_to_create = [] - cls.state[bi.sub_part] = (si, si.quantity, rq) - BuildItem.objects.create( - build=cls.build, + for idx, build_line in enumerate(cls.build.build_lines.all()): + required = build_line.quantity + idx + 1 + 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, - quantity=rq, - ) + quantity=required, + )) + + BuildItem.objects.bulk_create(items_to_create) # create and complete outputs cls.build.create_build_output(cls.build.quantity) @@ -822,9 +856,10 @@ class BuildOverallocationTest(BuildAPITest): self.assertTrue(self.build.is_complete) # Check stock items have reduced only by bom requirement (overallocation trimmed) - for bi in self.build.part.bom_items.all(): - si, oq, _ = self.state[bi.sub_part] - rq = self.build.required_quantity(bi, None) + for line in self.build.build_lines.all(): + + si, oq, _ = self.state[line.bom_item.sub_part] + rq = line.quantity si.refresh_from_db() self.assertEqual(si.quantity, oq - rq) diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 6d59a65e54..26659d82f0 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -13,7 +13,7 @@ from InvenTree import status_codes as status import common.models 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 stock.models import StockItem from users.models import Owner @@ -107,6 +107,11 @@ class BuildTestBase(TestCase): 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 cls.output_1 = StockItem.objects.create( part=cls.assembly, @@ -248,13 +253,10 @@ class BuildTest(BuildTestBase): for output in self.build.get_build_outputs().all(): 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.build.is_bom_item_allocated(self.bom_item_2, self.output_2)) + self.assertFalse(self.line_1.is_fully_allocated()) + self.assertFalse(self.line_2.is_overallocated()) - self.assertEqual(self.build.unallocated_quantity(self.bom_item_1, self.output_1), 15) - 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.assertEqual(self.line_1.allocated_quantity(), 0) self.assertFalse(self.build.is_complete) @@ -264,25 +266,25 @@ class BuildTest(BuildTestBase): stock = StockItem.objects.create(part=self.assembly, quantity=99) # 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): b.save() # 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): b.clean() # 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): b.clean() # 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() def test_duplicate_bom_line(self): @@ -302,13 +304,24 @@ class BuildTest(BuildTestBase): allocations: Map of {StockItem: quantity} """ + items_to_create = [] + for item, quantity in allocations.items(): - BuildItem.objects.create( + + # Find an appropriate BuildLine to allocate against + line = BuildLine.objects.filter( build=self.build, + bom_item__sub_part=item.part + ).first() + + items_to_create.append(BuildItem( + build_line=line, stock_item=item, quantity=quantity, install_into=output - ) + )) + + BuildItem.objects.bulk_create(items_to_create) def test_partial_allocation(self): """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 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 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( None, @@ -357,17 +371,17 @@ class BuildTest(BuildTestBase): self.assertFalse(self.build.is_fully_allocated(None)) - unallocated = self.build.unallocated_bom_items(None) - - self.assertEqual(len(unallocated), 1) - - self.build.unallocateStock() - - unallocated = self.build.unallocated_bom_items(None) + unallocated = self.build.unallocated_lines() 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.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): """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.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_2, None) @@ -587,7 +601,7 @@ class BuildTest(BuildTestBase): """Unit tests for the metadata field.""" # 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() for model in [Build, BuildItem]: @@ -644,7 +658,7 @@ class AutoAllocationTests(BuildTestBase): # No build item allocations have been made against the build 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 self.build.auto_allocate_stock( @@ -652,15 +666,15 @@ class AutoAllocationTests(BuildTestBase): 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.assertFalse(self.build.is_bom_item_allocated(self.bom_item_1)) - self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) + self.assertFalse(self.line_1.is_fully_allocated()) + self.assertFalse(self.line_2.is_fully_allocated()) - self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 50) - self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 30) + self.assertEqual(self.line_1.unallocated_quantity(), 50) + self.assertEqual(self.line_2.unallocated_quantity(), 30) # This time we expect stock to be allocated! self.build.auto_allocate_stock( @@ -669,15 +683,15 @@ class AutoAllocationTests(BuildTestBase): 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.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1)) - self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) + self.assertTrue(self.line_1.is_fully_allocated()) + self.assertFalse(self.line_2.is_fully_allocated()) - self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) - self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5) + self.assertEqual(self.line_1.unallocated_quantity(), 0) + self.assertEqual(self.line_2.unallocated_quantity(), 5) # This time, allow substitute parts to be used! self.build.auto_allocate_stock( @@ -685,12 +699,11 @@ class AutoAllocationTests(BuildTestBase): substitutes=True, ) - # self.assertEqual(self.build.allocated_stock.count(), 8) - self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0) - self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 5.0) + self.assertEqual(self.line_1.unallocated_quantity(), 0) + self.assertEqual(self.line_2.unallocated_quantity(), 5) - self.assertTrue(self.build.is_bom_item_allocated(self.bom_item_1)) - self.assertFalse(self.build.is_bom_item_allocated(self.bom_item_2)) + self.assertTrue(self.line_1.is_fully_allocated()) + self.assertFalse(self.line_2.is_fully_allocated()) def test_fully_auto(self): """We should be able to auto-allocate against a build in a single go""" @@ -701,7 +714,7 @@ class AutoAllocationTests(BuildTestBase): 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.build.unallocated_quantity(self.bom_item_2), 0) + self.assertEqual(self.line_1.unallocated_quantity(), 0) + self.assertEqual(self.line_2.unallocated_quantity(), 0) diff --git a/InvenTree/build/test_migrations.py b/InvenTree/build/test_migrations.py index 1072e2ae61..af094db7f2 100644 --- a/InvenTree/build/test_migrations.py +++ b/InvenTree/build/test_migrations.py @@ -158,3 +158,139 @@ class TestReferencePatternMigration(MigratorTestCase): pattern = Setting.objects.get(key='BUILDORDER_REFERENCE_PATTERN') 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, + ) diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 4de664d2a1..36422e1688 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -39,7 +39,5 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView): part = build.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 diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 1de77c4245..030630778f 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -122,8 +122,13 @@ class CurrencyExchangeView(APIView): # Information on last update try: - backend = ExchangeBackend.objects.get(name='InvenTreeExchange') - updated = backend.last_update + backend = ExchangeBackend.objects.filter(name='InvenTreeExchange') + + if backend.exists(): + backend = backend.first() + updated = backend.last_update + else: + updated = None except Exception: updated = None diff --git a/InvenTree/common/settings.py b/InvenTree/common/settings.py index 0c84e51ee7..205f13ee00 100644 --- a/InvenTree/common/settings.py +++ b/InvenTree/common/settings.py @@ -1,9 +1,13 @@ """User-configurable settings for the common app.""" +import logging + from django.conf import settings from moneyed import CURRENCIES +logger = logging.getLogger('inventree') + def currency_code_default(): """Returns the default currency code (or USD if not specified)""" diff --git a/InvenTree/company/migrations/0025_auto_20201110_1001.py b/InvenTree/company/migrations/0025_auto_20201110_1001.py index f2fa767d81..117710daa7 100644 --- a/InvenTree/company/migrations/0025_auto_20201110_1001.py +++ b/InvenTree/company/migrations/0025_auto_20201110_1001.py @@ -8,6 +8,7 @@ import common.settings class Migration(migrations.Migration): dependencies = [ + ('common', '0004_inventreesetting'), ('company', '0024_unique_name_email_constraint'), ] diff --git a/InvenTree/company/migrations/0039_auto_20210701_0509.py b/InvenTree/company/migrations/0039_auto_20210701_0509.py index 094c7a5009..74dadcc340 100644 --- a/InvenTree/company/migrations/0039_auto_20210701_0509.py +++ b/InvenTree/company/migrations/0039_auto_20210701_0509.py @@ -8,6 +8,7 @@ import djmoney.models.fields class Migration(migrations.Migration): dependencies = [ + ('common', '0004_inventreesetting'), ('company', '0038_manufacturerpartparameter'), ] diff --git a/InvenTree/company/migrations/0051_alter_supplierpricebreak_price.py b/InvenTree/company/migrations/0051_alter_supplierpricebreak_price.py index 9786ff618b..11f8d554be 100644 --- a/InvenTree/company/migrations/0051_alter_supplierpricebreak_price.py +++ b/InvenTree/company/migrations/0051_alter_supplierpricebreak_price.py @@ -8,6 +8,7 @@ import djmoney.models.validators class Migration(migrations.Migration): dependencies = [ + ('common', '0004_inventreesetting'), ('company', '0050_alter_company_website'), ] diff --git a/InvenTree/order/migrations/0038_auto_20201112_1737.py b/InvenTree/order/migrations/0038_auto_20201112_1737.py index cdad4fe72f..121bdbe64f 100644 --- a/InvenTree/order/migrations/0038_auto_20201112_1737.py +++ b/InvenTree/order/migrations/0038_auto_20201112_1737.py @@ -8,6 +8,7 @@ import common.settings class Migration(migrations.Migration): dependencies = [ + ('common', '0004_inventreesetting'), ('order', '0037_auto_20201110_0911'), ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index f7301e2894..f9992b4d83 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -833,10 +833,10 @@ class SalesOrder(TotalPriceMixin, Order): return True - def is_over_allocated(self): + def is_overallocated(self): """Return true if any lines in the order are over-allocated.""" for line in self.lines.all(): - if line.is_over_allocated(): + if line.is_overallocated(): return True return False @@ -1358,7 +1358,7 @@ class SalesOrderLineItem(OrderLineItem): 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 self.allocated_quantity() > self.quantity diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index 77c4a900aa..7d787b91ff 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -102,7 +102,7 @@ class SalesOrderTest(TestCase): self.assertEqual(self.line.allocated_quantity(), 0) self.assertEqual(self.line.fulfilled_quantity(), 0) 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.assertFalse(self.order.is_fully_allocated()) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 4af7ed98dd..a65a8bf583 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -350,7 +350,10 @@ class PartTestTemplateDetail(RetrieveUpdateDestroyAPI): 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() serializer_class = part_serializers.PartTestTemplateSerializer @@ -945,6 +948,28 @@ class PartFilter(rest_filters.FilterSet): else: 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() assembly = rest_filters.BooleanFilter() @@ -1181,32 +1206,6 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): except (ValueError, PartCategory.DoesNotExist): 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) return queryset diff --git a/InvenTree/part/filters.py b/InvenTree/part/filters.py index 70555a0a78..dbdc907bfd 100644 --- a/InvenTree/part/filters.py +++ b/InvenTree/part/filters.py @@ -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 = ''): """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 = Q(build__status__in=BuildStatusGroups.ACTIVE_CODES) + build_filter = Q(build_line__build__status__in=BuildStatusGroups.ACTIVE_CODES) return Coalesce( SubquerySum( diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 39cbf74ccb..a25a919520 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -100,7 +100,7 @@ salable: true purchaseable: false category: 7 - active: False + active: True IPN: BOB revision: A2 tree_id: 0 diff --git a/InvenTree/part/migrations/0055_auto_20201110_1001.py b/InvenTree/part/migrations/0055_auto_20201110_1001.py index 5c31eb3250..911766ad6d 100644 --- a/InvenTree/part/migrations/0055_auto_20201110_1001.py +++ b/InvenTree/part/migrations/0055_auto_20201110_1001.py @@ -8,6 +8,7 @@ import common.settings class Migration(migrations.Migration): dependencies = [ + ('common', '0004_inventreesetting'), ('part', '0054_auto_20201109_1246'), ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8e65f95e2f..83c2c63de5 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -10,6 +10,7 @@ import re from datetime import datetime, timedelta from decimal import Decimal, InvalidOperation +from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator, MinValueValidator @@ -1747,7 +1748,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) 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. Importantly, catches any errors which may occur during deletion of related objects, @@ -1757,6 +1758,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) Arguments: 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: @@ -1768,7 +1770,7 @@ class Part(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, MPTTModel) pricing = self.pricing if create or pricing.pk: - pricing.schedule_for_update() + pricing.schedule_for_update(test=test) except IntegrityError: # If this part instance has been deleted, # some post-delete or post-save signals may still be fired @@ -2360,11 +2362,15 @@ class PartPricing(common.models.MetaMixin): 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""" 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 InvenTree.ready.isImportingData(): return @@ -3720,7 +3726,7 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): Includes: - The referenced sub_part - - Any directly specvified substitute parts + - Any directly specified substitute parts - If allow_variants is True, all variants of sub_part """ # Set of parts we will allow @@ -3741,11 +3747,6 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): valid_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 if p.trackable != self.sub_part.trackable: continue @@ -3990,10 +3991,10 @@ class BomItem(DataImportMixin, MetadataMixin, models.Model): # Base quantity requirement base_quantity = self.quantity * build_quantity - # Overage requiremet - ovrg_quantity = self.get_overage_quantity(base_quantity) + # Overage requirement + overage_quantity = self.get_overage_quantity(base_quantity) - required = float(base_quantity) + float(ovrg_quantity) + required = float(base_quantity) + float(overage_quantity) return required diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 2a209633d4..512c5cd08b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -410,8 +410,6 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize partial = True fields = [ 'active', - 'allocated_to_build_orders', - 'allocated_to_sales_orders', 'assembly', 'barcode_hash', 'category', @@ -423,9 +421,6 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize 'description', 'full_name', 'image', - 'in_stock', - 'ordering', - 'building', 'IPN', 'is_template', 'keywords', @@ -441,20 +436,28 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize 'revision', 'salable', 'starred', - 'stock_item_count', - 'suppliers', 'thumbnail', - 'total_in_stock', 'trackable', - 'unallocated_stock', 'units', 'variant_of', - 'variant_stock', 'virtual', 'pricing_min', 'pricing_max', '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 'duplicate', '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( ordering=part.filters.annotate_on_order_quantity(), 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 def get_starred(self, part): @@ -587,17 +598,18 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize # Extra detail for the category 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_sales_orders = serializers.FloatField(read_only=True) - unallocated_stock = serializers.FloatField(read_only=True) building = 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) + required_for_build_orders = serializers.IntegerField(read_only=True) stock_item_count = 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) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 2d66770318..5837493f2a 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1997,10 +1997,14 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): 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 build.models.BuildItem.objects.create( - build=bo, - bom_item=bom_item, + build_line=line, stock_item=StockItem.objects.get(pk=1000), quantity=10, ) @@ -2021,8 +2025,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): # Allocate further stock against the build build.models.BuildItem.objects.create( - build=bo, - bom_item=bom_item, + build_line=line, stock_item=StockItem.objects.get(pk=1001), quantity=10, ) diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index 111f53125a..2d404b5ef9 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -144,7 +144,15 @@ class CategoryTest(TestCase): self.assertEqual(self.electronics.partcount(), 3) 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(False), 7) self.assertEqual(self.electronics.item_count, self.electronics.partcount()) diff --git a/InvenTree/part/test_pricing.py b/InvenTree/part/test_pricing.py index 18dc5c170a..e0f975dfb3 100644 --- a/InvenTree/part/test_pricing.py +++ b/InvenTree/part/test_pricing.py @@ -444,11 +444,6 @@ class PartPricingTests(InvenTreeTestCase): # Check that PartPricing objects have been created 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): """Test deleting a part instance with stock items. @@ -473,6 +468,9 @@ class PartPricingTests(InvenTreeTestCase): 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 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()) # 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()) diff --git a/InvenTree/plugin/base/event/events.py b/InvenTree/plugin/base/event/events.py index 5073afbceb..f2b28a9c3e 100644 --- a/InvenTree/plugin/base/event/events.py +++ b/InvenTree/plugin/base/event/events.py @@ -95,10 +95,14 @@ def allow_table_event(table_name): We *do not* want events to be fired for some tables! """ + # Prevent table events during the data import process if isImportingData(): - # Prevent table events during the data import process 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() # Ignore any tables which start with these prefixes diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 7e5567ba9b..5595a346d7 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -392,6 +392,8 @@ class BuildReport(ReportTemplateBase): return { 'build': my_build, '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(), 'reference': my_build.reference, 'quantity': my_build.quantity, diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 3a45debe9c..22358cfabb 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -21,7 +21,7 @@ import stock.serializers as StockSerializers from build.models import Build from build.serializers import BuildSerializer from company.models import Company, SupplierPart -from company.serializers import CompanySerializer, SupplierPartSerializer +from company.serializers import CompanySerializer from generic.states import StatusView from InvenTree.api import (APIDownloadMixin, AttachmentMixin, ListCreateDestroyAPIView, MetadataView) @@ -553,6 +553,28 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): queryset = StockItem.objects.all() 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): """Extend serializer context.""" ctx = super().get_serializer_context() @@ -743,8 +765,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): """ queryset = self.filter_queryset(self.get_queryset()) - params = request.query_params - page = self.paginate_queryset(queryset) if page is not None: @@ -754,78 +774,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): 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. a) For HTTP requests (e.g. via the browsable API) return a DRF response @@ -852,6 +800,7 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): 'part', 'part__category', 'location', + 'test_results', 'tags', ) diff --git a/InvenTree/stock/migrations/0053_auto_20201110_0513.py b/InvenTree/stock/migrations/0053_auto_20201110_0513.py index 4462eb44cb..0b0d6cbc5a 100644 --- a/InvenTree/stock/migrations/0053_auto_20201110_0513.py +++ b/InvenTree/stock/migrations/0053_auto_20201110_0513.py @@ -8,6 +8,7 @@ import common.settings class Migration(migrations.Migration): dependencies = [ + ('common', '0004_inventreesetting'), ('stock', '0052_stockitem_is_building'), ] diff --git a/InvenTree/stock/migrations/0065_auto_20210701_0509.py b/InvenTree/stock/migrations/0065_auto_20210701_0509.py index 99cde1e7f7..5aedb7c6da 100644 --- a/InvenTree/stock/migrations/0065_auto_20210701_0509.py +++ b/InvenTree/stock/migrations/0065_auto_20210701_0509.py @@ -4,6 +4,22 @@ import InvenTree.fields from django.db import migrations 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): @@ -11,7 +27,13 @@ class Migration(migrations.Migration): ('stock', '0064_auto_20210621_1724'), ] - operations = [ + operations = [] + + xoperations = [ + migrations.RunPython( + code=show_migrations, + reverse_code=migrations.RunPython.noop + ), migrations.AlterField( model_name='stockitem', name='purchase_price', diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index d048a82977..103622ee73 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -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): """Brief serializers for a StockItem.""" @@ -126,6 +170,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'purchase_price', 'purchase_price_currency', 'use_pack_size', + 'tests', 'tags', ] @@ -234,11 +279,11 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 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) - part_detail = PartBriefSerializer(source='part', 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() @@ -266,18 +311,22 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): part_detail = kwargs.pop('part_detail', False) location_detail = kwargs.pop('location_detail', False) supplier_part_detail = kwargs.pop('supplier_part_detail', False) + tests = kwargs.pop('tests', False) super(StockItemSerializer, self).__init__(*args, **kwargs) - if part_detail is not True: + if not part_detail: self.fields.pop('part_detail') - if location_detail is not True: + if not 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') + if not tests: + self.fields.pop('tests') + class SerializeStockItemSerializer(serializers.Serializer): """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): """Serializer for StockItemTracking model.""" diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 2daee5d748..e6841b9217 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -972,7 +972,7 @@ function loadBomTable(table, options={}) { } if (row.overage) { - text += ` (${row.overage}) `; + text += ` (+${row.overage})`; } return text; @@ -1161,6 +1161,8 @@ function loadBomTable(table, options={}) { } } + text = renderLink(text, url); + if (row.on_order && row.on_order > 0) { text += makeIconBadge( 'fa-shopping-cart', @@ -1168,7 +1170,7 @@ function loadBomTable(table, options={}) { ); } - return renderLink(text, url); + return text; } }); diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 52d412fb67..9b40bcd87b 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -13,7 +13,7 @@ formatDecimal, FullCalendar, getFormFieldValue, - getTableData,0 + getTableData, handleFormErrors, handleFormSuccess, imageHoverIcon, @@ -24,6 +24,7 @@ launchModalForm, linkButtonsToSelection, loadTableFilters, + locationDetail, makeDeleteButton, makeEditButton, makeRemoveButton, @@ -55,9 +56,8 @@ createBuildOutput, duplicateBuildOrder, editBuildOrder, - loadAllocationTable, + loadBuildLineTable, loadBuildOrderAllocationTable, - loadBuildOutputAllocationTable, loadBuildOutputTable, loadBuildTable, */ @@ -191,7 +191,6 @@ function duplicateBuildOrder(build_id, options={}) { } - /* Construct a form to cancel a build order */ function cancelBuildOrder(build_id, options={}) { @@ -397,7 +396,7 @@ function makeBuildOutputButtons(output_id, build_info, options={}) { var html = ''; // Tracked parts? Must be individually allocated - if (options.has_bom_items) { + if (options.has_tracked_lines) { // Add a button to allocate stock against this build output html += makeIconButton( @@ -405,17 +404,14 @@ function makeBuildOutputButtons(output_id, build_info, options={}) { 'button-output-allocate', output_id, '{% trans "Allocate stock items to this build output" %}', - { - disabled: true, - } ); - // Add a button to unallocate stock from this build output + // Add a button to deallocate stock from this build output html += makeIconButton( 'fa-minus-circle icon-red', - 'button-output-unallocate', + 'button-output-deallocate', output_id, - '{% trans "Unallocate stock from build output" %}', + '{% trans "Deallocate stock from build output" %}', ); } @@ -447,19 +443,19 @@ function makeBuildOutputButtons(output_id, build_info, options={}) { /* - * Unallocate stock against a particular build order + * Deallocate stock against a particular build order * * Options: * - output: pk value for a stock item "build output" * - bom_item: pk value for a particular BOMItem (build item) */ -function unallocateStock(build_id, options={}) { +function deallocateStock(build_id, options={}) { var url = `{% url "api-build-list" %}${build_id}/unallocate/`; var html = `
- {% trans "Are you sure you wish to unallocate stock items from this build?" %} + {% trans "Are you sure you wish to deallocate the selected stock items from this build?" %} `; @@ -472,12 +468,12 @@ function unallocateStock(build_id, options={}) { hidden: true, value: options.output, }, - bom_item: { + build_line: { hidden: true, - value: options.bom_item, + value: options.build_line, }, }, - title: '{% trans "Unallocate Stock Items" %}', + title: '{% trans "Deallocate Stock Items" %}', onSuccess: function(response, opts) { if (options.onSuccess) { options.onSuccess(response, opts); @@ -928,6 +924,7 @@ function loadBuildOrderAllocationTable(table, options={}) { name: 'buildorderallocation', groupBy: false, search: false, + sortable: true, paginationVAlign: 'bottom', original: options.params, formatNoMatches: function() { @@ -941,29 +938,35 @@ function loadBuildOrderAllocationTable(table, options={}) { }, { field: 'build', + sortable: true, switchable: false, title: '{% trans "Build Order" %}', formatter: function(value, row) { + let ref = `${row.build_detail.reference}`; + let html = renderLink(ref, `/build/${row.build}/`); - var ref = `${row.build_detail.reference}`; + html += `- ${row.build_detail.title}`; - return renderLink(ref, `/build/${row.build}/`); + html += buildStatusDisplay(row.build_detail.status, { + classes: 'float-right', + }); + + return html; } }, { - field: 'item', - title: '{% trans "Stock Item" %}', + field: 'quantity', + sortable: true, + title: '{% trans "Allocated Quantity" %}', formatter: function(value, row) { - // Render a link to the particular stock item - - var link = `/stock/item/${row.stock_item}/`; - var text = `{% trans "Stock Item" %} ${row.stock_item}`; + let link = `/stock/item/${row.stock_item}/`; + let text = formatDecimal(value); return renderLink(text, link); } }, { - field: 'location', + field: 'location_detail', title: '{% trans "Location" %}', formatter: function(value, row) { @@ -971,55 +974,24 @@ function loadBuildOrderAllocationTable(table, options={}) { return '{% trans "Location not specified" %}'; } - var link = `/stock/location/${value}`; - var text = row.location_detail.description; + let item = row.stock_item_detail; + item.location_detail = row.location_detail; - return renderLink(text, link); + return locationDetail(item, true); } }, - { - field: 'quantity', - title: '{% trans "Quantity" %}', - sortable: true, - } ] }); } -/* Internal helper functions for performing calculations on BOM data */ - -// Iterate through a list of allocations, returning *only* those which match a particular BOM row -function getAllocationsForBomRow(bom_row, allocations) { - var part_id = bom_row.sub_part; - - var matching_allocations = []; - - allocations.forEach(function(allocation) { - if (allocation.bom_part == part_id) { - matching_allocations.push(allocation); - } - }); - - return matching_allocations; -} - -// Sum the allocation quantity for a given BOM row -function sumAllocationsForBomRow(bom_row, allocations) { - var quantity = 0; - - getAllocationsForBomRow(bom_row, allocations).forEach(function(allocation) { - quantity += allocation.quantity; - }); - - return formatDecimal(quantity, 10); -} - - /* * Display a "build output" table for a particular build. * - * This displays a list of "active" (i.e. "in production") build outputs for a given build + * This displays a list of "active" (i.e. "in production") build outputs (stock items) for a given build. + * + * - Any required tests are displayed here for each output + * - Additionally, if any tracked items are present in the build, the allocated items are displayed * */ function loadBuildOutputTable(build_info, options={}) { @@ -1028,8 +1000,15 @@ function loadBuildOutputTable(build_info, options={}) { var params = options.params || {}; + // test templates for the part being assembled + let test_templates = null; + + // tracked line items for this build + let has_tracked_lines = false; + // Mandatory query filters params.part_detail = true; + params.tests = true; params.is_building = true; params.build = build_info.pk; @@ -1048,320 +1027,152 @@ function loadBuildOutputTable(build_info, options={}) { plural_name: '{% trans "build outputs" %}', }); - function setupBuildOutputButtonCallbacks() { - - // Callback for the "allocate" button - $(table).find('.button-output-allocate').click(function() { - var pk = $(this).attr('pk'); - - // Find the "allocation" sub-table associated with this output - var subtable = $(`#output-sub-table-${pk}`); - - if (subtable.exists()) { - var rows = getTableData(`#output-sub-table-${pk}`); - - allocateStockToBuild( - build_info.pk, - build_info.part, - rows, - { - output: pk, - success: function() { - $(table).bootstrapTable('refresh'); - } - } - ); - } else { - console.warn(`Could not locate sub-table for output ${pk}`); - } - }); - - // Callback for the "unallocate" button - $(table).find('.button-output-unallocate').click(function() { - var pk = $(this).attr('pk'); - - unallocateStock(build_info.pk, { - output: pk, - table: table - }); - }); - - // Callback for the "complete" button - $(table).find('.button-output-complete').click(function() { - var pk = $(this).attr('pk'); - - var output = $(table).bootstrapTable('getRowByUniqueId', pk); - - completeBuildOutputs( - build_info.pk, - [ - output, - ], - { - success: function() { - $(table).bootstrapTable('refresh'); - $('#build-stock-table').bootstrapTable('refresh'); - } - } - ); - }); - - // Callback for the "scrap" button - $(table).find('.button-output-scrap').click(function() { - var pk = $(this).attr('pk'); - var output = $(table).bootstrapTable('getRowByUniqueId', pk); - - scrapBuildOutputs( - build_info.pk, - [output], - { - success: function() { - $(table).bootstrapTable('refresh'); - $('#build-stock-table').bootstrapTable('refresh'); - } - } - ); - }); - - // Callback for the "remove" button - $(table).find('.button-output-remove').click(function() { - var pk = $(this).attr('pk'); - - var output = $(table).bootstrapTable('getRowByUniqueId', pk); - - deleteBuildOutputs( - build_info.pk, - [ - output, - ], - { - success: function() { - $(table).bootstrapTable('refresh'); - $('#build-stock-table').bootstrapTable('refresh'); - } - } - ); - }); - } - - // List of "tracked bom items" required for this build order - var bom_items = null; - - // Request list of BOM data for this build order + // Request list of required tests for the part being assembled inventreeGet( - '{% url "api-bom-list" %}', + '{% url "api-part-test-template-list" %}', { part: build_info.part, - sub_part_detail: true, - sub_part_trackable: true, }, { async: false, success: function(response) { - // Save the BOM items - bom_items = response; + test_templates = []; + response.forEach(function(item) { + // Only include "required" tests + if (item.required) { + test_templates.push(item); + } + }); } } ); - /* - * Construct a "sub table" showing the required BOM items - */ - function constructBuildOutputSubTable(index, row, element) { - var sub_table_id = `output-sub-table-${row.pk}`; - - var html = ` -
-
-
- `; - - element.html(html); - - // Pass through the cached BOM items - build_info.bom_items = bom_items; - - loadBuildOutputAllocationTable( - build_info, - row, - { - table: `#${sub_table_id}`, - parent_table: table, - } - ); - } - - function updateAllocationData(rows) { - // Update stock allocation information for the build outputs - - // Request updated stock allocation data for this build order + // Callback function to load the allocated stock items + function reloadOutputAllocations() { inventreeGet( - '{% url "api-build-item-list" %}', + '{% url "api-build-line-list" %}', { build: build_info.pk, - part_detail: true, - location_detail: true, - sub_part_trackable: true, tracked: true, }, { success: function(response) { + let build_lines = response.results || response; + let table_data = $(table).bootstrapTable('getData'); - // Group allocation information by the "install_into" field - var allocations = {}; + has_tracked_lines = build_lines.length > 0; - response.forEach(function(allocation) { - var target = allocation.install_into; + /* Iterate through each active build output and update allocations + * For each build output, we need to: + * - Append any existing allocations + * - Work out how many lines are "fully allocated" + */ + for (var ii = 0; ii < table_data.length; ii++) { + let output = table_data[ii]; - if (target != null) { - if (!(target in allocations)) { - allocations[target] = []; - } + let fully_allocated = 0; - allocations[target].push(allocation); - } - }); + // Construct a list of allocations for this output + let lines = []; - // Now that the allocations have been grouped by stock item, - // we can update each row in the table, - // using the pk value of each row (stock item) + // Iterate through each tracked build line item + for (let jj = 0; jj < build_lines.length; jj++) { - var data = []; + // Create a local copy of the build line + let line = Object.assign({}, build_lines[jj]); - rows.forEach(function(row) { - row.allocations = allocations[row.pk] || []; - data.push(row); + let required = line.bom_item_detail.quantity * output.quantity; - var n_completed_lines = 0; + let allocations = []; + let allocated = 0; - // Check how many BOM lines have been completely allocated for this build output - bom_items.forEach(function(bom_item) { + // Iterate through each allocation for this line item + for (let kk = 0; kk < line.allocations.length; kk++) { + let allocation = line.allocations[kk]; - var required_quantity = bom_item.quantity * row.quantity; - - if (sumAllocationsForBomRow(bom_item, row.allocations) >= required_quantity) { - n_completed_lines += 1; - } - - var output_progress_bar = $(`#output-progress-${row.pk}`); - - if (output_progress_bar.exists()) { - output_progress_bar.html( - makeProgressBar( - n_completed_lines, - bom_items.length, - { - max_width: '150px', - } - ) - ); - } - }); - }); - - // Reload table with updated data - $(table).bootstrapTable('load', data); - } - } - ); - } - - var part_tests = null; - - function updateTestResultData(rows) { - // Update test result information for the build outputs - - // Request test template data if it has not already been retrieved - if (part_tests == null) { - inventreeGet( - '{% url "api-part-test-template-list" %}', - { - part: build_info.part, - required: true, - }, - { - success: function(response) { - // Save the list of part tests - part_tests = response; - - // Callback to this function again - updateTestResultData(rows); - } - } - ); - - return; - } - - // Retrieve stock results for the entire build - inventreeGet( - '{% url "api-stock-test-result-list" %}', - { - build: build_info.pk, - ordering: '-date', - }, - { - success: function(results) { - - var data = []; - // Iterate through each row and find matching test results - rows.forEach(function(row) { - var test_results = {}; - - results.forEach(function(result) { - if (result.stock_item == row.pk) { - // This test result matches the particular stock item - - if (!(result.key in test_results)) { - test_results[result.key] = result.result; + if (allocation.install_into == output.pk) { + allocations.push(allocation); + allocated += allocation.quantity; } } - }); - row.passed_tests = test_results; + line.allocations = allocations; + line.allocated = allocated; + line.quantity = required; - data.push(row); - }); + if (allocated >= required) { + fully_allocated += 1; + } - $(table).bootstrapTable('load', data); + lines.push(line); + } + + // Push the row back in + output.lines = lines; + output.fully_allocated = fully_allocated; + table_data[ii] = output; + } + + // Update the table data + $(table).bootstrapTable('load', table_data); + + if (has_tracked_lines) { + $(table).bootstrapTable('showColumn', 'fully_allocated'); + } else { + $(table).bootstrapTable('hideColumn', 'fully_allocated'); + } } } ); } - // Return the number of 'passed' tests in a given row - function countPassedTests(row) { - if (part_tests == null) { - return 0; - } + // Callback function to construct a child table + function constructOutputSubTable(index, row, element) { + let sub_table_id = `output-table-${row.pk}`; - var results = row.passed_tests || {}; - var n = 0; + element.html(` +
+
+
+ `); - part_tests.forEach(function(test) { - if (results[test.key] || false) { - n += 1; + loadBuildLineTable( + `#${sub_table_id}`, + build_info.pk, + { + output: row.pk, + data: row.lines, + } + ); + } + + // Return the "passed test count" for a given row + function getPassedTestCount(row) { + let passed_tests = 0; + + // Iterate through the available test templates + test_templates.forEach(function(test) { + // Iterate through all the "test results" for the given stock item + // If the keys match, update the result + // As they are returned in order, the "latest" result is the one we use + + let final_result = false; + + row.tests.forEach(function(result) { + if (result.key == test.key) { + final_result = result.result; + } + }); + + if (final_result) { + passed_tests += 1; } }); - return n; - } - - // Return the number of 'fully allocated' lines for a given row - function countAllocatedLines(row) { - var n_completed_lines = 0; - - bom_items.forEach(function(bom_row) { - var required_quantity = bom_row.quantity * row.quantity; - - if (sumAllocationsForBomRow(bom_row, row.allocations || []) >= required_quantity) { - n_completed_lines += 1; - } - }); - - return n_completed_lines; + return passed_tests; } + // Now, construct the actual table $(table).inventreeTable({ url: '{% url "api-stock-list" %}', queryParams: filters, @@ -1372,23 +1183,18 @@ function loadBuildOutputTable(build_info, options={}) { sortable: true, search: true, sidePagination: 'client', - detailView: bom_items.length > 0, + detailView: true, detailFilter: function(index, row) { - return bom_items.length > 0; + return has_tracked_lines; }, detailFormatter: function(index, row, element) { - constructBuildOutputSubTable(index, row, element); + return constructOutputSubTable(index, row, element); }, formatNoMatches: function() { return '{% trans "No active build outputs found" %}'; }, - onPostBody: function(rows) { - // Add callbacks for the buttons - setupBuildOutputButtonCallbacks(); - }, - onLoadSuccess: function(rows) { - updateAllocationData(rows); - updateTestResultData(rows); + onLoadSuccess: function() { + reloadOutputAllocations(); }, buttons: constructExpandCollapseButtons(table), columns: [ @@ -1401,11 +1207,11 @@ function loadBuildOutputTable(build_info, options={}) { { field: 'part', title: '{% trans "Part" %}', - switchable: true, + switchable: false, formatter: function(value, row) { - var thumb = row.part_detail.thumbnail; - - return imageHoverIcon(thumb) + row.part_detail.full_name + makePartIcons(row.part_detail); + return imageHoverIcon(row.part_detail.thumbnail) + + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`) + + makePartIcons(row.part_detail); } }, { @@ -1414,18 +1220,19 @@ function loadBuildOutputTable(build_info, options={}) { switchable: false, sortable: true, formatter: function(value, row) { - - var url = `/stock/item/${row.pk}/`; - - var text = ''; + let text = ''; if (row.serial && row.quantity == 1) { text = `{% trans "Serial Number" %}: ${row.serial}`; } else { text = `{% trans "Quantity" %}: ${row.quantity}`; - if (row.part_detail && row.part_detail.units) { - text += ` ${row.part_detail.units}`; - } + + } + + text = renderLink(text, `/stock/item/${row.pk}/`); + + if (row.part_detail && row.part_detail.units) { + text += ` [${row.part_detail.units}]`; } if (row.batch) { @@ -1434,80 +1241,35 @@ function loadBuildOutputTable(build_info, options={}) { text += stockStatusDisplay(row.status, {classes: 'float-right'}); - return renderLink(text, url); - }, - sorter: function(a, b, row_a, row_b) { - // Sort first by quantity, and then by serial number - if ((row_a.quantity > 1) || (row_b.quantity > 1)) { - return row_a.quantity > row_b.quantity ? 1 : -1; - } - - if ((row_a.serial != null) && (row_b.serial != null)) { - var sn_a = Number.parseInt(row_a.serial) || 0; - var sn_b = Number.parseInt(row_b.serial) || 0; - - return sn_a > sn_b ? 1 : -1; - } - - return 0; + return text; } }, { - field: 'allocated', - title: '{% trans "Allocated Stock" %}', - visible: bom_items.length > 0, - switchable: false, + field: 'fully_allocated', + title: '{% trans "Allocated Lines" %}', + visible: false, sortable: true, + switchable: false, formatter: function(value, row) { - - if (bom_items.length == 0) { - return `
{% trans "No tracked BOM items for this build" %}
`; + if (!row.lines) { + return '-'; } - var progressBar = makeProgressBar( - countAllocatedLines(row), - bom_items.length, - { - max_width: '150px', - } - ); - - return `
${progressBar}
`; - }, - sorter: function(value_a, value_b, row_a, row_b) { - var q_a = countAllocatedLines(row_a); - var q_b = countAllocatedLines(row_b); - - return q_a > q_b ? 1 : -1; - }, + return makeProgressBar(row.fully_allocated, row.lines.length); + } }, { field: 'tests', - title: '{% trans "Completed Tests" %}', - sortable: true, + title: '{% trans "Required Tests" %}', + visible: test_templates.length > 0, switchable: true, formatter: function(value, row) { - if (part_tests == null || part_tests.length == 0) { - return `{% trans "No required tests for this build" %}`; + if (row.tests) { + return makeProgressBar( + getPassedTestCount(row), + test_templates.length + ); } - - var n_passed = countPassedTests(row); - - var progress = makeProgressBar( - n_passed, - part_tests.length, - { - max_width: '150px', - } - ); - - return progress; - }, - sorter: function(a, b, row_a, row_b) { - var n_a = countPassedTests(row_a); - var n_b = countPassedTests(row_b); - - return n_a > n_b ? 1 : -1; } }, { @@ -1519,39 +1281,92 @@ function loadBuildOutputTable(build_info, options={}) { row.pk, build_info, { - has_bom_items: bom_items.length > 0, + has_tracked_lines: has_tracked_lines, } - ); + ) } } ] }); - // Enable the "allocate" button when the sub-table is exanded - $(table).on('expand-row.bs.table', function(detail, index, row) { - $(`#button-output-allocate-${row.pk}`).prop('disabled', false); + /* Callbacks for the build output buttons */ + + // Allocate stock button + $(table).on('click', '.button-output-allocate', function() { + let pk = $(this).attr('pk'); + + // Retrieve build output row + let output = $(table).bootstrapTable('getRowByUniqueId', pk); + let lines = output.lines || []; + + allocateStockToBuild( + build_info.pk, + lines, + { + output: pk, + success: function() { + $(table).bootstrapTable('refresh'); + $('#build-stock-table').bootstrapTable('refresh'); + } + } + ); }); - // Disable the "allocate" button when the sub-table is collapsed - $(table).on('collapse-row.bs.table', function(detail, index, row) { - $(`#button-output-allocate-${row.pk}`).prop('disabled', true); + // Deallocate stock button + $(table).on('click', '.button-output-deallocate', function() { + let pk = $(this).attr('pk'); + + deallocateStock(build_info.pk, { + output: pk, + table: table + }); }); - // Add callbacks for the various table menubar buttons + // Complete build output button + $(table).on('click', '.button-output-complete', function() { + let pk = $(this).attr('pk'); + let output = $(table).bootstrapTable('getRowByUniqueId', pk); - // Scrap multiple outputs - $('#multi-output-scrap').click(function() { - var outputs = getTableData(table); + completeBuildOutputs( + build_info.pk, + [output], + { + success: function() { + $(table).bootstrapTable('refresh'); + $('#build-stock-table').bootstrapTable('refresh'); + } + } + ); + }); + + // Scrap build output button + $(table).on('click', '.button-output-scrap', function() { + let pk = $(this).attr('pk'); + let output = $(table).bootstrapTable('getRowByUniqueId', pk); scrapBuildOutputs( build_info.pk, - outputs, + [output], { success: function() { - // Reload the "in progress" table - $('#build-output-table').bootstrapTable('refresh'); + $(table).bootstrapTable('refresh'); + $('#build-stock-table').bootstrapTable('refresh'); + } + } + ); + }); - // Reload the "completed" table + // Remove build output button + $(table).on('click', '.button-output-remove', function() { + let pk = $(this).attr('pk'); + let output = $(table).bootstrapTable('getRowByUniqueId', pk); + + deleteBuildOutputs( + build_info.pk, + [output], + { + success: function() { + $(table).bootstrapTable('refresh'); $('#build-stock-table').bootstrapTable('refresh'); } } @@ -1596,6 +1411,25 @@ function loadBuildOutputTable(build_info, options={}) { ); }); + // Scrap multiple outputs + $('#multi-output-scrap').click(function() { + var outputs = getTableData(table); + + scrapBuildOutputs( + build_info.pk, + outputs, + { + success: function() { + // Reload the "in progress" table + $('#build-output-table').bootstrapTable('refresh'); + + // Reload the "completed" table + $('#build-stock-table').bootstrapTable('refresh'); + } + } + ); + }); + $('#outputs-expand').click(function() { $(table).bootstrapTable('expandAllRows'); }); @@ -1606,705 +1440,21 @@ function loadBuildOutputTable(build_info, options={}) { } -/* - * Display the "allocation table" for a particular build output. - * - * This displays a table of required allocations for a particular build output - * - * Args: - * - buildId: The PK of the Build object - * - partId: The PK of the Part object - * - output: The StockItem object which is the "output" of the build - * - options: - * -- table: The #id of the table (will be auto-calculated if not provided) - */ -function loadBuildOutputAllocationTable(buildInfo, output, options={}) { - - var buildId = buildInfo.pk; - var partId = buildInfo.part; - - var outputId = null; - - if (output) { - outputId = output.pk; - } else { - outputId = 'untracked'; - } - - var bom_items = buildInfo.bom_items || null; - - function loadBomData() { - let data = []; - - inventreeGet( - '{% url "api-bom-list" %}', - { - part: partId, - sub_part_detail: true, - sub_part_trackable: buildInfo.tracked_parts, - }, - { - async: false, - success: function(results) { - data = results; - } - } - ); - - return data; - } - - // If BOM items have not been provided, load via the API - if (bom_items == null) { - bom_items = loadBomData(); - } - - // Apply filters to build table - // As the table is constructed locally, we can apply filters directly - function filterBuildAllocationTable(filters={}) { - $(table).bootstrapTable( - 'filterBy', - filters, - { - 'filterAlgorithm': function(row, filters) { - let result = true; - - if (!filters) { - return true; - } - - // Filter by 'consumable' flag - if ('consumable' in filters) { - result &= filters.consumable == '1' ? row.consumable : !row.consumable; - } - - // Filter by 'optional' flag - if ('optional' in filters) { - result &= filters.optional == '1' ? row.optional : !row.optional; - } - - // Filter by 'allocated' flag - if ('allocated' in filters) { - let fully_allocated = row.consumable || isRowFullyAllocated(row); - result &= filters.allocated == '1' ? fully_allocated : !fully_allocated; - } - - // Filter by 'available' flag - if ('available' in filters) { - let available = row.available_stock > 0; - result &= filters.available == '1' ? available : !available; - } - - return result; - } - } - ); - } - - var table = options.table || `#allocation-table-${outputId}`; - - // Filters - let filters = loadTableFilters('builditems', options.params); - - setupFilterList('builditems', $(table), options.filterTarget, { - callback: function(table, filters, options) { - if (filters == null) { - // Destroy and re-create the table from scratch - $(table).bootstrapTable('destroy'); - loadBuildOutputAllocationTable(buildInfo, output, options); - } else { - filterBuildAllocationTable(filters); - } - } - }); - - var allocated_items = output == null ? null : output.allocations; - - function redrawAllocationData() { - // Force a refresh of each row in the table - // Note we cannot call 'refresh' because we are passing data from memory - - // How many rows are fully allocated? - var allocated_rows = 0; - - for (var idx = 0; idx < bom_items.length; idx++) { - var row = bom_items[idx]; - - if (isRowFullyAllocated(row)) { - allocated_rows++; - } - } - - // Reload table data - $(table).bootstrapTable('load', bom_items); - - // Find the top-level progress bar for this build output - var output_progress_bar = $(`#output-progress-${outputId}`); - - if (output_progress_bar.exists()) { - if (bom_items.length > 0) { - output_progress_bar.html( - makeProgressBar( - allocated_rows, - bom_items.length, - { - max_width: '150px', - } - ) - ); - } - } else { - console.warn(`Could not find progress bar for output '${outputId}'`); - } - } - - function reloadAllocationData(async=true) { - // Reload stock allocation data for this particular build output - - inventreeGet( - '{% url "api-build-item-list" %}', - { - build: buildId, - part_detail: true, - location_detail: true, - output: output == null ? null : output.pk, - }, - { - async: async, - success: function(response) { - allocated_items = response; - - redrawAllocationData(); - - } - } - ); - } - - if (allocated_items == null) { - // No allocation data provided? Request from server (blocking) - reloadAllocationData(false); - } else { - redrawAllocationData(); - } - - function requiredQuantity(row) { - // Return the required quantity for a given row - - var quantity = 0; - - if (output) { - // "Tracked" parts are calculated against individual build outputs - quantity = row.quantity * output.quantity; - } else { - // "Untracked" parts are specified against the build itself - quantity = row.quantity * buildInfo.quantity; - } - - // Store the required quantity in the row data - // Prevent weird rounding issues - row.required = formatDecimal(quantity, 15); - return row.required; - } - - function availableQuantity(row) { - // Return the total available stock for a given row - - // Base stock - var available = row.available_stock; - - // Substitute stock - available += (row.available_substitute_stock || 0); - - // Variant stock - if (row.allow_variants) { - available += (row.available_variant_stock || 0); - } - - return available; - } - - function allocatedQuantity(row) { - row.allocated = sumAllocationsForBomRow(row, allocated_items); - return row.allocated; - } - - function isRowFullyAllocated(row) { - return allocatedQuantity(row) >= requiredQuantity(row); - } - - function setupCallbacks() { - // Register button callbacks once table data are loaded - - // Callback for 'allocate' button - $(table).find('.button-add').click(function() { - - // Primary key of the 'sub_part' - var pk = $(this).attr('pk'); - - // Extract BomItem information from this row - var row = $(table).bootstrapTable('getRowByUniqueId', pk); - - if (!row) { - console.warn('getRowByUniqueId returned null'); - return; - } - - allocateStockToBuild( - buildId, - partId, - [ - row, - ], - { - source_location: buildInfo.source_location, - success: function(data) { - // $(table).bootstrapTable('refresh'); - reloadAllocationData(); - }, - output: output == null ? null : output.pk, - } - ); - }); - - // Callback for 'buy' button - $(table).find('.button-buy').click(function() { - - var pk = $(this).attr('pk'); - - inventreeGet( - `/api/part/${pk}/`, - {}, - { - success: function(part) { - orderParts( - [part], - {} - ); - } - } - ); - }); - - // Callback for 'build' button - $(table).find('.button-build').click(function() { - var pk = $(this).attr('pk'); - - // Extract row data from the table - var idx = $(this).closest('tr').attr('data-index'); - var row = $(table).bootstrapTable('getData')[idx]; - - newBuildOrder({ - part: pk, - parent: buildId, - quantity: requiredQuantity(row) - allocatedQuantity(row), - }); - }); - - // Callback for 'unallocate' button - $(table).find('.button-unallocate').click(function() { - - // Extract row data from the table - var idx = $(this).closest('tr').attr('data-index'); - var row = $(table).bootstrapTable('getData')[idx]; - - unallocateStock(buildId, { - bom_item: row.pk, - output: outputId == 'untracked' ? null : outputId, - table: table, - onSuccess: function(response, opts) { - reloadAllocationData(); - } - }); - }); - } - - // Load table of BOM items - $(table).inventreeTable({ - data: bom_items, - disablePagination: true, - formatNoMatches: function() { - return '{% trans "No BOM items found" %}'; - }, - name: 'build-allocation', - uniqueId: 'sub_part', - search: options.search || false, - queryParams: filters, - original: options.params, - onRefresh: function(data) { - filterBuildAllocationTable(filters); - }, - onPostBody: function(data) { - // Setup button callbacks - setupCallbacks(); - }, - sortable: true, - showColumns: true, - detailView: true, - detailFilter: function(index, row) { - return allocatedQuantity(row) > 0; - }, - buttons: constructExpandCollapseButtons(table), - detailFormatter: function(index, row, element) { - // Construct an 'inner table' which shows which stock items have been allocated - - var subTableId = `allocation-table-${outputId}-${row.pk}`; - - var html = `
`; - - element.html(html); - - var subTable = $(`#${subTableId}`); - - subTable.bootstrapTable({ - data: getAllocationsForBomRow(row, allocated_items), - showHeader: true, - columns: [ - { - field: 'part', - title: '{% trans "Part" %}', - formatter: function(value, row) { - - var html = imageHoverIcon(row.part_detail.thumbnail); - html += renderLink(row.part_detail.full_name, `/part/${value}/`); - return html; - } - }, - { - width: '50%', - field: 'quantity', - title: '{% trans "Assigned Stock" %}', - formatter: function(value, row) { - var text = ''; - - var url = ''; - - var serial = row.serial; - - if (row.stock_item_detail) { - serial = row.stock_item_detail.serial; - } - - if (serial && row.quantity == 1) { - text = `{% trans "Serial Number" %}: ${serial}`; - } else { - text = `{% trans "Quantity" %}: ${row.quantity}`; - if (row.part_detail && row.part_detail.units) { - text += ` ${row.part_detail.units}`; - } - } - - var pk = row.stock_item || row.pk; - - url = `/stock/item/${pk}/`; - - return renderLink(text, url); - } - }, - { - field: 'location', - title: '{% trans "Location" %}', - formatter: function(value, row) { - - if (row.location && row.location_detail) { - var text = shortenString(row.location_detail.pathstring); - var url = `/stock/location/${row.location}/`; - - return renderLink(text, url); - } else { - return '{% trans "No location set" %}'; - } - } - }, - { - field: 'actions', - formatter: function(value, row) { - /* Actions available for a particular stock item allocation: - * - * - Edit the allocation quantity - * - Delete the allocation - */ - - var pk = row.pk; - - var html = ''; - - html += makeEditButton('button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - - html += makeDeleteButton('button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); - - return wrapButtons(html); - } - } - ] - }); - - // Assign button callbacks to the newly created allocation buttons - subTable.find('.button-allocation-edit').click(function() { - var pk = $(this).attr('pk'); - - constructForm(`{% url "api-build-item-list" %}${pk}/`, { - fields: { - quantity: {}, - }, - title: '{% trans "Edit Allocation" %}', - onSuccess: reloadAllocationData, - }); - }); - - subTable.find('.button-allocation-delete').click(function() { - var pk = $(this).attr('pk'); - - constructForm(`{% url "api-build-item-list" %}${pk}/`, { - method: 'DELETE', - title: '{% trans "Remove Allocation" %}', - onSuccess: reloadAllocationData, - }); - }); - }, - columns: [ - { - visible: true, - switchable: false, - checkbox: true, - }, - { - field: 'sub_part_detail.full_name', - title: '{% trans "Required Part" %}', - sortable: true, - switchable: false, - formatter: function(value, row) { - var url = `/part/${row.sub_part}/`; - var thumb = row.sub_part_detail.thumbnail; - var name = row.sub_part_detail.full_name; - - var html = imageHoverIcon(thumb) + renderLink(name, url); - - html += makePartIcons(row.sub_part_detail); - - if (row.substitutes && row.substitutes.length > 0) { - html += makeIconBadge('fa-exchange-alt', '{% trans "Substitute parts available" %}'); - } - - if (row.allow_variants) { - html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}'); - } - - return html; - } - }, - { - field: 'reference', - title: '{% trans "Reference" %}', - sortable: true, - switchable: true, - }, - { - field: 'consumable', - title: '{% trans "Consumable" %}', - sortable: true, - switchable: true, - formatter: function(value) { - return yesNoLabel(value); - } - }, - { - field: 'optional', - title: '{% trans "Optional" %}', - sortable: true, - switchable: true, - formatter: function(value) { - return yesNoLabel(value); - } - }, - { - field: 'quantity', - title: '{% trans "Quantity Per" %}', - sortable: true, - switchable: false, - formatter: function(value, row) { - var text = value; - - if (row.sub_part_detail && row.sub_part_detail.units) { - text += ` ${row.sub_part_detail.units}`; - } - return text; - } - }, - { - field: 'available_stock', - title: '{% trans "Available" %}', - sortable: true, - switchable: true, - formatter: function(value, row) { - - var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`; - - // Calculate total "available" (unallocated) quantity - var substitute_stock = row.available_substitute_stock || 0; - var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0; - - var available_stock = availableQuantity(row); - - var required = requiredQuantity(row); - var allocated = allocatedQuantity(row); - - var text = ''; - - if (available_stock > 0) { - text += `${available_stock}`; - if (row.sub_part_detail && row.sub_part_detail.units) { - text += ` ${row.sub_part_detail.units}`; - } - } - - var icons = ''; - - if (row.consumable) { - icons += ``; - } else { - if (available_stock < (required - allocated)) { - icons += makeIconBadge('fa-times-circle icon-red', '{% trans "Insufficient stock available" %}'); - } else { - icons += makeIconBadge('fa-check-circle icon-green', '{% trans "Sufficient stock available" %}'); - } - - if (available_stock <= 0) { - icons += `{% trans "No Stock Available" %}`; - } else { - let extra = ''; - if ((substitute_stock > 0) && (variant_stock > 0)) { - extra = '{% trans "Includes variant and substitute stock" %}'; - } else if (variant_stock > 0) { - extra = '{% trans "Includes variant stock" %}'; - } else if (substitute_stock > 0) { - extra = '{% trans "Includes substitute stock" %}'; - } - - if (extra) { - icons += makeIconBadge('fa-info-circle icon-blue', extra); - } - } - } - - if (row.on_order && row.on_order > 0) { - icons += makeIconBadge('fa-shopping-cart', '{% trans "On Order" %}: ' + row.on_order); - } - - return renderLink(text, url) + icons; - }, - sorter: function(valA, valB, rowA, rowB) { - - return availableQuantity(rowA) > availableQuantity(rowB) ? 1 : -1; - }, - }, - { - field: 'allocated', - title: '{% trans "Allocated" %}', - sortable: true, - switchable: false, - formatter: function(value, row) { - var required = requiredQuantity(row); - var allocated = row.consumable ? required : allocatedQuantity(row); - var progressbar_text = `${allocated} / ${required}`; - if (row.sub_part_detail && row.sub_part_detail.units) { - progressbar_text += ` ${row.sub_part_detail.units}`; - } - return makeProgressBar(allocated, required, {text: progressbar_text}); - }, - sorter: function(valA, valB, rowA, rowB) { - // Custom sorting function for progress bars - - var aA = allocatedQuantity(rowA); - var aB = allocatedQuantity(rowB); - - var qA = requiredQuantity(rowA); - var qB = requiredQuantity(rowB); - - // Handle the case where both numerators are zero - if ((aA == 0) && (aB == 0)) { - return (qA > qB) ? 1 : -1; - } - - // Handle the case where either denominator is zero - if ((qA == 0) || (qB == 0)) { - return 1; - } - - var progressA = parseFloat(aA) / qA; - var progressB = parseFloat(aB) / qB; - - // Handle the case where both ratios are equal - if (progressA == progressB) { - return (qA > qB) ? 1 : -1; - } - - if (progressA == progressB) return 0; - - return (progressA > progressB) ? 1 : -1; - } - }, - { - field: 'actions', - title: '{% trans "Actions" %}', - switchable: false, - sortable: false, - formatter: function(value, row) { - - if (row.consumable) { - return `{% trans "Consumable item" %}`; - } - - // Generate action buttons for this build output - let html = ''; - - if (allocatedQuantity(row) < requiredQuantity(row)) { - if (row.sub_part_detail.assembly) { - html += makeIconButton('fa-tools icon-blue', 'button-build', row.sub_part, '{% trans "Build stock" %}'); - } - - if (row.sub_part_detail.purchaseable) { - html += makeIconButton('fa-shopping-cart icon-blue', 'button-buy', row.sub_part, '{% trans "Order stock" %}'); - } - - html += makeIconButton('fa-sign-in-alt icon-green', 'button-add', row.sub_part, '{% trans "Allocate stock" %}'); - } - - html += makeRemoveButton( - 'button-unallocate', - row.sub_part, - '{% trans "Unallocate stock" %}', - { - disabled: allocatedQuantity(row) == 0, - } - ); - - return wrapButtons(html); - } - }, - ] - }); - - filterBuildAllocationTable(filters); -} - - - /** * Allocate stock items to a build * * arguments: * - buildId: ID / PK value for the build * - partId: ID / PK value for the part being built - * - bom_items: A list of BomItem objects to be allocated + * - line_items: A list of BuildItem objects to be allocated * * options: * - output: ID / PK of the associated build output (or null for untracked items) * - source_location: ID / PK of the top-level StockLocation to source stock from (or null) */ -function allocateStockToBuild(build_id, part_id, bom_items, options={}) { +function allocateStockToBuild(build_id, line_items, options={}) { - if (bom_items.length == 0) { + if (line_items.length == 0) { showAlertDialog( '{% trans "Select Parts" %}', @@ -2314,7 +1464,22 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { return; } - // ID of the associated "build output" (or null) + let build = null; + + // Extract build information + inventreeGet(`{% url "api-build-list" %}${build_id}/`, {}, { + async: false, + success: function(response) { + build = response; + } + }); + + if (!build) { + console.error(`Failed to find build ${build_id}`); + return; + } + + // ID of the associated "build output" (stock item) (or null) var output_id = options.output || null; var auto_fill_filters = {}; @@ -2324,21 +1489,21 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { if (output_id) { // Request information on the particular build output (stock item) inventreeGet(`{% url "api-stock-list" %}${output_id}/`, {}, { + async: false, success: function(output) { if (output.quantity == 1 && output.serial != null) { auto_fill_filters.serial = output.serial; } }, - async: false, }); } - function renderBomItemRow(bom_item, quantity) { + function renderBuildLineRow(build_line, quantity) { - var pk = bom_item.pk; - var sub_part = bom_item.sub_part_detail; + var pk = build_line.pk; + var sub_part = build_line.part_detail; - var thumb = thumbnailImage(bom_item.sub_part_detail.thumbnail); + var thumb = thumbnailImage(sub_part.thumbnail); var delete_button = `
`; @@ -2365,8 +1530,8 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { ); var allocated_display = makeProgressBar( - bom_item.allocated, - bom_item.required, + build_line.allocated, + build_line.quantity, ); var stock_input = constructField( @@ -2380,8 +1545,6 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { } ); - // var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`); - var html = ` @@ -2407,16 +1570,16 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { var table_entries = ''; - for (var idx = 0; idx < bom_items.length; idx++) { - var bom_item = bom_items[idx]; + for (var idx = 0; idx < line_items.length; idx++) { + let item = line_items[idx]; // Ignore "consumable" BOM items - if (bom_item.consumable) { + if (item.part_detail.consumable) { continue; } - var required = bom_item.required || 0; - var allocated = bom_item.allocated || 0; + var required = item.quantity || 0; + var allocated = item.allocated || 0; var remaining = required - allocated; if (remaining < 0) { @@ -2428,7 +1591,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { // We only care about entries which are not yet fully allocated if (remaining > 0) { - table_entries += renderBomItemRow(bom_item, remaining); + table_entries += renderBuildLineRow(item, remaining); } } @@ -2508,13 +1671,13 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { ); // Initialize stock item fields - bom_items.forEach(function(bom_item) { + line_items.forEach(function(line_item) { initializeRelatedField( { - name: `items_stock_item_${bom_item.pk}`, + name: `items_stock_item_${line_item.pk}`, api_url: '{% url "api-stock-list" %}', filters: { - bom_item: bom_item.pk, + bom_item: line_item.bom_item_detail.pk, in_stock: true, available: true, part_detail: true, @@ -2536,7 +1699,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { } // Quantity remaining to be allocated - var remaining = Math.max((bom_item.required || 0) - (bom_item.allocated || 0), 0); + var remaining = Math.max((line_item.quantity || 0) - (line_item.allocated || 0), 0); // Calculate the available quantity var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0); @@ -2544,7 +1707,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { // Maximum amount that we need var desired = Math.min(available, remaining); - updateFieldValue(`items_quantity_${bom_item.pk}`, desired, {}, opts); + updateFieldValue(`items_quantity_${line_item.pk}`, desired, {}, opts); }, adjustFilters: function(filters) { // Restrict query to the selected location @@ -2586,7 +1749,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { var item_pk_values = []; - bom_items.forEach(function(item) { + line_items.forEach(function(item) { var quantity = getFormFieldValue( `items_quantity_${item.pk}`, @@ -2606,7 +1769,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { if (quantity != null) { data.items.push({ - bom_item: item.pk, + build_line: item.pk, stock_item: stock_item, quantity: quantity, output: output_id, @@ -3023,91 +2186,430 @@ function updateAllocationTotal(id, count, required) { } } -function loadAllocationTable(table, part_id, part, url, required, button) { +/* + * Render a table of BuildItem objects, which are allocated against a particular BuildLine + */ +function renderBuildLineAllocationTable(element, build_line, options={}) { - // Load the allocation table - table.bootstrapTable({ - url: url, - sortable: false, - formatNoMatches: function() { - return '{% trans "No parts allocated for" %} ' + part; - }, + let output = options.output || 'untracked'; + let tableId = `allocation-table-${output}-${build_line.pk}`; + + // Construct a table element + let html = ` +
+
+
`; + + element.html(html); + + let sub_table = $(`#${tableId}`); + + // Load the allocation items into the table + sub_table.bootstrapTable({ + data: build_line.allocations, + showHeader: false, columns: [ { - field: 'stock_item_detail', - title: '{% trans "Stock Item" %}', - formatter: function(value) { - return '' + parseFloat(value.quantity) + ' x ' + value.part_name + ' @ ' + value.location_name; - } - }, - { - field: 'stock_item_detail.quantity', - title: '{% trans "Available" %}', - formatter: function(value) { - return parseFloat(value); + field: 'part', + title: '{% trans "Part" %}', + formatter: function(value, row) { + let html = imageHoverIcon(row.part_detail.thumbnail); + html += renderLink(row.part_detail.full_name, `/part/${value}/`); + return html; } }, { field: 'quantity', - title: '{% trans "Allocated" %}', + title: '{% trans "Allocated Quantity" %}', formatter: function(value, row) { - var html = parseFloat(value); + let text = ''; + let url = ''; + let serial = row.serial; - var bEdit = ``; - var bDel = ``; + if (row.stock_item_detail) { + serial = row.stock_item_detail.serial; + } - html += ` -
- ${bEdit} - ${bDel} -
- `; + if (serial && row.quantity == 1) { + text = `{% trans "Serial Number" %}: ${serial}`; + } else { + text = `{% trans "Quantity" %}: ${row.quantity}`; + if (row.part_detail && row.part_detail.units) { + text += ` ${row.part_detail.units}`; + } + } - return html; + var pk = row.stock_item || row.pk; + + url = `/stock/item/${pk}/`; + + return renderLink(text, url); + } + }, + { + field: 'location', + title: '{% trans "Location" %}', + formatter: function(value, row) { + if (row.location_detail) { + var text = shortenString(row.location_detail.pathstring); + var url = `/stock/location/${row.location}/`; + + return renderLink(text, url); + } else { + return '{% trans "No location set" %}'; + } + } + }, + { + field: 'actions', + title: '', + formatter: function(value, row) { + let buttons = ''; + buttons += makeEditButton('button-allocation-edit', row.pk, '{% trans "Edit stock allocation" %}'); + buttons += makeDeleteButton('button-allocation-delete', row.pk, '{% trans "Delete stock allocation" %}'); + return wrapButtons(buttons); } } - ], + ] }); - // Callback for 'new-item' button - button.click(function() { - launchModalForm(button.attr('url'), { - success: function() { - table.bootstrapTable('refresh'); + // Callbacks + $(sub_table).on('click', '.button-allocation-edit', function() { + let pk = $(this).attr('pk'); + + constructForm(`{% url "api-build-item-list" %}${pk}/`, { + fields: { + quantity: {}, + }, + title: '{% trans "Edit Allocation" %}', + onSuccess: function() { + $(options.parent_table).bootstrapTable('refresh'); }, }); }); - table.on('load-success.bs.table', function() { - // Extract table data - var results = table.bootstrapTable('getData'); + $(sub_table).on('click', '.button-allocation-delete', function() { + let pk = $(this).attr('pk'); - var count = 0; + constructForm(`{% url "api-build-item-list" %}${pk}/`, { + method: 'DELETE', + title: '{% trans "Remove Allocation" %}', + onSuccess: function() { + $(options.parent_table).bootstrapTable('refresh'); + }, + }); + }); +} - for (var i = 0; i < results.length; i++) { - count += parseFloat(results[i].quantity); - } - updateAllocationTotal(part_id, count, required); +/* + * Load a table of BuildLine objects associated with a Build + * + * @param {int} build_id - The ID of the Build object + * @param {object} options - Options for the table + */ +function loadBuildLineTable(table, build_id, options={}) { + + let name = 'build-lines'; + let params = options.params || {}; + let output = options.output; + + params.build = build_id; + + if (output) { + params.output = output; + name += `-${output}`; + } else { + // Default to untracked parts for the build + params.tracked = false; + } + + let filters = loadTableFilters('buildlines', params); + let filterTarget = options.filterTarget || '#filter-list-buildlines'; + + setupFilterList('buildlines', $(table), filterTarget); + // If data is passed directly to this function, do not request data from the server + + let table_options = { + name: name, + uniqueId: 'pk', + detailView: true, + detailFilter: function(index, row) { + // Detail view is available if there is any allocated stock + return row.allocated > 0; + }, + detailFormatter: function(_index, row, element) { + renderBuildLineAllocationTable(element, row, { + parent_table: table, + }); + }, + formatNoMatches: function() { + return '{% trans "No build lines found" %}'; + }, + columns: [ + { + checkbox: true, + title: '{% trans "Select" %}', + searchable: false, + switchable: false, + }, + { + field: 'bom_item', + title: '{% trans "Required Part" %}', + switchable: false, + sortable: true, + sortName: 'part', + formatter: function(value, row) { + if (value == null) { + return `BOM item deleted`; + } + + let html = ''; + + // Part thumbnail + html += imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, `/part/${row.part_detail.pk}/`); + + if (row.bom_item_detail.allow_variants) { + html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}'); + } + + if (row.part_detail.trackable) { + html += makeIconBadge('fa-directions', '{% trans "Trackable part" %}'); + } + + return html; + } + }, + { + field: 'reference', + title: '{% trans "Reference" %}', + sortable: true, + formatter: function(value, row) { + return row.bom_item_detail.reference; + } + }, + { + field: 'consumable', + title: '{% trans "Consumable" %}', + sortable: true, + switchable: true, + formatter: function(value, row) { + return yesNoLabel(row.bom_item_detail.consumable); + } + }, + { + field: 'optional', + title: '{% trans "Optional" %}', + sortable: true, + switchable: true, + formatter: function(value, row) { + return yesNoLabel(row.bom_item_detail.optional); + } + }, + { + field: 'unit_quantity', + sortable: true, + title: '{% trans "Unit Quantity" %}', + formatter: function(value, row) { + let text = row.bom_item_detail.quantity; + + if (row.bom_item_detail.overage) { + text += ` (+${row.bom_item_detail.overage})`; + } + + if (row.part_detail.units) { + text += ` [${row.part_detail.units}]`; + } + + return text; + } + }, + { + field: 'quantity', + title: '{% trans "Required Quantity" %}', + sortable: true, + }, + { + field: 'available_stock', + title: '{% trans "Available" %}', + sortable: true, + formatter: function(value, row) { + var url = `/part/${row.part_detail.pk}/?display=part-stock`; + // Calculate the "available" quantity + let available = row.available_stock + row.available_substitute_stock; + + if (row.bom_item_detail.allow_variants) { + available += row.available_variant_stock; + } + + let text = ''; + + if (available > 0) { + text += `${formatDecimal(available)}`; + + if (row.part_detail.units) { + text += ` [${row.part_detail.units}]`; + } + } + + let icons = ''; + + if (row.bom_item_detail.consumable) { + icons += ``; + } else { + if (available < (row.quantity - row.allocated)) { + icons += makeIconBadge('fa-times-circle icon-red', '{% trans "Insufficient stock available" %}'); + } else { + icons += makeIconBadge('fa-check-circle icon-green', '{% trans "Sufficient stock available" %}'); + } + + if (available <= 0) { + icons += `{% trans "No Stock Available" %}`; + } else { + let extra = ''; + if ((row.available_substitute_stock > 0) && (row.available_variant_stock > 0)) { + extra = '{% trans "Includes variant and substitute stock" %}'; + } else if (row.available_variant_stock > 0) { + extra = '{% trans "Includes variant stock" %}'; + } else if (row.available_substitute_stock > 0) { + extra = '{% trans "Includes substitute stock" %}'; + } + + if (extra) { + icons += makeIconBadge('fa-info-circle icon-blue', extra); + } + } + } + + if (row.on_order && row.on_order > 0) { + icons += makeIconBadge('fa-shopping-cart', `{% trans "On Order" %}: ${formatDecimal(row.on_order)}`); + } + + return renderLink(text, url) + icons; + } + }, + { + field: 'allocated', + title: '{% trans "Allocated" %}', + sortable: true, + formatter: function(value, row) { + return makeProgressBar(row.allocated, row.quantity); + } + }, + { + field: 'actions', + title: '', + switchable: false, + sortable: false, + formatter: function(value, row) { + let buttons = ''; + let pk = row.pk; + + // Consumable items do not need to be allocated + if (row.bom_item_detail.consumable) { + return `{% trans "Consumable Item" %}`; + } + + if (row.part_detail.trackable && !options.output) { + // Tracked parts must be allocated to a specific build output + return `{% trans "Tracked item" %}`; + } + + if (row.allocated < row.quantity) { + + // Add a button to "build" stock for this line + if (row.part_detail.assembly) { + buttons += makeIconButton('fa-tools icon-blue', 'button-build', pk, '{% trans "Build stock" %}'); + } + + // Add a button to "purchase" stock for this line + if (row.part_detail.purchaseable) { + buttons += makeIconButton('fa-shopping-cart icon-blue', 'button-buy', pk, '{% trans "Order stock" %}'); + } + + // Add a button to "allocate" stock for this line + buttons += makeIconButton('fa-sign-in-alt icon-green', 'button-allocate', pk, '{% trans "Allocate stock" %}'); + } + + if (row.allocated > 0) { + buttons += makeRemoveButton('button-unallocate', pk, '{% trans "Remove stock allocation" %}'); + } + + return wrapButtons(buttons); + } + } + ] + }; + + if (options.data) { + Object.assign(table_options, { + data: options.data, + sidePagination: 'client', + showColumns: false, + pagination: false, + disablePagination: true, + search: false, + }); + } else { + Object.assign(table_options, { + url: '{% url "api-build-line-list" %}', + queryParams: filters, + original: params, + search: true, + sidePagination: 'server', + pagination: true, + showColumns: true, + buttons: constructExpandCollapseButtons(table), + }); + } + + $(table).inventreeTable(table_options); + + /* Add callbacks for allocation buttons */ + + // Callback to build stock + $(table).on('click', '.button-build', function() { + let pk = $(this).attr('pk'); + let row = $(table).bootstrapTable('getRowByUniqueId', pk); + + // Start a new "build" for this line + newBuildOrder({ + part: row.part_detail.pk, + parent: build_id, + quantity: Math.max(row.quantity - row.allocated, 0), + }); }); - // Button callbacks for editing and deleting the allocations - table.on('click', '.item-edit-button', function() { - var button = $(this); + // Callback to purchase stock + $(table).on('click', '.button-buy', function() { + let pk = $(this).attr('pk'); + let row = $(table).bootstrapTable('getRowByUniqueId', pk); - launchModalForm(button.attr('url'), { + // TODO: Refresh table after purchase order is created + orderParts([row.part_detail], {}); + }); + + // Callback to allocate stock + $(table).on('click', '.button-allocate', function() { + let pk = $(this).attr('pk'); + let row = $(table).bootstrapTable('getRowByUniqueId', pk); + + allocateStockToBuild(build_id, [row], { + output: options.output, success: function() { - table.bootstrapTable('refresh'); + $(table).bootstrapTable('refresh'); } }); }); - table.on('click', '.item-del-button', function() { - var button = $(this); + // Callback to un-allocate stock + $(table).on('click', '.button-unallocate', function() { + let pk = $(this).attr('pk'); - launchModalForm(button.attr('url'), { - success: function() { - table.bootstrapTable('refresh'); + deallocateStock(build_id, { + build_line: pk, + onSuccess: function() { + $(table).bootstrapTable('refresh'); } }); }); diff --git a/InvenTree/templates/js/translated/sales_order.js b/InvenTree/templates/js/translated/sales_order.js index 35e3d2d7e8..678169a82f 100644 --- a/InvenTree/templates/js/translated/sales_order.js +++ b/InvenTree/templates/js/translated/sales_order.js @@ -1727,12 +1727,12 @@ function loadSalesOrderLineItemTable(table, options={}) { options.params = options.params || {}; if (!options.order) { - console.error('function called without order ID'); + console.error('loadSalesOrderLineItemTable called without order ID'); return; } if (!options.status) { - console.error('function called without order status'); + console.error('loadSalesOrderLineItemTable called without order status'); return; } diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 6c422a3cae..c24b90c15c 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -480,8 +480,8 @@ function getBuildTableFilters() { } -// Return a dictionary of filters for the "build item" table -function getBuildItemTableFilters() { +// Return a dictionary of filters for the "build lines" table +function getBuildLineTableFilters() { return { allocated: { type: 'bool', @@ -491,6 +491,10 @@ function getBuildItemTableFilters() { type: 'bool', title: '{% trans "Available" %}', }, + tracked: { + type: 'bool', + title: '{% trans "Tracked" %}', + }, consumable: { type: 'bool', title: '{% trans "Consumable" %}', @@ -771,8 +775,8 @@ function getAvailableTableFilters(tableKey) { return getBOMTableFilters(); case 'build': return getBuildTableFilters(); - case 'builditems': - return getBuildItemTableFilters(); + case 'buildlines': + return getBuildLineTableFilters(); case 'location': return getStockLocationFilters(); case 'parameters': diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index df5efc8d08..7a9120770f 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -131,6 +131,7 @@ class RuleSet(models.Model): 'part_bomitemsubstitute', 'build_build', 'build_builditem', + 'build_buildline', 'build_buildorderattachment', 'stock_stockitem', 'stock_stocklocation', diff --git a/docs/docs/build/allocate.md b/docs/docs/build/allocate.md index 5eba88d672..06512b4c1f 100644 --- a/docs/docs/build/allocate.md +++ b/docs/docs/build/allocate.md @@ -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. -### 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 diff --git a/docs/docs/build/bom.md b/docs/docs/build/bom.md index f773f349ae..281e801d41 100644 --- a/docs/docs/build/bom.md +++ b/docs/docs/build/bom.md @@ -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" | | 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 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. diff --git a/docs/docs/build/build.md b/docs/docs/build/build.md index f7659e6f57..74fc6e2404 100644 --- a/docs/docs/build/build.md +++ b/docs/docs/build/build.md @@ -26,7 +26,7 @@ To navigate to the Build Order display, select *Build* from the main navigation {% include "img.html" %} {% 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. diff --git a/docs/docs/report/build.md b/docs/docs/report/build.md index 4461cadc9e..f1b138f5eb 100644 --- a/docs/docs/report/build.md +++ b/docs/docs/report/build.md @@ -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 | | 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 | -| quantity | Build order quantity | +| quantity | Build order quantity (number of assemblies being built) | #### build @@ -29,7 +32,9 @@ The following variables are accessed by build.variable | --- | --- | | active | Boolean that tells if the build is active | | 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. | | are_untracked_parts_allocated | Boolean that tells if all bom_items have allocated stock_items. | | creation_date | Date where the build has been created | @@ -42,7 +47,8 @@ The following variables are accessed by build.variable | notes | Text notes | | 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) | -| 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) | | 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 | @@ -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 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 -| Variable | Description | +| Attribute | Description | | --- | --- | | .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 | | .substitutes.all | A query set with all allowed substitutes for that part | +| .note | Extra text field which can contain additional information | #### allocated_stock.all -| Variable | Description | +| Attribute | Description | | --- | --- | | .bom_item | The bom item where this part belongs to | | .stock_item | The allocated [StockItem](./context_variables.md#stockitem) |