Global batch code (#12912)

* Add top-level batch code when receiving items

* Add API test

* Bump API version
This commit is contained in:
Oliver
2026-09-24 07:32:12 +10:00
committed by GitHub
parent 63f81714fb
commit c4c493d3fd
6 changed files with 91 additions and 9 deletions
@@ -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
+16 -1
View File
@@ -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:
+33
View File
@@ -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)
+1
View File
@@ -29,6 +29,7 @@ dist-ssr
/playwright/.cache/
/.nyc_output/
/coverage/
/flakiness-report/
# Report generation
stats.html
+35 -5
View File
@@ -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<string>('');
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: <InvenTreeIcon icon='batch_code' />,
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,
@@ -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