From ae0efe73d17fbd87ae1085891d35d86670281a50 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 5 Oct 2021 08:25:10 +1100 Subject: [PATCH] Further improvements to build allocation form - Auto-allocation button ignores outputs which are complete - StockItem API allows filtering by BomItem - Quantity inputs are now auto-filled - Display progress bar in the modal form --- .../management/commands/rebuild_thumbnails.py | 2 +- InvenTree/InvenTree/version.py | 6 +- InvenTree/build/models.py | 5 +- InvenTree/build/templates/build/detail.html | 34 ++++++++--- InvenTree/company/apps.py | 12 ---- InvenTree/part/apps.py | 6 +- InvenTree/part/models.py | 17 ++++++ InvenTree/stock/api.py | 33 ++++++++--- InvenTree/templates/js/translated/build.js | 56 +++++++++++++------ 9 files changed, 117 insertions(+), 54 deletions(-) diff --git a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py index 243d609863..07e700a1cf 100644 --- a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py +++ b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py @@ -23,7 +23,7 @@ logger = logging.getLogger("inventree-thumbnails") class Command(BaseCommand): """ Rebuild all thumbnail images - """ + """ def rebuild_thumbnail(self, model): """ diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index f309f85e66..1d9423371f 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,11 +10,15 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" -INVENTREE_API_VERSION = 12 +INVENTREE_API_VERSION = 13 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v13 -> 2021-10-05 + - Adds API endpoint to allocate stock items against a BuildOrder + - Updates StockItem API with improved filtering against BomItem data + v12 -> 2021-09-07 - Adds API endpoint to receive stock items against a PurchaseOrder diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index feddd01112..9a7b40b52f 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -9,8 +9,9 @@ import decimal import os from datetime import datetime -from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ + +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.urls import reverse @@ -1055,8 +1056,10 @@ class BuildItem(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) stock_item: Link to a StockItem object quantity: Number of units allocated + install_into: Destination stock item (or None) """ @staticmethod diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index c219a64db3..5e7f3d7c5f 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -433,16 +433,32 @@ $("#btn-auto-allocate").on('click', function() { var bom_items = $("#allocation-table-untracked").bootstrapTable("getData"); - allocateStockToBuild( - {{ build.pk }}, - {{ build.part.pk }}, - bom_items, - { - success: function(data) { - $('#allocation-table-untracked').bootstrapTable('refresh'); - } + var incomplete_bom_items = []; + + bom_items.forEach(function(bom_item) { + if (bom_item.required > bom_item.allocated) { + incomplete_bom_items.push(bom_item); } - ); + }); + + if (incomplete_bom_items.length == 0) { + showAlertDialog( + '{% trans "Allocation Complete" %}', + '{% trans "All untracked stock items have been allocated" %}', + ); + } else { + + allocateStockToBuild( + {{ build.pk }}, + {{ build.part.pk }}, + incomplete_bom_items, + { + success: function(data) { + $('#allocation-table-untracked').bootstrapTable('refresh'); + } + } + ); + } }); $('#btn-unallocate').on('click', function() { diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 497193237d..41371dd739 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -1,18 +1,6 @@ from __future__ import unicode_literals -import os -import logging - -from PIL import UnidentifiedImageError - from django.apps import AppConfig -from django.db.utils import OperationalError, ProgrammingError -from django.conf import settings - -from InvenTree.ready import canAppAccessDatabase - - -logger = logging.getLogger("inventree") class CompanyConfig(AppConfig): diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index ed423da9bd..49a9f2f90c 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -1,13 +1,9 @@ from __future__ import unicode_literals -import os import logging from django.db.utils import OperationalError, ProgrammingError from django.apps import AppConfig -from django.conf import settings - -from PIL import UnidentifiedImageError from InvenTree.ready import canAppAccessDatabase @@ -40,7 +36,7 @@ class PartConfig(AppConfig): items = BomItem.objects.filter(part__trackable=False, sub_part__trackable=True) for item in items: - print(f"Marking part '{item.part.name}' as trackable") + logger.info(f"Marking part '{item.part.name}' as trackable") item.part.trackable = True item.part.clean() item.part.save() diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index d7ad577081..18a53f5a79 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2329,6 +2329,23 @@ class BomItem(models.Model): def get_api_url(): return reverse('api-bom-list') + def get_stock_filter(self): + """ + Return a queryset filter for selecting StockItems which match this BomItem + + - If allow_variants is True, allow all part variants + + """ + + # Target part + part = self.sub_part + + if self.allow_variants: + variants = part.get_descendants(include_self=True) + return Q(part__in=[v.pk for v in variants]) + else: + return Q(part=part) + def save(self, *args, **kwargs): self.clean() diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index eaa65dd763..e03d821bae 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -2,11 +2,18 @@ JSON API for the Stock app """ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from decimal import Decimal, InvalidOperation +from datetime import datetime, timedelta + +from django.utils.translation import ugettext_lazy as _ + from django.conf.urls import url, include from django.urls import reverse from django.http import JsonResponse from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ from rest_framework import status from rest_framework.serializers import ValidationError @@ -22,7 +29,7 @@ from .models import StockItemTracking from .models import StockItemAttachment from .models import StockItemTestResult -from part.models import Part, PartCategory +from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer from company.models import Company, SupplierPart @@ -45,10 +52,6 @@ from InvenTree.helpers import str2bool, isNull from InvenTree.api import AttachmentMixin from InvenTree.filters import InvenTreeOrderingFilter -from decimal import Decimal, InvalidOperation - -from datetime import datetime, timedelta - class StockCategoryTree(TreeSerializer): title = _('Stock') @@ -670,14 +673,14 @@ class StockList(generics.ListCreateAPIView): return queryset def filter_queryset(self, queryset): + """ + Custom filtering for the StockItem queryset + """ params = self.request.query_params queryset = super().filter_queryset(queryset) - # Perform basic filtering: - # Note: We do not let DRF filter here, it be slow AF - supplier_part = params.get('supplier_part', None) if supplier_part: @@ -843,6 +846,18 @@ class StockList(generics.ListCreateAPIView): except (ValueError, PartCategory.DoesNotExist): raise ValidationError({"category": "Invalid category id specified"}) + # Does the client wish to filter by BomItem + bom_item_id = params.get('bom_item', None) + + if bom_item_id is not None: + try: + bom_item = BomItem.objects.get(pk=bom_item_id) + + queryset = queryset.filter(bom_item.get_stock_filter()) + + except (ValueError, BomItem.DoesNotExist): + pass + # Filter by StockItem status status = params.get('status', None) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index e7c6fab978..e72d4a2387 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -345,18 +345,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { function requiredQuantity(row) { // Return the requied quantity for a given row + var quantity = 0; + if (output) { // "Tracked" parts are calculated against individual build outputs - return row.quantity * output.quantity; + quantity = row.quantity * output.quantity; } else { // "Untracked" parts are specified against the build itself - return row.quantity * buildInfo.quantity; + quantity = row.quantity * buildInfo.quantity; } + + // Store the required quantity in the row data + row.required = quantity; + + return quantity; } function sumAllocations(row) { // Calculat total allocations for a given row if (!row.allocations) { + row.allocated = 0; return 0; } @@ -366,6 +374,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { quantity += item.quantity; }); + row.allocated = quantity; + return quantity; } @@ -394,7 +404,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { ], { success: function(data) { - // TODO: Reload table + $(table).bootstrapTable('refresh'); }, } ); @@ -854,6 +864,11 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { } ); + var allocated_display = makeProgressBar( + bom_item.allocated, + bom_item.required, + ); + var stock_input = constructField( `items_stock_item_${pk}`, { @@ -872,11 +887,12 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { ${thumb} ${sub_part.full_name} + + ${allocated_display} + ${stock_input} - - ${quantity_input} @@ -894,7 +910,15 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { for (var idx = 0; idx < bom_items.length; idx++) { var bom_item = bom_items[idx]; - table_entries += renderBomItemRow(bom_item); + var required = bom_item.required || 0; + var allocated = bom_item.allocated || 0; + var remaining = required - allocated; + + if (remaining < 0) { + remaining = 0; + } + + table_entries += renderBomItemRow(bom_item, remaining); } if (bom_items.length == 0) { @@ -913,8 +937,8 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { {% trans "Part" %} - {% trans "Stock Item" %} {% trans "Allocated" %} + {% trans "Stock Item" %} {% trans "Quantity" %} @@ -942,7 +966,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { name: `items_stock_item_${bom_item.pk}`, api_url: '{% url "api-stock-list" %}', filters: { - part: bom_item.sub_part, + bom_item: bom_item.pk, in_stock: true, part_detail: false, location_detail: true, @@ -968,7 +992,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { $(options.modal).find(`#allocation_row_${pk}`).remove(); }); }, - onSubmit: function(fields, options) { + onSubmit: function(fields, opts) { // Extract elements from the form var data = { @@ -983,7 +1007,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { `items_quantity_${item.pk}`, {}, { - modal: options.modal, + modal: opts.modal, }, ); @@ -991,7 +1015,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { `items_stock_item_${item.pk}`, {}, { - modal: options.modal, + modal: opts.modal, } ); @@ -1007,18 +1031,18 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { }); // Provide nested values - options.nested = { + opts.nested = { "items": item_pk_values }; inventreePut( - options.url, + opts.url, data, { method: 'POST', success: function(response) { // Hide the modal - $(options.modal).modal('hide'); + $(opts.modal).modal('hide'); if (options.success) { options.success(response); @@ -1027,10 +1051,10 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { error: function(xhr) { switch (xhr.status) { case 400: - handleFormErrors(xhr.responseJSON, fields, options); + handleFormErrors(xhr.responseJSON, fields, opts); break; default: - $(options.modal).modal('hide'); + $(opts.modal).modal('hide'); showApiError(xhr); break; }