Merge branch 'master' into merge-transfer

This commit is contained in:
Matthias Mair 2026-05-27 18:42:55 +02:00 committed by GitHub
commit f0416bafb7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
127 changed files with 100501 additions and 84438 deletions

View File

@ -117,7 +117,7 @@ jobs:
python-version: ${{ env.python_version }}
cache: "pip"
- name: Run pre commit hook Checks
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # pin@v2
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # pin@v2
- name: Check Version
run: |
pip install --require-hashes -r contrib/dev_reqs/requirements.txt
@ -415,7 +415,7 @@ jobs:
path: .coverage
retention-days: 14
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -597,7 +597,7 @@ jobs:
- name: Run Tests
run: invoke dev.test --check --migrations --report --coverage --translations
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
@ -734,7 +734,7 @@ jobs:
- name: Report coverage
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
@ -790,4 +790,4 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6

View File

@ -67,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
sarif_file: results.sarif

View File

@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation.
- [#12011](https://github.com/inventree/InvenTree/pull/12011) adds a "creation_date" field to the StockItem API endpoint, allowing users to track when each stock item was created. This field is read-only and is automatically set to the current date and time when a new stock item is created.
- [#12000](https://github.com/inventree/InvenTree/pull/12000) adds support for auto-allocation of stock items against sales orders. This includes both backend and frontend changes, allowing users to trigger auto-allocation via the API or through the UI. The auto-allocation process will attempt to allocate available stock items to the sales order line items, based on the specified stock sorting and allocation rules.
- [#11920](https://github.com/inventree/InvenTree/pull/11920) adds support for renaming attachments after they have been uploaded. This includes both backend and frontend changes, allowing users to rename attachments via the API or through the UI.
- [#11914](https://github.com/inventree/InvenTree/pull/11914) adds a "maximum_stock" field to the Part model, allowing users to specify a maximum preferred stock level for each part. This is used in conjunction with the existing "minimum_stock" field to allow users to define a preferred stock range for each part. The "high_stock" filter has also been added to the Part API endpoint, allowing users to filter parts which are above their maximum stock level.
- [#11631](https://github.com/inventree/InvenTree/pull/11631) adds "raw_amount" field to the BomItem model, allowing BOM quantities to account for the units of measure of the underlying part.

View File

@ -10,7 +10,7 @@
# - Monitors source files for any changes, and live-reloads server
# Base image last bumped 2026-03-20
FROM python:3.14-slim-trixie@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca AS inventree_base
FROM python:3.14.5-slim-trixie@sha256:c845af9399020c7e562969a13689e929074a10fd057acd1b1fad06a2fb068e97 AS inventree_base
# Build arguments for this image
ARG commit_tag=""

View File

@ -199,10 +199,20 @@ In either case, ensure that the directory is available *on your local machine* a
## Error Rendering Component
Sometimes, following a software update, you may find that certain components of the web interface are not rendering correctly, and presented with a message similar to the screenshow below:
Sometimes, following a software update, you may find that certain components of the web interface are not rendering correctly, and presented with a message similar to the screenshot below:
{{ image("faq/boundary.png", "Error Rendering Component") }}
This is often due to a caching issue with your web browser. Try performing a hard refresh of the page to clear the cache, this should resolve the issue in most cases.
If the problem persists, refer to the [troubleshooting guide](./troubleshooting.md) for further assistance.
## Expression tree is too large
If you are running a large InvenTree deployment on an SQLite database, you may encounter an error similar to:
```
Expression tree is too large (maximum depth 1000)
```
This is a [known limitation of SQLite](https://www.sqlite.org/limits.html) which can occur when performing complex queries on a large database. Due to [structural limitations](./start/processes.md#sqlite-limitations) of SQLite, it is recommended to use a more robust database backend such as PostgreSQL for larger deployments.

View File

@ -80,7 +80,16 @@ The *Deallocate Stock* button can be used to remove all allocations of untracked
## Automatic Stock Allocation
To speed up the allocation process, the *Auto Allocate* button can be used to allocate untracked stock items to the build. Automatic allocation of stock items does not work in every situation, as a number of criteria must be met.
To speed up the allocation process, the *Auto Allocate* button can be used to automatically allocate stock items to the build.
!!! info "Background Task"
Auto-allocation runs as a background task. The UI will display a progress indicator while the task is running.
#### Selecting Lines to Allocate
By default, auto-allocation processes **all eligible BOM line items** in the build order. To restrict allocation to a subset of lines, select the desired rows in the allocation table before pressing the button — the dialog will indicate how many lines are selected.
#### Auto Allocation Options
The *Automatic Allocation* dialog is presented as shown below:
@ -90,12 +99,16 @@ The *Automatic Allocation* dialog is presented as shown below:
Select the master location where stock items are to be allocated from. Leave this input blank to allocate stock items from any available location.
**Exclude Location**
Exclude stock from a specific location (and all of its sub-locations). Useful for reserving stock in a particular area.
**Interchangeable Stock**
Set this option to *True* to signal that stock items can be used interchangeably. This means that in the case where multiple stock items are available, the auto-allocation routine does not care which stock item it uses.
!!! warning "Take Care"
If the *Interchangeable Stock* option is enabled, and there are multiple stock items available, the results of the automatic allocation algorithm may somewhat unexpected.
If the *Interchangeable Stock* option is enabled, and there are multiple stock items available, the results of the automatic allocation algorithm may be somewhat unexpected.
!!! info "Example"
Let's say that we have 5 reels of our *C_100nF_0603* capacitor, each with 4,000 parts available. If we do not mind which of these reels the stock should be taken from, we enable the *Interchangeable Stock* option in the dialog above. In this case, the stock will be allocated from one of these reels, and eventually subtracted from stock when the build is completed.
@ -104,6 +117,32 @@ Set this option to *True* to signal that stock items can be used interchangeably
Set this option to *True* to allow substitute parts (as specified by the BOM) to be allocated, if the primary parts are not available.
**Optional Items**
Set this option to *True* to include optional BOM line items in the auto-allocation. By default, optional items are not automatically allocated.
**Item Type**
Controls which category of BOM line items is considered for auto-allocation:
| Option | Description |
| --- | --- |
| Untracked Items | Only untracked (non-serialized) BOM lines are allocated *(default)* |
| Tracked Items | Only tracked BOM lines are allocated |
| All Items | Both tracked and untracked BOM lines are allocated |
**Stock Priority**
Controls the order in which matching stock items are consumed:
| Option | Description |
| --- | --- |
| Oldest stock first (FIFO) | Stock items updated least recently are consumed first *(default)* |
| Newest stock first (LIFO) | Stock items updated most recently are consumed first |
| Smallest quantity first | Stock items with the lowest available quantity are consumed first |
| Largest quantity first | Stock items with the highest available quantity are consumed first |
| Soonest expiry date first | Stock items expiring earliest are consumed first; items with no expiry date are used last |
## Allocating Tracked Stock
Allocation of tracked stock items is slightly more complex. Instead of being allocated against the *Build Order*, tracked stock items must be allocated against an individual *Build Output*.

View File

@ -1,5 +1,5 @@
---
title: Resources InvenTree receives
title: Sponsored Resources
---
The InvenTree project is grateful to receive resources from various vendors free of charge.
@ -18,5 +18,6 @@ Individuals and companies can also support via [GitHub sponsors](https://github.
## Past Supporters
Non comprehensive list of past supporters. The project stops consuming resources for various reasons, this does not mean they are not good resources.
- [Coveralls](https://coveralls.io/) - Code coverage as a service
- [Deepsource](https://deepsource.io/) - Code quality and security analysis

View File

@ -110,6 +110,79 @@ After shipments were created, user can either:
During the allocation process, user is required to select the desired shipment that will contain the stock items.
### Auto Allocate Stock
To speed up the allocation process, use the *Auto Allocate Stock* button ({{ icon("wand") }}) available in the *Line Items* tab. This automatically finds available stock and creates the required allocations with minimal user interaction.
!!! info "Background Task"
Auto-allocation runs as a background task. The UI will display a progress indicator while the task is running.
#### Selecting Lines to Allocate
By default, auto-allocation processes **all unallocated line items** on the order. To restrict allocation to a subset of lines, select the desired rows in the *Line Items* table before pressing the button — the dialog will indicate how many lines are selected.
#### Auto Allocation Options
The auto-allocation dialog provides the following options:
**Source Location**
Restrict stock to a specific location (and all of its sub-locations). Leave blank to consider stock from any location.
**Exclude Location**
Exclude stock from a specific location (and all of its sub-locations). Useful for reserving stock in a particular area.
**Shipment**
Optionally assign all new allocations to a specific pending shipment. Only shipments that have not yet been completed are shown.
**Interchangeable Stock**
When enabled (default), stock may be drawn from multiple stock items or locations to fulfil a single line item. When disabled, a line item is only allocated if a single stock item can cover the entire remaining quantity.
!!! warning "Take Care"
Enabling *Interchangeable Stock* means the auto-allocation routine will combine stock from different batches or locations. Review the resulting allocations if traceability is important.
**Stock Priority**
Controls the order in which matching stock items are consumed:
| Option | Description |
| --- | --- |
| Oldest stock first (FIFO) | Stock items updated least recently are consumed first *(default)* |
| Newest stock first (LIFO) | Stock items updated most recently are consumed first |
| Smallest quantity first | Stock items with the lowest available quantity are consumed first |
| Largest quantity first | Stock items with the highest available quantity are consumed first |
| Soonest expiry date first | Stock items expiring earliest are consumed first; items with no expiry date are used last |
**Serialized Stock**
Controls whether serialized stock items are included in the auto-allocation:
| Option | Description |
| --- | --- |
| Allow any stock | Both serialized and unserialized stock items are considered *(default)* |
| Serialized stock only | Only stock items that carry a serial number are allocated |
| Unserialized stock only | Only stock items without a serial number are allocated |
#### Allocation Behaviour
The auto-allocation routine performs the following steps for each eligible line item:
1. Skips line items for *virtual* parts.
2. Skips line items that are already fully allocated.
3. Queries available stock for the line's part, applying any location and serialized-stock filters.
4. Sorts the candidates according to the chosen *Stock Priority*.
5. Greedily allocates from each stock item in turn until the remaining quantity for the line is satisfied.
#### Removing Allocations
Individual or multiple allocations can be removed from the *Allocated Stock* tab. Select the allocations to remove and use the *Delete* action.
!!! warning "Shipped Allocations Protected"
Allocations that belong to a completed (shipped) shipment cannot be deleted.
### Check Shipment
Shipments can be marked as "checked" to indicate that the items in the shipment has been verified. To mark a shipment as "checked", open the shipment actions menu, and select the "Check" action:

View File

@ -6,24 +6,24 @@ The InvenTree project is committed to providing a secure and safe environment fo
To that end, we have implemented a number of security measures over the years, which we will outline in this document.
## Organisational measures
## Organizational Measures
The InvenTree project is managed by a small team of developers, who are responsible for the ongoing development and maintenance of the software. Two geographically distributed users have administrative access to the InvenTree codebase. Merges are only done by one of these two users, the maintainer Oliver.
Read the Project [Governance](./project/governance.md) document for more information.
InvenTree is open-source, and we welcome contributions from the community. However, all contributions are reviewed and scrutinised before being merged into the codebase.
InvenTree is open-source, and we welcome contributions from the community. However, all contributions are reviewed and scrutinized before being merged into the codebase.
### Security Policy
The official [Security Policy]({{ sourcefile("SECURITY.md") }}) is available in the code repository.
We provide this document in our main repo to increase discoverabiltity to ensure that all security issues are handled in a timely manner.
We provide this document in our main repo to increase discoverability to ensure that all security issues are handled in a timely manner.
### Past Reports
If we become aware of a security issue, we will take immediate action to address the issue, and will provide a public disclosure of the issue once it has been resolved. We support assigning CVEs to security issues where appropriate. Our [past security advisories can be found here](https://github.com/inventree/InvenTree/security/advisories).
## Technical measures
## Technical Measures
### Code hosting
### Code Hosting
The InvenTree project is hosted on GitHub, and we rely on the security measures provided by GitHub to help protect the integrity of the codebase.
@ -35,24 +35,24 @@ Among those are:
- (Optional but encouraged) Two-factor authentication for user accounts
- (Optional but encouraged) Signed commits / actions
### Code style
### Code Style
We enforce style and security checks in our CI/CD pipeline, and we have several automated tests to ensure that the codebase is secure and functional.
Checks are run on every pull request, and we require that all checks pass before a pull request can be merged.
### Current versions
### Current Versions
InvenTree is built using the Django framework, which has a strong focus on security. We follow best practices for Django development, and we are committed to keeping the codebase up-to-date with the latest security patches and within supported versions.
### Test coverage
### Test Coverage
We run coverage tests on our codebase to ensure that we have a high level of test coverage above 90%. This is public and can be found [here](https://app.codecov.io/gh/inventree/InvenTree).
### Pinning dependencies
### Pinning Dependencies
We are pinning dependencies to specific versions - aiming for complete reproducibility of builds - wherever possible. Combined with continuous OSV checks, we are able to react quickly to security issues in our dependencies.
## Best practices
## Best Practices
We follow most of GitHubs community best practices, check our compliance [here](https://github.com/inventree/InvenTree/community).

View File

@ -32,6 +32,8 @@ Remove parts from a stock item record - for example taking parts from stock for
Count stock items (stocktake) to record the number of items in stock at a given point of time. The quantity for each part is pre-filled with the current quantity based on stock item history.
An optional **Location** field allows all counted items to be moved to a new location in the same operation. If left blank, each item retains its current location.
{{ image("stock/stock_count.png", "Stock Count") }}
### Merge Stock

View File

@ -87,7 +87,7 @@ nav:
- Project Details:
- Governance: project/governance.md
- Project Security: security.md
- Resources: project/resources.md
- Sponsored Resources: project/sponsored_resources.md
- Privacy: privacy.md
- Concepts:
- Terminology: concepts/terminology.md

View File

@ -1,11 +1,22 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 494
INVENTREE_API_VERSION = 497
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v497 -> 2026-05-27 : https://github.com/inventree/InvenTree/pull/12019
- Adds "location" field to StockCount API endpoint
v496 -> 2026-05-26 : https://github.com/inventree/InvenTree/pull/12011
- Add "creation_date" field to the StockItem API endpoint
v495 -> 2026-05-25 : https://github.com/inventree/InvenTree/pull/12000
- Adds "auto-allocate" API endpoint for sales orders
- Allow bulk-delete of SalesOrderAllocation objects via the API
- Add new allocation options to the Build auto-allocate API endpoint
v494 -> 2026-05-23 : https://github.com/inventree/InvenTree/pull/11990
- Offload build output operations to a background task, and return a task ID which can be used to monitor the progress of the task

View File

@ -550,29 +550,31 @@ def increment_serial_number(serial, part=None):
incremented value, or None if incrementing could not be performed.
"""
from InvenTree.exceptions import log_error
from InvenTree.ready import isReadOnlyCommand
from plugin import PluginMixinEnum, registry
# Ensure we start with a string value
if serial is not None:
serial = str(serial).strip()
# First, let any plugins attempt to increment the serial number
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
if not hasattr(plugin, 'increment_serial_number'):
continue
if not isReadOnlyCommand():
# First, let any plugins attempt to increment the serial number
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
if not hasattr(plugin, 'increment_serial_number'):
continue
signature = inspect.signature(plugin.increment_serial_number)
signature = inspect.signature(plugin.increment_serial_number)
# Note: 2024-08-21 - The 'part' parameter has been added to the signature
if 'part' in signature.parameters:
result = plugin.increment_serial_number(serial, part=part)
else:
result = plugin.increment_serial_number(serial)
if result is not None:
return str(result)
except Exception:
log_error('increment_serial_number', plugin=plugin.slug)
# Note: 2024-08-21 - The 'part' parameter has been added to the signature
if 'part' in signature.parameters:
result = plugin.increment_serial_number(serial, part=part)
else:
result = plugin.increment_serial_number(serial)
if result is not None:
return str(result)
except Exception:
log_error('increment_serial_number', plugin=plugin.slug)
# If we get to here, no plugins were able to "increment" the provided serial value
# Attempt to perform increment according to some basic rules

View File

@ -92,10 +92,23 @@ class PluginValidationMixin(DiffMixin):
Any model class which inherits from this mixin will be exposed to the plugin validation system.
"""
def should_plugin_validate(self):
"""Return True if this model instance should be validated by plugins.
The default implementation returns True, but this can be overridden in the implementing class if required.
"""
from InvenTree.ready import isReadOnlyCommand
# Prevent plugin validation when importing or exporting data
return not isReadOnlyCommand()
def run_plugin_validation(self):
"""Throw this model against the plugin validation interface."""
from plugin import PluginMixinEnum, registry
if not self.should_plugin_validate():
return
deltas = self.get_field_deltas()
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
@ -139,15 +152,16 @@ class PluginValidationMixin(DiffMixin):
from InvenTree.exceptions import log_error
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
plugin.validate_model_deletion(self)
except ValidationError as e:
# Plugin might raise a ValidationError to prevent deletion
raise e
except Exception:
log_error('validate_model_deletion', plugin=plugin.slug)
continue
if self.should_plugin_validate():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
plugin.validate_model_deletion(self)
except ValidationError as e:
# Plugin might raise a ValidationError to prevent deletion
raise e
except Exception:
log_error('validate_model_deletion', plugin=plugin.slug)
continue
super().delete(*args, **kwargs)

View File

@ -886,6 +886,8 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
build_lines = data.get('build_lines', [])
# Offload the task to the background worker
task_id = offload_task(
auto_allocate_build,
@ -896,6 +898,8 @@ class BuildAutoAllocate(BuildOrderContextMixin, CreateAPI):
substitutes=data['substitutes'],
optional_items=data['optional_items'],
item_type=data.get('item_type', 'untracked'),
stock_sort_by=data['stock_sort_by'],
line_ids=[line.pk for line in build_lines] if build_lines else None,
group='build',
)

View File

@ -1342,6 +1342,8 @@ class Build(
interchangeable = kwargs.get('interchangeable', False)
substitutes = kwargs.get('substitutes', True)
optional_items = kwargs.get('optional_items', False)
stock_sort_by = kwargs.get('stock_sort_by', stock.models.STOCK_SORT_DEFAULT)
line_ids = kwargs.get('line_ids')
def stock_sort(item, bom_item, variant_parts):
if item.part == bom_item.sub_part:
@ -1353,7 +1355,11 @@ class Build(
new_items = []
# Select only "untracked" line items
for line_item in self.untracked_line_items.all():
untracked_lines = self.untracked_line_items.all()
if line_ids:
untracked_lines = untracked_lines.filter(pk__in=line_ids)
for line_item in untracked_lines:
# Find the referenced BomItem
bom_item = line_item.bom_item
@ -1410,13 +1416,22 @@ class Build(
location__in=list(sublocations)
)
# Apply secondary ORM ordering before the Python match-quality stable-sort.
if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST:
available_stock = available_stock.order_by(
F('expiry_date').asc(nulls_last=True)
)
else:
available_stock = available_stock.order_by(stock_sort_by)
"""
Next, we sort the available stock items with the following priority:
1. Direct part matches (+1)
2. Variant part matches (+2)
3. Substitute part matches (+3)
This ensures that allocation priority is first given to "direct" parts
This ensures that allocation priority is first given to "direct" parts.
Python's stable sort preserves the secondary ORM ordering within each group.
"""
available_stock = sorted(
available_stock,

View File

@ -27,6 +27,7 @@ import company.serializers
import InvenTree.helpers
import part.filters
import part.serializers as part_serializers
import stock.models as stock_models
from common.settings import get_global_setting
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from InvenTree.mixins import DataImportExportSerializerMixin
@ -1065,6 +1066,24 @@ class BuildAutoAllocationSerializer(serializers.Serializer):
help_text=_('Select item type to auto-allocate'),
)
stock_sort_by = serializers.ChoiceField(
default=stock_models.STOCK_SORT_DEFAULT,
choices=stock_models.STOCK_SORT_CHOICES,
label=_('Stock Priority'),
help_text=_('Preferred order in which matching stock items are consumed'),
)
build_lines = serializers.PrimaryKeyRelatedField(
queryset=BuildLine.objects.all(),
many=True,
required=False,
default=list,
label=_('Build Lines'),
help_text=_(
'Limit allocation to these build lines (leave blank to allocate all lines)'
),
)
class BuildItemSerializer(
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer

View File

@ -12,7 +12,7 @@ from build.status_codes import BuildStatus
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import BomItem, Part
from stock.models import StockItem
from stock.models import StockItem, StockLocation, StockSortOrder
from stock.status_codes import StockStatus
@ -1359,9 +1359,10 @@ class BuildOutputCreateTest(BuildAPITest):
# Stock items have increased
self.assertEqual(n_items + 5, part.stock_items.count())
# Serial numbers have been created
# Serial numbers have been created, each with a creation_date
for sn in range(1, 6):
self.assertTrue(part.stock_items.filter(serial=sn).exists())
self.assertIsNotNone(part.stock_items.get(serial=sn).creation_date)
def test_create_unserialized_output(self):
"""Create an unserialized build output via the API."""
@ -1383,6 +1384,9 @@ class BuildOutputCreateTest(BuildAPITest):
# Stock items have increased
self.assertEqual(n_items + 1, part.stock_items.count())
# The new output must have a creation_date set
self.assertIsNotNone(part.stock_items.order_by('-pk').first().creation_date)
class BuildOutputScrapTest(BuildAPITest):
"""Unit tests for scrapping build outputs."""
@ -1811,3 +1815,162 @@ class BuildConsumeTest(BuildAPITest):
for line in self.build.build_lines.all():
self.assertEqual(line.consumed, 100)
class BuildAutoAllocateAPITest(InvenTreeAPITestCase):
"""API integration tests for BuildAutoAllocate endpoint back-ports (stock_sort_by, build_lines)."""
fixtures = ['company', 'users']
roles = ['build.add', 'build.change']
@classmethod
def setUpTestData(cls):
"""Create shared parts, locations and build data for all tests."""
super().setUpTestData()
cls.assembly = Part.objects.create(
name='AutoAlloc Assembly', description='', assembly=True
)
cls.component = Part.objects.create(
name='AutoAlloc Component', description='', component=True
)
cls.component_b = Part.objects.create(
name='AutoAlloc Component B', description='', component=True
)
cls.loc_a = StockLocation.objects.create(name='BuildShelf A')
cls.loc_b = StockLocation.objects.create(name='BuildShelf B')
def _next_ref(self):
"""Return a valid Build reference using the system-generated next value."""
return Build.generate_batch_code()
def _make_build(self, quantity=10):
"""Create a fresh Build with one untracked BOM line (component only)."""
build = Build.objects.create(
part=self.assembly,
reference=f'BO-{9000 + Build.objects.count():04d}',
quantity=quantity,
)
BomItem.objects.create(part=self.assembly, sub_part=self.component, quantity=1)
build.create_build_line_items()
return build
def _url(self, pk):
return reverse('api-build-auto-allocate', kwargs={'pk': pk})
def _create_build_two_lines(self, quantity=5):
"""Create a Build with two untracked BOM lines (component + component_b)."""
build = Build.objects.create(
part=self.assembly,
reference=f'BO-{9000 + Build.objects.count():04d}',
quantity=quantity,
)
BomItem.objects.create(part=self.assembly, sub_part=self.component, quantity=1)
BomItem.objects.create(
part=self.assembly, sub_part=self.component_b, quantity=1
)
build.create_build_line_items()
return build
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
def test_invalid_stock_sort_by_rejected(self):
"""An unrecognised stock_sort_by value is rejected with 400."""
build = self._make_build()
self.post(
self._url(build.pk), {'stock_sort_by': 'not_valid'}, expected_code=400
)
# ------------------------------------------------------------------
# stock_sort_by behaviour
# ------------------------------------------------------------------
def test_stock_sort_by_quantity_asc(self):
"""stock_sort_by=QUANTITY_ASC consumes the smallest lot first."""
build = self._make_build(quantity=15)
small = StockItem.objects.create(part=self.component, quantity=5)
StockItem.objects.create(part=self.component, quantity=100)
self.post(
self._url(build.pk),
{
'stock_sort_by': str(StockSortOrder.QUANTITY_ASC),
'interchangeable': True,
},
expected_code=200,
)
allocs = BuildItem.objects.filter(build_line__build=build)
self.assertTrue(any(a.stock_item == small and a.quantity == 5 for a in allocs))
def test_stock_sort_by_quantity_desc(self):
"""stock_sort_by=QUANTITY_DESC consumes the largest lot first, covering requirement in one allocation."""
build = self._make_build(quantity=15)
StockItem.objects.create(part=self.component, quantity=5)
large = StockItem.objects.create(part=self.component, quantity=100)
self.post(
self._url(build.pk),
{
'stock_sort_by': str(StockSortOrder.QUANTITY_DESC),
'interchangeable': True,
},
expected_code=200,
)
allocs = BuildItem.objects.filter(build_line__build=build)
self.assertEqual(allocs.count(), 1)
self.assertEqual(allocs.first().stock_item, large)
self.assertEqual(allocs.first().quantity, 15)
# ------------------------------------------------------------------
# build_lines filtering
# ------------------------------------------------------------------
def test_build_lines_subset_only_allocates_selected_lines(self):
"""When build_lines is specified only those lines are allocated."""
build = self._create_build_two_lines()
StockItem.objects.create(part=self.component, quantity=50)
StockItem.objects.create(part=self.component_b, quantity=50)
line_a = build.build_lines.filter(bom_item__sub_part=self.component).first()
self.post(
self._url(build.pk),
{'build_lines': [line_a.pk], 'interchangeable': True},
expected_code=200,
)
alloc_a = BuildItem.objects.filter(
build_line__build=build, stock_item__part=self.component
)
alloc_b = BuildItem.objects.filter(
build_line__build=build, stock_item__part=self.component_b
)
self.assertTrue(alloc_a.exists())
self.assertFalse(alloc_b.exists())
def test_build_lines_empty_allocates_all(self):
"""When build_lines is omitted, all untracked lines are allocated."""
build = self._create_build_two_lines()
StockItem.objects.create(part=self.component, quantity=50)
StockItem.objects.create(part=self.component_b, quantity=50)
self.post(self._url(build.pk), {'interchangeable': True}, expected_code=200)
alloc_a = BuildItem.objects.filter(
build_line__build=build, stock_item__part=self.component
)
alloc_b = BuildItem.objects.filter(
build_line__build=build, stock_item__part=self.component_b
)
self.assertTrue(alloc_a.exists())
self.assertTrue(alloc_b.exists())

View File

@ -2875,6 +2875,10 @@ class Parameter(
except ValidationError as e:
raise ValidationError({'data': e.message})
if InvenTree.ready.isReadOnlyCommand():
# Skip plugin validation checks during read-only management commands
return
# Finally, run custom validation checks (via plugins)
from plugin import PluginMixinEnum, registry

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,7 @@ import stock.serializers as stock_serializers
from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView
from InvenTree.api import (
BulkDeleteMixin,
BulkUpdateMixin,
ListCreateDestroyAPIView,
ParameterListMixin,
@ -1170,6 +1171,50 @@ class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI):
serializer_class = serializers.SalesOrderShipmentAllocationSerializer
class SalesOrderAutoAllocate(SalesOrderContextMixin, CreateAPI):
"""API endpoint to automatically allocate stock against a SalesOrder.
- Offloads work to a background task and returns task detail
"""
serializer_class = serializers.SalesOrderAutoAllocationSerializer
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
def post(self, *args, **kwargs):
"""Validate parameters and offload auto-allocation to a background task."""
from InvenTree.tasks import offload_task
from order.tasks import auto_allocate_sales_order
order_obj = self.get_object()
serializer = self.get_serializer(data=self.request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# Extract related models from the validated data
location = data.get('location')
exclude_location = data.get('exclude_location')
shipment = data.get('shipment')
line_items = data.get('line_items', [])
# Offload to the background worker
# Note: We provide the model ID values, not the model instances
task_id = offload_task(
auto_allocate_sales_order,
order_obj.pk,
location_id=location.pk if location else None,
exclude_location_id=exclude_location.pk if exclude_location else None,
shipment_id=shipment.pk if shipment else None,
line_ids=[item.pk for item in line_items] if line_items else None,
interchangeable=data['interchangeable'],
stock_sort_by=data['stock_sort_by'],
serialized_stock=data['serialized_stock'],
group='sales_order',
)
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
return Response(response, status=response['http_status'])
class SalesOrderAllocationFilter(FilterSet):
"""Custom filterset for the SalesOrderAllocationList endpoint."""
@ -1300,7 +1345,11 @@ class SalesOrderAllocationOutputOptions(OutputConfiguration):
class SalesOrderAllocationList(
SalesOrderAllocationMixin, BulkUpdateMixin, OutputOptionsMixin, ListAPI
SalesOrderAllocationMixin,
BulkDeleteMixin,
BulkUpdateMixin,
OutputOptionsMixin,
ListAPI,
):
"""API endpoint for listing SalesOrderAllocation objects."""
@ -1308,6 +1357,10 @@ class SalesOrderAllocationList(
filter_backends = SEARCH_ORDER_FILTER
output_options = SalesOrderAllocationOutputOptions
def filter_delete_queryset(self, queryset, request):
"""Prevent deletion of allocations that have already been shipped."""
return queryset.filter(shipment__shipment_date__isnull=True)
ordering_fields = [
'quantity',
'part',
@ -2582,6 +2635,11 @@ order_api_urls = [
SalesOrderAllocateSerials.as_view(),
name='api-so-allocate-serials',
),
path(
'auto-allocate/',
SalesOrderAutoAllocate.as_view(),
name='api-so-auto-allocate',
),
path('hold/', SalesOrderHold.as_view(), name='api-so-hold'),
path('cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
path('issue/', SalesOrderIssue.as_view(), name='api-so-issue'),

View File

@ -1317,6 +1317,18 @@ class PurchaseOrder(TotalPriceMixin, Order):
)
STOCK_SORT_CHOICES = stock.models.STOCK_SORT_CHOICES
STOCK_SORT_DEFAULT = stock.models.STOCK_SORT_DEFAULT
SERIALIZED_STOCK_CHOICES = [
('any', _('Allow any stock (serialized or unserialized)')),
('serialized', _('Serialized stock only')),
('unserialized', _('Unserialized stock only')),
]
SERIALIZED_STOCK_DEFAULT = 'any'
class SalesOrder(TotalPriceMixin, Order):
"""A SalesOrder represents a list of goods shipped outwards to a customer."""
@ -1469,6 +1481,130 @@ class SalesOrder(TotalPriceMixin, Order):
"""Return true if any lines in the order are over-allocated."""
return any(line.is_overallocated() for line in self.lines.all())
@transaction.atomic
def auto_allocate_stock(
self,
location: Optional[stock.models.StockLocation] = None,
exclude_location: Optional[stock.models.StockLocation] = None,
shipment: Optional['SalesOrderShipment'] = None,
line_ids: Optional[list] = None,
**kwargs,
):
"""Automatically allocate stock items against this SalesOrder.
For each unallocated line item, finds available stock for
the line's part, filtered and sorted according to the supplied kwargs, then
creates SalesOrderAllocation records in bulk.
Arguments:
location: If provided, only consider stock within this location tree.
exclude_location: If provided, exclude stock within this location tree.
shipment: Optional shipment to assign allocations to.
line_ids: If provided, only allocate against these specific line item PKs.
Kwargs:
interchangeable (bool): If True (default), consume stock from multiple
items/locations to satisfy a line. If False, only allocate when a
single item can cover the full remaining quantity.
"""
stock_sort_by = kwargs.get('stock_sort_by', STOCK_SORT_DEFAULT)
interchangeable = kwargs.get('interchangeable', True)
serialized_stock = kwargs.get('serialized_stock', SERIALIZED_STOCK_DEFAULT)
new_allocations = []
lines = self.lines.all()
if line_ids:
lines = lines.filter(pk__in=line_ids)
for line_item in lines:
if not line_item.part:
continue
if line_item.part.virtual:
continue
unallocated = line_item.quantity - line_item.allocated_quantity()
if unallocated <= 0:
continue
available_stock = stock.models.StockItem.objects.filter(
stock.models.StockItem.IN_STOCK_FILTER, part=line_item.part
)
if location:
sublocations = location.get_descendants(include_self=True)
available_stock = available_stock.filter(
location__in=list(sublocations)
)
if exclude_location:
sublocations = exclude_location.get_descendants(include_self=True)
available_stock = available_stock.exclude(
location__in=list(sublocations)
)
if serialized_stock == 'serialized':
available_stock = available_stock.filter(
serial__isnull=False, quantity=1
).exclude(serial='')
elif serialized_stock == 'unserialized':
available_stock = available_stock.filter(
Q(serial__isnull=True) | Q(serial='')
)
# Handle NULL expiry_date last when sorting by expiry.
if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST:
available_stock = available_stock.order_by(
F('expiry_date').asc(nulls_last=True)
)
else:
available_stock = available_stock.order_by(stock_sort_by)
stock_count = available_stock.count()
if stock_count == 0:
continue
if not interchangeable and stock_count > 1:
# Only allocate when a single item can fully cover the requirement.
single = next(
(
s
for s in available_stock
if s.unallocated_quantity() >= unallocated
),
None,
)
if single is None:
continue
available_stock = [single]
for stock_item in available_stock:
available_qty = stock_item.unallocated_quantity()
if available_qty <= 0:
continue
quantity = min(unallocated, available_qty)
new_allocations.append(
SalesOrderAllocation(
line=line_item,
item=stock_item,
quantity=quantity,
shipment=shipment,
)
)
unallocated -= quantity
if unallocated <= 0:
break
SalesOrderAllocation.objects.bulk_create(new_allocations, batch_size=250)
def is_completed(self) -> bool:
"""Check if this order is "shipped" (all line items delivered).

View File

@ -1990,6 +1990,113 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
allocation.save()
class SalesOrderAutoAllocationSerializer(serializers.Serializer):
"""DRF serializer for auto-allocating stock items against a SalesOrder."""
class Meta:
"""Serializer metaclass."""
fields = [
'location',
'exclude_location',
'shipment',
'interchangeable',
'stock_sort_by',
'serialized_stock',
'line_items',
]
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('Source Location'),
help_text=_(
'Stock location where items are sourced (leave blank to use any location)'
),
)
exclude_location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('Exclude Location'),
help_text=_('Exclude stock items from this location'),
)
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('Shipment'),
help_text=_('Assign allocations to this shipment'),
)
interchangeable = serializers.BooleanField(
default=True,
label=_('Interchangeable Stock'),
help_text=_(
'Allow stock to be taken from multiple locations to fulfil a single line item'
),
)
stock_sort_by = serializers.ChoiceField(
default=stock.models.STOCK_SORT_DEFAULT,
choices=stock.models.STOCK_SORT_CHOICES,
label=_('Stock Priority'),
help_text=_('Preferred order in which matching stock items are consumed'),
)
serialized_stock = serializers.ChoiceField(
default=order.models.SERIALIZED_STOCK_DEFAULT,
choices=order.models.SERIALIZED_STOCK_CHOICES,
label=_('Serialized Stock'),
help_text=_(
'Control whether serialized stock items are included in auto-allocation'
),
)
line_items = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderLineItem.objects.all(),
many=True,
required=False,
default=list,
label=_('Line Items'),
help_text=_(
'Limit allocation to these line items (leave blank to allocate all lines)'
),
)
def validate_shipment(self, shipment):
"""Validate that the shipment belongs to this order and is not yet shipped."""
order_obj = self.context.get('order')
if shipment is None:
return shipment
if shipment.shipment_date is not None:
raise ValidationError(_('Shipment has already been shipped'))
if order_obj and shipment.order != order_obj:
raise ValidationError(_('Shipment is not associated with this order'))
return shipment
def validate_line_items(self, line_items):
"""Validate that all provided line items belong to this order."""
order_obj = self.context.get('order')
if order_obj and line_items:
for line in line_items:
if line.order != order_obj:
raise ValidationError(_('Line item does not belong to this order'))
return line_items
@register_importer()
class SalesOrderExtraLineSerializer(
AbstractExtraLineSerializer, InvenTreeModelSerializer

View File

@ -14,6 +14,7 @@ from opentelemetry import trace
import common.notifications
import InvenTree.helpers_model
import order.models
import stock.models as stock_models
from InvenTree.tasks import ScheduledTask, scheduled_task
from order.events import PurchaseOrderEvents, SalesOrderEvents
from order.status_codes import (
@ -273,3 +274,38 @@ def complete_sales_order_shipment(
# Trigger event signalling that the shipment has been completed
trigger_event(SalesOrderEvents.SHIPMENT_COMPLETE, id=shipment.pk)
@tracer.start_as_current_span('auto_allocate_sales_order')
def auto_allocate_sales_order(
order_id: int,
location_id: Optional[int] = None,
exclude_location_id: Optional[int] = None,
shipment_id: Optional[int] = None,
line_ids: Optional[list] = None,
**kwargs,
):
"""Run auto-allocation for a specified SalesOrder."""
sales_order = order.models.SalesOrder.objects.get(pk=order_id)
location = (
stock_models.StockLocation.objects.get(pk=location_id) if location_id else None
)
exclude_location = (
stock_models.StockLocation.objects.get(pk=exclude_location_id)
if exclude_location_id
else None
)
shipment = (
order.models.SalesOrderShipment.objects.get(pk=shipment_id)
if shipment_id
else None
)
sales_order.auto_allocate_stock(
location=location,
exclude_location=exclude_location,
shipment=shipment,
line_ids=line_ids or None,
**kwargs,
)

View File

@ -21,6 +21,7 @@ from common.settings import set_global_setting
from company.models import Company, SupplierPart, SupplierPriceBreak
from InvenTree.unit_test import InvenTreeAPITestCase
from order import models
from order.models import SalesOrderAllocation, SalesOrderLineItem, SalesOrderShipment
from order.status_codes import (
PurchaseOrderStatus,
ReturnOrderLineStatus,
@ -31,7 +32,7 @@ from order.status_codes import (
TransferOrderStatusGroups,
)
from part.models import Part
from stock.models import StockItem, StockLocation
from stock.models import StockItem, StockLocation, StockSortOrder
from stock.status_codes import StockStatus
from users.models import Owner
@ -1141,6 +1142,10 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(stock_1.last().expiry_date, one_week_from_today)
self.assertEqual(stock_2.last().expiry_date, one_week_from_today)
# creation_date must be populated on both received items
self.assertIsNotNone(stock_1.last().creation_date)
self.assertIsNotNone(stock_2.last().creation_date)
# Barcodes should have been assigned to the stock items
self.assertTrue(
StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists()
@ -1217,6 +1222,7 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(item.serial, str(i))
self.assertEqual(item.quantity, 1)
self.assertEqual(item.batch, 'B-abc-123')
self.assertIsNotNone(item.creation_date)
# A single stock item (quantity 10) created for the second line item
items = StockItem.objects.filter(supplier_part=line_2.part)
@ -3740,3 +3746,377 @@ class TransferOrderAllocateTest(OrderTest):
['part_detail', 'item_detail', 'order_detail', 'location_detail'],
assert_subset=True,
)
class SalesOrderAutoAllocateAPITest(InvenTreeAPITestCase):
"""API integration tests for the SalesOrder auto-allocate endpoint."""
fixtures = ['company', 'users']
roles = ['sales_order.add', 'sales_order.change', 'sales_order.delete']
@classmethod
def setUpTestData(cls):
"""Create shared order, parts, locations and stock for all tests."""
super().setUpTestData()
cls.customer = Company.objects.create(
name='Test Customer', is_customer=True, description=''
)
cls.part = Part.objects.create(
name='AutoAlloc Part', salable=True, description=''
)
cls.loc_a = StockLocation.objects.create(name='Shelf A')
cls.loc_b = StockLocation.objects.create(name='Shelf B')
def _make_order(self, qty=50):
"""Create a fresh SalesOrder with one line item and one shipment."""
order = models.SalesOrder.objects.create(
customer=self.customer,
reference=f'SO-TEST-{models.SalesOrder.objects.count()}',
)
line = SalesOrderLineItem.objects.create(
order=order, part=self.part, quantity=qty
)
shipment = SalesOrderShipment.objects.create(order=order)
return order, line, shipment
def _url(self, pk):
return reverse('api-so-auto-allocate', kwargs={'pk': pk})
# ------------------------------------------------------------------
# Permission and basic response tests
# ------------------------------------------------------------------
def test_requires_authentication(self):
"""POST without authentication returns 401."""
self.client.logout()
order, _, _ = self._make_order()
self.post(self._url(order.pk), {}, expected_code=401)
def test_basic_post_returns_200(self):
"""POST with defaults runs synchronously in tests and returns 200."""
order, line, _ = self._make_order()
StockItem.objects.create(part=self.part, quantity=100)
response = self.post(self._url(order.pk), {}, expected_code=200)
self.assertIn('task_id', response.data)
self.assertTrue(response.data['complete'])
self.assertTrue(response.data['success'])
# Task ran synchronously — allocations are already committed
self.assertTrue(line.is_fully_allocated())
def test_invalid_order_pk_returns_404(self):
"""POST to a non-existent order pk returns 404."""
self.post(self._url(999999), {}, expected_code=404)
# ------------------------------------------------------------------
# Field validation
# ------------------------------------------------------------------
def test_invalid_stock_sort_by_rejected(self):
"""An unrecognised stock_sort_by value is rejected with 400."""
order, _, _ = self._make_order()
self.post(
self._url(order.pk),
{'stock_sort_by': 'not_a_valid_sort'},
expected_code=400,
)
def test_invalid_serialized_stock_rejected(self):
"""An unrecognised serialized_stock value is rejected with 400."""
order, _, _ = self._make_order()
self.post(self._url(order.pk), {'serialized_stock': 'maybe'}, expected_code=400)
def test_shipment_from_another_order_rejected(self):
"""A shipment that belongs to a different order is rejected with 400."""
order, _, _ = self._make_order()
_other_order, _, other_shipment = self._make_order()
self.post(
self._url(order.pk), {'shipment': other_shipment.pk}, expected_code=400
)
def test_shipped_shipment_rejected(self):
"""A shipment that has already been marked as shipped is rejected with 400."""
from datetime import date
order, _, shipment = self._make_order()
shipment.shipment_date = date.today()
shipment.save()
self.post(self._url(order.pk), {'shipment': shipment.pk}, expected_code=400)
def test_line_items_from_another_order_rejected(self):
"""line_items belonging to a different order are rejected with 400."""
order, _, _ = self._make_order()
_, other_line, _ = self._make_order()
self.post(
self._url(order.pk), {'line_items': [other_line.pk]}, expected_code=400
)
# ------------------------------------------------------------------
# Allocation behaviour
# ------------------------------------------------------------------
def test_allocates_available_stock(self):
"""Stock is allocated to the line item after a successful POST."""
order, line, _ = self._make_order(qty=30)
item = StockItem.objects.create(part=self.part, quantity=100)
self.post(self._url(order.pk), {}, expected_code=200)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertEqual(allocs.count(), 1)
self.assertEqual(allocs.first().item, item)
self.assertEqual(allocs.first().quantity, 30)
def test_line_items_subset_only_allocates_selected_lines(self):
"""When line_items is specified only those lines are allocated."""
order = models.SalesOrder.objects.create(
customer=self.customer,
reference=f'SO-SUBSET-{models.SalesOrder.objects.count()}',
)
line_a = SalesOrderLineItem.objects.create(
order=order, part=self.part, quantity=10
)
part_b = Part.objects.create(name='Part B', salable=True, description='')
line_b = SalesOrderLineItem.objects.create(
order=order, part=part_b, quantity=10
)
StockItem.objects.create(part=self.part, quantity=50)
StockItem.objects.create(part=part_b, quantity=50)
self.post(self._url(order.pk), {'line_items': [line_a.pk]}, expected_code=200)
self.assertTrue(line_a.is_fully_allocated())
self.assertFalse(line_b.is_fully_allocated())
def test_serialized_stock_only(self):
"""serialized_stock='serialized' allocates only serialized items."""
order, line, _ = self._make_order(qty=1)
# Unserialized item
StockItem.objects.create(part=self.part, quantity=50)
# Serialized item
serial_item = StockItem.objects.create(
part=self.part, quantity=1, serial='SN-001'
)
self.post(
self._url(order.pk), {'serialized_stock': 'serialized'}, expected_code=200
)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertEqual(allocs.count(), 1)
self.assertEqual(allocs.first().item, serial_item)
def test_unserialized_stock_only(self):
"""serialized_stock='unserialized' skips serialized items."""
order, line, _ = self._make_order(qty=10)
# Serialized items only
for sn in range(10):
StockItem.objects.create(part=self.part, quantity=1, serial=f'SN-{sn}')
self.post(
self._url(order.pk), {'serialized_stock': 'unserialized'}, expected_code=200
)
self.assertFalse(line.is_fully_allocated())
self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 0)
def test_stock_sort_by_quantity_asc(self):
"""stock_sort_by=QUANTITY_ASC consumes the smallest lot first."""
order, line, _ = self._make_order(qty=15)
small = StockItem.objects.create(part=self.part, quantity=5)
StockItem.objects.create(part=self.part, quantity=100)
self.post(
self._url(order.pk),
{
'stock_sort_by': str(StockSortOrder.QUANTITY_ASC),
'interchangeable': True,
},
expected_code=200,
)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertTrue(any(a.item == small and a.quantity == 5 for a in allocs))
def test_stock_sort_by_quantity_desc(self):
"""stock_sort_by=QUANTITY_DESC consumes the largest lot first, covering the requirement in one allocation."""
order, line, _ = self._make_order(qty=15)
StockItem.objects.create(part=self.part, quantity=5)
StockItem.objects.create(part=self.part, quantity=100)
self.post(
self._url(order.pk),
{
'stock_sort_by': str(StockSortOrder.QUANTITY_DESC),
'interchangeable': True,
},
expected_code=200,
)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertEqual(allocs.count(), 1)
self.assertEqual(allocs.first().quantity, 15)
def test_location_filter(self):
"""Only stock within the specified location is used."""
order, line, _ = self._make_order(qty=10)
StockItem.objects.create(part=self.part, quantity=50, location=self.loc_a)
StockItem.objects.create(part=self.part, quantity=50, location=self.loc_b)
self.post(self._url(order.pk), {'location': self.loc_a.pk}, expected_code=200)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertEqual(allocs.count(), 1)
self.assertEqual(allocs.first().item.location, self.loc_a)
def test_exclude_location_filter(self):
"""Stock in the excluded location is not used."""
order, line, _ = self._make_order(qty=10)
StockItem.objects.create(part=self.part, quantity=50, location=self.loc_a)
StockItem.objects.create(part=self.part, quantity=50, location=self.loc_b)
self.post(
self._url(order.pk), {'exclude_location': self.loc_a.pk}, expected_code=200
)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertEqual(allocs.count(), 1)
self.assertEqual(allocs.first().item.location, self.loc_b)
def test_shipment_assigned_to_allocations(self):
"""When a shipment is specified, all allocations are assigned to it."""
order, line, shipment = self._make_order(qty=20)
StockItem.objects.create(part=self.part, quantity=100)
self.post(self._url(order.pk), {'shipment': shipment.pk}, expected_code=200)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertTrue(allocs.exists())
for alloc in allocs:
self.assertEqual(alloc.shipment, shipment)
def test_interchangeable_false_skips_split_stock(self):
"""With interchangeable=False, allocation is skipped when no single item covers the full quantity."""
order, line, _ = self._make_order(qty=50)
StockItem.objects.create(part=self.part, quantity=20)
StockItem.objects.create(part=self.part, quantity=20)
self.post(self._url(order.pk), {'interchangeable': False}, expected_code=200)
self.assertFalse(line.is_fully_allocated())
self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 0)
class SalesOrderAllocationBulkDeleteAPITest(InvenTreeAPITestCase):
"""API integration tests for bulk-delete of SalesOrderAllocation, verifying shipped allocations are protected."""
fixtures = ['company', 'users']
roles = ['sales_order.add', 'sales_order.change', 'sales_order.delete']
@classmethod
def setUpTestData(cls):
"""Create shared order, part and stock for all tests."""
super().setUpTestData()
cls.customer = models.Company.objects.create(
name='BulkDelete Customer', is_customer=True, description=''
)
cls.part = Part.objects.create(
name='BulkDelete Part', salable=True, description=''
)
def _make_order_with_allocations(self, n_unshipped=2, n_shipped=1):
"""Return (order, unshipped_allocations, shipped_allocations)."""
order = models.SalesOrder.objects.create(
customer=self.customer,
reference=f'SO-BD-{models.SalesOrder.objects.count()}',
)
line = SalesOrderLineItem.objects.create(
order=order, part=self.part, quantity=100
)
unshipped_shipment = models.SalesOrderShipment.objects.create(
order=order, reference='1'
)
shipped_shipment = models.SalesOrderShipment.objects.create(
order=order, reference='2'
)
shipped_shipment.shipment_date = date.today()
shipped_shipment.save()
unshipped = []
for _ in range(n_unshipped):
item = StockItem.objects.create(part=self.part, quantity=10)
alloc = SalesOrderAllocation.objects.create(
line=line, item=item, quantity=10, shipment=unshipped_shipment
)
unshipped.append(alloc)
shipped = []
for _ in range(n_shipped):
item = StockItem.objects.create(part=self.part, quantity=10)
alloc = SalesOrderAllocation.objects.create(
line=line, item=item, quantity=10, shipment=shipped_shipment
)
shipped.append(alloc)
return order, unshipped, shipped
def _url(self):
return reverse('api-so-allocation-list')
# ------------------------------------------------------------------
# Basic bulk-delete
# ------------------------------------------------------------------
def test_bulk_delete_unshipped_allocations(self):
"""Unshipped allocations can be bulk-deleted."""
_, unshipped, _ = self._make_order_with_allocations(n_unshipped=2, n_shipped=0)
ids = [a.pk for a in unshipped]
self.delete(self._url(), {'items': ids}, expected_code=200)
self.assertFalse(SalesOrderAllocation.objects.filter(pk__in=ids).exists())
# ------------------------------------------------------------------
# Shipped allocation protection
# ------------------------------------------------------------------
def test_shipped_allocations_are_not_deleted(self):
"""Shipped allocations are silently skipped when included in a bulk-delete request."""
_, _, shipped = self._make_order_with_allocations(n_unshipped=0, n_shipped=2)
ids = [a.pk for a in shipped]
self.delete(self._url(), {'items': ids}, expected_code=200)
# All shipped allocations should still exist
self.assertEqual(SalesOrderAllocation.objects.filter(pk__in=ids).count(), 2)
def test_mixed_delete_removes_only_unshipped(self):
"""A bulk-delete of mixed shipped/unshipped allocations removes only the unshipped ones."""
_, unshipped, shipped = self._make_order_with_allocations(
n_unshipped=2, n_shipped=2
)
all_ids = [a.pk for a in unshipped] + [a.pk for a in shipped]
self.delete(self._url(), {'items': all_ids}, expected_code=200)
# Unshipped should be gone
for alloc in unshipped:
self.assertFalse(SalesOrderAllocation.objects.filter(pk=alloc.pk).exists())
# Shipped should remain
shipped_ids = [a.pk for a in shipped]
self.assertEqual(
SalesOrderAllocation.objects.filter(pk__in=shipped_ids).count(), 2
)

View File

@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.db.models import Sum
import order.tasks
from common.models import InvenTreeSetting, NotificationMessage
@ -20,7 +21,7 @@ from order.models import (
SalesOrderShipment,
)
from part.models import Part
from stock.models import StockItem
from stock.models import StockItem, StockLocation
from users.models import Owner
@ -619,3 +620,231 @@ class SalesOrderTest(InvenTreeTestCase):
# Ensure that virtual line item quantity values have been updated
for line in so.lines.all():
self.assertEqual(line.shipped, line.quantity)
class SalesOrderAutoAllocateTest(InvenTreeTestCase):
"""Tests for SalesOrder.auto_allocate_stock()."""
fixtures = ['company', 'users']
@classmethod
def setUpTestData(cls):
"""Create a basic order, line items, and stock items for each test."""
cls.customer = Company.objects.create(
name='Auto Alloc Co', description='', is_customer=True
)
cls.part = Part.objects.create(
name='Widget', salable=True, description='A salable widget'
)
cls.virtual_part = Part.objects.create(
name='Virtual Widget',
salable=True,
virtual=True,
description='A virtual part',
)
cls.loc_a = StockLocation.objects.create(name='Shelf A')
cls.loc_b = StockLocation.objects.create(name='Shelf B', parent=cls.loc_a)
def _make_order(self, qty=50):
order = SalesOrder.objects.create(customer=self.customer)
line = SalesOrderLineItem.objects.create(
order=order, part=self.part, quantity=qty
)
shipment = SalesOrderShipment.objects.create(order=order)
return order, line, shipment
def test_allocates_single_item(self):
"""A single stock item that covers the full quantity is fully allocated."""
order, line, _ = self._make_order(qty=30)
stock = StockItem.objects.create(part=self.part, quantity=100)
order.auto_allocate_stock()
self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 1)
alloc = SalesOrderAllocation.objects.get(line=line)
self.assertEqual(alloc.item, stock)
self.assertEqual(alloc.quantity, 30)
self.assertTrue(line.is_fully_allocated())
def test_interchangeable_consumes_multiple_items(self):
"""With interchangeable=True (default), multiple stock items are consumed."""
order, line, _ = self._make_order(qty=50)
StockItem.objects.create(part=self.part, quantity=20)
StockItem.objects.create(part=self.part, quantity=40)
order.auto_allocate_stock(interchangeable=True)
total = SalesOrderAllocation.objects.filter(line=line).aggregate(
t=Sum('quantity')
)['t']
self.assertEqual(total, 50)
self.assertTrue(line.is_fully_allocated())
def test_not_interchangeable_skips_split_stock(self):
"""With interchangeable=False, allocation is skipped when stock is split."""
order, line, _ = self._make_order(qty=50)
StockItem.objects.create(part=self.part, quantity=20)
StockItem.objects.create(part=self.part, quantity=20)
order.auto_allocate_stock(interchangeable=False)
self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 0)
self.assertFalse(line.is_fully_allocated())
def test_not_interchangeable_uses_single_sufficient_item(self):
"""With interchangeable=False, a single item that covers the full qty is used."""
order, line, _ = self._make_order(qty=30)
StockItem.objects.create(part=self.part, quantity=10)
StockItem.objects.create(part=self.part, quantity=50)
order.auto_allocate_stock(interchangeable=False)
self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 1)
alloc = SalesOrderAllocation.objects.get(line=line)
self.assertEqual(alloc.quantity, 30)
def test_location_filter(self):
"""Only stock within the specified location tree is considered."""
order, line, _ = self._make_order(qty=10)
StockItem.objects.create(part=self.part, quantity=100, location=self.loc_a)
StockItem.objects.create(part=self.part, quantity=100) # no location
order.auto_allocate_stock(location=self.loc_a)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertTrue(allocs.exists())
for alloc in allocs:
self.assertEqual(alloc.item.location, self.loc_a)
def test_exclude_location_filter(self):
"""Stock within the excluded location tree is not used."""
order, line, _ = self._make_order(qty=10)
excluded = StockItem.objects.create(
part=self.part, quantity=100, location=self.loc_a
)
included = StockItem.objects.create(part=self.part, quantity=100)
order.auto_allocate_stock(exclude_location=self.loc_a)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertTrue(allocs.exists())
allocated_items = [a.item for a in allocs]
self.assertNotIn(excluded, allocated_items)
self.assertIn(included, allocated_items)
def test_skips_fully_allocated_lines(self):
"""Lines that are already fully allocated are not touched."""
order, line, shipment = self._make_order(qty=10)
existing = StockItem.objects.create(part=self.part, quantity=100)
SalesOrderAllocation.objects.create(
line=line, item=existing, quantity=10, shipment=shipment
)
self.assertTrue(line.is_fully_allocated())
order.auto_allocate_stock()
self.assertEqual(SalesOrderAllocation.objects.filter(line=line).count(), 1)
def test_skips_virtual_parts(self):
"""Line items for virtual parts are skipped (virtual parts cannot hold stock)."""
order = SalesOrder.objects.create(customer=self.customer)
virtual_line = SalesOrderLineItem.objects.create(
order=order, part=self.virtual_part, quantity=5
)
order.auto_allocate_stock()
self.assertEqual(
SalesOrderAllocation.objects.filter(line=virtual_line).count(), 0
)
# Virtual parts are considered fully allocated without any stock
self.assertTrue(virtual_line.is_fully_allocated())
def test_allocates_serialized_stock(self):
"""Serialized stock items are included in auto-allocation."""
order, line, _ = self._make_order(qty=1)
serialized = StockItem.objects.create(
part=self.part, quantity=1, serial='SN001'
)
order.auto_allocate_stock()
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertEqual(allocs.count(), 1)
self.assertEqual(allocs.first().item, serialized)
self.assertTrue(line.is_fully_allocated())
def test_shipment_assigned(self):
"""Allocations are assigned to the provided shipment."""
order, line, shipment = self._make_order(qty=10)
StockItem.objects.create(part=self.part, quantity=50)
order.auto_allocate_stock(shipment=shipment)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertTrue(allocs.exists())
for alloc in allocs:
self.assertEqual(alloc.shipment, shipment)
def test_sort_quantity_asc(self):
"""quantity_asc sort consumes smallest lots first."""
order, line, _ = self._make_order(qty=15)
small = StockItem.objects.create(part=self.part, quantity=5)
StockItem.objects.create(part=self.part, quantity=100)
order.auto_allocate_stock(stock_sort_by='quantity', interchangeable=True)
allocs = SalesOrderAllocation.objects.filter(line=line).order_by('quantity')
self.assertTrue(allocs.exists())
# Small lot should be fully consumed
self.assertTrue(any(a.item == small and a.quantity == 5 for a in allocs))
def test_sort_quantity_desc(self):
"""quantity_desc sort consumes largest lots first."""
order, line, _ = self._make_order(qty=15)
StockItem.objects.create(part=self.part, quantity=5)
large = StockItem.objects.create(part=self.part, quantity=100)
order.auto_allocate_stock(stock_sort_by='-quantity', interchangeable=True)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertTrue(allocs.exists())
# Large lot should be the first consumed (fully covers 15)
self.assertEqual(allocs.count(), 1)
self.assertEqual(allocs.first().item, large)
def test_task_resolves_pk_params(self):
"""auto_allocate_sales_order task resolves location/shipment pks to instances."""
from order.tasks import auto_allocate_sales_order
order, line, shipment = self._make_order(qty=10)
StockItem.objects.create(part=self.part, quantity=50, location=self.loc_a)
auto_allocate_sales_order(
order.pk, location_id=self.loc_a.pk, shipment_id=shipment.pk
)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertTrue(allocs.exists())
for alloc in allocs:
self.assertEqual(alloc.shipment, shipment)
self.assertEqual(alloc.item.location, self.loc_a)
def test_task_exclude_location_pk(self):
"""auto_allocate_sales_order respects exclude_location_id parameter."""
from order.tasks import auto_allocate_sales_order
order, line, _ = self._make_order(qty=10)
excluded = StockItem.objects.create(
part=self.part, quantity=50, location=self.loc_a
)
included = StockItem.objects.create(part=self.part, quantity=50)
auto_allocate_sales_order(order.pk, exclude_location_id=self.loc_a.pk)
allocs = SalesOrderAllocation.objects.filter(line=line)
self.assertTrue(allocs.exists())
allocated_items = [a.item for a in allocs]
self.assertNotIn(excluded, allocated_items)
self.assertIn(included, allocated_items)

View File

@ -724,19 +724,21 @@ class Part(
"""
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
# Run the name through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
# Skip plugin validation checks during read-only management commands
if not InvenTree.ready.isReadOnlyCommand():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
# Run the name through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
try:
result = plugin.validate_part_name(self.name, self)
if result:
return
except ValidationError as exc:
if raise_error:
raise ValidationError({'name': exc.message})
except Exception:
log_error('validate_part_name', plugin=plugin.slug)
try:
result = plugin.validate_part_name(self.name, self)
if result:
return
except ValidationError as exc:
if raise_error:
raise ValidationError({'name': exc.message})
except Exception:
log_error('validate_part_name', plugin=plugin.slug)
def validate_ipn(self, raise_error=True):
"""Ensure that the IPN (internal part number) is valid for this Part".
@ -746,18 +748,20 @@ class Part(
"""
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
result = plugin.validate_part_ipn(self.IPN, self)
# Skip plugin validation checks during read-only management commands
if not InvenTree.ready.isReadOnlyCommand():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
result = plugin.validate_part_ipn(self.IPN, self)
if result:
# A "true" result force skips any subsequent checks
break
except ValidationError as exc:
if raise_error:
raise ValidationError({'IPN': exc.message})
except Exception:
log_error('validate_part_ipn', plugin=plugin.slug)
if result:
# A "true" result force skips any subsequent checks
break
except ValidationError as exc:
if raise_error:
raise ValidationError({'IPN': exc.message})
except Exception:
log_error('validate_part_ipn', plugin=plugin.slug)
# If we get to here, none of the plugins have raised an error
pattern = get_global_setting('PART_IPN_REGEX', '', create=False).strip()
@ -835,40 +839,41 @@ class Part(
Raises:
ValidationError if serial number is invalid and raise_error = True
"""
serial = str(serial).strip()
# First, throw the serial number against each of the loaded validation plugins
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
# Run the serial number through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
serial = str(serial).strip()
try:
result = False
if not InvenTree.ready.isReadOnlyCommand():
# First, throw the serial number against each of the loaded validation plugins
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
# Run the serial number through each custom validator
# If the plugin returns 'True' we will skip any subsequent validation
if hasattr(plugin, 'validate_serial_number'):
signature = inspect.signature(plugin.validate_serial_number)
try:
result = False
if 'stock_item' in signature.parameters:
# 2024-08-21: New method signature accepts a 'stock_item' parameter
result = plugin.validate_serial_number(
serial, self, stock_item=stock_item
)
if hasattr(plugin, 'validate_serial_number'):
signature = inspect.signature(plugin.validate_serial_number)
if 'stock_item' in signature.parameters:
# 2024-08-21: New method signature accepts a 'stock_item' parameter
result = plugin.validate_serial_number(
serial, self, stock_item=stock_item
)
else:
# Old method signature - does not accept a 'stock_item' parameter
result = plugin.validate_serial_number(serial, self)
if result is True:
return True
except ValidationError as exc:
if raise_error:
# Re-throw the error
raise exc
else:
# Old method signature - does not accept a 'stock_item' parameter
result = plugin.validate_serial_number(serial, self)
if result is True:
return True
except ValidationError as exc:
if raise_error:
# Re-throw the error
raise exc
else:
return False
except Exception:
log_error('validate_serial_number', plugin=plugin.slug)
return False
except Exception:
log_error('validate_serial_number', plugin=plugin.slug)
"""
If we are here, none of the loaded plugins (if any) threw an error or exited early
@ -960,7 +965,7 @@ class Part(
"""
from plugin import PluginMixinEnum, registry
if allow_plugins:
if allow_plugins and not InvenTree.ready.isReadOnlyCommand():
# Check with plugin system
# If any plugin returns a non-null result, that takes priority
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):

View File

@ -916,7 +916,14 @@ class StockFilter(FilterSet):
| Q(supplier_part__manufacturer_part__manufacturer=company)
).distinct()
# Update date filters
created_before = InvenTreeDateFilter(
label=_('Created before'), field_name='creation_date', lookup_expr='lt'
)
created_after = InvenTreeDateFilter(
label=_('Created after'), field_name='creation_date', lookup_expr='gt'
)
updated_before = InvenTreeDateFilter(
label=_('Updated before'), field_name='updated', lookup_expr='lt'
)
@ -933,6 +940,16 @@ class StockFilter(FilterSet):
label=_('Stocktake After'), field_name='stocktake_date', lookup_expr='gt'
)
has_stocktake = rest_filters.BooleanFilter(
label=_('Has Stocktake Date'), method='filter_has_stocktake'
)
def filter_has_stocktake(self, queryset, name, value):
"""Filter by whether or not the StockItem has a stocktake date."""
if str2bool(value):
return queryset.exclude(stocktake_date=None)
return queryset.filter(stocktake_date=None)
# Stock "expiry" filters
expiry_before = InvenTreeDateFilter(
label=_('Expiry date before'), field_name='expiry_date', lookup_expr='lt'
@ -1296,6 +1313,7 @@ class StockList(
'part__IPN',
'updated',
'purchase_price',
'creation_date',
'stocktake_date',
'expiry_date',
'packaging',

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-05-26 08:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("stock", "0119_alter_stockitemtestresult_date"),
]
operations = [
migrations.AddField(
model_name="stockitem",
name="creation_date",
field=models.DateTimeField(
blank=True,
help_text="Date that this stock item was created",
null=True,
verbose_name="Creation Date",
),
),
]

View File

@ -0,0 +1,102 @@
# Generated by Django 5.2.14 on 2026-05-26 08:49
import datetime
from tqdm import tqdm
from django.db import migrations
def set_creation_date(apps, schema_editor):
"""Set the creation_date field for existing StockItem entries."""
StockItem = apps.get_model('stock', 'StockItem')
StockItemTracking = apps.get_model('stock', 'StockItemTracking')
# First pass - find all StockHistoryCode.CREATED entries
# Note: As of 2026-05-26, the relevant status codes were:
# CREATED = 1
creation_entries = StockItemTracking.objects.filter(tracking_type=1)
items_to_update = []
def process_item(item):
"""Process a StockItem entry for update.
As we are iterating over a potentially large number of StockItem entries,
we periodically write updates to the database in batches to avoid memory issues.
"""
nonlocal items_to_update
items_to_update.append(item)
if len(items_to_update) >= 250:
StockItem.objects.bulk_update(items_to_update, ['creation_date'])
items_to_update = []
if creation_entries.count() > 0:
progress = tqdm(total=creation_entries.count(), desc='stock.0121: Setting creation_date for StockItem entries')
for entry in creation_entries:
item = StockItem.objects.filter(pk=entry.item_id).first()
if item and item.creation_date is None:
item.creation_date = entry.date
process_item(item)
progress.update(1)
progress.close()
# Next: Find any StockItem entries that have a null creation_date
items = StockItem.objects.filter(creation_date__isnull=True)
if items.count() > 0:
progress = tqdm(total=items.count(), desc='stock.0121: Setting creation_date for remaining StockItem entries')
for item in items:
# Source the creation_date from the stock item
earliest_entry = StockItemTracking.objects.filter(item_id=item.pk).order_by('date').first()
# Gather potential datetime sources for the creation_date field
utc = datetime.timezone.utc
def make_aware(dt):
"""Ensure a datetime is UTC-aware for safe cross-type comparison."""
return dt.replace(tzinfo=utc) if dt.tzinfo is None else dt.astimezone(utc)
raw_options = [
item.updated,
datetime.datetime(item.stocktake_date.year, item.stocktake_date.month, item.stocktake_date.day, tzinfo=utc) if item.stocktake_date else None,
earliest_entry.date if earliest_entry else None,
]
date_options = [make_aware(d) for d in raw_options if d is not None]
if date_options:
item.creation_date = min(date_options)
process_item(item)
progress.update(1)
# Update the remaining items
StockItem.objects.bulk_update(items_to_update, ['creation_date'], batch_size=250)
class Migration(migrations.Migration):
"""This migration extracts the 'creation_date' field for each StockItem entry.
- If a CREATED entry exists in the StockItemTracking table, this is used as the source for the creation_date field.
- If no CREATED entry exists, the creation_date is sourced from the earliest of the following
- The 'updated' field of the StockItem entry
- The 'stocktake_date' field of the StockItem entry (converted to a datetime)
- The earliest entry in the StockItemTracking table for that item
"""
dependencies = [
("stock", "0120_stockitem_creation_date"),
]
operations = [
migrations.RunPython(set_creation_date, reverse_code=migrations.RunPython.noop),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-05-26 09:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("stock", "0121_auto_20260526_0849"),
]
operations = [
migrations.AlterField(
model_name="stockitem",
name="creation_date",
field=models.DateTimeField(
auto_now_add=True,
help_text="Date that this stock item was created",
null=True,
verbose_name="Creation Date",
),
),
]

View File

@ -38,6 +38,7 @@ import stock.tasks
from common.icons import validate_icon
from common.settings import get_global_setting
from company import models as CompanyModels
from generic.enums import StringEnum
from generic.states import StatusCodeMixin
from generic.states.fields import InvenTreeCustomStatusModelField
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
@ -399,6 +400,27 @@ class StockItemReportContext(report.mixins.BaseReportContext):
test_templates: dict[str, PartModels.PartTestTemplate]
class StockSortOrder(StringEnum):
"""Enum of ORM sort fields available for stock auto-allocation."""
DATE_OLDEST = 'updated'
DATE_NEWEST = '-updated'
QUANTITY_ASC = 'quantity'
QUANTITY_DESC = '-quantity'
EXPIRY_SOONEST = 'expiry_date'
STOCK_SORT_CHOICES = [
(StockSortOrder.DATE_OLDEST, _('Oldest stock first (FIFO)')),
(StockSortOrder.DATE_NEWEST, _('Newest stock first (LIFO)')),
(StockSortOrder.QUANTITY_ASC, _('Smallest quantity first')),
(StockSortOrder.QUANTITY_DESC, _('Largest quantity first')),
(StockSortOrder.EXPIRY_SOONEST, _('Soonest expiry date first')),
]
STOCK_SORT_DEFAULT = StockSortOrder.DATE_OLDEST
class StockItem(
InvenTree.models.PluginValidationMixin,
InvenTree.models.InvenTreeAttachmentMixin,
@ -421,7 +443,8 @@ class StockItem(
batch: Batch number for this StockItem
serial: Unique serial number for this StockItem
link: Optional URL to link to external resource
updated: Date that this stock item was last updated (auto)
creation_date: Date that this stock item was created (auto)
updated: Date that the quantity of this stock item was last updated (auto)
expiry_date: Expiry date of the StockItem (optional)
stocktake_date: Date of last stocktake for this item
stocktake_user: User that performed the most recent stocktake
@ -756,24 +779,24 @@ class StockItem(
# First, let any plugins convert this serial number to an integer value
# If a non-null value is returned (by any plugin) we will use that
if not InvenTree.ready.isReadOnlyCommand():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
serial_int = plugin.convert_serial_to_int(serial)
except Exception:
InvenTree.exceptions.log_error(
'convert_serial_to_int', plugin=plugin.slug
)
serial_int = None
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
serial_int = plugin.convert_serial_to_int(serial)
except Exception:
InvenTree.exceptions.log_error(
'convert_serial_to_int', plugin=plugin.slug
)
serial_int = None
# Save the first returned result
if serial_int is not None:
# Ensure that it is clipped within a range allowed in the database schema
clip = 0x7FFFFFFF
serial_int = abs(serial_int)
serial_int = min(serial_int, clip)
# Return the first non-null value
return serial_int
# Save the first returned result
if serial_int is not None:
# Ensure that it is clipped within a range allowed in the database schema
clip = 0x7FFFFFFF
serial_int = abs(serial_int)
serial_int = min(serial_int, clip)
# Return the first non-null value
return serial_int
# None of the plugins provided a valid integer value
if serial not in [None, '']:
@ -899,15 +922,16 @@ class StockItem(
"""
from plugin import PluginMixinEnum, registry
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
plugin.validate_batch_code(self.batch, self)
except ValidationError as exc:
raise ValidationError({'batch': exc.message})
except Exception:
InvenTree.exceptions.log_error(
'validate_batch_code', plugin=plugin.slug
)
if not InvenTree.ready.isReadOnlyCommand():
for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION):
try:
plugin.validate_batch_code(self.batch, self)
except ValidationError as exc:
raise ValidationError({'batch': exc.message})
except Exception:
InvenTree.exceptions.log_error(
'validate_batch_code', plugin=plugin.slug
)
def clean(self):
"""Validate the StockItem object (separate to field validation).
@ -1205,6 +1229,15 @@ class StockItem(
related_name='stocktake_stock',
)
creation_date = models.DateTimeField(
null=True,
blank=True,
auto_now_add=True,
editable=False,
verbose_name=_('Creation Date'),
help_text=_('Date that this stock item was created'),
)
review_needed = models.BooleanField(default=False)
delete_on_deplete = models.BooleanField(
@ -2631,6 +2664,7 @@ class StockItem(
Keyword Arguments:
notes: Optional notes for the stocktake
status: Optionally adjust the stock status
location: Optionally set the stock location
"""
try:
count = Decimal(count)
@ -2642,6 +2676,14 @@ class StockItem(
tracking_info = {}
location = kwargs.pop('location', None)
if location and location != self.location:
old_location = self.location
self.location = location
tracking_info['location'] = location.pk
tracking_info['old_location'] = old_location.pk if old_location else None
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
if status and not self.compare_status(status):

View File

@ -371,8 +371,9 @@ class StockItemSerializer(
'SKU',
'MPN',
'barcode_hash',
'updated',
'creation_date',
'stocktake_date',
'updated',
'purchase_price',
'purchase_price_currency',
'use_pack_size',
@ -395,6 +396,7 @@ class StockItemSerializer(
read_only_fields = [
'allocated',
'barcode_hash',
'creation_date',
'stocktake_date',
'stocktake_user',
'updated',
@ -1751,6 +1753,20 @@ class StockAdjustmentSerializer(serializers.Serializer):
class StockCountSerializer(StockAdjustmentSerializer):
"""Serializer for counting stock items."""
class Meta:
"""Metaclass options."""
fields = ['items', 'notes', 'location']
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.filter(structural=False),
many=False,
required=False,
allow_null=True,
label=_('Location'),
help_text=_('Set stock location for counted items (optional)'),
)
def save(self):
"""Count stock."""
request = self.context['request']
@ -1758,6 +1774,7 @@ class StockCountSerializer(StockAdjustmentSerializer):
data = self.validated_data
items = data['items']
notes = data.get('notes', '')
location = data.get('location', None)
with transaction.atomic():
for item in items:
@ -1771,6 +1788,9 @@ class StockCountSerializer(StockAdjustmentSerializer):
if field_value := item.get(field_name, None):
extra[field_name] = field_value
if location is not None:
extra['location'] = location
stock_item.stocktake(quantity, request.user, notes=notes, **extra)

View File

@ -545,6 +545,62 @@ class StockItemListTest(StockAPITestCase):
for ordering in ['part', 'location', 'stock', 'status', 'IPN', 'MPN', 'SKU']:
self.run_ordering_test(self.list_url, ordering)
def test_creation_date_filter_and_ordering(self):
"""Test created_before / created_after filters and ordering by creation_date."""
import datetime
part = Part.objects.first()
location = StockLocation.objects.first()
# Create items with known, spread-out creation_dates via UPDATE after insert
dates = [
datetime.date(2020, 1, 1),
datetime.date(2021, 6, 15),
datetime.date(2023, 3, 30),
]
pks = []
for d in dates:
item = StockItem.objects.create(part=part, location=location, quantity=1)
StockItem.objects.filter(pk=item.pk).update(creation_date=d)
pks.append(item.pk)
# created_after=2020-12-31 should exclude the 2020 item
result_pks = [r['pk'] for r in self.get_stock(created_after='2020-12-31')]
self.assertNotIn(pks[0], result_pks)
self.assertIn(pks[1], result_pks)
self.assertIn(pks[2], result_pks)
# created_before=2022-01-01 should exclude the 2023 item
result_pks = [r['pk'] for r in self.get_stock(created_before='2022-01-01')]
self.assertIn(pks[0], result_pks)
self.assertIn(pks[1], result_pks)
self.assertNotIn(pks[2], result_pks)
# combined: only the 2021 item falls in the window
result_pks = [
r['pk']
for r in self.get_stock(
created_after='2020-12-31', created_before='2022-01-01'
)
]
self.assertNotIn(pks[0], result_pks)
self.assertIn(pks[1], result_pks)
self.assertNotIn(pks[2], result_pks)
# ordering=creation_date: our three items must appear in ascending date order
results = self.get(
self.list_url, {'ordering': 'creation_date'}, expected_code=200
).data
ordered_pks = [r['pk'] for r in results if r['pk'] in pks]
self.assertEqual(ordered_pks, pks)
# ordering=-creation_date: descending
results = self.get(
self.list_url, {'ordering': '-creation_date'}, expected_code=200
).data
ordered_pks = [r['pk'] for r in results if r['pk'] in pks]
self.assertEqual(ordered_pks, list(reversed(pks)))
def test_pagination(self):
"""Test that pagination boundaries are observed correctly.
@ -1454,6 +1510,49 @@ class StockItemTest(StockAPITestCase):
data={'part': 1, 'location': 1, 'quantity': 10},
expected_code=201,
)
# creation_date must be populated on the newly created item
item = StockItem.objects.get(pk=response.data[0]['pk'])
self.assertIsNotNone(item.creation_date)
def test_creation_date_is_readonly(self):
"""creation_date must not be modifiable via the API."""
item = StockItem.objects.create(
part=Part.objects.get(pk=1),
location=StockLocation.objects.get(pk=1),
quantity=1,
)
original_date = item.creation_date
self.assertIsNotNone(original_date)
url = reverse('api-stock-detail', kwargs={'pk': item.pk})
self.patch(
url, data={'creation_date': '2000-01-01T00:00:00Z'}, expected_code=200
)
# Field is read-only; the DB value must be unchanged
item.refresh_from_db()
self.assertEqual(item.creation_date, original_date)
def test_creation_date_set_on_serialize(self):
"""creation_date must be set on items produced by the serialize endpoint."""
# Stock item 100: part 25 (trackable), quantity 10, location 7
item = StockItem.objects.get(pk=100)
url = reverse('api-stock-item-serialize', kwargs={'pk': item.pk})
self.post(
url,
data={
'quantity': 3,
'serial_numbers': '901,902,903',
'destination': item.location.pk,
},
expected_code=201,
)
new_items = StockItem.objects.filter(
part=item.part, serial__in=['901', '902', '903']
)
self.assertEqual(new_items.count(), 3)
for new_item in new_items:
self.assertIsNotNone(new_item.creation_date)
def test_stock_item_create_with_supplier_part(self):
"""Test creation of a StockItem via the API, including SupplierPart data."""
@ -1597,6 +1696,7 @@ class StockItemTest(StockAPITestCase):
# Item location should have been set automatically
self.assertIsNotNone(item.location)
self.assertIn(item.serial, serials)
self.assertIsNotNone(item.creation_date)
# There now should be 10 unique stock entries for this part
self.assertEqual(trackable_part.stock_entries().count(), 10)
@ -2153,6 +2253,88 @@ class StocktakeTest(StockAPITestCase):
status_code=status.HTTP_400_BAD_REQUEST,
)
def test_count_with_location(self):
"""Test that the stock count endpoint correctly handles the optional location field."""
url = reverse('api-stock-count')
# Stock item pk=1234 starts at location 5; pk=1 starts at location 3
item_a = StockItem.objects.get(pk=1234)
item_b = StockItem.objects.get(pk=1)
self.assertEqual(item_a.location.pk, 5)
self.assertEqual(item_b.location.pk, 3)
# --- location is updated when provided (single item) ---
response = self.post(
url,
{'items': [{'pk': item_a.pk, 'quantity': 10}], 'location': 1},
expected_code=201,
)
self.assertEqual(response.data['items'][0]['pk'], item_a.pk)
item_a.refresh_from_db()
self.assertEqual(item_a.location.pk, 1)
# Tracking entry records the location change
entry = StockItemTracking.objects.filter(
item=item_a, tracking_type=StockHistoryCode.STOCK_COUNT
).latest('date')
self.assertEqual(entry.deltas.get('location'), 1)
self.assertEqual(entry.deltas.get('old_location'), 5)
# --- location is updated for multiple items simultaneously ---
response = self.post(
url,
{
'items': [
{'pk': item_a.pk, 'quantity': 5},
{'pk': item_b.pk, 'quantity': 20},
],
'location': 2,
},
expected_code=201,
)
self.assertEqual(len(response.data['items']), 2)
item_a.refresh_from_db()
item_b.refresh_from_db()
self.assertEqual(item_a.location.pk, 2)
self.assertEqual(item_b.location.pk, 2)
# Both items have a tracking entry with the new location
for item, old_loc in [(item_a, 1), (item_b, 3)]:
entry = StockItemTracking.objects.filter(
item=item, tracking_type=StockHistoryCode.STOCK_COUNT
).latest('date')
self.assertEqual(entry.deltas.get('location'), 2)
self.assertEqual(entry.deltas.get('old_location'), old_loc)
# --- location is unchanged when not provided ---
response = self.post(
url, {'items': [{'pk': item_a.pk, 'quantity': 7}]}, expected_code=201
)
item_a.refresh_from_db()
# Location should still be 2 (unchanged from the previous count)
self.assertEqual(item_a.location.pk, 2)
# Tracking entry has no location delta when location was not provided
entry = StockItemTracking.objects.filter(
item=item_a, tracking_type=StockHistoryCode.STOCK_COUNT
).latest('date')
self.assertNotIn('location', entry.deltas)
self.assertNotIn('old_location', entry.deltas)
# --- structural location is rejected ---
structural = StockLocation.objects.create(name='Structural', structural=True)
response = self.post(
url,
{'items': [{'pk': item_a.pk, 'quantity': 1}], 'location': structural.pk},
expected_code=400,
)
self.assertIn('does not exist', str(response.data['location']))
class StockTransferMergeTest(StockAPITestCase):
"""Tests for optional merge-on-transfer behavior."""

View File

@ -415,3 +415,172 @@ class TestStockItemTrackingMigration(MigratorTestCase):
)
self.assertIn('salesorder', item.deltas)
self.assertEqual(item.deltas['salesorder'], 1)
class TestCreationDateMigration(MigratorTestCase):
"""Test the backfill data migration for StockItem.creation_date (stock.0121).
The migration has two passes:
- Pass 1: items with a CREATED (tracking_type=1) entry get creation_date from that entry.
- Pass 2: remaining nulls get min(updated, stocktake_date, earliest_tracking_entry).
Six scenarios are exercised to cover every meaningful code path.
"""
migrate_from = ('stock', '0119_alter_stockitemtestresult_date')
migrate_to = ('stock', '0122_alter_stockitem_creation_date')
def prepare(self):
"""Create StockItem entries with varied data to exercise all backfill paths."""
import datetime
from django.db import connection
Part = self.old_state.apps.get_model('part', 'part')
StockItemTracking = self.old_state.apps.get_model('stock', 'stockitemtracking')
utc = datetime.timezone.utc
part = Part.objects.create(
name='Migration Test Part', level=0, tree_id=1, lft=0, rght=0
)
def make_item(stocktake_date=None):
"""Insert a StockItem row via raw SQL, bypassing the duplicate-column ORM bug.
The historical model at migration 0119 has status_custom_key enumerated twice
(once from contribute_to_class on the status field, once from migration 0113's
explicit AddField), so ORM-generated INSERTs fail with
'column specified more than once'. Raw SQL avoids the ORM field list entirely.
Raw SQL also leaves updated=NULL (no DB-level default for auto_now), which
makes Scenario 6 a clean "no date sources available" case.
"""
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO stock_stockitem
(part_id, quantity, level, tree_id, lft, rght,
status, delete_on_deplete, review_needed, is_building,
link, serial_int, barcode_data, barcode_hash)
VALUES (%s, 1, 0, 0, 0, 0, 10, false, false, false, '', 0, '', '')
RETURNING id
""",
[part.pk],
)
pk = cursor.fetchone()[0]
if stocktake_date is not None:
cursor.execute(
'UPDATE stock_stockitem SET stocktake_date = %s WHERE id = %s',
[stocktake_date, pk],
)
return pk
def add_tracking(pk, tracking_type, date):
"""Create a tracking entry, then override its auto_now_add date."""
entry = StockItemTracking.objects.create(
item_id=pk, tracking_type=tracking_type
)
# auto_now_add prevents setting date on INSERT; use UPDATE to set a historical value
StockItemTracking.objects.filter(pk=entry.pk).update(date=date)
# --- Scenario 1 ---
# Item with a single CREATED (type=1) tracking entry.
# Pass 1 should set creation_date = that entry's date.
pk = make_item()
add_tracking(pk, 1, datetime.datetime(2022, 1, 15, 10, 0, 0, tzinfo=utc))
self.pk_s1 = pk
self.expected_s1 = datetime.datetime(2022, 1, 15, 10, 0, 0, tzinfo=utc)
# --- Scenario 2 ---
# Item with a CREATED entry (newer date) AND an older non-CREATED entry.
# Pass 1 sets creation_date = CREATED entry date; older entry is ignored.
# This verifies pass 1 wins over pass 2's min() logic.
pk = make_item()
add_tracking(
pk, 1, datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=utc)
) # CREATED, newer
add_tracking(
pk, 2, datetime.datetime(2018, 3, 10, 0, 0, 0, tzinfo=utc)
) # non-CREATED, older
self.pk_s2 = pk
self.expected_s2 = datetime.datetime(2023, 6, 1, 0, 0, 0, tzinfo=utc)
self.rejected_s2 = datetime.date(2018, 3, 10)
# --- Scenario 3 ---
# Item with only non-CREATED tracking entries.
# Pass 2 uses min(earliest_entry_date), so earliest entry wins.
pk = make_item()
add_tracking(
pk, 2, datetime.datetime(2021, 7, 20, 0, 0, 0, tzinfo=utc)
) # later
add_tracking(
pk, 3, datetime.datetime(2020, 2, 14, 0, 0, 0, tzinfo=utc)
) # earliest
self.pk_s3 = pk
self.expected_s3 = datetime.datetime(2020, 2, 14, 0, 0, 0, tzinfo=utc)
# --- Scenario 4 ---
# Item with only stocktake_date set; no tracking entries.
# Pass 2 uses stocktake_as_datetime (in the past) as the date.
pk = make_item(stocktake_date=datetime.date(2019, 11, 5))
self.pk_s4 = pk
self.expected_s4_date = datetime.date(2019, 11, 5)
# --- Scenario 5 ---
# Item with stocktake_date AND a non-CREATED tracking entry where the tracking
# entry is older than stocktake_date.
# Pass 2 uses min(stocktake, tracking); tracking wins.
pk = make_item(stocktake_date=datetime.date(2021, 4, 1))
add_tracking(pk, 2, datetime.datetime(2017, 8, 22, 0, 0, 0, tzinfo=utc))
self.pk_s5 = pk
self.expected_s5 = datetime.datetime(2017, 8, 22, 0, 0, 0, tzinfo=utc)
# --- Scenario 6 ---
# Item inserted via raw SQL with no stocktake_date and no tracking entries,
# so updated=NULL. No date sources exist → creation_date stays NULL after migration.
pk = make_item()
self.pk_s6 = pk
def test_migration(self):
"""Verify creation_date is correctly backfilled for each scenario."""
import datetime
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
utc = datetime.timezone.utc
def at_utc(dt):
"""Normalise to UTC and strip sub-second precision for comparison."""
return dt.astimezone(utc).replace(microsecond=0)
# Scenario 1: CREATED tracking entry → creation_date = entry date
item = StockItem.objects.get(pk=self.pk_s1)
self.assertIsNotNone(item.creation_date)
self.assertEqual(at_utc(item.creation_date), self.expected_s1)
# Scenario 2: CREATED entry (newer) wins over older non-CREATED entry
item = StockItem.objects.get(pk=self.pk_s2)
self.assertIsNotNone(item.creation_date)
self.assertEqual(at_utc(item.creation_date), self.expected_s2)
# Explicitly confirm the older non-CREATED date was NOT chosen
self.assertNotEqual(item.creation_date.astimezone(utc).date(), self.rejected_s2)
# Scenario 3: Earliest non-CREATED tracking entry wins (pass 2 min())
item = StockItem.objects.get(pk=self.pk_s3)
self.assertIsNotNone(item.creation_date)
self.assertEqual(at_utc(item.creation_date), self.expected_s3)
# Scenario 4: stocktake_date (past) wins over auto_now 'updated' (now)
item = StockItem.objects.get(pk=self.pk_s4)
self.assertIsNotNone(item.creation_date)
self.assertEqual(
item.creation_date.astimezone(utc).date(), self.expected_s4_date
)
# Scenario 5: Oldest tracking entry wins over stocktake_date (pass 2 min())
item = StockItem.objects.get(pk=self.pk_s5)
self.assertIsNotNone(item.creation_date)
self.assertEqual(at_utc(item.creation_date), self.expected_s5)
# Scenario 6: updated=NULL, no stocktake, no tracking → creation_date stays NULL
item = StockItem.objects.get(pk=self.pk_s6)
self.assertIsNone(item.creation_date)

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
import { Alert, Stack } from '@mantine/core';
import { Alert, Stack, Text } from '@mantine/core';
import { ErrorBoundary, type FallbackRender } from '@sentry/react';
import { IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback } from 'react';
@ -14,8 +14,12 @@ export function DefaultFallback({
title={`INVE-E17: ${t`Error rendering component`}: ${title}`}
>
<Stack gap='xs'>
{t`An error occurred while rendering this component. Refer to the console for more information.`}
{t`Try reloading the page, or contact your administrator if the problem persists.`}
<Text size='sm'>
{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>
</Alert>
);

View File

@ -182,6 +182,7 @@ export enum ApiEndpoints {
sales_order_complete = 'order/so/:id/complete/',
sales_order_allocate = 'order/so/:id/allocate/',
sales_order_allocate_serials = 'order/so/:id/allocate-serials/',
sales_order_auto_allocate = 'order/so/:id/auto-allocate/',
sales_order_line_list = 'order/so-line/',
sales_order_extra_line_list = 'order/so-extra-line/',

View File

@ -5,14 +5,16 @@ import {
IconLink,
IconPlug,
IconPointer,
IconReport,
IconSettings,
IconTags,
IconUserBolt,
IconUserCog
} from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import { UserRoles } from '@lib/index';
import { ModelType, UserRoles } from '@lib/index';
import { openContextModal } from '@mantine/modals';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
@ -36,6 +38,8 @@ export function getActions(navigate: NavigateFunction) {
const user = useUserState();
const actions: SpotlightActionData[] = useMemo(() => {
const staff = user?.isStaff() ?? false;
const _actions: SpotlightActionData[] = [
{
id: 'dashboard',
@ -90,7 +94,7 @@ export function getActions(navigate: NavigateFunction) {
}
];
user?.isStaff() &&
staff &&
_actions.push({
id: 'data-import',
label: t`Import Data`,
@ -149,7 +153,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconLink size='1.2rem' />
});
user?.isStaff() &&
staff &&
_actions.push({
id: 'system-settings',
label: t`System Settings`,
@ -158,7 +162,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconSettings size='1.2rem' />
});
user?.isStaff() &&
staff &&
_actions.push({
id: 'admin-center',
label: t`Admin Center`,
@ -167,7 +171,7 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconUserBolt size='1.2rem' />
});
user?.isStaff() &&
staff &&
_actions.push({
id: 'plugin-settings',
label: t`Plugins`,
@ -176,6 +180,26 @@ export function getActions(navigate: NavigateFunction) {
leftSection: <IconPlug size='1.2rem' />
});
staff &&
user?.hasViewPermission(ModelType.reporttemplate) &&
_actions.push({
id: 'report-templates',
label: t`Report Templates`,
description: t`Manage report templates`,
onClick: () => navigate('/settings/admin/reports'),
leftSection: <IconReport size='1.2rem' />
});
staff &&
user?.hasViewPermission(ModelType.labeltemplate) &&
_actions.push({
id: 'label-templates',
label: t`Label Templates`,
description: t`Manage label templates`,
onClick: () => navigate('/settings/admin/labels'),
leftSection: <IconTags size='1.2rem' />
});
return _actions;
}, [navigate, setNavigationOpen, globalSettings, user]);

View File

@ -239,6 +239,10 @@ export function useBuildAutoAllocateFields({
optional_items: {
hidden: item_type === 'tracked',
value: item_type === 'tracked' ? false : undefined
},
stock_sort_by: {},
build_lines: {
hidden: true
}
};
}, [item_type]);

View File

@ -584,3 +584,28 @@ export function useSalesOrderAllocationFields({
};
}, [orderId, shipment]);
}
export function useSalesOrderAutoAllocateFields({
orderId
}: {
orderId: number;
}): ApiFormFieldSet {
return useMemo(() => {
return {
location: {},
exclude_location: {},
interchangeable: {},
stock_sort_by: {},
serialized_stock: {},
shipment: {
filters: {
order: orderId,
shipped: false
}
},
line_items: {
hidden: true
}
};
}, [orderId]);
}

View File

@ -993,6 +993,9 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
const initialValue = mapAdjustmentItems(items);
// Extract all location values from the items
const locations = [...new Set(items.map((item) => item.location))];
const fields: ApiFormFieldSet = {
items: {
field_type: 'table',
@ -1016,6 +1019,12 @@ function stockCountFields(items: any[]): ApiFormFieldSet {
{ title: t`Actions` }
]
},
location: {
value: locations.length === 1 ? locations[0] : undefined,
filters: {
structural: false
}
},
notes: {}
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More