From 1f84f24514ebd2c5adb589a55000429fad73c6e3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 22 Feb 2025 08:34:45 +1100 Subject: [PATCH] Batch code fix (#9123) * Fix batch code assignment when receiving items * Add playwright tests * Harden playwright tests * Refactoring --- .../src/components/render/StatusRenderer.tsx | 20 +++ src/frontend/src/forms/PurchaseOrderForms.tsx | 125 +++++++++--------- src/frontend/tests/helpers.ts | 7 + .../tests/pages/pui_purchase_order.spec.ts | 40 +++++- 4 files changed, 129 insertions(+), 63 deletions(-) diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index f13d0ce36b..74bba6fd65 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -88,6 +88,26 @@ export function getStatusCodes( return statusCodes; } +/** + * Return a list of status codes select options for a given model type + * returns an array of objects with keys "value" and "display_name" + * + */ +export function getStatusCodeOptions(type: ModelType | string): any[] { + const statusCodes = getStatusCodes(type); + + if (!statusCodes) { + return []; + } + + return Object.values(statusCodes.values).map((entry) => { + return { + value: entry.key, + display_name: entry.label + }; + }); +} + /* * Return the name of a status code, based on the key */ diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 5998b9b2ce..caea7ddb90 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -22,12 +22,10 @@ import { IconUser, IconUsers } from '@tabler/icons-react'; -import { useQuery } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { IconCalendarExclamation } from '@tabler/icons-react'; import dayjs from 'dayjs'; -import { api } from '../App'; import { ActionButton } from '../components/buttons/ActionButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; import { StandaloneField } from '../components/forms/StandaloneField'; @@ -42,6 +40,7 @@ import { import { Thumbnail } from '../components/images/Thumbnail'; import { ProgressBar } from '../components/items/ProgressBar'; import { StylishText } from '../components/items/StylishText'; +import { getStatusCodeOptions } from '../components/render/StatusRenderer'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ModelType } from '../enums/ModelType'; import { InvenTreeIcon } from '../functions/icons'; @@ -304,10 +303,14 @@ function LineItemFormRow({ order: record?.order }); // Generate new serial numbers - serialNumberGenerator.update({ - part: record?.supplier_part_detail?.part, - quantity: props.item.quantity - }); + if (trackable) { + serialNumberGenerator.update({ + part: record?.supplier_part_detail?.part, + quantity: props.item.quantity + }); + } else { + props.changeFn(props.idx, 'serial_numbers', undefined); + } } }); @@ -604,7 +607,10 @@ function LineItemFormRow({ )} props.changeFn(props.idx, 'batch', value)} + onValueChange={(value) => { + props.changeFn(props.idx, 'batch_code', value); + }} + fieldName='batch_code' fieldDefinition={{ field_type: 'string', label: t`Batch Code`, @@ -618,6 +624,7 @@ function LineItemFormRow({ onValueChange={(value) => props.changeFn(props.idx, 'serial_numbers', value) } + fieldName='serial_numbers' fieldDefinition={{ field_type: 'string', label: t`Serial Numbers`, @@ -632,6 +639,7 @@ function LineItemFormRow({ onValueChange={(value) => props.changeFn(props.idx, 'expiry_date', value) } + fieldName='expiry_date' fieldDefinition={{ field_type: 'date', label: t`Expiry Date`, @@ -644,6 +652,7 @@ function LineItemFormRow({ props.changeFn(props.idx, 'packaging', value)} + fieldName='packaging' fieldDefinition={{ field_type: 'string', label: t`Packaging` @@ -654,6 +663,7 @@ function LineItemFormRow({ props.changeFn(props.idx, 'status', value)} fieldDefinition={{ field_type: 'choice', @@ -665,6 +675,7 @@ function LineItemFormRow({ /> props.changeFn(props.idx, 'note', value)} fieldDefinition={{ field_type: 'string', @@ -689,23 +700,10 @@ type LineItemsForm = { }; export function useReceiveLineItems(props: LineItemsForm) { - const { data } = useQuery({ - queryKey: ['stock', 'status'], - queryFn: async () => { - return api.get(apiUrl(ApiEndpoints.stock_status)).then((response) => { - if (response.status === 200) { - const entries = Object.values(response.data.values); - const mapped = entries.map((item: any) => { - return { - value: item.key, - display_name: item.label - }; - }); - return mapped; - } - }); - } - }); + const stockStatusCodes = useMemo( + () => getStatusCodeOptions(ModelType.stockitem), + [] + ); const records = Object.fromEntries( props.items.map((item) => [item.pk, item]) @@ -715,45 +713,47 @@ export function useReceiveLineItems(props: LineItemsForm) { (elem) => elem.quantity !== elem.received ); - const fields: ApiFormFieldSet = { - id: { - value: props.orderPk, - hidden: true - }, - items: { - field_type: 'table', - value: filteredItems.map((elem, idx) => { - return { - line_item: elem.pk, - location: elem.destination ?? elem.destination_detail?.pk ?? null, - quantity: elem.quantity - elem.received, - expiry_date: null, - batch_code: '', - serial_numbers: '', - status: 10, - barcode: null - }; - }), - modelRenderer: (row: TableFieldRowProps) => { - const record = records[row.item.line_item]; - - return ( - - ); + const fields: ApiFormFieldSet = useMemo(() => { + return { + id: { + value: props.orderPk, + hidden: true }, - headers: [t`Part`, t`SKU`, t`Received`, t`Quantity`, t`Actions`] - }, - location: { - filters: { - structural: false + items: { + field_type: 'table', + value: filteredItems.map((elem, idx) => { + return { + line_item: elem.pk, + location: elem.destination ?? elem.destination_detail?.pk ?? null, + quantity: elem.quantity - elem.received, + expiry_date: null, + batch_code: '', + serial_numbers: '', + status: 10, + barcode: null + }; + }), + modelRenderer: (row: TableFieldRowProps) => { + const record = records[row.item.line_item]; + + return ( + + ); + }, + headers: [t`Part`, t`SKU`, t`Received`, t`Quantity`, t`Actions`] + }, + location: { + filters: { + structural: false + } } - } - }; + }; + }, [filteredItems, props, stockStatusCodes]); return useCreateApiFormModal({ ...props.formProps, @@ -763,6 +763,7 @@ export function useReceiveLineItems(props: LineItemsForm) { initialData: { location: props.destinationPk }, - size: '80%' + size: '80%', + successMessage: t`Items received` }); } diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts index c39abded1d..cb4e03096a 100644 --- a/src/frontend/tests/helpers.ts +++ b/src/frontend/tests/helpers.ts @@ -65,6 +65,12 @@ export const getRowFromCell = async (cell) => { return cell.locator('xpath=ancestor::tr').first(); }; +export const clickOnRowMenu = async (cell) => { + const row = await getRowFromCell(cell); + + await row.getByLabel(/row-action-menu-/i).click(); +}; + /** * Navigate to the provided page, and wait for loading to complete * @param page @@ -80,6 +86,7 @@ export const navigate = async (page, url: string) => { } await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle'); }; /** diff --git a/src/frontend/tests/pages/pui_purchase_order.spec.ts b/src/frontend/tests/pages/pui_purchase_order.spec.ts index 99d8d78456..ce72489db9 100644 --- a/src/frontend/tests/pages/pui_purchase_order.spec.ts +++ b/src/frontend/tests/pages/pui_purchase_order.spec.ts @@ -2,6 +2,7 @@ import { test } from '../baseFixtures.ts'; import { clearTableFilters, clickButtonIfVisible, + clickOnRowMenu, navigate, openFilterDrawer, setTableChoiceFilter @@ -257,7 +258,6 @@ test('Purchase Orders - Receive Items', async ({ page }) => { await page.getByRole('cell', { name: 'PO0014' }).click(); await page.getByRole('tab', { name: 'Order Details' }).click(); - await page.getByText('0 / 3').waitFor(); // Select all line items to receive await page.getByRole('tab', { name: 'Line Items' }).click(); @@ -278,4 +278,42 @@ test('Purchase Orders - Receive Items', async ({ page }) => { await page.getByText('Mechanical Lab').waitFor(); await page.getByRole('button', { name: 'Cancel' }).click(); + + // Let's actually receive an item (with custom values) + await navigate(page, 'purchasing/purchase-order/2/line-items'); + + const cell = await page.getByText('Red Paint', { exact: true }); + await clickOnRowMenu(cell); + await page.getByRole('menuitem', { name: 'Receive line item' }).click(); + + // Select destination location + await page.getByLabel('related-field-location').click(); + await page.getByRole('option', { name: 'Factory', exact: true }).click(); + + // Receive only a *single* item + await page.getByLabel('number-field-quantity').fill('1'); + + // Assign custom information + await page.getByLabel('action-button-assign-batch-').click(); + await page.getByLabel('action-button-adjust-packaging').click(); + await page.getByLabel('action-button-change-status').click(); + await page.getByLabel('action-button-add-note').click(); + + await page.getByLabel('text-field-batch_code').fill('my-batch-code'); + await page.getByLabel('text-field-packaging').fill('bucket'); + await page.getByLabel('text-field-note').fill('The quick brown fox'); + await page.getByLabel('choice-field-status').click(); + await page.getByRole('option', { name: 'Destroyed' }).click(); + + // Short timeout to allow for debouncing + await page.waitForTimeout(200); + + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Items received').waitFor(); + + await page.getByRole('tab', { name: 'Received Stock' }).click(); + await clearTableFilters(page); + + await page.getByRole('cell', { name: 'my-batch-code' }).first().waitFor(); + await page.getByRole('cell', { name: 'bucket' }).first().waitFor(); });