mirror of
https://github.com/inventree/InvenTree.git
synced 2025-09-13 22:21:37 +00:00
[refactor] Stock return API endpoints (#10132)
* Add "StockReturn" API endpoint - Provide multiple items - Provide quantity for each item * Add frontend form * update frontend forms * Refactor frontend * Allow splitting quantity * Refactoring backend endpoints * cleanup * Update unit test * unit tests * Bump API version * Fix unit test * Add tests for returning build items to stock * Playwright tests * Enhanced unit tests * Add docs
This commit is contained in:
@@ -144,6 +144,7 @@ export enum ApiEndpoints {
|
||||
stock_test_result_list = 'stock/test/',
|
||||
stock_transfer = 'stock/transfer/',
|
||||
stock_remove = 'stock/remove/',
|
||||
stock_return = 'stock/return/',
|
||||
stock_add = 'stock/add/',
|
||||
stock_count = 'stock/count/',
|
||||
stock_change_status = 'stock/change_status/',
|
||||
@@ -153,7 +154,6 @@ export enum ApiEndpoints {
|
||||
stock_install = 'stock/:id/install/',
|
||||
stock_uninstall = 'stock/:id/uninstall/',
|
||||
stock_serialize = 'stock/:id/serialize/',
|
||||
stock_return = 'stock/:id/return/',
|
||||
stock_serial_info = 'stock/:id/serial-numbers/',
|
||||
|
||||
// Generator API endpoints
|
||||
|
@@ -746,6 +746,54 @@ function stockTransferFields(items: any[]): ApiFormFieldSet {
|
||||
return fields;
|
||||
}
|
||||
|
||||
function stockReturnFields(items: any[]): ApiFormFieldSet {
|
||||
if (!items) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Only include items that are currently *not* in stock
|
||||
const records = Object.fromEntries(
|
||||
items.filter((item) => !item.in_stock).map((item) => [item.pk, item])
|
||||
);
|
||||
|
||||
const fields: ApiFormFieldSet = {
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: mapAdjustmentItems(items),
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = records[row.item.pk];
|
||||
|
||||
return (
|
||||
<StockOperationsRow
|
||||
props={row}
|
||||
key={record.pk}
|
||||
record={record}
|
||||
transfer
|
||||
changeStatus
|
||||
/>
|
||||
);
|
||||
},
|
||||
headers: [
|
||||
{ title: t`Part` },
|
||||
{ title: t`Location` },
|
||||
{ title: t`Batch` },
|
||||
{ title: t`Quantity` },
|
||||
{ title: t`Return`, style: { width: '200px' } },
|
||||
{ title: t`Actions` }
|
||||
]
|
||||
},
|
||||
location: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
merge: {},
|
||||
notes: {}
|
||||
};
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function stockRemoveFields(items: any[]): ApiFormFieldSet {
|
||||
if (!items) {
|
||||
return {};
|
||||
@@ -1136,7 +1184,12 @@ export function useAddStockItem(props: StockOperationProps) {
|
||||
fieldGenerator: stockAddFields,
|
||||
endpoint: ApiEndpoints.stock_add,
|
||||
title: t`Add Stock`,
|
||||
successMessage: t`Stock added`
|
||||
successMessage: t`Stock added`,
|
||||
preFormContent: (
|
||||
<Alert color='blue'>
|
||||
{t`Increase the quantity of the selected stock items by a given amount.`}
|
||||
</Alert>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1146,7 +1199,12 @@ export function useRemoveStockItem(props: StockOperationProps) {
|
||||
fieldGenerator: stockRemoveFields,
|
||||
endpoint: ApiEndpoints.stock_remove,
|
||||
title: t`Remove Stock`,
|
||||
successMessage: t`Stock removed`
|
||||
successMessage: t`Stock removed`,
|
||||
preFormContent: (
|
||||
<Alert color='blue'>
|
||||
{t`Decrease the quantity of the selected stock items by a given amount.`}
|
||||
</Alert>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1156,7 +1214,27 @@ export function useTransferStockItem(props: StockOperationProps) {
|
||||
fieldGenerator: stockTransferFields,
|
||||
endpoint: ApiEndpoints.stock_transfer,
|
||||
title: t`Transfer Stock`,
|
||||
successMessage: t`Stock transferred`
|
||||
successMessage: t`Stock transferred`,
|
||||
preFormContent: (
|
||||
<Alert color='blue'>
|
||||
{t`Transfer selected items to the specified location.`}
|
||||
</Alert>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
export function useReturnStockItem(props: StockOperationProps) {
|
||||
return useStockOperationModal({
|
||||
...props,
|
||||
fieldGenerator: stockReturnFields,
|
||||
endpoint: ApiEndpoints.stock_return,
|
||||
title: t`Return Stock`,
|
||||
successMessage: t`Stock returned`,
|
||||
preFormContent: (
|
||||
<Alert color='blue'>
|
||||
{t`Return selected items into stock, to the specified location.`}
|
||||
</Alert>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1166,7 +1244,12 @@ export function useCountStockItem(props: StockOperationProps) {
|
||||
fieldGenerator: stockCountFields,
|
||||
endpoint: ApiEndpoints.stock_count,
|
||||
title: t`Count Stock`,
|
||||
successMessage: t`Stock counted`
|
||||
successMessage: t`Stock counted`,
|
||||
preFormContent: (
|
||||
<Alert color='blue'>
|
||||
{t`Count the selected stock items, and adjust the quantity accordingly.`}
|
||||
</Alert>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1176,7 +1259,12 @@ export function useChangeStockStatus(props: StockOperationProps) {
|
||||
fieldGenerator: stockChangeStatusFields,
|
||||
endpoint: ApiEndpoints.stock_change_status,
|
||||
title: t`Change Stock Status`,
|
||||
successMessage: t`Stock status changed`
|
||||
successMessage: t`Stock status changed`,
|
||||
preFormContent: (
|
||||
<Alert color='blue'>
|
||||
{t`Change the status of the selected stock items.`}
|
||||
</Alert>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1222,7 +1310,12 @@ export function useDeleteStockItem(props: StockOperationProps) {
|
||||
endpoint: ApiEndpoints.stock_item_list,
|
||||
modalFunc: useDeleteApiFormModal,
|
||||
title: t`Delete Stock Items`,
|
||||
successMessage: t`Stock deleted`
|
||||
successMessage: t`Stock deleted`,
|
||||
preFormContent: (
|
||||
<Alert color='red'>
|
||||
{t`This operation will permanently delete the selected stock items.`}
|
||||
</Alert>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import type { InvenTreeIconType, TablerIconType } from '@lib/types/Icons';
|
||||
import {
|
||||
IconArrowBack,
|
||||
IconArrowBigDownLineFilled,
|
||||
IconArrowMerge,
|
||||
IconBell,
|
||||
@@ -165,6 +166,7 @@ const icons: InvenTreeIconType = {
|
||||
refresh: IconRefresh,
|
||||
select_image: IconGridDots,
|
||||
delete: IconTrash,
|
||||
return: IconArrowBack,
|
||||
packaging: IconPackage,
|
||||
packages: IconPackages,
|
||||
install: IconTransitionRight,
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
useDeleteStockItem,
|
||||
useMergeStockItem,
|
||||
useRemoveStockItem,
|
||||
useReturnStockItem,
|
||||
useTransferStockItem
|
||||
} from '../forms/StockForms';
|
||||
import { InvenTreeIcon } from '../functions/icons';
|
||||
@@ -29,6 +30,7 @@ interface StockAdjustActionProps {
|
||||
merge?: boolean;
|
||||
remove?: boolean;
|
||||
transfer?: boolean;
|
||||
return?: boolean;
|
||||
}
|
||||
|
||||
interface StockAdjustActionReturnProps {
|
||||
@@ -58,6 +60,7 @@ export function useStockAdjustActions(
|
||||
const mergeStock = useMergeStockItem(props.formProps);
|
||||
const removeStock = useRemoveStockItem(props.formProps);
|
||||
const transferStock = useTransferStockItem(props.formProps);
|
||||
const returnStock = useReturnStockItem(props.formProps);
|
||||
|
||||
// Construct a list of modals available for stock adjustment actions
|
||||
const modals: UseModalReturn[] = useMemo(() => {
|
||||
@@ -74,6 +77,7 @@ export function useStockAdjustActions(
|
||||
props.merge != false && modals.push(mergeStock);
|
||||
props.remove != false && modals.push(removeStock);
|
||||
props.transfer != false && modals.push(transferStock);
|
||||
props.return === true && modals.push(returnStock);
|
||||
props.delete != false &&
|
||||
user.hasDeleteRole(UserRoles.stock) &&
|
||||
modals.push(deleteStock);
|
||||
@@ -159,6 +163,16 @@ export function useStockAdjustActions(
|
||||
}
|
||||
});
|
||||
|
||||
props.return === true &&
|
||||
menuActions.push({
|
||||
name: t`Return Stock`,
|
||||
icon: <InvenTreeIcon icon='return' iconProps={{ color: 'blue' }} />,
|
||||
tooltip: t`Return selected items into stock`,
|
||||
onClick: () => {
|
||||
returnStock.open();
|
||||
}
|
||||
});
|
||||
|
||||
props.delete != false &&
|
||||
menuActions.push({
|
||||
name: t`Delete Stock`,
|
||||
|
@@ -443,6 +443,7 @@ export default function BuildDetail() {
|
||||
allowAdd={false}
|
||||
tableName='build-consumed'
|
||||
showLocation={false}
|
||||
allowReturn
|
||||
params={{
|
||||
consumed_by: id
|
||||
}}
|
||||
|
@@ -248,6 +248,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
allowAdd={false}
|
||||
tableName='assigned-stock'
|
||||
showLocation={false}
|
||||
allowReturn
|
||||
params={{ customer: company.pk }}
|
||||
/>
|
||||
) : (
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
@@ -725,6 +724,8 @@ export default function StockDetail() {
|
||||
const stockAdjustActions = useStockAdjustActions({
|
||||
formProps: stockOperationProps,
|
||||
delete: false,
|
||||
assign: !!stockitem.in_stock,
|
||||
return: !!stockitem.consumed_by || !!stockitem.customer,
|
||||
merge: false
|
||||
});
|
||||
|
||||
@@ -756,30 +757,6 @@ export default function StockDetail() {
|
||||
successMessage: t`Stock item serialized`
|
||||
});
|
||||
|
||||
const returnStockItem = useCreateApiFormModal({
|
||||
url: ApiEndpoints.stock_return,
|
||||
pk: stockitem.pk,
|
||||
title: t`Return Stock Item`,
|
||||
preFormContent: (
|
||||
<Alert color='blue'>
|
||||
{t`Return this item into stock. This will remove the customer assignment.`}
|
||||
</Alert>
|
||||
),
|
||||
fields: {
|
||||
location: {},
|
||||
status: {},
|
||||
notes: {}
|
||||
},
|
||||
initialData: {
|
||||
location: stockitem.location ?? stockitem.part_detail?.default_location,
|
||||
status: stockitem.status_custom_key ?? stockitem.status
|
||||
},
|
||||
successMessage: t`Item returned to stock`,
|
||||
onFormSuccess: () => {
|
||||
refreshInstance();
|
||||
}
|
||||
});
|
||||
|
||||
const orderPartsWizard = OrderPartsWizard({
|
||||
parts: stockitem.part_detail ? [stockitem.part_detail] : []
|
||||
});
|
||||
@@ -884,20 +861,6 @@ export default function StockDetail() {
|
||||
onClick: () => {
|
||||
orderPartsWizard.openWizard();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Return`,
|
||||
tooltip: t`Return from customer`,
|
||||
hidden: !stockitem.customer,
|
||||
icon: (
|
||||
<InvenTreeIcon
|
||||
icon='return_orders'
|
||||
iconProps={{ color: 'blue' }}
|
||||
/>
|
||||
),
|
||||
onClick: () => {
|
||||
stockitem.pk && returnStockItem.open();
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>,
|
||||
@@ -1045,7 +1008,6 @@ export default function StockDetail() {
|
||||
{duplicateStockItem.modal}
|
||||
{deleteStockItem.modal}
|
||||
{serializeStockItem.modal}
|
||||
{returnStockItem.modal}
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
{orderPartsWizard.wizard}
|
||||
</>
|
||||
|
@@ -463,12 +463,14 @@ export function StockItemTable({
|
||||
allowAdd = false,
|
||||
showLocation = true,
|
||||
showPricing = true,
|
||||
allowReturn = false,
|
||||
tableName = 'stockitems'
|
||||
}: Readonly<{
|
||||
params?: any;
|
||||
allowAdd?: boolean;
|
||||
showLocation?: boolean;
|
||||
showPricing?: boolean;
|
||||
allowReturn?: boolean;
|
||||
tableName: string;
|
||||
}>) {
|
||||
const table = useTable(tableName);
|
||||
@@ -536,7 +538,8 @@ export function StockItemTable({
|
||||
});
|
||||
|
||||
const stockAdjustActions = useStockAdjustActions({
|
||||
formProps: stockOperationProps
|
||||
formProps: stockOperationProps,
|
||||
return: allowReturn
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
|
@@ -299,6 +299,39 @@ test('Stock - Stock Actions', async ({ browser }) => {
|
||||
await page.getByLabel('action-menu-stock-operations-return').click();
|
||||
});
|
||||
|
||||
test('Stock - Return Items', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'sales/customer/32/assigned-stock'
|
||||
});
|
||||
|
||||
// Return stock items assigned to customer
|
||||
await page.getByRole('cell', { name: 'Select all records' }).click();
|
||||
await page.getByRole('button', { name: 'action-menu-stock-actions' }).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'action-menu-stock-actions-return-stock' })
|
||||
.click();
|
||||
await page.getByText('Return selected items into stock').first().waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Location detail
|
||||
await navigate(page, 'stock/item/1253');
|
||||
await page
|
||||
.getByRole('button', { name: 'action-menu-stock-operations' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'action-menu-stock-operations-return-stock'
|
||||
})
|
||||
.click();
|
||||
await page.getByText('#128').waitFor();
|
||||
await page.getByText('Merge into existing stock').waitFor();
|
||||
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('0');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await page.getByText('Quantity must be greater than zero').waitFor();
|
||||
await page.getByText('This field is required.').waitFor();
|
||||
});
|
||||
|
||||
test('Stock - Tracking', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'stock/item/176/details' });
|
||||
|
||||
|
Reference in New Issue
Block a user