diff --git a/docs/docs/assets/images/part/part_stocktake_from_category.png b/docs/docs/assets/images/part/part_stocktake_from_category.png deleted file mode 100644 index 680a541258..0000000000 Binary files a/docs/docs/assets/images/part/part_stocktake_from_category.png and /dev/null differ diff --git a/docs/docs/assets/images/part/part_stocktake_from_location.png b/docs/docs/assets/images/part/part_stocktake_from_location.png deleted file mode 100644 index 14bacefb17..0000000000 Binary files a/docs/docs/assets/images/part/part_stocktake_from_location.png and /dev/null differ diff --git a/docs/docs/assets/images/part/part_stocktake_from_part.png b/docs/docs/assets/images/part/part_stocktake_from_part.png deleted file mode 100644 index d2ecb36ec7..0000000000 Binary files a/docs/docs/assets/images/part/part_stocktake_from_part.png and /dev/null differ diff --git a/docs/docs/assets/images/part/part_stocktake_generate.png b/docs/docs/assets/images/part/part_stocktake_generate.png deleted file mode 100644 index 7cfc96bc85..0000000000 Binary files a/docs/docs/assets/images/part/part_stocktake_generate.png and /dev/null differ diff --git a/docs/docs/assets/images/part/part_stocktake_report_table.png b/docs/docs/assets/images/part/part_stocktake_report_table.png deleted file mode 100644 index e1c5d0dd17..0000000000 Binary files a/docs/docs/assets/images/part/part_stocktake_report_table.png and /dev/null differ diff --git a/docs/docs/assets/images/part/part_stocktake_settings.png b/docs/docs/assets/images/part/part_stocktake_settings.png index ffa20e0ab1..3dbe0277d7 100644 Binary files a/docs/docs/assets/images/part/part_stocktake_settings.png and b/docs/docs/assets/images/part/part_stocktake_settings.png differ diff --git a/docs/docs/assets/images/plugin/builtin/stocktake_exporter_options.png b/docs/docs/assets/images/plugin/builtin/stocktake_exporter_options.png new file mode 100644 index 0000000000..7845a98bf4 Binary files /dev/null and b/docs/docs/assets/images/plugin/builtin/stocktake_exporter_options.png differ diff --git a/docs/docs/part/stocktake.md b/docs/docs/part/stocktake.md index 6ec9b2e217..b986c6b01f 100644 --- a/docs/docs/part/stocktake.md +++ b/docs/docs/part/stocktake.md @@ -1,8 +1,10 @@ --- -title: Part Stocktake +title: Part Stock History --- -## Part Stocktake +## Part Stock History + +InvenTree can track the historical stock levels of parts, allowing users to view past stocktake data and generate reports based on this information. A *Stocktake* refers to a "snapshot" of stock levels for a particular part, at a specific point in time. Stocktake information is used for tracking a historical record of the quantity and value of part stock. @@ -25,41 +27,33 @@ The total cost of stock on hand is calculated based on the provided pricing data !!! info "Cost Range" Cost data is provided as a *range* of values, accounting for any variability in available pricing data. -### Display Stocktake Data +### Display Historical Stock Data -Historical stocktake data for a particular part can be viewed in the *Stocktake* tab, available on the *Part* page. +Historical stock data for a particular part can be viewed in the *Stock History* tab, available on the *Part* page. This tab displays a chart of historical stock quantity and cost data, and corresponding tabulated data: {{ image("part/part_stocktake_tab.png", "Part stocktake tab") }} -If this tab is not visible, ensure that the *Part Stocktake* [user setting](../settings/user.md) is enabled in the *Display Settings* section. +If this tab is not visible, ensure that the *Enable Stock History* [user setting](../settings/user.md) is enabled in the *Display Settings* section. -{{ image("part/part_stocktake_enable_tab.png", "Enable stocktake tab") }} +{{ image("part/part_stocktake_enable_tab.png", "Enable stock history tab") }} -!!! info "Permission Required" - The stocktake tab will be unavailable if your user account does not have the [required permissions](#stocktake-permissions) - -## Stocktake Reports - -While a *Stocktake* entry records a historical snapshot of stock levels for a single *part*, a *Stocktake Report* is used to generate a report data file which contains stocktake entries for multiple parts. Stocktake reports can be generated for the entire range of parts available in the database, or a subset of parts as determined by user-configurable filters. - -Stocktake reports can be [generated manually](#performing-a-stocktake) by the user, or (if enabled) [generated automatically](#automatic-stocktake) at a specified interval. - -As there is a lot of data to crunch to build a report, stocktake reports are generated by the [background worker process](../settings/tasks.md). When the report is completed, it is saved to the database and made available for download. - -!!! tip "Background Worker" - If the background worker process is not running, stocktake reports will be unavailable! - -Stocktake reports are made available for download as a tabulated `.csv` file, which can be opened in many external applications for further analysis. - -## Stocktake Settings +## Stock History Settings There are a number of configuration options available in the [settings view](../settings/global.md): -{{ image("part/part_stocktake_settings.png", "Stocktake settings") }} +| Name | Description | Default | Units | +| ---- | ----------- | ------- | ----- | +{{ globalsetting("STOCKTAKE_ENABLE") }} +{{ globalsetting("STOCKTAKE_EXCLUDE_EXTERNAL") }} +{{ globalsetting("STOCKTAKE_AUTO_DAYS") }} +{{ globalsetting("STOCKTAKE_DELETE_OLD_ENTRIES")}} +{{ globalsetting("STOCKTAKE_DELETE_DAYS") }} -### Enable Stocktake +{{ image("part/part_stocktake_settings.png", "Stock history settings") }} + +### Enable Stock History Enable or disable stocktake functionality. Note that by default, stocktake functionality is disabled. @@ -67,76 +61,10 @@ Enable or disable stocktake functionality. Note that by default, stocktake funct Configure the number of days between generation of [automatic stocktake reports](#automatic-stocktake). If this value is set to zero, automatic stocktake reports will not be generated. -### Delete Old Reports +### Delete Old Stock History Entries -Configure how many days stocktake reports will be retained, before being deleted automatically. +If enabled, stock history entries older than the specified number of days will be automatically deleted from the database. -### Historical Stocktake Reports +### Stock History Deletion Interval -The *Stocktake Settings* display also provides a table of historical stocktake reports: - -{{ image("part/part_stocktake_report_table.png", "Stocktake report table") }} - -## Stocktake Permissions - -Stocktake data and actions are protected by the [stocktake role](../settings/permissions.md#role): - -| Permission | Actions Available | -| --- | --- | -| `stocktake.view` | View historical stocktake data for parts | -| `stocktake.add` | Perform stocktake and generate reports | -| `stocktake.delete` | Delete stocktake records and reports | - -## Performing a Stocktake - -Manual stocktake can be performed via the web interface in a number of locations. The user can filter the parts for which the stocktake will be performed. A new stocktake entry will be generated for each selected part, and optionally a report can be generated for download. - -When performing a stocktake, various options are presented to the user: - -{{ image("part/part_stocktake_generate.png", "Generate stocktake report") }} - -| Option | Description | -| --- | --- | -| Part | Limit stocktake context to a part. If the selected part is a [template part](./index.md#template), any variant parts will also be included in the stocktake | -| Category | Limit stocktake context to a single [part category](./index.md#part-category). Parts which exist in child categories (under the selected parent category) will also be included. | -| Location | Limit stocktake context to a single [stock location](../stock/index.md#stock-location). Any parts which have stock items contained in this location (or any child locations) will be included in the stocktake | -| Generate Report | Select this option to generate a [stocktake report](#stocktake-reports) for the selected parts. | -| Update Parts | Select this option to save a new stocktake record for each selected part. | - -### Part Stocktake - -A stocktake report for a single part can be generated from the *Stocktake Tab* on the part page: - -{{ image("part/part_stocktake_from_part.png", "Generate part stocktake report") }} - -### Category Stocktake - -A stocktake report for a part category can be generated from the *Part Category* page: - -{{ image("part/part_stocktake_from_category.png", "Generate category stocktake report") }} - -### Location Stocktake - -A stocktake report for a stock location can be generated from the *Stock Location* page: - -{{ image("part/part_stocktake_from_location.png", "Generate location stocktake report") }} - -### Automatic Stocktake - -If enabled, stocktake reports can be generated automatically at a configured interval, specified in number of days. Automatic stocktake reports are performed on the entire database of parts. - -### API Functionality - -Stocktake actions can also be performed via the [API](../api/index.md). - -## Stocktake Settings - -The following settings are available for stocktake: - -| Name | Description | Default | Units | -| ---- | ----------- | ------- | ----- | -{{ globalsetting("STOCKTAKE_ENABLE") }} -{{ globalsetting("STOCKTAKE_EXCLUDE_EXTERNAL") }} -{{ globalsetting("STOCKTAKE_AUTO_DAYS") }} -{{ globalsetting("STOCKTAKE_DELETE_REPORT_DAYS") }} -{{ globalsetting("DISPLAY_PROFILE_INFO") }} +Configure how many days historical stock records are retained in the database. diff --git a/docs/docs/part/views.md b/docs/docs/part/views.md index 8d927a243b..9c485893f8 100644 --- a/docs/docs/part/views.md +++ b/docs/docs/part/views.md @@ -107,9 +107,9 @@ This tab is only displayed if the part is marked as *Purchaseable*. The *Sales Orders* tab shows a list of the sales orders for this part. It provides a view for important sales order information like customer, status, creation and shipment dates. -### Stocktake +### Stock History -The *Stocktake* tab provide historical stock level information, based on user-provided stocktake data. Refer to the [stocktake documentation](./stocktake.md) for further information. +The *Stock History* tab provide historical stock level information. Refer to the [stock history documentation](./stocktake.md) for further information. ### Test Templates diff --git a/docs/docs/plugins/builtin/index.md b/docs/docs/plugins/builtin/index.md index db5792325b..0d4d1db859 100644 --- a/docs/docs/plugins/builtin/index.md +++ b/docs/docs/plugins/builtin/index.md @@ -22,6 +22,7 @@ The following builtin plugins are available in InvenTree: | Data Export | [BOM Exporter](./bom_exporter.md) | Custom [exporter](../mixins/export.md) for BOM data | Yes | | Data Export | [InvenTree Exporter](./inventree_exporter.md) | Custom [exporter](../mixins/export.md) for InvenTree data | Yes | | Data Export | [Parameter Exporter](./part_parameter_exporter.md) | Custom [exporter](../mixins/export.md) for part parameter data | Yes | +| Data Export | [Stocktake Exporter](./stocktake_exporter.md) | Custom [exporter](../mixins/export.md) for stocktake data | No | | Events | [Auto Create Child Builds](./auto_create_builds.md) | Automatically create child build orders for sub-assemblies | No | | Events | [Auto Issue Orders](./auto_issue.md) | Automatically issue pending orders when target date is reached | No | | Label Printing | [Label Printer](./inventree_label.md) | Custom [label](../mixins/label.md) for InvenTree data | Yes | diff --git a/docs/docs/plugins/builtin/slack_notification.md b/docs/docs/plugins/builtin/slack_notification.md index e32e95d126..517a1b55f3 100644 --- a/docs/docs/plugins/builtin/slack_notification.md +++ b/docs/docs/plugins/builtin/slack_notification.md @@ -9,3 +9,7 @@ This plugin provides a mechanism to send notifications to a Slack channel when c ### API Key To use this plugin, you need to provide a Slack API key. This key is used to authenticate the plugin with the Slack API and send messages to the specified channel. + +### Activation + +This plugin is an *optional* plugin, and must be enabled in the InvenTree settings. diff --git a/docs/docs/plugins/builtin/stocktake_exporter.md b/docs/docs/plugins/builtin/stocktake_exporter.md new file mode 100644 index 0000000000..dad3ef6ce6 --- /dev/null +++ b/docs/docs/plugins/builtin/stocktake_exporter.md @@ -0,0 +1,35 @@ +--- +title: Stocktake Exporter +--- + +## Stocktake Exporter Plugin + +The **Stocktake Exporter Plugin** provides custom "stocktake" export functionality for [Part](../../part/index.md) data. + +It utilizes the [ExporterMixin](../mixins/export.md) mixin to provide a custom export format for stocktake data. + +This exporter plugin can be used to export a comprehensive list of current stock levels for selected parts. + +### Activation + +This plugin is an *optional* plugin, and must be enabled in the InvenTree settings. + +### Plugin Settings + +There are no configurable settings for this plugin. + +## Usage + +This plugin is used in the same way as the [InvenTree Exporter Plugin](./inventree_exporter.md), but provides a custom export format for stocktake data. + +### Export Options + +When exporting part data, the *Stocktake Exporter* plugin is available for selection in the export dialog. When selected, the plugin provides some additional export options to control the data export process. + +{{ image("stocktake_exporter_options.png", base="plugin/builtin", title="Stocktake Export Options") }} + +| Option | Description | +|--------|-------------| +| `Pricing Data` | Include pricing data in the export. This will add columns for the cost of "stock on hand". | +| `Include External Stock` | Include stock from external warehouses in the export. This will add columns for the stock levels in external warehouses, and include the external quantities in the total stock count and valuation. | +| `Include Variant Items` | Include variant items in the export. This will add columns for the variant items associated with each part, and include the variant quantities in the total stock count and valuation. | diff --git a/docs/docs/report/index.md b/docs/docs/report/index.md index 36a2490257..b18c0fb5d1 100644 --- a/docs/docs/report/index.md +++ b/docs/docs/report/index.md @@ -184,7 +184,7 @@ Snippets are included in a template as follows: {% raw %}{% include 'snippets/' %}{% endraw %} ``` -For example, consider a stocktake report for a particular stock location, where we wish to render a table with a row for each item in that location. +For example, consider a custom stocktake report for a particular stock location, where we wish to render a table with a row for each item in that location. ```html {% raw %} diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 60f7090edc..9df2e163a5 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -257,6 +257,7 @@ nav: - BOM Exporter: plugins/builtin/bom_exporter.md - InvenTree Exporter: plugins/builtin/inventree_exporter.md - Parameter Exporter: plugins/builtin/part_parameter_exporter.md + - Stocktake Exporter: plugins/builtin/stocktake_exporter.md - Label Printing: - Label Printer: plugins/builtin/inventree_label.md - Label Machine: plugins/builtin/inventree_label_machine.md diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5824b584c9..05e0655131 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,18 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 378 +INVENTREE_API_VERSION = 379 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v379 -> 2025-08-04 : https://github.com/inventree/InvenTree/pull/10124 + - Removes "PartStocktakeReport" model and associated API endpoints + - Remove "last_stocktake" field from the Part model + - Remove "user" field from PartStocktake model + - Remove "note" field from PartStocktake model + v378 -> 2025-08-01 : https://github.com/inventree/InvenTree/pull/10111 - Adds "scheduled_to_build" annotated field to BuildLine serializer diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 524956d788..21cb0f65ab 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -279,12 +279,12 @@ def str2bool(text, test=True): return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off'] -def is_bool(text): +def is_bool(text: str) -> bool: """Determine if a string value 'looks' like a boolean.""" return str2bool(text, True) or str2bool(text, False) -def isNull(text): +def isNull(text: str) -> bool: """Test if a string 'looks' like a null value. This is useful for querying the API against a null key. Args: @@ -304,11 +304,14 @@ def isNull(text): ] -def normalize(d): +def normalize(d, rounding: Optional[int] = None) -> Decimal: """Normalize a decimal number, and remove exponential formatting.""" if type(d) is not Decimal: d = Decimal(d) + if rounding is not None: + d = round(d, rounding) + d = d.normalize() # Ref: https://docs.python.org/3/library/decimal.html diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index eb91897aea..781425256a 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -1067,9 +1067,9 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'validator': bool, }, 'STOCKTAKE_ENABLE': { - 'name': _('Stocktake Functionality'), + 'name': _('Enable Stock History'), 'description': _( - 'Enable stocktake functionality for recording stock levels and calculating stock value' + 'Enable functionality for recording historical stock levels and value' ), 'validator': bool, 'default': False, @@ -1077,27 +1077,34 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'STOCKTAKE_EXCLUDE_EXTERNAL': { 'name': _('Exclude External Locations'), 'description': _( - 'Exclude stock items in external locations from stocktake calculations' + 'Exclude stock items in external locations from stock history calculations' ), 'validator': bool, 'default': False, }, 'STOCKTAKE_AUTO_DAYS': { 'name': _('Automatic Stocktake Period'), - 'description': _( - 'Number of days between automatic stocktake recording (set to zero to disable)' - ), - 'validator': [int, MinValueValidator(0)], - 'default': 0, - }, - 'STOCKTAKE_DELETE_REPORT_DAYS': { - 'name': _('Report Deletion Interval'), - 'description': _( - 'Stocktake reports will be deleted after specified number of days' - ), - 'default': 30, + 'description': _('Number of days between automatic stock history recording'), + 'validator': [int, MinValueValidator(1)], + 'default': 7, 'units': _('days'), - 'validator': [int, MinValueValidator(7)], + }, + 'STOCKTAKE_DELETE_OLD_ENTRIES': { + 'name': _('Delete Old Stock History Entries'), + 'description': _( + 'Delete stock history entries older than the specified number of days' + ), + 'default': False, + 'validator': bool, + }, + 'STOCKTAKE_DELETE_DAYS': { + 'name': _('Stock History Deletion Interval'), + 'description': _( + 'Stock history entries will be deleted after specified number of days' + ), + 'default': 365, + 'units': _('days'), + 'validator': [int, MinValueValidator(30)], }, 'DISPLAY_FULL_NAMES': { 'name': _('Display Users full names'), diff --git a/src/backend/InvenTree/common/setting/user.py b/src/backend/InvenTree/common/setting/user.py index 2c0e3ec386..73cdf769fe 100644 --- a/src/backend/InvenTree/common/setting/user.py +++ b/src/backend/InvenTree/common/setting/user.py @@ -212,10 +212,8 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { ], }, 'DISPLAY_STOCKTAKE_TAB': { - 'name': _('Part Stocktake'), - 'description': _( - 'Display part stocktake information (if stocktake functionality is enabled)' - ), + 'name': _('Show Stock History'), + 'description': _('Display stock history information in the part detail page'), 'default': True, 'validator': bool, }, diff --git a/src/backend/InvenTree/part/admin.py b/src/backend/InvenTree/part/admin.py index 3907eae294..6c6ae85b7a 100644 --- a/src/backend/InvenTree/part/admin.py +++ b/src/backend/InvenTree/part/admin.py @@ -52,14 +52,7 @@ class PartPricingAdmin(admin.ModelAdmin): class PartStocktakeAdmin(admin.ModelAdmin): """Admin class for PartStocktake model.""" - list_display = ['part', 'date', 'quantity', 'user'] - - -@admin.register(models.PartStocktakeReport) -class PartStocktakeReportAdmin(admin.ModelAdmin): - """Admin class for PartStocktakeReport model.""" - - list_display = ['date', 'user'] + list_display = ['part', 'date', 'quantity'] @admin.register(models.PartCategory) diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 73c50b6957..cd40a05764 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -13,10 +13,14 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.response import Response -import InvenTree.permissions import part.filters from data_exporter.mixins import DataExportViewMixin -from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView +from InvenTree.api import ( + BulkDeleteMixin, + BulkUpdateMixin, + ListCreateDestroyAPIView, + MetadataView, +) from InvenTree.filters import ( ORDER_FILTER, ORDER_FILTER_ALIAS, @@ -52,7 +56,6 @@ from .models import ( PartRelated, PartSellPriceBreak, PartStocktake, - PartStocktakeReport, PartTestTemplate, ) @@ -822,16 +825,6 @@ class PartFilter(rest_filters.FilterSet): return queryset.filter(q_a | q_b).distinct() - stocktake = rest_filters.BooleanFilter( - label='Has stocktake', method='filter_has_stocktake' - ) - - def filter_has_stocktake(self, queryset, name, value): - """Filter the queryset based on whether stocktake data is available.""" - if str2bool(value): - return queryset.exclude(last_stocktake=None) - return queryset.filter(last_stocktake=None) - stock_to_build = rest_filters.BooleanFilter( label='Required for Build Order', method='filter_stock_to_build' ) @@ -1149,7 +1142,6 @@ class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI): 'unallocated_stock', 'category', 'default_location', - 'last_stocktake', 'units', 'pricing_min', 'pricing_max', @@ -1453,10 +1445,10 @@ class PartStocktakeFilter(rest_filters.FilterSet): """Metaclass options.""" model = PartStocktake - fields = ['part', 'user'] + fields = ['part'] -class PartStocktakeList(ListCreateAPI): +class PartStocktakeList(BulkDeleteMixin, ListCreateAPI): """API endpoint for listing part stocktake information.""" queryset = PartStocktake.objects.all() @@ -1488,47 +1480,6 @@ class PartStocktakeDetail(RetrieveUpdateDestroyAPI): serializer_class = part_serializers.PartStocktakeSerializer -class PartStocktakeReportList(ListAPI): - """API endpoint for listing part stocktake report information.""" - - queryset = PartStocktakeReport.objects.all() - serializer_class = part_serializers.PartStocktakeReportSerializer - - filter_backends = ORDER_FILTER - - ordering_fields = ['date', 'pk'] - - # Newest first, by default - ordering = '-pk' - - -class PartStocktakeReportDetail(RetrieveUpdateDestroyAPI): - """API endpoint for detail view of a single PartStocktakeReport object.""" - - queryset = PartStocktakeReport.objects.all() - serializer_class = part_serializers.PartStocktakeReportSerializer - - -class PartStocktakeReportGenerate(CreateAPI): - """API endpoint for manually generating a new PartStocktakeReport.""" - - serializer_class = part_serializers.PartStocktakeReportGenerateSerializer - - permission_classes = [ - InvenTree.permissions.IsAuthenticatedOrReadScope, - InvenTree.permissions.RolePermission, - ] - - role_required = 'stocktake' - - def get_serializer_context(self): - """Extend serializer context data.""" - context = super().get_serializer_context() - context['request'] = self.request - - return context - - class BomFilter(rest_filters.FilterSet): """Custom filters for the BOM list.""" @@ -1946,26 +1897,6 @@ part_api_urls = [ path( 'stocktake/', include([ - path( - r'report/', - include([ - path( - 'generate/', - PartStocktakeReportGenerate.as_view(), - name='api-part-stocktake-report-generate', - ), - path( - '/', - PartStocktakeReportDetail.as_view(), - name='api-part-stocktake-report-detail', - ), - path( - '', - PartStocktakeReportList.as_view(), - name='api-part-stocktake-report-list', - ), - ]), - ), path( '/', PartStocktakeDetail.as_view(), diff --git a/src/backend/InvenTree/part/migrations/0097_partstocktakereport.py b/src/backend/InvenTree/part/migrations/0097_partstocktakereport.py index a376ee2c88..330776b7ab 100644 --- a/src/backend/InvenTree/part/migrations/0097_partstocktakereport.py +++ b/src/backend/InvenTree/part/migrations/0097_partstocktakereport.py @@ -3,7 +3,11 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion -import part.models + + +def fake_func(*args, **kwargs): + """A placeholder function to avoid import errors.""" + pass class Migration(migrations.Migration): @@ -19,7 +23,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('date', models.DateField(auto_now_add=True, verbose_name='Date')), - ('report', models.FileField(help_text='Stocktake report file (generated internally)', upload_to=part.models.save_stocktake_report, verbose_name='Report')), + ('report', models.FileField(help_text='Stocktake report file (generated internally)', upload_to=fake_func, verbose_name='Report')), ('user', models.ForeignKey(blank=True, help_text='User who requested this stocktake report', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocktake_reports', to=settings.AUTH_USER_MODEL, verbose_name='User')), ], ), diff --git a/src/backend/InvenTree/part/migrations/0142_remove_part_last_stocktake_remove_partstocktake_note_and_more.py b/src/backend/InvenTree/part/migrations/0142_remove_part_last_stocktake_remove_partstocktake_note_and_more.py new file mode 100644 index 0000000000..98a6b14be1 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0142_remove_part_last_stocktake_remove_partstocktake_note_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.23 on 2025-08-04 08:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("part", "0141_auto_20250722_0303"), + ] + + operations = [ + migrations.RemoveField( + model_name="part", + name="last_stocktake", + ), + migrations.RemoveField( + model_name="partstocktake", + name="note", + ), + migrations.RemoveField( + model_name="partstocktake", + name="user", + ), + migrations.DeleteModel( + name="PartStocktakeReport", + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 08981548a1..ade8f06e79 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -452,7 +452,6 @@ class Part( creation_date: Date that this part was added to the database creation_user: User who added this part to the database responsible_owner: Owner (either user or group) which is responsible for this part (optional) - last_stocktake: Date at which last stocktake was performed for this Part BOM (Bill of Materials) related attributes: bom_checksum: Checksum for the BOM of this part @@ -1324,10 +1323,6 @@ class Part( related_name='parts_responsible', ) - last_stocktake = models.DateField( - blank=True, null=True, verbose_name=_('Last Stocktake') - ) - @property def category_path(self): """Return the category path of this Part instance.""" @@ -1743,11 +1738,14 @@ class Part( self.sales_order_allocation_count(**kwargs), ]) - def stock_entries(self, include_variants=True, in_stock=None, location=None): + def stock_entries( + self, include_variants=True, include_external=True, in_stock=None, location=None + ): """Return all stock entries for this Part. Arguments: include_variants: If True, include stock entries for all part variants + include_external: If True, include stock entries which are in 'external' locations in_stock: If True, filter by stock entries which are 'in stock' location: If set, filter by stock entries in the specified location """ @@ -1763,6 +1761,10 @@ class Part( elif in_stock is False: query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER) + if include_external is False: + # Exclude stock entries which are not 'internal' + query = query.filter(external=False) + if location: locations = location.get_descendants(include_self=True) query = query.filter(location__in=locations) @@ -2565,11 +2567,6 @@ class Part( return params - @property - def latest_stocktake(self): - """Return the latest PartStocktake object associated with this part (if one exists).""" - return self.stocktakes.order_by('-pk').first() - @property def has_variants(self): """Check if this Part object has variants underneath it.""" @@ -3419,13 +3416,12 @@ class PartPricing(common.models.MetaMixin): class PartStocktake(models.Model): - """Model representing a 'stocktake' entry for a particular Part. + """Model representing a 'stock history' entry for a particular Part. A 'stocktake' is a representative count of available stock: - Performed on a given date - Records quantity of part in stock (across multiple stock items) - Records estimated value of "stock on hand" - - Records user information """ part = models.ForeignKey( @@ -3456,23 +3452,6 @@ class PartStocktake(models.Model): auto_now_add=True, ) - note = models.CharField( - max_length=250, - blank=True, - verbose_name=_('Notes'), - help_text=_('Additional notes'), - ) - - user = models.ForeignKey( - User, - blank=True, - null=True, - on_delete=models.SET_NULL, - related_name='part_stocktakes', - verbose_name=_('User'), - help_text=_('User who performed this stocktake'), - ) - cost_min = InvenTree.fields.InvenTreeModelMoneyField( null=True, blank=True, @@ -3488,79 +3467,6 @@ class PartStocktake(models.Model): ) -@receiver(post_save, sender=PartStocktake, dispatch_uid='post_save_stocktake') -def update_last_stocktake(sender, instance, created, **kwargs): - """Callback function when a PartStocktake instance is created / edited.""" - # When a new PartStocktake instance is create, update the last_stocktake date for the Part - if created: - try: - part = instance.part - part.last_stocktake = instance.date - part.save() - except Exception: - pass - - -def save_stocktake_report(instance, filename): - """Save stocktake reports to the correct subdirectory.""" - filename = os.path.basename(filename) - return os.path.join('stocktake', 'report', filename) - - -class PartStocktakeReport(models.Model): - """A PartStocktakeReport is a generated report which provides a summary of current stock on hand. - - Reports are generated by the background worker process, and saved as .csv files for download. - Background processing is preferred as (for very large datasets), report generation may take a while. - - A report can be manually requested by a user, or automatically generated periodically. - - When generating a report, the "parts" to be reported can be filtered, e.g. by "category". - - A stocktake report contains the following information, with each row relating to a single Part instance: - - - Number of individual stock items on hand - - Total quantity of stock on hand - - Estimated total cost of stock on hand (min:max range) - """ - - def __str__(self): - """Construct a simple string representation for the report.""" - return os.path.basename(self.report.name) - - def get_absolute_url(self): - """Return the URL for the associated report file for download.""" - if self.report: - return self.report.url - return None - - date = models.DateField(verbose_name=_('Date'), auto_now_add=True) - - report = models.FileField( - upload_to=save_stocktake_report, - unique=False, - blank=False, - verbose_name=_('Report'), - help_text=_('Stocktake report file (generated internally)'), - ) - - part_count = models.IntegerField( - default=0, - verbose_name=_('Part Count'), - help_text=_('Number of parts covered by stocktake'), - ) - - user = models.ForeignKey( - User, - blank=True, - null=True, - on_delete=models.SET_NULL, - related_name='stocktake_reports', - verbose_name=_('User'), - help_text=_('User who requested this stocktake report'), - ) - - class PartSellPriceBreak(common.models.PriceBreak): """Represents a price break for selling this part.""" diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index ed7f46a8a9..1402267fa3 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -29,14 +29,11 @@ import InvenTree.serializers import InvenTree.status import part.filters as part_filters import part.helpers as part_helpers -import part.stocktake -import part.tasks import stock.models import users.models from importer.registry import register_importer from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.ready import isGeneratingSchema -from InvenTree.tasks import offload_task from users.serializers import UserSerializer from .models import ( @@ -53,7 +50,6 @@ from .models import ( PartSellPriceBreak, PartStar, PartStocktake, - PartStocktakeReport, PartTestTemplate, ) @@ -684,7 +680,6 @@ class PartSerializer( 'IPN', 'is_template', 'keywords', - 'last_stocktake', 'link', 'locked', 'minimum_stock', @@ -1334,17 +1329,12 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'cost_min_currency', 'cost_max', 'cost_max_currency', - 'note', - 'user', - 'user_detail', ] read_only_fields = ['date', 'user'] quantity = serializers.FloatField() - user_detail = UserSerializer(source='user', read_only=True, many=False) - cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer() @@ -1361,106 +1351,6 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): return super().save() -class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializer): - """Serializer for stocktake report class.""" - - class Meta: - """Metaclass defines serializer fields.""" - - model = PartStocktakeReport - fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail'] - read_only_fields = ['date', 'report', 'part_count', 'user'] - - user_detail = UserSerializer(source='user', read_only=True, many=False) - - report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True) - - -class PartStocktakeReportGenerateSerializer(serializers.Serializer): - """Serializer class for manually generating a new PartStocktakeReport via the API.""" - - part = serializers.PrimaryKeyRelatedField( - queryset=Part.objects.all(), - required=False, - allow_null=True, - label=_('Part'), - help_text=_( - 'Limit stocktake report to a particular part, and any variant parts' - ), - ) - - category = serializers.PrimaryKeyRelatedField( - queryset=PartCategory.objects.all(), - required=False, - allow_null=True, - label=_('Category'), - help_text=_( - 'Limit stocktake report to a particular part category, and any child categories' - ), - ) - - location = serializers.PrimaryKeyRelatedField( - queryset=stock.models.StockLocation.objects.all(), - required=False, - allow_null=True, - label=_('Location'), - help_text=_( - 'Limit stocktake report to a particular stock location, and any child locations' - ), - ) - - exclude_external = serializers.BooleanField( - default=True, - label=_('Exclude External Stock'), - help_text=_('Exclude stock items in external locations'), - ) - - generate_report = serializers.BooleanField( - default=True, - label=_('Generate Report'), - help_text=_('Generate report file containing calculated stocktake data'), - ) - - update_parts = serializers.BooleanField( - default=True, - label=_('Update Parts'), - help_text=_('Update specified parts with calculated stocktake data'), - ) - - def validate(self, data): - """Custom validation for this serializer.""" - # Stocktake functionality must be enabled - if not common.settings.get_global_setting('STOCKTAKE_ENABLE'): - raise serializers.ValidationError( - _('Stocktake functionality is not enabled') - ) - - # Check that background worker is running - if not InvenTree.status.is_worker_running(): - raise serializers.ValidationError(_('Background worker check failed')) - - return data - - def save(self): - """Saving this serializer instance requests generation of a new stocktake report.""" - data = self.validated_data - user = self.context['request'].user - - # Generate a new report - offload_task( - part.stocktake.generate_stocktake_report, - force_async=True, - user=user, - part=data.get('part', None), - category=data.get('category', None), - location=data.get('location', None), - exclude_external=data.get('exclude_external', True), - generate_report=data.get('generate_report', True), - update_parts=data.get('update_parts', True), - group='report', - ) - - @extend_schema_field( serializers.CharField( help_text=_('Select currency from available options') diff --git a/src/backend/InvenTree/part/stocktake.py b/src/backend/InvenTree/part/stocktake.py index 1a37a2308e..fcdfff40c9 100644 --- a/src/backend/InvenTree/part/stocktake.py +++ b/src/backend/InvenTree/part/stocktake.py @@ -1,303 +1,104 @@ -"""Stocktake report functionality.""" - -import io -import time - -from django.contrib.auth.models import User -from django.core.files.base import ContentFile -from django.utils.translation import gettext_lazy as _ +"""Stock history functionality.""" import structlog -import tablib from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money -import common.currency -import common.models -import InvenTree.helpers -import stock.models - logger = structlog.get_logger('inventree') -def perform_stocktake(target, user: User, note: str = '', commit=True, **kwargs): - """Perform stocktake action on a single part. +def perform_stocktake() -> None: + """Generate stock history entries for all active parts.""" + import InvenTree.helpers + import part.models as part_models + from common.currency import currency_code_default + from common.settings import get_global_setting - Arguments: - target: A single Part model instance - user: User who requested this stocktake - note: Optional note to attach to the stocktake - commit: If True (default) save the result to the database + if not get_global_setting('STOCKTAKE_ENABLE', False, cache=False): + logger.info('Stocktake functionality is disabled - skipping') + return - kwargs: - exclude_external: If True, exclude stock items in external locations (default = False) - location: Optional StockLocation to filter results for generated report + exclude_external = get_global_setting( + 'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False + ) - Returns: - PartStocktake: A new PartStocktake model instance (for the specified Part) + active_parts = part_models.Part.objects.filter(active=True) - Note that while we record a *total stocktake* for the Part instance which gets saved to the database, - the user may have requested a stocktake limited to a particular location. + # New history entries to be created + history_entries = [] - In this case, the stocktake *report* will be limited to the specified location. - """ - import part.models + N_BULK_CREATE = 250 - # Determine which locations are "valid" for the generated report - location = kwargs.get('location') - locations = location.get_descendants(include_self=True) if location else [] + base_currency = currency_code_default() + today = InvenTree.helpers.current_date() - # Grab all "available" stock items for the Part - # We do not include variant stock when performing a stocktake, - # otherwise the stocktake entries will be duplicated - stock_entries = target.stock_entries(in_stock=True, include_variants=False) + logger.info( + 'Creating new stock history entries for %s active parts', active_parts.count() + ) - exclude_external = kwargs.get('exclude_external', False) + for part in active_parts: + # Is there a recent stock history record for this part? + if part_models.PartStocktake.objects.filter( + part=part, date__gte=today + ).exists(): + continue - if exclude_external: - stock_entries = stock_entries.exclude(location__external=True) + pricing = part.pricing - # Cache min/max pricing information for this Part - pricing = target.pricing + # Fetch all 'in stock' items for this part + stock_items = part.stock_entries( + in_stock=True, include_external=not exclude_external, include_variants=True + ) - if not pricing.is_valid: - # If pricing is not valid, let's update - logger.info('Pricing not valid for %s - updating', target) - pricing.update_pricing(cascade=False) - pricing.refresh_from_db() + total_cost_min = Money(0, base_currency) + total_cost_max = Money(0, base_currency) - base_currency = common.currency.currency_code_default() + total_quantity = 0 + items_count = 0 - # Keep track of total quantity and cost for this part - total_quantity = 0 - total_cost_min = Money(0, base_currency) - total_cost_max = Money(0, base_currency) + for item in stock_items: + # Extract cost information - # Separately, keep track of stock quantity and value within the specified location - location_item_count = 0 - location_quantity = 0 - location_cost_min = Money(0, base_currency) - location_cost_max = Money(0, base_currency) - - for entry in stock_entries: - entry_cost_min = None - entry_cost_max = None - - # Update price range values - if entry.purchase_price: - entry_cost_min = entry.purchase_price - entry_cost_max = entry.purchase_price - - else: - # If no purchase price is available, fall back to the part pricing data entry_cost_min = pricing.overall_min or pricing.overall_max entry_cost_max = pricing.overall_max or pricing.overall_min - # Convert to base currency - try: - entry_cost_min = ( - convert_money(entry_cost_min, base_currency) * entry.quantity + 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 + ) + except Exception: + entry_cost_min = Money(0, base_currency) + entry_cost_max = Money(0, base_currency) + + # Update total quantities + items_count += 1 + total_quantity += item.quantity + total_cost_min += entry_cost_min + total_cost_max += entry_cost_max + + # Add a new stocktake entry for this part + history_entries.append( + part_models.PartStocktake( + part=part, + item_count=items_count, + quantity=total_quantity, + cost_min=total_cost_min, + cost_max=total_cost_max, ) - entry_cost_max = ( - convert_money(entry_cost_max, base_currency) * entry.quantity - ) - except Exception: - entry_cost_min = Money(0, base_currency) - entry_cost_max = Money(0, base_currency) - - # Update total cost values - total_quantity += entry.quantity - total_cost_min += entry_cost_min - total_cost_max += entry_cost_max - - # Test if this stock item is within the specified location - if location and entry.location not in locations: - continue - - # Update location cost values - location_item_count += 1 - location_quantity += entry.quantity - location_cost_min += entry_cost_min - location_cost_max += entry_cost_max - - # Construct PartStocktake instance - # Note that we use the *total* values for the PartStocktake instance - instance = part.models.PartStocktake( - part=target, - item_count=stock_entries.count(), - quantity=total_quantity, - cost_min=total_cost_min, - cost_max=total_cost_max, - note=note, - user=user, - ) - - if commit: - instance.save() - - # Add location-specific data to the instance - instance.location_item_count = location_item_count - instance.location_quantity = location_quantity - instance.location_cost_min = location_cost_min - instance.location_cost_max = location_cost_max - - return instance - - -def generate_stocktake_report(**kwargs): - """Generated a new stocktake report. - - Note that this method should be called only by the background worker process! - - Unless otherwise specified, the stocktake report is generated for *all* Part instances. - Optional filters can by supplied via the kwargs - - kwargs: - user: The user who requested this stocktake (set to None for automated stocktake) - part: Optional Part instance to filter by (including variant parts) - category: Optional PartCategory to filter results - location: Optional StockLocation to filter results - exclude_external: If True, exclude stock items in external locations (default = False) - generate_report: If True, generate a stocktake report from the calculated data (default=True) - update_parts: If True, save stocktake information against each filtered Part (default = True) - """ - import part.models - - # Determine if external locations should be excluded - exclude_external = kwargs.get( - 'exclude_exernal', - common.models.InvenTreeSetting.get_setting('STOCKTAKE_EXCLUDE_EXTERNAL', False), - ) - - parts = part.models.Part.objects.all() - user = kwargs.get('user') - - generate_report = kwargs.get('generate_report', True) - update_parts = kwargs.get('update_parts', True) - - # Filter by 'Part' instance - if p := kwargs.get('part'): - variants = p.get_descendants(include_self=True) - parts = parts.filter(pk__in=[v.pk for v in variants]) - - # Filter by 'Category' instance (cascading) - if category := kwargs.get('category'): - categories = category.get_descendants(include_self=True) - parts = parts.filter(category__in=categories) - - # Filter by 'Location' instance (cascading) - # Stocktake report will be limited to parts which have stock items within this location - if location := kwargs.get('location'): - # Extract flat list of all sublocations - locations = list(location.get_descendants(include_self=True)) - - # Items which exist within these locations - items = stock.models.StockItem.objects.filter(location__in=locations) - - if exclude_external: - items = items.exclude(location__external=True) - - # List of parts which exist within these locations - unique_parts = items.order_by().values('part').distinct() - - parts = parts.filter(pk__in=[result['part'] for result in unique_parts]) - - # Exit if filters removed all parts - n_parts = parts.count() - - if n_parts == 0: - logger.info('No parts selected for stocktake report - exiting') - return - - logger.info('Generating new stocktake report for %s parts', n_parts) - - base_currency = common.currency.currency_code_default() - - # Construct an initial dataset for the stocktake report - dataset = tablib.Dataset( - headers=[ - _('Part ID'), - _('Part Name'), - _('Part Description'), - _('Category ID'), - _('Category Name'), - _('Stock Items'), - _('Total Quantity'), - _('Total Cost Min') + f' ({base_currency})', - _('Total Cost Max') + f' ({base_currency})', - ] - ) - - parts = parts.prefetch_related('category', 'stock_items') - - # Simple profiling for this task - t_start = time.time() - - # Keep track of each individual "stocktake" we perform. - # They may be bulk-commited to the database afterwards - stocktake_instances = [] - - total_parts = 0 - - # Iterate through each Part which matches the filters above - for p in parts: - # Create a new stocktake for this part (do not commit, this will take place later on) - stocktake = perform_stocktake( - p, user, commit=False, exclude_external=exclude_external, location=location ) - total_parts += 1 + # Batch create stock history entries + if len(history_entries) >= N_BULK_CREATE: + part_models.PartStocktake.objects.bulk_create(history_entries) + history_entries = [] - stocktake_instances.append(stocktake) - - # Add a row to the dataset - dataset.append([ - p.pk, - p.full_name, - p.description, - p.category.pk if p.category else '', - p.category.name if p.category else '', - stocktake.location_item_count, - stocktake.location_quantity, - InvenTree.helpers.normalize(stocktake.location_cost_min.amount), - InvenTree.helpers.normalize(stocktake.location_cost_max.amount), - ]) - - # Save a new PartStocktakeReport instance - buffer = io.StringIO() - buffer.write(dataset.export('csv')) - - today = InvenTree.helpers.current_date().isoformat() - filename = f'InvenTree_Stocktake_{today}.csv' - report_file = ContentFile(buffer.getvalue(), name=filename) - - if generate_report: - report_instance = part.models.PartStocktakeReport.objects.create( - report=report_file, part_count=total_parts, user=user - ) - - # Notify the requesting user - if user: - common.notifications.trigger_notification( - report_instance, - category='generate_stocktake_report', - context={ - 'name': _('Stocktake Report Available'), - 'message': _('A new stocktake report is available for download'), - }, - targets=[user], - ) - - # If 'update_parts' is set, we save stocktake entries for each individual part - if update_parts: - # Use bulk_create for efficient insertion of stocktake - part.models.PartStocktake.objects.bulk_create( - stocktake_instances, batch_size=500 - ) - - t_stocktake = time.time() - t_start - logger.info( - 'Generated stocktake report for %s parts in %ss', - total_parts, - round(t_stocktake, 2), - ) + if len(history_entries) > 0: + # Save any remaining stocktake entries + part_models.PartStocktake.objects.bulk_create(history_entries) diff --git a/src/backend/InvenTree/part/tasks.py b/src/backend/InvenTree/part/tasks.py index a3d85e5cd8..2704d43a22 100644 --- a/src/backend/InvenTree/part/tasks.py +++ b/src/backend/InvenTree/part/tasks.py @@ -30,7 +30,7 @@ def notify_low_stock(part: Model): """Notify interested users that a part is 'low stock'. Rules: - - Triggered when the available stock for a given part falls be low the configured threhsold + - Triggered when the available stock for a given part falls be low the configured threshold - A notification is delivered to any users who are 'subscribed' to this part """ # Do not trigger low-stock notifications for inactive parts @@ -307,7 +307,9 @@ def check_missing_pricing(limit=250): @tracer.start_as_current_span('scheduled_stocktake_reports') @scheduled_task(ScheduledTask.DAILY) def scheduled_stocktake_reports(): - """Scheduled tasks for creating automated stocktake reports. + """Scheduled tasks for creating automated 'stocktake' entries. + + A "stocktake" entry is a snapshot of the current stock levels for a given Part. This task runs daily, and performs the following functions: @@ -315,38 +317,40 @@ def scheduled_stocktake_reports(): - Generate new reports at the specified period """ import part.stocktake - from part.models import PartStocktakeReport + from part.models import PartStocktake - # First let's delete any old stocktake reports - delete_n_days = int( - get_global_setting('STOCKTAKE_DELETE_REPORT_DAYS', 30, cache=False) - ) - threshold = datetime.now() - timedelta(days=delete_n_days) - old_reports = PartStocktakeReport.objects.filter(date__lt=threshold) + if get_global_setting('STOCKTAKE_DELETE_OLD_ENTRIES', False, cache=False): + # First let's delete any old stock history entries + delete_n_days = int( + get_global_setting('STOCKTAKE_DELETE_DAYS', 365, cache=False) + ) - if old_reports.count() > 0: - logger.info('Deleting %s stale stocktake reports', old_reports.count()) - old_reports.delete() + threshold = datetime.now() - timedelta(days=delete_n_days) + old_entries = PartStocktake.objects.filter(date__lt=threshold) + + if old_entries.count() > 0: + logger.info('Deleting %s old stock entries', old_entries.count()) + old_entries.delete() # Next, check if stocktake functionality is enabled if not get_global_setting('STOCKTAKE_ENABLE', False, cache=False): logger.info('Stocktake functionality is not enabled - exiting') return - report_n_days = int(get_global_setting('STOCKTAKE_AUTO_DAYS', 0, cache=False)) + report_n_days = int(get_global_setting('STOCKTAKE_AUTO_DAYS', 7, cache=False)) if report_n_days < 1: logger.info('Stocktake auto reports are disabled, exiting') return if not check_daily_holdoff('STOCKTAKE_RECENT_REPORT', report_n_days): - logger.info('Stocktake report was recently generated - exiting') + logger.info('Stock history was recently generated - exiting') return - # Let's start a new stocktake report for all parts - part.stocktake.generate_stocktake_report(update_parts=True) + # Generate new stock history entries + part.stocktake.perform_stocktake() - # Record the date of this report + # Record the date of this task run record_task_success('STOCKTAKE_RECENT_REPORT') diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index bea0a1ee62..c702fb47d8 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -32,7 +32,6 @@ from part.models import ( PartParameter, PartParameterTemplate, PartRelated, - PartStocktake, PartTestTemplate, ) from stock.models import StockItem, StockLocation @@ -2998,166 +2997,6 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase): p.refresh_from_db() -class PartStocktakeTest(InvenTreeAPITestCase): - """Unit tests for the part stocktake functionality.""" - - superuser = False - is_staff = False - roles = ['stocktake.view'] - - fixtures = ['category', 'part', 'location', 'stock'] - - def test_list_endpoint(self): - """Test the list endpoint for the stocktake data.""" - url = reverse('api-part-stocktake-list') - - self.assignRole('part.view') - - # Initially, no stocktake entries - response = self.get(url, expected_code=200) - - self.assertEqual(len(response.data), 0) - - total = 0 - - # Iterate over (up to) 5 parts in the database - for p in Part.objects.all()[:5]: - # Create some entries - to_create = [] - - n = p.pk % 10 - - for idx in range(n): - to_create.append(PartStocktake(part=p, quantity=(idx + 1) * 100)) - - total += 1 - - # Create all entries in a single bulk-create - PartStocktake.objects.bulk_create(to_create) - - # Query list endpoint - response = self.get(url, {'part': p.pk}, expected_code=200) - - # Check that the expected number of PartStocktake instances has been created - self.assertEqual(len(response.data), n) - - # List all entries - response = self.get(url, {}, expected_code=200) - - self.assertEqual(len(response.data), total) - - def test_create_stocktake(self): - """Test that stocktake entries can be created via the API.""" - url = reverse('api-part-stocktake-list') - - self.assignRole('stocktake.add') - self.assignRole('stocktake.view') - - for p in Part.objects.all(): - # Initially no stocktake information available - self.assertIsNone(p.latest_stocktake) - - note = f'Note {p.pk}' - quantity = p.pk + 5 - - self.post( - url, - {'part': p.pk, 'quantity': quantity, 'note': note}, - expected_code=201, - ) - - p.refresh_from_db() - stocktake = p.latest_stocktake - - self.assertIsNotNone(stocktake) - self.assertEqual(stocktake.quantity, quantity) - self.assertEqual(stocktake.part, p) - self.assertEqual(stocktake.note, note) - - def test_edit_stocktake(self): - """Test that a Stoctake instance can be edited and deleted via the API. - - Note that only 'staff' users can perform these actions. - """ - p = Part.objects.all().first() - - st = PartStocktake.objects.create(part=p, quantity=10) - - url = reverse('api-part-stocktake-detail', kwargs={'pk': st.pk}) - self.assignRole('part.view') - - # Test we can retrieve via API - self.get(url, expected_code=200) - - # Try to edit data - self.patch(url, {'note': 'Another edit'}, expected_code=403) - - # Assign 'edit' role permission - self.assignRole('stocktake.change') - - # Try again - self.patch(url, {'note': 'Editing note field again'}, expected_code=200) - - # Try to delete - self.delete(url, expected_code=403) - - self.assignRole('stocktake.delete') - - self.delete(url, expected_code=204) - - def test_report_list(self): - """Test for PartStocktakeReport list endpoint.""" - from part.stocktake import generate_stocktake_report - - # Initially, no stocktake records are available - self.assertEqual(PartStocktake.objects.count(), 0) - - # Generate stocktake data for all parts (default configuration) - generate_stocktake_report() - - # At least one report now created - n = PartStocktake.objects.count() - self.assertGreater(n, 0) - - self.assignRole('stocktake.view') - - response = self.get(reverse('api-part-stocktake-list'), expected_code=200) - - self.assertEqual(len(response.data), n) - - # Stocktake report should be available via the API, also - response = self.get( - reverse('api-part-stocktake-report-list'), expected_code=200 - ) - - self.assertEqual(len(response.data), 1) - - data = response.data[0] - - self.assertEqual(data['part_count'], 14) - self.assertEqual(data['user'], None) - self.assertTrue(data['report'].endswith('.csv')) - - def test_report_generate(self): - """Test API functionality for generating a new stocktake report.""" - url = reverse('api-part-stocktake-report-generate') - - # Permission denied, initially - self.assignRole('stocktake.view') - response = self.post(url, data={}, expected_code=403) - - # Stocktake functionality disabled - InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', False, None) - self.assignRole('stocktake.add') - response = self.post(url, data={}, expected_code=400) - - self.assertIn('Stocktake functionality is not enabled', str(response.data)) - - InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', True, None) - response = self.post(url, data={}, expected_code=400) - self.assertIn('Background worker check failed', str(response.data)) - - class PartMetadataAPITest(InvenTreeAPITestCase): """Unit tests for the various metadata endpoints of API.""" diff --git a/src/backend/InvenTree/part/test_part.py b/src/backend/InvenTree/part/test_part.py index 61703664d6..6fff694875 100644 --- a/src/backend/InvenTree/part/test_part.py +++ b/src/backend/InvenTree/part/test_part.py @@ -20,7 +20,6 @@ from .models import ( PartCategoryStar, PartRelated, PartStar, - PartStocktake, PartTestTemplate, rename_part_image, ) @@ -340,18 +339,6 @@ class PartTest(TestCase): self.r2.delete() self.assertEqual(PartRelated.objects.count(), countbefore) - def test_stocktake(self): - """Test for adding stocktake data.""" - # Grab a part - p = Part.objects.all().first() - - self.assertIsNone(p.last_stocktake) - - ps = PartStocktake.objects.create(part=p, quantity=100) - - self.assertIsNotNone(p.last_stocktake) - self.assertEqual(p.last_stocktake, ps.date) - def test_delete(self): """Test delete operation for a Part instance.""" part = Part.objects.first() @@ -957,3 +944,56 @@ class PartNotificationTest(InvenTreeTestCase): from error_report.models import Error self.assertEqual(Error.objects.count(), 0) + + +class PartStockHistoryTest(InvenTreeTestCase): + """Test generation of stock history entries.""" + + fixtures = ['category', 'part', 'location', 'stock'] + + def test_stock_history(self): + """Test that stock history entries are generated correctly.""" + from part.models import Part, PartStocktake + from part.stocktake import perform_stocktake + + N_STOCKTAKE = PartStocktake.objects.count() + + # Cache the initial count of stocktake entries + stock_history_entries = { + part.pk: part.stocktakes.count() for part in Part.objects.all() + } + + # Initially, run with stocktake functionality disabled + set_global_setting('STOCKTAKE_ENABLE', False) + + perform_stocktake() + + # No change, as functionality is disabled + self.assertEqual(PartStocktake.objects.count(), N_STOCKTAKE) + + for p in Part.objects.all(): + self.assertEqual(p.stocktakes.count(), stock_history_entries[p.pk]) + + # Now enable stocktake functionality + set_global_setting('STOCKTAKE_ENABLE', True) + + # Ensure that there is at least one inactive part + p = Part.objects.first() + p.active = False + p.save() + + perform_stocktake() + self.assertGreater(PartStocktake.objects.count(), N_STOCKTAKE) + + for p in Part.objects.all(): + if p.active: + # Active parts should have stocktake entries created + self.assertGreater(p.stocktakes.count(), stock_history_entries[p.pk]) + else: + # Inactive parts should not have stocktake entries created + self.assertEqual(p.stocktakes.count(), stock_history_entries[p.pk]) + + # Now, run again - should not create any new entries + N_STOCKTAKE = PartStocktake.objects.count() + perform_stocktake() + self.assertEqual(PartStocktake.objects.count(), N_STOCKTAKE) diff --git a/src/backend/InvenTree/plugin/base/event/events.py b/src/backend/InvenTree/plugin/base/event/events.py index 02e7b42b04..1a77432804 100644 --- a/src/backend/InvenTree/plugin/base/event/events.py +++ b/src/backend/InvenTree/plugin/base/event/events.py @@ -156,7 +156,6 @@ def allow_table_event(table_name): 'common_webhookmessage', 'part_partpricing', 'part_partstocktake', - 'part_partstocktakereport', ] return table_name not in ignore_tables diff --git a/src/backend/InvenTree/plugin/builtin/exporter/stocktake_exporter.py b/src/backend/InvenTree/plugin/builtin/exporter/stocktake_exporter.py new file mode 100644 index 0000000000..f2ffe8c66a --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/exporter/stocktake_exporter.py @@ -0,0 +1,160 @@ +"""Custom data exporter for part stocktake data.""" + +from decimal import Decimal + +from django.utils.translation import gettext_lazy as _ + +from rest_framework import serializers + +from InvenTree.helpers import normalize +from part.models import Part +from part.serializers import PartSerializer +from plugin import InvenTreePlugin +from plugin.mixins import DataExportMixin + + +class PartStocktakeExportOptionsSerializer(serializers.Serializer): + """Custom export options for the PartStocktakeExporter plugin.""" + + export_pricing_data = serializers.BooleanField( + default=True, label=_('Pricing Data'), help_text=_('Include part pricing data') + ) + + export_include_external_items = serializers.BooleanField( + default=False, + label=_('Include External Stock'), + help_text=_('Include external stock in the stocktake data'), + ) + + export_include_variant_items = serializers.BooleanField( + default=False, + label=_('Include Variant Items'), + help_text=_('Include part variant stock in pricing calculations'), + ) + + +class PartStocktakeExporter(DataExportMixin, InvenTreePlugin): + """Builtin plugin for exporting part stocktake data. + + Extends the "part" export process, to include stocktake data. + """ + + NAME = 'Part Stocktake Exporter' + SLUG = 'inventree-stocktake-exporter' + TITLE = _('Part Stocktake Exporter') + DESCRIPTION = _('Exporter for part stocktake data') + VERSION = '1.0.0' + AUTHOR = _('InvenTree contributors') + + ExportOptionsSerializer = PartStocktakeExportOptionsSerializer + + def supports_export( + self, + model_class: type, + user=None, + serializer_class=None, + view_class=None, + *args, + **kwargs, + ) -> bool: + """Supported if the base model is Part.""" + return model_class == Part and serializer_class == PartSerializer + + def generate_filename(self, model_class, export_format: str) -> str: + """Generate a filename for the exported part stocktake data.""" + from InvenTree.helpers import current_date + + date = current_date().isoformat() + return f'InvenTree_Stocktake_{date}.{export_format}' + + def update_headers(self, headers, context, **kwargs): + """Define headers for the Stocktake export.""" + export_pricing_data = context.get('export_pricing_data', True) + include_external_items = context.get('export_include_external_items', True) + include_variant_items = context.get('export_include_variant_items', False) + + # Use only a subset of fields from the PartSerializer + base_headers = [ + 'pk', + 'name', + 'IPN', + 'description', + 'category', + 'allocated_to_build_orders', + 'allocated_to_sales_orders', + 'required_for_build_orders', + 'required_for_sales_orders', + 'ordering', + 'building', + 'scheduled_to_build', + 'external_stock', + 'variant_stock', + 'stock_item_count', + 'total_in_stock', + ] + + if not include_external_items: + base_headers.remove('external_stock') + + if not include_variant_items: + base_headers.remove('variant_stock') + + stocktake_headers = { + key: headers[key] for key in base_headers if key in headers + } + + if export_pricing_data: + stocktake_headers.update({ + 'pricing_min': _('Minimum Unit Cost'), + 'pricing_max': _('Maximum Unit Cost'), + 'pricing_min_total': _('Minimum Total Cost'), + 'pricing_max_total': _('Maximum Total Cost'), + }) + + return stocktake_headers + + def prefetch_queryset(self, queryset): + """Prefetch related data for the queryset.""" + return queryset.prefetch_related('stock_items') + + def export_data( + self, queryset, serializer_class, headers, context, output, **kwargs + ): + """Export the data for the given queryset.""" + export_pricing_data = context.get('export_pricing_data', True) + include_external_items = context.get('export_include_external_items', False) + include_variant_items = context.get('export_include_variant_items', False) + + data = super().export_data( + queryset, serializer_class, headers, context, output, **kwargs + ) + + if export_pricing_data: + for row in data: + quantity = Decimal(row.get('total_in_stock', 0)) + + if not include_external_items: + quantity -= Decimal(row.get('external_stock', 0)) + + if not include_variant_items: + quantity -= Decimal(row.get('variant_stock', 0)) + + if quantity < 0: + quantity = Decimal(0) + + pricing_min = row.get('pricing_min', None) + pricing_max = row.get('pricing_max', None) + + if pricing_min is not None: + pricing_min = Decimal(pricing_min) + row['pricing_min_total'] = normalize( + pricing_min * quantity, rounding=10 + ) + + if pricing_max is not None: + pricing_max = Decimal(pricing_max) + row['pricing_max_total'] = normalize( + pricing_max * quantity, rounding=10 + ) + + return data diff --git a/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py b/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py new file mode 100644 index 0000000000..a22ecf2a63 --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/exporter/test_exporter.py @@ -0,0 +1,83 @@ +"""Unit test for the exporter plugins.""" + +from django.urls import reverse + +from InvenTree.unit_test import InvenTreeAPITestCase +from plugin.registry import registry + + +class StocktakeExporterTest(InvenTreeAPITestCase): + """Test the stocktake exporter plugin.""" + + fixtures = ['category', 'part', 'location', 'stock', 'bom', 'company'] + roles = ['part.add', 'part.change', 'part.delete', 'stock.view'] + + def test_stocktake_exporter(self): + """Test the stocktake exporter plugin.""" + from part.models import Part + + slug = 'inventree-stocktake-exporter' + + registry.set_plugin_state(slug, True) + + url = reverse('api-part-list') + + # Download all part data using the 'stocktake' exporter + # Use the "default" values + with self.export_data( + url, export_plugin=slug, export_format='csv' + ) as data_file: + self.process_csv( + data_file, + required_rows=Part.objects.count(), + required_cols=[ + 'Name', + 'IPN', + 'Total Stock', + 'Minimum Unit Cost', + 'Maximum Total Cost', + ], + excluded_cols=['Active', 'External Stock', 'Variant Stock'], + ) + + # Now, with additional parameters specific to the plugin + with self.export_data( + url, + export_plugin=slug, + export_format='csv', + export_pricing_data=True, + export_include_external_items=True, + export_include_variant_items=True, + ) as data_file: + self.process_csv( + data_file, + required_rows=Part.objects.count(), + required_cols=[ + 'Total Stock', + 'On Order', + 'Minimum Unit Cost', + 'Maximum Total Cost', + 'External Stock', + 'Variant Stock', + ], + excluded_cols=['Active'], + ) + + # Finally, exclude pricing data entirely + with self.export_data( + url, export_plugin=slug, export_format='csv', export_pricing_data=False + ) as data_file: + self.process_csv( + data_file, + required_rows=Part.objects.count(), + required_cols=['Total Stock', 'On Order'], + excluded_cols=[ + 'Minimum Unit Cost', + 'Maximum Total Cost', + 'Variant Stock', + 'External Stock', + ], + ) + + # Reset plugin state + registry.set_plugin_state(slug, False) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 25d49e4fb5..86bad07b50 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -504,7 +504,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): # Filter by 'mixin' parameter mixin_results = { PluginMixinEnum.BARCODE: 5, - PluginMixinEnum.EXPORTER: 3, + PluginMixinEnum.EXPORTER: 4, PluginMixinEnum.ICON_PACK: 1, PluginMixinEnum.MAIL: 1, PluginMixinEnum.NOTIFICATION: 3, diff --git a/src/backend/InvenTree/users/oauth2_scopes.py b/src/backend/InvenTree/users/oauth2_scopes.py index 1f0178bd73..b5e687ce1f 100644 --- a/src/backend/InvenTree/users/oauth2_scopes.py +++ b/src/backend/InvenTree/users/oauth2_scopes.py @@ -13,7 +13,6 @@ _roles = { 'admin': 'Role Admin', 'part_category': 'Role Part Categories', 'part': 'Role Parts', - 'stocktake': 'Role Stocktake', 'stock_location': 'Role Stock Locations', 'stock': 'Role Stock Items', 'build': 'Role Build Orders', diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index aaf0cea096..0fe705bfd5 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -12,7 +12,6 @@ class RuleSetEnum(StringEnum): ADMIN = 'admin' PART_CATEGORY = 'part_category' PART = 'part' - STOCKTAKE = 'stocktake' STOCK_LOCATION = 'stock_location' STOCK = 'stock' BUILD = 'build' @@ -27,7 +26,6 @@ RULESET_CHOICES = [ (RuleSetEnum.ADMIN, _('Admin')), (RuleSetEnum.PART_CATEGORY, _('Part Categories')), (RuleSetEnum.PART, _('Parts')), - (RuleSetEnum.STOCKTAKE, _('Stocktake')), (RuleSetEnum.STOCK_LOCATION, _('Stock Locations')), (RuleSetEnum.STOCK, _('Stock Items')), (RuleSetEnum.BUILD, _('Build Orders')), @@ -112,12 +110,12 @@ def get_ruleset_models() -> dict: 'part_partparameter', 'part_partrelated', 'part_partstar', + 'part_partstocktake', 'part_partcategorystar', 'company_supplierpart', 'company_manufacturerpart', 'company_manufacturerpartparameter', ], - RuleSetEnum.STOCKTAKE: ['part_partstocktake', 'part_partstocktakereport'], RuleSetEnum.STOCK_LOCATION: ['stock_stocklocation', 'stock_stocklocationtype'], RuleSetEnum.STOCK: [ 'stock_stockitem', diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index f8d9f4967a..b99e428e43 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -118,8 +118,6 @@ export enum ApiEndpoints { part_pricing_internal = 'part/internal-price/', part_pricing_sale = 'part/sale-price/', part_stocktake_list = 'part/stocktake/', - part_stocktake_report_list = 'part/stocktake/report/', - part_stocktake_report_generate = 'part/stocktake/report/generate/', category_list = 'part/category/', category_tree = 'part/category/tree/', category_parameter_list = 'part/category/parameters/', diff --git a/src/frontend/lib/enums/Roles.tsx b/src/frontend/lib/enums/Roles.tsx index 4c2d3bbaa8..efa6c4a04b 100644 --- a/src/frontend/lib/enums/Roles.tsx +++ b/src/frontend/lib/enums/Roles.tsx @@ -12,8 +12,7 @@ export enum UserRoles { return_order = 'return_order', sales_order = 'sales_order', stock = 'stock', - stock_location = 'stock_location', - stocktake = 'stocktake' + stock_location = 'stock_location' } /* @@ -46,8 +45,6 @@ export function userRoleLabel(role: UserRoles): string { return t`Stock Items`; case UserRoles.stock_location: return t`Stock Location`; - case UserRoles.stocktake: - return t`Stocktake`; default: return role as string; } diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 818aac1dc6..7a0a724b0b 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -279,14 +279,3 @@ export function partStocktakeFields(): ApiFormFieldSet { note: {} }; } - -export function generateStocktakeReportFields(): ApiFormFieldSet { - return { - part: {}, - category: {}, - location: {}, - exclude_external: {}, - generate_report: {}, - update_parts: {} - }; -} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 4e65140bde..2de2f52575 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -1,7 +1,6 @@ import { t } from '@lingui/core/macro'; import { Stack } from '@mantine/core'; import { - IconClipboardCheck, IconCoins, IconCpu, IconDevicesPc, @@ -103,8 +102,6 @@ const LocationTypesTable = Loadable( lazy(() => import('../../../../tables/stock/LocationTypesTable')) ); -const StocktakePanel = Loadable(lazy(() => import('./StocktakePanel'))); - export default function AdminCenter() { const user = useUserState(); @@ -197,13 +194,6 @@ export default function AdminCenter() { content: , hidden: !user.hasViewRole(UserRoles.part_category) }, - { - name: 'stocktake', - label: t`Stocktake`, - icon: , - content: , - hidden: !user.hasViewRole(UserRoles.stocktake) - }, { name: 'labels', label: t`Label Templates`, diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/StocktakePanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/StocktakePanel.tsx deleted file mode 100644 index 67b315510f..0000000000 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/StocktakePanel.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Trans } from '@lingui/react/macro'; -import { Divider, Stack } from '@mantine/core'; -import { lazy } from 'react'; - -import { StylishText } from '../../../../components/items/StylishText'; -import { GlobalSettingList } from '../../../../components/settings/SettingList'; -import { Loadable } from '../../../../functions/loading'; - -const StocktakeReportTable = Loadable( - lazy(() => import('../../../../tables/settings/StocktakeReportTable')) -); - -export default function StocktakePanel() { - return ( - - - - Stocktake Reports - - - - - ); -} diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 8148dee83c..1b0f687608 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -3,6 +3,7 @@ import { Skeleton, Stack } from '@mantine/core'; import { IconBellCog, IconCategory, + IconClipboardList, IconCurrencyDollar, IconFileAnalytics, IconFingerprint, @@ -242,6 +243,22 @@ export default function SystemSettings() { /> ) }, + { + name: 'stock-history', + label: t`Stock History`, + icon: , + content: ( + + ) + }, { name: 'buildorders', label: t`Build Orders`, diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 38e3f3aea7..965367702e 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -109,7 +109,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; import PartAllocationPanel from './PartAllocationPanel'; import PartPricingPanel from './PartPricingPanel'; -import PartStocktakeDetail from './PartStocktakeDetail'; +import PartStockHistoryDetail from './PartStockHistoryDetail'; import PartSupplierDetail from './PartSupplierDetail'; /** @@ -909,9 +909,12 @@ export default function PartDetail() { name: 'stocktake', label: t`Stock History`, icon: , - content: part ? : , + content: part ? ( + + ) : ( + + ), hidden: - !user.hasViewRole(UserRoles.stocktake) || !globalSettings.isSet('STOCKTAKE_ENABLE') || !userSettings.isSet('DISPLAY_STOCKTAKE_TAB') }, diff --git a/src/frontend/src/pages/part/PartStocktakeDetail.tsx b/src/frontend/src/pages/part/PartStockHistoryDetail.tsx similarity index 85% rename from src/frontend/src/pages/part/PartStocktakeDetail.tsx rename to src/frontend/src/pages/part/PartStockHistoryDetail.tsx index 509ce4d54c..87d77b9e4e 100644 --- a/src/frontend/src/pages/part/PartStocktakeDetail.tsx +++ b/src/frontend/src/pages/part/PartStockHistoryDetail.tsx @@ -1,3 +1,8 @@ +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 type { TableColumn } from '@lib/types/Tables'; import { t } from '@lingui/core/macro'; import { type ChartTooltipProps, LineChart } from '@mantine/charts'; import { @@ -8,27 +13,17 @@ import { SimpleGrid, Text } from '@mantine/core'; -import { useCallback, useMemo, useState } from 'react'; - -import { AddItemButton } from '@lib/components/AddItemButton'; -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 type { TableColumn } from '@lib/types/Tables'; import dayjs from 'dayjs'; +import { useCallback, useMemo, useState } from 'react'; import { formatDate, formatPriceRange } from '../../defaults/formatters'; +import { partStocktakeFields } from '../../forms/PartForms'; import { - generateStocktakeReportFields, - 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 { InvenTreeTable } from '../../tables/InvenTreeTable'; /* @@ -67,7 +62,7 @@ function ChartTooltip({ label, payload }: Readonly) { ); } -export default function PartStocktakeDetail({ +export default function PartStockHistoryDetail({ partId }: Readonly<{ partId: number }>) { const user = useUserState(); @@ -94,29 +89,19 @@ export default function PartStocktakeDetail({ table: table }); - const generateReport = useCreateApiFormModal({ - url: ApiEndpoints.part_stocktake_report_generate, - title: t`Generate Stocktake Report`, - fields: generateStocktakeReportFields(), - initialData: { - part: partId - }, - successMessage: t`Stocktake report scheduled` - }); - const tableColumns: TableColumn[] = useMemo(() => { return [ - { + DecimalColumn({ accessor: 'quantity', sortable: false, switchable: false - }, - { + }), + DecimalColumn({ accessor: 'item_count', title: t`Stock Items`, switchable: true, sortable: false - }, + }), { accessor: 'cost', title: t`Stock Value`, @@ -129,38 +114,24 @@ export default function PartStocktakeDetail({ }, { accessor: 'date', - sortable: false - }, - { - accessor: 'note', - sortable: false + sortable: true, + switchable: false } ]; }, []); - const tableActions = useMemo(() => { - return [ - generateReport.open()} - hidden={!user.hasAddRole(UserRoles.stocktake)} - /> - ]; - }, [user]); - const rowActions = useCallback( (record: any) => { return [ RowEditAction({ - hidden: !user.hasChangeRole(UserRoles.stocktake), + hidden: !user.hasChangeRole(UserRoles.part), onClick: () => { setSelectedStocktake(record.pk); editStocktakeEntry.open(); } }), RowDeleteAction({ - hidden: !user.hasDeleteRole(UserRoles.stocktake), + hidden: !user.hasDeleteRole(UserRoles.part), onClick: () => { setSelectedStocktake(record.pk); deleteStocktakeEntry.open(); @@ -207,7 +178,6 @@ export default function PartStocktakeDetail({ return ( <> - {generateReport.modal} {editStocktakeEntry.modal} {deleteStocktakeEntry.modal} @@ -216,12 +186,13 @@ export default function PartStocktakeDetail({ tableState={table} columns={tableColumns} props={{ + enableSelection: true, + enableBulkDelete: true, params: { part: partId, ordering: 'date' }, - rowActions: rowActions, - tableActions: tableActions + rowActions: rowActions }} /> {table.isLoading ? ( diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx index 1794c9fb4e..ffd4204f4f 100644 --- a/src/frontend/src/tables/part/PartTable.tsx +++ b/src/frontend/src/tables/part/PartTable.tsx @@ -316,12 +316,6 @@ function partTableFilters(): TableFilter[] { label: t`Subscribed`, description: t`Filter by parts to which the user is subscribed`, type: 'boolean' - }, - { - name: 'stocktake', - label: t`Has Stocktake`, - description: t`Filter by parts which have stocktake information`, - type: 'boolean' } ]; } diff --git a/src/frontend/src/tables/settings/StocktakeReportTable.tsx b/src/frontend/src/tables/settings/StocktakeReportTable.tsx deleted file mode 100644 index efb087ad6b..0000000000 --- a/src/frontend/src/tables/settings/StocktakeReportTable.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { t } from '@lingui/core/macro'; -import { useCallback, useMemo, useState } from 'react'; - -import { AddItemButton } from '@lib/components/AddItemButton'; -import { type RowAction, RowDeleteAction } from '@lib/components/RowActions'; -import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; -import { apiUrl } from '@lib/functions/Api'; -import type { ApiFormFieldSet } from '@lib/types/Forms'; -import type { TableColumn } from '@lib/types/Tables'; -import { AttachmentLink } from '../../components/items/AttachmentLink'; -import { RenderUser } from '../../components/render/User'; -import { generateStocktakeReportFields } from '../../forms/PartForms'; -import { - useCreateApiFormModal, - useDeleteApiFormModal -} from '../../hooks/UseForm'; -import { useTable } from '../../hooks/UseTable'; -import { DateColumn } from '../ColumnRenderers'; -import { InvenTreeTable } from '../InvenTreeTable'; - -export default function StocktakeReportTable() { - const table = useTable('stocktake-report'); - - const tableColumns: TableColumn[] = useMemo(() => { - return [ - { - accessor: 'report', - title: t`Report`, - sortable: false, - switchable: false, - render: (record: any) => , - noContext: true - }, - { - accessor: 'part_count', - title: t`Part Count`, - sortable: false - }, - DateColumn({ - accessor: 'date', - title: t`Date` - }), - { - accessor: 'user', - title: t`User`, - sortable: false, - render: (record: any) => RenderUser({ instance: record.user_detail }) - } - ]; - }, []); - - const [selectedReport, setSelectedReport] = useState( - undefined - ); - - const deleteReport = useDeleteApiFormModal({ - url: ApiEndpoints.part_stocktake_report_list, - pk: selectedReport, - title: t`Delete Report`, - onFormSuccess: () => table.refreshTable() - }); - - const generateFields: ApiFormFieldSet = useMemo( - () => generateStocktakeReportFields(), - [] - ); - - const generateReport = useCreateApiFormModal({ - url: ApiEndpoints.part_stocktake_report_generate, - title: t`Generate Stocktake Report`, - fields: generateFields, - successMessage: t`Stocktake report scheduled` - }); - - const tableActions = useMemo(() => { - return [ - generateReport.open()} - /> - ]; - }, []); - - const rowActions = useCallback((record: any): RowAction[] => { - return [ - RowDeleteAction({ - onClick: () => { - setSelectedReport(record.pk); - deleteReport.open(); - } - }) - ]; - }, []); - - return ( - <> - {generateReport.modal} - {deleteReport.modal} - - - ); -} diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts index 494e284412..8d864a6116 100644 --- a/src/frontend/tests/helpers.ts +++ b/src/frontend/tests/helpers.ts @@ -100,10 +100,10 @@ export const navigate = async ( /** * CLick on the 'tab' element with the provided name */ -export const loadTab = async (page, tabName) => { +export const loadTab = async (page, tabName, exact?) => { await page .getByLabel(/panel-tabs-/) - .getByRole('tab', { name: tabName }) + .getByRole('tab', { name: tabName, exact: exact ?? false }) .click(); await page.waitForLoadState('networkidle'); diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index 5a3c11661c..767b22adad 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -139,7 +139,8 @@ test('Settings - Global', async ({ browser, request }) => { await loadTab(page, 'Barcodes'); await loadTab(page, 'Pricing'); await loadTab(page, 'Parts'); - await loadTab(page, 'Stock'); + await loadTab(page, 'Stock', true); + await loadTab(page, 'Stock History'); await loadTab(page, 'Notifications'); await page