mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-31 17:28:07 +00:00
[UI] PO Receive bug (#12709)
* [UI] Fix for receiving items * Add playwright regression test
This commit is contained in:
@@ -375,13 +375,12 @@ function LineItemFormRow({
|
|||||||
}, [record.destination]);
|
}, [record.destination]);
|
||||||
|
|
||||||
// Batch code generator
|
// 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({
|
const batchCodeGenerator = useBatchCodeGenerator({
|
||||||
isEnabled: () => batchOpen,
|
isEnabled: () => batchOpen
|
||||||
onGenerate: (value: any) => {
|
|
||||||
if (value) {
|
|
||||||
props.changeFn(props.rowId, 'batch_code', value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serial number generator
|
// Serial number generator
|
||||||
@@ -776,7 +775,10 @@ function LineItemFormRow({
|
|||||||
field_type: 'string',
|
field_type: 'string',
|
||||||
label: t`Batch Code`,
|
label: t`Batch Code`,
|
||||||
description: t`Enter batch code for received items`,
|
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}
|
error={props.rowErrors?.batch_code?.message}
|
||||||
/>
|
/>
|
||||||
@@ -898,7 +900,13 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
|||||||
return {
|
return {
|
||||||
id: elem.pk,
|
id: elem.pk,
|
||||||
line_item: 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,
|
quantity: elem.quantity - elem.received,
|
||||||
expiry_date: null,
|
expiry_date: null,
|
||||||
batch_code: '',
|
batch_code: '',
|
||||||
@@ -938,7 +946,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [filteredItems, records, props, stockStatusCodes]);
|
}, [filteredItems, records, props.orderPk, stockStatusCodes]);
|
||||||
|
|
||||||
return useCreateApiFormModal({
|
return useCreateApiFormModal({
|
||||||
...props.formProps,
|
...props.formProps,
|
||||||
|
|||||||
@@ -119,17 +119,27 @@ export function PurchaseOrderLineItemTable({
|
|||||||
|
|
||||||
const [singleRecord, setSingleRecord] = useState(null);
|
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({
|
const receiveLineItems = useReceiveLineItems({
|
||||||
items: singleRecord ? [singleRecord] : table.selectedRecords,
|
items: receiveItems,
|
||||||
orderPk: orderId,
|
orderPk: orderId,
|
||||||
destinationPk: order.destination,
|
destinationPk: order.destination,
|
||||||
formProps: {
|
formProps: {
|
||||||
// Timeout is a small hack to prevent function being called before re-render
|
onClose: onReceiveItemsClose
|
||||||
onClose: () => {
|
|
||||||
table.clearSelectedRecords();
|
|
||||||
table.refreshTable();
|
|
||||||
setTimeout(() => setSingleRecord(null), 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
import { createApi } from '../api.ts';
|
||||||
import { test } from '../baseFixtures.ts';
|
import { test } from '../baseFixtures.ts';
|
||||||
import { readeruser, stevenuser } from '../defaults.ts';
|
import { readeruser, stevenuser } from '../defaults.ts';
|
||||||
import {
|
import {
|
||||||
@@ -607,6 +608,99 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
|
|||||||
await page.getByRole('cell', { name: 'my-batch-code' }).first().waitFor();
|
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 }) => {
|
test('Purchase Orders - Receive Virtual Items', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, {
|
const page = await doCachedLogin(browser, {
|
||||||
url: 'purchasing/purchase-order/19'
|
url: 'purchasing/purchase-order/19'
|
||||||
|
|||||||
Reference in New Issue
Block a user