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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user