From 47f2e6acdd2cfaad720c3dc932ede8e149ad97a9 Mon Sep 17 00:00:00 2001 From: Petr Ledvina Date: Tue, 13 Oct 2026 00:23:24 +0200 Subject: [PATCH] Show allocated part full names when BOM parts differ (#12981) Keep the BOM requirement on parent rows. In allocation subtables, use the shared part renderer with full_name when the allocated part differs from the requirement, respecting PART_NAME_FORMAT. Exact matches keep the compact name; shared copy, thumbnail and status behavior is retained. Mark differing parts with a neutral information icon and scope output allocation tables to the selected output. Cover revision differences, family descendants, empty fields, custom formats, copying and refresh. --- .../src/tables/build/BuildLineTable.tsx | 56 +++- .../src/tables/build/BuildOutputTable.tsx | 1 + src/frontend/tests/pages/pui_build.spec.ts | 242 +++++++++++++++++- 3 files changed, 294 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 2c97ad595d..b330967a02 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -11,13 +11,14 @@ import useTable from '@lib/hooks/UseTable'; import type { TableFilter } from '@lib/types/Filters'; import type { RowAction, TableColumn } from '@lib/types/Tables'; import { t } from '@lingui/core/macro'; -import { Alert, Center, Group, Paper, Text } from '@mantine/core'; +import { Alert, Center, Group, Paper, Text, Tooltip } from '@mantine/core'; import { IconArrowRight, IconCircleCheck, IconCircleDashedCheck, IconCircleMinus, IconCircleX, + IconInfoCircle, IconShoppingCart, IconTool, IconWand @@ -68,6 +69,32 @@ function isLineConsumable(record: any): boolean { ); } +function allocationPartDiffers(allocation: any, bomPartId: number): boolean { + const allocatedPartId = allocation.part_detail?.pk; + + return ( + allocatedPartId != null && + bomPartId != null && + allocatedPartId !== bomPartId + ); +} + +function AllocatedPartDiffersIcon() { + const label = t`Allocated part differs from the BOM part`; + + return ( + + + + ); +} + /** * Render a sub-table of allocated stock against a particular build line. * @@ -88,11 +115,28 @@ export function BuildLineSubTable({ const user = useUserState(); const navigate = useNavigate(); const table = useTable('buildline-subtable'); + const allocations = lineItem.filteredAllocations ?? lineItem.allocations; + const showFullNames = allocations.some((allocation: any) => + allocationPartDiffers(allocation, lineItem.part) + ); const tableColumns: any[] = useMemo(() => { return [ PartColumn({ - part: 'part_detail' + part: 'part_detail', + render: (record: any) => { + const partDiffers = allocationPartDiffers(record, lineItem.part); + + return ( + + + {partDiffers && } + + ); + } }), { accessor: 'quantity', @@ -114,7 +158,7 @@ export function BuildLineSubTable({ accessor: 'location_detail' }) ]; - }, []); + }, [lineItem.part, showFullNames]); const rowActions = useCallback( (record: any): RowAction[] => { @@ -151,7 +195,7 @@ export function BuildLineSubTable({ + {(record.filteredAllocations ?? record.allocations).some( + (allocation: any) => + allocationPartDiffers(allocation, record.part) + ) && } ); } diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index aeeabc30b0..c806ac113d 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -140,6 +140,7 @@ function OutputAllocationDrawer({ { await setTableChoiceFilter(page, 'Available', 'No'); await page.getByText('1 - 4 / 4').waitFor(); }); + +test.describe('Build Order - Allocated part identity', () => { + const buildId = 991810; + const outputIds = [991811, 991812]; + const partDiffersLabel = 'Allocated part differs from the BOM part'; + + function part( + pk: number, + name: string, + revision: string | null, + IPN: string | null, + fullName: string + ) { + return { + pk, + name, + full_name: fullName, + revision, + IPN, + description: `${name} description`, + active: true, + trackable: true, + assembly: false, + testable: false + }; + } + + function allocation( + pk: number, + actualPart: ReturnType, + outputId = outputIds[0] + ) { + return { + pk, + stock_item: pk, + part_detail: actualPart, + install_into: outputId, + quantity: 1, + stock_item_detail: { + pk, + part: actualPart.pk, + quantity: 1, + serial: `COMP-${pk}`, + batch: '' + }, + location_detail: { pk: 991813, name: 'Allocation test location' } + }; + } + + function line( + requiredPart: ReturnType, + allocations: ReturnType[] + ) { + return { + pk: 991814, + build: buildId, + bom_item: 991815, + part: requiredPart.pk, + part_detail: requiredPart, + bom_item_detail: { + pk: 991815, + sub_part: requiredPart.pk, + quantity: 1, + reference: 'MB', + allow_variants: true + }, + quantity: allocations.length, + allocated: allocations.length, + consumed: 0, + available_stock: 10, + available_substitute_stock: 0, + available_variant_stock: 0, + in_production: 0, + scheduled_to_build: 0, + on_order: 0, + trackable: true, + allocations + }; + } + + async function mockAllocations( + page: Page, + buildLine: ReturnType + ) { + const assembly = { + ...part( + 991816, + 'Allocation test assembly', + '', + 'ALLOC-TEST', + 'ALLOC-TEST | Allocation test assembly' + ), + assembly: true + }; + + await page.route(`**/api/part/${assembly.pk}/bom-validate/**`, (route) => + route.fulfill({ json: { bom_validated: true } }) + ); + await page.route('**/api/build/**', async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === `/api/build/${buildId}/`) { + await route.fulfill({ + json: { + pk: buildId, + reference: 'BO-ALLOCATION-TEST', + title: 'Allocated part identity', + part: assembly.pk, + part_detail: assembly, + quantity: 2, + completed: 0, + status: 20 + } + }); + } else if ( + url.pathname === '/api/build/line/' && + url.searchParams.get('build') === `${buildId}` + ) { + const lines = url.searchParams.has('assembly') ? [] : [buildLine]; + await route.fulfill({ + json: url.searchParams.has('limit') + ? { + count: lines.length, + next: null, + previous: null, + results: lines + } + : lines + }); + } else if ( + url.pathname === '/api/build/' && + url.searchParams.get('parent') === `${buildId}` + ) { + await route.fulfill({ + json: { count: 0, next: null, previous: null, results: [] } + }); + } else { + await route.continue(); + } + }); + await page.route('**/api/stock/?*', async (route) => { + if ( + new URL(route.request().url()).searchParams.get('build') !== + `${buildId}` + ) { + await route.continue(); + return; + } + const outputs = outputIds.map((pk, index) => ({ + pk, + part: assembly.pk, + part_detail: assembly, + build: buildId, + is_building: true, + quantity: 1, + serial: `OUTPUT-${index + 1}`, + status: 10 + })); + await route.fulfill({ + json: { + count: outputs.length, + next: null, + previous: null, + results: outputs + } + }); + }); + } + + test('places difference icons and uses full names under marked parents', async ({ + browser + }) => { + const page = await doCachedLogin(browser); + const required = part( + 991820, + 'ahr5e-mb', + 'v1.1', + 'AHR5E-MB-V1.1', + 'AHR5E-MB-V1.1 | ahr5e-mb | v1.1' + ); + const older = part( + 991821, + 'ahr5e-mb', + 'v1.0', + 'AHR5E-MB-V1.0', + 'AHR5E-MB-V1.0 | ahr5e-mb | v1.0' + ); + const allocations = [ + allocation(991830, required, outputIds[0]), + allocation(991831, older, outputIds[1]), + allocation(991832, required, outputIds[1]) + ]; + await mockAllocations(page, line(required, allocations)); + await navigate( + page, + `manufacturing/build-order/${buildId}/incomplete-outputs` + ); + + for (const [index, marked] of [false, true].entries()) { + await page + .getByRole('cell', { name: `# OUTPUT-${index + 1}`, exact: true }) + .click(); + const drawer = page.getByRole('dialog', { + name: /Build Output Stock Allocation/ + }); + const parent = await getRowFromCell( + drawer.getByRole('cell', { name: required.IPN!, exact: true }) + ); + const parentPart = parent + .getByText(required.name, { exact: true }) + .locator('xpath=ancestor::td[1]'); + await expect(parentPart).toHaveText(required.name); + await expect( + parentPart.getByRole('img', { name: partDiffersLabel, exact: true }) + ).toHaveCount(marked ? 1 : 0); + await parentPart.getByText(required.name, { exact: true }).click(); + + for (const record of allocations.filter( + (item) => item.install_into === outputIds[index] + )) { + const row = drawer + .getByRole('cell', { name: `# COMP-${record.pk}`, exact: true }) + .locator('xpath=ancestor::tr[1]'); + const cell = row.getByRole('cell').first(); + await expect(cell).toHaveText( + marked ? record.part_detail.full_name : record.part_detail.name + ); + const icon = cell.getByRole('img', { + name: partDiffersLabel, + exact: true + }); + if (record.part_detail.pk === required.pk) { + await expect(icon).toHaveCount(0); + } else { + await expect(icon).toBeVisible(); + } + } + await drawer.getByLabel('close-allocation-drawer').click(); + } + }); +});