mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
890c998420
commit
198ac9b275
@ -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
|
||||
|
@ -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',
|
||||
|
20
InvenTree/company/migrations/0047_supplierpart_pack_size.py
Normal file
20
InvenTree/company/migrations/0047_supplierpart_pack_size.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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).
|
||||
|
@ -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 = [
|
||||
|
@ -49,6 +49,11 @@
|
||||
<span class='fas fa-edit icon-green'></span> {% trans "Edit Supplier Part" %}
|
||||
</a></li>
|
||||
{% 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 %}
|
||||
<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" %}
|
||||
@ -140,6 +145,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{{ part.packaging }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% 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 %}
|
||||
<tr>
|
||||
<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 () {
|
||||
|
||||
editSupplierPart({{ part.pk }}, {
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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,
|
||||
|
@ -42,12 +42,18 @@
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<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 %}
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
@ -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');
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
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."""
|
||||
|
@ -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) {
|
||||
|
@ -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 = `<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: {
|
||||
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 = `<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}`,
|
||||
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={}) {
|
||||
</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
|
||||
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}<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) {
|
||||
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 = '';
|
||||
|
||||
line_items.forEach(function(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 += `<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) {
|
||||
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',
|
||||
|
@ -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 += `<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',
|
||||
@ -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 += `<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',
|
||||
|
Loading…
Reference in New Issue
Block a user