mirror of
https://github.com/inventree/InvenTree.git
synced 2026-02-12 09:17:13 +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:
BIN
docs/docs/assets/images/part/part_stocktake_manual.png
Normal file
BIN
docs/docs/assets/images/part/part_stocktake_manual.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/docs/assets/images/part/stocktake_report_dashboard.png
Normal file
BIN
docs/docs/assets/images/part/stocktake_report_dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -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
|
||||
- The total number of individual [stock items](../stock/index.md) available
|
||||
- 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* 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"
|
||||
Cost data is provided as a *range* of values, accounting for any variability in available pricing data.
|
||||
!!! info "Value Range"
|
||||
Value data is provided as a *range* of values, accounting for any variability in available pricing 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") }}
|
||||
|
||||
### 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
|
||||
|
||||
There are a number of configuration options available in the [settings view](../settings/global.md):
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# 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."""
|
||||
|
||||
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
|
||||
- Adds "sub_part_active" filter to BomItem API endpoint
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ class DataExportViewMixin:
|
||||
"""Export the data in the specified format.
|
||||
|
||||
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_context: Additional context data to pass to the plugin
|
||||
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.
|
||||
- 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
|
||||
serializer_class = self.get_serializer_class()
|
||||
|
||||
|
||||
@@ -1204,11 +1204,18 @@ class PartStocktakeFilter(FilterSet):
|
||||
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."""
|
||||
|
||||
queryset = PartStocktake.objects.all()
|
||||
serializer_class = part_serializers.PartStocktakeSerializer
|
||||
filterset_class = PartStocktakeFilter
|
||||
|
||||
def get_serializer_context(self):
|
||||
@@ -1226,14 +1233,65 @@ class PartStocktakeList(BulkDeleteMixin, ListCreateAPI):
|
||||
ordering = '-pk'
|
||||
|
||||
|
||||
class PartStocktakeDetail(RetrieveUpdateDestroyAPI):
|
||||
class PartStocktakeDetail(PartStocktakeMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail API endpoint for a single PartStocktake instance.
|
||||
|
||||
Note: Only staff (admin) users can access this endpoint.
|
||||
"""
|
||||
|
||||
|
||||
class PartStocktakeGenerate(CreateAPI):
|
||||
"""API endpoint for generating a PartStocktake instance."""
|
||||
|
||||
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):
|
||||
@@ -1604,6 +1662,11 @@ part_api_urls = [
|
||||
PartStocktakeDetail.as_view(),
|
||||
name='api-part-stocktake-detail',
|
||||
),
|
||||
path(
|
||||
'generate/',
|
||||
PartStocktakeGenerate.as_view(),
|
||||
name='api-part-stocktake-generate',
|
||||
),
|
||||
path('', PartStocktakeList.as_view(), name='api-part-stocktake-list'),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ import part.filters as part_filters
|
||||
import part.helpers as part_helpers
|
||||
import stock.models
|
||||
import users.models
|
||||
from data_exporter.mixins import DataExportSerializerMixin
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.mixins import DataImportExportSerializerMixin
|
||||
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)
|
||||
|
||||
|
||||
class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
class PartStocktakeSerializer(
|
||||
InvenTree.serializers.FilterableSerializerMixin,
|
||||
DataExportSerializerMixin,
|
||||
InvenTree.serializers.InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for the PartStocktake model."""
|
||||
|
||||
class Meta:
|
||||
@@ -1196,18 +1201,32 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
model = PartStocktake
|
||||
fields = [
|
||||
'pk',
|
||||
'date',
|
||||
'part',
|
||||
'part_name',
|
||||
'part_ipn',
|
||||
'part_description',
|
||||
'date',
|
||||
'item_count',
|
||||
'quantity',
|
||||
'cost_min',
|
||||
'cost_min_currency',
|
||||
'cost_max',
|
||||
'cost_max_currency',
|
||||
# Optional detail fields
|
||||
'part_detail',
|
||||
]
|
||||
|
||||
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()
|
||||
|
||||
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_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):
|
||||
"""Called when this serializer is saved."""
|
||||
data = self.validated_data
|
||||
@@ -1226,6 +1262,72 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
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(
|
||||
serializers.CharField(
|
||||
help_text=_('Select currency from available options')
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
"""Stock history functionality."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import structlog
|
||||
import tablib
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
|
||||
import common.models
|
||||
from InvenTree.helpers import current_date
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
def perform_stocktake() -> None:
|
||||
"""Generate stock history entries for all active parts."""
|
||||
def perform_stocktake(
|
||||
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 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.settings import get_global_setting
|
||||
|
||||
@@ -18,36 +50,98 @@ def perform_stocktake() -> None:
|
||||
logger.info('Stocktake functionality is disabled - skipping')
|
||||
return
|
||||
|
||||
exclude_external = get_global_setting(
|
||||
'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False
|
||||
)
|
||||
# If exclude_external is not provided, use global setting
|
||||
if exclude_external is None:
|
||||
exclude_external = get_global_setting(
|
||||
'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
|
||||
history_entries = []
|
||||
|
||||
N_BULK_CREATE = 250
|
||||
|
||||
base_currency = currency_code_default()
|
||||
today = InvenTree.helpers.current_date()
|
||||
|
||||
logger.info(
|
||||
'Creating new stock history entries for %s active parts', active_parts.count()
|
||||
)
|
||||
logger.info('Creating new stock history entries for %s parts', 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?
|
||||
if part_models.PartStocktake.objects.filter(
|
||||
part=part, date__gte=today
|
||||
).exists():
|
||||
if (
|
||||
generate_entry
|
||||
and part_models.PartStocktake.objects.filter(
|
||||
part=part, date__gte=today
|
||||
).exists()
|
||||
):
|
||||
continue
|
||||
|
||||
pricing = part.pricing
|
||||
try:
|
||||
pricing = part.pricing_data
|
||||
except Exception:
|
||||
pricing = None
|
||||
|
||||
# Fetch all 'in stock' items for this part
|
||||
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)
|
||||
@@ -56,23 +150,34 @@ def perform_stocktake() -> None:
|
||||
total_quantity = 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:
|
||||
# Extract cost information
|
||||
|
||||
entry_cost_min = pricing.overall_min or pricing.overall_max
|
||||
entry_cost_max = pricing.overall_max or pricing.overall_min
|
||||
entry_cost_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:
|
||||
entry_cost_min = item.purchase_price
|
||||
entry_cost_max = item.purchase_price
|
||||
|
||||
try:
|
||||
entry_cost_min = (
|
||||
convert_money(entry_cost_min, base_currency) * item.quantity
|
||||
)
|
||||
entry_cost_max = (
|
||||
convert_money(entry_cost_max, base_currency) * item.quantity
|
||||
)
|
||||
entry_cost_min = entry_cost_min * item.quantity
|
||||
entry_cost_max = entry_cost_max * item.quantity
|
||||
|
||||
if entry_cost_min.currency != base_currency:
|
||||
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:
|
||||
entry_cost_min = 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_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
|
||||
history_entries.append(
|
||||
part_models.PartStocktake(
|
||||
@@ -94,11 +206,31 @@ def perform_stocktake() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
# Batch create stock history entries
|
||||
if len(history_entries) >= N_BULK_CREATE:
|
||||
part_models.PartStocktake.objects.bulk_create(history_entries)
|
||||
history_entries = []
|
||||
|
||||
if len(history_entries) > 0:
|
||||
# Save any remaining stocktake entries
|
||||
if generate_entry:
|
||||
# Bulk-create PartStocktake entries
|
||||
part_models.PartStocktake.objects.bulk_create(history_entries)
|
||||
|
||||
if report_output:
|
||||
# Save report data, and mark as complete
|
||||
|
||||
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')
|
||||
)
|
||||
|
||||
@@ -120,6 +120,7 @@ export enum ApiEndpoints {
|
||||
part_pricing_internal = 'part/internal-price/',
|
||||
part_pricing_sale = 'part/sale-price/',
|
||||
part_stocktake_list = 'part/stocktake/',
|
||||
part_stocktake_generate = 'part/stocktake/generate/',
|
||||
category_list = 'part/category/',
|
||||
category_tree = 'part/category/tree/',
|
||||
category_parameter_list = 'part/category/parameters/',
|
||||
|
||||
@@ -9,12 +9,13 @@ import GetStartedWidget from './widgets/GetStartedWidget';
|
||||
import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget';
|
||||
import NewsWidget from './widgets/NewsWidget';
|
||||
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
|
||||
*/
|
||||
export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
|
||||
function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
|
||||
const user = useUserState.getState();
|
||||
const globalSettings = useGlobalSettingsState.getState();
|
||||
|
||||
@@ -186,7 +187,7 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
|
||||
function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
|
||||
return [
|
||||
{
|
||||
label: 'gstart',
|
||||
@@ -207,10 +208,14 @@ export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function BuiltinSettingsWidgets(): DashboardWidgetProps[] {
|
||||
function BuiltinSettingsWidgets(): DashboardWidgetProps[] {
|
||||
return [ColorToggleDashboardWidget(), LanguageSelectDashboardWidget()];
|
||||
}
|
||||
|
||||
function BuiltinActionWidgets(): DashboardWidgetProps[] {
|
||||
return [StocktakeDashboardWidget()];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns A list of built-in dashboard widgets
|
||||
@@ -219,6 +224,7 @@ export default function DashboardWidgetLibrary(): DashboardWidgetProps[] {
|
||||
return [
|
||||
...BuiltinQueryCountWidgets(),
|
||||
...BuiltinGettingStartedWidgets(),
|
||||
...BuiltinSettingsWidgets()
|
||||
...BuiltinSettingsWidgets(),
|
||||
...BuiltinActionWidgets()
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -266,7 +266,6 @@ export function partStocktakeFields(): ApiFormFieldSet {
|
||||
cost_min: {},
|
||||
cost_min_currency: {},
|
||||
cost_max: {},
|
||||
cost_max_currency: {},
|
||||
note: {}
|
||||
cost_max_currency: {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { UserRoles } from '@lib/enums/Roles';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { AddItemButton } from '@lib/index';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type ChartTooltipProps, LineChart } from '@mantine/charts';
|
||||
@@ -18,12 +19,13 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { formatDate, formatPriceRange } from '../../defaults/formatters';
|
||||
import { partStocktakeFields } from '../../forms/PartForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { DecimalColumn } from '../../tables/ColumnRenderers';
|
||||
import { DateColumn, DecimalColumn } from '../../tables/ColumnRenderers';
|
||||
import { InvenTreeTable } from '../../tables/InvenTreeTable';
|
||||
|
||||
/*
|
||||
@@ -89,19 +91,38 @@ export default function PartStockHistoryDetail({
|
||||
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(() => {
|
||||
return [
|
||||
DecimalColumn({
|
||||
accessor: 'quantity',
|
||||
sortable: false,
|
||||
switchable: false
|
||||
}),
|
||||
DateColumn({}),
|
||||
DecimalColumn({
|
||||
accessor: 'item_count',
|
||||
title: t`Stock Items`,
|
||||
switchable: true,
|
||||
sortable: false
|
||||
}),
|
||||
DecimalColumn({
|
||||
accessor: 'quantity',
|
||||
title: t`Stock Quantity`,
|
||||
sortable: false,
|
||||
switchable: false
|
||||
}),
|
||||
{
|
||||
accessor: 'cost',
|
||||
title: t`Stock Value`,
|
||||
@@ -111,11 +132,6 @@ export default function PartStockHistoryDetail({
|
||||
currency: record.cost_min_currency
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'date',
|
||||
sortable: true,
|
||||
switchable: false
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
@@ -124,7 +140,7 @@ export default function PartStockHistoryDetail({
|
||||
(record: any) => {
|
||||
return [
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.part),
|
||||
hidden: !user.hasChangeRole(UserRoles.part) || !user.isSuperuser(),
|
||||
onClick: () => {
|
||||
setSelectedStocktake(record.pk);
|
||||
editStocktakeEntry.open();
|
||||
@@ -176,8 +192,20 @@ export default function PartStockHistoryDetail({
|
||||
return [min_date.valueOf(), max_date.valueOf()];
|
||||
}, [chartData]);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
hidden={!user.hasAddRole(UserRoles.part)}
|
||||
key='add-stocktake'
|
||||
tooltip={t`Generate Stocktake Entry`}
|
||||
onClick={() => newStocktakeEntry.open()}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newStocktakeEntry.modal}
|
||||
{editStocktakeEntry.modal}
|
||||
{deleteStocktakeEntry.modal}
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
@@ -188,11 +216,13 @@ export default function PartStockHistoryDetail({
|
||||
props={{
|
||||
enableSelection: true,
|
||||
enableBulkDelete: true,
|
||||
enableDownload: true,
|
||||
params: {
|
||||
part: partId,
|
||||
ordering: '-date'
|
||||
},
|
||||
rowActions: rowActions
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
}}
|
||||
/>
|
||||
{table.isLoading ? (
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test } from '../baseFixtures.js';
|
||||
import { doCachedLogin } from '../login.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 }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// Reset wizards
|
||||
await page.getByLabel('dashboard-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Clear Widgets' }).click();
|
||||
// Reset dashboard widgets
|
||||
await resetDashboard(page);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
// Ensure that the "SampleUI" plugin is enabled
|
||||
await setPluginState({
|
||||
@@ -48,6 +75,8 @@ test('Dashboard - Plugins', async ({ browser }) => {
|
||||
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await resetDashboard(page);
|
||||
|
||||
// Add a dashboard widget from the SampleUI plugin
|
||||
await page.getByLabel('dashboard-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Add Widget' }).click();
|
||||
|
||||
Reference in New Issue
Block a user