[UI] PO Receive bug (#12709)

* [UI] Fix for receiving items

* Add playwright regression test
This commit is contained in:
Oliver
2026-08-25 14:34:33 +10:00
committed by GitHub
parent a6687a6b10
commit 39f2d724aa
3 changed files with 128 additions and 16 deletions
+17 -9
View File
@@ -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,
@@ -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
}
});
@@ -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'