mirror of
https://github.com/inventree/InvenTree.git
synced 2026-10-14 14:11:55 +00:00
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:
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 { 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 (
|
||||
<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.
|
||||
*
|
||||
@@ -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 (
|
||||
<Group wrap='nowrap'>
|
||||
<RenderPartColumn
|
||||
part={record.part_detail}
|
||||
full_name={showFullNames}
|
||||
/>
|
||||
{partDiffers && <AllocatedPartDiffersIcon />}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}),
|
||||
{
|
||||
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({
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
tableData={lineItem.filteredAllocations ?? lineItem.allocations}
|
||||
tableData={allocations}
|
||||
props={{
|
||||
minHeight: 200,
|
||||
enableSearch: false,
|
||||
@@ -347,6 +391,10 @@ export default function BuildLineTable({
|
||||
expanded={table.isRowExpanded(record.pk)}
|
||||
/>
|
||||
<RenderPartColumn part={record.part_detail} />
|
||||
{(record.filteredAllocations ?? record.allocations).some(
|
||||
(allocation: any) =>
|
||||
allocationPartDiffers(allocation, record.part)
|
||||
) && <AllocatedPartDiffersIcon />}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ function OutputAllocationDrawer({
|
||||
<Divider />
|
||||
<Paper p='md'>
|
||||
<BuildLineTable
|
||||
key={output?.pk}
|
||||
build={build}
|
||||
output={output}
|
||||
params={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import { test } from '../baseFixtures.ts';
|
||||
import {
|
||||
activateCalendarView,
|
||||
@@ -925,3 +925,243 @@ test('Build Order - BOM Quantity', async ({ browser }) => {
|
||||
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<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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user