2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-23 17:37:38 +00:00

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 <code@mjmair.com>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
karan Lala
2025-10-19 17:47:05 -07:00
committed by GitHub
parent 2cae87d138
commit 5425ace1fa
8 changed files with 159 additions and 21 deletions

View File

@@ -1,12 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v411 -> 2025-06-19 : https://github.com/inventree/InvenTree/pull/10630
- Editorialy changes to machine api - no functional changes - Editorialy changes to machine api - no functional changes

View File

@@ -1022,6 +1022,9 @@ class PartMixin(SerializerContextMixin):
if str2bool(self.request.query_params.get('parameters', False)): if str2bool(self.request.query_params.get('parameters', False)):
queryset = queryset.prefetch_related('parameters', 'parameters__template') queryset = queryset.prefetch_related('parameters', 'parameters__template')
if str2bool(self.request.query_params.get('price_breaks', True)):
queryset = queryset.prefetch_related('salepricebreaks')
return queryset return queryset
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
@@ -1061,6 +1064,7 @@ class PartOutputOptions(OutputConfiguration):
InvenTreeOutputOption('category_detail'), InvenTreeOutputOption('category_detail'),
InvenTreeOutputOption('location_detail'), InvenTreeOutputOption('location_detail'),
InvenTreeOutputOption('path_detail'), InvenTreeOutputOption('path_detail'),
InvenTreeOutputOption('price_breaks'),
] ]

View File

@@ -213,10 +213,14 @@ class PartTestTemplateSerializer(
@register_importer() @register_importer()
class PartSalePriceSerializer( class PartSalePriceSerializer(
DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTree.serializers.InvenTreeModelSerializer,
): ):
"""Serializer for sale prices for Part model.""" """Serializer for sale prices for Part model."""
no_filters = True
class Meta: class Meta:
"""Metaclass defining serializer fields.""" """Metaclass defining serializer fields."""
@@ -692,6 +696,7 @@ class PartSerializer(
'pricing_max', 'pricing_max',
'pricing_updated', 'pricing_updated',
'responsible', 'responsible',
'price_breaks',
# Annotated fields # Annotated fields
'allocated_to_build_orders', 'allocated_to_build_orders',
'allocated_to_sales_orders', 'allocated_to_sales_orders',
@@ -731,6 +736,7 @@ class PartSerializer(
create = kwargs.pop('create', False) create = kwargs.pop('create', False)
# pricing = kwargs.pop('pricing', True) # pricing = kwargs.pop('pricing', True)
# path_detail = kwargs.pop('path_detail', False) # path_detail = kwargs.pop('path_detail', False)
# price_breaks = kwargs.pop('price_breaks', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -750,6 +756,9 @@ class PartSerializer(
if not path_detail: if not path_detail:
self.fields.pop('category_path', None) self.fields.pop('category_path', None)
if not price_breaks:
self.fields.pop('price_breaks', None)
if not pricing: if not pricing:
self.fields.pop('pricing_min', None) self.fields.pop('pricing_min', None)
self.fields.pop('pricing_max', None) self.fields.pop('pricing_max', None)
@@ -1032,6 +1041,12 @@ class PartSerializer(
PartParameterSerializer(many=True, read_only=True, allow_null=True) 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 # Extra fields used only for creation of a new Part instance
duplicate = DuplicatePartSerializer( duplicate = DuplicatePartSerializer(
label=_('Duplicate Part'), label=_('Duplicate Part'),

View File

@@ -32,6 +32,7 @@ from part.models import (
PartParameter, PartParameter,
PartParameterTemplate, PartParameterTemplate,
PartRelated, PartRelated,
PartSellPriceBreak,
PartTestTemplate, PartTestTemplate,
) )
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@@ -2059,8 +2060,8 @@ class PartListTests(PartAPITestBase):
with CaptureQueriesContext(connection) as ctx: with CaptureQueriesContext(connection) as ctx:
self.get(url, query, expected_code=200) self.get(url, query, expected_code=200)
# No more than 20 database queries # No more than 25 database queries
self.assertLess(len(ctx), 20) self.assertLess(len(ctx), 25)
# Test 'category_detail' annotation # Test 'category_detail' annotation
for b in [False, True]: for b in [False, True]:
@@ -2076,6 +2077,80 @@ class PartListTests(PartAPITestBase):
# No more than 20 DB queries # No more than 20 DB queries
self.assertLessEqual(len(ctx), 20) 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): class PartNotesTests(InvenTreeAPITestCase):
"""Tests for the 'notes' field (markdown field).""" """Tests for the 'notes' field (markdown field)."""

View File

@@ -174,8 +174,14 @@ export function ApiForm({
); );
const defaultValues: FieldValues = useMemo(() => { const defaultValues: FieldValues = useMemo(() => {
const defaultValuesMap = mapFields(fields ?? {}, (_path, field) => { const defaultValuesMap = mapFields(props.fields ?? {}, (_path, field) => {
return field.value ?? field.default ?? undefined; 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 // If the user has specified initial data, that overrides default values
@@ -188,7 +194,6 @@ export function ApiForm({
} }
}); });
} }
return defaultValuesMap; return defaultValuesMap;
}, [props.fields, props.initialData]); }, [props.fields, props.initialData]);

View File

@@ -56,7 +56,11 @@ export function ApiFormField({
// hook up the value state to the input field // hook up the value state to the input field
if (definition.value !== undefined) { if (definition.value !== undefined) {
field.onChange(definition.value); field.onChange(
definition.adjustValue
? definition.adjustValue(definition.value)
: definition.value
);
} }
}, [definition.value]); }, [definition.value]);
@@ -112,7 +116,7 @@ export function ApiFormField({
switch (definition.field_type) { switch (definition.field_type) {
case 'integer': case 'integer':
val = Number.parseInt(value) ?? ''; val = Number.parseInt(value, 10) ?? '';
break; break;
case 'decimal': case 'decimal':
case 'float': case 'float':

View File

@@ -101,14 +101,36 @@ export function useSalesOrderFields({
export function useSalesOrderLineItemFields({ export function useSalesOrderLineItemFields({
customerId, customerId,
orderId, orderId,
create create,
currency
}: { }: {
customerId?: number; customerId?: number;
orderId?: number; orderId?: number;
create?: boolean; create?: boolean;
currency?: string;
}): ApiFormFieldSet { }): ApiFormFieldSet {
const fields = useMemo(() => { const [salePrice, setSalePrice] = useState<string>('0');
return { const [partCurrency, setPartCurrency] = useState<string>(currency ?? '');
const [part, setPart] = useState<any>({});
const [quantity, setQuantity] = useState<string>('');
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: { order: {
filters: { filters: {
customer_detail: true customer_detail: true
@@ -119,20 +141,29 @@ export function useSalesOrderLineItemFields({
part: { part: {
filters: { filters: {
active: true, active: true,
salable: true salable: true,
} price_breaks: true
},
onValueChange: (_: any, record?: any) => setPart(record)
}, },
reference: {}, reference: {},
quantity: {}, quantity: {
sale_price: {}, onValueChange: setQuantity
sale_price_currency: {}, },
sale_price: {
value: salePrice
},
sale_price_currency: {
value: partCurrency,
onValueChange: setPartCurrency
},
target_date: {}, target_date: {},
notes: {}, notes: {},
link: {} link: {}
}; };
}, []);
return fields; return fields;
}, [salePrice, partCurrency, orderId, create]);
} }
function SalesOrderAllocateLineRow({ function SalesOrderAllocateLineRow({

View File

@@ -148,7 +148,7 @@ export default function SalesOrderLineItemTable({
0 0
); );
let color: string | undefined = undefined; let color: string | undefined;
let text = `${formatDecimal(available)}`; let text = `${formatDecimal(available)}`;
const extra: ReactNode[] = []; const extra: ReactNode[] = [];
@@ -241,7 +241,8 @@ export default function SalesOrderLineItemTable({
const createLineFields = useSalesOrderLineItemFields({ const createLineFields = useSalesOrderLineItemFields({
orderId: orderId, orderId: orderId,
customerId: customerId, customerId: customerId,
create: true create: true,
currency: currency
}); });
const newLine = useCreateApiFormModal({ const newLine = useCreateApiFormModal({