mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13:05:42 +00:00
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:
@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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."""
|
||||
|
Reference in New Issue
Block a user