From 5425ace1fa2e34b697abeee34ae3908c8d35449c Mon Sep 17 00:00:00 2001 From: karan Lala Date: Sun, 19 Oct 2025 17:47:05 -0700 Subject: [PATCH] CreateSalesOrderLineItem: auto-apply sales price based on part price_breaks, currency, and quantity (#10549) * updated part api mixin and part serializer to include salepricebreaks when explicitly queried * fixed bug in ApiForm that prevents form field value from updating correctly * CreateSalesOrderLineItem form now automatically applies price break based on currency and quantity for a part * bumped API version due to PartsList api price_breaks query param * fixed incorrect field names * Remove whitespace * Increase database query limit from 20 to 25 * updated ApiForm to propagate false and 0 as values to their respective fields, updated ApiFormField to eagerly apply AdjustValue to apply adjustments when onChange does not fire (when the value does not truly change) * updated pagination number check to a range instead of discreet values. --------- Co-authored-by: Matthias Mair Co-authored-by: Oliver --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/part/api.py | 4 + src/backend/InvenTree/part/serializers.py | 17 +++- src/backend/InvenTree/part/test_api.py | 79 ++++++++++++++++++- src/frontend/src/components/forms/ApiForm.tsx | 11 ++- .../components/forms/fields/ApiFormField.tsx | 8 +- src/frontend/src/forms/SalesOrderForms.tsx | 51 +++++++++--- .../tables/sales/SalesOrderLineItemTable.tsx | 5 +- 8 files changed, 159 insertions(+), 21 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index ad564185b8..22af1dad31 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 411 +INVENTREE_API_VERSION = 412 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v412 -> 2025-10-19 : https://github.com/inventree/InvenTree/pull/10549 + - added a new query parameter for the PartsList api: price_breaks (default: false) + v411 -> 2025-06-19 : https://github.com/inventree/InvenTree/pull/10630 - Editorialy changes to machine api - no functional changes diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index bf7814a08d..df5397e98b 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1022,6 +1022,9 @@ class PartMixin(SerializerContextMixin): if str2bool(self.request.query_params.get('parameters', False)): queryset = queryset.prefetch_related('parameters', 'parameters__template') + if str2bool(self.request.query_params.get('price_breaks', True)): + queryset = queryset.prefetch_related('salepricebreaks') + return queryset def get_serializer(self, *args, **kwargs): @@ -1061,6 +1064,7 @@ class PartOutputOptions(OutputConfiguration): InvenTreeOutputOption('category_detail'), InvenTreeOutputOption('location_detail'), InvenTreeOutputOption('path_detail'), + InvenTreeOutputOption('price_breaks'), ] diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 2860741a32..681cd5a507 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -213,10 +213,14 @@ class PartTestTemplateSerializer( @register_importer() class PartSalePriceSerializer( - DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer + InvenTree.serializers.FilterableSerializerMixin, + DataImportExportSerializerMixin, + InvenTree.serializers.InvenTreeModelSerializer, ): """Serializer for sale prices for Part model.""" + no_filters = True + class Meta: """Metaclass defining serializer fields.""" @@ -692,6 +696,7 @@ class PartSerializer( 'pricing_max', 'pricing_updated', 'responsible', + 'price_breaks', # Annotated fields 'allocated_to_build_orders', 'allocated_to_sales_orders', @@ -731,6 +736,7 @@ class PartSerializer( create = kwargs.pop('create', False) # pricing = kwargs.pop('pricing', True) # path_detail = kwargs.pop('path_detail', False) + # price_breaks = kwargs.pop('price_breaks', False) super().__init__(*args, **kwargs) @@ -750,6 +756,9 @@ class PartSerializer( if not path_detail: self.fields.pop('category_path', None) + if not price_breaks: + self.fields.pop('price_breaks', None) + if not pricing: self.fields.pop('pricing_min', None) self.fields.pop('pricing_max', None) @@ -1032,6 +1041,12 @@ class PartSerializer( PartParameterSerializer(many=True, read_only=True, allow_null=True) ) + price_breaks = enable_filter( + PartSalePriceSerializer(source='salepricebreaks', many=True, read_only=True), + False, + filter_name='price_breaks', + ) + # Extra fields used only for creation of a new Part instance duplicate = DuplicatePartSerializer( label=_('Duplicate Part'), diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 96f5770cbc..f2d99f20fc 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -32,6 +32,7 @@ from part.models import ( PartParameter, PartParameterTemplate, PartRelated, + PartSellPriceBreak, PartTestTemplate, ) from stock.models import StockItem, StockLocation @@ -2059,8 +2060,8 @@ class PartListTests(PartAPITestBase): with CaptureQueriesContext(connection) as ctx: self.get(url, query, expected_code=200) - # No more than 20 database queries - self.assertLess(len(ctx), 20) + # No more than 25 database queries + self.assertLess(len(ctx), 25) # Test 'category_detail' annotation for b in [False, True]: @@ -2076,6 +2077,80 @@ class PartListTests(PartAPITestBase): # No more than 20 DB queries self.assertLessEqual(len(ctx), 20) + def test_price_breaks(self): + """Test that price_breaks parameter works correctly and efficiently.""" + url = reverse('api-part-list') + + # Create some parts with sale price breaks + for i in range(5): + part = Part.objects.create( + name=f'Part with breaks {i}', + description='A part with sale price breaks', + category=PartCategory.objects.first(), + salable=True, + ) + + # Create multiple price breaks for each part + for qty, price in [(1, 10 + i), (10, 9 + i), (100, 8 + i)]: + PartSellPriceBreak.objects.create( + part=part, quantity=qty, price=price, price_currency='USD' + ) + + # Test 1: Without price_breaks parameter (default is False, field should not be included) + response = self.get( + url, {'limit': 5, 'search': 'Part with breaks'}, expected_code=200 + ) + + self.assertEqual(len(response.data['results']), 5) + first_result = response.data['results'][0] + self.assertNotIn('price_breaks', first_result) + + # Test 2: Explicitly with price_breaks=false + with CaptureQueriesContext(connection) as ctx: + response = self.get( + url, + {'limit': 5, 'price_breaks': False, 'search': 'Part with breaks'}, + expected_code=200, + ) + + first_result = response.data['results'][0] + self.assertNotIn('price_breaks', first_result) + + query_count_without_price_breaks = len(ctx) + + # Test 3: Explicitly with price_breaks=true + with CaptureQueriesContext(connection) as ctx: + response = self.get( + url, + {'limit': 5, 'price_breaks': True, 'search': 'Part with breaks'}, + expected_code=200, + ) + + # Should have price_breaks field with data + for result in response.data['results']: + self.assertIn('price_breaks', result) + self.assertIsInstance(result['price_breaks'], list) + self.assertEqual(len(result['price_breaks']), 3) + + for pb in result['price_breaks']: + self.assertIn('pk', pb) + self.assertIn('quantity', pb) + self.assertIn('price', pb) + self.assertIn('price_currency', pb) + + # Make sure there's no n+1 query problem + query_count_with_price_breaks = len(ctx) + query_difference = ( + query_count_with_price_breaks - query_count_without_price_breaks + ) + + # There are 2 additional queries, 1 for the salepricebreak subselect and 1 for Currency codes because of InvenTreeCurrencySerializer + self.assertLessEqual( + query_difference, + 2, + f'Query count difference too high: {query_difference} (with: {query_count_with_price_breaks}, without: {query_count_without_price_breaks})', + ) + class PartNotesTests(InvenTreeAPITestCase): """Tests for the 'notes' field (markdown field).""" diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index c442fb5820..3e1534544e 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -174,8 +174,14 @@ export function ApiForm({ ); const defaultValues: FieldValues = useMemo(() => { - const defaultValuesMap = mapFields(fields ?? {}, (_path, field) => { - return field.value ?? field.default ?? undefined; + const defaultValuesMap = mapFields(props.fields ?? {}, (_path, field) => { + if (field.value !== undefined && field.value !== null) { + return field.value; + } + if (field.default !== undefined && field.default !== null) { + return field.default; + } + return undefined; }); // If the user has specified initial data, that overrides default values @@ -188,7 +194,6 @@ export function ApiForm({ } }); } - return defaultValuesMap; }, [props.fields, props.initialData]); diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index e5d56bbdd4..7e2dca1089 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -56,7 +56,11 @@ export function ApiFormField({ // hook up the value state to the input field if (definition.value !== undefined) { - field.onChange(definition.value); + field.onChange( + definition.adjustValue + ? definition.adjustValue(definition.value) + : definition.value + ); } }, [definition.value]); @@ -112,7 +116,7 @@ export function ApiFormField({ switch (definition.field_type) { case 'integer': - val = Number.parseInt(value) ?? ''; + val = Number.parseInt(value, 10) ?? ''; break; case 'decimal': case 'float': diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index 4b3b35252a..785c2bca53 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -101,14 +101,36 @@ export function useSalesOrderFields({ export function useSalesOrderLineItemFields({ customerId, orderId, - create + create, + currency }: { customerId?: number; orderId?: number; create?: boolean; + currency?: string; }): ApiFormFieldSet { - const fields = useMemo(() => { - return { + const [salePrice, setSalePrice] = useState('0'); + const [partCurrency, setPartCurrency] = useState(currency ?? ''); + const [part, setPart] = useState({}); + const [quantity, setQuantity] = useState(''); + + useEffect(() => { + if (!create || !part || !part.price_breaks) return; + + const qty = quantity ? Number.parseInt(quantity, 10) : 0; + + const applicablePriceBreaks = part.price_breaks + .filter( + (pb: any) => pb.price_currency == partCurrency && qty <= pb.quantity + ) + .sort((a: any, b: any) => a.quantity - b.quantity); + + if (applicablePriceBreaks.length) + setSalePrice(applicablePriceBreaks[0].price); + }, [part, quantity, partCurrency, create]); + + return useMemo(() => { + const fields: ApiFormFieldSet = { order: { filters: { customer_detail: true @@ -119,20 +141,29 @@ export function useSalesOrderLineItemFields({ part: { filters: { active: true, - salable: true - } + salable: true, + price_breaks: true + }, + onValueChange: (_: any, record?: any) => setPart(record) }, reference: {}, - quantity: {}, - sale_price: {}, - sale_price_currency: {}, + quantity: { + onValueChange: setQuantity + }, + sale_price: { + value: salePrice + }, + sale_price_currency: { + value: partCurrency, + onValueChange: setPartCurrency + }, target_date: {}, notes: {}, link: {} }; - }, []); - return fields; + return fields; + }, [salePrice, partCurrency, orderId, create]); } function SalesOrderAllocateLineRow({ diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 336f55b1b9..698fec0bba 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -148,7 +148,7 @@ export default function SalesOrderLineItemTable({ 0 ); - let color: string | undefined = undefined; + let color: string | undefined; let text = `${formatDecimal(available)}`; const extra: ReactNode[] = []; @@ -241,7 +241,8 @@ export default function SalesOrderLineItemTable({ const createLineFields = useSalesOrderLineItemFields({ orderId: orderId, customerId: customerId, - create: true + create: true, + currency: currency }); const newLine = useCreateApiFormModal({