Skip to content

Commit 003ea5d

Browse files
Improve logic for automatically updating part pricing (#8090) (#8122)
* Improve logic for automatically updating part pricing * Simplify logic * Update unit tests to ensure pricing flows upwards * Unit test update * Add unit tests for handling of "multi level" BOM pricing * ADjust unit tests * Improve efficiency of operation * Adjust testing for pricing - Only allow pricing updates in testing if TESTING_PRICING flag is set * Tweak when pricing updates are performed * More tweaks
1 parent 4e8c59c commit 003ea5d

File tree

8 files changed

+202
-92
lines changed

8 files changed

+202
-92
lines changed

src/backend/InvenTree/InvenTree/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,6 +1284,9 @@
12841284
# Flag to allow table events during testing
12851285
TESTING_TABLE_EVENTS = False
12861286

1287+
# Flag to allow pricing recalculations during testing
1288+
TESTING_PRICING = False
1289+
12871290
# User interface customization values
12881291
CUSTOM_LOGO = get_custom_file(
12891292
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True

src/backend/InvenTree/InvenTree/unit_test.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
264264
MAX_QUERY_TIME = 7.5
265265

266266
@contextmanager
267-
def assertNumQueriesLessThan(self, value, using='default', verbose=None, url=None):
267+
def assertNumQueriesLessThan(self, value, using='default', verbose=False, url=None):
268268
"""Context manager to check that the number of queries is less than a certain value.
269269
270270
Example:
@@ -282,10 +282,8 @@ def assertNumQueriesLessThan(self, value, using='default', verbose=None, url=Non
282282
f'Query count exceeded at {url}: Expected < {value} queries, got {n}'
283283
) # pragma: no cover
284284

285-
if verbose or n >= value:
286-
msg = '\r\n%s' % json.dumps(
287-
context.captured_queries, indent=4
288-
) # pragma: no cover
285+
if verbose and n >= value:
286+
msg = f'\r\n{json.dumps(context.captured_queries, indent=4)}' # pragma: no cover
289287
else:
290288
msg = None
291289

src/backend/InvenTree/build/test_api.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,12 @@ def setUpTestData(cls):
966966
outputs = cls.build.build_outputs.all()
967967
cls.build.complete_build_output(outputs[0], cls.user)
968968

969+
def setUp(self):
970+
"""Basic operation as part of test suite setup"""
971+
super().setUp()
972+
973+
self.generate_exchange_rates()
974+
969975
def test_setup(self):
970976
"""Validate expected state after set-up."""
971977
self.assertEqual(self.build.incomplete_outputs.count(), 0)
@@ -994,7 +1000,7 @@ def test_overallocated_requires_acceptance(self):
9941000
'accept_overallocated': 'accept',
9951001
},
9961002
expected_code=201,
997-
max_query_count=550, # TODO: Come back and refactor this
1003+
max_query_count=1000, # TODO: Come back and refactor this
9981004
)
9991005

10001006
self.build.refresh_from_db()
@@ -1015,9 +1021,11 @@ def test_overallocated_can_trim(self):
10151021
'accept_overallocated': 'trim',
10161022
},
10171023
expected_code=201,
1018-
max_query_count=555, # TODO: Come back and refactor this
1024+
max_query_count=1000, # TODO: Come back and refactor this
10191025
)
10201026

1027+
# Note: Large number of queries is due to pricing recalculation for each stock item
1028+
10211029
self.build.refresh_from_db()
10221030

10231031
# Build should have been marked as complete

src/backend/InvenTree/company/models.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,7 +1052,10 @@ def get_api_url():
10521052
)
10531053
def after_save_supplier_price(sender, instance, created, **kwargs):
10541054
"""Callback function when a SupplierPriceBreak is created or updated."""
1055-
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
1055+
if (
1056+
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
1057+
and not InvenTree.ready.isImportingData()
1058+
):
10561059
if instance.part and instance.part.part:
10571060
instance.part.part.schedule_pricing_update(create=True)
10581061

@@ -1064,6 +1067,9 @@ def after_save_supplier_price(sender, instance, created, **kwargs):
10641067
)
10651068
def after_delete_supplier_price(sender, instance, **kwargs):
10661069
"""Callback function when a SupplierPriceBreak is deleted."""
1067-
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
1070+
if (
1071+
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
1072+
and not InvenTree.ready.isImportingData()
1073+
):
10681074
if instance.part and instance.part.part:
10691075
instance.part.part.schedule_pricing_update(create=False)

src/backend/InvenTree/part/models.py

Lines changed: 50 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1931,7 +1931,7 @@ def pricing(self):
19311931

19321932
return pricing
19331933

1934-
def schedule_pricing_update(self, create: bool = False, test: bool = False):
1934+
def schedule_pricing_update(self, create: bool = False):
19351935
"""Helper function to schedule a pricing update.
19361936
19371937
Importantly, catches any errors which may occur during deletion of related objects,
@@ -1941,7 +1941,6 @@ def schedule_pricing_update(self, create: bool = False, test: bool = False):
19411941
19421942
Arguments:
19431943
create: Whether or not a new PartPricing object should be created if it does not already exist
1944-
test: Whether or not the pricing update is allowed during unit tests
19451944
"""
19461945
try:
19471946
self.refresh_from_db()
@@ -1952,7 +1951,7 @@ def schedule_pricing_update(self, create: bool = False, test: bool = False):
19521951
pricing = self.pricing
19531952

19541953
if create or pricing.pk:
1955-
pricing.schedule_for_update(test=test)
1954+
pricing.schedule_for_update()
19561955
except IntegrityError:
19571956
# If this part instance has been deleted,
19581957
# some post-delete or post-save signals may still be fired
@@ -2532,7 +2531,8 @@ class PartPricing(common.models.MetaMixin):
25322531
- Detailed pricing information is very context specific in any case
25332532
"""
25342533

2535-
price_modified = False
2534+
# When calculating assembly pricing, we limit the depth of the calculation
2535+
MAX_PRICING_DEPTH = 50
25362536

25372537
@property
25382538
def is_valid(self):
@@ -2561,14 +2561,10 @@ def convert(self, money):
25612561

25622562
return result
25632563

2564-
def schedule_for_update(self, counter: int = 0, test: bool = False):
2564+
def schedule_for_update(self, counter: int = 0):
25652565
"""Schedule this pricing to be updated."""
25662566
import InvenTree.ready
25672567

2568-
# If we are running within CI, only schedule the update if the test flag is set
2569-
if settings.TESTING and not test:
2570-
return
2571-
25722568
# If importing data, skip pricing update
25732569
if InvenTree.ready.isImportingData():
25742570
return
@@ -2612,7 +2608,7 @@ def schedule_for_update(self, counter: int = 0, test: bool = False):
26122608
logger.debug('Pricing for %s already scheduled for update - skipping', p)
26132609
return
26142610

2615-
if counter > 25:
2611+
if counter > self.MAX_PRICING_DEPTH:
26162612
# Prevent infinite recursion / stack depth issues
26172613
logger.debug(
26182614
counter, f'Skipping pricing update for {p} - maximum depth exceeded'
@@ -2631,16 +2627,36 @@ def schedule_for_update(self, counter: int = 0, test: bool = False):
26312627

26322628
import part.tasks as part_tasks
26332629

2630+
# Pricing calculations are performed in the background,
2631+
# unless the TESTING_PRICING flag is set
2632+
background = not settings.TESTING or not settings.TESTING_PRICING
2633+
26342634
# Offload task to update the pricing
2635-
# Force async, to prevent running in the foreground
2635+
# Force async, to prevent running in the foreground (unless in testing mode)
26362636
InvenTree.tasks.offload_task(
2637-
part_tasks.update_part_pricing, self, counter=counter, force_async=True
2637+
part_tasks.update_part_pricing,
2638+
self,
2639+
counter=counter,
2640+
force_async=background,
26382641
)
26392642

2640-
def update_pricing(self, counter: int = 0, cascade: bool = True):
2641-
"""Recalculate all cost data for the referenced Part instance."""
2642-
# If importing data, skip pricing update
2643+
def update_pricing(
2644+
self,
2645+
counter: int = 0,
2646+
cascade: bool = True,
2647+
previous_min=None,
2648+
previous_max=None,
2649+
):
2650+
"""Recalculate all cost data for the referenced Part instance.
2651+
2652+
Arguments:
2653+
counter: Recursion counter (used to prevent infinite recursion)
2654+
cascade: If True, update pricing for all assemblies and templates which use this part
2655+
previous_min: Previous minimum price (used to prevent further updates if unchanged)
2656+
previous_max: Previous maximum price (used to prevent further updates if unchanged)
26432657
2658+
"""
2659+
# If importing data, skip pricing update
26442660
if InvenTree.ready.isImportingData():
26452661
return
26462662

@@ -2671,18 +2687,25 @@ def update_pricing(self, counter: int = 0, cascade: bool = True):
26712687
# Background worker processes may try to concurrently update
26722688
pass
26732689

2690+
pricing_changed = False
2691+
2692+
# Without previous pricing data, we assume that the pricing has changed
2693+
if previous_min != self.overall_min or previous_max != self.overall_max:
2694+
pricing_changed = True
2695+
26742696
# Update parent assemblies and templates
2675-
if cascade and self.price_modified:
2697+
if pricing_changed and cascade:
26762698
self.update_assemblies(counter)
26772699
self.update_templates(counter)
26782700

26792701
def update_assemblies(self, counter: int = 0):
26802702
"""Schedule updates for any assemblies which use this part."""
26812703
# If the linked Part is used in any assemblies, schedule a pricing update for those assemblies
2704+
26822705
used_in_parts = self.part.get_used_in()
26832706

26842707
for p in used_in_parts:
2685-
p.pricing.schedule_for_update(counter + 1)
2708+
p.pricing.schedule_for_update(counter=counter + 1)
26862709

26872710
def update_templates(self, counter: int = 0):
26882711
"""Schedule updates for any template parts above this part."""
@@ -2698,13 +2721,13 @@ def save(self, *args, **kwargs):
26982721

26992722
try:
27002723
self.update_overall_cost()
2701-
except IntegrityError:
2724+
except Exception:
27022725
# If something has happened to the Part model, might throw an error
27032726
pass
27042727

27052728
try:
27062729
super().save(*args, **kwargs)
2707-
except IntegrityError:
2730+
except Exception:
27082731
# This error may be thrown if there is already duplicate pricing data
27092732
pass
27102733

@@ -2772,9 +2795,6 @@ def update_bom_cost(self, save=True):
27722795

27732796
any_max_elements = True
27742797

2775-
old_bom_cost_min = self.bom_cost_min
2776-
old_bom_cost_max = self.bom_cost_max
2777-
27782798
if any_min_elements:
27792799
self.bom_cost_min = cumulative_min
27802800
else:
@@ -2785,12 +2805,6 @@ def update_bom_cost(self, save=True):
27852805
else:
27862806
self.bom_cost_max = None
27872807

2788-
if (
2789-
old_bom_cost_min != self.bom_cost_min
2790-
or old_bom_cost_max != self.bom_cost_max
2791-
):
2792-
self.price_modified = True
2793-
27942808
if save:
27952809
self.save()
27962810

@@ -2854,12 +2868,6 @@ def update_purchase_cost(self, save=True):
28542868
if purchase_max is None or cost > purchase_max:
28552869
purchase_max = cost
28562870

2857-
if (
2858-
self.purchase_cost_min != purchase_min
2859-
or self.purchase_cost_max != purchase_max
2860-
):
2861-
self.price_modified = True
2862-
28632871
self.purchase_cost_min = purchase_min
28642872
self.purchase_cost_max = purchase_max
28652873

@@ -2886,12 +2894,6 @@ def update_internal_cost(self, save=True):
28862894
if max_int_cost is None or cost > max_int_cost:
28872895
max_int_cost = cost
28882896

2889-
if (
2890-
self.internal_cost_min != min_int_cost
2891-
or self.internal_cost_max != max_int_cost
2892-
):
2893-
self.price_modified = True
2894-
28952897
self.internal_cost_min = min_int_cost
28962898
self.internal_cost_max = max_int_cost
28972899

@@ -2927,12 +2929,6 @@ def update_supplier_cost(self, save=True):
29272929
if max_sup_cost is None or cost > max_sup_cost:
29282930
max_sup_cost = cost
29292931

2930-
if (
2931-
self.supplier_price_min != min_sup_cost
2932-
or self.supplier_price_max != max_sup_cost
2933-
):
2934-
self.price_modified = True
2935-
29362932
self.supplier_price_min = min_sup_cost
29372933
self.supplier_price_max = max_sup_cost
29382934

@@ -2968,9 +2964,6 @@ def update_variant_cost(self, save=True):
29682964
if variant_max is None or v_max > variant_max:
29692965
variant_max = v_max
29702966

2971-
if self.variant_cost_min != variant_min or self.variant_cost_max != variant_max:
2972-
self.price_modified = True
2973-
29742967
self.variant_cost_min = variant_min
29752968
self.variant_cost_max = variant_max
29762969

@@ -3091,12 +3084,6 @@ def update_sale_cost(self, save=True):
30913084
if max_sell_history is None or cost > max_sell_history:
30923085
max_sell_history = cost
30933086

3094-
if (
3095-
self.sale_history_min != min_sell_history
3096-
or self.sale_history_max != max_sell_history
3097-
):
3098-
self.price_modified = True
3099-
31003087
self.sale_history_min = min_sell_history
31013088
self.sale_history_max = max_sell_history
31023089

@@ -4509,7 +4496,10 @@ def update_bom_build_lines(sender, instance, created, **kwargs):
45094496
def update_pricing_after_edit(sender, instance, created, **kwargs):
45104497
"""Callback function when a part price break is created or updated."""
45114498
# Update part pricing *unless* we are importing data
4512-
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
4499+
if (
4500+
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
4501+
and not InvenTree.ready.isImportingData()
4502+
):
45134503
if instance.part:
45144504
instance.part.schedule_pricing_update(create=True)
45154505

@@ -4526,7 +4516,10 @@ def update_pricing_after_edit(sender, instance, created, **kwargs):
45264516
def update_pricing_after_delete(sender, instance, **kwargs):
45274517
"""Callback function when a part price break is deleted."""
45284518
# Update part pricing *unless* we are importing data
4529-
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
4519+
if (
4520+
InvenTree.ready.canAppAccessDatabase(allow_test=settings.TESTING_PRICING)
4521+
and not InvenTree.ready.isImportingData()
4522+
):
45304523
if instance.part:
45314524
instance.part.schedule_pricing_update(create=False)
45324525

src/backend/InvenTree/part/tasks.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ def update_part_pricing(pricing: part.models.PartPricing, counter: int = 0):
7373
"""
7474
logger.info('Updating part pricing for %s', pricing.part)
7575

76-
pricing.update_pricing(counter=counter)
76+
pricing.update_pricing(
77+
counter=counter,
78+
previous_min=pricing.overall_min,
79+
previous_max=pricing.overall_max,
80+
)
7781

7882

7983
@scheduled_task(ScheduledTask.DAILY)

0 commit comments

Comments
 (0)