mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-06 03:51:34 +00:00
BOM Enhancements (#10042)
* Add "round_up_multiple" field * Adjust field def * Add serializer field * Update frontend * Nullify empty numerical values * Calculate round_up_multiple value * Adjust table rendering * Update API version * Add unit test * Additional unit test * Change name of value * Update BOM docs * Add new fields * Add data migration for new fields * Bug fix for data migration * Adjust API fields * Bump API docs * Update frontend * Remove old 'overage' field * Updated BOM docs * Docs tweak * Fix required quantity calculation * additional unit tests * Tweak BOM table * Enhanced "can_build" serializer * Refactor "can_build" calculations * Code cleanup * Serializer fix * Enhanced rendering * Updated playwright tests * Fix method name * Update API unit test * Refactor 'can_build' calculation - Make it much more efficient - Reduce code duplication * Fix unit test * Adjust serializer type * Update src/backend/InvenTree/part/models.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/InvenTree/part/test_bom_item.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/docs/manufacturing/bom.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/docs/manufacturing/bom.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Adjust unit test * Adjust tests * Tweak requirements * Tweak playwright tests * More playwright fixes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -99,8 +99,12 @@ export function ApiFormField({
|
||||
);
|
||||
|
||||
// Coerce the value to a numerical value
|
||||
const numericalValue: number | '' = useMemo(() => {
|
||||
let val: number | '' = 0;
|
||||
const numericalValue: number | null = useMemo(() => {
|
||||
let val: number | null = 0;
|
||||
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (definition.field_type) {
|
||||
case 'integer':
|
||||
@@ -116,7 +120,7 @@ export function ApiFormField({
|
||||
}
|
||||
|
||||
if (Number.isNaN(val) || !Number.isFinite(val)) {
|
||||
val = '';
|
||||
val = null;
|
||||
}
|
||||
|
||||
return val;
|
||||
@@ -198,10 +202,16 @@ export function ApiFormField({
|
||||
ref={field.ref}
|
||||
id={fieldId}
|
||||
aria-label={`number-field-${field.name}`}
|
||||
value={numericalValue}
|
||||
value={numericalValue === null ? '' : numericalValue}
|
||||
error={definition.error ?? error?.message}
|
||||
decimalScale={definition.field_type == 'integer' ? 0 : 10}
|
||||
onChange={(value: number | string | null) => onChange(value)}
|
||||
onChange={(value: number | string | null) => {
|
||||
if (value != null && value.toString().trim() === '') {
|
||||
onChange(null);
|
||||
} else {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
step={1}
|
||||
/>
|
||||
);
|
||||
|
@@ -29,12 +29,14 @@ export function bomItemFields(): ApiFormFieldSet {
|
||||
},
|
||||
quantity: {},
|
||||
reference: {},
|
||||
overage: {},
|
||||
note: {},
|
||||
setup_quantity: {},
|
||||
attrition: {},
|
||||
rounding_multiple: {},
|
||||
allow_variants: {},
|
||||
inherited: {},
|
||||
consumable: {},
|
||||
optional: {}
|
||||
optional: {},
|
||||
note: {}
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -140,14 +140,73 @@ export function BomTable({
|
||||
const units = record.sub_part_detail?.units;
|
||||
|
||||
return (
|
||||
<Group justify='space-between' grow>
|
||||
<Text>{quantity}</Text>
|
||||
{record.overage && <Text size='xs'>+{record.overage}</Text>}
|
||||
{units && <Text size='xs'>{units}</Text>}
|
||||
<Group justify='space-between'>
|
||||
<Group gap='xs'>
|
||||
<Text>{quantity}</Text>
|
||||
{record.setup_quantity && record.setup_quantity > 0 && (
|
||||
<Text size='xs'>{`(+${record.setup_quantity})`}</Text>
|
||||
)}
|
||||
{record.attrition && record.attrition > 0 && (
|
||||
<Text size='xs'>{`(+${record.attrition}%)`}</Text>
|
||||
)}
|
||||
</Group>
|
||||
{units && <Text size='xs'>[{units}]</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'setup_quantity',
|
||||
defaultVisible: false,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
const setup_quantity = record.setup_quantity;
|
||||
const units = record.sub_part_detail?.units;
|
||||
if (setup_quantity == null || setup_quantity === 0) {
|
||||
return '-';
|
||||
} else {
|
||||
return (
|
||||
<Group gap='xs' justify='space-between'>
|
||||
<Text size='xs'>{formatDecimal(setup_quantity)}</Text>
|
||||
{units && <Text size='xs'>[{units}]</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'attrition',
|
||||
defaultVisible: false,
|
||||
sortable: true,
|
||||
render: (record: any) => {
|
||||
const attrition = record.attrition;
|
||||
if (attrition == null || attrition === 0) {
|
||||
return '-';
|
||||
} else {
|
||||
return <Text size='xs'>{`${formatDecimal(attrition)}%`}</Text>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'rounding_multiple',
|
||||
defaultVisible: false,
|
||||
sortable: false,
|
||||
render: (record: any) => {
|
||||
const units = record.sub_part_detail?.units;
|
||||
const multiple: number | null = record.round_up_multiple;
|
||||
|
||||
if (multiple == null) {
|
||||
return '-';
|
||||
} else {
|
||||
return (
|
||||
<Group gap='xs' justify='space-between'>
|
||||
<Text>{formatDecimal(multiple)}</Text>
|
||||
{units && <Text size='xs'>[{units}]</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'substitutes',
|
||||
defaultVisible: false,
|
||||
|
@@ -392,14 +392,48 @@ export default function BuildLineTable({
|
||||
defaultVisible: false,
|
||||
switchable: false,
|
||||
render: (record: any) => {
|
||||
// Include information about the BOM item (if available)
|
||||
const extra: any[] = [];
|
||||
|
||||
if (record?.bom_item_detail?.setup_quantity) {
|
||||
extra.push(
|
||||
<Text key='setup-quantity' size='sm'>
|
||||
{t`Setup Quantity`}: {record.bom_item_detail.setup_quantity}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (record?.bom_item_detail?.attrition) {
|
||||
extra.push(
|
||||
<Text key='attrition' size='sm'>
|
||||
{t`Attrition`}: {record.bom_item_detail.attrition}%
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (record?.bom_item_detail?.rounding_multiple) {
|
||||
extra.push(
|
||||
<Text key='rounding-multiple' size='sm'>
|
||||
{t`Rounding Multiple`}:{' '}
|
||||
{record.bom_item_detail.rounding_multiple}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// If a build output is specified, use the provided quantity
|
||||
return (
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Text>{record.requiredQuantity}</Text>
|
||||
{record?.part_detail?.units && (
|
||||
<Text size='xs'>[{record.part_detail.units}]</Text>
|
||||
)}
|
||||
</Group>
|
||||
<TableHoverCard
|
||||
title={t`BOM Information`}
|
||||
extra={extra}
|
||||
value={
|
||||
<Group justify='space-between' wrap='nowrap'>
|
||||
<Text>{record.requiredQuantity}</Text>
|
||||
{record?.part_detail?.units && (
|
||||
<Text size='xs'>[{record.part_detail.units}]</Text>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@@ -234,6 +234,8 @@ test('Build Order - Allocation', async ({ browser }) => {
|
||||
await page.getByText('Reel Storage').waitFor();
|
||||
await page.getByText('R_10K_0805_1%').first().click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
// The capacitor stock should be fully allocated
|
||||
const cell = await page.getByRole('cell', { name: /C_1uF_0805/ });
|
||||
const row = await getRowFromCell(cell);
|
||||
@@ -278,7 +280,7 @@ test('Build Order - Allocation', async ({ browser }) => {
|
||||
{
|
||||
name: 'Blue Widget',
|
||||
ipn: 'widget.blue',
|
||||
available: '39',
|
||||
available: '129',
|
||||
required: '5',
|
||||
allocated: '5'
|
||||
},
|
||||
@@ -313,7 +315,7 @@ test('Build Order - Allocation', async ({ browser }) => {
|
||||
|
||||
// Check for expected buttons on Red Widget
|
||||
const redWidget = await page.getByRole('cell', { name: 'Red Widget' });
|
||||
const redRow = await redWidget.locator('xpath=ancestor::tr').first();
|
||||
const redRow = await getRowFromCell(redWidget);
|
||||
|
||||
await redRow.getByLabel(/row-action-menu-/i).click();
|
||||
await page
|
||||
@@ -426,3 +428,33 @@ test('Build Order - External', async ({ browser }) => {
|
||||
await page.getByRole('cell', { name: 'PO0017' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'PO0018' }).waitFor();
|
||||
});
|
||||
|
||||
test('Build Order - BOM Quantity', async ({ browser }) => {
|
||||
// Validate required build order quantities (based on BOM values)
|
||||
|
||||
const page = await doCachedLogin(browser, { url: 'part/81/bom' });
|
||||
|
||||
// Expected quantity values for the BOM items
|
||||
await page.getByText('15(+50)').waitFor();
|
||||
await page.getByText('10(+100)').waitFor();
|
||||
|
||||
await loadTab(page, 'Part Details');
|
||||
|
||||
// Expected "can build" value: 13
|
||||
const canBuild = await page
|
||||
.getByRole('cell', { name: 'Can Build' })
|
||||
.locator('div');
|
||||
const row = await getRowFromCell(canBuild);
|
||||
await row.getByText('13').waitFor();
|
||||
|
||||
await loadTab(page, 'Build Orders');
|
||||
await page.getByRole('cell', { name: 'BO0016' }).click();
|
||||
|
||||
await loadTab(page, 'Required Parts');
|
||||
|
||||
const line = await page
|
||||
.getByRole('cell', { name: 'Thumbnail R_10K_0805_1%' })
|
||||
.locator('div');
|
||||
const row2 = await getRowFromCell(line);
|
||||
await row2.getByText('1175').waitFor();
|
||||
});
|
||||
|
@@ -207,15 +207,17 @@ test('Stock - Serialize', async ({ browser }) => {
|
||||
await page.getByLabel('text-field-serial_numbers').fill('200-250');
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page
|
||||
.getByText('Group range 200-250 exceeds allowed quantity')
|
||||
.getByText('Number of unique serial numbers (51) must match quantity (100)')
|
||||
.waitFor();
|
||||
|
||||
await page.getByLabel('text-field-serial_numbers').fill('1, 2, 3');
|
||||
await page.waitForTimeout(250);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page
|
||||
.getByText('Number of unique serial numbers (3) must match quantity (10)')
|
||||
.getByText('Number of unique serial numbers (3) must match quantity (100)')
|
||||
.waitFor();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
Reference in New Issue
Block a user