diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 670203e2a9..70755eec47 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 74 +INVENTREE_API_VERSION = 75 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v75 -> 2022-09-05 : https://github.com/inventree/InvenTree/pull/3644 + - Adds "pack_size" attribute to SupplierPart API serializer + v74 -> 2022-08-28 : https://github.com/inventree/InvenTree/pull/3615 - Add confirmation field for completing PurchaseOrder if the order has incomplete lines - Add confirmation field for completing SalesOrder if the order has incomplete lines diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index 3307c9a619..83f6b34a08 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -8,6 +8,7 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView +from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.helpers import str2bool from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI @@ -338,12 +339,30 @@ class SupplierPartList(ListCreateDestroyAPIView): filter_backends = [ DjangoFilterBackend, filters.SearchFilter, - filters.OrderingFilter, + InvenTreeOrderingFilter, ] filterset_fields = [ ] + ordering_fields = [ + 'SKU', + 'part', + 'supplier', + 'manufacturer', + 'MPN', + 'packaging', + 'pack_size', + 'in_stock', + ] + + ordering_field_aliases = { + 'part': 'part__name', + 'supplier': 'supplier__name', + 'manufacturer': 'manufacturer_part__manufacturer__name', + 'MPN': 'manufacturer_part__MPN', + } + search_fields = [ 'SKU', 'supplier__name', diff --git a/InvenTree/company/migrations/0047_supplierpart_pack_size.py b/InvenTree/company/migrations/0047_supplierpart_pack_size.py new file mode 100644 index 0000000000..47a4e6b3fb --- /dev/null +++ b/InvenTree/company/migrations/0047_supplierpart_pack_size.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.15 on 2022-09-05 04:21 + +import InvenTree.fields +import django.core.validators +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0046_alter_company_image'), + ] + + operations = [ + migrations.AddField( + model_name='supplierpart', + name='pack_size', + field=InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Unit quantity supplied in a single pack', max_digits=15, validators=[django.core.validators.MinValueValidator(0.001)], verbose_name='Pack Quantity'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index fa68dfcf1b..aa67de0018 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -20,7 +20,7 @@ import InvenTree.fields import InvenTree.helpers import InvenTree.validators from common.settings import currency_code_default -from InvenTree.fields import InvenTreeURLField +from InvenTree.fields import InvenTreeURLField, RoundingDecimalField from InvenTree.models import InvenTreeAttachment from InvenTree.status_codes import PurchaseOrderStatus @@ -406,6 +406,7 @@ class SupplierPart(models.Model): multiple: Multiple that the part is provided in lead_time: Supplier lead time packaging: packaging that the part is supplied in, e.g. "Reel" + pack_size: Quantity of item supplied in a single pack (e.g. 30ml in a single tube) """ objects = SupplierPartManager() @@ -527,6 +528,14 @@ class SupplierPart(models.Model): packaging = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Packaging'), help_text=_('Part packaging')) + pack_size = RoundingDecimalField( + verbose_name=_('Pack Quantity'), + help_text=_('Unit quantity supplied in a single pack'), + default=1, + max_digits=15, decimal_places=5, + validators=[MinValueValidator(0.001)], + ) + multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Order multiple')) # TODO - Reimplement lead-time as a charfield with special validation (pattern matching). diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 5a9e5b4587..ae88131997 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -239,6 +239,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer): pretty_name = serializers.CharField(read_only=True) + pack_size = serializers.FloatField() + def __init__(self, *args, **kwargs): """Initialize this serializer with extra detail fields as required""" @@ -273,6 +275,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer): manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True) + url = serializers.CharField(source='get_absolute_url', read_only=True) + class Meta: """Metaclass options.""" @@ -291,12 +295,14 @@ class SupplierPartSerializer(InvenTreeModelSerializer): 'note', 'pk', 'packaging', + 'pack_size', 'part', 'part_detail', 'pretty_name', 'SKU', 'supplier', 'supplier_detail', + 'url', ] read_only_fields = [ diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 5c4533a197..ebe4f658ad 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -49,6 +49,11 @@ {% trans "Edit Supplier Part" %} {% endif %} + {% if roles.purchase_order.add %} +
  • + {% trans "Duplicate Supplier Part" %} +
  • + {% endif %} {% if roles.purchase_order.delete %}
  • {% trans "Delete Supplier Part" %} @@ -140,6 +145,13 @@ src="{% static 'img/blank_image.png' %}" {{ part.packaging }}{% include "clip.html"%} {% endif %} + {% if part.pack_size != 1.0 %} + + + {% trans "Pack Quantity" %} + {% decimal part.pack_size %}{% if part.part.units %} {{ part.part.units }}{% endif %} + + {% endif %} {% if part.note %} @@ -386,6 +398,12 @@ $('#update-part-availability').click(function() { }); }); +$('#duplicate-part').click(function() { + duplicateSupplierPart({{ part.pk }}, { + follow: true + }); +}); + $('#edit-part').click(function () { editSupplierPart({{ part.pk }}, { diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index d3d4922d46..e5aaaa298c 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -475,6 +475,9 @@ class PurchaseOrder(Order): # Create a new stock item if line.part and quantity > 0: + # Take the 'pack_size' of the SupplierPart into account + pack_quantity = Decimal(quantity) * Decimal(line.part.pack_size) + # Determine if we should individually serialize the items, or not if type(serials) is list and len(serials) > 0: serialize = True @@ -488,7 +491,7 @@ class PurchaseOrder(Order): part=line.part.part, supplier_part=line.part, location=location, - quantity=1 if serialize else quantity, + quantity=1 if serialize else pack_quantity, purchase_order=self, status=status, batch=batch_code, @@ -515,6 +518,7 @@ class PurchaseOrder(Order): ) # Update the number of parts received against the particular line item + # Note that this quantity does *not* take the pack_size into account, it is "number of packs" line.received += quantity line.save() diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index f161d42b5b..43311db925 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -515,11 +515,14 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): serial_numbers = data.get('serial_numbers', '').strip() base_part = line_item.part.part + pack_size = line_item.part.pack_size + + pack_quantity = pack_size * quantity # Does the quantity need to be "integer" (for trackable parts?) if base_part.trackable: - if Decimal(quantity) != int(quantity): + if Decimal(pack_quantity) != int(pack_quantity): raise ValidationError({ 'quantity': _('An integer quantity must be provided for trackable parts'), }) @@ -528,7 +531,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): if serial_numbers: try: # Pass the serial numbers through to the parent serializer once validated - data['serials'] = extract_serial_numbers(serial_numbers, quantity, base_part.getLatestSerialNumberInt()) + data['serials'] = extract_serial_numbers(serial_numbers, pack_quantity, base_part.getLatestSerialNumberInt()) except DjangoValidationError as e: raise ValidationError({ 'serial_numbers': e.messages, diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index cbb82640db..5ffb6bcd7e 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -42,12 +42,18 @@ @@ -235,19 +241,11 @@ $("#edit-order").click(function() { $("#receive-order").click(function() { // Auto select items which have not been fully allocated - var items = $("#po-line-table").bootstrapTable('getData'); - - var items_to_receive = []; - - items.forEach(function(item) { - if (item.received < item.quantity) { - items_to_receive.push(item); - } - }); + var items = getTableData('#po-line-table'); receivePurchaseOrderItems( {{ order.id }}, - items_to_receive, + items, { success: function() { $("#po-line-table").bootstrapTable('refresh'); diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index 3885c972c4..8c66b3a53a 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -1,6 +1,7 @@ """Various unit tests for order models""" from datetime import datetime, timedelta +from decimal import Decimal import django.core.exceptions as django_exceptions from django.contrib.auth import get_user_model @@ -194,11 +195,18 @@ class OrderTest(TestCase): # Receive the rest of the items order.receive_line_item(line, loc, 50, user=None) + self.assertEqual(part.on_order, 1300) + line = PurchaseOrderLineItem.objects.get(id=2) + in_stock = part.total_stock + order.receive_line_item(line, loc, 500, user=None) - self.assertEqual(part.on_order, 800) + # Check that the part stock quantity has increased by the correct amount + self.assertEqual(part.total_stock, in_stock + 500) + + self.assertEqual(part.on_order, 1100) self.assertEqual(order.status, PurchaseOrderStatus.PLACED) for line in order.pending_line_items(): @@ -206,6 +214,91 @@ class OrderTest(TestCase): self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE) + def test_receive_pack_size(self): + """Test receiving orders from suppliers with different pack_size values""" + + prt = Part.objects.get(pk=1) + sup = Company.objects.get(pk=1) + + # Create a new supplier part with larger pack size + sp_1 = SupplierPart.objects.create( + part=prt, + supplier=sup, + SKU='SKUx10', + pack_size=10, + ) + + # Create a new supplier part with smaller pack size + sp_2 = SupplierPart.objects.create( + part=prt, + supplier=sup, + SKU='SKUx0.1', + pack_size=0.1, + ) + + # Record values before we start + on_order = prt.on_order + in_stock = prt.total_stock + + n = PurchaseOrder.objects.count() + + # Create a new PurchaseOrder + po = PurchaseOrder.objects.create( + supplier=sup, + reference=f"PO-{n + 1}", + description='Some PO', + ) + + # Add line items + + # 3 x 10 = 30 + line_1 = PurchaseOrderLineItem.objects.create( + order=po, + part=sp_1, + quantity=3 + ) + + # 13 x 0.1 = 1.3 + line_2 = PurchaseOrderLineItem.objects.create( + order=po, + part=sp_2, + quantity=13, + ) + + po.place_order() + + # The 'on_order' quantity should have been increased by 31.3 + self.assertEqual(prt.on_order, round(on_order + Decimal(31.3), 1)) + + loc = StockLocation.objects.get(id=1) + + # Receive 1x item against line_1 + po.receive_line_item(line_1, loc, 1, user=None) + + # Receive 5x item against line_2 + po.receive_line_item(line_2, loc, 5, user=None) + + # Check that the line items have been updated correctly + self.assertEqual(line_1.quantity, 3) + self.assertEqual(line_1.received, 1) + self.assertEqual(line_1.remaining(), 2) + + self.assertEqual(line_2.quantity, 13) + self.assertEqual(line_2.received, 5) + self.assertEqual(line_2.remaining(), 8) + + # The 'on_order' quantity should have decreased by 10.5 + self.assertEqual( + prt.on_order, + round(on_order + Decimal(31.3) - Decimal(10.5), 1) + ) + + # The 'in_stock' quantity should have increased by 10.5 + self.assertEqual( + prt.total_stock, + round(in_stock + Decimal(10.5), 1) + ) + def test_overdue_notification(self): """Test overdue purchase order notification diff --git a/InvenTree/part/filters.py b/InvenTree/part/filters.py index 112a1799eb..026328f816 100644 --- a/InvenTree/part/filters.py +++ b/InvenTree/part/filters.py @@ -19,8 +19,8 @@ Relevant PRs: from decimal import Decimal from django.db import models -from django.db.models import (F, FloatField, Func, IntegerField, OuterRef, Q, - Subquery) +from django.db.models import (DecimalField, ExpressionWrapper, F, FloatField, + Func, IntegerField, OuterRef, Q, Subquery) from django.db.models.functions import Coalesce from sql_util.utils import SubquerySum @@ -32,19 +32,43 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, def annotate_on_order_quantity(reference: str = ''): - """Annotate the 'on order' quantity for each part in a queryset""" + """Annotate the 'on order' quantity for each part in a queryset. + + Sum the 'remaining quantity' of each line item for any open purchase orders for each part: + + - Purchase order must be 'active' or 'pending' + - Received quantity must be less than line item quantity + + Note that in addition to the 'quantity' on order, we must also take into account 'pack_size'. + """ # Filter only 'active' purhase orders - order_filter = Q(order__status__in=PurchaseOrderStatus.OPEN) + # Filter only line with outstanding quantity + order_filter = Q( + order__status__in=PurchaseOrderStatus.OPEN, + quantity__gt=F('received'), + ) return Coalesce( - SubquerySum(f'{reference}supplier_parts__purchase_order_line_items__quantity', filter=order_filter), + SubquerySum( + ExpressionWrapper( + F(f'{reference}supplier_parts__purchase_order_line_items__quantity') * F(f'{reference}supplier_parts__pack_size'), + output_field=DecimalField(), + ), + filter=order_filter + ), Decimal(0), - output_field=models.DecimalField() + output_field=DecimalField() ) - Coalesce( - SubquerySum(f'{reference}supplier_parts__purchase_order_line_items__received', filter=order_filter), + SubquerySum( + ExpressionWrapper( + F(f'{reference}supplier_parts__purchase_order_line_items__received') * F(f'{reference}supplier_parts__pack_size'), + output_field=DecimalField(), + ), + filter=order_filter + ), Decimal(0), - output_field=models.DecimalField(), + output_field=DecimalField(), ) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 16035c5a46..03407fd89c 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2036,22 +2036,30 @@ class Part(MetadataMixin, MPTTModel): @property def on_order(self): - """Return the total number of items on order for this part.""" - orders = self.supplier_parts.filter(purchase_order_line_items__order__status__in=PurchaseOrderStatus.OPEN).aggregate( - quantity=Sum('purchase_order_line_items__quantity'), - received=Sum('purchase_order_line_items__received') - ) + """Return the total number of items on order for this part. - quantity = orders['quantity'] - received = orders['received'] + Note that some supplier parts may have a different pack_size attribute, + and this needs to be taken into account! + """ - if quantity is None: - quantity = 0 + quantity = 0 - if received is None: - received = 0 + # Iterate through all supplier parts + for sp in self.supplier_parts.all(): - return quantity - received + # Look at any incomplete line item for open orders + lines = sp.purchase_order_line_items.filter( + order__status__in=PurchaseOrderStatus.OPEN, + quantity__gt=F('received'), + ) + + for line in lines: + remaining = line.quantity - line.received + + if remaining > 0: + quantity += remaining * sp.pack_size + + return quantity def get_parameters(self): """Return all parameters for this part, ordered by name.""" diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 79819d613e..35179485d0 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1,5 +1,7 @@ """Unit tests for the various part API endpoints""" +from random import randint + from django.urls import reverse import PIL @@ -9,7 +11,7 @@ from rest_framework.test import APIClient import build.models import order.models from common.models import InvenTreeSetting -from company.models import Company +from company.models import Company, SupplierPart from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, StockStatus) @@ -1676,6 +1678,110 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): self.assertEqual(part.total_stock, 91) self.assertEqual(part.available_stock, 56) + def test_on_order(self): + """Test that the 'on_order' queryset annotation works as expected. + + This queryset annotation takes into account any outstanding line items for active orders, + and should also use the 'pack_size' of the supplier part objects. + """ + + supplier = Company.objects.create( + name='Paint Supplies', + description='A supplier of paints', + is_supplier=True + ) + + # First, create some parts + paint = PartCategory.objects.create( + parent=None, + name="Paint", + description="Paints and such", + ) + + for color in ['Red', 'Green', 'Blue', 'Orange', 'Yellow']: + p = Part.objects.create( + category=paint, + units='litres', + name=f"{color} Paint", + description=f"Paint which is {color} in color" + ) + + # Create multiple supplier parts in different sizes + for pk_sz in [1, 10, 25, 100]: + sp = SupplierPart.objects.create( + part=p, + supplier=supplier, + SKU=f"PNT-{color}-{pk_sz}L", + pack_size=pk_sz, + ) + + self.assertEqual(p.supplier_parts.count(), 4) + + # Check that we have the right base data to start with + self.assertEqual(paint.parts.count(), 5) + self.assertEqual(supplier.supplied_parts.count(), 20) + + supplier_parts = supplier.supplied_parts.all() + + # Create multiple orders + for _ii in range(5): + + po = order.models.PurchaseOrder.objects.create( + supplier=supplier, + description='ordering some paint', + ) + + # Order an assortment of items + for sp in supplier_parts: + + # Generate random quantity to order + quantity = randint(10, 20) + + # Mark up to half of the quantity as received + received = randint(0, quantity // 2) + + # Add a line item + item = order.models.PurchaseOrderLineItem.objects.create( + part=sp, + order=po, + quantity=quantity, + received=received, + ) + + # Now grab a list of parts from the API + response = self.get( + reverse('api-part-list'), + { + 'category': paint.pk, + }, + expected_code=200, + ) + + # Check that the correct number of items have been returned + self.assertEqual(len(response.data), 5) + + for item in response.data: + # Calculate the 'ordering' quantity from first principles + p = Part.objects.get(pk=item['pk']) + + on_order = 0 + + for sp in p.supplier_parts.all(): + for line_item in sp.purchase_order_line_items.all(): + po = line_item.order + + if po.status in PurchaseOrderStatus.OPEN: + remaining = line_item.quantity - line_item.received + + if remaining > 0: + on_order += remaining * sp.pack_size + + # The annotated quantity must be equal to the hand-calculated quantity + self.assertEqual(on_order, item['ordering']) + + # The annotated quantity must also match the part.on_order quantity + self.assertEqual(on_order, p.on_order) + class BomItemTest(InvenTreeAPITestCase): """Unit tests for the BomItem API.""" diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index 681735db6a..6b90a78c87 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -16,6 +16,7 @@ deleteManufacturerParts, deleteManufacturerPartParameters, deleteSupplierParts, + duplicateSupplierPart, editCompany, loadCompanyTable, loadManufacturerPartTable, @@ -130,7 +131,8 @@ function supplierPartFields(options={}) { }, packaging: { icon: 'fa-box', - } + }, + pack_size: {}, }; if (options.part) { @@ -198,6 +200,39 @@ function createSupplierPart(options={}) { } +/* + * Launch a modal form to duplicate an existing SupplierPart instance + */ +function duplicateSupplierPart(part, options={}) { + + var fields = options.fields || supplierPartFields(); + + // Retrieve information for the supplied part + inventreeGet(`/api/company/part/${part}/`, {}, { + success: function(data) { + + // Remove fields which we do not want to duplicate + delete data['pk']; + delete data['available']; + delete data['availability_updated']; + + constructForm(`/api/company/part/`, { + method: 'POST', + fields: fields, + title: '{% trans "Duplicate Supplier Part" %}', + data: data, + onSuccess: function(response) { + handleFormSuccess(response, options); + } + }); + } + }); +} + + +/* + * Launch a modal form to edit an existing SupplierPart instance + */ function editSupplierPart(part, options={}) { var fields = options.fields || supplierPartFields(); @@ -865,6 +900,7 @@ function loadSupplierPartTable(table, url, options) { switchable: params['part_detail'], sortable: true, field: 'part_detail.full_name', + sortName: 'part', title: '{% trans "Part" %}', formatter: function(value, row) { @@ -915,6 +951,7 @@ function loadSupplierPartTable(table, url, options) { visible: params['manufacturer_detail'], switchable: params['manufacturer_detail'], sortable: true, + sortName: 'manufacturer', field: 'manufacturer_detail.name', title: '{% trans "Manufacturer" %}', formatter: function(value, row) { @@ -933,6 +970,7 @@ function loadSupplierPartTable(table, url, options) { visible: params['manufacturer_detail'], switchable: params['manufacturer_detail'], sortable: true, + sortName: 'MPN', field: 'manufacturer_part_detail.MPN', title: '{% trans "MPN" %}', formatter: function(value, row) { @@ -943,8 +981,24 @@ function loadSupplierPartTable(table, url, options) { } } }, + { + field: 'description', + title: '{% trans "Description" %}', + sortable: false, + }, + { + field: 'packaging', + title: '{% trans "Packaging" %}', + sortable: true, + }, + { + field: 'pack_size', + title: '{% trans "Pack Quantity" %}', + sortable: true, + }, { field: 'link', + sortable: false, title: '{% trans "Link" %}', formatter: function(value) { if (value) { @@ -954,21 +1008,11 @@ function loadSupplierPartTable(table, url, options) { } } }, - { - field: 'description', - title: '{% trans "Description" %}', - sortable: false, - }, { field: 'note', title: '{% trans "Notes" %}', sortable: false, }, - { - field: 'packaging', - title: '{% trans "Packaging" %}', - sortable: false, - }, { field: 'in_stock', title: '{% trans "In Stock" %}', @@ -976,7 +1020,7 @@ function loadSupplierPartTable(table, url, options) { }, { field: 'available', - title: '{% trans "Available" %}', + title: '{% trans "Availability" %}', sortable: true, formatter: function(value, row) { if (row.availability_updated) { diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index b9cd3b5a65..c52585dfe1 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -794,6 +794,35 @@ function poLineItemFields(options={}) { supplier_detail: true, supplier: options.supplier, }, + onEdit: function(value, name, field, opts) { + // If the pack_size != 1, add a note to the field + var pack_size = 1; + var units = ''; + + // Remove any existing note fields + $(opts.modal).find('#info-pack-size').remove(); + + if (value != null) { + inventreeGet(`/api/company/part/${value}/`, + { + part_detail: true, + }, + { + success: function(response) { + // Extract information from the returned query + pack_size = response.pack_size || 1; + units = response.part_detail.units || ''; + }, + } + ).then(function() { + + if (pack_size != 1) { + var txt = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; + $(opts.modal).find('#hint_id_quantity').after(`
    ${txt}
    `); + } + }); + } + }, secondary: { method: 'POST', title: '{% trans "Add Supplier Part" %}', @@ -1151,16 +1180,46 @@ function orderParts(parts_list, options={}) { afterRender: function(fields, opts) { parts.forEach(function(part) { + var pk = part.pk; + // Filter by base part - supplier_part_filters.part = part.pk; + supplier_part_filters.part = pk; if (part.manufacturer_part) { // Filter by manufacturer part supplier_part_filters.manufacturer_part = part.manufacturer_part; } - // Configure the "supplier part" field - initializeRelatedField({ + // Callback function when supplier part is changed + // This is used to update the "pack size" attribute + var onSupplierPartChanged = function(value, name, field, opts) { + var pack_size = 1; + var units = ''; + + $(opts.modal).find(`#info-pack-size-${pk}`).remove(); + + if (value != null) { + inventreeGet( + `/api/company/part/${value}/`, + { + part_detail: true, + }, + { + success: function(response) { + pack_size = response.pack_size || 1; + units = response.part_detail.units || ''; + } + } + ).then(function() { + if (pack_size != 1) { + var txt = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; + $(opts.modal).find(`#id_quantity_${pk}`).after(`
    ${txt}
    `); + } + }); + } + }; + + var supplier_part_field = { name: `part_${part.pk}`, model: 'supplierpart', api_url: '{% url "api-supplier-part-list" %}', @@ -1169,10 +1228,15 @@ function orderParts(parts_list, options={}) { auto_fill: true, value: options.supplier_part, filters: supplier_part_filters, + onEdit: onSupplierPartChanged, noResults: function(query) { return '{% trans "No matching supplier parts" %}'; } - }, null, opts); + }; + + // Configure the "supplier part" field + initializeRelatedField(supplier_part_field, null, opts); + addFieldCallback(`part_${part.pk}`, supplier_part_field, opts); // Configure the "purchase order" field initializeRelatedField({ @@ -1394,6 +1458,20 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { `; + var units = line_item.part_detail.units || ''; + var pack_size = line_item.supplier_part_detail.pack_size || 1; + var pack_size_div = ''; + + var received = quantity * pack_size; + + if (pack_size != 1) { + pack_size_div = ` +
    + {% trans "Pack Quantity" %}: ${pack_size} ${units}
    + {% trans "Received Quantity" %}: ${received} ${units} +
    `; + } + // Quantity to Receive var quantity_input = constructField( `items_quantity_${pk}`, @@ -1433,7 +1511,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ); // Hidden inputs below the "quantity" field - var quantity_input_group = `${quantity_input}
    ${batch_input}
    `; + var quantity_input_group = `${quantity_input}${pack_size_div}
    ${batch_input}
    `; if (line_item.part_detail.trackable) { quantity_input_group += `
    ${sn_input}
    `; @@ -1545,7 +1623,9 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { var table_entries = ''; line_items.forEach(function(item) { - table_entries += renderLineItem(item); + if (item.received < item.quantity) { + table_entries += renderLineItem(item); + } }); var html = ``; @@ -1581,7 +1661,8 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { confirmMessage: '{% trans "Confirm receipt of items" %}', title: '{% trans "Receive Purchase Order Items" %}', afterRender: function(fields, opts) { - // Initialize the "destination" field for each item + + // Run initialization routines for each line in the form line_items.forEach(function(item) { var pk = item.pk; @@ -1602,18 +1683,21 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { render_description: false, }; + // Initialize the location field initializeRelatedField( field_details, null, opts, ); + // Add 'clear' button callback for the location field addClearCallback( name, field_details, opts ); + // Setup stock item status field initializeChoiceField( { name: `items_status_${pk}`, @@ -1621,6 +1705,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { null, opts ); + + // Add change callback for quantity field + if (item.supplier_part_detail.pack_size != 1) { + $(opts.modal).find(`#id_items_quantity_${pk}`).change(function() { + var value = $(opts.modal).find(`#id_items_quantity_${pk}`).val(); + + var el = $(opts.modal).find(`#quantity_${pk}`).find('.pack_received_quantity'); + + var actual = value * item.supplier_part_detail.pack_size; + actual = formatDecimal(actual); + el.text(actual); + }); + } }); // Add callbacks to remove rows @@ -2158,6 +2255,23 @@ function loadPurchaseOrderLineItemTable(table, options={}) { switchable: false, field: 'quantity', title: '{% trans "Quantity" %}', + formatter: function(value, row) { + var units = ''; + + if (row.part_detail.units) { + units = ` ${row.part_detail.units}`; + } + + var data = value; + + if (row.supplier_part_detail.pack_size != 1.0) { + var pack_size = row.supplier_part_detail.pack_size; + var total = value * pack_size; + data += ``; + } + + return data; + }, footerFormatter: function(data) { return data.map(function(row) { return +row['quantity']; @@ -2166,6 +2280,21 @@ function loadPurchaseOrderLineItemTable(table, options={}) { }, 0); } }, + { + sortable: false, + switchable: true, + field: 'supplier_part_detail.pack_size', + title: '{% trans "Pack Quantity" %}', + formatter: function(value, row) { + var units = row.part_detail.units; + + if (units) { + value += ` ${units}`; + } + + return value; + } + }, { sortable: true, field: 'purchase_price', diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index ebb2949d28..bc0e8c81db 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1036,6 +1036,17 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { { field: 'quantity', title: '{% trans "Quantity" %}', + formatter: function(value, row) { + var data = value; + + if (row.supplier_part_detail.pack_size != 1.0) { + var pack_size = row.supplier_part_detail.pack_size; + var total = value * pack_size; + data += ``; + } + + return data; + }, }, { field: 'target_date', @@ -1077,6 +1088,17 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) { field: 'received', title: '{% trans "Received" %}', switchable: true, + formatter: function(value, row) { + var data = value; + + if (value > 0 && row.supplier_part_detail.pack_size != 1.0) { + var pack_size = row.supplier_part_detail.pack_size; + var total = value * pack_size; + data += ``; + } + + return data; + }, }, { field: 'purchase_price', diff --git a/tasks.py b/tasks.py index 7962841e59..112370babf 100644 --- a/tasks.py +++ b/tasks.py @@ -43,6 +43,7 @@ def content_excludes(): "exchange.rate", "exchange.exchangebackend", "common.notificationentry", + "common.notificationmessage", "user_sessions.session", ]