diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 6e126f558bdd..d2798a8699ad 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -1039,6 +1039,36 @@ 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..2210bd908c92 100644 --- a/src/backend/InvenTree/part/test_pricing.py +++ b/src/backend/InvenTree/part/test_pricing.py @@ -173,6 +173,38 @@ 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."""