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",
]