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
|
||||||
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
|
||||||
|
@ -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',
|
||||||
|
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.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).
|
||||||
|
@ -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 = [
|
||||||
|
@ -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 }}, {
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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');
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
@ -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."""
|
||||||
|
@ -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) {
|
||||||
|
@ -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',
|
||||||
|
@ -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',
|
||||||
|
Loading…
Reference in New Issue
Block a user