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:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user