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(`
${txt}
`); } - }).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={}) { ${supplier_part_input} ${purchase_order_input} ${quantity_input} + ${merge_item_input} ${buttons} `; @@ -761,6 +752,7 @@ function orderParts(parts_list, options={}) { {% trans "Supplier Part" %} {% trans "Purchase Order" %} {% trans "Quantity" %} + {% trans "Merge" %} @@ -838,6 +830,10 @@ function orderParts(parts_list, options={}) { success: function(response) { pack_quantity = response.pack_quantity_native || 1; units = response.part_detail.units || ''; + if(response.supplier) { + order_filters.supplier = response.supplier; + options.supplier = response.supplier; + } } } ).then(function() { @@ -926,6 +922,7 @@ function orderParts(parts_list, options={}) { quantity: getFormFieldValue(`quantity_${pk}`, {type: 'decimal'}, opts), part: getFormFieldValue(`part_${pk}`, {}, opts), order: getFormFieldValue(`order_${pk}`, {}, opts), + merge_items: getFormFieldValue(`merge_item_${pk}`, {type: 'boolean'}, opts), }; // Duplicate the form options, to prevent 'field_suffix' override @@ -984,7 +981,7 @@ function orderParts(parts_list, options={}) { var pk = $(this).attr('pk'); // Launch dialog to create new purchase order - createPurchaseOrder({ + const poOptions = { onSuccess: function(response) { setRelatedFieldData( `order_${pk}`, @@ -992,7 +989,14 @@ function orderParts(parts_list, options={}) { opts ); } - }); + } + + if(options.supplier) { + poOptions.supplier = options.supplier; + poOptions.hide_supplier = true; + } + + createPurchaseOrder(poOptions); }); } }); diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 2c93175c24..9006d3442d 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -144,24 +144,24 @@ export function ApiFormField({ ); // Coerce the value to a numerical value - const numericalValue: number | undefined = useMemo(() => { - let val = 0; + const numericalValue: number | '' = useMemo(() => { + let val: number | '' = 0; switch (definition.field_type) { case 'integer': - val = parseInt(value) ?? 0; + val = parseInt(value) ?? ''; break; case 'decimal': case 'float': case 'number': - val = parseFloat(value) ?? 0; + val = parseFloat(value) ?? ''; break; default: break; } if (isNaN(val) || !isFinite(val)) { - val = 0; + val = ''; } return val; diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index abbf51e265..7fcdd80d58 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -11,6 +11,7 @@ import { IconUser, IconUsers } from '@tabler/icons-react'; +import { useEffect, useMemo, useState } from 'react'; import { ApiFormAdjustFilterType, @@ -20,45 +21,76 @@ import { /* * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance */ -export function purchaseOrderLineItemFields() { - let fields: ApiFormFieldSet = { - order: { - filters: { - supplier_detail: true - }, - hidden: true - }, - part: { - filters: { - part_detail: true, - supplier_detail: true - }, - adjustFilters: (value: ApiFormAdjustFilterType) => { - // TODO: Adjust part based on the supplier associated with the supplier - return value.filters; - } - }, - quantity: {}, - reference: {}, - purchase_price: { - icon: - }, - purchase_price_currency: { - icon: - }, - target_date: { - icon: - }, - destination: { - icon: - }, - notes: { - icon: - }, - link: { - icon: +export function usePurchaseOrderLineItemFields({ + create +}: { + create?: boolean; +}) { + const [purchasePrice, setPurchasePrice] = useState(''); + const [autoPricing, setAutoPricing] = useState(true); + + useEffect(() => { + if (autoPricing) { + setPurchasePrice(''); } - }; + }, [autoPricing]); + + useEffect(() => { + setAutoPricing(purchasePrice === ''); + }, [purchasePrice]); + + const fields = useMemo(() => { + const fields: ApiFormFieldSet = { + order: { + filters: { + supplier_detail: true + }, + hidden: true + }, + part: { + filters: { + part_detail: true, + supplier_detail: true + }, + adjustFilters: (value: ApiFormAdjustFilterType) => { + // TODO: Adjust part based on the supplier associated with the supplier + return value.filters; + } + }, + quantity: {}, + reference: {}, + purchase_price: { + icon: , + value: purchasePrice, + onValueChange: setPurchasePrice + }, + purchase_price_currency: { + icon: + }, + auto_pricing: { + value: autoPricing, + onValueChange: setAutoPricing + }, + target_date: { + icon: + }, + destination: { + icon: + }, + notes: { + icon: + }, + link: { + icon: + } + }; + + if (create) { + fields['merge_items'] = {}; + } + + return fields; + }, [create, autoPricing, purchasePrice]); return fields; } diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index 99b958eb6a..fbc273eca4 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -12,7 +12,7 @@ import { RenderStockLocation } from '../../components/render/Stock'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; import { UserRoles } from '../../enums/Roles'; -import { purchaseOrderLineItemFields } from '../../forms/PurchaseOrderForms'; +import { usePurchaseOrderLineItemFields } from '../../forms/PurchaseOrderForms'; import { getDetailUrl } from '../../functions/urls'; import { useCreateApiFormModal, @@ -178,7 +178,7 @@ export function PurchaseOrderLineItemTable({ const newLine = useCreateApiFormModal({ url: ApiEndpoints.purchase_order_line_list, title: t`Add Line Item`, - fields: purchaseOrderLineItemFields(), + fields: usePurchaseOrderLineItemFields({ create: true }), initialData: { order: orderId }, @@ -193,7 +193,7 @@ export function PurchaseOrderLineItemTable({ url: ApiEndpoints.purchase_order_line_list, pk: selectedLine, title: t`Edit Line Item`, - fields: purchaseOrderLineItemFields(), + fields: usePurchaseOrderLineItemFields({}), onFormSuccess: table.refreshTable });