diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2e3cd3fa4..45f4a02dd5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ 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.
- [#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..68c7f97f70 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,11 +1,19 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 494
+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
+ - 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 8767884d58..df704043f9 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 fccfa62d9c..302eefb27e 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
@@ -1062,6 +1063,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..5ba8824b3a 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
@@ -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."""
@@ -1811,3 +1815,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 adcf57282a..f7f956a7cc 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 07f3ff955a..68d13c9e45 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -1983,6 +1983,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..5f686d6b5f 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
@@ -1141,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()
@@ -1217,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)
@@ -3740,3 +3746,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/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 350fd86ec2..f8f570e049 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,
@@ -421,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
@@ -1205,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 952bd6b6b7..6cfab5f186 100644
--- a/src/backend/InvenTree/stock/serializers.py
+++ b/src/backend/InvenTree/stock/serializers.py
@@ -370,8 +370,9 @@ class StockItemSerializer(
'SKU',
'MPN',
'barcode_hash',
- 'updated',
+ 'creation_date',
'stocktake_date',
+ 'updated',
'purchase_price',
'purchase_price_currency',
'use_pack_size',
@@ -394,6 +395,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/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 ae339859ef..267f1ce077 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/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]);
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/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/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);