2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-04 10:31:03 +00:00

Build consume fix (#11529)

* Add new build task

* Refactor background task for consuming build stock

- Run as a single task
- Improve query efficiency

* Refactor consuming stock against build via API

- Return task_id for monitoring
- Keep frontend updated

* Task tracking for auto-allocation

* Add e2e integration tests:

- Auto-allocate stock
- Consume stock

* Bump API version

* Playwright test fixes

* Adjust unit tests

* Robustify unit test

* Widen test scope

* Adjust playwright test

* Loosen test requirements again

* idk, another change :|

* Robustify test
This commit is contained in:
Oliver
2026-03-17 20:51:12 +11:00
committed by GitHub
parent 97aec82d33
commit 84cd81d9a8
13 changed files with 283 additions and 185 deletions

View File

@@ -853,7 +853,7 @@ export function useConsumeBuildItemsForm({
url: ApiEndpoints.build_order_consume,
pk: buildId,
title: t`Consume Stock`,
successMessage: t`Stock items scheduled to be consumed`,
successMessage: null,
onFormSuccess: onFormSuccess,
size: '80%',
fields: consumeFields,
@@ -954,7 +954,7 @@ export function useConsumeBuildLinesForm({
url: ApiEndpoints.build_order_consume,
pk: buildId,
title: t`Consume Stock`,
successMessage: t`Stock items scheduled to be consumed`,
successMessage: null,
onFormSuccess: onFormSuccess,
fields: consumeFields,
initialData: {

View File

@@ -13,6 +13,7 @@ import type { TableColumn } from '@lib/types/Tables';
import { Alert } from '@mantine/core';
import { IconCircleDashedCheck, IconCircleX } from '@tabler/icons-react';
import { useConsumeBuildItemsForm } from '../../forms/BuildForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import {
useDeleteApiFormModal,
useEditApiFormModal
@@ -189,12 +190,28 @@ export default function BuildAllocatedStockTable({
return selectedItems.filter((item) => !item.part_detail?.trackable);
}, [selectedItems]);
const [consumeTaskId, setConsumeTaskId] = useState<string>('');
useBackgroundTask({
taskId: consumeTaskId,
message: t`Consuming allocated stock`,
successMessage: t`Stock consumed successfully`,
onSuccess: () => {
table.refreshTable();
}
});
const consumeStock = useConsumeBuildItemsForm({
buildId: buildId ?? 0,
allocatedItems: itemsToConsume,
onFormSuccess: () => {
onFormSuccess: (response: any) => {
table.clearSelectedRecords();
table.refreshTable();
if (response.task_id) {
setConsumeTaskId(response.task_id);
} else {
table.refreshTable();
}
}
});

View File

@@ -31,6 +31,7 @@ import {
useBuildOrderFields,
useConsumeBuildLinesForm
} from '../../forms/BuildForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@@ -569,6 +570,17 @@ export default function BuildLineTable({
modelType: ModelType.build
});
const [allocateTaskId, setAllocateTaskId] = useState<string>('');
useBackgroundTask({
taskId: allocateTaskId,
message: t`Allocating stock to build order`,
successMessage: t`Stock allocation complete`,
onSuccess: () => {
table.refreshTable();
}
});
const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk,
@@ -582,8 +594,10 @@ export default function BuildLineTable({
substitutes: true,
optional_items: false
},
successMessage: t`Auto allocation in progress`,
table: table,
successMessage: null,
onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id);
},
preFormContent: (
<Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate untracked BOM items to this build according to the selected options`}</Text>
@@ -669,12 +683,28 @@ export default function BuildLineTable({
parts: partsToOrder
});
const [consumeTaskId, setConsumeTaskId] = useState<string>('');
useBackgroundTask({
taskId: consumeTaskId,
message: t`Consuming allocated stock`,
successMessage: t`Stock consumed successfully`,
onSuccess: () => {
table.refreshTable();
}
});
const consumeLines = useConsumeBuildLinesForm({
buildId: build.pk,
buildLines: selectedRows,
onFormSuccess: () => {
onFormSuccess: (response: any) => {
table.clearSelectedRecords();
table.refreshTable();
if (response.task_id) {
setConsumeTaskId(response.task_id);
} else {
table.refreshTable();
}
}
});

View File

@@ -45,6 +45,7 @@ import {
useStockItemSerializeFields
} from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import {
useCreateApiFormModal,
useEditApiFormModal
@@ -215,6 +216,17 @@ export default function BuildOutputTable({
}
});
const [allocateTaskId, setAllocateTaskId] = useState<string>('');
useBackgroundTask({
taskId: allocateTaskId,
message: t`Allocating stock to build order`,
successMessage: t`Stock allocation complete`,
onSuccess: () => {
refetchTrackedItems();
}
});
const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk,
@@ -226,12 +238,9 @@ export default function BuildOutputTable({
location: build.take_from,
substitutes: true
},
successMessage: t`Auto-allocation in progress`,
onFormSuccess: () => {
// After a short delay, refresh the tracked items
setTimeout(() => {
refetchTrackedItems();
}, 2500);
successMessage: null,
onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id);
},
table: table,
preFormContent: (

View File

@@ -219,6 +219,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
await clearTableFilters(page);
// We have now loaded the "Build Order" table. Check for some expected texts
await page.getByRole('textbox', { name: 'table-search-input' }).fill('1');
await page.getByText('On Hold').first().waitFor();
await page.getByText('Pending').first().waitFor();
@@ -315,7 +316,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
await page.getByText('Build outputs have been completed').waitFor();
// Check for expected UI elements in the "scrap output" dialog
const cell3 = await page.getByRole('cell', { name: '16' });
const cell3 = await page.getByRole('cell', { name: '16', exact: true });
const row3 = await getRowFromCell(cell3);
await row3.getByLabel(/row-action-menu-/i).click();
await page.getByRole('menuitem', { name: 'Scrap' }).click();
@@ -468,54 +469,69 @@ test('Build Order - Auto Allocate Tracked', async ({ browser }) => {
// Test partial stock consumption against build order
test('Build Order - Consume Stock', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'manufacturing/build-order/24/line-items'
url: 'manufacturing/build-order/28/line-items'
});
// Check for expected progress values
await page.getByText('2 / 2', { exact: true }).waitFor();
await page.getByText('8 / 10', { exact: true }).waitFor();
await page.getByText('5 / 35', { exact: true }).waitFor();
await page.getByText('5 / 40', { exact: true }).waitFor();
// Duplicate this build order, to ensure a fresh run each time
await page.getByRole('button', { name: 'action-menu-build-order-' }).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Created').waitFor();
// Open the "Allocate Stock" dialog
await page.getByRole('checkbox', { name: 'Select all records' }).check();
// Issue the order
await page.getByRole('button', { name: 'Issue Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Order issued').waitFor();
// Navigate to the "required parts" tab - and auto-allocate stock
await loadTab(page, 'Required Parts');
await page
.getByRole('button', { name: 'action-button-allocate-stock' })
.getByRole('button', { name: 'action-button-auto-allocate-' })
.click();
await page
.getByLabel('Allocate Stock')
.getByText('5 / 35', { exact: true })
.waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// Open the "Consume Stock" dialog
// Task progress should be updated by the background worker thread
await page.getByText('Allocating stock to build order').waitFor();
await page.getByText('Stock allocation complete').waitFor();
// Check for allocated stock
await page.getByText('15 / 15').waitFor();
await page.getByText('10 / 10').waitFor();
await page.getByText('5 / 5').waitFor();
// Consume a single allocated item against the order
await loadTab(page, 'Allocated Stock');
await page.getByRole('checkbox', { name: 'Select record 1' }).check();
await page
.getByRole('button', { name: 'action-button-consume-stock' })
.click();
await page.getByLabel('Consume Stock').getByText('2 / 2').waitFor();
await page.getByLabel('Consume Stock').getByText('8 / 10').waitFor();
await page.getByLabel('Consume Stock').getByText('5 / 35').waitFor();
await page.getByLabel('Consume Stock').getByText('5 / 40').waitFor();
await page
.getByRole('textbox', { name: 'text-field-notes', exact: true })
.fill('some notes here...');
await page.getByRole('button', { name: 'Cancel' }).click();
.getByRole('textbox', { name: 'text-field-notes' })
.fill('consuming a single item');
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
// Try with a different build order
await navigate(page, 'manufacturing/build-order/26/line-items');
// Confirm progress and success
await page.getByText('Consuming allocated stock').waitFor();
await page.getByText('Stock consumed successfully').waitFor();
// Consume the rest of the stock via line items
await loadTab(page, 'Required Parts');
await page.getByRole('checkbox', { name: 'Select all records' }).check();
await page
.getByRole('button', { name: 'action-button-consume-stock' })
.click();
await page.getByLabel('Consume Stock').getByText('306 / 1,900').waitFor();
await page
.getByLabel('Consume Stock')
.getByText('Fully consumed')
.first()
.waitFor();
.getByRole('textbox', { name: 'text-field-notes' })
.fill('consuming remaining items');
await page.waitForTimeout(250);
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForTimeout(1000);
await page.getByText('Consuming allocated stock').waitFor();
await page.getByText('Stock consumed successfully').waitFor();
await page.getByText('Fully consumed').first().waitFor();
await page.getByText('15 / 15').first().waitFor();
});
test('Build Order - Tracked Outputs', async ({ browser }) => {
@@ -523,7 +539,7 @@ test('Build Order - Tracked Outputs', async ({ browser }) => {
url: 'manufacturing/build-order/10/incomplete-outputs'
});
const cancelBuildOutput = async (cell) => {
const cancelBuildOutput = async (cell: any) => {
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
@@ -633,9 +649,11 @@ test('Build Order - Filters', async ({ browser }) => {
// Toggle 'Outstanding' filter
await setTableChoiceFilter(page, 'Outstanding', 'Yes');
await page.getByRole('textbox', { name: 'table-search-input' }).fill('1');
await page.getByRole('cell', { name: 'BO0017' }).waitFor();
await clearTableFilters(page);
await page.getByRole('textbox', { name: 'table-search-input' }).fill('');
await setTableChoiceFilter(page, 'Outstanding', 'No');
await page.getByText('1 - 6 / 6').waitFor();

View File

@@ -147,20 +147,7 @@ test('Parts - BOM', async ({ browser }) => {
test('Parts - BOM Validation', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/107/bom' });
// Run BOM validation step
await page
.getByRole('button', { name: 'action-button-validate-bom' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
// Background task monitoring
await page.getByText('Validating BOM').waitFor();
await page.getByText('BOM validated').waitFor();
await page.getByRole('button', { name: 'bom-validation-info' }).hover();
await page.getByText('Validated By: allaccessAlly').waitFor();
// Edit line item, to ensure BOM is not valid next time around
// Edit line item, to ensure BOM is not valid
const cell = await page.getByRole('cell', { name: 'Red paint Red Paint' });
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
@@ -176,6 +163,22 @@ test('Parts - BOM Validation', async ({ browser }) => {
await input.fill(`${nextValue.toFixed(3)}`);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('BOM item updated').waitFor();
await loadTab(page, 'Part Details');
await loadTab(page, 'Bill of Materials');
// Run BOM validation step
await page
.getByRole('button', { name: 'action-button-validate-bom' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
// Background task monitoring
await page.getByText('Validating BOM').waitFor();
await page.getByText('BOM validated').waitFor();
await page.getByRole('button', { name: 'bom-validation-info' }).hover();
await page.getByText('Validated By: allaccessAlly').waitFor();
});
test('Parts - Editing', async ({ browser }) => {
@@ -313,10 +316,9 @@ test('Parts - Requirements', async ({ browser }) => {
// Also check requirements for part with open build orders which have been partially consumed
await navigate(page, 'part/105/details');
await page.getByText('Required: 2').waitFor();
await page.getByText('Available: 32').waitFor();
await page.getByText('In Stock: 34').waitFor();
await page.getByText('2 / 2').waitFor(); // Allocated to build orders
await page.getByText(/Required: \d+/).waitFor();
});
test('Parts - Allocations', async ({ browser }) => {

View File

@@ -442,6 +442,22 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
await navigate(page, 'purchasing/purchase-order/2/line-items');
const cell = await page.getByText('Red Paint', { exact: true });
// First, ensure that the row has sufficient quantity to receive
// This is required to ensure the robustness of this test,
// as the test data may be modified by other tests
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit' }).click();
const quantityInput = await page.getByRole('textbox', {
name: 'number-field-quantity'
});
const quantity = Number.parseInt(await quantityInput.inputValue());
await quantityInput.fill((quantity + 100).toString());
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Updated').waitFor();
// Now, receive the items
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Receive line item' }).click();
@@ -451,6 +467,7 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
// Receive only a *single* item
await page.getByLabel('number-field-quantity').fill('1');
await page.waitForTimeout(500);
// Assign custom information
await page.getByLabel('action-button-assign-batch-').click();
@@ -477,6 +494,9 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
await loadTab(page, 'Received Stock');
await clearTableFilters(page);
await page
.getByRole('textbox', { name: 'table-search-input' })
.fill('my-batch-code');
await page.getByRole('cell', { name: 'my-batch-code' }).first().waitFor();
});

View File

@@ -94,7 +94,7 @@ test('Importing - BOM', async ({ browser }) => {
await page.getByRole('button', { name: 'Accept Column Mapping' }).click();
await page.waitForTimeout(500);
await page.getByText('Importing Data').waitFor();
await page.getByText('Importing Data').first().waitFor();
await page.getByText('0 / 3').waitFor();
await page.getByText('Screw for fixing wood').first().waitFor();