2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-05 11:01:04 +00:00

[enhancements] Stock tracking enhancements (#11260)

* Data migrations for StockItemTracking

- Propagate the 'part' links

* Enable filtering of stock tracking entries by part

* Enable filtering by date range

* Display stock tracking for part

* Table enhancements

* Bump API version

* Display stock item column

* Ensure 'quantity' is recorded for stock tracking entries

* Add new global settings

* Adds background task for deleting old stock tracking entries

* Docs updates

* Enhanced docs

* Cast quantity to float

* Rever data migration

* Ensure part link gets created

* Improved prefetch for API

* Playwright testing

* Tweak unit test thresholds

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2026-02-10 21:54:35 +11:00
committed by GitHub
parent 613ed40843
commit 1c1933b694
29 changed files with 669 additions and 208 deletions

View File

@@ -1,6 +1,6 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
import { Alert, Divider, Skeleton, Stack, Text, Title } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useStore } from 'zustand';
@@ -24,11 +24,13 @@ import { SettingItem } from './SettingItem';
* Display a list of setting items, based on a list of provided keys
*/
export function SettingList({
heading,
settingsState,
keys,
onChange,
onLoaded
}: Readonly<{
heading?: string;
settingsState: SettingsStateProps;
keys?: string[];
onChange?: () => void;
@@ -162,6 +164,8 @@ export function SettingList({
<>
{editSettingModal.modal}
<Stack gap='xs'>
{heading && <Title order={4}>{heading}</Title>}
{heading && <Divider />}
{(keys || allKeys)?.map((key, i) => {
const setting = settingsState?.settings?.find(
(s: any) => s.key === key
@@ -198,16 +202,26 @@ export function SettingList({
);
}
export function UserSettingList({ keys }: Readonly<{ keys: string[] }>) {
export function UserSettingList({
keys,
heading
}: Readonly<{ keys: string[]; heading?: string }>) {
const userSettings = useUserSettingsState();
return <SettingList settingsState={userSettings} keys={keys} />;
return (
<SettingList settingsState={userSettings} keys={keys} heading={heading} />
);
}
export function GlobalSettingList({ keys }: Readonly<{ keys: string[] }>) {
export function GlobalSettingList({
keys,
heading
}: Readonly<{ keys: string[]; heading?: string }>) {
const globalSettings = useGlobalSettingsState();
return <SettingList settingsState={globalSettings} keys={keys} />;
return (
<SettingList settingsState={globalSettings} keys={keys} heading={heading} />
);
}
export function PluginSettingList({

View File

@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Skeleton, Stack } from '@mantine/core';
import { Divider, Skeleton, Stack } from '@mantine/core';
import {
IconBellCog,
IconCategory,
@@ -254,15 +254,26 @@ export default function SystemSettings() {
label: t`Stock History`,
icon: <IconClipboardList />,
content: (
<GlobalSettingList
keys={[
'STOCKTAKE_ENABLE',
'STOCKTAKE_EXCLUDE_EXTERNAL',
'STOCKTAKE_AUTO_DAYS',
'STOCKTAKE_DELETE_OLD_ENTRIES',
'STOCKTAKE_DELETE_DAYS'
]}
/>
<Stack gap='xs'>
<GlobalSettingList
heading={t`Part Stocktake`}
keys={[
'STOCKTAKE_ENABLE',
'STOCKTAKE_EXCLUDE_EXTERNAL',
'STOCKTAKE_AUTO_DAYS',
'STOCKTAKE_DELETE_OLD_ENTRIES',
'STOCKTAKE_DELETE_DAYS'
]}
/>
<Divider />
<GlobalSettingList
heading={t`Stock Tracking`}
keys={[
'STOCK_TRACKING_DELETE_OLD_ENTRIES',
'STOCK_TRACKING_DELETE_DAYS'
]}
/>
</Stack>
)
},
{

View File

@@ -7,6 +7,7 @@ import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { type ChartTooltipProps, LineChart } from '@mantine/charts';
import {
Accordion,
Center,
Divider,
Loader,
@@ -16,6 +17,7 @@ import {
} from '@mantine/core';
import dayjs from 'dayjs';
import { useCallback, useMemo, useState } from 'react';
import { StylishText } from '../../components/items/StylishText';
import { formatDate, formatPriceRange } from '../../defaults/formatters';
import { partStocktakeFields } from '../../forms/PartForms';
import {
@@ -27,6 +29,7 @@ import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
import { DateColumn, DecimalColumn } from '../../tables/ColumnRenderers';
import { InvenTreeTable } from '../../tables/InvenTreeTable';
import { StockTrackingTable } from '../../tables/stock/StockTrackingTable';
/*
* Render a tooltip for the chart, with correct date information
@@ -64,9 +67,7 @@ function ChartTooltip({ label, payload }: Readonly<ChartTooltipProps>) {
);
}
export default function PartStockHistoryDetail({
partId
}: Readonly<{ partId: number }>) {
export function PartStocktakePanel({ partId }: Readonly<{ partId: number }>) {
const user = useUserState();
const table = useTable('part-stocktake');
@@ -208,7 +209,7 @@ export default function PartStockHistoryDetail({
{newStocktakeEntry.modal}
{editStocktakeEntry.modal}
{deleteStocktakeEntry.modal}
<SimpleGrid cols={{ base: 1, md: 2 }}>
<SimpleGrid cols={{ base: 1, lg: 2 }}>
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_stocktake_list)}
tableState={table}
@@ -284,3 +285,28 @@ export default function PartStockHistoryDetail({
</>
);
}
export default function PartStockHistoryDetail({
partId
}: Readonly<{ partId: number }>) {
return (
<Accordion multiple defaultValue={['stocktake']}>
<Accordion.Item value='tracking'>
<Accordion.Control>
<StylishText size='lg'>{t`Stock Tracking`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<StockTrackingTable partId={partId} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='stocktake'>
<Accordion.Control>
<StylishText size='lg'>{t`Stocktake Entries`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<PartStocktakePanel partId={partId} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@@ -24,8 +24,13 @@ import {
} from '../../components/render/Stock';
import { RenderUser } from '../../components/render/User';
import { useTable } from '../../hooks/UseTable';
import { DateColumn, DescriptionColumn } from '../ColumnRenderers';
import { UserFilter } from '../Filter';
import { DateColumn, DescriptionColumn, PartColumn } from '../ColumnRenderers';
import {
IncludeVariantsFilter,
MaxDateFilter,
MinDateFilter,
UserFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
type StockTrackingEntry = {
@@ -34,9 +39,15 @@ type StockTrackingEntry = {
details: ReactNode;
};
export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
export function StockTrackingTable({
itemId,
partId
}: Readonly<{
itemId?: number;
partId?: number;
}>) {
const navigate = useNavigate();
const table = useTable('stock_tracking');
const table = useTable(partId ? 'part_stock_tracking' : 'stock_tracking');
// Render "details" for a stock tracking record
const renderDetails = useCallback(
@@ -200,6 +211,9 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
const filters: TableFilter[] = useMemo(() => {
return [
MinDateFilter(),
MaxDateFilter(),
IncludeVariantsFilter(),
UserFilter({
name: 'user',
label: t`User`,
@@ -213,6 +227,43 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
DateColumn({
switchable: false
}),
PartColumn({
title: t`Part`,
part: 'part_detail',
switchable: true,
hidden: !partId
}),
{
title: t`IPN`,
accessor: 'part_detail.IPN',
sortable: true,
defaultVisible: false,
switchable: true,
hidden: !partId
},
{
accessor: 'item',
title: t`Stock Item`,
sortable: false,
switchable: false,
hidden: !partId,
render: (record: any) => {
const item = record.item_detail;
if (!item) {
return (
<Text
c='red'
size='xs'
fs='italic'
>{t`Stock item no longer exists`}</Text>
);
} else if (item.serial && item.quantity == 1) {
return `${t`Serial`} #${item.serial}`;
} else {
return `${t`Item ID`} ${item.pk}`;
}
}
},
DescriptionColumn({
accessor: 'label'
}),
@@ -250,10 +301,15 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
props={{
params: {
item: itemId,
part: partId,
part_detail: partId ? true : undefined,
item_detail: partId ? true : undefined,
user_detail: true
},
enableDownload: true,
tableFilters: filters
tableFilters: filters,
modelType: partId ? ModelType.stockitem : undefined,
modelField: 'item'
}}
/>
);

View File

@@ -423,6 +423,55 @@ test('Stock - Tracking', async ({ browser }) => {
await page.getByText('- - Factory/Office Block/Room').first().waitFor();
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
/* Add some more stock items and tracking information:
* - Duplicate this stock item
* - Give it a unique serial number
* - Ensure the tracking information is duplicated correctly
* - Delete the new stock item
* - Ensure that the tracking information is retained against the base part
*/
// Duplicate the stock item
await page
.getByRole('button', { name: 'action-menu-stock-item-actions' })
.click();
await page
.getByRole('menuitem', { name: 'action-menu-stock-item-actions-duplicate' })
.click();
await page
.getByRole('textbox', { name: 'text-field-serial_numbers' })
.fill('9876');
await page.getByRole('button', { name: 'Submit' }).click();
// Check stock tracking information is correct
await page.getByText('Serial Number: 9876').first().waitFor();
await loadTab(page, 'Stock Tracking');
await page
.getByRole('cell', { name: 'Stock item created' })
.first()
.waitFor();
// Delete this stock item
await page
.getByRole('button', { name: 'action-menu-stock-item-actions' })
.click();
await page
.getByRole('menuitem', { name: 'action-menu-stock-item-actions-delete' })
.click();
await page.getByRole('button', { name: 'Delete' }).click();
// Check stock tracking for base part
await loadTab(page, 'Stock History');
await page.getByRole('button', { name: 'Stock Tracking' }).click();
await page.getByText('Stock item no longer exists').first().waitFor();
await page
.getByRole('cell', { name: 'Thumbnail Blue Widget' })
.first()
.waitFor();
await page.getByRole('cell', { name: 'Item ID 232' }).first().waitFor();
await page.getByRole('cell', { name: 'Serial #116' }).first().waitFor();
});
test('Stock - Location', async ({ browser }) => {