diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 8a71696186..fb5f9a9b06 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -375,13 +375,12 @@ function LineItemFormRow({ }, [record.destination]); // Batch code generator + // Note: the generated value is offered as a placeholder (accepted via the + // "accept suggested value" button) rather than written into the field + // directly - otherwise, a manually-entered batch code can be silently + // overwritten if the (debounced, async) generator resolves afterwards const batchCodeGenerator = useBatchCodeGenerator({ - isEnabled: () => batchOpen, - onGenerate: (value: any) => { - if (value) { - props.changeFn(props.rowId, 'batch_code', value); - } - } + isEnabled: () => batchOpen }); // Serial number generator @@ -776,7 +775,10 @@ function LineItemFormRow({ field_type: 'string', label: t`Batch Code`, description: t`Enter batch code for received items`, - value: props.item.batch_code + value: props.item.batch_code, + placeholderAutofill: true, + placeholder: + batchCodeGenerator.result && `${batchCodeGenerator.result}` }} error={props.rowErrors?.batch_code?.message} /> @@ -898,7 +900,13 @@ export function useReceiveLineItems(props: LineItemsForm) { return { id: elem.pk, line_item: elem.pk, - location: elem.destination ?? elem.destination_detail?.pk ?? null, + // Leave unset if this line has no destination of its own, so a + // manually-selected location (or the order's own destination + // fallback) is actually applied — `destination_detail` already + // resolves through that fallback chain, so baking its value in + // here would make the backend treat this line as if the user + // had explicitly chosen the PO's default, blocking any override. + location: elem.destination ?? null, quantity: elem.quantity - elem.received, expiry_date: null, batch_code: '', @@ -938,7 +946,7 @@ export function useReceiveLineItems(props: LineItemsForm) { } } }; - }, [filteredItems, records, props, stockStatusCodes]); + }, [filteredItems, records, props.orderPk, stockStatusCodes]); return useCreateApiFormModal({ ...props.formProps, diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index d1c2f19e97..5c6c14c989 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -119,17 +119,27 @@ export function PurchaseOrderLineItemTable({ const [singleRecord, setSingleRecord] = useState(null); + // Keep a stable array reference for unchanged selections, so downstream + // memoization isn't defeated by a fresh array literal on every render + // (which was resetting in-progress edits in the "receive items" modal) + const receiveItems = useMemo( + () => (singleRecord ? [singleRecord] : table.selectedRecords), + [singleRecord, table.selectedRecords] + ); + + const onReceiveItemsClose = useCallback(() => { + table.clearSelectedRecords(); + table.refreshTable(); + // Timeout is a small hack to prevent function being called before re-render + setTimeout(() => setSingleRecord(null), 500); + }, [table]); + const receiveLineItems = useReceiveLineItems({ - items: singleRecord ? [singleRecord] : table.selectedRecords, + items: receiveItems, orderPk: orderId, destinationPk: order.destination, formProps: { - // Timeout is a small hack to prevent function being called before re-render - onClose: () => { - table.clearSelectedRecords(); - table.refreshTable(); - setTimeout(() => setSingleRecord(null), 500); - } + onClose: onReceiveItemsClose } }); diff --git a/src/frontend/tests/pages/pui_purchasing.spec.ts b/src/frontend/tests/pages/pui_purchasing.spec.ts index 2e20610570..3f36ea2b88 100644 --- a/src/frontend/tests/pages/pui_purchasing.spec.ts +++ b/src/frontend/tests/pages/pui_purchasing.spec.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import { createApi } from '../api.ts'; import { test } from '../baseFixtures.ts'; import { readeruser, stevenuser } from '../defaults.ts'; import { @@ -607,6 +608,99 @@ test('Purchase Orders - Receive Items', async ({ browser }) => { await page.getByRole('cell', { name: 'my-batch-code' }).first().waitFor(); }); +test('Purchase Orders - Custom Location', async ({ browser }) => { + const page = await doCachedLogin(browser); + + await navigate(page, 'purchasing/purchase-order/14/line-items'); + + // Line item pk=36 ("Widget Board" / 002.01-PCB) has no destination of its + // own, so it falls back to the order's default destination ("Mechanical + // Lab"). Target it via its target date, as its quantity gets bumped below + // (and its part / IPN are shared with another line on this order). + const row = page.getByRole('row').filter({ hasText: '2024-10-23' }); + await row.waitFor(); + + // First, ensure that the row has sufficient quantity to receive + // This is required to ensure the robustness of this test, + // as the test data may be modified by other tests + await row.getByLabel(/row-action-menu-/i).click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + + const quantityInput = page.getByRole('textbox', { + name: 'number-field-quantity' + }); + const quantity = Number.parseInt(await quantityInput.inputValue()); + await quantityInput.fill((quantity + 100).toString()); + + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Item Updated').waitFor(); + + // Now, receive a single unit into a location *different* from the + // order's default destination ("Mechanical Lab") + await row.getByLabel(/row-action-menu-/i).click(); + await page.getByRole('menuitem', { name: 'Receive line item' }).click(); + + await page.getByLabel('tree-field-location').fill('storage room a'); + await page.getByText('Storage Room A (purple door)').click(); + + await page.getByLabel('number-field-quantity').fill('1'); + await page.waitForTimeout(500); + + await page.getByLabel('action-button-assign-batch-').click(); + await page + .getByLabel('text-field-batch_code', { exact: true }) + .fill('po-custom-location-test'); + + // Short timeout to allow for debouncing + await page.waitForTimeout(200); + + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Items received').waitFor(); + + // Verify (via the UI) that the item was received into the location we + // picked, and not the order's default destination + await loadTab(page, 'Received Stock'); + await clearTableFilters(page); + + await page + .getByRole('textbox', { name: 'table-search-input' }) + .fill('po-custom-location-test'); + + const receivedRow = page + .getByRole('row') + .filter({ hasText: 'po-custom-location-test' }) + .first(); + + await expect(receivedRow).toContainText('Storage Room A'); + await expect(receivedRow).not.toContainText('Mechanical Lab'); + + // Cross-check against the API, in case the displayed location text does + // not reflect the stock item's actual location + const api = await createApi({}); + + const locations = await api + .get('stock/location/', { params: { search: 'Storage Room A' } }) + .then((res) => res.json()); + const targetLocation = locations.find( + (loc: any) => loc.name === 'Storage Room A' + ); + expect(targetLocation).toBeTruthy(); + + const items = await api + .get('stock/', { params: { batch: 'po-custom-location-test' } }) + .then((res) => res.json()); + expect(items.length).toBeGreaterThan(0); + + // The batch code may be shared with stock items received by earlier runs + // of this test, so check the most recently-created one + const latestItem = items.reduce((a: any, b: any) => (b.pk > a.pk ? b : a)); + expect(latestItem.location).toBe(targetLocation.pk); + + // This supplier part has a pack quantity of 6, so receiving "1" (pack) + // should result in a stock item with a quantity of 6 (base units) + expect(latestItem.quantity).toBe(6); +}); + test('Purchase Orders - Receive Virtual Items', async ({ browser }) => { const page = await doCachedLogin(browser, { url: 'purchasing/purchase-order/19'