Feature: Supplier part pack size (#3644)

* Adds 'pack_size' field to SupplierPart model

* Edit pack_size for SupplierPart via API

* Display pack size in supplier part page template

* Improve table ordering for SupplierPart table

* Fix for API filtering

- Need to use custom filter class

* Adds functionality to duplicate an existing SupplierPart

* Bump API version number

* Display annotation of pack size in purchase order line item table

* Display additional information in part purchase order table

* Add UOM to purchase order table

* Improve receive items functionality

* Indicate quantity which will be received in modal form

* Update the received quantity as the user changes the value

* Take  the pack_size into account when receiving line items

* Take supplierpart pack size into account when receiving line items

* Add "pack size" column to purchase order line item table

* Tweak supplier part table

* Update 'on_order' queryset annotation to take pack_size into account

- May god have mercy on my soul

* Adds a unit test to validate that the on_order queryset annotation is working as expected

* Update Part.on_order method to take pack_size into account

- Check in existing unit test also

* Fix existing unit tests

- Previous unit test was actually in error
- Logic for calculating "on_order" was broked

* More unit tests for receiving items against a purchase order

* Allow pack_size < 1

* Display pack size when adding / editing PurchaseOrderLineItem

* Fix bug in part purchase order table

* Update part purchase order table again

* Exclude notificationmessage when exporting dataset

* Also display pack size when ordering parts from secondary form

* javascript linting

* Change user facing strings to "Pack Quantity"
This commit is contained in:
Oliver 2022-09-08 09:49:14 +10:00 committed by GitHub
parent 890c998420
commit 198ac9b275
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 567 additions and 60 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # 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 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 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 PurchaseOrder if the order has incomplete lines
- Add confirmation field for completing SalesOrder if the order has incomplete lines - Add confirmation field for completing SalesOrder if the order has incomplete lines

View File

@ -8,6 +8,7 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters from rest_framework import filters
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
@ -338,12 +339,30 @@ class SupplierPartList(ListCreateDestroyAPIView):
filter_backends = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
filters.SearchFilter, filters.SearchFilter,
filters.OrderingFilter, InvenTreeOrderingFilter,
] ]
filterset_fields = [ 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 = [ search_fields = [
'SKU', 'SKU',
'supplier__name', 'supplier__name',

View File

@ -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'),
),
]

View File

@ -20,7 +20,7 @@ import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.validators import InvenTree.validators
from common.settings import currency_code_default 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.models import InvenTreeAttachment
from InvenTree.status_codes import PurchaseOrderStatus from InvenTree.status_codes import PurchaseOrderStatus
@ -406,6 +406,7 @@ class SupplierPart(models.Model):
multiple: Multiple that the part is provided in multiple: Multiple that the part is provided in
lead_time: Supplier lead time lead_time: Supplier lead time
packaging: packaging that the part is supplied in, e.g. "Reel" 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() 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')) 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')) 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). # TODO - Reimplement lead-time as a charfield with special validation (pattern matching).

View File

@ -239,6 +239,8 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
pretty_name = serializers.CharField(read_only=True) pretty_name = serializers.CharField(read_only=True)
pack_size = serializers.FloatField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required""" """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) manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', read_only=True)
url = serializers.CharField(source='get_absolute_url', read_only=True)
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@ -291,12 +295,14 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
'note', 'note',
'pk', 'pk',
'packaging', 'packaging',
'pack_size',
'part', 'part',
'part_detail', 'part_detail',
'pretty_name', 'pretty_name',
'SKU', 'SKU',
'supplier', 'supplier',
'supplier_detail', 'supplier_detail',
'url',
] ]
read_only_fields = [ read_only_fields = [

View File

@ -49,6 +49,11 @@
<span class='fas fa-edit icon-green'></span> {% trans "Edit Supplier Part" %} <span class='fas fa-edit icon-green'></span> {% trans "Edit Supplier Part" %}
</a></li> </a></li>
{% endif %} {% endif %}
{% if roles.purchase_order.add %}
<li><a class='dropdown-item' href='#' id='duplicate-part' title='{% trans "Duplicate Supplier Part" %}'>
<span class='fas fa-clone'></span> {% trans "Duplicate Supplier Part" %}
</a></li>
{% endif %}
{% if roles.purchase_order.delete %} {% if roles.purchase_order.delete %}
<li><a class='dropdown-item' href='#' id='delete-part' title='{% trans "Delete Supplier Part" %}'> <li><a class='dropdown-item' href='#' id='delete-part' title='{% trans "Delete Supplier Part" %}'>
<span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Supplier Part" %} <span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Supplier Part" %}
@ -140,6 +145,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{{ part.packaging }}{% include "clip.html"%}</td> <td>{{ part.packaging }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if part.pack_size != 1.0 %}
<tr>
<td><span class='fas fa-box'></span></td>
<td>{% trans "Pack Quantity" %}</td>
<td>{% decimal part.pack_size %}{% if part.part.units %} {{ part.part.units }}{% endif %}</td>
</tr>
{% endif %}
{% if part.note %} {% if part.note %}
<tr> <tr>
<td><span class='fas fa-sticky-note'></span></td> <td><span class='fas fa-sticky-note'></span></td>
@ -386,6 +398,12 @@ $('#update-part-availability').click(function() {
}); });
}); });
$('#duplicate-part').click(function() {
duplicateSupplierPart({{ part.pk }}, {
follow: true
});
});
$('#edit-part').click(function () { $('#edit-part').click(function () {
editSupplierPart({{ part.pk }}, { editSupplierPart({{ part.pk }}, {

View File

@ -475,6 +475,9 @@ class PurchaseOrder(Order):
# Create a new stock item # Create a new stock item
if line.part and quantity > 0: 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 # Determine if we should individually serialize the items, or not
if type(serials) is list and len(serials) > 0: if type(serials) is list and len(serials) > 0:
serialize = True serialize = True
@ -488,7 +491,7 @@ class PurchaseOrder(Order):
part=line.part.part, part=line.part.part,
supplier_part=line.part, supplier_part=line.part,
location=location, location=location,
quantity=1 if serialize else quantity, quantity=1 if serialize else pack_quantity,
purchase_order=self, purchase_order=self,
status=status, status=status,
batch=batch_code, batch=batch_code,
@ -515,6 +518,7 @@ class PurchaseOrder(Order):
) )
# Update the number of parts received against the particular line item # 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.received += quantity
line.save() line.save()

View File

@ -515,11 +515,14 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
serial_numbers = data.get('serial_numbers', '').strip() serial_numbers = data.get('serial_numbers', '').strip()
base_part = line_item.part.part 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?) # Does the quantity need to be "integer" (for trackable parts?)
if base_part.trackable: if base_part.trackable:
if Decimal(quantity) != int(quantity): if Decimal(pack_quantity) != int(pack_quantity):
raise ValidationError({ raise ValidationError({
'quantity': _('An integer quantity must be provided for trackable parts'), 'quantity': _('An integer quantity must be provided for trackable parts'),
}) })
@ -528,7 +531,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
if serial_numbers: if serial_numbers:
try: try:
# Pass the serial numbers through to the parent serializer once validated # 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: except DjangoValidationError as e:
raise ValidationError({ raise ValidationError({
'serial_numbers': e.messages, 'serial_numbers': e.messages,

View File

@ -42,12 +42,18 @@
<span class='fas fa-tools'></span> <span class='caret'></span> <span class='fas fa-tools'></span> <span class='caret'></span>
</button> </button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='edit-order'><span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}</a></li> <li><a class='dropdown-item' href='#' id='edit-order'>
<span class='fas fa-edit icon-green'></span> {% trans "Edit order" %}
</a></li>
{% if order.can_cancel %} {% if order.can_cancel %}
<li><a class='dropdown-item' href='#' id='cancel-order'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}</a></li> <li><a class='dropdown-item' href='#' id='cancel-order'>
<span class='fas fa-times-circle icon-red'></span> {% trans "Cancel order" %}
</a></li>
{% endif %} {% endif %}
{% if roles.purchase_order.add %} {% if roles.purchase_order.add %}
<li><a class='dropdown-item' href='#' id='duplicate-order'><span class='fas fa-clone'></span> {% trans "Duplicate order" %}</a></li> <li><a class='dropdown-item' href='#' id='duplicate-order'>
<span class='fas fa-clone'></span> {% trans "Duplicate order" %}
</a></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@ -235,19 +241,11 @@ $("#edit-order").click(function() {
$("#receive-order").click(function() { $("#receive-order").click(function() {
// Auto select items which have not been fully allocated // Auto select items which have not been fully allocated
var items = $("#po-line-table").bootstrapTable('getData'); var items = getTableData('#po-line-table');
var items_to_receive = [];
items.forEach(function(item) {
if (item.received < item.quantity) {
items_to_receive.push(item);
}
});
receivePurchaseOrderItems( receivePurchaseOrderItems(
{{ order.id }}, {{ order.id }},
items_to_receive, items,
{ {
success: function() { success: function() {
$("#po-line-table").bootstrapTable('refresh'); $("#po-line-table").bootstrapTable('refresh');

View File

@ -1,6 +1,7 @@
"""Various unit tests for order models""" """Various unit tests for order models"""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal
import django.core.exceptions as django_exceptions import django.core.exceptions as django_exceptions
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -194,11 +195,18 @@ class OrderTest(TestCase):
# Receive the rest of the items # Receive the rest of the items
order.receive_line_item(line, loc, 50, user=None) order.receive_line_item(line, loc, 50, user=None)
self.assertEqual(part.on_order, 1300)
line = PurchaseOrderLineItem.objects.get(id=2) line = PurchaseOrderLineItem.objects.get(id=2)
in_stock = part.total_stock
order.receive_line_item(line, loc, 500, user=None) 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) self.assertEqual(order.status, PurchaseOrderStatus.PLACED)
for line in order.pending_line_items(): for line in order.pending_line_items():
@ -206,6 +214,91 @@ class OrderTest(TestCase):
self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE) 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): def test_overdue_notification(self):
"""Test overdue purchase order notification """Test overdue purchase order notification

View File

@ -19,8 +19,8 @@ Relevant PRs:
from decimal import Decimal from decimal import Decimal
from django.db import models from django.db import models
from django.db.models import (F, FloatField, Func, IntegerField, OuterRef, Q, from django.db.models import (DecimalField, ExpressionWrapper, F, FloatField,
Subquery) Func, IntegerField, OuterRef, Q, Subquery)
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from sql_util.utils import SubquerySum from sql_util.utils import SubquerySum
@ -32,19 +32,43 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
def annotate_on_order_quantity(reference: str = ''): 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 # 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( 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), Decimal(0),
output_field=models.DecimalField() output_field=DecimalField()
) - Coalesce( ) - 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), Decimal(0),
output_field=models.DecimalField(), output_field=DecimalField(),
) )

View File

@ -2036,22 +2036,30 @@ class Part(MetadataMixin, MPTTModel):
@property @property
def on_order(self): def on_order(self):
"""Return the total number of items on order for this part.""" """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')
)
quantity = orders['quantity'] Note that some supplier parts may have a different pack_size attribute,
received = orders['received'] and this needs to be taken into account!
"""
if quantity is None:
quantity = 0 quantity = 0
if received is None: # Iterate through all supplier parts
received = 0 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): def get_parameters(self):
"""Return all parameters for this part, ordered by name.""" """Return all parameters for this part, ordered by name."""

View File

@ -1,5 +1,7 @@
"""Unit tests for the various part API endpoints""" """Unit tests for the various part API endpoints"""
from random import randint
from django.urls import reverse from django.urls import reverse
import PIL import PIL
@ -9,7 +11,7 @@ from rest_framework.test import APIClient
import build.models import build.models
import order.models import order.models
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from company.models import Company from company.models import Company, SupplierPart
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
StockStatus) StockStatus)
@ -1676,6 +1678,110 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(part.total_stock, 91) self.assertEqual(part.total_stock, 91)
self.assertEqual(part.available_stock, 56) 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): class BomItemTest(InvenTreeAPITestCase):
"""Unit tests for the BomItem API.""" """Unit tests for the BomItem API."""

View File

@ -16,6 +16,7 @@
deleteManufacturerParts, deleteManufacturerParts,
deleteManufacturerPartParameters, deleteManufacturerPartParameters,
deleteSupplierParts, deleteSupplierParts,
duplicateSupplierPart,
editCompany, editCompany,
loadCompanyTable, loadCompanyTable,
loadManufacturerPartTable, loadManufacturerPartTable,
@ -130,7 +131,8 @@ function supplierPartFields(options={}) {
}, },
packaging: { packaging: {
icon: 'fa-box', icon: 'fa-box',
} },
pack_size: {},
}; };
if (options.part) { 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={}) { function editSupplierPart(part, options={}) {
var fields = options.fields || supplierPartFields(); var fields = options.fields || supplierPartFields();
@ -865,6 +900,7 @@ function loadSupplierPartTable(table, url, options) {
switchable: params['part_detail'], switchable: params['part_detail'],
sortable: true, sortable: true,
field: 'part_detail.full_name', field: 'part_detail.full_name',
sortName: 'part',
title: '{% trans "Part" %}', title: '{% trans "Part" %}',
formatter: function(value, row) { formatter: function(value, row) {
@ -915,6 +951,7 @@ function loadSupplierPartTable(table, url, options) {
visible: params['manufacturer_detail'], visible: params['manufacturer_detail'],
switchable: params['manufacturer_detail'], switchable: params['manufacturer_detail'],
sortable: true, sortable: true,
sortName: 'manufacturer',
field: 'manufacturer_detail.name', field: 'manufacturer_detail.name',
title: '{% trans "Manufacturer" %}', title: '{% trans "Manufacturer" %}',
formatter: function(value, row) { formatter: function(value, row) {
@ -933,6 +970,7 @@ function loadSupplierPartTable(table, url, options) {
visible: params['manufacturer_detail'], visible: params['manufacturer_detail'],
switchable: params['manufacturer_detail'], switchable: params['manufacturer_detail'],
sortable: true, sortable: true,
sortName: 'MPN',
field: 'manufacturer_part_detail.MPN', field: 'manufacturer_part_detail.MPN',
title: '{% trans "MPN" %}', title: '{% trans "MPN" %}',
formatter: function(value, row) { 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', field: 'link',
sortable: false,
title: '{% trans "Link" %}', title: '{% trans "Link" %}',
formatter: function(value) { formatter: function(value) {
if (value) { if (value) {
@ -954,21 +1008,11 @@ function loadSupplierPartTable(table, url, options) {
} }
} }
}, },
{
field: 'description',
title: '{% trans "Description" %}',
sortable: false,
},
{ {
field: 'note', field: 'note',
title: '{% trans "Notes" %}', title: '{% trans "Notes" %}',
sortable: false, sortable: false,
}, },
{
field: 'packaging',
title: '{% trans "Packaging" %}',
sortable: false,
},
{ {
field: 'in_stock', field: 'in_stock',
title: '{% trans "In Stock" %}', title: '{% trans "In Stock" %}',
@ -976,7 +1020,7 @@ function loadSupplierPartTable(table, url, options) {
}, },
{ {
field: 'available', field: 'available',
title: '{% trans "Available" %}', title: '{% trans "Availability" %}',
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
if (row.availability_updated) { if (row.availability_updated) {

View File

@ -794,6 +794,35 @@ function poLineItemFields(options={}) {
supplier_detail: true, supplier_detail: true,
supplier: options.supplier, 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 = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
$(opts.modal).find('#hint_id_quantity').after(`<div class='form-info-message' id='info-pack-size'>${txt}</div>`);
}
});
}
},
secondary: { secondary: {
method: 'POST', method: 'POST',
title: '{% trans "Add Supplier Part" %}', title: '{% trans "Add Supplier Part" %}',
@ -1151,16 +1180,46 @@ function orderParts(parts_list, options={}) {
afterRender: function(fields, opts) { afterRender: function(fields, opts) {
parts.forEach(function(part) { parts.forEach(function(part) {
var pk = part.pk;
// Filter by base part // Filter by base part
supplier_part_filters.part = part.pk; supplier_part_filters.part = pk;
if (part.manufacturer_part) { if (part.manufacturer_part) {
// Filter by manufacturer part // Filter by manufacturer part
supplier_part_filters.manufacturer_part = part.manufacturer_part; supplier_part_filters.manufacturer_part = part.manufacturer_part;
} }
// Configure the "supplier part" field // Callback function when supplier part is changed
initializeRelatedField({ // 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 = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
$(opts.modal).find(`#id_quantity_${pk}`).after(`<div class='form-info-message' id='info-pack-size-${pk}'>${txt}</div>`);
}
});
}
};
var supplier_part_field = {
name: `part_${part.pk}`, name: `part_${part.pk}`,
model: 'supplierpart', model: 'supplierpart',
api_url: '{% url "api-supplier-part-list" %}', api_url: '{% url "api-supplier-part-list" %}',
@ -1169,10 +1228,15 @@ function orderParts(parts_list, options={}) {
auto_fill: true, auto_fill: true,
value: options.supplier_part, value: options.supplier_part,
filters: supplier_part_filters, filters: supplier_part_filters,
onEdit: onSupplierPartChanged,
noResults: function(query) { noResults: function(query) {
return '{% trans "No matching supplier parts" %}'; 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 // Configure the "purchase order" field
initializeRelatedField({ initializeRelatedField({
@ -1394,6 +1458,20 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
</span> </span>
`; `;
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 = `
<div class='alert alert-block alert-info'>
{% trans "Pack Quantity" %}: ${pack_size} ${units}<br>
{% trans "Received Quantity" %}: <span class='pack_received_quantity' id='items_received_quantity_${pk}'>${received}</span> ${units}
</div>`;
}
// Quantity to Receive // Quantity to Receive
var quantity_input = constructField( var quantity_input = constructField(
`items_quantity_${pk}`, `items_quantity_${pk}`,
@ -1433,7 +1511,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
); );
// Hidden inputs below the "quantity" field // Hidden inputs below the "quantity" field
var quantity_input_group = `${quantity_input}<div class='collapse' id='div-batch-${pk}'>${batch_input}</div>`; var quantity_input_group = `${quantity_input}${pack_size_div}<div class='collapse' id='div-batch-${pk}'>${batch_input}</div>`;
if (line_item.part_detail.trackable) { if (line_item.part_detail.trackable) {
quantity_input_group += `<div class='collapse' id='div-serials-${pk}'>${sn_input}</div>`; quantity_input_group += `<div class='collapse' id='div-serials-${pk}'>${sn_input}</div>`;
@ -1545,7 +1623,9 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
var table_entries = ''; var table_entries = '';
line_items.forEach(function(item) { line_items.forEach(function(item) {
if (item.received < item.quantity) {
table_entries += renderLineItem(item); table_entries += renderLineItem(item);
}
}); });
var html = ``; var html = ``;
@ -1581,7 +1661,8 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
confirmMessage: '{% trans "Confirm receipt of items" %}', confirmMessage: '{% trans "Confirm receipt of items" %}',
title: '{% trans "Receive Purchase Order Items" %}', title: '{% trans "Receive Purchase Order Items" %}',
afterRender: function(fields, opts) { 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) { line_items.forEach(function(item) {
var pk = item.pk; var pk = item.pk;
@ -1602,18 +1683,21 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
render_description: false, render_description: false,
}; };
// Initialize the location field
initializeRelatedField( initializeRelatedField(
field_details, field_details,
null, null,
opts, opts,
); );
// Add 'clear' button callback for the location field
addClearCallback( addClearCallback(
name, name,
field_details, field_details,
opts opts
); );
// Setup stock item status field
initializeChoiceField( initializeChoiceField(
{ {
name: `items_status_${pk}`, name: `items_status_${pk}`,
@ -1621,6 +1705,19 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
null, null,
opts 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 // Add callbacks to remove rows
@ -2158,6 +2255,23 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
switchable: false, switchable: false,
field: 'quantity', field: 'quantity',
title: '{% trans "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 += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_size}${units} - {% trans "Total Quantity" %}: ${total}${units}'></span>`;
}
return data;
},
footerFormatter: function(data) { footerFormatter: function(data) {
return data.map(function(row) { return data.map(function(row) {
return +row['quantity']; return +row['quantity'];
@ -2166,6 +2280,21 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
}, 0); }, 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, sortable: true,
field: 'purchase_price', field: 'purchase_price',

View File

@ -1036,6 +1036,17 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
{ {
field: 'quantity', field: 'quantity',
title: '{% trans "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 += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_size} - {% trans "Total Quantity" %}: ${total}'></span>`;
}
return data;
},
}, },
{ {
field: 'target_date', field: 'target_date',
@ -1077,6 +1088,17 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
field: 'received', field: 'received',
title: '{% trans "Received" %}', title: '{% trans "Received" %}',
switchable: true, 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 += `<span class='fas fa-info-circle icon-blue float-right' title='{% trans "Pack Quantity" %}: ${pack_size} - {% trans "Total Quantity" %}: ${total}'></span>`;
}
return data;
},
}, },
{ {
field: 'purchase_price', field: 'purchase_price',

View File

@ -43,6 +43,7 @@ def content_excludes():
"exchange.rate", "exchange.rate",
"exchange.exchangebackend", "exchange.exchangebackend",
"common.notificationentry", "common.notificationentry",
"common.notificationmessage",
"user_sessions.session", "user_sessions.session",
] ]