2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-28 11:59:23 +00:00

[Feature] SalesOrder Auto-Allocate (#12000)

* Add basic auto-allocate functionality

- backend code
- background task
- API endpoint

* Add new endpoint enum

* add frontend components

* Tweak auto-allocate output

* Allow specifying of individual line items

* Tweak error boundary

* Enable bulk-delete of allocated items against sales order

* Refactor stock sorting options

* Allow user to select how to handle serialized stock

* Backport new functionality to BuildOrder allocation

* Refactor sorting options to use enumerated values

* Implement functional unit tests for new feature

* Update API and CHANGELOG

* Additional unit test

* Add playwright testing

* Documentation

* Update docs for build auto-allocate

* Fix dependencies

* Adjust build line filtering

* Fix serializer
This commit is contained in:
Oliver
2026-05-26 23:21:06 +10:00
committed by GitHub
parent 06680758c3
commit 540eb84796
23 changed files with 1468 additions and 22 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
- [#11920](https://github.com/inventree/InvenTree/pull/11920) adds support for renaming attachments after they have been uploaded. This includes both backend and frontend changes, allowing users to rename attachments via the API or through the UI. - [#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. - [#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. - [#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.
+41 -2
View File
@@ -80,7 +80,16 @@ The *Deallocate Stock* button can be used to remove all allocations of untracked
## Automatic Stock Allocation ## 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: 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. 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** **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. 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" !!! 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" !!! 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. 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. 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 ## 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*. 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*.
+73
View File
@@ -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. 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 ### 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: 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:
@@ -1,11 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 494 INVENTREE_API_VERSION = 495
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
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 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 - Offload build output operations to a background task, and return a task ID which can be used to monitor the progress of the task
+4
View File
@@ -886,6 +886,8 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
build_lines = data.get('build_lines', [])
# Offload the task to the background worker # Offload the task to the background worker
task_id = offload_task( task_id = offload_task(
auto_allocate_build, auto_allocate_build,
@@ -896,6 +898,8 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
substitutes=data['substitutes'], substitutes=data['substitutes'],
optional_items=data['optional_items'], optional_items=data['optional_items'],
item_type=data.get('item_type', 'untracked'), 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', group='build',
) )
+17 -2
View File
@@ -1342,6 +1342,8 @@ class Build(
interchangeable = kwargs.get('interchangeable', False) interchangeable = kwargs.get('interchangeable', False)
substitutes = kwargs.get('substitutes', True) substitutes = kwargs.get('substitutes', True)
optional_items = kwargs.get('optional_items', False) 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): def stock_sort(item, bom_item, variant_parts):
if item.part == bom_item.sub_part: if item.part == bom_item.sub_part:
@@ -1353,7 +1355,11 @@ class Build(
new_items = [] new_items = []
# Select only "untracked" line 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 # Find the referenced BomItem
bom_item = line_item.bom_item bom_item = line_item.bom_item
@@ -1410,13 +1416,22 @@ class Build(
location__in=list(sublocations) 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: Next, we sort the available stock items with the following priority:
1. Direct part matches (+1) 1. Direct part matches (+1)
2. Variant part matches (+2) 2. Variant part matches (+2)
3. Substitute part matches (+3) 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 = sorted(
available_stock, available_stock,
@@ -27,6 +27,7 @@ import company.serializers
import InvenTree.helpers import InvenTree.helpers
import part.filters import part.filters
import part.serializers as part_serializers import part.serializers as part_serializers
import stock.models as stock_models
from common.settings import get_global_setting from common.settings import get_global_setting
from generic.states.fields import InvenTreeCustomStatusSerializerMixin from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
@@ -1065,6 +1066,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
help_text=_('Select item type to auto-allocate'), 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( class BuildItemSerializer(
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
+160 -1
View File
@@ -12,7 +12,7 @@ from build.status_codes import BuildStatus
from common.settings import set_global_setting from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import BomItem, Part from part.models import BomItem, Part
from stock.models import StockItem from stock.models import StockItem, StockLocation, StockSortOrder
from stock.status_codes import StockStatus from stock.status_codes import StockStatus
@@ -1811,3 +1811,162 @@ class BuildConsumeTest(BuildAPITest):
for line in self.build.build_lines.all(): for line in self.build.build_lines.all():
self.assertEqual(line.consumed, 100) 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())
+59 -1
View File
@@ -31,6 +31,7 @@ import stock.serializers as stock_serializers
from data_exporter.mixins import DataExportViewMixin from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView from generic.states.api import StatusView
from InvenTree.api import ( from InvenTree.api import (
BulkDeleteMixin,
BulkUpdateMixin, BulkUpdateMixin,
ListCreateDestroyAPIView, ListCreateDestroyAPIView,
ParameterListMixin, ParameterListMixin,
@@ -1170,6 +1171,50 @@ class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI):
serializer_class = serializers.SalesOrderShipmentAllocationSerializer 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): class SalesOrderAllocationFilter(FilterSet):
"""Custom filterset for the SalesOrderAllocationList endpoint.""" """Custom filterset for the SalesOrderAllocationList endpoint."""
@@ -1300,7 +1345,11 @@ class SalesOrderAllocationOutputOptions(OutputConfiguration):
class SalesOrderAllocationList( class SalesOrderAllocationList(
SalesOrderAllocationMixin, BulkUpdateMixin, OutputOptionsMixin, ListAPI SalesOrderAllocationMixin,
BulkDeleteMixin,
BulkUpdateMixin,
OutputOptionsMixin,
ListAPI,
): ):
"""API endpoint for listing SalesOrderAllocation objects.""" """API endpoint for listing SalesOrderAllocation objects."""
@@ -1308,6 +1357,10 @@ class SalesOrderAllocationList(
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
output_options = SalesOrderAllocationOutputOptions 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 = [ ordering_fields = [
'quantity', 'quantity',
'part', 'part',
@@ -2582,6 +2635,11 @@ order_api_urls = [
SalesOrderAllocateSerials.as_view(), SalesOrderAllocateSerials.as_view(),
name='api-so-allocate-serials', 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('hold/', SalesOrderHold.as_view(), name='api-so-hold'),
path('cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'), path('cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
path('issue/', SalesOrderIssue.as_view(), name='api-so-issue'), path('issue/', SalesOrderIssue.as_view(), name='api-so-issue'),
+136
View File
@@ -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): class SalesOrder(TotalPriceMixin, Order):
"""A SalesOrder represents a list of goods shipped outwards to a customer.""" """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 true if any lines in the order are over-allocated."""
return any(line.is_overallocated() for line in self.lines.all()) 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: def is_completed(self) -> bool:
"""Check if this order is "shipped" (all line items delivered). """Check if this order is "shipped" (all line items delivered).
+107
View File
@@ -1990,6 +1990,113 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
allocation.save() 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() @register_importer()
class SalesOrderExtraLineSerializer( class SalesOrderExtraLineSerializer(
AbstractExtraLineSerializer, InvenTreeModelSerializer AbstractExtraLineSerializer, InvenTreeModelSerializer
+36
View File
@@ -14,6 +14,7 @@ from opentelemetry import trace
import common.notifications import common.notifications
import InvenTree.helpers_model import InvenTree.helpers_model
import order.models import order.models
import stock.models as stock_models
from InvenTree.tasks import ScheduledTask, scheduled_task from InvenTree.tasks import ScheduledTask, scheduled_task
from order.events import PurchaseOrderEvents, SalesOrderEvents from order.events import PurchaseOrderEvents, SalesOrderEvents
from order.status_codes import ( 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 signalling that the shipment has been completed
trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=shipment.pk) 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,
)
+376 -1
View File
@@ -21,6 +21,7 @@ from common.settings import set_global_setting
from company.models import Company, SupplierPart, SupplierPriceBreak from company.models import Company, SupplierPart, SupplierPriceBreak
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from order import models from order import models
from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment
from order.status_codes import ( from order.status_codes import (
PurchaseOrderStatus, PurchaseOrderStatus,
ReturnOrderLineStatus, ReturnOrderLineStatus,
@@ -31,7 +32,7 @@ from order.status_codes import (
TransferOrderStatusGroups, TransferOrderStatusGroups,
) )
from part.models import Part 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 stock.status_codes import StockStatus
from users.models import Owner from users.models import Owner
@@ -3740,3 +3741,377 @@ class TransferOrderAllocateTest(OrderTest):
['part_detail', 'item_detail', 'order_detail', 'location_detail'], ['part_detail', 'item_detail', 'order_detail', 'location_detail'],
assert_subset=True, 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
)
+230 -1
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Sum
import order.tasks import order.tasks
from common.models import InvenTreeSetting, NotificationMessage from common.models import InvenTreeSetting, NotificationMessage
@@ -20,7 +21,7 @@ from order.models import (
SalesOrderShipment, SalesOrderShipment,
) )
from part.models import Part from part.models import Part
from stock.models import StockItem from stock.models import StockItem, StockLocation
from users.models import Owner from users.models import Owner
@@ -619,3 +620,231 @@ class SalesOrderTest(InvenTreeTestCase):
# Ensure that virtual line item quantity values have been updated # Ensure that virtual line item quantity values have been updated
for line in so.lines.all(): for line in so.lines.all():
self.assertEqual(line.shipped, line.quantity) 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)
+22
View File
@@ -38,6 +38,7 @@ import stock.tasks
from common.icons import validate_icon from common.icons import validate_icon
from common.settings import get_global_setting from common.settings import get_global_setting
from company import models as CompanyModels from company import models as CompanyModels
from generic.enums import StringEnum
from generic.states import StatusCodeMixin from generic.states import StatusCodeMixin
from generic.states.fields import InvenTreeCustomStatusModelField from generic.states.fields import InvenTreeCustomStatusModelField
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
@@ -399,6 +400,27 @@ class StockItemReportContext(report.mixins.BaseReportContext):
test_templates: dict[str, PartModels.PartTestTemplate] 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( class StockItem(
InvenTree.models.PluginValidationMixin, InvenTree.models.PluginValidationMixin,
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
+7 -3
View File
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro'; 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 { ErrorBoundary, type FallbackRender } from '@sentry/react';
import { IconExclamationCircle } from '@tabler/icons-react'; import { IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback } from 'react'; import { type ReactNode, useCallback } from 'react';
@@ -14,8 +14,12 @@ export function DefaultFallback({
title={`INVE-E17: ${t`Error rendering component`}: ${title}`} title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
> >
<Stack gap='xs'> <Stack gap='xs'>
{t`An error occurred while rendering this component. Refer to the console for more information.`} <Text size='sm'>
{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.`}
</Text>
<Text size='sm'>
{t`Try reloading the page, or contact your administrator if the problem persists.`}
</Text>
</Stack> </Stack>
</Alert> </Alert>
); );
+1
View File
@@ -182,6 +182,7 @@ export enum ApiEndpoints {
sales_order_complete = 'order/so/:id/complete/', sales_order_complete = 'order/so/:id/complete/',
sales_order_allocate = 'order/so/:id/allocate/', sales_order_allocate = 'order/so/:id/allocate/',
sales_order_allocate_serials = 'order/so/:id/allocate-serials/', 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_line_list = 'order/so-line/',
sales_order_extra_line_list = 'order/so-extra-line/', sales_order_extra_line_list = 'order/so-extra-line/',
+4
View File
@@ -239,6 +239,10 @@ export function useBuildAutoAllocateFields({
optional_items: { optional_items: {
hidden: item_type === 'tracked', hidden: item_type === 'tracked',
value: item_type === 'tracked' ? false : undefined value: item_type === 'tracked' ? false : undefined
},
stock_sort_by: {},
build_lines: {
hidden: true
} }
}; };
}, [item_type]); }, [item_type]);
@@ -584,3 +584,28 @@ export function useSalesOrderAllocationFields({
}; };
}, [orderId, shipment]); }, [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]);
}
@@ -574,6 +574,9 @@ export default function BuildLineTable({
}); });
const [allocateTaskId, setAllocateTaskId] = useState<string>(''); const [allocateTaskId, setAllocateTaskId] = useState<string>('');
const [autoAllocateInitialData, setAutoAllocateInitialData] = useState<
Record<string, any>
>({});
useBackgroundTask({ useBackgroundTask({
taskId: allocateTaskId, taskId: allocateTaskId,
@@ -584,6 +587,24 @@ export default function BuildLineTable({
} }
}); });
const autoAllocatePreFormContent = useMemo(() => {
const n = table.selectedRecords.length;
if (n > 0) {
return (
<Alert color='blue' title={t`Auto Allocate Stock`}>
<Text>
{t`Auto-allocating stock for`} {n} {t`selected line item(s)`}
</Text>
</Alert>
);
}
return (
<Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate untracked BOM items to this build according to the selected options`}</Text>
</Alert>
);
}, [table.selectedRecords]);
const autoAllocateStock = useCreateApiFormModal({ const autoAllocateStock = useCreateApiFormModal({
url: ApiEndpoints.build_order_auto_allocate, url: ApiEndpoints.build_order_auto_allocate,
pk: build.pk, pk: build.pk,
@@ -595,17 +616,18 @@ export default function BuildLineTable({
location: build.take_from, location: build.take_from,
interchangeable: true, interchangeable: true,
substitutes: true, substitutes: true,
optional_items: false optional_items: false,
...autoAllocateInitialData
}, },
successMessage: null, successMessage: null,
onFormSuccess: (response: any) => { onFormSuccess: (response: any) => {
setAllocateTaskId(response.task_id); if (response.task_id) {
setAllocateTaskId(response.task_id);
} else {
table.refreshTable();
}
}, },
preFormContent: ( preFormContent: autoAllocatePreFormContent
<Alert color='green' title={t`Auto Allocate Stock`}>
<Text>{t`Automatically allocate untracked BOM items to this build according to the selected options`}</Text>
</Alert>
)
}); });
const allocateStock = useAllocateStockToBuildForm({ const allocateStock = useAllocateStockToBuildForm({
@@ -835,6 +857,9 @@ export default function BuildLineTable({
hidden={!visible || hasOutput} hidden={!visible || hasOutput}
color='blue' color='blue'
onClick={() => { onClick={() => {
setAutoAllocateInitialData({
build_lines: table.selectedRecords.map((r) => r.pk)
});
autoAllocateStock.open(); autoAllocateStock.open();
}} }}
/>, />,
@@ -377,6 +377,10 @@ export default function SalesOrderAllocationTable({
enableFilters: !isSubTable, enableFilters: !isSubTable,
enableDownload: !isSubTable, enableDownload: !isSubTable,
enableSelection: !isSubTable, enableSelection: !isSubTable,
enableBulkDelete:
!isSubTable &&
allowEdit &&
user.hasDeleteRole(UserRoles.sales_order),
minHeight: isSubTable ? 100 : undefined, minHeight: isSubTable ? 100 : undefined,
rowActions: rowActions, rowActions: rowActions,
tableActions: isSubTable ? undefined : tableActions, tableActions: isSubTable ? undefined : tableActions,
@@ -1,11 +1,12 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Group, Paper, Text } from '@mantine/core'; import { Alert, Group, Paper, Text } from '@mantine/core';
import { import {
IconArrowRight, IconArrowRight,
IconHash, IconHash,
IconShoppingCart, IconShoppingCart,
IconSquareArrowRight, IconSquareArrowRight,
IconTools IconTools,
IconWand
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import type { DataTableRowExpansionProps } from 'mantine-datatable'; import type { DataTableRowExpansionProps } from 'mantine-datatable';
import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { type ReactNode, useCallback, useMemo, useState } from 'react';
@@ -35,8 +36,10 @@ import { useBuildOrderFields } from '../../forms/BuildForms';
import { import {
useAllocateToSalesOrderForm, useAllocateToSalesOrderForm,
useSalesOrderAllocateSerialsFields, useSalesOrderAllocateSerialsFields,
useSalesOrderAutoAllocateFields,
useSalesOrderLineItemFields useSalesOrderLineItemFields
} from '../../forms/SalesOrderForms'; } from '../../forms/SalesOrderForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
@@ -327,6 +330,52 @@ export default function SalesOrderLineItemTable({
} }
}); });
const [allocateTaskId, setAllocateTaskId] = useState<string>('');
useBackgroundTask({
taskId: allocateTaskId,
message: t`Allocating stock to sales order`,
successMessage: t`Stock allocation complete`,
onSuccess: () => {
table.refreshTable();
}
});
const [autoAllocateInitialData, setAutoAllocateInitialData] = useState<any>(
{}
);
const autoAllocatePreFormContent = useMemo(() => {
const count = table.selectedRecords.length;
if (count > 0) {
return (
<Alert color='blue'>
<Text size='sm'>
{t`${count} line item(s) selected — only these lines will be allocated`}
</Text>
</Alert>
);
}
return (
<Alert color='green'>
<Text size='sm'>{t`All unallocated line items will be allocated`}</Text>
</Alert>
);
}, [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<any[]>([]); const [partsToOrder, setPartsToOrder] = useState<any[]>([]);
const orderPartsWizard = OrderPartsWizard({ const orderPartsWizard = OrderPartsWizard({
@@ -385,9 +434,28 @@ export default function SalesOrderLineItemTable({
); );
allocateStock.open(); allocateStock.open();
}} }}
/>,
<ActionButton
key='auto-allocate-stock'
tooltip={t`Auto Allocate Stock`}
icon={<IconWand />}
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( const rowActions = useCallback(
(record: any): RowAction[] => { (record: any): RowAction[] => {
@@ -529,6 +597,7 @@ export default function SalesOrderLineItemTable({
{newBuildOrder.modal} {newBuildOrder.modal}
{allocateBySerials.modal} {allocateBySerials.modal}
{allocateStock.modal} {allocateStock.modal}
{autoAllocateStock.modal}
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
<InvenTreeTable <InvenTreeTable
url={apiUrl(ApiEndpoints.sales_order_line_list)} url={apiUrl(ApiEndpoints.sales_order_line_list)}
@@ -128,6 +128,42 @@ test('Sales Orders - Basic Tests', async ({ browser }) => {
await page.getByRole('button', { name: 'Issue Order' }).waitFor(); 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 }) => { test('Sales Orders - Shipments', async ({ browser }) => {
const page = await doCachedLogin(browser); const page = await doCachedLogin(browser);