diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 20d59d51c1..a37cfc736e 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 549 +INVENTREE_API_VERSION = 550 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v550 -> 2026-09-22 : https://github.com/inventree/InvenTree/pull/12912 + - Adds a top-level 'batch_code' field to the PurchaseOrderReceive API endpoint + v549 -> 2026-09-22 : https://github.com/inventree/InvenTree/pull/12908 - Adds filtering / ordering / searching options to the SelectionList API endpoint diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 4f40faa7f7..f19c73a172 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -994,7 +994,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): class Meta: """Metaclass options.""" - fields = ['items', 'location'] + fields = ['items', 'location', 'batch_code'] items = PurchaseOrderLineItemReceiveSerializer(many=True) @@ -1007,6 +1007,16 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): help_text=_('Select destination location for received items'), ) + batch_code = serializers.CharField( + label=_('Batch Code'), + help_text=_( + 'Enter batch code for incoming stock items - applied to any line item which does not specify its own batch code' + ), + required=False, + default='', + allow_blank=True, + ) + def validate(self, data): """Custom validation for the serializer. @@ -1019,6 +1029,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): items = data.get('items', []) location = data.get('location', order.destination) + batch_code = data.get('batch_code', '') if len(items) == 0: raise ValidationError(_('Line items must be provided')) @@ -1050,6 +1061,10 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): 'location': _('Destination location must be specified') }) + # If no batch code is specified for this line item, fall back to the top-level value + if not item.get('batch_code'): + item['batch_code'] = batch_code + barcode = item.get('barcode', '') if barcode: diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index fbec1bb160..a8f63cb96c 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -1532,6 +1532,39 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(item_1.batch, 'B-abc-123') self.assertEqual(item_2.batch, 'B-xyz-789') + def test_top_level_batch_code(self): + """Test the top-level 'batch_code' field. + + - Applied to any line item which does not specify its own batch code + - A line item's own 'batch_code' value takes precedence + """ + line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) + line_2 = models.PurchaseOrderLineItem.objects.get(pk=2) + + data = { + 'items': [ + {'line_item': 1, 'quantity': 10}, + {'line_item': 2, 'quantity': 10, 'batch_code': 'B-xyz-789'}, + ], + 'location': 1, + 'batch_code': 'B-top-level', + } + + n = StockItem.objects.count() + + self.post(self.url, data, expected_code=201) + + self.assertEqual(n + 2, StockItem.objects.count()) + + item_1 = StockItem.objects.filter(supplier_part=line_1.part).first() + item_2 = StockItem.objects.filter(supplier_part=line_2.part).first() + + # Line item 1 did not specify its own batch code - falls back to top-level value + self.assertEqual(item_1.batch, 'B-top-level') + + # Line item 2 specified its own batch code - takes precedence + self.assertEqual(item_2.batch, 'B-xyz-789') + def test_serial_numbers(self): """Test that we can supply a 'serial number' when receiving items.""" line_1 = models.PurchaseOrderLineItem.objects.get(pk=1) diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore index 5d9699d78c..139bf919c1 100644 --- a/src/frontend/.gitignore +++ b/src/frontend/.gitignore @@ -29,6 +29,7 @@ dist-ssr /playwright/.cache/ /.nyc_output/ /coverage/ +/flakiness-report/ # Report generation stats.html diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index e2cc8cdce5..023a4be345 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -11,7 +11,7 @@ import { Table, TextInput } from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; +import { useDisclosure, useId } from '@mantine/hooks'; import { IconAddressBook, IconCalendar, @@ -345,11 +345,13 @@ export function usePurchaseOrderFields({ function LineItemFormRow({ props, record, - statuses + statuses, + topLevelBatchCode }: Readonly<{ props: TableFieldRowProps; record: any; statuses: any; + topLevelBatchCode?: string; }>) { // Barcode Modal state const [opened, { open, close }] = useDisclosure(false, { @@ -771,11 +773,13 @@ function LineItemFormRow({ onValueChange={(value) => { props.changeFn(props.rowId, 'batch_code', value); }} - fieldName='batch_code' + fieldName='line_batch_code' fieldDefinition={{ field_type: 'string', label: t`Batch Code`, - description: t`Enter batch code for received items`, + description: topLevelBatchCode + ? t`Overrides the top-level batch code ("${topLevelBatchCode}") for this line item` + : t`Enter batch code for received items`, value: props.item.batch_code, placeholderAutofill: true, placeholder: @@ -869,6 +873,8 @@ type LineItemsForm = { }; export function useReceiveLineItems(props: LineItemsForm) { + const modalId = useId(); + const stockStatusCodes = useMemo( () => getStatusCodeOptions(ModelType.stockitem), [] @@ -878,6 +884,14 @@ export function useReceiveLineItems(props: LineItemsForm) { return Object.fromEntries(props.items.map((item) => [item.pk, item])); }, [props.items]); + // Top-level batch code, applied to any line item which does not specify its own + const [batchCode, setBatchCode] = useState(''); + + const batchCodeGenerator = useBatchCodeGenerator({ + modalId, + initialQuery: { order: props.orderPk } + }); + const filteredItems = useMemo(() => { return props.items .filter((elem) => elem.quantity !== elem.received) @@ -928,6 +942,7 @@ export function useReceiveLineItems(props: LineItemsForm) { props={row} record={record} statuses={stockStatusCodes} + topLevelBatchCode={batchCode} key={row.rowId} /> ); @@ -945,12 +960,27 @@ export function useReceiveLineItems(props: LineItemsForm) { filters: { structural: false } + }, + batch_code: { + icon: , + value: batchCode, + onValueChange: setBatchCode, + placeholderAutofill: true, + placeholder: batchCodeGenerator.result && `${batchCodeGenerator.result}` } }; - }, [filteredItems, records, props.orderPk, stockStatusCodes]); + }, [ + filteredItems, + records, + props.orderPk, + stockStatusCodes, + batchCode, + batchCodeGenerator.result + ]); return useCreateApiFormModal({ ...props.formProps, + modalId, url: apiUrl(ApiEndpoints.purchase_order_receive, props.orderPk), title: t`Receive Line Items`, fields: fields, diff --git a/src/frontend/tests/pages/pui_purchasing.spec.ts b/src/frontend/tests/pages/pui_purchasing.spec.ts index efcb06d4cc..a83434f135 100644 --- a/src/frontend/tests/pages/pui_purchasing.spec.ts +++ b/src/frontend/tests/pages/pui_purchasing.spec.ts @@ -589,7 +589,7 @@ test('Purchase Orders - Receive Items', async ({ browser }) => { await page.getByLabel('action-button-add-note').click(); await page - .getByLabel('text-field-batch_code', { exact: true }) + .getByLabel('text-field-line_batch_code', { exact: true }) .fill('my-batch-code'); await page.getByLabel('text-field-packaging', { exact: true }).fill('bucket'); await page @@ -653,7 +653,7 @@ test('Purchase Orders - Custom Location', async ({ browser }) => { await page.getByLabel('action-button-assign-batch-').click(); await page - .getByLabel('text-field-batch_code', { exact: true }) + .getByLabel('text-field-line_batch_code', { exact: true }) .fill('po-custom-location-test'); // Short timeout to allow for debouncing