From 9830be679d66623728dfd003d5e0edd23cf75cbb Mon Sep 17 00:00:00 2001 From: Aman Jain Date: Sat, 18 Jul 2026 17:07:33 -0700 Subject: [PATCH 1/3] fix: trigger pricing recalculation when SupplierPart is saved or deleted When a SupplierPart's pack_quantity is updated after price breaks have already been created, the Part's pricing (and BOM cost rollups for any assemblies using that part) was not recalculated. This is because there was no post_save or post_delete signal handler for the SupplierPart model to trigger schedule_pricing_update on the linked Part. Added post_save and post_delete signal handlers for SupplierPart that mirror the existing SupplierPriceBreak signal handlers. The pricing cascade (via update_assemblies) ensures BOM line costs in parent assemblies are also updated. Fixes #12285 --- src/backend/InvenTree/company/models.py | 34 +++++++++++++++++++++ src/backend/InvenTree/part/test_pricing.py | 35 ++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 29af645fbf7e..556dc782dffa 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -1035,6 +1035,40 @@ def get_api_url(): ) +@receiver( + post_save, sender=SupplierPart, dispatch_uid='post_save_supplier_part' +) +def after_save_supplier_part(sender, instance, created, **kwargs): + """Callback function when a SupplierPart is created or updated. + + Triggers a pricing update for the linked Part, so that changes to + pack_quantity are reflected in Part pricing and BOM cost rollups. + """ + if ( + InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING) + and not InvenTree.ready.isImportingData() + and instance.part + ): + instance.part.schedule_pricing_update(create=True) + + +@receiver( + post_delete, sender=SupplierPart, dispatch_uid='post_delete_supplier_part' +) +def after_delete_supplier_part(sender, instance, **kwargs): + """Callback function when a SupplierPart is deleted. + + Triggers a pricing update for the linked Part, so that removal of a + supplier part is reflected in Part pricing and BOM cost rollups. + """ + if ( + InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING) + and not InvenTree.ready.isImportingData() + and instance.part + ): + instance.part.schedule_pricing_update(create=False) + + @receiver( post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break' ) diff --git a/src/backend/InvenTree/part/test_pricing.py b/src/backend/InvenTree/part/test_pricing.py index 468b221d492f..fc69d755053d 100644 --- a/src/backend/InvenTree/part/test_pricing.py +++ b/src/backend/InvenTree/part/test_pricing.py @@ -173,6 +173,41 @@ def test_supplier_part_pricing(self): self.assertIsNone(pricing.supplier_price_min) self.assertIsNone(pricing.supplier_price_max) + @override_settings(TESTING_PRICING=True) + def test_supplier_part_pack_quantity_update(self): + """Test that changing pack_quantity on a SupplierPart triggers pricing recalculation.""" + pricing = self.part.pricing + + supplier = company.models.Company.objects.create( + name='Pack Test Supplier', is_supplier=True + ) + + sp = company.models.SupplierPart.objects.create( + supplier=supplier, + part=self.part, + SKU='PACK_TEST', + pack_quantity='1', + ) + + company.models.SupplierPriceBreak.objects.create( + part=sp, quantity=1, price=50, price_currency='USD' + ) + + pricing.refresh_from_db() + + # Price per unit should be $50 / 1 = $50 + self.assertEqual(pricing.supplier_price_min, Money(50, 'USD')) + + # Now update pack_quantity to 100 (i.e. 100 units per pack) + sp.pack_quantity = '100' + sp.save() + + pricing.refresh_from_db() + + # Price per unit should now be $50 / 100 = $0.50 + self.assertEqual(pricing.supplier_price_min, Money('0.5', 'USD')) + self.assertEqual(pricing.supplier_price_max, Money('0.5', 'USD')) + @override_settings(TESTING_PRICING=True) def test_internal_pricing(self): """Tests for internal price breaks.""" From 8e2fbfe62539d80a1784fc6727e0cede27f4c2bc Mon Sep 17 00:00:00 2001 From: Aman Jain Date: Tue, 28 Jul 2026 10:51:55 -0700 Subject: [PATCH 2/3] style: fix ruff format issues --- src/backend/InvenTree/company/models.py | 38 +++++++++++----------- src/backend/InvenTree/part/test_pricing.py | 5 +-- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 556dc782dffa..e9b8fa1e96df 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -683,17 +683,19 @@ def clean(self): if not self.part.units and not InvenTree.conversion.is_dimensionless( native_value ): - raise ValidationError({ - 'pack_quantity': _( - 'Pack units must be compatible with the base part units' - ) - }) + raise ValidationError( + { + 'pack_quantity': _( + 'Pack units must be compatible with the base part units' + ) + } + ) # Native value must be greater than zero if float(native_value.magnitude) <= 0: - raise ValidationError({ - 'pack_quantity': _('Pack units must be greater than zero') - }) + raise ValidationError( + {'pack_quantity': _('Pack units must be greater than zero')} + ) # Update native pack units value self.pack_quantity_native = Decimal(native_value.magnitude) @@ -704,11 +706,13 @@ def clean(self): # Ensure that the linked manufacturer_part points to the same part! if self.manufacturer_part and self.part: if self.manufacturer_part.part != self.part: - raise ValidationError({ - 'manufacturer_part': _( - 'Linked manufacturer part must reference the same base part' - ) - }) + raise ValidationError( + { + 'manufacturer_part': _( + 'Linked manufacturer part must reference the same base part' + ) + } + ) def save(self, *args, **kwargs): """Overriding save method to connect an existing ManufacturerPart.""" @@ -1035,9 +1039,7 @@ def get_api_url(): ) -@receiver( - post_save, sender=SupplierPart, dispatch_uid='post_save_supplier_part' -) +@receiver(post_save, sender=SupplierPart, dispatch_uid='post_save_supplier_part') def after_save_supplier_part(sender, instance, created, **kwargs): """Callback function when a SupplierPart is created or updated. @@ -1052,9 +1054,7 @@ def after_save_supplier_part(sender, instance, created, **kwargs): instance.part.schedule_pricing_update(create=True) -@receiver( - post_delete, sender=SupplierPart, dispatch_uid='post_delete_supplier_part' -) +@receiver(post_delete, sender=SupplierPart, dispatch_uid='post_delete_supplier_part') def after_delete_supplier_part(sender, instance, **kwargs): """Callback function when a SupplierPart is deleted. diff --git a/src/backend/InvenTree/part/test_pricing.py b/src/backend/InvenTree/part/test_pricing.py index fc69d755053d..2210bd908c92 100644 --- a/src/backend/InvenTree/part/test_pricing.py +++ b/src/backend/InvenTree/part/test_pricing.py @@ -183,10 +183,7 @@ def test_supplier_part_pack_quantity_update(self): ) sp = company.models.SupplierPart.objects.create( - supplier=supplier, - part=self.part, - SKU='PACK_TEST', - pack_quantity='1', + supplier=supplier, part=self.part, SKU='PACK_TEST', pack_quantity='1' ) company.models.SupplierPriceBreak.objects.create( From c1b2aef58d32b15234f40477d5631caadf32601e Mon Sep 17 00:00:00 2001 From: Aman Jain Date: Sun, 2 Aug 2026 07:25:37 -0700 Subject: [PATCH 3/3] style: apply ruff format with --preview flag (matching project config) --- src/backend/InvenTree/company/models.py | 30 +++++++++++-------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 769c5440b232..d2798a8699ad 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -687,19 +687,17 @@ def clean(self): if not self.part.units and not InvenTree.conversion.is_dimensionless( native_value ): - raise ValidationError( - { - 'pack_quantity': _( - 'Pack units must be compatible with the base part units' - ) - } - ) + raise ValidationError({ + 'pack_quantity': _( + 'Pack units must be compatible with the base part units' + ) + }) # Native value must be greater than zero if float(native_value.magnitude) <= 0: - raise ValidationError( - {'pack_quantity': _('Pack units must be greater than zero')} - ) + raise ValidationError({ + 'pack_quantity': _('Pack units must be greater than zero') + }) # Update native pack units value self.pack_quantity_native = Decimal(native_value.magnitude) @@ -710,13 +708,11 @@ def clean(self): # Ensure that the linked manufacturer_part points to the same part! if self.manufacturer_part and self.part: if self.manufacturer_part.part != self.part: - raise ValidationError( - { - 'manufacturer_part': _( - 'Linked manufacturer part must reference the same base part' - ) - } - ) + raise ValidationError({ + 'manufacturer_part': _( + 'Linked manufacturer part must reference the same base part' + ) + }) def save(self, *args, **kwargs): """Overriding save method to connect an existing ManufacturerPart."""