2
0
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:
Oliver
2025-08-07 10:47:26 +10:00
committed by GitHub
parent c4011d0be3
commit f1b5f2c379
23 changed files with 427 additions and 143 deletions

View File

@@ -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

View File

@@ -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>
)
});
}

View File

@@ -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,

View File

@@ -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`,

View File

@@ -443,6 +443,7 @@ export default function BuildDetail() {
allowAdd={false}
tableName='build-consumed'
showLocation={false}
allowReturn
params={{
consumed_by: id
}}

View File

@@ -248,6 +248,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
allowAdd={false}
tableName='assigned-stock'
showLocation={false}
allowReturn
params={{ customer: company.pk }}
/>
) : (

View File

@@ -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}
</>

View File

@@ -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(() => {

View File

@@ -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' });