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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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