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:
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
|
- 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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
exclude_external = get_global_setting(
|
# If exclude_external is not provided, use global setting
|
||||||
'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False
|
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
|
# 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 (
|
||||||
part=part, date__gte=today
|
generate_entry
|
||||||
).exists():
|
and part_models.PartStocktake.objects.filter(
|
||||||
|
part=part, date__gte=today
|
||||||
|
).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)
|
|
||||||
history_entries = []
|
|
||||||
|
|
||||||
if len(history_entries) > 0:
|
|
||||||
# Save any remaining stocktake entries
|
|
||||||
part_models.PartStocktake.objects.bulk_create(history_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_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/',
|
||||||
|
|||||||
@@ -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()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {},
|
||||||
cost_min_currency: {},
|
cost_min_currency: {},
|
||||||
cost_max: {},
|
cost_max: {},
|
||||||
cost_max_currency: {},
|
cost_max_currency: {}
|
||||||
note: {}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user