[refactor] SalesOrder allocation fixes (#12379)

* Use API for unit test

* Refactor SalesOrderSerialAllocationSerializer

- Move business logic into the SalesOrder model

* Add unit testing for serial allocation
This commit is contained in:
Oliver 2026-07-14 10:34:54 +10:00 committed by GitHub
parent d9b9a87893
commit 6be654954e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 301 additions and 95 deletions

View File

@ -1633,6 +1633,97 @@ class SalesOrder(TotalPriceMixin, Order):
SalesOrderAllocation.objects.bulk_create(new_allocations, batch_size=250)
@transaction.atomic
def allocate_serial_numbers(
self,
line_item: 'SalesOrderLineItem',
quantity: int,
serial_numbers: str,
shipment: Optional['SalesOrderShipment'] = None,
) -> list['SalesOrderAllocation']:
"""Allocate stock items against this SalesOrder, by serial number.
Arguments:
line_item: The SalesOrderLineItem to allocate against
quantity: The number of serial numbers expected
serial_numbers: A string of serial numbers to allocate (e.g. "1,2,3-5")
shipment: Optional shipment to assign the allocations to
Raises:
ValidationError: If the line item does not belong to this order,
the serial numbers cannot be parsed, or any of the requested
serial numbers do not exist or are unavailable for allocation.
"""
if line_item.order != self:
raise ValidationError(_('Line item is not associated with this order'))
part = line_item.part
serials = InvenTree.helpers.extract_serial_numbers(
serial_numbers, quantity, part.get_latest_serial_number(), part=part
)
serials_not_exist = set()
serials_unavailable = set()
stock_items_to_allocate = []
for serial in serials:
serial = str(serial).strip()
items = stock.models.StockItem.objects.filter(
part=part, serial=serial, quantity=1
)
if not items.exists():
serials_not_exist.add(serial)
continue
stock_item = items[0]
if get_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS'):
if (
stock_item.hasRequiredTests()
and not stock_item.passedAllRequiredTests()
):
serials_unavailable.add(serial)
continue
if not stock_item.in_stock:
serials_unavailable.add(serial)
continue
if stock_item.unallocated_quantity() < 1:
serials_unavailable.add(serial)
continue
# At this point, the serial number is valid, and can be added to the list
stock_items_to_allocate.append(stock_item)
if len(serials_not_exist) > 0:
error_msg = _('No match found for the following serial numbers')
error_msg += ': '
error_msg += ','.join(sorted(serials_not_exist))
raise ValidationError({'serial_numbers': error_msg})
if len(serials_unavailable) > 0:
error_msg = _('The following serial numbers are unavailable')
error_msg += ': '
error_msg += ','.join(sorted(serials_unavailable))
raise ValidationError({'serial_numbers': error_msg})
allocations = [
SalesOrderAllocation(
line=line_item, item=stock_item, quantity=1, shipment=shipment
)
for stock_item in stock_items_to_allocate
]
SalesOrderAllocation.objects.bulk_create(allocations, batch_size=250)
return allocations
def is_completed(self) -> bool:
"""Check if this order is "shipped" (all line items delivered).

View File

@ -1871,104 +1871,23 @@ class SalesOrderSerialAllocationSerializer(serializers.Serializer):
return shipment
def validate(self, data):
"""Validation for the serializer.
- Ensure the serial_numbers and quantity fields match
- Check that all serial numbers exist
- Check that the serial numbers are not yet allocated
"""
data = super().validate(data)
def save(self):
"""Allocate stock items against the sales order, by serial number."""
data = self.validated_data
sales_order = self.context['order']
line_item = data['line_item']
quantity = data['quantity']
serial_numbers = data['serial_numbers']
part = line_item.part
try:
data['serials'] = extract_serial_numbers(
serial_numbers, quantity, part.get_latest_serial_number(), part=part
)
except DjangoValidationError as e:
raise ValidationError({'serial_numbers': e.messages})
serials_not_exist = set()
serials_unavailable = set()
stock_items_to_allocate = []
for serial in data['serials']:
serial = str(serial).strip()
items = stock.models.StockItem.objects.filter(
part=part, serial=serial, quantity=1
)
if not items.exists():
serials_not_exist.add(str(serial))
continue
stock_item = items[0]
if get_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS'):
if (
stock_item.hasRequiredTests()
and not stock_item.passedAllRequiredTests()
):
serials_unavailable.add(str(serial))
continue
if not stock_item.in_stock:
serials_unavailable.add(str(serial))
continue
if stock_item.unallocated_quantity() < 1:
serials_unavailable.add(str(serial))
continue
# At this point, the serial number is valid, and can be added to the list
stock_items_to_allocate.append(stock_item)
if len(serials_not_exist) > 0:
error_msg = _('No match found for the following serial numbers')
error_msg += ': '
error_msg += ','.join(sorted(serials_not_exist))
raise ValidationError({'serial_numbers': error_msg})
if len(serials_unavailable) > 0:
error_msg = _('The following serial numbers are unavailable')
error_msg += ': '
error_msg += ','.join(sorted(serials_unavailable))
raise ValidationError({'serial_numbers': error_msg})
data['stock_items'] = stock_items_to_allocate
return data
def save(self):
"""Allocate stock items against the sales order."""
data = self.validated_data
line_item = data['line_item']
stock_items = data['stock_items']
shipment = data.get('shipment', None)
allocations = []
for stock_item in stock_items:
# Create a new SalesOrderAllocation
allocations.append(
order.models.SalesOrderAllocation(
line=line_item, item=stock_item, quantity=1, shipment=shipment
)
)
with transaction.atomic():
order.models.SalesOrderAllocation.objects.bulk_create(
allocations, batch_size=250
try:
return sales_order.allocate_serial_numbers(
line_item, quantity, serial_numbers, shipment=shipment
)
except (ValidationError, DjangoValidationError) as exc:
# Catch model errors and re-throw as DRF errors
raise ValidationError(detail=serializers.as_serializer_error(exc))
class SalesOrderShipmentAllocationSerializer(serializers.Serializer):

View File

@ -2698,6 +2698,184 @@ class SalesOrderAllocateTest(OrderTest):
response = self.post(self.url, data, expected_code=201)
class SalesOrderAllocateSerialsTest(OrderTest):
"""Unit tests for allocating stock items against a SalesOrder, by serial number."""
@classmethod
def setUpTestData(cls):
"""Init routine for this unit test class."""
super().setUpTestData()
def setUp(self):
"""Init routines for this unit testing class."""
super().setUp()
self.assignRole('sales_order.add')
self.url = reverse('api-so-allocate-serials', kwargs={'pk': 1})
self.order = models.SalesOrder.objects.get(pk=1)
self.part = Part.objects.create(
name='Serial Allocation Part',
salable=True,
trackable=True,
description='A trackable part for serial allocation tests',
)
self.line = models.SalesOrderLineItem.objects.create(
order=self.order, part=self.part, quantity=5
)
# Create some serialized stock items for this part
self.stock_items = [
StockItem.objects.create(part=self.part, quantity=1, serial=str(n))
for n in range(1, 6)
]
self.shipment = models.SalesOrderShipment.objects.create(order=self.order)
def test_allocate(self):
"""Test that we can allocate stock items to a SalesOrder line, by serial number."""
self.assertEqual(self.order.stock_allocations.count(), 0)
data = {'line_item': self.line.pk, 'quantity': 3, 'serial_numbers': '1,2,3'}
self.post(self.url, data, expected_code=201)
self.assertEqual(self.order.stock_allocations.count(), 3)
allocated_serials = {
allocation.item.serial for allocation in self.order.stock_allocations.all()
}
self.assertEqual(allocated_serials, {'1', '2', '3'})
for allocation in self.order.stock_allocations.all():
self.assertEqual(allocation.quantity, 1)
self.assertIsNone(allocation.shipment)
def test_allocate_with_shipment(self):
"""Test that allocations are correctly assigned to a provided shipment."""
data = {
'line_item': self.line.pk,
'quantity': 2,
'serial_numbers': '4,5',
'shipment': self.shipment.pk,
}
self.post(self.url, data, expected_code=201)
for allocation in self.order.stock_allocations.all():
self.assertEqual(allocation.shipment, self.shipment)
def test_invalid_line_item(self):
"""Test that a line item belonging to a different order is rejected."""
other_order = models.SalesOrder.objects.exclude(pk=self.order.pk).first()
other_line = models.SalesOrderLineItem.objects.create(
order=other_order, part=self.part, quantity=5
)
data = {'line_item': other_line.pk, 'quantity': 1, 'serial_numbers': '1'}
response = self.post(self.url, data, expected_code=400)
self.assertIn('Line item is not associated with this order', str(response.data))
self.assertEqual(self.order.stock_allocations.count(), 0)
def test_shipment_already_shipped(self):
"""Test that a shipment which has already been shipped is rejected."""
self.shipment.shipment_date = date.today()
self.shipment.save()
data = {
'line_item': self.line.pk,
'quantity': 1,
'serial_numbers': '1',
'shipment': self.shipment.pk,
}
response = self.post(self.url, data, expected_code=400)
self.assertIn('Shipment has already been shipped', str(response.data))
self.assertEqual(self.order.stock_allocations.count(), 0)
def test_shipment_wrong_order(self):
"""Test that a shipment belonging to a different order is rejected."""
other_order = models.SalesOrder.objects.exclude(pk=self.order.pk).first()
other_shipment = models.SalesOrderShipment.objects.create(order=other_order)
data = {
'line_item': self.line.pk,
'quantity': 1,
'serial_numbers': '1',
'shipment': other_shipment.pk,
}
response = self.post(self.url, data, expected_code=400)
self.assertIn('Shipment is not associated with this order', str(response.data))
self.assertEqual(self.order.stock_allocations.count(), 0)
def test_serial_not_exist(self):
"""Test that non-existent serial numbers are rejected."""
data = {'line_item': self.line.pk, 'quantity': 1, 'serial_numbers': '999'}
response = self.post(self.url, data, expected_code=400)
self.assertIn(
'No match found for the following serial numbers', str(response.data)
)
self.assertIn('999', str(response.data))
self.assertEqual(self.order.stock_allocations.count(), 0)
def test_serial_unavailable(self):
"""Test that an already-allocated serial number is rejected as unavailable."""
# Fully allocate stock item with serial '1' against some other line
models.SalesOrderAllocation.objects.create(
line=self.line, item=self.stock_items[0], quantity=1
)
data = {'line_item': self.line.pk, 'quantity': 1, 'serial_numbers': '1'}
response = self.post(self.url, data, expected_code=400)
self.assertIn(
'The following serial numbers are unavailable', str(response.data)
)
self.assertIn('1', str(response.data))
# No *additional* allocation should have been created
self.assertEqual(self.order.stock_allocations.count(), 1)
def test_block_on_required_tests(self):
"""Test the SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS setting."""
from part.models import PartTestTemplate
self.part.testable = True
self.part.save()
PartTestTemplate.objects.create(
part=self.part, test_name='A required test', required=True
)
data = {'line_item': self.line.pk, 'quantity': 1, 'serial_numbers': '1'}
set_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS', True)
response = self.post(self.url, data, expected_code=400)
self.assertIn(
'The following serial numbers are unavailable', str(response.data)
)
set_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS', False)
self.post(self.url, data, expected_code=201)
class ReturnOrderTests(InvenTreeAPITestCase):
"""Unit tests for ReturnOrder API endpoints."""

View File

@ -6,13 +6,18 @@ 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
from django.urls import reverse
import order.tasks
from common.models import InvenTreeSetting, NotificationMessage
from common.settings import set_global_setting
from company.models import Address, Company
from InvenTree import status_codes as status
from InvenTree.unit_test import InvenTreeTestCase, addUserPermission
from InvenTree.unit_test import (
InvenTreeAPITestCase,
InvenTreeTestCase,
addUserPermission,
)
from order.models import (
SalesOrder,
SalesOrderAllocation,
@ -25,14 +30,17 @@ from stock.models import StockItem, StockItemTracking, StockLocation
from users.models import Owner
class SalesOrderTest(InvenTreeTestCase):
class SalesOrderTest(InvenTreeAPITestCase):
"""Run tests to ensure that the SalesOrder model is working correctly."""
fixtures = ['company', 'users']
roles = ['sales_order.add']
@classmethod
def setUpTestData(cls):
"""Initial setup for this set of unit tests."""
super().setUpTestData()
# Create a Company to ship the goods to
cls.customer = Company.objects.create(
name='ABC Co', description='My customer', is_customer=True
@ -455,8 +463,18 @@ class SalesOrderTest(InvenTreeTestCase):
self.assertFalse(shipment.is_complete())
self.assertTrue(shipment.check_can_complete(raise_error=False))
# Complete the shipment
shipment.complete_shipment(None)
# Complete the shipment via the API
self.assignRole('sales_order.add')
url = reverse('api-so-shipment-ship', kwargs={'pk': shipment.pk})
response = self.post(
url,
expected_code=200,
benchmark=True,
max_query_time=100,
max_query_count=10000,
)
self.assertEqual(response.status_code, 200)
shipment.refresh_from_db()
self.assertIsNotNone(shipment.shipment_date)