2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-12 03:28:37 +00:00

[Feature] SalesOrder Auto-Allocate (#12000)

* Add basic auto-allocate functionality

- backend code
- background task
- API endpoint

* Add new endpoint enum

* add frontend components

* Tweak auto-allocate output

* Allow specifying of individual line items

* Tweak error boundary

* Enable bulk-delete of allocated items against sales order

* Refactor stock sorting options

* Allow user to select how to handle serialized stock

* Backport new functionality to BuildOrder allocation

* Refactor sorting options to use enumerated values

* Implement functional unit tests for new feature

* Update API and CHANGELOG

* Additional unit test

* Add playwright testing

* Documentation

* Update docs for build auto-allocate

* Fix dependencies

* Adjust build line filtering

* Fix serializer
This commit is contained in:
Oliver
2026-05-26 23:21:06 +10:00
committed by GitHub
parent 06680758c3
commit 540eb84796
23 changed files with 1468 additions and 22 deletions
+7 -3
View File
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Alert, Stack } from '@mantine/core';
import { Alert, Stack, Text } from '@mantine/core';
import { ErrorBoundary, type FallbackRender } from '@sentry/react';
import { IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback } from 'react';
@@ -14,8 +14,12 @@ export function DefaultFallback({
title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
>
<Stack gap='xs'>
{t`An error occurred while rendering this component. Refer to the console for more information.`}
{t`Try reloading the page, or contact your administrator if the problem persists.`}
<Text size='sm'>
{t`An error occurred while rendering this component. Refer to the console for more information.`}
</Text>
<Text size='sm'>
{t`Try reloading the page, or contact your administrator if the problem persists.`}
</Text>
</Stack>
</Alert>
);
+1
View File
@@ -182,6 +182,7 @@ export enum ApiEndpoints {
sales_order_complete = 'order/so/:id/complete/',
sales_order_allocate = 'order/so/:id/allocate/',
sales_order_allocate_serials = 'order/so/:id/allocate-serials/',
sales_order_auto_allocate = 'order/so/:id/auto-allocate/',
sales_order_line_list = 'order/so-line/',
sales_order_extra_line_list = 'order/so-extra-line/',
+4
View File
@@ -239,6 +239,10 @@ export function useBuildAutoAllocateFields({
optional_items: {
hidden: item_type === 'tracked',
value: item_type === 'tracked' ? false : undefined
},
stock_sort_by: {},
build_lines: {
hidden: true
}
};
}, [item_type]);
@@ -584,3 +584,28 @@ export function useSalesOrderAllocationFields({
};
}, [orderId, shipment]);
}
export function useSalesOrderAutoAllocateFields({
orderId
}: {
orderId: number;
}): ApiFormFieldSet {
return useMemo(() => {
return {
location: {},
exclude_location: {},
interchangeable: {},
stock_sort_by: {},
serialized_stock: {},
shipment: {
filters: {
order: orderId,
shipped: false
}
},
line_items: {
hidden: true
}
};
}, [orderId]);
}
@@ -574,6 +574,9 @@ export default function BuildLineTable({
});
const [allocateTaskId, setAllocateTaskId] = useState<string>('');
const [autoAllocateInitialData, setAutoAllocateInitialData] = useState<
Record<string, any>
>({});
useBackgroundTask({
taskId: allocateTaskId,
@@ -584,6 +587,24 @@ export default function BuildLineTable({
}
});
const autoAllocatePreFormContent = useMemo(() => {
const n = table.selectedRecords.length;
if (n > 0) {
return (
<Alert color='blue' title={t`Auto Allocate Stock`}>
<Text>
{t`Auto-allocating stock for`} {n} {t`selected line item(s)`}
</Text>
</Alert>
);
}
return (
<Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate untracked BOM items to this build according to the selected options`}</Text>
</Alert>
);
}, [table.selectedRecords]);
const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk,
@@ -595,17 +616,18 @@ export default function BuildLineTable({
location: build.take_from,
interchangeable: true,
substitutes: true,
optional_items: false
optional_items: false,
...autoAllocateInitialData
},
successMessage: null,
onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id);
if (response.task_id) {
setAllocateTaskId(response.task_id);
} else {
table.refreshTable();
}
},
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>
</Alert>
)
preFormContent: autoAllocatePreFormContent
});
const allocateStock = useAllocateStockToBuildForm({
@@ -835,6 +857,9 @@ export default function BuildLineTable({
hidden={!visible || hasOutput}
color='blue'
onClick={() => {
setAutoAllocateInitialData({
build_lines: table.selectedRecords.map((r) => r.pk)
});
autoAllocateStock.open();
}}
/>,
@@ -377,6 +377,10 @@ export default function SalesOrderAllocationTable({
enableFilters: !isSubTable,
enableDownload: !isSubTable,
enableSelection: !isSubTable,
enableBulkDelete:
!isSubTable &&
allowEdit &&
user.hasDeleteRole(UserRoles.sales_order),
minHeight: isSubTable ? 100 : undefined,
rowActions: rowActions,
tableActions: isSubTable ? undefined : tableActions,
@@ -1,11 +1,12 @@
import { t } from '@lingui/core/macro';
import { Group, Paper, Text } from '@mantine/core';
import { Alert, Group, Paper, Text } from '@mantine/core';
import {
IconArrowRight,
IconHash,
IconShoppingCart,
IconSquareArrowRight,
IconTools
IconTools,
IconWand
} from '@tabler/icons-react';
import type { DataTableRowExpansionProps } from 'mantine-datatable';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
@@ -35,8 +36,10 @@ import { useBuildOrderFields } from '../../forms/BuildForms';
import {
useAllocateToSalesOrderForm,
useSalesOrderAllocateSerialsFields,
useSalesOrderAutoAllocateFields,
useSalesOrderLineItemFields
} from '../../forms/SalesOrderForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@@ -327,6 +330,52 @@ export default function SalesOrderLineItemTable({
}
});
const [allocateTaskId, setAllocateTaskId] = useState<string>('');
useBackgroundTask({
taskId: allocateTaskId,
message: t`Allocating stock to sales order`,
successMessage: t`Stock allocation complete`,
onSuccess: () => {
table.refreshTable();
}
});
const [autoAllocateInitialData, setAutoAllocateInitialData] = useState<any>(
{}
);
const autoAllocatePreFormContent = useMemo(() => {
const count = table.selectedRecords.length;
if (count > 0) {
return (
<Alert color='blue'>
<Text size='sm'>
{t`${count} line item(s) selected — only these lines will be allocated`}
</Text>
</Alert>
);
}
return (
<Alert color='green'>
<Text size='sm'>{t`All unallocated line items will be allocated`}</Text>
</Alert>
);
}, [table.selectedRecords.length]);
const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.sales_order_auto_allocate,
pk: orderId,
title: t`Auto Allocate Stock`,
fields: useSalesOrderAutoAllocateFields({ orderId }),
initialData: autoAllocateInitialData,
preFormContent: autoAllocatePreFormContent,
successMessage: null,
onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id);
}
});
const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
const orderPartsWizard = OrderPartsWizard({
@@ -385,9 +434,28 @@ export default function SalesOrderLineItemTable({
);
allocateStock.open();
}}
/>,
<ActionButton
key='auto-allocate-stock'
tooltip={t`Auto Allocate Stock`}
icon={<IconWand />}
color='blue'
hidden={!editable || !user.hasChangeRole(UserRoles.sales_order)}
onClick={() => {
setAutoAllocateInitialData({
line_items: table.selectedRecords.map((r) => r.pk)
});
autoAllocateStock.open();
}}
/>
];
}, [user, orderId, table.hasSelectedRecords, table.selectedRecords]);
}, [
editable,
user,
orderId,
table.hasSelectedRecords,
table.selectedRecords
]);
const rowActions = useCallback(
(record: any): RowAction[] => {
@@ -529,6 +597,7 @@ export default function SalesOrderLineItemTable({
{newBuildOrder.modal}
{allocateBySerials.modal}
{allocateStock.modal}
{autoAllocateStock.modal}
{orderPartsWizard.wizard}
<InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_line_list)}
@@ -128,6 +128,42 @@ test('Sales Orders - Basic Tests', async ({ browser }) => {
await page.getByRole('button', { name: 'Issue Order' }).waitFor();
});
test('Sales Orders - Auto Allocate', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'sales/sales-order/11/' });
// Duplicate the order
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Pending').first().waitFor();
await loadTab(page, 'Line Items');
await page
.getByRole('button', { name: 'action-button-auto-allocate-' })
.click();
await page
.getByRole('combobox', { name: 'choice-field-stock_sort_by' })
.click();
await page.getByRole('option', { name: 'Newest stock' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Stock allocation complete').first().waitFor();
await page.getByText('10 / 10').first().waitFor();
// Cancel the order (to free up the allocated stock)
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Cancelled').first().waitFor();
// Check that the allocated stock has been released
await page
.getByRole('region', { name: 'Line Items', exact: true })
.getByLabel('table-refresh')
.click();
await page.getByText('0 / 10').first().waitFor();
});
test('Sales Orders - Shipments', async ({ browser }) => {
const page = await doCachedLogin(browser);