2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 20:15:44 +00:00

Stock Transfer Improvements (#8570)

* Allow transfer of items independent of status marker

* Update test

* Display errors in stock transsfer form

* Add option to set status when transferring stock

* Fix inStock check for stock actions

* Allow adjustment of status when counting stock item

* Allow status adjustment for other actions:

- Remove stock
- Add stock

* Revert error behavior

* Enhanced unit test

* Unit test fix

* Bump API version

* Fix for playwright test

- Added helper func

* Extend playwright tests for stock actions
This commit is contained in:
Oliver
2024-11-28 07:06:58 +11:00
committed by GitHub
parent 28ea275d1a
commit c074250ce6
12 changed files with 281 additions and 52 deletions

View File

@ -23,6 +23,11 @@ export type ApiFormAdjustFilterType = {
data: FieldValues;
};
export type ApiFormFieldChoice = {
value: any;
display_name: string;
};
/** Definition of the ApiForm field component.
* - The 'name' attribute *must* be provided
* - All other attributes are optional, and may be provided by the API
@ -83,7 +88,7 @@ export type ApiFormFieldType = {
child?: ApiFormFieldType;
children?: { [key: string]: ApiFormFieldType };
required?: boolean;
choices?: any[];
choices?: ApiFormFieldChoice[];
hidden?: boolean;
disabled?: boolean;
exclude?: boolean;

View File

@ -213,6 +213,7 @@ export function TableField({
*/
export function TableFieldExtraRow({
visible,
fieldName,
fieldDefinition,
defaultValue,
emptyValue,
@ -220,6 +221,7 @@ export function TableFieldExtraRow({
onValueChange
}: {
visible: boolean;
fieldName?: string;
fieldDefinition: ApiFormFieldType;
defaultValue?: any;
error?: string;
@ -253,6 +255,7 @@ export function TableFieldExtraRow({
<InvenTreeIcon icon='downright' />
</Container>
<StandaloneField
fieldName={fieldName ?? 'field'}
fieldDefinition={field}
defaultValue={defaultValue}
error={error}

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Flex, Group, Skeleton, Table, Text } from '@mantine/core';
import { Flex, Group, Skeleton, Stack, Table, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import {
@ -19,6 +19,7 @@ import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import type {
ApiFormAdjustFilterType,
ApiFormFieldChoice,
ApiFormFieldSet
} from '../components/forms/fields/ApiFormField';
import {
@ -43,6 +44,7 @@ import {
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
import { StatusFilterOptions } from '../tables/Filter';
/**
* Construct a set of fields for creating / editing a StockItem instance
@ -430,6 +432,7 @@ type StockRow = {
function StockOperationsRow({
props,
transfer = false,
changeStatus = false,
add = false,
setMax = false,
merge = false,
@ -437,15 +440,29 @@ function StockOperationsRow({
}: {
props: TableFieldRowProps;
transfer?: boolean;
changeStatus?: boolean;
add?: boolean;
setMax?: boolean;
merge?: boolean;
record?: any;
}) {
const statusOptions: ApiFormFieldChoice[] = useMemo(() => {
return (
StatusFilterOptions(ModelType.stockitem)()?.map((choice) => {
return {
value: choice.value,
display_name: choice.label
};
}) ?? []
);
}, []);
const [quantity, setQuantity] = useState<StockItemQuantity>(
add ? 0 : (props.item?.quantity ?? 0)
);
const [status, setStatus] = useState<number | undefined>(undefined);
const removeAndRefresh = () => {
props.removeFn(props.idx);
};
@ -463,6 +480,17 @@ function StockOperationsRow({
}
});
const [statusOpen, statusHandlers] = useDisclosure(false, {
onOpen: () => {
setStatus(record?.status || undefined);
props.changeFn(props.idx, 'status', record?.status || undefined);
},
onClose: () => {
setStatus(undefined);
props.changeFn(props.idx, 'status', undefined);
}
});
const stockString: string = useMemo(() => {
if (!record) {
return '-';
@ -481,14 +509,21 @@ function StockOperationsRow({
<>
<Table.Tr>
<Table.Td>
<Flex gap='sm' align='center'>
<Thumbnail
size={40}
src={record.part_detail?.thumbnail}
align='center'
/>
<div>{record.part_detail?.name}</div>
</Flex>
<Stack gap='xs'>
<Flex gap='sm' align='center'>
<Thumbnail
size={40}
src={record.part_detail?.thumbnail}
align='center'
/>
<div>{record.part_detail?.name}</div>
</Flex>
{props.rowErrors?.pk?.message && (
<Text c='red' size='xs'>
{props.rowErrors.pk.message}
</Text>
)}
</Stack>
</Table.Td>
<Table.Td>
{record.location ? record.location_detail?.pathstring : '-'}
@ -531,6 +566,15 @@ function StockOperationsRow({
}
/>
)}
{changeStatus && (
<ActionButton
size='sm'
icon={<InvenTreeIcon icon='status' />}
tooltip={t`Change Status`}
onClick={() => statusHandlers.toggle()}
variant={statusOpen ? 'filled' : 'transparent'}
/>
)}
{transfer && (
<ActionButton
size='sm'
@ -544,12 +588,30 @@ function StockOperationsRow({
</Flex>
</Table.Td>
</Table.Tr>
{changeStatus && (
<TableFieldExtraRow
visible={statusOpen}
onValueChange={(value: any) => {
setStatus(value);
props.changeFn(props.idx, 'status', value || undefined);
}}
fieldName='status'
fieldDefinition={{
field_type: 'choice',
label: t`Status`,
choices: statusOptions,
value: status
}}
defaultValue={status}
/>
)}
{transfer && (
<TableFieldExtraRow
visible={transfer && packagingOpen}
onValueChange={(value: any) => {
props.changeFn(props.idx, 'packaging', value || undefined);
}}
fieldName='packaging'
fieldDefinition={{
field_type: 'string',
label: t`Packaging`
@ -604,19 +666,19 @@ function stockTransferFields(items: any[]): ApiFormFieldSet {
<StockOperationsRow
props={row}
transfer
changeStatus
setMax
key={record.pk}
record={record}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Move`, t`Actions`]
headers: [t`Part`, t`Location`, t`Stock`, t`Move`, t`Actions`]
},
location: {
filters: {
structural: false
}
// TODO: icon
},
notes: {}
};
@ -641,6 +703,7 @@ function stockRemoveFields(items: any[]): ApiFormFieldSet {
<StockOperationsRow
props={row}
setMax
changeStatus
add
key={record.pk}
record={record}
@ -670,7 +733,13 @@ function stockAddFields(items: any[]): ApiFormFieldSet {
const record = records[row.item.pk];
return (
<StockOperationsRow props={row} add key={record.pk} record={record} />
<StockOperationsRow
changeStatus
props={row}
add
key={record.pk}
record={record}
/>
);
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Add`, t`Actions`]
@ -696,6 +765,7 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
return (
<StockOperationsRow
props={row}
changeStatus
key={row.item.pk}
record={records[row.item.pk]}
/>
@ -763,6 +833,7 @@ function stockMergeFields(items: any[]): ApiFormFieldSet {
props={row}
key={row.item.item}
merge
changeStatus
record={records[row.item.item]}
/>
);

View File

@ -653,7 +653,15 @@ export default function StockDetail() {
});
const stockActions = useMemo(() => {
const inStock = stockitem.in_stock;
const inStock =
user.hasChangeRole(UserRoles.stock) &&
stockitem.quantity > 0 &&
!stockitem.sales_order &&
!stockitem.belongs_to &&
!stockitem.customer &&
!stockitem.consumed_by &&
!stockitem.is_building;
const serial = stockitem.serial;
const serialized =
serial != null &&

View File

@ -37,6 +37,18 @@ export const clearTableFilters = async (page) => {
await page.getByLabel('filter-drawer-close').click();
};
export const setTableChoiceFilter = async (page, filter, value) => {
await openFilterDrawer(page);
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByPlaceholder('Select filter').fill(filter);
await page.getByRole('option', { name: 'Status' }).click();
await page.getByPlaceholder('Select filter value').click();
await page.getByRole('option', { name: value }).click();
await closeFilterDrawer(page);
};
/**
* Return the parent 'row' element for a given 'cell' element
* @param cell - The cell element

View File

@ -1,8 +1,9 @@
import { test } from '../baseFixtures.ts';
import { baseUrl } from '../defaults.ts';
import { clearTableFilters, setTableChoiceFilter } from '../helpers.ts';
import { doQuickLogin } from '../login.ts';
test('Sales Orders', async ({ page }) => {
test('Sales Orders - Basic Tests', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/home`);
@ -11,7 +12,11 @@ test('Sales Orders', async ({ page }) => {
// Check for expected text in the table
await page.getByRole('tab', { name: 'Sales Orders' }).waitFor();
await page.getByText('In Progress').first().waitFor();
await clearTableFilters(page);
await setTableChoiceFilter(page, 'status', 'On Hold');
await page.getByText('On Hold').first().waitFor();
// Navigate to a particular sales order

View File

@ -182,10 +182,22 @@ test('Stock - Stock Actions', async ({ page }) => {
await page.getByLabel('action-menu-stock-operations-count').waitFor();
await page.getByLabel('action-menu-stock-operations-add').waitFor();
await page.getByLabel('action-menu-stock-operations-remove').waitFor();
await page.getByLabel('action-menu-stock-operations-transfer').click();
await page.getByLabel('text-field-notes').fill('test notes');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('This field is required.').first().waitFor();
// Set the status field
await page.getByLabel('action-button-change-status').click();
await page.getByLabel('choice-field-status').click();
await page.getByText('Attention needed').click();
// Set the packaging field
await page.getByLabel('action-button-adjust-packaging').click();
await page.getByLabel('text-field-packaging').fill('test packaging');
// Close the dialog
await page.getByRole('button', { name: 'Cancel' }).click();
// Find an item which has been sent to a customer