2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-03-30 16:11: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,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 449
INVENTREE_API_VERSION = 450
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v450 -> 2026-02-10 : https://github.com/inventree/InvenTree/pull/11260
- Adds "part" field to the StockItemTracking model and API endpoints
- Additional filtering options for the StockItemTracking API endpoint
v449 -> 2026-02-07 : https://github.com/inventree/InvenTree/pull/11266
- Add missing nullable annotations to PartStocktakeSerializer

View File

@@ -1173,6 +1173,7 @@ class Build(
user,
notes=notes,
deltas={
'quantity': float(quantity),
'location': location.pk,
'status': StockStatus.REJECTED.value,
'buildorder': self.pk,
@@ -1267,7 +1268,11 @@ class Build(
output.save(add_note=False)
deltas = {'status': status, 'buildorder': self.pk}
deltas = {
'status': status,
'buildorder': self.pk,
'quantity': float(output.quantity),
}
if location:
deltas['location'] = location.pk

View File

@@ -1099,7 +1099,7 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'validator': bool,
},
'STOCKTAKE_ENABLE': {
'name': _('Enable Stock History'),
'name': _('Enable Stocktake'),
'description': _(
'Enable functionality for recording historical stock levels and value'
),
@@ -1109,30 +1109,47 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'STOCKTAKE_EXCLUDE_EXTERNAL': {
'name': _('Exclude External Locations'),
'description': _(
'Exclude stock items in external locations from stock history calculations'
'Exclude stock items in external locations from stocktake calculations'
),
'validator': bool,
'default': False,
},
'STOCKTAKE_AUTO_DAYS': {
'name': _('Automatic Stocktake Period'),
'description': _('Number of days between automatic stock history recording'),
'description': _('Number of days between automatic stocktake recording'),
'validator': [int, MinValueValidator(1)],
'default': 7,
'units': _('days'),
},
'STOCKTAKE_DELETE_OLD_ENTRIES': {
'name': _('Delete Old Stock History Entries'),
'name': _('Delete Old Stocktake Entries'),
'description': _(
'Delete stock history entries older than the specified number of days'
'Delete stocktake entries older than the specified number of days'
),
'default': False,
'validator': bool,
},
'STOCKTAKE_DELETE_DAYS': {
'name': _('Stock History Deletion Interval'),
'name': _('Stocktake Deletion Interval'),
'description': _(
'Stock history entries will be deleted after specified number of days'
'Stocktake entries will be deleted after specified number of days'
),
'default': 365,
'units': _('days'),
'validator': [int, MinValueValidator(30)],
},
'STOCK_TRACKING_DELETE_OLD_ENTRIES': {
'name': _('Delete Old Stock Tracking Entries'),
'description': _(
'Delete stock tracking entries older than the specified number of days'
),
'default': False,
'validator': bool,
},
'STOCK_TRACKING_DELETE_DAYS': {
'name': _('Stock Tracking Deletion Interval'),
'description': _(
'Stock tracking entries will be deleted after specified number of days'
),
'default': 365,
'units': _('days'),

View File

@@ -2915,7 +2915,12 @@ class ReturnOrder(TotalPriceMixin, Order):
if status is None:
status = StockStatus.QUARANTINED.value
deltas = {'status': status, 'returnorder': self.pk, 'location': location.pk}
deltas = {
'status': status,
'returnorder': self.pk,
'location': location.pk,
'quantity': float(line.quantity),
}
if stock_item.customer:
deltas['customer'] = stock_item.customer.pk

View File

@@ -1267,7 +1267,7 @@ class PurchaseOrderReceiveTest(OrderTest):
],
'location': location.pk,
},
max_query_count=104 + 2 * N_LINES,
max_query_count=104 + 3 * N_LINES,
).data
# Check for expected response

View File

@@ -4128,7 +4128,7 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
# Normalize decimal values to ensure consistent representation
# These values are only included if they are non-zero
# This is to provide some backwards compatibility from before these fields were addede
# This is to provide some backwards compatibility from before these fields were added
if value is not None and field in [
'quantity',
'attrition',

View File

@@ -329,7 +329,7 @@ def scheduled_stocktake_reports():
threshold = datetime.now() - timedelta(days=delete_n_days)
old_entries = PartStocktake.objects.filter(date__lt=threshold)
if old_entries.count() > 0:
if old_entries.exists():
logger.info('Deleting %s old stock entries', old_entries.count())
old_entries.delete()

View File

@@ -1490,6 +1490,54 @@ class StockTrackingOutputOptions(OutputConfiguration):
]
class StockTrackingFilter(FilterSet):
"""API filter options for the StockTrackingList endpoint."""
class Meta:
"""Metaclass options."""
model = StockItemTracking
fields = ['item', 'user']
include_variants = rest_filters.BooleanFilter(
label=_('Include Part Variants'), method='filter_include_variants'
)
def filter_include_variants(self, queryset, name, value):
"""Filter by whether or not to include part variants.
Note:
- This filter does nothing by itself, and is only used to modify the behavior of the 'part' filter.
- Refer to the 'filter_part' method for more information on how this works.
"""
return queryset
part = rest_filters.ModelChoiceFilter(
label=_('Part'), queryset=Part.objects.all(), method='filter_part'
)
def filter_part(self, queryset, name, part):
"""Filter StockTracking entries by the linked part.
Note:
- This filter behavior also takes into account the 'include_variants' filter, which determines whether or not to include part variants in the results.
"""
include_variants = str2bool(self.data.get('include_variants', False))
if include_variants:
return queryset.filter(part__in=part.get_descendants(include_self=True))
else:
return queryset.filter(part=part)
min_date = InvenTreeDateFilter(
label=_('Date after'), field_name='date', lookup_expr='gt'
)
max_date = InvenTreeDateFilter(
label=_('Date before'), field_name='date', lookup_expr='lt'
)
class StockTrackingList(
SerializerContextMixin, DataExportViewMixin, OutputOptionsMixin, ListAPI
):
@@ -1501,8 +1549,9 @@ class StockTrackingList(
- GET: Return list of StockItemTracking objects
"""
queryset = StockItemTracking.objects.all()
queryset = StockItemTracking.objects.all().prefetch_related('item', 'part')
serializer_class = StockSerializers.StockTrackingSerializer
filterset_class = StockTrackingFilter
output_options = StockTrackingOutputOptions
def get_delta_model_map(self) -> dict:
@@ -1598,8 +1647,6 @@ class StockTrackingList(
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['item', 'user']
ordering = '-date'
ordering_fields = ['date']

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.10 on 2026-02-05 12:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("part", "0146_auto_20251203_1241"),
("stock", "0116_alter_stockitem_link"),
]
operations = [
migrations.AddField(
model_name="stockitemtracking",
name="part",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="stock_tracking_info",
to="part.part",
),
),
migrations.AlterField(
model_name="stockitemtracking",
name="item",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tracking_info",
to="stock.stockitem",
),
),
]

View File

@@ -0,0 +1,57 @@
# Generated by Django 5.2.10 on 2026-02-05 12:18
from django.db import migrations
def add_part_links(apps, schema_editor):
"""Add links to the Part model for all existing StockItemTracking entries."""
StockItemTracking = apps.get_model('stock', 'StockItemTracking')
history_entries = []
for tracking in StockItemTracking.objects.all():
item = tracking.item
if item is None:
continue
part = item.part
if part is None:
continue
tracking.part = part
history_entries.append(tracking)
if len(history_entries) > 0:
StockItemTracking.objects.bulk_update(history_entries, ['part'])
print(f"\nUpdated {len(history_entries)} StockItemTracking entries with part links")
def remove_null_items(apps, schema_editor):
"""Reverse migration - remove any StockItemTracking entries which have a null item link."""
StockItemTracking = apps.get_model('stock', 'StockItemTracking')
null_items = StockItemTracking.objects.filter(item__isnull=True)
count = null_items.count()
if count > 0:
null_items.delete()
print(f"\nDeleted {count} StockItemTracking entries with null item links")
class Migration(migrations.Migration):
dependencies = [
("stock", "0117_stockitemtracking_part_alter_stockitemtracking_item"),
]
operations = [
migrations.RunPython(
add_part_links,
reverse_code=remove_null_items,
)
]

View File

@@ -1360,7 +1360,7 @@ class StockItem(
item.save(add_note=False)
code = StockHistoryCode.SENT_TO_CUSTOMER
deltas = {}
deltas = {'quantity': float(quantity)}
if customer is not None:
deltas['customer'] = customer.pk
@@ -1441,7 +1441,11 @@ class StockItem(
# Split the stock item
item = self.splitStock(quantity, None, user)
tracking_info = {}
tracking_info = {
'quantity': float(quantity)
if quantity is not None
else float(item.quantity)
}
if location:
tracking_info['location'] = location.pk
@@ -1651,7 +1655,7 @@ class StockItem(
stock_item.location = None
stock_item.save(add_note=False)
deltas = {'stockitem': self.pk}
deltas = {'stockitem': self.pk, 'quantity': float(quantity)}
if build is not None:
deltas['buildorder'] = build.pk
@@ -1666,7 +1670,7 @@ class StockItem(
StockHistoryCode.INSTALLED_CHILD_ITEM,
user,
notes=notes,
deltas={'stockitem': stock_item.pk},
deltas={'stockitem': stock_item.pk, 'quantity': float(quantity)},
)
trigger_event(
@@ -1692,11 +1696,14 @@ class StockItem(
self.belongs_to.add_tracking_entry(
StockHistoryCode.REMOVED_CHILD_ITEM,
user,
deltas={'stockitem': self.pk},
deltas={'stockitem': self.pk, 'quantity': float(self.quantity)},
notes=notes,
)
tracking_info = {'stockitem': self.belongs_to.pk}
tracking_info = {
'stockitem': self.belongs_to.pk,
'quantity': float(self.quantity),
}
self.add_tracking_entry(
StockHistoryCode.REMOVED_FROM_ASSEMBLY,
@@ -1835,6 +1842,7 @@ class StockItem(
entry = StockItemTracking(
item=self,
part=self.part,
tracking_type=entry_type.value,
user=user,
date=InvenTree.helpers.current_time(),
@@ -1964,7 +1972,7 @@ class StockItem(
# Remove the equivalent number of items
self.take_stock(
quantity, user, code=StockHistoryCode.STOCK_SERIZALIZED, notes=notes
quantity, user, code=StockHistoryCode.STOCK_SERIALIZED, notes=notes
)
return items
@@ -2175,7 +2183,10 @@ class StockItem(
user,
quantity=self.quantity,
notes=notes,
deltas={'location': location.pk if location else None},
deltas={
'location': location.pk if location else None,
'quantity': self.quantity,
},
)
# Update the location of the item
@@ -2416,7 +2427,7 @@ class StockItem(
self.location = location
tracking_info = {}
tracking_info = {'quantity': float(quantity)}
tracking_code = StockHistoryCode.STOCK_MOVE
@@ -2869,21 +2880,18 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
class StockItemTracking(InvenTree.models.InvenTreeModel):
"""Stock tracking entry - used for tracking history of a particular StockItem.
Note: 2021-05-11
The legacy StockTrackingItem model contained very little information about the "history" of the item.
In fact, only the "quantity" of the item was recorded at each interaction.
Also, the "title" was translated at time of generation, and thus was not really translatable.
The "new" system tracks all 'delta' changes to the model,
and tracks change "type" which can then later be translated
Attributes:
item: ForeignKey reference to a particular StockItem
part: ForeignKey reference to the Part associated with this StockItem
date: Date that this tracking info was created
tracking_type: The type of tracking information
notes: Associated notes (input by user)
user: The user associated with this tracking info
deltas: The changes associated with this history item
Notes:
If the underlying stock item is deleted, the "item" field will be set to null, but the tracking information will be retained.
The tracking data will be removed if the associated part is deleted, as the tracking information is not relevant without the part context.
"""
class Meta:
@@ -2896,6 +2904,13 @@ class StockItemTracking(InvenTree.models.InvenTreeModel):
"""Return API url."""
return reverse('api-stock-tracking-list')
def save(self, *args, **kwargs):
"""Ensure that the 'part' link is always correct."""
if self.item:
self.part = self.item.part
super().save(*args, **kwargs)
def get_absolute_url(self):
"""Return url for instance."""
return InvenTree.helpers.pui_url(f'/stock/item/{self.item.id}')
@@ -2910,7 +2925,19 @@ class StockItemTracking(InvenTree.models.InvenTreeModel):
tracking_type = models.IntegerField(default=StockHistoryCode.LEGACY)
item = models.ForeignKey(
StockItem, on_delete=models.CASCADE, related_name='tracking_info'
StockItem,
on_delete=models.SET_NULL,
null=True,
blank=False,
related_name='tracking_info',
)
part = models.ForeignKey(
'part.part',
on_delete=models.CASCADE,
related_name='stock_tracking_info',
null=True,
blank=True,
)
date = models.DateTimeField(auto_now_add=True, editable=False)

View File

@@ -1248,6 +1248,8 @@ class StockTrackingSerializer(
'pk',
'item',
'item_detail',
'part',
'part_detail',
'date',
'deltas',
'label',
@@ -1256,13 +1258,21 @@ class StockTrackingSerializer(
'user',
'user_detail',
]
read_only_fields = ['date', 'user', 'label', 'tracking_type']
read_only_fields = ['date', 'part', 'user', 'label', 'tracking_type']
label = serializers.CharField(read_only=True)
item_detail = enable_filter(
StockItemSerializer(source='item', many=False, read_only=True, allow_null=True),
prefetch_fields=['item'],
prefetch_fields=['item', 'item__part'],
)
part_detail = enable_filter(
part_serializers.PartBriefSerializer(
source='part', many=False, read_only=True, allow_null=True
),
default_include=False,
prefetch_fields=['part'],
)
user_detail = enable_filter(

View File

@@ -53,7 +53,7 @@ class StockHistoryCode(StatusCode):
STOCK_COUNT = 10, _('Stock counted')
STOCK_ADD = 11, _('Stock manually added')
STOCK_REMOVE = 12, _('Stock manually removed')
STOCK_SERIZALIZED = 13, _('Serialized stock items')
STOCK_SERIALIZED = 13, _('Serialized stock items')
RETURNED_TO_STOCK = 15, _('Returned to stock') # Stock item returned to stock

View File

@@ -1,8 +1,13 @@
"""Background tasks for the stock app."""
from datetime import datetime, timedelta
import structlog
from opentelemetry import trace
from common.settings import get_global_setting
from InvenTree.tasks import ScheduledTask, offload_task, scheduled_task
tracer = trace.get_tracer(__name__)
logger = structlog.get_logger('inventree')
@@ -43,7 +48,6 @@ def rebuild_stock_item_tree(tree_id: int, rebuild_on_fail: bool = True) -> bool:
"""
from InvenTree.exceptions import log_error
from InvenTree.sentry import report_exception
from InvenTree.tasks import offload_task
from stock.models import StockItem
if tree_id:
@@ -65,3 +69,27 @@ def rebuild_stock_item_tree(tree_id: int, rebuild_on_fail: bool = True) -> bool:
# No tree_id provided, so rebuild the entire tree
StockItem.objects.rebuild()
return True
@tracer.start_as_current_span('delete_old_stock_tracking')
@scheduled_task(ScheduledTask.DAILY)
def delete_old_stock_tracking():
"""Remove old stock tracking entries before a certain date."""
from stock.models import StockItemTracking
if not get_global_setting('STOCK_TRACKING_DELETE_OLD_ENTRIES', False):
return
delete_n_days = int(get_global_setting('STOCK_TRACKING_DELETE_DAYS', 365))
threshold = datetime.now() - timedelta(days=delete_n_days)
old_entries = StockItemTracking.objects.filter(date__lte=threshold)
if old_entries.exists():
logger.info(
'Deleting old stock tracking entries',
count=old_entries.count(),
threshold=threshold,
)
old_entries.delete()

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 }) => {