Merge branch 'master' into line-discount

This commit is contained in:
Oliver
2026-07-14 17:39:31 +10:00
committed by GitHub
5 changed files with 308 additions and 9 deletions
@@ -1037,6 +1037,13 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
# Ensure barcodes are unique # Ensure barcodes are unique
unique_barcodes = set() unique_barcodes = set()
# Ensure serial numbers are unique across all line items in this request
# (each line is only validated against the *database* individually)
unique_serials = set()
serials_globally_unique = get_global_setting(
'SERIAL_NUMBER_GLOBALLY_UNIQUE', False
)
# Check if the location is not specified for any particular item # Check if the location is not specified for any particular item
for item in items: for item in items:
line = item['line_item'] line = item['line_item']
@@ -1062,6 +1069,25 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
else: else:
unique_barcodes.add(barcode) unique_barcodes.add(barcode)
if serials := item.get('serials'):
if line.part:
# Scope uniqueness the same way as the database check:
# per part tree, or globally (if so configured)
for serial in serials:
key = (
str(serial)
if serials_globally_unique
else (line.part.part.tree_id, str(serial))
)
if key in unique_serials:
raise ValidationError(
_('Supplied serial numbers must be unique')
+ f': {serial}'
)
unique_serials.add(key)
return data return data
def save(self) -> list[stock.models.StockItem]: def save(self) -> list[stock.models.StockItem]:
+37
View File
@@ -1369,6 +1369,43 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(item.quantity, 10) self.assertEqual(item.quantity, 10)
self.assertEqual(item.batch, 'B-xyz-789') self.assertEqual(item.batch, 'B-xyz-789')
def test_duplicate_serial_numbers_across_items(self):
"""Duplicate serial numbers across line items in a single request are rejected.
Regression test: each line's serials used to be validated against the
database only, so two lines could claim the same serial number and create
duplicate serialized stock items.
"""
data = {
'items': [
{'line_item': 1, 'quantity': 3, 'serial_numbers': '100-102'},
{'line_item': 1, 'quantity': 3, 'serial_numbers': '102-104'},
],
'location': 1,
}
# Serial 102 is claimed by both entries - request must be rejected
response = self.post(self.url, data, expected_code=400)
self.assertIn('Supplied serial numbers must be unique', str(response.data))
# No new stock items have been created
self.assertEqual(self.n, StockItem.objects.count())
# Non-overlapping serial numbers are accepted
data['items'][1]['serial_numbers'] = '103-105'
self.post(self.url, data, expected_code=201, max_query_count=250)
self.assertEqual(self.n + 6, StockItem.objects.count())
for serial in range(100, 106):
self.assertEqual(
StockItem.objects.filter(serial=str(serial)).count(),
1,
f'Expected exactly one stock item with serial {serial}',
)
def test_receive_large_quantity(self): def test_receive_large_quantity(self):
"""Test receipt of a large number of items.""" """Test receipt of a large number of items."""
from stock.status_codes import StockStatus from stock.status_codes import StockStatus
+36 -8
View File
@@ -550,6 +550,9 @@ class StockItem(
def delete(self, ignore_serial_check: bool = False, **kwargs): def delete(self, ignore_serial_check: bool = False, **kwargs):
"""Custom delete method for StockItem model. """Custom delete method for StockItem model.
Any child items are re-linked to the parent of this item,
to preserve the stock item genealogy chain.
Arguments: Arguments:
ignore_serial_check: If True, allow deletion of serialized stock items regardless of global setting ignore_serial_check: If True, allow deletion of serialized stock items regardless of global setting
""" """
@@ -559,6 +562,13 @@ class StockItem(
if self.serialized: if self.serialized:
raise ValidationError(_('Serialized stock items cannot be deleted')) raise ValidationError(_('Serialized stock items cannot be deleted'))
with transaction.atomic():
# Re-link any child items to the parent of this item,
# so the genealogy chain survives deletion of an intermediate item
parent = StockItem.objects.filter(pk=self.parent_id).first()
StockItem.objects.filter(parent=self).update(parent=parent)
super().delete(**kwargs) super().delete(**kwargs)
@staticmethod @staticmethod
@@ -1036,7 +1046,7 @@ class StockItem(
"""Returns part name.""" """Returns part name."""
return self.part.full_name return self.part.full_name
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship # Note: When a StockItem is deleted, child items are re-linked to its parent (see delete())
parent = models.ForeignKey( parent = models.ForeignKey(
'stock.StockItem', 'stock.StockItem',
verbose_name=_('Parent Stock Item'), verbose_name=_('Parent Stock Item'),
@@ -2491,6 +2501,11 @@ class StockItem(
if location is None: if location is None:
return None return None
# This item must itself be in a state which allows merging
# (e.g. not serialized, in production, installed, or assigned to an order / customer)
if not self.can_merge():
return None
candidates = list( candidates = list(
StockItem.objects StockItem.objects
.filter(part=self.part, location=location) .filter(part=self.part, location=location)
@@ -2567,10 +2582,14 @@ class StockItem(
pricing_data.append([self.purchase_price, self.quantity]) pricing_data.append([self.purchase_price, self.quantity])
for other in other_items: for other in other_items:
# If the stock item cannot be merged, return # Check the merge in both directions, so that the generic state checks
if not self.can_merge(other, raise_error=raise_error, **kwargs): # (serialized, in production, installed, assigned to an order / customer)
# are applied to the incoming items as well as this one
if not self.can_merge(
other, raise_error=raise_error, **kwargs
) or not other.can_merge(self, raise_error=raise_error, **kwargs):
logger.warning( logger.warning(
'Stock item <%s> could not be merge into <%s>', other.pk, self.pk 'Stock item <%s> could not be merged into <%s>', other.pk, self.pk
) )
return return
@@ -3060,6 +3079,15 @@ class StockItem(
status = self._resolve_status_kwarg(kwargs) status = self._resolve_status_kwarg(kwargs)
self._apply_status_change(status, tracking_info) self._apply_status_change(status, tracking_info)
# Optional fields which can be supplied in a 'stocktake' call
self._apply_optional_transfer_fields(kwargs, tracking_info)
# Have any fields (other than quantity) been updated?
# Must be determined *before* model reference deltas are extracted,
# as those only affect the tracking entry (not the model instance)
fields_updated = len(tracking_info) > 0
self._apply_model_reference_fields(kwargs, tracking_info) self._apply_model_reference_fields(kwargs, tracking_info)
quantity_updated = self.serialized or self.updateQuantity(count) quantity_updated = self.serialized or self.updateQuantity(count)
@@ -3068,13 +3096,13 @@ class StockItem(
# (self.quantity is updated by updateQuantity() even if the item was deleted) # (self.quantity is updated by updateQuantity() even if the item was deleted)
tracking_info['quantity'] = 1 if self.serialized else float(self.quantity) tracking_info['quantity'] = 1 if self.serialized else float(self.quantity)
if quantity_updated: # Save if the quantity or any other field was changed.
# Note that updateQuantity() may have *deleted* the item (depleted to zero),
# in which case there is nothing left to save.
if self.pk and (quantity_updated or fields_updated):
self.stocktake_date = InvenTree.helpers.current_date() self.stocktake_date = InvenTree.helpers.current_date()
self.stocktake_user = user self.stocktake_user = user
# Optional fields which can be supplied in a 'stocktake' call
self._apply_optional_transfer_fields(kwargs, tracking_info)
self.save(add_note=False) self.save(add_note=False)
trigger_event( trigger_event(
+95
View File
@@ -2945,6 +2945,56 @@ class StocktakeTest(StockAPITestCase):
str(response.data['location']), str(response.data['location']),
) )
def test_count_unchanged_quantity_saves_field_changes(self):
"""Location / status changes are saved even if the counted quantity is unchanged.
Regression test: counting an item to its *existing* quantity used to skip
the model save entirely, losing any location / status change while still
recording the change in stock history.
"""
item = StockItem.objects.get(pk=1234)
quantity = item.quantity
self.assertEqual(item.location.pk, 5)
self.assertEqual(item.status, StockStatus.OK.value)
# Clear any existing stocktake date so we can verify it gets set
item.stocktake_date = None
item.save()
self.post(
reverse('api-stock-count'),
{
'items': [
{
'pk': item.pk,
'quantity': float(quantity),
'status': StockStatus.DAMAGED.value,
}
],
'location': 1,
},
expected_code=201,
)
item.refresh_from_db()
# Quantity is unchanged, but the location and status changes must be saved
self.assertEqual(item.quantity, quantity)
self.assertEqual(item.location.pk, 1)
self.assertEqual(item.status, StockStatus.DAMAGED.value)
self.assertIsNotNone(item.stocktake_date)
# The stock history entry must agree with the saved state
entry = StockItemTracking.objects.filter(
item=item, tracking_type=StockHistoryCode.STOCK_COUNT
).latest('date')
self.assertEqual(entry.deltas.get('quantity'), float(quantity))
self.assertEqual(entry.deltas.get('location'), 1)
self.assertEqual(entry.deltas.get('old_location'), 5)
self.assertEqual(entry.deltas.get('status'), StockStatus.DAMAGED.value)
self.assertEqual(entry.deltas.get('old_status'), StockStatus.OK.value)
def test_bulk_count_query_benchmark(self): def test_bulk_count_query_benchmark(self):
"""Benchmark: measure the number of DB queries required to count 100 stock items at once.""" """Benchmark: measure the number of DB queries required to count 100 stock items at once."""
InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', True, change_user=None) InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', True, change_user=None)
@@ -3271,6 +3321,51 @@ class StockTransferMergeTest(StockAPITestCase):
).exists() ).exists()
) )
def test_transfer_merge_skips_protected_source(self):
"""Merge-on-transfer must not absorb items in a protected state.
Regression test: only the *target* item used to be validated, so a
transfer with merge=True could absorb (and delete) an in-production
build output. Such items must be moved as a separate lot instead.
"""
existing = StockItem.objects.create(
part=self.part, location=self.dest, quantity=100
)
# An "in production" build output
self.part.assembly = True
self.part.save()
bo = build.models.Build.objects.create(
reference='BO-9999', part=self.part, title='Merge test build', quantity=50
)
building = StockItem.objects.create(
part=self.part,
location=self.source_loc,
quantity=50,
build=bo,
is_building=True,
)
self.post(
self.url,
{
'items': [{'pk': building.pk, 'quantity': 50, 'merge': True}],
'location': self.dest.pk,
},
expected_code=201,
)
# The build output must survive - transferred as a separate lot instead
building.refresh_from_db()
self.assertTrue(building.is_building)
self.assertEqual(building.location, self.dest)
self.assertEqual(building.quantity, 50)
existing.refresh_from_db()
self.assertEqual(existing.quantity, 100)
class StockItemDeletionTest(StockAPITestCase): class StockItemDeletionTest(StockAPITestCase):
"""Tests for stock item deletion via the API.""" """Tests for stock item deletion via the API."""
+113
View File
@@ -351,6 +351,71 @@ class StockTest(StockTestBase):
stock.splitStock(stock.quantity, None, self.user) stock.splitStock(stock.quantity, None, self.user)
self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1) self.assertEqual(StockItem.objects.filter(part=3).count(), n + 1)
def test_delete_reparents_children(self):
"""Test that deleting an intermediate item re-links children to the grandparent."""
grandparent = StockItem.objects.get(id=1234)
parent = grandparent.splitStock(200, None, self.user)
child_a = parent.splitStock(50, None, self.user)
child_b = parent.splitStock(50, None, self.user)
self.assertEqual(parent.parent, grandparent)
self.assertEqual(child_a.parent, parent)
self.assertEqual(child_b.parent, parent)
# Deleting the intermediate item grafts its children onto the grandparent
parent.delete()
child_a.refresh_from_db()
child_b.refresh_from_db()
self.assertEqual(child_a.parent, grandparent)
self.assertEqual(child_b.parent, grandparent)
# Deleting a top-level item leaves its children with no parent
grandparent.delete()
child_a.refresh_from_db()
child_b.refresh_from_db()
self.assertIsNone(child_a.parent)
self.assertIsNone(child_b.parent)
def test_implicit_delete_reparents_children(self):
"""Test child re-linking when items are deleted by depletion or merging."""
grandparent = StockItem.objects.get(id=1234)
# An item depleted to zero with delete_on_deplete set is deleted
parent = grandparent.splitStock(200, None, self.user)
child = parent.splitStock(50, None, self.user)
parent.delete_on_deplete = True
parent.save()
self.assertTrue(parent.take_stock(150, self.user, notes='Deplete'))
self.assertFalse(StockItem.objects.filter(pk=parent.pk).exists())
child.refresh_from_db()
self.assertEqual(child.parent, grandparent)
# An item absorbed by a merge is also deleted
source = grandparent.splitStock(100, None, self.user)
kid = source.splitStock(25, None, self.user)
target = StockItem.objects.create(
part=grandparent.part,
supplier_part=grandparent.supplier_part,
quantity=10,
location=grandparent.location,
)
target.merge_stock_items([source], raise_error=True, user=self.user)
self.assertFalse(StockItem.objects.filter(pk=source.pk).exists())
kid.refresh_from_db()
self.assertEqual(kid.parent, grandparent)
def test_over_adjustment_quantities(self): def test_over_adjustment_quantities(self):
"""Stock adjustments are clamped to the available stock quantity. """Stock adjustments are clamped to the available stock quantity.
@@ -1074,6 +1139,54 @@ class StockTest(StockTestBase):
# Final purchase price should be the weighted average # Final purchase price should be the weighted average
self.assertAlmostEqual(s1.purchase_price.amount, 16.875, places=3) self.assertAlmostEqual(s1.purchase_price.amount, 16.875, places=3)
def test_merge_protected_items(self):
"""Stock items in a protected state cannot be absorbed by a merge.
Regression test: merge_stock_items() used to run the generic state checks
(in production, assigned to customer, etc.) against the *target* item only,
allowing e.g. a build output to be merged away and deleted.
"""
part = Part.objects.first()
part.stock_items.all().delete()
target = StockItem.objects.create(part=part, quantity=10)
# The incoming item is "in production" (a build output)
part.assembly = True
part.save()
bo = Build.objects.create(
reference='BO-9998', part=part, title='Merge test build', quantity=20
)
building = StockItem.objects.create(
part=part, quantity=20, build=bo, is_building=True
)
# Without raise_error, the merge is refused silently
target.merge_stock_items([building])
target.refresh_from_db()
building.refresh_from_db()
self.assertEqual(part.stock_items.count(), 2)
self.assertEqual(target.quantity, 10)
self.assertEqual(building.quantity, 20)
# With raise_error, the merge raises a ValidationError
with self.assertRaises(ValidationError):
target.merge_stock_items([building], raise_error=True)
# An item assigned to a customer is likewise protected
customer = Company.objects.create(name='MergeCust', is_customer=True)
assigned = StockItem.objects.create(part=part, quantity=5, customer=customer)
target.merge_stock_items([assigned])
target.refresh_from_db()
self.assertEqual(target.quantity, 10)
self.assertTrue(StockItem.objects.filter(pk=assigned.pk).exists())
def test_notify_low_stock(self): def test_notify_low_stock(self):
"""Test that the 'notify_low_stock' task is triggered correctly.""" """Test that the 'notify_low_stock' task is triggered correctly."""
FUNC_NAME = 'part.tasks.notify_low_stock_if_required' FUNC_NAME = 'part.tasks.notify_low_stock_if_required'