2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-12 17:27:02 +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

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -14,18 +14,18 @@ In particular, an individual *Stocktake* record tracks the following information
- A reference to the [part](./index.md) which is being counted - A reference to the [part](./index.md) which is being counted
- The total number of individual [stock items](../stock/index.md) available - The total number of individual [stock items](../stock/index.md) available
- The total stock quantity of available stock - The total stock quantity of available stock
- The total cost of stock on hand - The total value range of stock on hand
### Stock Items vs Stock Quantity ### Stock Items vs Stock Quantity
*Stock Items* refers to the number of stock entries (e.g. *"3 reels of capacitors"*). *Stock Quantity* refers to the total cumulative stock count (e.g. *"4,560 total capacitors"*). *Stock Items* refers to the number of stock entries (e.g. *"3 reels of capacitors"*). *Stock Quantity* refers to the total cumulative stock count (e.g. *"4,560 total capacitors"*).
### Cost of Stock on Hand ### Value Range of Stock on Hand
The total cost of stock on hand is calculated based on the provided pricing data. For stock items which have a recorded *cost* (e.g. *purchase price*), this value is used. If no direct pricing information is available for a particular stock item, the price range of the part itself is used. The total value range of stock on hand is calculated based on the provided pricing data. For stock items which have a recorded *cost* (e.g. *purchase price*), this value is used. If no direct pricing information is available for a particular stock item, the price range of the part itself is used.
!!! info "Cost Range" !!! info "Value Range"
Cost data is provided as a *range* of values, accounting for any variability in available pricing data. Value data is provided as a *range* of values, accounting for any variability in available pricing data.
### Display Historical Stock Data ### Display Historical Stock Data
@@ -39,6 +39,42 @@ If this tab is not visible, ensure that the *Enable Stock History* [user setting
{{ image("part/part_stocktake_enable_tab.png", "Enable stock history tab") }} {{ image("part/part_stocktake_enable_tab.png", "Enable stock history tab") }}
### Stocktake Entry Generation
By default, stocktake entries are generated automatically at regular intervals (see [settings](#stock-history-settings) below). However, users can generate a stocktake entry on demand, using the *Generate Stocktake Entry* button in the *Stock History* tab:
{{ image("part/part_stocktake_manual.png", "Generate stocktake entry") }}
This will schedule the generation of a new stocktake entry for the selected part, and the new entry will be visible in the stock history data once the generation process is complete.
## Stocktake Reports
In addition to the part stocktake entries, which are periodically generated for all parts in the database, users can also generate a stocktake *report*, against a particular set of input parameters. Instead of generating a stocktake entry for a single part, this process generates a report which contains stocktake data for all parts which match the specified parameters.
The generated report (once completed) will be available for download as a CSV file, and will contain the stocktake entry data for all parts which match the specified parameters.
### Report Options
The following parameters can be specified when generating a stocktake report:
| Parameter | Description |
| --------- | ----------- |
| Part | If provided, the report will only include stocktake data for the specified part, including and variant parts. If left blank, the report will include data for all parts in the database. |
| Category | If provided, the report will only include stocktake data for parts which belong to the specified category, including any sub-categories. If left blank, the report will include data for all parts in the database. |
| Location | If provided, the report will only include stocktake data for parts which have stock items located at the specified location, including any sub-locations. If left blank, the report will include data for all parts in the database. |
### Generating a Stocktake Report
The following methods for generating a stocktake report via the user interface are available:
#### Dashboard Widget
A dashboard widget is available for generating stocktake reports, which can be added to any dashboard view:
{{ image("part/stocktake_report_dashboard.png", "Stocktake dashboard widget") }}
Here, the user can specify the report parameters, and then click the *Generate Report* button to generate a new stocktake report based on the specified parameters.
## Stock History Settings ## Stock History Settings
There are a number of configuration options available in the [settings view](../settings/global.md): There are a number of configuration options available in the [settings view](../settings/global.md):

View File

@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 447 INVENTREE_API_VERSION = 448
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v448 -> 2026-02-05 : https://github.com/inventree/InvenTree/pull/11257
- Adds API endpoint for manually generating a stocktake entry
v447 -> 2026-02-02 : https://github.com/inventree/InvenTree/pull/11242 v447 -> 2026-02-02 : https://github.com/inventree/InvenTree/pull/11242
- Adds "sub_part_active" filter to BomItem API endpoint - Adds "sub_part_active" filter to BomItem API endpoint

View File

@@ -304,7 +304,7 @@ class DataExportViewMixin:
"""Export the data in the specified format. """Export the data in the specified format.
Arguments: Arguments:
export_plugin: The plugin instance to use for exporting the data export_plugin: The plugin instance to use for exporting the data. If not provided, the default exporter is used
export_format: The file format to export the data in export_format: The file format to export the data in
export_context: Additional context data to pass to the plugin export_context: Additional context data to pass to the plugin
output: The DataOutput object to write to output: The DataOutput object to write to
@@ -312,6 +312,11 @@ class DataExportViewMixin:
- By default, uses the provided serializer to generate the data, and return it as a file download. - By default, uses the provided serializer to generate the data, and return it as a file download.
- If a plugin is specified, the plugin can be used to augment or replace the export functionality. - If a plugin is specified, the plugin can be used to augment or replace the export functionality.
""" """
if export_plugin is None:
from plugin.registry import registry
export_plugin = registry.get_plugin('inventree-exporter')
# Get the base serializer class for the view # Get the base serializer class for the view
serializer_class = self.get_serializer_class() serializer_class = self.get_serializer_class()

View File

@@ -1204,11 +1204,18 @@ class PartStocktakeFilter(FilterSet):
fields = ['part'] fields = ['part']
class PartStocktakeList(BulkDeleteMixin, ListCreateAPI): class PartStocktakeMixin:
"""Mixin class for PartStocktake API endpoints."""
queryset = PartStocktake.objects.all().prefetch_related('part')
serializer_class = part_serializers.PartStocktakeSerializer
class PartStocktakeList(
PartStocktakeMixin, DataExportViewMixin, BulkDeleteMixin, ListCreateAPI
):
"""API endpoint for listing part stocktake information.""" """API endpoint for listing part stocktake information."""
queryset = PartStocktake.objects.all()
serializer_class = part_serializers.PartStocktakeSerializer
filterset_class = PartStocktakeFilter filterset_class = PartStocktakeFilter
def get_serializer_context(self): def get_serializer_context(self):
@@ -1226,14 +1233,65 @@ class PartStocktakeList(BulkDeleteMixin, ListCreateAPI):
ordering = '-pk' ordering = '-pk'
class PartStocktakeDetail(RetrieveUpdateDestroyAPI): class PartStocktakeDetail(PartStocktakeMixin, RetrieveUpdateDestroyAPI):
"""Detail API endpoint for a single PartStocktake instance. """Detail API endpoint for a single PartStocktake instance.
Note: Only staff (admin) users can access this endpoint. Note: Only staff (admin) users can access this endpoint.
""" """
class PartStocktakeGenerate(CreateAPI):
"""API endpoint for generating a PartStocktake instance."""
queryset = PartStocktake.objects.all() queryset = PartStocktake.objects.all()
serializer_class = part_serializers.PartStocktakeSerializer serializer_class = part_serializers.PartStocktakeGenerateSerializer
def post(self, request, *args, **kwargs):
"""Perform stocktake generation on POST request."""
from common.models import DataOutput
from part.stocktake import perform_stocktake
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
part = data.get('part', None)
category = data.get('category', None)
location = data.get('location', None)
# Do we want to generate a report?
if data.get('generate_report', True):
report_output = DataOutput.objects.create(
user=request.user, output_type='stocktake'
)
else:
report_output = None
# Offload the actual stocktake generation to a background task, as it may take some time to complete
offload_task(
perform_stocktake,
part_id=part.pk if part else None,
category_id=category.pk if category else None,
location_id=location.pk if location else None,
generate_entry=data.get('generate_entry', True),
report_output_id=report_output.pk if report_output else None,
group='stocktake',
)
if report_output:
report_output.refresh_from_db()
result = {
'category': category,
'location': location,
'part': part,
'output': report_output,
}
output_serializer = part_serializers.PartStocktakeGenerateSerializer(result)
return Response(output_serializer.data)
class BomFilter(FilterSet): class BomFilter(FilterSet):
@@ -1604,6 +1662,11 @@ part_api_urls = [
PartStocktakeDetail.as_view(), PartStocktakeDetail.as_view(),
name='api-part-stocktake-detail', name='api-part-stocktake-detail',
), ),
path(
'generate/',
PartStocktakeGenerate.as_view(),
name='api-part-stocktake-generate',
),
path('', PartStocktakeList.as_view(), name='api-part-stocktake-list'), path('', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
]), ]),
), ),

View File

@@ -30,6 +30,7 @@ import part.filters as part_filters
import part.helpers as part_helpers import part.helpers as part_helpers
import stock.models import stock.models
import users.models import users.models
from data_exporter.mixins import DataExportSerializerMixin
from importer.registry import register_importer from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema from InvenTree.ready import isGeneratingSchema
@@ -1187,7 +1188,11 @@ class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer)
return part.sales_order_allocation_count(include_variants=True, pending=True) return part.sales_order_allocation_count(include_variants=True, pending=True)
class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): class PartStocktakeSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataExportSerializerMixin,
InvenTree.serializers.InvenTreeModelSerializer,
):
"""Serializer for the PartStocktake model.""" """Serializer for the PartStocktake model."""
class Meta: class Meta:
@@ -1196,18 +1201,32 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
model = PartStocktake model = PartStocktake
fields = [ fields = [
'pk', 'pk',
'date',
'part', 'part',
'part_name',
'part_ipn',
'part_description',
'date',
'item_count', 'item_count',
'quantity', 'quantity',
'cost_min', 'cost_min',
'cost_min_currency', 'cost_min_currency',
'cost_max', 'cost_max',
'cost_max_currency', 'cost_max_currency',
# Optional detail fields
'part_detail',
] ]
read_only_fields = ['date', 'user'] read_only_fields = ['date', 'user']
def __init__(self, *args, **kwargs):
"""Custom initialization for PartStocktakeSerializer."""
exclude_pk = kwargs.pop('exclude_pk', False)
super().__init__(*args, **kwargs)
if exclude_pk:
self.fields.pop('pk', None)
quantity = serializers.FloatField() quantity = serializers.FloatField()
cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
@@ -1216,6 +1235,23 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer() cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
part_name = serializers.CharField(
source='part.name', read_only=True, label=_('Part Name')
)
part_ipn = serializers.CharField(
source='part.IPN', read_only=True, label=_('Part IPN')
)
part_description = serializers.CharField(
source='part.description', read_only=True, label=_('Part Description')
)
part_detail = enable_filter(
PartBriefSerializer(source='part', read_only=True, many=False, pricing=False),
default_include=False,
)
def save(self): def save(self):
"""Called when this serializer is saved.""" """Called when this serializer is saved."""
data = self.validated_data data = self.validated_data
@@ -1226,6 +1262,72 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
return super().save() return super().save()
class PartStocktakeGenerateSerializer(serializers.Serializer):
"""Serializer for generating PartStocktake entries."""
class Meta:
"""Metaclass options."""
fields = [
'part',
'category',
'location',
'generate_entry',
'generate_report',
'output',
]
part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.all(),
label=_('Part'),
help_text=_(
'Select a part to generate stocktake information for that part (and any variant parts)'
),
required=False,
allow_null=True,
)
category = serializers.PrimaryKeyRelatedField(
queryset=PartCategory.objects.all(),
label=_('Category'),
help_text=_(
'Select a category to include all parts within that category (and subcategories)'
),
required=False,
allow_null=True,
)
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
label=_('Location'),
help_text=_(
'Select a location to include all parts with stock in that location (including sub-locations)'
),
required=False,
allow_null=True,
)
generate_entry = serializers.BooleanField(
label=_('Generate Stocktake Entries'),
help_text=_('Save stocktake entries for the selected parts'),
write_only=True,
required=False,
default=False,
)
generate_report = serializers.BooleanField(
label=_('Generate Report'),
help_text=_('Generate a stocktake report for the selected parts'),
write_only=True,
required=False,
default=False,
)
output = common.serializers.DataOutputSerializer(
read_only=True, many=False, label=_('Output')
)
@extend_schema_field( @extend_schema_field(
serializers.CharField( serializers.CharField(
help_text=_('Select currency from available options') help_text=_('Select currency from available options')

View File

@@ -1,16 +1,48 @@
"""Stock history functionality.""" """Stock history functionality."""
from typing import Optional
from django.core.files.base import ContentFile
import structlog import structlog
import tablib
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money from djmoney.money import Money
import common.models
from InvenTree.helpers import current_date
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
def perform_stocktake() -> None: def perform_stocktake(
"""Generate stock history entries for all active parts.""" part_id: Optional[int] = None,
category_id: Optional[int] = None,
location_id: Optional[int] = None,
exclude_external: Optional[bool] = None,
generate_entry: bool = True,
report_output_id: Optional[int] = None,
) -> None:
"""Capture a snapshot of stock-on-hand and stock value.
Arguments:
part_id: Optional ID of a part to perform stocktake on. If not provided, all active parts will be processed.
category_id: Optional category ID to use to filter parts
location_id: Optional location ID to use to filter stock items
exclude_external: If True, exclude external stock items from the stocktake
generate_entry: If True, create stocktake entries in the database
report_output_id: Optional ID of a DataOutput object for the stocktake report (e.g. for download)
The default implementation creates stocktake entries for all active parts,
and writes these stocktake entries to the database.
Alternatively, the scope of the stocktake can be limited by providing a queryset of parts,
or by providing a category ID or location ID to filter the parts/stock items.
"""
import InvenTree.helpers import InvenTree.helpers
import part.models as part_models import part.models as part_models
import part.serializers as part_serializers
import stock.models as stock_models
from common.currency import currency_code_default from common.currency import currency_code_default
from common.settings import get_global_setting from common.settings import get_global_setting
@@ -18,36 +50,98 @@ def perform_stocktake() -> None:
logger.info('Stocktake functionality is disabled - skipping') logger.info('Stocktake functionality is disabled - skipping')
return return
# If exclude_external is not provided, use global setting
if exclude_external is None:
exclude_external = get_global_setting( exclude_external = get_global_setting(
'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False 'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False
) )
active_parts = part_models.Part.objects.filter(active=True) # If a single part is provided, limit to that part
# Otherwise, start with all active parts
if part_id is not None:
try:
part = part_models.Part.objects.get(id=part_id)
parts = part.get_descendants(include_self=True)
except (ValueError, part_models.Part.DoesNotExist):
parts = part_models.Part.objects.all()
else:
parts = part_models.Part.objects.all()
# Only use active parts
parts = parts.filter(active=True)
# Prefetch related pricing information
parts = parts.prefetch_related('pricing_data', 'stock_items')
# Filter part queryset by category, if provided
if category_id is not None:
# Filter parts by category (including subcategories)
try:
category = part_models.PartCategory.objects.get(id=category_id)
parts = parts.filter(
category__in=category.get_descendants(include_self=True)
)
except (ValueError, part_models.PartCategory.DoesNotExist):
pass
# Fetch location if provided
if location_id is not None:
try:
location = stock_models.StockLocation.objects.get(id=location_id)
except (ValueError, stock_models.StockLocation.DoesNotExist):
location = None
else:
location = None
if location is not None:
# Location limited, so we will disable saving of stocktake entries
generate_entry = False
# New history entries to be created # New history entries to be created
history_entries = [] history_entries = []
N_BULK_CREATE = 250
base_currency = currency_code_default() base_currency = currency_code_default()
today = InvenTree.helpers.current_date() today = InvenTree.helpers.current_date()
logger.info( logger.info('Creating new stock history entries for %s parts', parts.count())
'Creating new stock history entries for %s active parts', active_parts.count()
)
for part in active_parts: # Fetch report output object if provided
if report_output_id is not None:
try:
report_output = common.models.DataOutput.objects.get(id=report_output_id)
except (ValueError, common.models.DataOutput.DoesNotExist):
report_output = None
else:
report_output = None
if report_output:
# Initialize progress on the report output
report_output.total = parts.count()
report_output.progress = 0
report_output.complete = False
report_output.save()
for part in parts:
# Is there a recent stock history record for this part? # Is there a recent stock history record for this part?
if part_models.PartStocktake.objects.filter( if (
generate_entry
and part_models.PartStocktake.objects.filter(
part=part, date__gte=today part=part, date__gte=today
).exists(): ).exists()
):
continue continue
pricing = part.pricing try:
pricing = part.pricing_data
except Exception:
pricing = None
# Fetch all 'in stock' items for this part # Fetch all 'in stock' items for this part
stock_items = part.stock_entries( stock_items = part.stock_entries(
in_stock=True, include_external=not exclude_external, include_variants=True location=location,
in_stock=True,
include_external=not exclude_external,
include_variants=True,
) )
total_cost_min = Money(0, base_currency) total_cost_min = Money(0, base_currency)
@@ -56,23 +150,34 @@ def perform_stocktake() -> None:
total_quantity = 0 total_quantity = 0
items_count = 0 items_count = 0
if stock_items.count() == 0:
# No stock items - skip this part if location is specified
if location:
continue
for item in stock_items: for item in stock_items:
# Extract cost information # Extract cost information
entry_cost_min = pricing.overall_min or pricing.overall_max entry_cost_min = (
entry_cost_max = pricing.overall_max or pricing.overall_min (pricing.overall_min or pricing.overall_max) if pricing else None
)
entry_cost_max = (
(pricing.overall_max or pricing.overall_min) if pricing else None
)
if item.purchase_price is not None: if item.purchase_price is not None:
entry_cost_min = item.purchase_price entry_cost_min = item.purchase_price
entry_cost_max = item.purchase_price entry_cost_max = item.purchase_price
try: try:
entry_cost_min = ( entry_cost_min = entry_cost_min * item.quantity
convert_money(entry_cost_min, base_currency) * item.quantity entry_cost_max = entry_cost_max * item.quantity
)
entry_cost_max = ( if entry_cost_min.currency != base_currency:
convert_money(entry_cost_max, base_currency) * item.quantity entry_cost_min = convert_money(entry_cost_min, base_currency)
)
if entry_cost_max.currency != base_currency:
entry_cost_max = convert_money(entry_cost_max, base_currency)
except Exception: except Exception:
entry_cost_min = Money(0, base_currency) entry_cost_min = Money(0, base_currency)
entry_cost_max = Money(0, base_currency) entry_cost_max = Money(0, base_currency)
@@ -83,6 +188,13 @@ def perform_stocktake() -> None:
total_cost_min += entry_cost_min total_cost_min += entry_cost_min
total_cost_max += entry_cost_max total_cost_max += entry_cost_max
if report_output:
report_output.progress += 1
# Update report progress every few items, to avoid excessive database writes
if report_output.progress % 50 == 0:
report_output.save()
# Add a new stocktake entry for this part # Add a new stocktake entry for this part
history_entries.append( history_entries.append(
part_models.PartStocktake( part_models.PartStocktake(
@@ -94,11 +206,31 @@ def perform_stocktake() -> None:
) )
) )
# Batch create stock history entries if generate_entry:
if len(history_entries) >= N_BULK_CREATE: # Bulk-create PartStocktake entries
part_models.PartStocktake.objects.bulk_create(history_entries) part_models.PartStocktake.objects.bulk_create(history_entries)
history_entries = []
if len(history_entries) > 0: if report_output:
# Save any remaining stocktake entries # Save report data, and mark as complete
part_models.PartStocktake.objects.bulk_create(history_entries)
today = current_date()
serializer = part_serializers.PartStocktakeSerializer(exclude_pk=True)
headers = serializer.generate_headers()
# Export the data to a file
dataset = tablib.Dataset(headers=list(headers.values()))
header_keys = list(headers.keys())
for entry in history_entries:
entry.date = today
row = serializer.to_representation(entry)
dataset.append([row.get(header, '') for header in header_keys])
datafile = dataset.export('csv')
report_output.mark_complete(
output=ContentFile(datafile, 'stocktake_report.csv')
)

View File

@@ -120,6 +120,7 @@ export enum ApiEndpoints {
part_pricing_internal = 'part/internal-price/', part_pricing_internal = 'part/internal-price/',
part_pricing_sale = 'part/sale-price/', part_pricing_sale = 'part/sale-price/',
part_stocktake_list = 'part/stocktake/', part_stocktake_list = 'part/stocktake/',
part_stocktake_generate = 'part/stocktake/generate/',
category_list = 'part/category/', category_list = 'part/category/',
category_tree = 'part/category/tree/', category_tree = 'part/category/tree/',
category_parameter_list = 'part/category/parameters/', category_parameter_list = 'part/category/parameters/',

View File

@@ -9,12 +9,13 @@ import GetStartedWidget from './widgets/GetStartedWidget';
import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget'; import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget';
import NewsWidget from './widgets/NewsWidget'; import NewsWidget from './widgets/NewsWidget';
import QueryCountDashboardWidget from './widgets/QueryCountDashboardWidget'; 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 * @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 user = useUserState.getState();
const globalSettings = useGlobalSettingsState.getState(); const globalSettings = useGlobalSettingsState.getState();
@@ -186,7 +187,7 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
}); });
} }
export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] { function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
return [ return [
{ {
label: 'gstart', label: 'gstart',
@@ -207,10 +208,14 @@ export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
]; ];
} }
export function BuiltinSettingsWidgets(): DashboardWidgetProps[] { function BuiltinSettingsWidgets(): DashboardWidgetProps[] {
return [ColorToggleDashboardWidget(), LanguageSelectDashboardWidget()]; return [ColorToggleDashboardWidget(), LanguageSelectDashboardWidget()];
} }
function BuiltinActionWidgets(): DashboardWidgetProps[] {
return [StocktakeDashboardWidget()];
}
/** /**
* *
* @returns A list of built-in dashboard widgets * @returns A list of built-in dashboard widgets
@@ -219,6 +224,7 @@ export default function DashboardWidgetLibrary(): DashboardWidgetProps[] {
return [ return [
...BuiltinQueryCountWidgets(), ...BuiltinQueryCountWidgets(),
...BuiltinGettingStartedWidgets(), ...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: {},
cost_min_currency: {}, cost_min_currency: {},
cost_max: {}, cost_max: {},
cost_max_currency: {}, cost_max_currency: {}
note: {}
}; };
} }

View File

@@ -2,6 +2,7 @@ import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { AddItemButton } from '@lib/index';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { type ChartTooltipProps, LineChart } from '@mantine/charts'; import { type ChartTooltipProps, LineChart } from '@mantine/charts';
@@ -18,12 +19,13 @@ import { useCallback, useMemo, useState } from 'react';
import { formatDate, formatPriceRange } from '../../defaults/formatters'; import { formatDate, formatPriceRange } from '../../defaults/formatters';
import { partStocktakeFields } from '../../forms/PartForms'; import { partStocktakeFields } from '../../forms/PartForms';
import { import {
useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable'; import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { DecimalColumn } from '../../tables/ColumnRenderers'; import { DateColumn, DecimalColumn } from '../../tables/ColumnRenderers';
import { InvenTreeTable } from '../../tables/InvenTreeTable'; import { InvenTreeTable } from '../../tables/InvenTreeTable';
/* /*
@@ -89,19 +91,38 @@ export default function PartStockHistoryDetail({
table: table 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(() => { const tableColumns: TableColumn[] = useMemo(() => {
return [ return [
DecimalColumn({ DateColumn({}),
accessor: 'quantity',
sortable: false,
switchable: false
}),
DecimalColumn({ DecimalColumn({
accessor: 'item_count', accessor: 'item_count',
title: t`Stock Items`, title: t`Stock Items`,
switchable: true, switchable: true,
sortable: false sortable: false
}), }),
DecimalColumn({
accessor: 'quantity',
title: t`Stock Quantity`,
sortable: false,
switchable: false
}),
{ {
accessor: 'cost', accessor: 'cost',
title: t`Stock Value`, title: t`Stock Value`,
@@ -111,11 +132,6 @@ export default function PartStockHistoryDetail({
currency: record.cost_min_currency currency: record.cost_min_currency
}); });
} }
},
{
accessor: 'date',
sortable: true,
switchable: false
} }
]; ];
}, []); }, []);
@@ -124,7 +140,7 @@ export default function PartStockHistoryDetail({
(record: any) => { (record: any) => {
return [ return [
RowEditAction({ RowEditAction({
hidden: !user.hasChangeRole(UserRoles.part), hidden: !user.hasChangeRole(UserRoles.part) || !user.isSuperuser(),
onClick: () => { onClick: () => {
setSelectedStocktake(record.pk); setSelectedStocktake(record.pk);
editStocktakeEntry.open(); editStocktakeEntry.open();
@@ -176,8 +192,20 @@ export default function PartStockHistoryDetail({
return [min_date.valueOf(), max_date.valueOf()]; return [min_date.valueOf(), max_date.valueOf()];
}, [chartData]); }, [chartData]);
const tableActions = useMemo(() => {
return [
<AddItemButton
hidden={!user.hasAddRole(UserRoles.part)}
key='add-stocktake'
tooltip={t`Generate Stocktake Entry`}
onClick={() => newStocktakeEntry.open()}
/>
];
}, [user]);
return ( return (
<> <>
{newStocktakeEntry.modal}
{editStocktakeEntry.modal} {editStocktakeEntry.modal}
{deleteStocktakeEntry.modal} {deleteStocktakeEntry.modal}
<SimpleGrid cols={{ base: 1, md: 2 }}> <SimpleGrid cols={{ base: 1, md: 2 }}>
@@ -188,11 +216,13 @@ export default function PartStockHistoryDetail({
props={{ props={{
enableSelection: true, enableSelection: true,
enableBulkDelete: true, enableBulkDelete: true,
enableDownload: true,
params: { params: {
part: partId, part: partId,
ordering: '-date' ordering: '-date'
}, },
rowActions: rowActions rowActions: rowActions,
tableActions: tableActions
}} }}
/> />
{table.isLoading ? ( {table.isLoading ? (

View File

@@ -1,13 +1,18 @@
import type { Page } from '@playwright/test';
import { test } from '../baseFixtures.js'; import { test } from '../baseFixtures.js';
import { doCachedLogin } from '../login.js'; import { doCachedLogin } from '../login.js';
import { setPluginState } from '../settings.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 }) => { test('Dashboard - Basic', async ({ browser }) => {
const page = await doCachedLogin(browser); const page = await doCachedLogin(browser);
// Reset wizards // Reset dashboard widgets
await page.getByLabel('dashboard-menu').click(); await resetDashboard(page);
await page.getByRole('menuitem', { name: 'Clear Widgets' }).click();
await page.getByText('Use the menu to add widgets').waitFor(); 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(); 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 }) => { test('Dashboard - Plugins', async ({ browser }) => {
// Ensure that the "SampleUI" plugin is enabled // Ensure that the "SampleUI" plugin is enabled
await setPluginState({ await setPluginState({
@@ -48,6 +75,8 @@ test('Dashboard - Plugins', async ({ browser }) => {
const page = await doCachedLogin(browser); const page = await doCachedLogin(browser);
await resetDashboard(page);
// Add a dashboard widget from the SampleUI plugin // Add a dashboard widget from the SampleUI plugin
await page.getByLabel('dashboard-menu').click(); await page.getByLabel('dashboard-menu').click();
await page.getByRole('menuitem', { name: 'Add Widget' }).click(); await page.getByRole('menuitem', { name: 'Add Widget' }).click();