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.
This commit is contained in:
Petr Ledvina authored and GitHub committed 2026-10-13 09:23:24 +11:00
1 parent a3fdcd1f14
commit 47f2e6acdd
3 files changed
+294 -5

No files matched your search

@@ -11,13 +11,14 @@ import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables'; import type { RowAction, TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro'; 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 { import {
IconArrowRight, IconArrowRight,
IconCircleCheck, IconCircleCheck,
IconCircleDashedCheck, IconCircleDashedCheck,
IconCircleMinus, IconCircleMinus,
IconCircleX, IconCircleX,
IconInfoCircle,
IconShoppingCart, IconShoppingCart,
IconTool, IconTool,
IconWand 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 (
<Tooltip label={label}>
<IconInfoCircle
role='img'
aria-label={label}
size={16}
color='blue'
style={{ flexShrink: 0 }}
/>
</Tooltip>
);
}
/** /**
* Render a sub-table of allocated stock against a particular build line. * Render a sub-table of allocated stock against a particular build line.
* *
@@ -88,11 +115,28 @@ export function BuildLineSubTable({
const user = useUserState(); const user = useUserState();
const navigate = useNavigate(); const navigate = useNavigate();
const table = useTable('buildline-subtable'); 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(() => { const tableColumns: any[] = useMemo(() => {
return [ return [
PartColumn({ PartColumn({
part: 'part_detail' part: 'part_detail',
render: (record: any) => {
const partDiffers = allocationPartDiffers(record, lineItem.part);
return (
<Group wrap='nowrap'>
<RenderPartColumn
part={record.part_detail}
full_name={showFullNames}
/>
{partDiffers && <AllocatedPartDiffersIcon />}
</Group>
);
}
}), }),
{ {
accessor: 'quantity', accessor: 'quantity',
@@ -114,7 +158,7 @@ export function BuildLineSubTable({
accessor: 'location_detail' accessor: 'location_detail'
}) })
]; ];
}, []); }, [lineItem.part, showFullNames]);
const rowActions = useCallback( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
@@ -151,7 +195,7 @@ export function BuildLineSubTable({
<InvenTreeTable <InvenTreeTable
tableState={table} tableState={table}
columns={tableColumns} columns={tableColumns}
tableData={lineItem.filteredAllocations ?? lineItem.allocations} tableData={allocations}
props={{ props={{
minHeight: 200, minHeight: 200,
enableSearch: false, enableSearch: false,
@@ -347,6 +391,10 @@ export default function BuildLineTable({
expanded={table.isRowExpanded(record.pk)} expanded={table.isRowExpanded(record.pk)}
/> />
<RenderPartColumn part={record.part_detail} /> <RenderPartColumn part={record.part_detail} />
{(record.filteredAllocations ?? record.allocations).some(
(allocation: any) =>
allocationPartDiffers(allocation, record.part)
) && <AllocatedPartDiffersIcon />}
</Group> </Group>
); );
} }
@@ -140,6 +140,7 @@ function OutputAllocationDrawer({
<Divider /> <Divider />
<Paper p='md'> <Paper p='md'>
<BuildLineTable <BuildLineTable
key={output?.pk}
build={build} build={build}
output={output} output={output}
params={{ params={{
+241 -1
View File
@@ -1,4 +1,4 @@
import { expect } from '@playwright/test'; import { type Page, expect } from '@playwright/test';
import { test } from '../baseFixtures.ts'; import { test } from '../baseFixtures.ts';
import { import {
activateCalendarView, activateCalendarView,
@@ -925,3 +925,243 @@ test('Build Order - BOM Quantity', async ({ browser }) => {
await setTableChoiceFilter(page, 'Available', 'No'); await setTableChoiceFilter(page, 'Available', 'No');
await page.getByText('1 - 4 / 4').waitFor(); 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<typeof part>,
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<typeof part>,
allocations: ReturnType<typeof allocation>[]
) {
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<typeof line>
) {
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();
}
});
});