diff --git a/docs/docs/assets/images/part/part_stocktake_manual.png b/docs/docs/assets/images/part/part_stocktake_manual.png new file mode 100644 index 0000000000..5d6fa69683 Binary files /dev/null and b/docs/docs/assets/images/part/part_stocktake_manual.png differ diff --git a/docs/docs/assets/images/part/stocktake_report_dashboard.png b/docs/docs/assets/images/part/stocktake_report_dashboard.png new file mode 100644 index 0000000000..cc5ddb75ac Binary files /dev/null and b/docs/docs/assets/images/part/stocktake_report_dashboard.png differ diff --git a/docs/docs/part/stocktake.md b/docs/docs/part/stocktake.md index b986c6b01f..e38e514d24 100644 --- a/docs/docs/part/stocktake.md +++ b/docs/docs/part/stocktake.md @@ -14,18 +14,18 @@ In particular, an individual *Stocktake* record tracks the following information - A reference to the [part](./index.md) which is being counted - The total number of individual [stock items](../stock/index.md) available - The total stock quantity of available stock -- The total cost of stock on hand +- The total value range of stock on hand ### Stock Items vs Stock Quantity *Stock Items* refers to the number of stock entries (e.g. *"3 reels of capacitors"*). *Stock Quantity* refers to the total cumulative stock count (e.g. *"4,560 total capacitors"*). -### Cost of Stock on Hand +### Value Range of Stock on Hand -The total cost of stock on hand is calculated based on the provided pricing data. For stock items which have a recorded *cost* (e.g. *purchase price*), this value is used. If no direct pricing information is available for a particular stock item, the price range of the part itself is used. +The total value range of stock on hand is calculated based on the provided pricing data. For stock items which have a recorded *cost* (e.g. *purchase price*), this value is used. If no direct pricing information is available for a particular stock item, the price range of the part itself is used. -!!! info "Cost Range" - Cost data is provided as a *range* of values, accounting for any variability in available pricing data. +!!! info "Value Range" + Value data is provided as a *range* of values, accounting for any variability in available pricing data. ### Display Historical Stock Data @@ -39,6 +39,42 @@ If this tab is not visible, ensure that the *Enable Stock History* [user setting {{ image("part/part_stocktake_enable_tab.png", "Enable stock history tab") }} +### Stocktake Entry Generation + +By default, stocktake entries are generated automatically at regular intervals (see [settings](#stock-history-settings) below). However, users can generate a stocktake entry on demand, using the *Generate Stocktake Entry* button in the *Stock History* tab: + +{{ image("part/part_stocktake_manual.png", "Generate stocktake entry") }} + +This will schedule the generation of a new stocktake entry for the selected part, and the new entry will be visible in the stock history data once the generation process is complete. + +## Stocktake Reports + +In addition to the part stocktake entries, which are periodically generated for all parts in the database, users can also generate a stocktake *report*, against a particular set of input parameters. Instead of generating a stocktake entry for a single part, this process generates a report which contains stocktake data for all parts which match the specified parameters. + +The generated report (once completed) will be available for download as a CSV file, and will contain the stocktake entry data for all parts which match the specified parameters. + +### Report Options + +The following parameters can be specified when generating a stocktake report: + +| Parameter | Description | +| --------- | ----------- | +| Part | If provided, the report will only include stocktake data for the specified part, including and variant parts. If left blank, the report will include data for all parts in the database. | +| Category | If provided, the report will only include stocktake data for parts which belong to the specified category, including any sub-categories. If left blank, the report will include data for all parts in the database. | +| Location | If provided, the report will only include stocktake data for parts which have stock items located at the specified location, including any sub-locations. If left blank, the report will include data for all parts in the database. | + +### Generating a Stocktake Report + +The following methods for generating a stocktake report via the user interface are available: + +#### Dashboard Widget + +A dashboard widget is available for generating stocktake reports, which can be added to any dashboard view: + +{{ image("part/stocktake_report_dashboard.png", "Stocktake dashboard widget") }} + +Here, the user can specify the report parameters, and then click the *Generate Report* button to generate a new stocktake report based on the specified parameters. + ## Stock History Settings There are a number of configuration options available in the [settings view](../settings/global.md): diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f8d15dc97a..b1dbd9a91f 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 447 +INVENTREE_API_VERSION = 448 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v448 -> 2026-02-05 : https://github.com/inventree/InvenTree/pull/11257 + - Adds API endpoint for manually generating a stocktake entry + v447 -> 2026-02-02 : https://github.com/inventree/InvenTree/pull/11242 - Adds "sub_part_active" filter to BomItem API endpoint diff --git a/src/backend/InvenTree/data_exporter/mixins.py b/src/backend/InvenTree/data_exporter/mixins.py index 4b373dcf51..f40ee113f2 100644 --- a/src/backend/InvenTree/data_exporter/mixins.py +++ b/src/backend/InvenTree/data_exporter/mixins.py @@ -304,7 +304,7 @@ class DataExportViewMixin: """Export the data in the specified format. Arguments: - export_plugin: The plugin instance to use for exporting the data + export_plugin: The plugin instance to use for exporting the data. If not provided, the default exporter is used export_format: The file format to export the data in export_context: Additional context data to pass to the plugin output: The DataOutput object to write to @@ -312,6 +312,11 @@ class DataExportViewMixin: - By default, uses the provided serializer to generate the data, and return it as a file download. - If a plugin is specified, the plugin can be used to augment or replace the export functionality. """ + if export_plugin is None: + from plugin.registry import registry + + export_plugin = registry.get_plugin('inventree-exporter') + # Get the base serializer class for the view serializer_class = self.get_serializer_class() diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 6a6f3326a1..db8eefd5e6 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1204,11 +1204,18 @@ class PartStocktakeFilter(FilterSet): fields = ['part'] -class PartStocktakeList(BulkDeleteMixin, ListCreateAPI): +class PartStocktakeMixin: + """Mixin class for PartStocktake API endpoints.""" + + queryset = PartStocktake.objects.all().prefetch_related('part') + serializer_class = part_serializers.PartStocktakeSerializer + + +class PartStocktakeList( + PartStocktakeMixin, DataExportViewMixin, BulkDeleteMixin, ListCreateAPI +): """API endpoint for listing part stocktake information.""" - queryset = PartStocktake.objects.all() - serializer_class = part_serializers.PartStocktakeSerializer filterset_class = PartStocktakeFilter def get_serializer_context(self): @@ -1226,14 +1233,65 @@ class PartStocktakeList(BulkDeleteMixin, ListCreateAPI): ordering = '-pk' -class PartStocktakeDetail(RetrieveUpdateDestroyAPI): +class PartStocktakeDetail(PartStocktakeMixin, RetrieveUpdateDestroyAPI): """Detail API endpoint for a single PartStocktake instance. Note: Only staff (admin) users can access this endpoint. """ + +class PartStocktakeGenerate(CreateAPI): + """API endpoint for generating a PartStocktake instance.""" + queryset = PartStocktake.objects.all() - serializer_class = part_serializers.PartStocktakeSerializer + serializer_class = part_serializers.PartStocktakeGenerateSerializer + + def post(self, request, *args, **kwargs): + """Perform stocktake generation on POST request.""" + from common.models import DataOutput + from part.stocktake import perform_stocktake + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + data = serializer.validated_data + + part = data.get('part', None) + category = data.get('category', None) + location = data.get('location', None) + + # Do we want to generate a report? + if data.get('generate_report', True): + report_output = DataOutput.objects.create( + user=request.user, output_type='stocktake' + ) + else: + report_output = None + + # Offload the actual stocktake generation to a background task, as it may take some time to complete + offload_task( + perform_stocktake, + part_id=part.pk if part else None, + category_id=category.pk if category else None, + location_id=location.pk if location else None, + generate_entry=data.get('generate_entry', True), + report_output_id=report_output.pk if report_output else None, + group='stocktake', + ) + + if report_output: + report_output.refresh_from_db() + + result = { + 'category': category, + 'location': location, + 'part': part, + 'output': report_output, + } + + output_serializer = part_serializers.PartStocktakeGenerateSerializer(result) + + return Response(output_serializer.data) class BomFilter(FilterSet): @@ -1604,6 +1662,11 @@ part_api_urls = [ PartStocktakeDetail.as_view(), name='api-part-stocktake-detail', ), + path( + 'generate/', + PartStocktakeGenerate.as_view(), + name='api-part-stocktake-generate', + ), path('', PartStocktakeList.as_view(), name='api-part-stocktake-list'), ]), ), diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 60ce08275a..17d38dc0f7 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -30,6 +30,7 @@ import part.filters as part_filters import part.helpers as part_helpers import stock.models import users.models +from data_exporter.mixins import DataExportSerializerMixin from importer.registry import register_importer from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.ready import isGeneratingSchema @@ -1187,7 +1188,11 @@ class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer) return part.sales_order_allocation_count(include_variants=True, pending=True) -class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): +class PartStocktakeSerializer( + InvenTree.serializers.FilterableSerializerMixin, + DataExportSerializerMixin, + InvenTree.serializers.InvenTreeModelSerializer, +): """Serializer for the PartStocktake model.""" class Meta: @@ -1196,18 +1201,32 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): model = PartStocktake fields = [ 'pk', - 'date', 'part', + 'part_name', + 'part_ipn', + 'part_description', + 'date', 'item_count', 'quantity', 'cost_min', 'cost_min_currency', 'cost_max', 'cost_max_currency', + # Optional detail fields + 'part_detail', ] read_only_fields = ['date', 'user'] + def __init__(self, *args, **kwargs): + """Custom initialization for PartStocktakeSerializer.""" + exclude_pk = kwargs.pop('exclude_pk', False) + + super().__init__(*args, **kwargs) + + if exclude_pk: + self.fields.pop('pk', None) + quantity = serializers.FloatField() cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) @@ -1216,6 +1235,23 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True) cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer() + part_name = serializers.CharField( + source='part.name', read_only=True, label=_('Part Name') + ) + + part_ipn = serializers.CharField( + source='part.IPN', read_only=True, label=_('Part IPN') + ) + + part_description = serializers.CharField( + source='part.description', read_only=True, label=_('Part Description') + ) + + part_detail = enable_filter( + PartBriefSerializer(source='part', read_only=True, many=False, pricing=False), + default_include=False, + ) + def save(self): """Called when this serializer is saved.""" data = self.validated_data @@ -1226,6 +1262,72 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): return super().save() +class PartStocktakeGenerateSerializer(serializers.Serializer): + """Serializer for generating PartStocktake entries.""" + + class Meta: + """Metaclass options.""" + + fields = [ + 'part', + 'category', + 'location', + 'generate_entry', + 'generate_report', + 'output', + ] + + part = serializers.PrimaryKeyRelatedField( + queryset=Part.objects.all(), + label=_('Part'), + help_text=_( + 'Select a part to generate stocktake information for that part (and any variant parts)' + ), + required=False, + allow_null=True, + ) + + category = serializers.PrimaryKeyRelatedField( + queryset=PartCategory.objects.all(), + label=_('Category'), + help_text=_( + 'Select a category to include all parts within that category (and subcategories)' + ), + required=False, + allow_null=True, + ) + + location = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockLocation.objects.all(), + label=_('Location'), + help_text=_( + 'Select a location to include all parts with stock in that location (including sub-locations)' + ), + required=False, + allow_null=True, + ) + + generate_entry = serializers.BooleanField( + label=_('Generate Stocktake Entries'), + help_text=_('Save stocktake entries for the selected parts'), + write_only=True, + required=False, + default=False, + ) + + generate_report = serializers.BooleanField( + label=_('Generate Report'), + help_text=_('Generate a stocktake report for the selected parts'), + write_only=True, + required=False, + default=False, + ) + + output = common.serializers.DataOutputSerializer( + read_only=True, many=False, label=_('Output') + ) + + @extend_schema_field( serializers.CharField( help_text=_('Select currency from available options') diff --git a/src/backend/InvenTree/part/stocktake.py b/src/backend/InvenTree/part/stocktake.py index fcdfff40c9..40bc857afd 100644 --- a/src/backend/InvenTree/part/stocktake.py +++ b/src/backend/InvenTree/part/stocktake.py @@ -1,16 +1,48 @@ """Stock history functionality.""" +from typing import Optional + +from django.core.files.base import ContentFile + import structlog +import tablib from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money +import common.models +from InvenTree.helpers import current_date + logger = structlog.get_logger('inventree') -def perform_stocktake() -> None: - """Generate stock history entries for all active parts.""" +def perform_stocktake( + part_id: Optional[int] = None, + category_id: Optional[int] = None, + location_id: Optional[int] = None, + exclude_external: Optional[bool] = None, + generate_entry: bool = True, + report_output_id: Optional[int] = None, +) -> None: + """Capture a snapshot of stock-on-hand and stock value. + + Arguments: + part_id: Optional ID of a part to perform stocktake on. If not provided, all active parts will be processed. + category_id: Optional category ID to use to filter parts + location_id: Optional location ID to use to filter stock items + exclude_external: If True, exclude external stock items from the stocktake + generate_entry: If True, create stocktake entries in the database + report_output_id: Optional ID of a DataOutput object for the stocktake report (e.g. for download) + + The default implementation creates stocktake entries for all active parts, + and writes these stocktake entries to the database. + + Alternatively, the scope of the stocktake can be limited by providing a queryset of parts, + or by providing a category ID or location ID to filter the parts/stock items. + """ import InvenTree.helpers import part.models as part_models + import part.serializers as part_serializers + import stock.models as stock_models from common.currency import currency_code_default from common.settings import get_global_setting @@ -18,36 +50,98 @@ def perform_stocktake() -> None: logger.info('Stocktake functionality is disabled - skipping') return - exclude_external = get_global_setting( - 'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False - ) + # If exclude_external is not provided, use global setting + if exclude_external is None: + exclude_external = get_global_setting( + 'STOCKTAKE_EXCLUDE_EXTERNAL', False, cache=False + ) - active_parts = part_models.Part.objects.filter(active=True) + # If a single part is provided, limit to that part + # Otherwise, start with all active parts + if part_id is not None: + try: + part = part_models.Part.objects.get(id=part_id) + parts = part.get_descendants(include_self=True) + except (ValueError, part_models.Part.DoesNotExist): + parts = part_models.Part.objects.all() + else: + parts = part_models.Part.objects.all() + + # Only use active parts + parts = parts.filter(active=True) + + # Prefetch related pricing information + parts = parts.prefetch_related('pricing_data', 'stock_items') + + # Filter part queryset by category, if provided + if category_id is not None: + # Filter parts by category (including subcategories) + try: + category = part_models.PartCategory.objects.get(id=category_id) + parts = parts.filter( + category__in=category.get_descendants(include_self=True) + ) + except (ValueError, part_models.PartCategory.DoesNotExist): + pass + + # Fetch location if provided + if location_id is not None: + try: + location = stock_models.StockLocation.objects.get(id=location_id) + except (ValueError, stock_models.StockLocation.DoesNotExist): + location = None + else: + location = None + + if location is not None: + # Location limited, so we will disable saving of stocktake entries + generate_entry = False # New history entries to be created history_entries = [] - N_BULK_CREATE = 250 - base_currency = currency_code_default() today = InvenTree.helpers.current_date() - logger.info( - 'Creating new stock history entries for %s active parts', active_parts.count() - ) + logger.info('Creating new stock history entries for %s parts', parts.count()) - for part in active_parts: + # Fetch report output object if provided + if report_output_id is not None: + try: + report_output = common.models.DataOutput.objects.get(id=report_output_id) + except (ValueError, common.models.DataOutput.DoesNotExist): + report_output = None + else: + report_output = None + + if report_output: + # Initialize progress on the report output + report_output.total = parts.count() + report_output.progress = 0 + report_output.complete = False + report_output.save() + + for part in parts: # Is there a recent stock history record for this part? - if part_models.PartStocktake.objects.filter( - part=part, date__gte=today - ).exists(): + if ( + generate_entry + and part_models.PartStocktake.objects.filter( + part=part, date__gte=today + ).exists() + ): continue - pricing = part.pricing + try: + pricing = part.pricing_data + except Exception: + pricing = None # Fetch all 'in stock' items for this part stock_items = part.stock_entries( - in_stock=True, include_external=not exclude_external, include_variants=True + location=location, + in_stock=True, + include_external=not exclude_external, + include_variants=True, ) total_cost_min = Money(0, base_currency) @@ -56,23 +150,34 @@ def perform_stocktake() -> None: total_quantity = 0 items_count = 0 + if stock_items.count() == 0: + # No stock items - skip this part if location is specified + if location: + continue + for item in stock_items: # Extract cost information - entry_cost_min = pricing.overall_min or pricing.overall_max - entry_cost_max = pricing.overall_max or pricing.overall_min + entry_cost_min = ( + (pricing.overall_min or pricing.overall_max) if pricing else None + ) + entry_cost_max = ( + (pricing.overall_max or pricing.overall_min) if pricing else None + ) if item.purchase_price is not None: entry_cost_min = item.purchase_price entry_cost_max = item.purchase_price try: - entry_cost_min = ( - convert_money(entry_cost_min, base_currency) * item.quantity - ) - entry_cost_max = ( - convert_money(entry_cost_max, base_currency) * item.quantity - ) + entry_cost_min = entry_cost_min * item.quantity + entry_cost_max = entry_cost_max * item.quantity + + if entry_cost_min.currency != base_currency: + entry_cost_min = convert_money(entry_cost_min, base_currency) + + if entry_cost_max.currency != base_currency: + entry_cost_max = convert_money(entry_cost_max, base_currency) except Exception: entry_cost_min = Money(0, base_currency) entry_cost_max = Money(0, base_currency) @@ -83,6 +188,13 @@ def perform_stocktake() -> None: total_cost_min += entry_cost_min total_cost_max += entry_cost_max + if report_output: + report_output.progress += 1 + + # Update report progress every few items, to avoid excessive database writes + if report_output.progress % 50 == 0: + report_output.save() + # Add a new stocktake entry for this part history_entries.append( part_models.PartStocktake( @@ -94,11 +206,31 @@ def perform_stocktake() -> None: ) ) - # Batch create stock history entries - if len(history_entries) >= N_BULK_CREATE: - part_models.PartStocktake.objects.bulk_create(history_entries) - history_entries = [] - - if len(history_entries) > 0: - # Save any remaining stocktake entries + if generate_entry: + # Bulk-create PartStocktake entries part_models.PartStocktake.objects.bulk_create(history_entries) + + if report_output: + # Save report data, and mark as complete + + today = current_date() + + serializer = part_serializers.PartStocktakeSerializer(exclude_pk=True) + + headers = serializer.generate_headers() + + # Export the data to a file + dataset = tablib.Dataset(headers=list(headers.values())) + + header_keys = list(headers.keys()) + + for entry in history_entries: + entry.date = today + row = serializer.to_representation(entry) + dataset.append([row.get(header, '') for header in header_keys]) + + datafile = dataset.export('csv') + + report_output.mark_complete( + output=ContentFile(datafile, 'stocktake_report.csv') + ) diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 5c61f4e6ec..8f7d905d36 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -120,6 +120,7 @@ export enum ApiEndpoints { part_pricing_internal = 'part/internal-price/', part_pricing_sale = 'part/sale-price/', part_stocktake_list = 'part/stocktake/', + part_stocktake_generate = 'part/stocktake/generate/', category_list = 'part/category/', category_tree = 'part/category/tree/', category_parameter_list = 'part/category/parameters/', diff --git a/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx b/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx index 7c5268faac..7b2352a18f 100644 --- a/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx +++ b/src/frontend/src/components/dashboard/DashboardWidgetLibrary.tsx @@ -9,12 +9,13 @@ import GetStartedWidget from './widgets/GetStartedWidget'; import LanguageSelectDashboardWidget from './widgets/LanguageSelectWidget'; import NewsWidget from './widgets/NewsWidget'; import QueryCountDashboardWidget from './widgets/QueryCountDashboardWidget'; +import StocktakeDashboardWidget from './widgets/StocktakeDashboardWidget'; /** * * @returns A list of built-in dashboard widgets which display the number of results for a particular query */ -export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { +function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { const user = useUserState.getState(); const globalSettings = useGlobalSettingsState.getState(); @@ -186,7 +187,7 @@ export function BuiltinQueryCountWidgets(): DashboardWidgetProps[] { }); } -export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] { +function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] { return [ { label: 'gstart', @@ -207,10 +208,14 @@ export function BuiltinGettingStartedWidgets(): DashboardWidgetProps[] { ]; } -export function BuiltinSettingsWidgets(): DashboardWidgetProps[] { +function BuiltinSettingsWidgets(): DashboardWidgetProps[] { return [ColorToggleDashboardWidget(), LanguageSelectDashboardWidget()]; } +function BuiltinActionWidgets(): DashboardWidgetProps[] { + return [StocktakeDashboardWidget()]; +} + /** * * @returns A list of built-in dashboard widgets @@ -219,6 +224,7 @@ export default function DashboardWidgetLibrary(): DashboardWidgetProps[] { return [ ...BuiltinQueryCountWidgets(), ...BuiltinGettingStartedWidgets(), - ...BuiltinSettingsWidgets() + ...BuiltinSettingsWidgets(), + ...BuiltinActionWidgets() ]; } diff --git a/src/frontend/src/components/dashboard/widgets/StocktakeDashboardWidget.tsx b/src/frontend/src/components/dashboard/widgets/StocktakeDashboardWidget.tsx new file mode 100644 index 0000000000..03e94af071 --- /dev/null +++ b/src/frontend/src/components/dashboard/widgets/StocktakeDashboardWidget.tsx @@ -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(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} + + + + + ); +} + +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: () => , + enabled: user.hasAddRole(UserRoles.part) + }; +} diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index e561614e8c..3177663aa9 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -266,7 +266,6 @@ export function partStocktakeFields(): ApiFormFieldSet { cost_min: {}, cost_min_currency: {}, cost_max: {}, - cost_max_currency: {}, - note: {} + cost_max_currency: {} }; } diff --git a/src/frontend/src/pages/part/PartStockHistoryDetail.tsx b/src/frontend/src/pages/part/PartStockHistoryDetail.tsx index 01fded4056..6ddc9571ef 100644 --- a/src/frontend/src/pages/part/PartStockHistoryDetail.tsx +++ b/src/frontend/src/pages/part/PartStockHistoryDetail.tsx @@ -2,6 +2,7 @@ import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; +import { AddItemButton } from '@lib/index'; import type { TableColumn } from '@lib/types/Tables'; import { t } from '@lingui/core/macro'; import { type ChartTooltipProps, LineChart } from '@mantine/charts'; @@ -18,12 +19,13 @@ import { useCallback, useMemo, useState } from 'react'; import { formatDate, formatPriceRange } from '../../defaults/formatters'; import { partStocktakeFields } from '../../forms/PartForms'; import { + useCreateApiFormModal, useDeleteApiFormModal, useEditApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { useUserState } from '../../states/UserState'; -import { DecimalColumn } from '../../tables/ColumnRenderers'; +import { DateColumn, DecimalColumn } from '../../tables/ColumnRenderers'; import { InvenTreeTable } from '../../tables/InvenTreeTable'; /* @@ -89,19 +91,38 @@ export default function PartStockHistoryDetail({ table: table }); + const newStocktakeEntry = useCreateApiFormModal({ + title: t`Generate Stocktake Report`, + url: apiUrl(ApiEndpoints.part_stocktake_generate), + fields: { + part: { + value: partId, + disabled: true + }, + generate_entry: { + value: true, + hidden: true + } + }, + submitText: t`Generate`, + successMessage: t`Stocktake report scheduled for generation` + }); + const tableColumns: TableColumn[] = useMemo(() => { return [ - DecimalColumn({ - accessor: 'quantity', - sortable: false, - switchable: false - }), + DateColumn({}), DecimalColumn({ accessor: 'item_count', title: t`Stock Items`, switchable: true, sortable: false }), + DecimalColumn({ + accessor: 'quantity', + title: t`Stock Quantity`, + sortable: false, + switchable: false + }), { accessor: 'cost', title: t`Stock Value`, @@ -111,11 +132,6 @@ export default function PartStockHistoryDetail({ currency: record.cost_min_currency }); } - }, - { - accessor: 'date', - sortable: true, - switchable: false } ]; }, []); @@ -124,7 +140,7 @@ export default function PartStockHistoryDetail({ (record: any) => { return [ RowEditAction({ - hidden: !user.hasChangeRole(UserRoles.part), + hidden: !user.hasChangeRole(UserRoles.part) || !user.isSuperuser(), onClick: () => { setSelectedStocktake(record.pk); editStocktakeEntry.open(); @@ -176,8 +192,20 @@ export default function PartStockHistoryDetail({ return [min_date.valueOf(), max_date.valueOf()]; }, [chartData]); + const tableActions = useMemo(() => { + return [ +