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:
@@ -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
|
||||||
|
|
||||||
|
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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'),
|
||||||
|
@@ -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)."""
|
||||||
|
@@ -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]);
|
||||||
|
|
||||||
|
@@ -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':
|
||||||
|
@@ -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({
|
||||||
|
@@ -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({
|
||||||
|
Reference in New Issue
Block a user