diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 283accadde..e6e7e8dc9b 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 172 +INVENTREE_API_VERSION = 173 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v173 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6483 + - Adds "merge_items" to the PurchaseOrderLine create API endpoint + - Adds "auto_pricing" to the PurchaseOrderLine create/update API endpoint + v172 - 2024-02-20 : https://github.com/inventree/InvenTree/pull/6526 - Adds "enabled" field to the PartTestTemplate API endpoint - Adds "enabled" filter to the PartTestTemplate list diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index e10e74cf81..eee46120f0 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -1,5 +1,8 @@ """JSON API for the Order app.""" +from decimal import Decimal +from typing import cast + from django.contrib.auth import authenticate, login from django.db import transaction from django.db.models import F, Q @@ -481,6 +484,14 @@ class PurchaseOrderLineItemMixin: return self.serializer_class(*args, **kwargs) + def perform_update(self, serializer): + """Override the perform_update method to auto-update pricing if required.""" + super().perform_update(serializer) + + # possibly auto-update pricing based on the supplier part pricing data + if serializer.validated_data.get('auto_pricing', True): + serializer.instance.update_pricing() + class PurchaseOrderLineItemList( PurchaseOrderLineItemMixin, APIDownloadMixin, ListCreateDestroyAPIView @@ -493,6 +504,44 @@ class PurchaseOrderLineItemList( filterset_class = PurchaseOrderLineItemFilter + def create(self, request, *args, **kwargs): + """Create or update a new PurchaseOrderLineItem object.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = cast(dict, serializer.validated_data) + + # possibly merge duplicate items + line_item = None + if data.get('merge_items', True): + other_line = models.PurchaseOrderLineItem.objects.filter( + part=data.get('part'), + order=data.get('order'), + target_date=data.get('target_date'), + destination=data.get('destination'), + ).first() + + if other_line is not None: + other_line.quantity += Decimal(data.get('quantity', 0)) + other_line.save() + + line_item = other_line + + # otherwise create a new line item + if line_item is None: + line_item = serializer.save() + + # possibly auto-update pricing based on the supplier part pricing data + if data.get('auto_pricing', True) and isinstance( + line_item, models.PurchaseOrderLineItem + ): + line_item.update_pricing() + + serializer = serializers.PurchaseOrderLineItemSerializer(line_item) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + def filter_queryset(self, queryset): """Additional filtering options.""" params = self.request.query_params diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 3254e8d8ae..80ac2f616c 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -1439,6 +1439,17 @@ class PurchaseOrderLineItem(OrderLineItem): r = self.quantity - self.received return max(r, 0) + def update_pricing(self): + """Update pricing information based on the supplier part data.""" + if self.part: + price = self.part.get_price(self.quantity) + + if price is None: + return + + self.purchase_price = Decimal(price) / Decimal(self.quantity) + self.save() + class PurchaseOrderExtraLine(OrderExtraLine): """Model for a single ExtraLine in a PurchaseOrder. diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index ec5ba4d200..3405e7b8b8 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -340,11 +340,13 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): 'received', 'purchase_price', 'purchase_price_currency', + 'auto_pricing', 'destination', 'destination_detail', 'target_date', 'total_price', 'link', + 'merge_items', ] def __init__(self, *args, **kwargs): @@ -362,6 +364,10 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): if order_detail is not True: self.fields.pop('order_detail') + def skip_create_fields(self): + """Return a list of fields to skip when creating a new object.""" + return ['auto_pricing', 'merge_items'] + super().skip_create_fields() + @staticmethod def annotate_queryset(queryset): """Add some extra annotations to this queryset. @@ -419,6 +425,14 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): purchase_price = InvenTreeMoneySerializer(allow_null=True) + auto_pricing = serializers.BooleanField( + label=_('Auto Pricing'), + help_text=_( + 'Automatically calculate purchase price based on supplier part data' + ), + default=True, + ) + destination_detail = stock.serializers.LocationBriefSerializer( source='get_destination', read_only=True ) @@ -429,6 +443,14 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer): order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False) + merge_items = serializers.BooleanField( + label=_('Merge Items'), + help_text=_( + 'Merge items with the same part, destination and target date into one line item' + ), + default=True, + ) + def validate(self, data): """Custom validation for the serializer. diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index c971c3d6a5..c00f577b07 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -14,7 +14,7 @@ from icalendar import Calendar from rest_framework import status from common.settings import currency_codes -from company.models import Company +from company.models import Company, SupplierPart, SupplierPriceBreak from InvenTree.status_codes import ( PurchaseOrderStatus, ReturnOrderLineStatus, @@ -675,6 +675,94 @@ class PurchaseOrderLineItemTest(OrderTest): # We should have 2 less PurchaseOrderLineItems after deletign them self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2) + def test_po_line_merge_pricing(self): + """Test that we can create a new PurchaseOrderLineItem via the API.""" + self.assignRole('purchase_order.add') + self.generate_exchange_rates() + + su = Company.objects.get(pk=1) + sp = SupplierPart.objects.get(pk=1) + po = models.PurchaseOrder.objects.create(supplier=su, reference='PO-1234567890') + SupplierPriceBreak.objects.create(part=sp, quantity=1, price=Money(1, 'USD')) + SupplierPriceBreak.objects.create(part=sp, quantity=10, price=Money(0.5, 'USD')) + + li1 = self.post( + reverse('api-po-line-list'), + { + 'order': po.pk, + 'part': sp.pk, + 'quantity': 1, + 'auto_pricing': True, + 'merge_items': False, + }, + expected_code=201, + ).json() + self.assertEqual(float(li1['purchase_price']), 1) + + li2 = self.post( + reverse('api-po-line-list'), + { + 'order': po.pk, + 'part': sp.pk, + 'quantity': 10, + 'auto_pricing': True, + 'merge_items': False, + }, + expected_code=201, + ).json() + self.assertEqual(float(li2['purchase_price']), 0.5) + + # test that items where not merged + self.assertNotEqual(li1['pk'], li2['pk']) + + li3 = self.post( + reverse('api-po-line-list'), + { + 'order': po.pk, + 'part': sp.pk, + 'quantity': 9, + 'auto_pricing': True, + 'merge_items': True, + }, + expected_code=201, + ).json() + + # test that items where merged + self.assertEqual(li1['pk'], li3['pk']) + + # test that price was recalculated + self.assertEqual(float(li3['purchase_price']), 0.5) + + # test that pricing will be not recalculated if auto_pricing is False + li4 = self.post( + reverse('api-po-line-list'), + { + 'order': po.pk, + 'part': sp.pk, + 'quantity': 1, + 'auto_pricing': False, + 'purchase_price': 0.5, + 'merge_items': False, + }, + expected_code=201, + ).json() + self.assertEqual(float(li4['purchase_price']), 0.5) + + # test that pricing is correctly recalculated if auto_pricing is True for update + li5 = self.patch( + reverse('api-po-line-detail', kwargs={'pk': li4['pk']}), + {**li4, 'quantity': 5, 'auto_pricing': False}, + expected_code=200, + ).json() + self.assertEqual(float(li5['purchase_price']), 0.5) + + li5 = self.patch( + reverse('api-po-line-detail', kwargs={'pk': li4['pk']}), + {**li4, 'quantity': 5, 'auto_pricing': True}, + expected_code=200, + ).json() + self.assertEqual(float(li5['purchase_price']), 1) + class PurchaseOrderDownloadTest(OrderTest): """Unit tests for downloading PurchaseOrder data via the API endpoint.""" diff --git a/InvenTree/templates/js/translated/purchase_order.js b/InvenTree/templates/js/translated/purchase_order.js index ac4d7d0dd2..47379dc2a3 100644 --- a/InvenTree/templates/js/translated/purchase_order.js +++ b/InvenTree/templates/js/translated/purchase_order.js @@ -225,7 +225,7 @@ function createPurchaseOrder(options={}) { }; } - constructForm('{% url "api-po-list" %}', { + constructForm('{% url "api-po-list" %}?supplier_detail=true', { method: 'POST', fields: fields, groups: groups, @@ -268,7 +268,6 @@ function duplicatePurchaseOrder(order_id, options={}) { /* Construct a set of fields for the PurchaseOrderLineItem form */ function poLineItemFields(options={}) { - var fields = { order: { filters: { @@ -286,8 +285,6 @@ function poLineItemFields(options={}) { // If the pack_quantity != 1, add a note to the field var pack_quantity = 1; var units = ''; - var supplier_part_id = value; - var quantity = getFormFieldValue('quantity', {}, opts); // Remove any existing note fields $(opts.modal).find('#info-pack-size').remove(); @@ -314,37 +311,6 @@ function poLineItemFields(options={}) { var txt = ` {% trans "Pack Quantity" %}: ${formatDecimal(pack_quantity)} ${units}`; $(opts.modal).find('#hint_id_quantity').after(`
`); } - }).then(function() { - // Update pricing data (if available) - if (options.update_pricing) { - inventreeGet( - '{% url "api-part-supplier-price-list" %}', - { - part: supplier_part_id, - ordering: 'quantity', - }, - { - success: function(response) { - // Returned prices are in increasing order of quantity - if (response.length > 0) { - let index = 0; - - for (var idx = 0; idx < response.length; idx++) { - if (response[idx].quantity > quantity) { - break; - } - - index = idx; - } - - // Update price and currency data in the form - updateFieldValue('purchase_price', response[index].price, {}, opts); - updateFieldValue('purchase_price_currency', response[index].price_currency, {}, opts); - } - } - } - ); - } }); }, secondary: { @@ -377,10 +343,20 @@ function poLineItemFields(options={}) { reference: {}, purchase_price: { icon: 'fa-dollar-sign', + onEdit: function(value, name, field, opts) { + updateFieldValue('auto_pricing', value === '', {}, opts); + } }, purchase_price_currency: { icon: 'fa-coins', }, + auto_pricing: { + onEdit: function(value, name, field, opts) { + if (value) { + updateFieldValue('purchase_price', '', {}, opts); + } + } + }, target_date: { icon: 'fa-calendar-alt', }, @@ -411,6 +387,10 @@ function poLineItemFields(options={}) { fields.target_date.value = options.target_date; } + if (options.create) { + fields.merge_items = {}; + } + return fields; } @@ -425,6 +405,7 @@ function createPurchaseOrderLineItem(order, options={}) { currency: options.currency, target_date: options.target_date, update_pricing: true, + create: true, }); constructForm('{% url "api-po-line-list" %}', { @@ -697,6 +678,15 @@ function orderParts(parts_list, options={}) { } ); + const merge_item_input = constructField( + `merge_item_${pk}`, + { + type: 'boolean', + value: true, + }, + { hideLabels: true }, + ); + let buttons = ''; if (parts.length > 1) { @@ -723,6 +713,7 @@ function orderParts(parts_list, options={}) {