mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-27 22:26:00 +00:00
Global batch code (#12912)
* Add top-level batch code when receiving items * Add API test * Bump API version
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""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 = """
|
||||||
|
|
||||||
|
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
|
v549 -> 2026-09-22 : https://github.com/inventree/InvenTree/pull/12908
|
||||||
- Adds filtering / ordering / searching options to the SelectionList API endpoint
|
- Adds filtering / ordering / searching options to the SelectionList API endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -994,7 +994,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass options."""
|
"""Metaclass options."""
|
||||||
|
|
||||||
fields = ['items', 'location']
|
fields = ['items', 'location', 'batch_code']
|
||||||
|
|
||||||
items = PurchaseOrderLineItemReceiveSerializer(many=True)
|
items = PurchaseOrderLineItemReceiveSerializer(many=True)
|
||||||
|
|
||||||
@@ -1007,6 +1007,16 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
help_text=_('Select destination location for received items'),
|
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):
|
def validate(self, data):
|
||||||
"""Custom validation for the serializer.
|
"""Custom validation for the serializer.
|
||||||
|
|
||||||
@@ -1019,6 +1029,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
items = data.get('items', [])
|
items = data.get('items', [])
|
||||||
|
|
||||||
location = data.get('location', order.destination)
|
location = data.get('location', order.destination)
|
||||||
|
batch_code = data.get('batch_code', '')
|
||||||
|
|
||||||
if len(items) == 0:
|
if len(items) == 0:
|
||||||
raise ValidationError(_('Line items must be provided'))
|
raise ValidationError(_('Line items must be provided'))
|
||||||
@@ -1050,6 +1061,10 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
|||||||
'location': _('Destination location must be specified')
|
'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', '')
|
barcode = item.get('barcode', '')
|
||||||
|
|
||||||
if barcode:
|
if barcode:
|
||||||
|
|||||||
@@ -1532,6 +1532,39 @@ class PurchaseOrderReceiveTest(OrderTest):
|
|||||||
self.assertEqual(item_1.batch, 'B-abc-123')
|
self.assertEqual(item_1.batch, 'B-abc-123')
|
||||||
self.assertEqual(item_2.batch, 'B-xyz-789')
|
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):
|
def test_serial_numbers(self):
|
||||||
"""Test that we can supply a 'serial number' when receiving items."""
|
"""Test that we can supply a 'serial number' when receiving items."""
|
||||||
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
line_1 = models.PurchaseOrderLineItem.objects.get(pk=1)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ dist-ssr
|
|||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/.nyc_output/
|
/.nyc_output/
|
||||||
/coverage/
|
/coverage/
|
||||||
|
/flakiness-report/
|
||||||
|
|
||||||
# Report generation
|
# Report generation
|
||||||
stats.html
|
stats.html
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
TextInput
|
TextInput
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure, useId } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconAddressBook,
|
IconAddressBook,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
@@ -345,11 +345,13 @@ export function usePurchaseOrderFields({
|
|||||||
function LineItemFormRow({
|
function LineItemFormRow({
|
||||||
props,
|
props,
|
||||||
record,
|
record,
|
||||||
statuses
|
statuses,
|
||||||
|
topLevelBatchCode
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
props: TableFieldRowProps;
|
props: TableFieldRowProps;
|
||||||
record: any;
|
record: any;
|
||||||
statuses: any;
|
statuses: any;
|
||||||
|
topLevelBatchCode?: string;
|
||||||
}>) {
|
}>) {
|
||||||
// Barcode Modal state
|
// Barcode Modal state
|
||||||
const [opened, { open, close }] = useDisclosure(false, {
|
const [opened, { open, close }] = useDisclosure(false, {
|
||||||
@@ -771,11 +773,13 @@ function LineItemFormRow({
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
props.changeFn(props.rowId, 'batch_code', value);
|
props.changeFn(props.rowId, 'batch_code', value);
|
||||||
}}
|
}}
|
||||||
fieldName='batch_code'
|
fieldName='line_batch_code'
|
||||||
fieldDefinition={{
|
fieldDefinition={{
|
||||||
field_type: 'string',
|
field_type: 'string',
|
||||||
label: t`Batch Code`,
|
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,
|
value: props.item.batch_code,
|
||||||
placeholderAutofill: true,
|
placeholderAutofill: true,
|
||||||
placeholder:
|
placeholder:
|
||||||
@@ -869,6 +873,8 @@ type LineItemsForm = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useReceiveLineItems(props: LineItemsForm) {
|
export function useReceiveLineItems(props: LineItemsForm) {
|
||||||
|
const modalId = useId();
|
||||||
|
|
||||||
const stockStatusCodes = useMemo(
|
const stockStatusCodes = useMemo(
|
||||||
() => getStatusCodeOptions(ModelType.stockitem),
|
() => getStatusCodeOptions(ModelType.stockitem),
|
||||||
[]
|
[]
|
||||||
@@ -878,6 +884,14 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
|||||||
return Object.fromEntries(props.items.map((item) => [item.pk, item]));
|
return Object.fromEntries(props.items.map((item) => [item.pk, item]));
|
||||||
}, [props.items]);
|
}, [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(() => {
|
const filteredItems = useMemo(() => {
|
||||||
return props.items
|
return props.items
|
||||||
.filter((elem) => elem.quantity !== elem.received)
|
.filter((elem) => elem.quantity !== elem.received)
|
||||||
@@ -928,6 +942,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
|||||||
props={row}
|
props={row}
|
||||||
record={record}
|
record={record}
|
||||||
statuses={stockStatusCodes}
|
statuses={stockStatusCodes}
|
||||||
|
topLevelBatchCode={batchCode}
|
||||||
key={row.rowId}
|
key={row.rowId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -945,12 +960,27 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
|||||||
filters: {
|
filters: {
|
||||||
structural: false
|
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({
|
return useCreateApiFormModal({
|
||||||
...props.formProps,
|
...props.formProps,
|
||||||
|
modalId,
|
||||||
url: apiUrl(ApiEndpoints.purchase_order_receive, props.orderPk),
|
url: apiUrl(ApiEndpoints.purchase_order_receive, props.orderPk),
|
||||||
title: t`Receive Line Items`,
|
title: t`Receive Line Items`,
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
|
|||||||
await page.getByLabel('action-button-add-note').click();
|
await page.getByLabel('action-button-add-note').click();
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByLabel('text-field-batch_code', { exact: true })
|
.getByLabel('text-field-line_batch_code', { exact: true })
|
||||||
.fill('my-batch-code');
|
.fill('my-batch-code');
|
||||||
await page.getByLabel('text-field-packaging', { exact: true }).fill('bucket');
|
await page.getByLabel('text-field-packaging', { exact: true }).fill('bucket');
|
||||||
await page
|
await page
|
||||||
@@ -653,7 +653,7 @@ test('Purchase Orders - Custom Location', async ({ browser }) => {
|
|||||||
|
|
||||||
await page.getByLabel('action-button-assign-batch-').click();
|
await page.getByLabel('action-button-assign-batch-').click();
|
||||||
await page
|
await page
|
||||||
.getByLabel('text-field-batch_code', { exact: true })
|
.getByLabel('text-field-line_batch_code', { exact: true })
|
||||||
.fill('po-custom-location-test');
|
.fill('po-custom-location-test');
|
||||||
|
|
||||||
// Short timeout to allow for debouncing
|
// Short timeout to allow for debouncing
|
||||||
|
|||||||
Reference in New Issue
Block a user