From 540eb84796da3a2683d9e69323fda861cc56517f Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 26 May 2026 23:21:06 +1000 Subject: [PATCH 1/3] [Feature] SalesOrder Auto-Allocate (#12000) * Add basic auto-allocate functionality - backend code - background task - API endpoint * Add new endpoint enum * add frontend components * Tweak auto-allocate output * Allow specifying of individual line items * Tweak error boundary * Enable bulk-delete of allocated items against sales order * Refactor stock sorting options * Allow user to select how to handle serialized stock * Backport new functionality to BuildOrder allocation * Refactor sorting options to use enumerated values * Implement functional unit tests for new feature * Update API and CHANGELOG * Additional unit test * Add playwright testing * Documentation * Update docs for build auto-allocate * Fix dependencies * Adjust build line filtering * Fix serializer --- CHANGELOG.md | 1 + docs/docs/manufacturing/allocate.md | 43 +- docs/docs/sales/sales_order.md | 73 ++++ .../InvenTree/InvenTree/api_version.py | 7 +- src/backend/InvenTree/build/api.py | 4 + src/backend/InvenTree/build/models.py | 19 +- src/backend/InvenTree/build/serializers.py | 19 + src/backend/InvenTree/build/test_api.py | 161 +++++++- src/backend/InvenTree/order/api.py | 60 ++- src/backend/InvenTree/order/models.py | 136 +++++++ src/backend/InvenTree/order/serializers.py | 107 +++++ src/backend/InvenTree/order/tasks.py | 36 ++ src/backend/InvenTree/order/test_api.py | 377 +++++++++++++++++- .../InvenTree/order/test_sales_order.py | 231 ++++++++++- src/backend/InvenTree/stock/models.py | 22 + src/frontend/lib/components/Boundary.tsx | 10 +- src/frontend/lib/enums/ApiEndpoints.tsx | 1 + src/frontend/src/forms/BuildForms.tsx | 4 + src/frontend/src/forms/SalesOrderForms.tsx | 25 ++ .../src/tables/build/BuildLineTable.tsx | 39 +- .../sales/SalesOrderAllocationTable.tsx | 4 + .../tables/sales/SalesOrderLineItemTable.tsx | 75 +++- src/frontend/tests/pages/pui_sales.spec.ts | 36 ++ 23 files changed, 1468 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e3cd3fa4..013e8907b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules. - [#11920](https://github.com/inventree/InvenTree/pull/11920) adds support for renaming attachments after they have been uploaded. This includes both backend and frontend changes, allowing users to rename attachments via the API or through the UI. - [#11914](https://github.com/inventree/InvenTree/pull/11914) adds a "maximum_stock" field to the Part model, allowing users to specify a maximum preferred stock level for each part. This is used in conjunction with the existing "minimum_stock" field to allow users to define a preferred stock range for each part. The "high_stock" filter has also been added to the Part API endpoint, allowing users to filter parts which are above their maximum stock level. - [#11631](https://github.com/inventree/InvenTree/pull/11631) adds "raw_amount" field to the BomItem model, allowing BOM quantities to account for the units of measure of the underlying part. diff --git a/docs/docs/manufacturing/allocate.md b/docs/docs/manufacturing/allocate.md index 4594e85839..da24fbc57d 100644 --- a/docs/docs/manufacturing/allocate.md +++ b/docs/docs/manufacturing/allocate.md @@ -80,7 +80,16 @@ The *Deallocate Stock* button can be used to remove all allocations of untracked ## Automatic Stock Allocation -To speed up the allocation process, the *Auto Allocate* button can be used to allocate untracked stock items to the build. Automatic allocation of stock items does not work in every situation, as a number of criteria must be met. +To speed up the allocation process, the *Auto Allocate* button can be used to automatically allocate stock items to the build. + +!!! info "Background Task" + Auto-allocation runs as a background task. The UI will display a progress indicator while the task is running. + +#### Selecting Lines to Allocate + +By default, auto-allocation processes **all eligible BOM line items** in the build order. To restrict allocation to a subset of lines, select the desired rows in the allocation table before pressing the button — the dialog will indicate how many lines are selected. + +#### Auto Allocation Options The *Automatic Allocation* dialog is presented as shown below: @@ -90,12 +99,16 @@ The *Automatic Allocation* dialog is presented as shown below: Select the master location where stock items are to be allocated from. Leave this input blank to allocate stock items from any available location. +**Exclude Location** + +Exclude stock from a specific location (and all of its sub-locations). Useful for reserving stock in a particular area. + **Interchangeable Stock** Set this option to *True* to signal that stock items can be used interchangeably. This means that in the case where multiple stock items are available, the auto-allocation routine does not care which stock item it uses. !!! warning "Take Care" - If the *Interchangeable Stock* option is enabled, and there are multiple stock items available, the results of the automatic allocation algorithm may somewhat unexpected. + If the *Interchangeable Stock* option is enabled, and there are multiple stock items available, the results of the automatic allocation algorithm may be somewhat unexpected. !!! info "Example" Let's say that we have 5 reels of our *C_100nF_0603* capacitor, each with 4,000 parts available. If we do not mind which of these reels the stock should be taken from, we enable the *Interchangeable Stock* option in the dialog above. In this case, the stock will be allocated from one of these reels, and eventually subtracted from stock when the build is completed. @@ -104,6 +117,32 @@ Set this option to *True* to signal that stock items can be used interchangeably Set this option to *True* to allow substitute parts (as specified by the BOM) to be allocated, if the primary parts are not available. +**Optional Items** + +Set this option to *True* to include optional BOM line items in the auto-allocation. By default, optional items are not automatically allocated. + +**Item Type** + +Controls which category of BOM line items is considered for auto-allocation: + +| Option | Description | +| --- | --- | +| Untracked Items | Only untracked (non-serialized) BOM lines are allocated *(default)* | +| Tracked Items | Only tracked BOM lines are allocated | +| All Items | Both tracked and untracked BOM lines are allocated | + +**Stock Priority** + +Controls the order in which matching stock items are consumed: + +| Option | Description | +| --- | --- | +| Oldest stock first (FIFO) | Stock items updated least recently are consumed first *(default)* | +| Newest stock first (LIFO) | Stock items updated most recently are consumed first | +| Smallest quantity first | Stock items with the lowest available quantity are consumed first | +| Largest quantity first | Stock items with the highest available quantity are consumed first | +| Soonest expiry date first | Stock items expiring earliest are consumed first; items with no expiry date are used last | + ## Allocating Tracked Stock Allocation of tracked stock items is slightly more complex. Instead of being allocated against the *Build Order*, tracked stock items must be allocated against an individual *Build Output*. diff --git a/docs/docs/sales/sales_order.md b/docs/docs/sales/sales_order.md index d1c8a790ff..239651b753 100644 --- a/docs/docs/sales/sales_order.md +++ b/docs/docs/sales/sales_order.md @@ -110,6 +110,79 @@ After shipments were created, user can either: During the allocation process, user is required to select the desired shipment that will contain the stock items. +### Auto Allocate Stock + +To speed up the allocation process, use the *Auto Allocate Stock* button ({{ icon("wand") }}) available in the *Line Items* tab. This automatically finds available stock and creates the required allocations with minimal user interaction. + +!!! info "Background Task" + Auto-allocation runs as a background task. The UI will display a progress indicator while the task is running. + +#### Selecting Lines to Allocate + +By default, auto-allocation processes **all unallocated line items** on the order. To restrict allocation to a subset of lines, select the desired rows in the *Line Items* table before pressing the button — the dialog will indicate how many lines are selected. + +#### Auto Allocation Options + +The auto-allocation dialog provides the following options: + +**Source Location** + +Restrict stock to a specific location (and all of its sub-locations). Leave blank to consider stock from any location. + +**Exclude Location** + +Exclude stock from a specific location (and all of its sub-locations). Useful for reserving stock in a particular area. + +**Shipment** + +Optionally assign all new allocations to a specific pending shipment. Only shipments that have not yet been completed are shown. + +**Interchangeable Stock** + +When enabled (default), stock may be drawn from multiple stock items or locations to fulfil a single line item. When disabled, a line item is only allocated if a single stock item can cover the entire remaining quantity. + +!!! warning "Take Care" + Enabling *Interchangeable Stock* means the auto-allocation routine will combine stock from different batches or locations. Review the resulting allocations if traceability is important. + +**Stock Priority** + +Controls the order in which matching stock items are consumed: + +| Option | Description | +| --- | --- | +| Oldest stock first (FIFO) | Stock items updated least recently are consumed first *(default)* | +| Newest stock first (LIFO) | Stock items updated most recently are consumed first | +| Smallest quantity first | Stock items with the lowest available quantity are consumed first | +| Largest quantity first | Stock items with the highest available quantity are consumed first | +| Soonest expiry date first | Stock items expiring earliest are consumed first; items with no expiry date are used last | + +**Serialized Stock** + +Controls whether serialized stock items are included in the auto-allocation: + +| Option | Description | +| --- | --- | +| Allow any stock | Both serialized and unserialized stock items are considered *(default)* | +| Serialized stock only | Only stock items that carry a serial number are allocated | +| Unserialized stock only | Only stock items without a serial number are allocated | + +#### Allocation Behaviour + +The auto-allocation routine performs the following steps for each eligible line item: + +1. Skips line items for *virtual* parts. +2. Skips line items that are already fully allocated. +3. Queries available stock for the line's part, applying any location and serialized-stock filters. +4. Sorts the candidates according to the chosen *Stock Priority*. +5. Greedily allocates from each stock item in turn until the remaining quantity for the line is satisfied. + +#### Removing Allocations + +Individual or multiple allocations can be removed from the *Allocated Stock* tab. Select the allocations to remove and use the *Delete* action. + +!!! warning "Shipped Allocations Protected" + Allocations that belong to a completed (shipped) shipment cannot be deleted. + ### Check Shipment Shipments can be marked as "checked" to indicate that the items in the shipment has been verified. To mark a shipment as "checked", open the shipment actions menu, and select the "Check" action: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index a8192a2431..dc09802eff 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 494 +INVENTREE_API_VERSION = 495 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v495 -> 2026-05-25 : https://github.com/inventree/InvenTree/pull/12000 + - Adds "auto-allocate" API endpoint for sales orders + - Allow bulk-delete of SalesOrderAllocation objects via the API + - Add new allocation options to the Build auto-allocate API endpoint + v494 -> 2026-05-23 : https://github.com/inventree/InvenTree/pull/11990 - Offload build output operations to a background task, and return a task ID which can be used to monitor the progress of the task diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index a20f59c363..3a1455306b 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -886,6 +886,8 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI): serializer.is_valid(raise_exception=True) data = serializer.validated_data + build_lines = data.get('build_lines', []) + # Offload the task to the background worker task_id = offload_task( auto_allocate_build, @@ -896,6 +898,8 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI): substitutes=data['substitutes'], optional_items=data['optional_items'], item_type=data.get('item_type', 'untracked'), + stock_sort_by=data['stock_sort_by'], + line_ids=[line.pk for line in build_lines] if build_lines else None, group='build', ) diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index c3691c5677..dfcf26e0b4 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -1342,6 +1342,8 @@ class Build( interchangeable = kwargs.get('interchangeable', False) substitutes = kwargs.get('substitutes', True) optional_items = kwargs.get('optional_items', False) + stock_sort_by = kwargs.get('stock_sort_by', stock.models.STOCK_SORT_DEFAULT) + line_ids = kwargs.get('line_ids') def stock_sort(item, bom_item, variant_parts): if item.part == bom_item.sub_part: @@ -1353,7 +1355,11 @@ class Build( new_items = [] # Select only "untracked" line items - for line_item in self.untracked_line_items.all(): + untracked_lines = self.untracked_line_items.all() + if line_ids: + untracked_lines = untracked_lines.filter(pk__in=line_ids) + + for line_item in untracked_lines: # Find the referenced BomItem bom_item = line_item.bom_item @@ -1410,13 +1416,22 @@ class Build( location__in=list(sublocations) ) + # Apply secondary ORM ordering before the Python match-quality stable-sort. + if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST: + available_stock = available_stock.order_by( + F('expiry_date').asc(nulls_last=True) + ) + else: + available_stock = available_stock.order_by(stock_sort_by) + """ Next, we sort the available stock items with the following priority: 1. Direct part matches (+1) 2. Variant part matches (+2) 3. Substitute part matches (+3) - This ensures that allocation priority is first given to "direct" parts + This ensures that allocation priority is first given to "direct" parts. + Python's stable sort preserves the secondary ORM ordering within each group. """ available_stock = sorted( available_stock, diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index d7cc0e5729..888ad883bd 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -27,6 +27,7 @@ import company.serializers import InvenTree.helpers import part.filters import part.serializers as part_serializers +import stock.models as stock_models from common.settings import get_global_setting from generic.states.fields import InvenTreeCustomStatusSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin @@ -1065,6 +1066,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer): help_text=_('Select item type to auto-allocate'), ) + stock_sort_by = serializers.ChoiceField( + default=stock_models.STOCK_SORT_DEFAULT, + choices=stock_models.STOCK_SORT_CHOICES, + label=_('Stock Priority'), + help_text=_('Preferred order in which matching stock items are consumed'), + ) + + build_lines = serializers.PrimaryKeyRelatedField( + queryset=BuildLine.objects.all(), + many=True, + required=False, + default=list, + label=_('Build Lines'), + help_text=_( + 'Limit allocation to these build lines (leave blank to allocate all lines)' + ), + ) + class BuildItemSerializer( FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 9cd8427c9b..32a97e27cd 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -12,7 +12,7 @@ from build.status_codes import BuildStatus from common.settings import set_global_setting from InvenTree.unit_test import InvenTreeAPITestCase from part.models import BomItem, Part -from stock.models import StockItem +from stock.models import StockItem, StockLocation, StockSortOrder from stock.status_codes import StockStatus @@ -1811,3 +1811,162 @@ class BuildConsumeTest(BuildAPITest): for line in self.build.build_lines.all(): self.assertEqual(line.consumed, 100) + + +class BuildAutoAllocateAPITest(InvenTreeAPITestCase): + """API integration tests for BuildAutoAllocate endpoint back-ports (stock_sort_by, build_lines).""" + + fixtures = ['company', 'users'] + + roles = ['build.add', 'build.change'] + + @classmethod + def setUpTestData(cls): + """Create shared parts, locations and build data for all tests.""" + super().setUpTestData() + + cls.assembly = Part.objects.create( + name='AutoAlloc Assembly', description='', assembly=True + ) + cls.component = Part.objects.create( + name='AutoAlloc Component', description='', component=True + ) + cls.component_b = Part.objects.create( + name='AutoAlloc Component B', description='', component=True + ) + + cls.loc_a = StockLocation.objects.create(name='BuildShelf A') + cls.loc_b = StockLocation.objects.create(name='BuildShelf B') + + def _next_ref(self): + """Return a valid Build reference using the system-generated next value.""" + return Build.generate_batch_code() + + def _make_build(self, quantity=10): + """Create a fresh Build with one untracked BOM line (component only).""" + build = Build.objects.create( + part=self.assembly, + reference=f'BO-{9000 + Build.objects.count():04d}', + quantity=quantity, + ) + BomItem.objects.create(part=self.assembly, sub_part=self.component, quantity=1) + build.create_build_line_items() + return build + + def _url(self, pk): + return reverse('api-build-auto-allocate', kwargs={'pk': pk}) + + def _create_build_two_lines(self, quantity=5): + """Create a Build with two untracked BOM lines (component + component_b).""" + build = Build.objects.create( + part=self.assembly, + reference=f'BO-{9000 + Build.objects.count():04d}', + quantity=quantity, + ) + BomItem.objects.create(part=self.assembly, sub_part=self.component, quantity=1) + BomItem.objects.create( + part=self.assembly, sub_part=self.component_b, quantity=1 + ) + build.create_build_line_items() + return build + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + + def test_invalid_stock_sort_by_rejected(self): + """An unrecognised stock_sort_by value is rejected with 400.""" + build = self._make_build() + self.post( + self._url(build.pk), {'stock_sort_by': 'not_valid'}, expected_code=400 + ) + + # ------------------------------------------------------------------ + # stock_sort_by behaviour + # ------------------------------------------------------------------ + + def test_stock_sort_by_quantity_asc(self): + """stock_sort_by=QUANTITY_ASC consumes the smallest lot first.""" + build = self._make_build(quantity=15) + small = StockItem.objects.create(part=self.component, quantity=5) + StockItem.objects.create(part=self.component, quantity=100) + + self.post( + self._url(build.pk), + { + 'stock_sort_by': str(StockSortOrder.QUANTITY_ASC), + 'interchangeable': True, + }, + expected_code=200, + ) + + allocs = BuildItem.objects.filter(build_line__build=build) + self.assertTrue(any(a.stock_item == small and a.quantity == 5 for a in allocs)) + + def test_stock_sort_by_quantity_desc(self): + """stock_sort_by=QUANTITY_DESC consumes the largest lot first, covering requirement in one allocation.""" + build = self._make_build(quantity=15) + StockItem.objects.create(part=self.component, quantity=5) + large = StockItem.objects.create(part=self.component, quantity=100) + + self.post( + self._url(build.pk), + { + 'stock_sort_by': str(StockSortOrder.QUANTITY_DESC), + 'interchangeable': True, + }, + expected_code=200, + ) + + allocs = BuildItem.objects.filter(build_line__build=build) + self.assertEqual(allocs.count(), 1) + self.assertEqual(allocs.first().stock_item, large) + self.assertEqual(allocs.first().quantity, 15) + + # ------------------------------------------------------------------ + # build_lines filtering + # ------------------------------------------------------------------ + + def test_build_lines_subset_only_allocates_selected_lines(self): + """When build_lines is specified only those lines are allocated.""" + build = self._create_build_two_lines() + + StockItem.objects.create(part=self.component, quantity=50) + StockItem.objects.create(part=self.component_b, quantity=50) + + line_a = build.build_lines.filter(bom_item__sub_part=self.component).first() + + self.post( + self._url(build.pk), + {'build_lines': [line_a.pk], 'interchangeable': True}, + expected_code=200, + ) + + alloc_a = BuildItem.objects.filter( + build_line__build=build, stock_item__part=self.component + ) + alloc_b = BuildItem.objects.filter( + build_line__build=build, stock_item__part=self.component_b + ) + + self.assertTrue(alloc_a.exists()) + self.assertFalse(alloc_b.exists()) + + def test_build_lines_empty_allocates_all(self): + """When build_lines is omitted, all untracked lines are allocated.""" + build = self._create_build_two_lines() + + StockItem.objects.create(part=self.component, quantity=50) + StockItem.objects.create(part=self.component_b, quantity=50) + + self.post(self._url(build.pk), {'interchangeable': True}, expected_code=200) + + alloc_a = BuildItem.objects.filter( + build_line__build=build, stock_item__part=self.component + ) + alloc_b = BuildItem.objects.filter( + build_line__build=build, stock_item__part=self.component_b + ) + + self.assertTrue(alloc_a.exists()) + self.assertTrue(alloc_b.exists()) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 709cf3ba26..73269e5f30 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -31,6 +31,7 @@ import stock.serializers as stock_serializers from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView from InvenTree.api import ( + BulkDeleteMixin, BulkUpdateMixin, ListCreateDestroyAPIView, ParameterListMixin, @@ -1170,6 +1171,50 @@ class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI): serializer_class = serializers.SalesOrderShipmentAllocationSerializer +class SalesOrderAutoAllocate(SalesOrderContextMixin, CreateAPI): + """API endpoint to automatically allocate stock against a SalesOrder. + + - Offloads work to a background task and returns task detail + """ + + serializer_class = serializers.SalesOrderAutoAllocationSerializer + + @extend_schema(responses={200: common.serializers.TaskDetailSerializer}) + def post(self, *args, **kwargs): + """Validate parameters and offload auto-allocation to a background task.""" + from InvenTree.tasks import offload_task + from order.tasks import auto_allocate_sales_order + + order_obj = self.get_object() + serializer = self.get_serializer(data=self.request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + # Extract related models from the validated data + location = data.get('location') + exclude_location = data.get('exclude_location') + shipment = data.get('shipment') + line_items = data.get('line_items', []) + + # Offload to the background worker + # Note: We provide the model ID values, not the model instances + task_id = offload_task( + auto_allocate_sales_order, + order_obj.pk, + location_id=location.pk if location else None, + exclude_location_id=exclude_location.pk if exclude_location else None, + shipment_id=shipment.pk if shipment else None, + line_ids=[item.pk for item in line_items] if line_items else None, + interchangeable=data['interchangeable'], + stock_sort_by=data['stock_sort_by'], + serialized_stock=data['serialized_stock'], + group='sales_order', + ) + + response = common.serializers.TaskDetailSerializer.from_task(task_id).data + return Response(response, status=response['http_status']) + + class SalesOrderAllocationFilter(FilterSet): """Custom filterset for the SalesOrderAllocationList endpoint.""" @@ -1300,7 +1345,11 @@ class SalesOrderAllocationOutputOptions(OutputConfiguration): class SalesOrderAllocationList( - SalesOrderAllocationMixin, BulkUpdateMixin, OutputOptionsMixin, ListAPI + SalesOrderAllocationMixin, + BulkDeleteMixin, + BulkUpdateMixin, + OutputOptionsMixin, + ListAPI, ): """API endpoint for listing SalesOrderAllocation objects.""" @@ -1308,6 +1357,10 @@ class SalesOrderAllocationList( filter_backends = SEARCH_ORDER_FILTER output_options = SalesOrderAllocationOutputOptions + def filter_delete_queryset(self, queryset, request): + """Prevent deletion of allocations that have already been shipped.""" + return queryset.filter(shipment__shipment_date__isnull=True) + ordering_fields = [ 'quantity', 'part', @@ -2582,6 +2635,11 @@ order_api_urls = [ SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials', ), + path( + 'auto-allocate/', + SalesOrderAutoAllocate.as_view(), + name='api-so-auto-allocate', + ), path('hold/', SalesOrderHold.as_view(), name='api-so-hold'), path('cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'), path('issue/', SalesOrderIssue.as_view(), name='api-so-issue'), diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 5973f4292b..ba587a9719 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -1317,6 +1317,18 @@ class PurchaseOrder(TotalPriceMixin, Order): ) +STOCK_SORT_CHOICES = stock.models.STOCK_SORT_CHOICES +STOCK_SORT_DEFAULT = stock.models.STOCK_SORT_DEFAULT + +SERIALIZED_STOCK_CHOICES = [ + ('any', _('Allow any stock (serialized or unserialized)')), + ('serialized', _('Serialized stock only')), + ('unserialized', _('Unserialized stock only')), +] + +SERIALIZED_STOCK_DEFAULT = 'any' + + class SalesOrder(TotalPriceMixin, Order): """A SalesOrder represents a list of goods shipped outwards to a customer.""" @@ -1469,6 +1481,130 @@ class SalesOrder(TotalPriceMixin, Order): """Return true if any lines in the order are over-allocated.""" return any(line.is_overallocated() for line in self.lines.all()) + @transaction.atomic + def auto_allocate_stock( + self, + location: Optional[stock.models.StockLocation] = None, + exclude_location: Optional[stock.models.StockLocation] = None, + shipment: Optional['SalesOrderShipment'] = None, + line_ids: Optional[list] = None, + **kwargs, + ): + """Automatically allocate stock items against this SalesOrder. + + For each unallocated line item, finds available stock for + the line's part, filtered and sorted according to the supplied kwargs, then + creates SalesOrderAllocation records in bulk. + + Arguments: + location: If provided, only consider stock within this location tree. + exclude_location: If provided, exclude stock within this location tree. + shipment: Optional shipment to assign allocations to. + line_ids: If provided, only allocate against these specific line item PKs. + + Kwargs: + interchangeable (bool): If True (default), consume stock from multiple + items/locations to satisfy a line. If False, only allocate when a + single item can cover the full remaining quantity. + """ + stock_sort_by = kwargs.get('stock_sort_by', STOCK_SORT_DEFAULT) + interchangeable = kwargs.get('interchangeable', True) + serialized_stock = kwargs.get('serialized_stock', SERIALIZED_STOCK_DEFAULT) + + new_allocations = [] + + lines = self.lines.all() + if line_ids: + lines = lines.filter(pk__in=line_ids) + + for line_item in lines: + if not line_item.part: + continue + + if line_item.part.virtual: + continue + + unallocated = line_item.quantity - line_item.allocated_quantity() + + if unallocated <= 0: + continue + + available_stock = stock.models.StockItem.objects.filter( + stock.models.StockItem.IN_STOCK_FILTER, part=line_item.part + ) + + if location: + sublocations = location.get_descendants(include_self=True) + available_stock = available_stock.filter( + location__in=list(sublocations) + ) + + if exclude_location: + sublocations = exclude_location.get_descendants(include_self=True) + available_stock = available_stock.exclude( + location__in=list(sublocations) + ) + + if serialized_stock == 'serialized': + available_stock = available_stock.filter( + serial__isnull=False, quantity=1 + ).exclude(serial='') + elif serialized_stock == 'unserialized': + available_stock = available_stock.filter( + Q(serial__isnull=True) | Q(serial='') + ) + + # Handle NULL expiry_date last when sorting by expiry. + if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST: + available_stock = available_stock.order_by( + F('expiry_date').asc(nulls_last=True) + ) + else: + available_stock = available_stock.order_by(stock_sort_by) + + stock_count = available_stock.count() + + if stock_count == 0: + continue + + if not interchangeable and stock_count > 1: + # Only allocate when a single item can fully cover the requirement. + single = next( + ( + s + for s in available_stock + if s.unallocated_quantity() >= unallocated + ), + None, + ) + if single is None: + continue + available_stock = [single] + + for stock_item in available_stock: + available_qty = stock_item.unallocated_quantity() + + if available_qty <= 0: + continue + + quantity = min(unallocated, available_qty) + + new_allocations.append( + SalesOrderAllocation( + line=line_item, + item=stock_item, + quantity=quantity, + shipment=shipment, + ) + ) + + unallocated -= quantity + + if unallocated <= 0: + break + + SalesOrderAllocation.objects.bulk_create(new_allocations, batch_size=250) + def is_completed(self) -> bool: """Check if this order is "shipped" (all line items delivered). diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index c2d1b909ac..81c2a95fec 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -1990,6 +1990,113 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer): allocation.save() +class SalesOrderAutoAllocationSerializer(serializers.Serializer): + """DRF serializer for auto-allocating stock items against a SalesOrder.""" + + class Meta: + """Serializer metaclass.""" + + fields = [ + 'location', + 'exclude_location', + 'shipment', + 'interchangeable', + 'stock_sort_by', + 'serialized_stock', + 'line_items', + ] + + location = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockLocation.objects.all(), + many=False, + allow_null=True, + required=False, + label=_('Source Location'), + help_text=_( + 'Stock location where items are sourced (leave blank to use any location)' + ), + ) + + exclude_location = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockLocation.objects.all(), + many=False, + allow_null=True, + required=False, + label=_('Exclude Location'), + help_text=_('Exclude stock items from this location'), + ) + + shipment = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderShipment.objects.all(), + many=False, + allow_null=True, + required=False, + label=_('Shipment'), + help_text=_('Assign allocations to this shipment'), + ) + + interchangeable = serializers.BooleanField( + default=True, + label=_('Interchangeable Stock'), + help_text=_( + 'Allow stock to be taken from multiple locations to fulfil a single line item' + ), + ) + + stock_sort_by = serializers.ChoiceField( + default=stock.models.STOCK_SORT_DEFAULT, + choices=stock.models.STOCK_SORT_CHOICES, + label=_('Stock Priority'), + help_text=_('Preferred order in which matching stock items are consumed'), + ) + + serialized_stock = serializers.ChoiceField( + default=order.models.SERIALIZED_STOCK_DEFAULT, + choices=order.models.SERIALIZED_STOCK_CHOICES, + label=_('Serialized Stock'), + help_text=_( + 'Control whether serialized stock items are included in auto-allocation' + ), + ) + + line_items = serializers.PrimaryKeyRelatedField( + queryset=order.models.SalesOrderLineItem.objects.all(), + many=True, + required=False, + default=list, + label=_('Line Items'), + help_text=_( + 'Limit allocation to these line items (leave blank to allocate all lines)' + ), + ) + + def validate_shipment(self, shipment): + """Validate that the shipment belongs to this order and is not yet shipped.""" + order_obj = self.context.get('order') + + if shipment is None: + return shipment + + if shipment.shipment_date is not None: + raise ValidationError(_('Shipment has already been shipped')) + + if order_obj and shipment.order != order_obj: + raise ValidationError(_('Shipment is not associated with this order')) + + return shipment + + def validate_line_items(self, line_items): + """Validate that all provided line items belong to this order.""" + order_obj = self.context.get('order') + + if order_obj and line_items: + for line in line_items: + if line.order != order_obj: + raise ValidationError(_('Line item does not belong to this order')) + + return line_items + + @register_importer() class SalesOrderExtraLineSerializer( AbstractExtraLineSerializer, InvenTreeModelSerializer diff --git a/src/backend/InvenTree/order/tasks.py b/src/backend/InvenTree/order/tasks.py index d9065c2d20..8950b089a5 100644 --- a/src/backend/InvenTree/order/tasks.py +++ b/src/backend/InvenTree/order/tasks.py @@ -14,6 +14,7 @@ from opentelemetry import trace import common.notifications import InvenTree.helpers_model import order.models +import stock.models as stock_models from InvenTree.tasks import ScheduledTask, scheduled_task from order.events import PurchaseOrderEvents, SalesOrderEvents from order.status_codes import ( @@ -273,3 +274,38 @@ def complete_sales_order_shipment( # Trigger event signalling that the shipment has been completed trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=shipment.pk) + + +@tracer.start_as_current_span('auto_allocate_sales_order') +def auto_allocate_sales_order( + order_id: int, + location_id: Optional[int] = None, + exclude_location_id: Optional[int] = None, + shipment_id: Optional[int] = None, + line_ids: Optional[list] = None, + **kwargs, +): + """Run auto-allocation for a specified SalesOrder.""" + sales_order = order.models.SalesOrder.objects.get(pk=order_id) + + location = ( + stock_models.StockLocation.objects.get(pk=location_id) if location_id else None + ) + exclude_location = ( + stock_models.StockLocation.objects.get(pk=exclude_location_id) + if exclude_location_id + else None + ) + shipment = ( + order.models.SalesOrderShipment.objects.get(pk=shipment_id) + if shipment_id + else None + ) + + sales_order.auto_allocate_stock( + location=location, + exclude_location=exclude_location, + shipment=shipment, + line_ids=line_ids or None, + **kwargs, + ) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 2da8cd6115..87be1c8d29 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -21,6 +21,7 @@ from common.settings import set_global_setting from company.models import Company, SupplierPart, SupplierPriceBreak from InvenTree.unit_test import InvenTreeAPITestCase from order import models +from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment from order.status_codes import ( PurchaseOrderStatus, ReturnOrderLineStatus, @@ -31,7 +32,7 @@ from order.status_codes import ( TransferOrderStatusGroups, ) from part.models import Part -from stock.models import StockItem, StockLocation +from stock.models import StockItem, StockLocation, StockSortOrder from stock.status_codes import StockStatus from users.models import Owner @@ -3740,3 +3741,377 @@ class TransferOrderAllocateTest(OrderTest): ['part_detail', 'item_detail', 'order_detail', 'location_detail'], assert_subset=True, ) + + +class SalesOrderAutoAllocateAPITest(InvenTreeAPITestCase): + """API integration tests for the SalesOrder auto-allocate endpoint.""" + + fixtures = ['company', 'users'] + + roles = ['sales_order.add', 'sales_order.change', 'sales_order.delete'] + + @classmethod + def setUpTestData(cls): + """Create shared order, parts, locations and stock for all tests.""" + super().setUpTestData() + + cls.customer = Company.objects.create( + name='Test Customer', is_customer=True, description='' + ) + cls.part = Part.objects.create( + name='AutoAlloc Part', salable=True, description='' + ) + + cls.loc_a = StockLocation.objects.create(name='Shelf A') + cls.loc_b = StockLocation.objects.create(name='Shelf B') + + def _make_order(self, qty=50): + """Create a fresh SalesOrder with one line item and one shipment.""" + order = models.SalesOrder.objects.create( + customer=self.customer, + reference=f'SO-TEST-{models.SalesOrder.objects.count()}', + ) + line = SalesOrderLineItem.objects.create( + order=order, part=self.part, quantity=qty + ) + shipment = SalesOrderShipment.objects.create(order=order) + return order, line, shipment + + def _url(self, pk): + return reverse('api-so-auto-allocate', kwargs={'pk': pk}) + + # ------------------------------------------------------------------ + # Permission and basic response tests + # ------------------------------------------------------------------ + + def test_requires_authentication(self): + """POST without authentication returns 401.""" + self.client.logout() + order, _, _ = self._make_order() + self.post(self._url(order.pk), {}, expected_code=401) + + def test_basic_post_returns_200(self): + """POST with defaults runs synchronously in tests and returns 200.""" + order, line, _ = self._make_order() + StockItem.objects.create(part=self.part, quantity=100) + + response = self.post(self._url(order.pk), {}, expected_code=200) + + self.assertIn('task_id', response.data) + self.assertTrue(response.data['complete']) + self.assertTrue(response.data['success']) + + # Task ran synchronously — allocations are already committed + self.assertTrue(line.is_fully_allocated()) + + def test_invalid_order_pk_returns_404(self): + """POST to a non-existent order pk returns 404.""" + self.post(self._url(999999), {}, expected_code=404) + + # ------------------------------------------------------------------ + # Field validation + # ------------------------------------------------------------------ + + def test_invalid_stock_sort_by_rejected(self): + """An unrecognised stock_sort_by value is rejected with 400.""" + order, _, _ = self._make_order() + self.post( + self._url(order.pk), + {'stock_sort_by': 'not_a_valid_sort'}, + expected_code=400, + ) + + def test_invalid_serialized_stock_rejected(self): + """An unrecognised serialized_stock value is rejected with 400.""" + order, _, _ = self._make_order() + self.post(self._url(order.pk), {'serialized_stock': 'maybe'}, expected_code=400) + + def test_shipment_from_another_order_rejected(self): + """A shipment that belongs to a different order is rejected with 400.""" + order, _, _ = self._make_order() + _other_order, _, other_shipment = self._make_order() + + self.post( + self._url(order.pk), {'shipment': other_shipment.pk}, expected_code=400 + ) + + def test_shipped_shipment_rejected(self): + """A shipment that has already been marked as shipped is rejected with 400.""" + from datetime import date + + order, _, shipment = self._make_order() + shipment.shipment_date = date.today() + shipment.save() + + self.post(self._url(order.pk), {'shipment': shipment.pk}, expected_code=400) + + def test_line_items_from_another_order_rejected(self): + """line_items belonging to a different order are rejected with 400.""" + order, _, _ = self._make_order() + _, other_line, _ = self._make_order() + + self.post( + self._url(order.pk), {'line_items': [other_line.pk]}, expected_code=400 + ) + + # ------------------------------------------------------------------ + # Allocation behaviour + # ------------------------------------------------------------------ + + def test_allocates_available_stock(self): + """Stock is allocated to the line item after a successful POST.""" + order, line, _ = self._make_order(qty=30) + item = StockItem.objects.create(part=self.part, quantity=100) + + self.post(self._url(order.pk), {}, expected_code=200) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertEqual(allocs.count(), 1) + self.assertEqual(allocs.first().item, item) + self.assertEqual(allocs.first().quantity, 30) + + def test_line_items_subset_only_allocates_selected_lines(self): + """When line_items is specified only those lines are allocated.""" + order = models.SalesOrder.objects.create( + customer=self.customer, + reference=f'SO-SUBSET-{models.SalesOrder.objects.count()}', + ) + line_a = SalesOrderLineItem.objects.create( + order=order, part=self.part, quantity=10 + ) + part_b = Part.objects.create(name='Part B', salable=True, description='') + line_b = SalesOrderLineItem.objects.create( + order=order, part=part_b, quantity=10 + ) + + StockItem.objects.create(part=self.part, quantity=50) + StockItem.objects.create(part=part_b, quantity=50) + + self.post(self._url(order.pk), {'line_items': [line_a.pk]}, expected_code=200) + + self.assertTrue(line_a.is_fully_allocated()) + self.assertFalse(line_b.is_fully_allocated()) + + def test_serialized_stock_only(self): + """serialized_stock='serialized' allocates only serialized items.""" + order, line, _ = self._make_order(qty=1) + # Unserialized item + StockItem.objects.create(part=self.part, quantity=50) + # Serialized item + serial_item = StockItem.objects.create( + part=self.part, quantity=1, serial='SN-001' + ) + + self.post( + self._url(order.pk), {'serialized_stock': 'serialized'}, expected_code=200 + ) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertEqual(allocs.count(), 1) + self.assertEqual(allocs.first().item, serial_item) + + def test_unserialized_stock_only(self): + """serialized_stock='unserialized' skips serialized items.""" + order, line, _ = self._make_order(qty=10) + # Serialized items only + for sn in range(10): + StockItem.objects.create(part=self.part, quantity=1, serial=f'SN-{sn}') + + self.post( + self._url(order.pk), {'serialized_stock': 'unserialized'}, expected_code=200 + ) + + self.assertFalse(line.is_fully_allocated()) + self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 0) + + def test_stock_sort_by_quantity_asc(self): + """stock_sort_by=QUANTITY_ASC consumes the smallest lot first.""" + order, line, _ = self._make_order(qty=15) + small = StockItem.objects.create(part=self.part, quantity=5) + StockItem.objects.create(part=self.part, quantity=100) + + self.post( + self._url(order.pk), + { + 'stock_sort_by': str(StockSortOrder.QUANTITY_ASC), + 'interchangeable': True, + }, + expected_code=200, + ) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertTrue(any(a.item == small and a.quantity == 5 for a in allocs)) + + def test_stock_sort_by_quantity_desc(self): + """stock_sort_by=QUANTITY_DESC consumes the largest lot first, covering the requirement in one allocation.""" + order, line, _ = self._make_order(qty=15) + StockItem.objects.create(part=self.part, quantity=5) + StockItem.objects.create(part=self.part, quantity=100) + + self.post( + self._url(order.pk), + { + 'stock_sort_by': str(StockSortOrder.QUANTITY_DESC), + 'interchangeable': True, + }, + expected_code=200, + ) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertEqual(allocs.count(), 1) + self.assertEqual(allocs.first().quantity, 15) + + def test_location_filter(self): + """Only stock within the specified location is used.""" + order, line, _ = self._make_order(qty=10) + StockItem.objects.create(part=self.part, quantity=50, location=self.loc_a) + StockItem.objects.create(part=self.part, quantity=50, location=self.loc_b) + + self.post(self._url(order.pk), {'location': self.loc_a.pk}, expected_code=200) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertEqual(allocs.count(), 1) + self.assertEqual(allocs.first().item.location, self.loc_a) + + def test_exclude_location_filter(self): + """Stock in the excluded location is not used.""" + order, line, _ = self._make_order(qty=10) + StockItem.objects.create(part=self.part, quantity=50, location=self.loc_a) + StockItem.objects.create(part=self.part, quantity=50, location=self.loc_b) + + self.post( + self._url(order.pk), {'exclude_location': self.loc_a.pk}, expected_code=200 + ) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertEqual(allocs.count(), 1) + self.assertEqual(allocs.first().item.location, self.loc_b) + + def test_shipment_assigned_to_allocations(self): + """When a shipment is specified, all allocations are assigned to it.""" + order, line, shipment = self._make_order(qty=20) + StockItem.objects.create(part=self.part, quantity=100) + + self.post(self._url(order.pk), {'shipment': shipment.pk}, expected_code=200) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertTrue(allocs.exists()) + for alloc in allocs: + self.assertEqual(alloc.shipment, shipment) + + def test_interchangeable_false_skips_split_stock(self): + """With interchangeable=False, allocation is skipped when no single item covers the full quantity.""" + order, line, _ = self._make_order(qty=50) + StockItem.objects.create(part=self.part, quantity=20) + StockItem.objects.create(part=self.part, quantity=20) + + self.post(self._url(order.pk), {'interchangeable': False}, expected_code=200) + + self.assertFalse(line.is_fully_allocated()) + self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 0) + + +class SalesOrderAllocationBulkDeleteAPITest(InvenTreeAPITestCase): + """API integration tests for bulk-delete of SalesOrderAllocation, verifying shipped allocations are protected.""" + + fixtures = ['company', 'users'] + + roles = ['sales_order.add', 'sales_order.change', 'sales_order.delete'] + + @classmethod + def setUpTestData(cls): + """Create shared order, part and stock for all tests.""" + super().setUpTestData() + + cls.customer = models.Company.objects.create( + name='BulkDelete Customer', is_customer=True, description='' + ) + cls.part = Part.objects.create( + name='BulkDelete Part', salable=True, description='' + ) + + def _make_order_with_allocations(self, n_unshipped=2, n_shipped=1): + """Return (order, unshipped_allocations, shipped_allocations).""" + order = models.SalesOrder.objects.create( + customer=self.customer, + reference=f'SO-BD-{models.SalesOrder.objects.count()}', + ) + line = SalesOrderLineItem.objects.create( + order=order, part=self.part, quantity=100 + ) + unshipped_shipment = models.SalesOrderShipment.objects.create( + order=order, reference='1' + ) + + shipped_shipment = models.SalesOrderShipment.objects.create( + order=order, reference='2' + ) + shipped_shipment.shipment_date = date.today() + shipped_shipment.save() + + unshipped = [] + for _ in range(n_unshipped): + item = StockItem.objects.create(part=self.part, quantity=10) + alloc = SalesOrderAllocation.objects.create( + line=line, item=item, quantity=10, shipment=unshipped_shipment + ) + unshipped.append(alloc) + + shipped = [] + for _ in range(n_shipped): + item = StockItem.objects.create(part=self.part, quantity=10) + alloc = SalesOrderAllocation.objects.create( + line=line, item=item, quantity=10, shipment=shipped_shipment + ) + shipped.append(alloc) + + return order, unshipped, shipped + + def _url(self): + return reverse('api-so-allocation-list') + + # ------------------------------------------------------------------ + # Basic bulk-delete + # ------------------------------------------------------------------ + + def test_bulk_delete_unshipped_allocations(self): + """Unshipped allocations can be bulk-deleted.""" + _, unshipped, _ = self._make_order_with_allocations(n_unshipped=2, n_shipped=0) + ids = [a.pk for a in unshipped] + + self.delete(self._url(), {'items': ids}, expected_code=200) + + self.assertFalse(SalesOrderAllocation.objects.filter(pk__in=ids).exists()) + + # ------------------------------------------------------------------ + # Shipped allocation protection + # ------------------------------------------------------------------ + + def test_shipped_allocations_are_not_deleted(self): + """Shipped allocations are silently skipped when included in a bulk-delete request.""" + _, _, shipped = self._make_order_with_allocations(n_unshipped=0, n_shipped=2) + ids = [a.pk for a in shipped] + + self.delete(self._url(), {'items': ids}, expected_code=200) + + # All shipped allocations should still exist + self.assertEqual(SalesOrderAllocation.objects.filter(pk__in=ids).count(), 2) + + def test_mixed_delete_removes_only_unshipped(self): + """A bulk-delete of mixed shipped/unshipped allocations removes only the unshipped ones.""" + _, unshipped, shipped = self._make_order_with_allocations( + n_unshipped=2, n_shipped=2 + ) + all_ids = [a.pk for a in unshipped] + [a.pk for a in shipped] + + self.delete(self._url(), {'items': all_ids}, expected_code=200) + + # Unshipped should be gone + for alloc in unshipped: + self.assertFalse(SalesOrderAllocation.objects.filter(pk=alloc.pk).exists()) + + # Shipped should remain + shipped_ids = [a.pk for a in shipped] + self.assertEqual( + SalesOrderAllocation.objects.filter(pk__in=shipped_ids).count(), 2 + ) diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index 0186cfd90c..5e078fa1c5 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.exceptions import ValidationError +from django.db.models import Sum import order.tasks from common.models import InvenTreeSetting, NotificationMessage @@ -20,7 +21,7 @@ from order.models import ( SalesOrderShipment, ) from part.models import Part -from stock.models import StockItem +from stock.models import StockItem, StockLocation from users.models import Owner @@ -619,3 +620,231 @@ class SalesOrderTest(InvenTreeTestCase): # Ensure that virtual line item quantity values have been updated for line in so.lines.all(): self.assertEqual(line.shipped, line.quantity) + + +class SalesOrderAutoAllocateTest(InvenTreeTestCase): + """Tests for SalesOrder.auto_allocate_stock().""" + + fixtures = ['company', 'users'] + + @classmethod + def setUpTestData(cls): + """Create a basic order, line items, and stock items for each test.""" + cls.customer = Company.objects.create( + name='Auto Alloc Co', description='', is_customer=True + ) + cls.part = Part.objects.create( + name='Widget', salable=True, description='A salable widget' + ) + cls.virtual_part = Part.objects.create( + name='Virtual Widget', + salable=True, + virtual=True, + description='A virtual part', + ) + + cls.loc_a = StockLocation.objects.create(name='Shelf A') + cls.loc_b = StockLocation.objects.create(name='Shelf B', parent=cls.loc_a) + + def _make_order(self, qty=50): + order = SalesOrder.objects.create(customer=self.customer) + line = SalesOrderLineItem.objects.create( + order=order, part=self.part, quantity=qty + ) + shipment = SalesOrderShipment.objects.create(order=order) + return order, line, shipment + + def test_allocates_single_item(self): + """A single stock item that covers the full quantity is fully allocated.""" + order, line, _ = self._make_order(qty=30) + stock = StockItem.objects.create(part=self.part, quantity=100) + + order.auto_allocate_stock() + + self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 1) + alloc = SalesOrderAllocation.objects.get(line=line) + self.assertEqual(alloc.item, stock) + self.assertEqual(alloc.quantity, 30) + self.assertTrue(line.is_fully_allocated()) + + def test_interchangeable_consumes_multiple_items(self): + """With interchangeable=True (default), multiple stock items are consumed.""" + order, line, _ = self._make_order(qty=50) + StockItem.objects.create(part=self.part, quantity=20) + StockItem.objects.create(part=self.part, quantity=40) + + order.auto_allocate_stock(interchangeable=True) + + total = SalesOrderAllocation.objects.filter(line=line).aggregate( + t=Sum('quantity') + )['t'] + self.assertEqual(total, 50) + self.assertTrue(line.is_fully_allocated()) + + def test_not_interchangeable_skips_split_stock(self): + """With interchangeable=False, allocation is skipped when stock is split.""" + order, line, _ = self._make_order(qty=50) + StockItem.objects.create(part=self.part, quantity=20) + StockItem.objects.create(part=self.part, quantity=20) + + order.auto_allocate_stock(interchangeable=False) + + self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 0) + self.assertFalse(line.is_fully_allocated()) + + def test_not_interchangeable_uses_single_sufficient_item(self): + """With interchangeable=False, a single item that covers the full qty is used.""" + order, line, _ = self._make_order(qty=30) + StockItem.objects.create(part=self.part, quantity=10) + StockItem.objects.create(part=self.part, quantity=50) + + order.auto_allocate_stock(interchangeable=False) + + self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 1) + alloc = SalesOrderAllocation.objects.get(line=line) + self.assertEqual(alloc.quantity, 30) + + def test_location_filter(self): + """Only stock within the specified location tree is considered.""" + order, line, _ = self._make_order(qty=10) + StockItem.objects.create(part=self.part, quantity=100, location=self.loc_a) + StockItem.objects.create(part=self.part, quantity=100) # no location + + order.auto_allocate_stock(location=self.loc_a) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertTrue(allocs.exists()) + for alloc in allocs: + self.assertEqual(alloc.item.location, self.loc_a) + + def test_exclude_location_filter(self): + """Stock within the excluded location tree is not used.""" + order, line, _ = self._make_order(qty=10) + excluded = StockItem.objects.create( + part=self.part, quantity=100, location=self.loc_a + ) + included = StockItem.objects.create(part=self.part, quantity=100) + + order.auto_allocate_stock(exclude_location=self.loc_a) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertTrue(allocs.exists()) + allocated_items = [a.item for a in allocs] + self.assertNotIn(excluded, allocated_items) + self.assertIn(included, allocated_items) + + def test_skips_fully_allocated_lines(self): + """Lines that are already fully allocated are not touched.""" + order, line, shipment = self._make_order(qty=10) + existing = StockItem.objects.create(part=self.part, quantity=100) + SalesOrderAllocation.objects.create( + line=line, item=existing, quantity=10, shipment=shipment + ) + self.assertTrue(line.is_fully_allocated()) + + order.auto_allocate_stock() + + self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 1) + + def test_skips_virtual_parts(self): + """Line items for virtual parts are skipped (virtual parts cannot hold stock).""" + order = SalesOrder.objects.create(customer=self.customer) + virtual_line = SalesOrderLineItem.objects.create( + order=order, part=self.virtual_part, quantity=5 + ) + + order.auto_allocate_stock() + + self.assertEqual( + SalesOrderAllocation.objects.filter(line=virtual_line).count(), 0 + ) + # Virtual parts are considered fully allocated without any stock + self.assertTrue(virtual_line.is_fully_allocated()) + + def test_allocates_serialized_stock(self): + """Serialized stock items are included in auto-allocation.""" + order, line, _ = self._make_order(qty=1) + serialized = StockItem.objects.create( + part=self.part, quantity=1, serial='SN001' + ) + + order.auto_allocate_stock() + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertEqual(allocs.count(), 1) + self.assertEqual(allocs.first().item, serialized) + self.assertTrue(line.is_fully_allocated()) + + def test_shipment_assigned(self): + """Allocations are assigned to the provided shipment.""" + order, line, shipment = self._make_order(qty=10) + StockItem.objects.create(part=self.part, quantity=50) + + order.auto_allocate_stock(shipment=shipment) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertTrue(allocs.exists()) + for alloc in allocs: + self.assertEqual(alloc.shipment, shipment) + + def test_sort_quantity_asc(self): + """quantity_asc sort consumes smallest lots first.""" + order, line, _ = self._make_order(qty=15) + small = StockItem.objects.create(part=self.part, quantity=5) + StockItem.objects.create(part=self.part, quantity=100) + + order.auto_allocate_stock(stock_sort_by='quantity', interchangeable=True) + + allocs = SalesOrderAllocation.objects.filter(line=line).order_by('quantity') + self.assertTrue(allocs.exists()) + # Small lot should be fully consumed + self.assertTrue(any(a.item == small and a.quantity == 5 for a in allocs)) + + def test_sort_quantity_desc(self): + """quantity_desc sort consumes largest lots first.""" + order, line, _ = self._make_order(qty=15) + StockItem.objects.create(part=self.part, quantity=5) + large = StockItem.objects.create(part=self.part, quantity=100) + + order.auto_allocate_stock(stock_sort_by='-quantity', interchangeable=True) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertTrue(allocs.exists()) + # Large lot should be the first consumed (fully covers 15) + self.assertEqual(allocs.count(), 1) + self.assertEqual(allocs.first().item, large) + + def test_task_resolves_pk_params(self): + """auto_allocate_sales_order task resolves location/shipment pks to instances.""" + from order.tasks import auto_allocate_sales_order + + order, line, shipment = self._make_order(qty=10) + StockItem.objects.create(part=self.part, quantity=50, location=self.loc_a) + + auto_allocate_sales_order( + order.pk, location_id=self.loc_a.pk, shipment_id=shipment.pk + ) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertTrue(allocs.exists()) + for alloc in allocs: + self.assertEqual(alloc.shipment, shipment) + self.assertEqual(alloc.item.location, self.loc_a) + + def test_task_exclude_location_pk(self): + """auto_allocate_sales_order respects exclude_location_id parameter.""" + from order.tasks import auto_allocate_sales_order + + order, line, _ = self._make_order(qty=10) + excluded = StockItem.objects.create( + part=self.part, quantity=50, location=self.loc_a + ) + included = StockItem.objects.create(part=self.part, quantity=50) + + auto_allocate_sales_order(order.pk, exclude_location_id=self.loc_a.pk) + + allocs = SalesOrderAllocation.objects.filter(line=line) + self.assertTrue(allocs.exists()) + allocated_items = [a.item for a in allocs] + self.assertNotIn(excluded, allocated_items) + self.assertIn(included, allocated_items) diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 040daee86e..0f4d10536c 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -38,6 +38,7 @@ import stock.tasks from common.icons import validate_icon from common.settings import get_global_setting from company import models as CompanyModels +from generic.enums import StringEnum from generic.states import StatusCodeMixin from generic.states.fields import InvenTreeCustomStatusModelField from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField @@ -399,6 +400,27 @@ class StockItemReportContext(report.mixins.BaseReportContext): test_templates: dict[str, PartModels.PartTestTemplate] +class StockSortOrder(StringEnum): + """Enum of ORM sort fields available for stock auto-allocation.""" + + DATE_OLDEST = 'updated' + DATE_NEWEST = '-updated' + QUANTITY_ASC = 'quantity' + QUANTITY_DESC = '-quantity' + EXPIRY_SOONEST = 'expiry_date' + + +STOCK_SORT_CHOICES = [ + (StockSortOrder.DATE_OLDEST, _('Oldest stock first (FIFO)')), + (StockSortOrder.DATE_NEWEST, _('Newest stock first (LIFO)')), + (StockSortOrder.QUANTITY_ASC, _('Smallest quantity first')), + (StockSortOrder.QUANTITY_DESC, _('Largest quantity first')), + (StockSortOrder.EXPIRY_SOONEST, _('Soonest expiry date first')), +] + +STOCK_SORT_DEFAULT = StockSortOrder.DATE_OLDEST + + class StockItem( InvenTree.models.PluginValidationMixin, InvenTree.models.InvenTreeAttachmentMixin, diff --git a/src/frontend/lib/components/Boundary.tsx b/src/frontend/lib/components/Boundary.tsx index 38b858f696..2d1bca4436 100644 --- a/src/frontend/lib/components/Boundary.tsx +++ b/src/frontend/lib/components/Boundary.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/core/macro'; -import { Alert, Stack } from '@mantine/core'; +import { Alert, Stack, Text } from '@mantine/core'; import { ErrorBoundary, type FallbackRender } from '@sentry/react'; import { IconExclamationCircle } from '@tabler/icons-react'; import { type ReactNode, useCallback } from 'react'; @@ -14,8 +14,12 @@ export function DefaultFallback({ title={`INVE-E17: ${t`Error rendering component`}: ${title}`} > - {t`An error occurred while rendering this component. Refer to the console for more information.`} - {t`Try reloading the page, or contact your administrator if the problem persists.`} + + {t`An error occurred while rendering this component. Refer to the console for more information.`} + + + {t`Try reloading the page, or contact your administrator if the problem persists.`} + ); diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index cfb6e5e498..daee58fef9 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -182,6 +182,7 @@ export enum ApiEndpoints { sales_order_complete = 'order/so/:id/complete/', sales_order_allocate = 'order/so/:id/allocate/', sales_order_allocate_serials = 'order/so/:id/allocate-serials/', + sales_order_auto_allocate = 'order/so/:id/auto-allocate/', sales_order_line_list = 'order/so-line/', sales_order_extra_line_list = 'order/so-extra-line/', diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 6f1ea7d961..0deea93e13 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -239,6 +239,10 @@ export function useBuildAutoAllocateFields({ optional_items: { hidden: item_type === 'tracked', value: item_type === 'tracked' ? false : undefined + }, + stock_sort_by: {}, + build_lines: { + hidden: true } }; }, [item_type]); diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index 52cd670a19..ecfe82993c 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -584,3 +584,28 @@ export function useSalesOrderAllocationFields({ }; }, [orderId, shipment]); } + +export function useSalesOrderAutoAllocateFields({ + orderId +}: { + orderId: number; +}): ApiFormFieldSet { + return useMemo(() => { + return { + location: {}, + exclude_location: {}, + interchangeable: {}, + stock_sort_by: {}, + serialized_stock: {}, + shipment: { + filters: { + order: orderId, + shipped: false + } + }, + line_items: { + hidden: true + } + }; + }, [orderId]); +} diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 07fbe7d6f4..8631204214 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -574,6 +574,9 @@ export default function BuildLineTable({ }); const [allocateTaskId, setAllocateTaskId] = useState(''); + const [autoAllocateInitialData, setAutoAllocateInitialData] = useState< + Record + >({}); useBackgroundTask({ taskId: allocateTaskId, @@ -584,6 +587,24 @@ export default function BuildLineTable({ } }); + const autoAllocatePreFormContent = useMemo(() => { + const n = table.selectedRecords.length; + if (n > 0) { + return ( + + + {t`Auto-allocating stock for`} {n} {t`selected line item(s)`} + + + ); + } + return ( + + {t`Automatically allocate untracked BOM items to this build according to the selected options`} + + ); + }, [table.selectedRecords]); + const autoAllocateStock = useCreateApiFormModal({ url: ApiEndpoints.build_order_auto_allocate, pk: build.pk, @@ -595,17 +616,18 @@ export default function BuildLineTable({ location: build.take_from, interchangeable: true, substitutes: true, - optional_items: false + optional_items: false, + ...autoAllocateInitialData }, successMessage: null, onFormSuccess: (response: any) => { - setAllocateTaskId(response.task_id); + if (response.task_id) { + setAllocateTaskId(response.task_id); + } else { + table.refreshTable(); + } }, - preFormContent: ( - - {t`Automatically allocate untracked BOM items to this build according to the selected options`} - - ) + preFormContent: autoAllocatePreFormContent }); const allocateStock = useAllocateStockToBuildForm({ @@ -835,6 +857,9 @@ export default function BuildLineTable({ hidden={!visible || hasOutput} color='blue' onClick={() => { + setAutoAllocateInitialData({ + build_lines: table.selectedRecords.map((r) => r.pk) + }); autoAllocateStock.open(); }} />, diff --git a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx index d684c19dca..f8f10d8c6a 100644 --- a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx @@ -377,6 +377,10 @@ export default function SalesOrderAllocationTable({ enableFilters: !isSubTable, enableDownload: !isSubTable, enableSelection: !isSubTable, + enableBulkDelete: + !isSubTable && + allowEdit && + user.hasDeleteRole(UserRoles.sales_order), minHeight: isSubTable ? 100 : undefined, rowActions: rowActions, tableActions: isSubTable ? undefined : tableActions, diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index e3ab149f9e..4b6e3781fd 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -1,11 +1,12 @@ import { t } from '@lingui/core/macro'; -import { Group, Paper, Text } from '@mantine/core'; +import { Alert, Group, Paper, Text } from '@mantine/core'; import { IconArrowRight, IconHash, IconShoppingCart, IconSquareArrowRight, - IconTools + IconTools, + IconWand } from '@tabler/icons-react'; import type { DataTableRowExpansionProps } from 'mantine-datatable'; import { type ReactNode, useCallback, useMemo, useState } from 'react'; @@ -35,8 +36,10 @@ import { useBuildOrderFields } from '../../forms/BuildForms'; import { useAllocateToSalesOrderForm, useSalesOrderAllocateSerialsFields, + useSalesOrderAutoAllocateFields, useSalesOrderLineItemFields } from '../../forms/SalesOrderForms'; +import useBackgroundTask from '../../hooks/UseBackgroundTask'; import { useCreateApiFormModal, useDeleteApiFormModal, @@ -327,6 +330,52 @@ export default function SalesOrderLineItemTable({ } }); + const [allocateTaskId, setAllocateTaskId] = useState(''); + + useBackgroundTask({ + taskId: allocateTaskId, + message: t`Allocating stock to sales order`, + successMessage: t`Stock allocation complete`, + onSuccess: () => { + table.refreshTable(); + } + }); + + const [autoAllocateInitialData, setAutoAllocateInitialData] = useState( + {} + ); + + const autoAllocatePreFormContent = useMemo(() => { + const count = table.selectedRecords.length; + if (count > 0) { + return ( + + + {t`${count} line item(s) selected — only these lines will be allocated`} + + + ); + } + return ( + + {t`All unallocated line items will be allocated`} + + ); + }, [table.selectedRecords.length]); + + const autoAllocateStock = useCreateApiFormModal({ + url: ApiEndpoints.sales_order_auto_allocate, + pk: orderId, + title: t`Auto Allocate Stock`, + fields: useSalesOrderAutoAllocateFields({ orderId }), + initialData: autoAllocateInitialData, + preFormContent: autoAllocatePreFormContent, + successMessage: null, + onFormSuccess: (response: any) => { + setAllocateTaskId(response.task_id); + } + }); + const [partsToOrder, setPartsToOrder] = useState([]); const orderPartsWizard = OrderPartsWizard({ @@ -385,9 +434,28 @@ export default function SalesOrderLineItemTable({ ); allocateStock.open(); }} + />, + } + color='blue' + hidden={!editable || !user.hasChangeRole(UserRoles.sales_order)} + onClick={() => { + setAutoAllocateInitialData({ + line_items: table.selectedRecords.map((r) => r.pk) + }); + autoAllocateStock.open(); + }} /> ]; - }, [user, orderId, table.hasSelectedRecords, table.selectedRecords]); + }, [ + editable, + user, + orderId, + table.hasSelectedRecords, + table.selectedRecords + ]); const rowActions = useCallback( (record: any): RowAction[] => { @@ -529,6 +597,7 @@ export default function SalesOrderLineItemTable({ {newBuildOrder.modal} {allocateBySerials.modal} {allocateStock.modal} + {autoAllocateStock.modal} {orderPartsWizard.wizard} { await page.getByRole('button', { name: 'Issue Order' }).waitFor(); }); +test('Sales Orders - Auto Allocate', async ({ browser }) => { + const page = await doCachedLogin(browser, { url: 'sales/sales-order/11/' }); + + // Duplicate the order + await page.getByRole('button', { name: 'action-menu-order-actions' }).click(); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Pending').first().waitFor(); + + await loadTab(page, 'Line Items'); + await page + .getByRole('button', { name: 'action-button-auto-allocate-' }) + .click(); + await page + .getByRole('combobox', { name: 'choice-field-stock_sort_by' }) + .click(); + await page.getByRole('option', { name: 'Newest stock' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + await page.getByText('Stock allocation complete').first().waitFor(); + await page.getByText('10 / 10').first().waitFor(); + + // Cancel the order (to free up the allocated stock) + await page.getByRole('button', { name: 'action-menu-order-actions' }).click(); + await page.getByRole('menuitem', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Cancelled').first().waitFor(); + + // Check that the allocated stock has been released + await page + .getByRole('region', { name: 'Line Items', exact: true }) + .getByLabel('table-refresh') + .click(); + await page.getByText('0 / 10').first().waitFor(); +}); + test('Sales Orders - Shipments', async ({ browser }) => { const page = await doCachedLogin(browser); From 0ee2f033bb60587022e66bf368c093add52aa92f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 27 May 2026 00:41:17 +1000 Subject: [PATCH 2/3] [db] Stock creation date (#12011) * Migrations * Add to serializer * Set the "creation_date" field to auto_now_add * Ordering and filtering * Add unit test * Add "has_stocktake" filter * Add test for data migration * Additional unit tests for StockItem API endpoint * Udpate API and CHANGELOG --- CHANGELOG.md | 1 + .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/build/test_api.py | 6 +- src/backend/InvenTree/order/test_api.py | 5 + src/backend/InvenTree/stock/api.py | 20 ++- .../0120_stockitem_creation_date.py | 23 +++ .../migrations/0121_auto_20260526_0849.py | 102 +++++++++++ .../0122_alter_stockitem_creation_date.py | 23 +++ src/backend/InvenTree/stock/models.py | 12 +- src/backend/InvenTree/stock/serializers.py | 4 +- src/backend/InvenTree/stock/test_api.py | 100 +++++++++++ .../InvenTree/stock/test_migrations.py | 169 ++++++++++++++++++ src/frontend/src/tables/Filter.tsx | 4 +- .../src/tables/stock/StockItemTable.tsx | 41 +++-- 14 files changed, 490 insertions(+), 25 deletions(-) create mode 100644 src/backend/InvenTree/stock/migrations/0120_stockitem_creation_date.py create mode 100644 src/backend/InvenTree/stock/migrations/0121_auto_20260526_0849.py create mode 100644 src/backend/InvenTree/stock/migrations/0122_alter_stockitem_creation_date.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 013e8907b4..45f4a02dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created. - [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules. - [#11920](https://github.com/inventree/InvenTree/pull/11920) adds support for renaming attachments after they have been uploaded. This includes both backend and frontend changes, allowing users to rename attachments via the API or through the UI. - [#11914](https://github.com/inventree/InvenTree/pull/11914) adds a "maximum_stock" field to the Part model, allowing users to specify a maximum preferred stock level for each part. This is used in conjunction with the existing "minimum_stock" field to allow users to define a preferred stock range for each part. The "high_stock" filter has also been added to the Part API endpoint, allowing users to filter parts which are above their maximum stock level. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index dc09802eff..68c7f97f70 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 = 495 +INVENTREE_API_VERSION = 496 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v496 -> 2026-05-26 : https://github.com/inventree/InvenTree/pull/12011 + - Add "creation_date" field to the StockItem API endpoint + v495 -> 2026-05-25 : https://github.com/inventree/InvenTree/pull/12000 - Adds "auto-allocate" API endpoint for sales orders - Allow bulk-delete of SalesOrderAllocation objects via the API diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 32a97e27cd..5ba8824b3a 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -1359,9 +1359,10 @@ class BuildOutputCreateTest(BuildAPITest): # Stock items have increased self.assertEqual(n_items + 5, part.stock_items.count()) - # Serial numbers have been created + # Serial numbers have been created, each with a creation_date for sn in range(1, 6): self.assertTrue(part.stock_items.filter(serial=sn).exists()) + self.assertIsNotNone(part.stock_items.get(serial=sn).creation_date) def test_create_unserialized_output(self): """Create an unserialized build output via the API.""" @@ -1383,6 +1384,9 @@ class BuildOutputCreateTest(BuildAPITest): # Stock items have increased self.assertEqual(n_items + 1, part.stock_items.count()) + # The new output must have a creation_date set + self.assertIsNotNone(part.stock_items.order_by('-pk').first().creation_date) + class BuildOutputScrapTest(BuildAPITest): """Unit tests for scrapping build outputs.""" diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 87be1c8d29..5f686d6b5f 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -1142,6 +1142,10 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(stock_1.last().expiry_date, one_week_from_today) self.assertEqual(stock_2.last().expiry_date, one_week_from_today) + # creation_date must be populated on both received items + self.assertIsNotNone(stock_1.last().creation_date) + self.assertIsNotNone(stock_2.last().creation_date) + # Barcodes should have been assigned to the stock items self.assertTrue( StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists() @@ -1218,6 +1222,7 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(item.serial, str(i)) self.assertEqual(item.quantity, 1) self.assertEqual(item.batch, 'B-abc-123') + self.assertIsNotNone(item.creation_date) # A single stock item (quantity 10) created for the second line item items = StockItem.objects.filter(supplier_part=line_2.part) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index b93b8edf2c..d1eff6b112 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -916,7 +916,14 @@ class StockFilter(FilterSet): | Q(supplier_part__manufacturer_part__manufacturer=company) ).distinct() - # Update date filters + created_before = InvenTreeDateFilter( + label=_('Created before'), field_name='creation_date', lookup_expr='lt' + ) + + created_after = InvenTreeDateFilter( + label=_('Created after'), field_name='creation_date', lookup_expr='gt' + ) + updated_before = InvenTreeDateFilter( label=_('Updated before'), field_name='updated', lookup_expr='lt' ) @@ -933,6 +940,16 @@ class StockFilter(FilterSet): label=_('Stocktake After'), field_name='stocktake_date', lookup_expr='gt' ) + has_stocktake = rest_filters.BooleanFilter( + label=_('Has Stocktake Date'), method='filter_has_stocktake' + ) + + def filter_has_stocktake(self, queryset, name, value): + """Filter by whether or not the StockItem has a stocktake date.""" + if str2bool(value): + return queryset.exclude(stocktake_date=None) + return queryset.filter(stocktake_date=None) + # Stock "expiry" filters expiry_before = InvenTreeDateFilter( label=_('Expiry date before'), field_name='expiry_date', lookup_expr='lt' @@ -1296,6 +1313,7 @@ class StockList( 'part__IPN', 'updated', 'purchase_price', + 'creation_date', 'stocktake_date', 'expiry_date', 'packaging', diff --git a/src/backend/InvenTree/stock/migrations/0120_stockitem_creation_date.py b/src/backend/InvenTree/stock/migrations/0120_stockitem_creation_date.py new file mode 100644 index 0000000000..e4abb47560 --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0120_stockitem_creation_date.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-05-26 08:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("stock", "0119_alter_stockitemtestresult_date"), + ] + + operations = [ + migrations.AddField( + model_name="stockitem", + name="creation_date", + field=models.DateTimeField( + blank=True, + help_text="Date that this stock item was created", + null=True, + verbose_name="Creation Date", + ), + ), + ] diff --git a/src/backend/InvenTree/stock/migrations/0121_auto_20260526_0849.py b/src/backend/InvenTree/stock/migrations/0121_auto_20260526_0849.py new file mode 100644 index 0000000000..d6e5087a8e --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0121_auto_20260526_0849.py @@ -0,0 +1,102 @@ +# Generated by Django 5.2.14 on 2026-05-26 08:49 + +import datetime +from tqdm import tqdm + +from django.db import migrations + + +def set_creation_date(apps, schema_editor): + """Set the creation_date field for existing StockItem entries.""" + + StockItem = apps.get_model('stock', 'StockItem') + StockItemTracking = apps.get_model('stock', 'StockItemTracking') + + # First pass - find all StockHistoryCode.CREATED entries + # Note: As of 2026-05-26, the relevant status codes were: + # CREATED = 1 + + creation_entries = StockItemTracking.objects.filter(tracking_type=1) + + items_to_update = [] + + def process_item(item): + """Process a StockItem entry for update. + + As we are iterating over a potentially large number of StockItem entries, + we periodically write updates to the database in batches to avoid memory issues. + """ + nonlocal items_to_update + + items_to_update.append(item) + + if len(items_to_update) >= 250: + StockItem.objects.bulk_update(items_to_update, ['creation_date']) + items_to_update = [] + + if creation_entries.count() > 0: + progress = tqdm(total=creation_entries.count(), desc='stock.0121: Setting creation_date for StockItem entries') + + + for entry in creation_entries: + item = StockItem.objects.filter(pk=entry.item_id).first() + if item and item.creation_date is None: + item.creation_date = entry.date + process_item(item) + + progress.update(1) + + progress.close() + + # Next: Find any StockItem entries that have a null creation_date + items = StockItem.objects.filter(creation_date__isnull=True) + + if items.count() > 0: + + progress = tqdm(total=items.count(), desc='stock.0121: Setting creation_date for remaining StockItem entries') + + for item in items: + # Source the creation_date from the stock item + earliest_entry = StockItemTracking.objects.filter(item_id=item.pk).order_by('date').first() + + # Gather potential datetime sources for the creation_date field + utc = datetime.timezone.utc + + def make_aware(dt): + """Ensure a datetime is UTC-aware for safe cross-type comparison.""" + return dt.replace(tzinfo=utc) if dt.tzinfo is None else dt.astimezone(utc) + + raw_options = [ + item.updated, + datetime.datetime(item.stocktake_date.year, item.stocktake_date.month, item.stocktake_date.day, tzinfo=utc) if item.stocktake_date else None, + earliest_entry.date if earliest_entry else None, + ] + date_options = [make_aware(d) for d in raw_options if d is not None] + + if date_options: + item.creation_date = min(date_options) + process_item(item) + + progress.update(1) + + # Update the remaining items + StockItem.objects.bulk_update(items_to_update, ['creation_date'], batch_size=250) + + +class Migration(migrations.Migration): + """This migration extracts the 'creation_date' field for each StockItem entry. + + - If a CREATED entry exists in the StockItemTracking table, this is used as the source for the creation_date field. + - If no CREATED entry exists, the creation_date is sourced from the earliest of the following + - The 'updated' field of the StockItem entry + - The 'stocktake_date' field of the StockItem entry (converted to a datetime) + - The earliest entry in the StockItemTracking table for that item + """ + + dependencies = [ + ("stock", "0120_stockitem_creation_date"), + ] + + operations = [ + migrations.RunPython(set_creation_date, reverse_code=migrations.RunPython.noop), + ] diff --git a/src/backend/InvenTree/stock/migrations/0122_alter_stockitem_creation_date.py b/src/backend/InvenTree/stock/migrations/0122_alter_stockitem_creation_date.py new file mode 100644 index 0000000000..c26c17b8b1 --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0122_alter_stockitem_creation_date.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-05-26 09:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("stock", "0121_auto_20260526_0849"), + ] + + operations = [ + migrations.AlterField( + model_name="stockitem", + name="creation_date", + field=models.DateTimeField( + auto_now_add=True, + help_text="Date that this stock item was created", + null=True, + verbose_name="Creation Date", + ), + ), + ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 0f4d10536c..a89a94d26d 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -443,7 +443,8 @@ class StockItem( batch: Batch number for this StockItem serial: Unique serial number for this StockItem link: Optional URL to link to external resource - updated: Date that this stock item was last updated (auto) + creation_date: Date that this stock item was created (auto) + updated: Date that the quantity of this stock item was last updated (auto) expiry_date: Expiry date of the StockItem (optional) stocktake_date: Date of last stocktake for this item stocktake_user: User that performed the most recent stocktake @@ -1227,6 +1228,15 @@ class StockItem( related_name='stocktake_stock', ) + creation_date = models.DateTimeField( + null=True, + blank=True, + auto_now_add=True, + editable=False, + verbose_name=_('Creation Date'), + help_text=_('Date that this stock item was created'), + ) + review_needed = models.BooleanField(default=False) delete_on_deplete = models.BooleanField( diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 570b4f7e51..0bb12450a8 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -371,8 +371,9 @@ class StockItemSerializer( 'SKU', 'MPN', 'barcode_hash', - 'updated', + 'creation_date', 'stocktake_date', + 'updated', 'purchase_price', 'purchase_price_currency', 'use_pack_size', @@ -395,6 +396,7 @@ class StockItemSerializer( read_only_fields = [ 'allocated', 'barcode_hash', + 'creation_date', 'stocktake_date', 'stocktake_user', 'updated', diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index d274fffafa..27de523ec8 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -545,6 +545,62 @@ class StockItemListTest(StockAPITestCase): for ordering in ['part', 'location', 'stock', 'status', 'IPN', 'MPN', 'SKU']: self.run_ordering_test(self.list_url, ordering) + def test_creation_date_filter_and_ordering(self): + """Test created_before / created_after filters and ordering by creation_date.""" + import datetime + + part = Part.objects.first() + location = StockLocation.objects.first() + + # Create items with known, spread-out creation_dates via UPDATE after insert + dates = [ + datetime.date(2020, 1, 1), + datetime.date(2021, 6, 15), + datetime.date(2023, 3, 30), + ] + pks = [] + for d in dates: + item = StockItem.objects.create(part=part, location=location, quantity=1) + StockItem.objects.filter(pk=item.pk).update(creation_date=d) + pks.append(item.pk) + + # created_after=2020-12-31 should exclude the 2020 item + result_pks = [r['pk'] for r in self.get_stock(created_after='2020-12-31')] + self.assertNotIn(pks[0], result_pks) + self.assertIn(pks[1], result_pks) + self.assertIn(pks[2], result_pks) + + # created_before=2022-01-01 should exclude the 2023 item + result_pks = [r['pk'] for r in self.get_stock(created_before='2022-01-01')] + self.assertIn(pks[0], result_pks) + self.assertIn(pks[1], result_pks) + self.assertNotIn(pks[2], result_pks) + + # combined: only the 2021 item falls in the window + result_pks = [ + r['pk'] + for r in self.get_stock( + created_after='2020-12-31', created_before='2022-01-01' + ) + ] + self.assertNotIn(pks[0], result_pks) + self.assertIn(pks[1], result_pks) + self.assertNotIn(pks[2], result_pks) + + # ordering=creation_date: our three items must appear in ascending date order + results = self.get( + self.list_url, {'ordering': 'creation_date'}, expected_code=200 + ).data + ordered_pks = [r['pk'] for r in results if r['pk'] in pks] + self.assertEqual(ordered_pks, pks) + + # ordering=-creation_date: descending + results = self.get( + self.list_url, {'ordering': '-creation_date'}, expected_code=200 + ).data + ordered_pks = [r['pk'] for r in results if r['pk'] in pks] + self.assertEqual(ordered_pks, list(reversed(pks))) + def test_pagination(self): """Test that pagination boundaries are observed correctly. @@ -1454,6 +1510,49 @@ class StockItemTest(StockAPITestCase): data={'part': 1, 'location': 1, 'quantity': 10}, expected_code=201, ) + # creation_date must be populated on the newly created item + item = StockItem.objects.get(pk=response.data[0]['pk']) + self.assertIsNotNone(item.creation_date) + + def test_creation_date_is_readonly(self): + """creation_date must not be modifiable via the API.""" + item = StockItem.objects.create( + part=Part.objects.get(pk=1), + location=StockLocation.objects.get(pk=1), + quantity=1, + ) + original_date = item.creation_date + self.assertIsNotNone(original_date) + + url = reverse('api-stock-detail', kwargs={'pk': item.pk}) + self.patch( + url, data={'creation_date': '2000-01-01T00:00:00Z'}, expected_code=200 + ) + # Field is read-only; the DB value must be unchanged + item.refresh_from_db() + self.assertEqual(item.creation_date, original_date) + + def test_creation_date_set_on_serialize(self): + """creation_date must be set on items produced by the serialize endpoint.""" + # Stock item 100: part 25 (trackable), quantity 10, location 7 + item = StockItem.objects.get(pk=100) + url = reverse('api-stock-item-serialize', kwargs={'pk': item.pk}) + + self.post( + url, + data={ + 'quantity': 3, + 'serial_numbers': '901,902,903', + 'destination': item.location.pk, + }, + expected_code=201, + ) + new_items = StockItem.objects.filter( + part=item.part, serial__in=['901', '902', '903'] + ) + self.assertEqual(new_items.count(), 3) + for new_item in new_items: + self.assertIsNotNone(new_item.creation_date) def test_stock_item_create_with_supplier_part(self): """Test creation of a StockItem via the API, including SupplierPart data.""" @@ -1597,6 +1696,7 @@ class StockItemTest(StockAPITestCase): # Item location should have been set automatically self.assertIsNotNone(item.location) self.assertIn(item.serial, serials) + self.assertIsNotNone(item.creation_date) # There now should be 10 unique stock entries for this part self.assertEqual(trackable_part.stock_entries().count(), 10) diff --git a/src/backend/InvenTree/stock/test_migrations.py b/src/backend/InvenTree/stock/test_migrations.py index 7901239951..c26b881f42 100644 --- a/src/backend/InvenTree/stock/test_migrations.py +++ b/src/backend/InvenTree/stock/test_migrations.py @@ -415,3 +415,172 @@ class TestStockItemTrackingMigration(MigratorTestCase): ) self.assertIn('salesorder', item.deltas) self.assertEqual(item.deltas['salesorder'], 1) + + +class TestCreationDateMigration(MigratorTestCase): + """Test the backfill data migration for StockItem.creation_date (stock.0121). + + The migration has two passes: + - Pass 1: items with a CREATED (tracking_type=1) entry get creation_date from that entry. + - Pass 2: remaining nulls get min(updated, stocktake_date, earliest_tracking_entry). + + Six scenarios are exercised to cover every meaningful code path. + """ + + migrate_from = ('stock', '0119_alter_stockitemtestresult_date') + migrate_to = ('stock', '0122_alter_stockitem_creation_date') + + def prepare(self): + """Create StockItem entries with varied data to exercise all backfill paths.""" + import datetime + + from django.db import connection + + Part = self.old_state.apps.get_model('part', 'part') + StockItemTracking = self.old_state.apps.get_model('stock', 'stockitemtracking') + + utc = datetime.timezone.utc + + part = Part.objects.create( + name='Migration Test Part', level=0, tree_id=1, lft=0, rght=0 + ) + + def make_item(stocktake_date=None): + """Insert a StockItem row via raw SQL, bypassing the duplicate-column ORM bug. + + The historical model at migration 0119 has status_custom_key enumerated twice + (once from contribute_to_class on the status field, once from migration 0113's + explicit AddField), so ORM-generated INSERTs fail with + 'column specified more than once'. Raw SQL avoids the ORM field list entirely. + Raw SQL also leaves updated=NULL (no DB-level default for auto_now), which + makes Scenario 6 a clean "no date sources available" case. + """ + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO stock_stockitem + (part_id, quantity, level, tree_id, lft, rght, + status, delete_on_deplete, review_needed, is_building, + link, serial_int, barcode_data, barcode_hash) + VALUES (%s, 1, 0, 0, 0, 0, 10, false, false, false, '', 0, '', '') + RETURNING id + """, + [part.pk], + ) + pk = cursor.fetchone()[0] + if stocktake_date is not None: + cursor.execute( + 'UPDATE stock_stockitem SET stocktake_date = %s WHERE id = %s', + [stocktake_date, pk], + ) + return pk + + def add_tracking(pk, tracking_type, date): + """Create a tracking entry, then override its auto_now_add date.""" + entry = StockItemTracking.objects.create( + item_id=pk, tracking_type=tracking_type + ) + # auto_now_add prevents setting date on INSERT; use UPDATE to set a historical value + StockItemTracking.objects.filter(pk=entry.pk).update(date=date) + + # --- Scenario 1 --- + # Item with a single CREATED (type=1) tracking entry. + # Pass 1 should set creation_date = that entry's date. + pk = make_item() + add_tracking(pk, 1, datetime.datetime(2022, 1, 15, 10, 0, 0, tzinfo=utc)) + self.pk_s1 = pk + self.expected_s1 = datetime.datetime(2022, 1, 15, 10, 0, 0, tzinfo=utc) + + # --- Scenario 2 --- + # Item with a CREATED entry (newer date) AND an older non-CREATED entry. + # Pass 1 sets creation_date = CREATED entry date; older entry is ignored. + # This verifies pass 1 wins over pass 2's min() logic. + pk = make_item() + add_tracking( + pk, 1, datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=utc) + ) # CREATED, newer + add_tracking( + pk, 2, datetime.datetime(2018, 3, 10, 0, 0, 0, tzinfo=utc) + ) # non-CREATED, older + self.pk_s2 = pk + self.expected_s2 = datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=utc) + self.rejected_s2 = datetime.date(2018, 3, 10) + + # --- Scenario 3 --- + # Item with only non-CREATED tracking entries. + # Pass 2 uses min(earliest_entry_date), so earliest entry wins. + pk = make_item() + add_tracking( + pk, 2, datetime.datetime(2021, 7, 20, 0, 0, 0, tzinfo=utc) + ) # later + add_tracking( + pk, 3, datetime.datetime(2020, 2, 14, 0, 0, 0, tzinfo=utc) + ) # earliest + self.pk_s3 = pk + self.expected_s3 = datetime.datetime(2020, 2, 14, 0, 0, 0, tzinfo=utc) + + # --- Scenario 4 --- + # Item with only stocktake_date set; no tracking entries. + # Pass 2 uses stocktake_as_datetime (in the past) as the date. + pk = make_item(stocktake_date=datetime.date(2019, 11, 5)) + self.pk_s4 = pk + self.expected_s4_date = datetime.date(2019, 11, 5) + + # --- Scenario 5 --- + # Item with stocktake_date AND a non-CREATED tracking entry where the tracking + # entry is older than stocktake_date. + # Pass 2 uses min(stocktake, tracking); tracking wins. + pk = make_item(stocktake_date=datetime.date(2021, 4, 1)) + add_tracking(pk, 2, datetime.datetime(2017, 8, 22, 0, 0, 0, tzinfo=utc)) + self.pk_s5 = pk + self.expected_s5 = datetime.datetime(2017, 8, 22, 0, 0, 0, tzinfo=utc) + + # --- Scenario 6 --- + # Item inserted via raw SQL with no stocktake_date and no tracking entries, + # so updated=NULL. No date sources exist → creation_date stays NULL after migration. + pk = make_item() + self.pk_s6 = pk + + def test_migration(self): + """Verify creation_date is correctly backfilled for each scenario.""" + import datetime + + StockItem = self.new_state.apps.get_model('stock', 'stockitem') + utc = datetime.timezone.utc + + def at_utc(dt): + """Normalise to UTC and strip sub-second precision for comparison.""" + return dt.astimezone(utc).replace(microsecond=0) + + # Scenario 1: CREATED tracking entry → creation_date = entry date + item = StockItem.objects.get(pk=self.pk_s1) + self.assertIsNotNone(item.creation_date) + self.assertEqual(at_utc(item.creation_date), self.expected_s1) + + # Scenario 2: CREATED entry (newer) wins over older non-CREATED entry + item = StockItem.objects.get(pk=self.pk_s2) + self.assertIsNotNone(item.creation_date) + self.assertEqual(at_utc(item.creation_date), self.expected_s2) + # Explicitly confirm the older non-CREATED date was NOT chosen + self.assertNotEqual(item.creation_date.astimezone(utc).date(), self.rejected_s2) + + # Scenario 3: Earliest non-CREATED tracking entry wins (pass 2 min()) + item = StockItem.objects.get(pk=self.pk_s3) + self.assertIsNotNone(item.creation_date) + self.assertEqual(at_utc(item.creation_date), self.expected_s3) + + # Scenario 4: stocktake_date (past) wins over auto_now 'updated' (now) + item = StockItem.objects.get(pk=self.pk_s4) + self.assertIsNotNone(item.creation_date) + self.assertEqual( + item.creation_date.astimezone(utc).date(), self.expected_s4_date + ) + + # Scenario 5: Oldest tracking entry wins over stocktake_date (pass 2 min()) + item = StockItem.objects.get(pk=self.pk_s5) + self.assertIsNotNone(item.creation_date) + self.assertEqual(at_utc(item.creation_date), self.expected_s5) + + # Scenario 6: updated=NULL, no stocktake, no tracking → creation_date stays NULL + item = StockItem.objects.get(pk=self.pk_s6) + self.assertIsNone(item.creation_date) diff --git a/src/frontend/src/tables/Filter.tsx b/src/frontend/src/tables/Filter.tsx index a7d547fdc9..81221bd2d2 100644 --- a/src/frontend/src/tables/Filter.tsx +++ b/src/frontend/src/tables/Filter.tsx @@ -308,7 +308,7 @@ export function UpdatedAfterFilter(): TableFilter { return { name: 'updated_after', label: t`Updated After`, - description: t`Show orders updated after this date`, + description: t`Show items updated after this date`, type: 'date' }; } @@ -317,7 +317,7 @@ export function UpdatedBeforeFilter(): TableFilter { return { name: 'updated_before', label: t`Updated Before`, - description: t`Show orders updated before this date`, + description: t`Show items updated before this date`, type: 'date' }; } diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 2f65c74edb..8e9f9c01da 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -32,6 +32,8 @@ import { } from '../ColumnRenderers'; import { BatchFilter, + CreatedAfterFilter, + CreatedBeforeFilter, HasBatchCodeFilter, InStockFilter, IncludeVariantsFilter, @@ -41,7 +43,9 @@ import { SerialGTEFilter, SerialLTEFilter, StatusFilterOptions, - SupplierFilter + SupplierFilter, + UpdatedAfterFilter, + UpdatedBeforeFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; @@ -143,17 +147,21 @@ function stockItemTableColumns({ sortable: true, defaultVisible: false }, - + DateColumn({ + title: t`Created`, + accessor: 'creation_date', + sortable: true + }), + DateColumn({ + title: t`Last Updated`, + accessor: 'updated' + }), DateColumn({ title: t`Expiry Date`, accessor: 'expiry_date', hidden: !useGlobalSettingsState.getState().isSet('STOCK_ENABLE_EXPIRY'), defaultVisible: false }), - DateColumn({ - title: t`Last Updated`, - accessor: 'updated' - }), DateColumn({ accessor: 'stocktake_date', title: t`Stocktake Date`, @@ -273,18 +281,10 @@ function stockItemTableFilters({ type: 'date', active: enableExpiry }, - { - name: 'updated_before', - label: t`Updated Before`, - description: t`Show items updated before this date`, - type: 'date' - }, - { - name: 'updated_after', - label: t`Updated After`, - description: t`Show items updated after this date`, - type: 'date' - }, + UpdatedBeforeFilter(), + UpdatedAfterFilter(), + CreatedBeforeFilter(), + CreatedAfterFilter(), { name: 'stocktake_before', label: t`Stocktake Before`, @@ -297,6 +297,11 @@ function stockItemTableFilters({ description: t`Show items counted after this date`, type: 'date' }, + { + name: 'has_stocktake', + label: t`Has Stocktake Date`, + description: t`Show items which have a stocktake date` + }, { name: 'external', label: t`External Location`, From bc03f349085582e99606b94ff9c4cded905aeb1b Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 27 May 2026 01:00:36 +1000 Subject: [PATCH 3/3] Additional spotlight actions (#12012) * Additional spotlight actions * Fix memo --- src/frontend/src/defaults/actions.tsx | 34 +++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/defaults/actions.tsx b/src/frontend/src/defaults/actions.tsx index d61c6c41c1..36e7df4402 100644 --- a/src/frontend/src/defaults/actions.tsx +++ b/src/frontend/src/defaults/actions.tsx @@ -5,14 +5,16 @@ import { IconLink, IconPlug, IconPointer, + IconReport, IconSettings, + IconTags, IconUserBolt, IconUserCog } from '@tabler/icons-react'; import type { NavigateFunction } from 'react-router-dom'; import { ModelInformationDict } from '@lib/enums/ModelInformation'; -import { UserRoles } from '@lib/index'; +import { ModelType, UserRoles } from '@lib/index'; import { openContextModal } from '@mantine/modals'; import { useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; @@ -36,6 +38,8 @@ export function getActions(navigate: NavigateFunction) { const user = useUserState(); const actions: SpotlightActionData[] = useMemo(() => { + const staff = user?.isStaff() ?? false; + const _actions: SpotlightActionData[] = [ { id: 'dashboard', @@ -90,7 +94,7 @@ export function getActions(navigate: NavigateFunction) { } ]; - user?.isStaff() && + staff && _actions.push({ id: 'data-import', label: t`Import Data`, @@ -149,7 +153,7 @@ export function getActions(navigate: NavigateFunction) { leftSection: }); - user?.isStaff() && + staff && _actions.push({ id: 'system-settings', label: t`System Settings`, @@ -158,7 +162,7 @@ export function getActions(navigate: NavigateFunction) { leftSection: }); - user?.isStaff() && + staff && _actions.push({ id: 'admin-center', label: t`Admin Center`, @@ -167,7 +171,7 @@ export function getActions(navigate: NavigateFunction) { leftSection: }); - user?.isStaff() && + staff && _actions.push({ id: 'plugin-settings', label: t`Plugins`, @@ -176,6 +180,26 @@ export function getActions(navigate: NavigateFunction) { leftSection: }); + staff && + user?.hasViewPermission(ModelType.reporttemplate) && + _actions.push({ + id: 'report-templates', + label: t`Report Templates`, + description: t`Manage report templates`, + onClick: () => navigate('/settings/admin/reports'), + leftSection: + }); + + staff && + user?.hasViewPermission(ModelType.labeltemplate) && + _actions.push({ + id: 'label-templates', + label: t`Label Templates`, + description: t`Manage label templates`, + onClick: () => navigate('/settings/admin/labels'), + leftSection: + }); + return _actions; }, [navigate, setNavigationOpen, globalSettings, user]);