2
0
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:
Oliver
2026-02-06 10:21:30 +11:00
committed by GitHub
parent 2c59c165ba
commit b4eeba5e31
14 changed files with 545 additions and 68 deletions

View File

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

View File

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

View File

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

View File

@@ -266,7 +266,6 @@ export function partStocktakeFields(): ApiFormFieldSet {
cost_min: {},
cost_min_currency: {},
cost_max: {},
cost_max_currency: {},
note: {}
cost_max_currency: {}
};
}

View File

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

View File

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