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:
@@ -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.
|
||||||
|
|||||||
@@ -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*.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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/',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user