mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-04 10:31:03 +00:00
[enhancement] Stocktake updates (#11257)
* Allow part queryset to be passed to 'perform_stocktake' function * Add new options to perform_stocktake * Allow download of part stocktake snapshot data * API endpoint for generating a stocktake entry * Simplify code * Generate report output * Dashboard stocktake widget - Generate stocktake snapshot from the dashboard * Force stocktake entry for part * Add docs * Cleanup docs * Update API version * Improve efficiency of stocktake generation * Error handling * Add simple playwright test * Fix typing
This commit is contained in:
@@ -120,6 +120,7 @@ export enum ApiEndpoints {
|
||||
part_pricing_internal = 'part/internal-price/',
|
||||
part_pricing_sale = 'part/sale-price/',
|
||||
part_stocktake_list = 'part/stocktake/',
|
||||
part_stocktake_generate = 'part/stocktake/generate/',
|
||||
category_list = 'part/category/',
|
||||
category_tree = 'part/category/tree/',
|
||||
category_parameter_list = 'part/category/parameters/',
|
||||
|
||||
@@ -9,12 +9,13 @@ import GetStartedWidget from './widgets/GetStartedWidget';
|
||||
import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget';
|
||||
import NewsWidget from './widgets/NewsWidget';
|
||||
import QueryCountDashboardWidget from './widgets/QueryCountDashboardWidget';
|
||||
import StocktakeDashboardWidget from './widgets/StocktakeDashboardWidget';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns A list of built-in dashboard widgets which display the number of results for a particular query
|
||||
*/
|
||||
export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
|
||||
function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
|
||||
const user = useUserState.getState();
|
||||
const globalSettings = useGlobalSettingsState.getState();
|
||||
|
||||
@@ -186,7 +187,7 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
|
||||
function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
|
||||
return [
|
||||
{
|
||||
label: 'gstart',
|
||||
@@ -207,10 +208,14 @@ export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function BuiltinSettingsWidgets(): DashboardWidgetProps[] {
|
||||
function BuiltinSettingsWidgets(): DashboardWidgetProps[] {
|
||||
return [ColorToggleDashboardWidget(), LanguageSelectDashboardWidget()];
|
||||
}
|
||||
|
||||
function BuiltinActionWidgets(): DashboardWidgetProps[] {
|
||||
return [StocktakeDashboardWidget()];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns A list of built-in dashboard widgets
|
||||
@@ -219,6 +224,7 @@ export default function DashboardWidgetLibrary(): DashboardWidgetProps[] {
|
||||
return [
|
||||
...BuiltinQueryCountWidgets(),
|
||||
...BuiltinGettingStartedWidgets(),
|
||||
...BuiltinSettingsWidgets()
|
||||
...BuiltinSettingsWidgets(),
|
||||
...BuiltinActionWidgets()
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ApiEndpoints, UserRoles, apiUrl } from '@lib/index';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Button, Stack } from '@mantine/core';
|
||||
import { IconClipboardList } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import useDataOutput from '../../../hooks/UseDataOutput';
|
||||
import { useCreateApiFormModal } from '../../../hooks/UseForm';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import type { DashboardWidgetProps } from '../DashboardWidget';
|
||||
|
||||
function StocktakeWidget() {
|
||||
const [outputId, setOutputId] = useState<number | undefined>(undefined);
|
||||
|
||||
useDataOutput({
|
||||
title: t`Generating Stocktake Report`,
|
||||
id: outputId
|
||||
});
|
||||
|
||||
const stocktakeForm = useCreateApiFormModal({
|
||||
title: t`Generate Stocktake Report`,
|
||||
url: apiUrl(ApiEndpoints.part_stocktake_generate),
|
||||
fields: {
|
||||
part: {
|
||||
filters: {
|
||||
active: true
|
||||
}
|
||||
},
|
||||
category: {},
|
||||
location: {},
|
||||
generate_entry: {
|
||||
value: false
|
||||
},
|
||||
generate_report: {
|
||||
value: true
|
||||
}
|
||||
},
|
||||
submitText: t`Generate`,
|
||||
successMessage: null,
|
||||
onFormSuccess: (response) => {
|
||||
if (response.output?.pk) {
|
||||
setOutputId(response.output.pk);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{stocktakeForm.modal}
|
||||
<Stack gap='xs'>
|
||||
<Button
|
||||
leftSection={<IconClipboardList />}
|
||||
onClick={() => stocktakeForm.open()}
|
||||
>{t`Generate Stocktake Report`}</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StocktakeDashboardWidget(): DashboardWidgetProps {
|
||||
const user = useUserState();
|
||||
|
||||
return {
|
||||
label: 'stk',
|
||||
title: t`Stocktake`,
|
||||
description: t`Generate a new stocktake report`,
|
||||
minHeight: 1,
|
||||
minWidth: 2,
|
||||
render: () => <StocktakeWidget />,
|
||||
enabled: user.hasAddRole(UserRoles.part)
|
||||
};
|
||||
}
|
||||
@@ -266,7 +266,6 @@ export function partStocktakeFields(): ApiFormFieldSet {
|
||||
cost_min: {},
|
||||
cost_min_currency: {},
|
||||
cost_max: {},
|
||||
cost_max_currency: {},
|
||||
note: {}
|
||||
cost_max_currency: {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { AddItemButton } from '@lib/index';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type ChartTooltipProps, LineChart } from '@mantine/charts';
|
||||
@@ -18,12 +19,13 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { formatDate, formatPriceRange } from '../../defaults/formatters';
|
||||
import { partStocktakeFields } from '../../forms/PartForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { DecimalColumn } from '../../tables/ColumnRenderers';
|
||||
import { DateColumn, DecimalColumn } from '../../tables/ColumnRenderers';
|
||||
import { InvenTreeTable } from '../../tables/InvenTreeTable';
|
||||
|
||||
/*
|
||||
@@ -89,19 +91,38 @@ export default function PartStockHistoryDetail({
|
||||
table: table
|
||||
});
|
||||
|
||||
const newStocktakeEntry = useCreateApiFormModal({
|
||||
title: t`Generate Stocktake Report`,
|
||||
url: apiUrl(ApiEndpoints.part_stocktake_generate),
|
||||
fields: {
|
||||
part: {
|
||||
value: partId,
|
||||
disabled: true
|
||||
},
|
||||
generate_entry: {
|
||||
value: true,
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
submitText: t`Generate`,
|
||||
successMessage: t`Stocktake report scheduled for generation`
|
||||
});
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
DecimalColumn({
|
||||
accessor: 'quantity',
|
||||
sortable: false,
|
||||
switchable: false
|
||||
}),
|
||||
DateColumn({}),
|
||||
DecimalColumn({
|
||||
accessor: 'item_count',
|
||||
title: t`Stock Items`,
|
||||
switchable: true,
|
||||
sortable: false
|
||||
}),
|
||||
DecimalColumn({
|
||||
accessor: 'quantity',
|
||||
title: t`Stock Quantity`,
|
||||
sortable: false,
|
||||
switchable: false
|
||||
}),
|
||||
{
|
||||
accessor: 'cost',
|
||||
title: t`Stock Value`,
|
||||
@@ -111,11 +132,6 @@ export default function PartStockHistoryDetail({
|
||||
currency: record.cost_min_currency
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'date',
|
||||
sortable: true,
|
||||
switchable: false
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
@@ -124,7 +140,7 @@ export default function PartStockHistoryDetail({
|
||||
(record: any) => {
|
||||
return [
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.part),
|
||||
hidden: !user.hasChangeRole(UserRoles.part) || !user.isSuperuser(),
|
||||
onClick: () => {
|
||||
setSelectedStocktake(record.pk);
|
||||
editStocktakeEntry.open();
|
||||
@@ -176,8 +192,20 @@ export default function PartStockHistoryDetail({
|
||||
return [min_date.valueOf(), max_date.valueOf()];
|
||||
}, [chartData]);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
hidden={!user.hasAddRole(UserRoles.part)}
|
||||
key='add-stocktake'
|
||||
tooltip={t`Generate Stocktake Entry`}
|
||||
onClick={() => newStocktakeEntry.open()}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newStocktakeEntry.modal}
|
||||
{editStocktakeEntry.modal}
|
||||
{deleteStocktakeEntry.modal}
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
@@ -188,11 +216,13 @@ export default function PartStockHistoryDetail({
|
||||
props={{
|
||||
enableSelection: true,
|
||||
enableBulkDelete: true,
|
||||
enableDownload: true,
|
||||
params: {
|
||||
part: partId,
|
||||
ordering: '-date'
|
||||
},
|
||||
rowActions: rowActions
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
}}
|
||||
/>
|
||||
{table.isLoading ? (
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test } from '../baseFixtures.js';
|
||||
import { doCachedLogin } from '../login.js';
|
||||
import { setPluginState } from '../settings.js';
|
||||
|
||||
const resetDashboard = async (page: Page) => {
|
||||
await page.getByLabel('dashboard-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Clear Widgets' }).click();
|
||||
};
|
||||
|
||||
test('Dashboard - Basic', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// Reset wizards
|
||||
await page.getByLabel('dashboard-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Clear Widgets' }).click();
|
||||
// Reset dashboard widgets
|
||||
await resetDashboard(page);
|
||||
|
||||
await page.getByText('Use the menu to add widgets').waitFor();
|
||||
|
||||
@@ -39,6 +44,28 @@ test('Dashboard - Basic', async ({ browser }) => {
|
||||
await page.getByLabel('dashboard-accept-layout').click();
|
||||
});
|
||||
|
||||
test('Dashboard - Stocktake', async ({ browser }) => {
|
||||
// Trigger a "stocktake" report from the dashboard
|
||||
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// Reset dashboard widgets
|
||||
await resetDashboard(page);
|
||||
|
||||
await page.getByLabel('dashboard-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Add Widget' }).click();
|
||||
await page.getByLabel('dashboard-widgets-filter-input').fill('stocktake');
|
||||
await page.getByRole('button', { name: 'add-widget-stk' }).click();
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByRole('button', { name: 'Generate Stocktake Report' }).click();
|
||||
|
||||
await page.getByText('Select a category to include').waitFor();
|
||||
await page.getByRole('button', { name: 'Generate', exact: true }).waitFor();
|
||||
});
|
||||
|
||||
test('Dashboard - Plugins', async ({ browser }) => {
|
||||
// Ensure that the "SampleUI" plugin is enabled
|
||||
await setPluginState({
|
||||
@@ -48,6 +75,8 @@ test('Dashboard - Plugins', async ({ browser }) => {
|
||||
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await resetDashboard(page);
|
||||
|
||||
// Add a dashboard widget from the SampleUI plugin
|
||||
await page.getByLabel('dashboard-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Add Widget' }).click();
|
||||
|
||||
Reference in New Issue
Block a user