Skip to content

Commit 5916d10

Browse files
Merge pull request #127 from OpenSPP/ken/optimize_spp_program_phase_4
perf(spp_programs): fetch fund balance once per approval batch
2 parents fbaf3a3 + c407beb commit 5916d10

8 files changed

Lines changed: 182 additions & 10 deletions

File tree

spp_programs/README.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,11 @@ Dependencies
254254
Changelog
255255
=========
256256

257+
19.0.2.0.4
258+
~~~~~~~~~~
259+
260+
- Fetch fund balance once per approval batch instead of per entitlement
261+
257262
19.0.2.0.3
258263
~~~~~~~~~~
259264

spp_programs/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"name": "OpenSPP Programs",
55
"summary": "Manage cash and in-kind entitlements, integrate with inventory, and enhance program management features for comprehensive social protection and agricultural support.",
66
"category": "OpenSPP/Core",
7-
"version": "19.0.2.0.3",
7+
"version": "19.0.2.0.4",
88
"sequence": 1,
99
"author": "OpenSPP.org",
1010
"website": "https://github.com/OpenSPP/OpenSPP2",

spp_programs/models/managers/entitlement_manager_base.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -577,10 +577,13 @@ def approve_entitlements(self, entitlements):
577577
entitlements.mapped("partner_id")
578578
entitlements.mapped("journal_id.currency_id")
579579

580+
# Fetch fund balance once for the whole batch instead of per entitlement
581+
fund_balance = self.check_fund_balance(entitlements[0].cycle_id.program_id.id)
582+
580583
for rec in entitlements:
581584
if rec.state in ("draft", "pending_validation"):
582-
fund_balance = self.check_fund_balance(rec.cycle_id.program_id.id) - amt
583-
if fund_balance >= rec.initial_amount:
585+
remaining_balance = fund_balance - amt
586+
if remaining_balance >= rec.initial_amount:
584587
amt += rec.initial_amount
585588
# Prepare journal entry (account.move) via account.payment
586589
amount = rec.initial_amount
@@ -634,7 +637,7 @@ def approve_entitlements(self, entitlements):
634637
+ "is insufficient for the entitlement: %(entitlement)s"
635638
) % {
636639
"program": rec.cycle_id.program_id.name,
637-
"fund": fund_balance,
640+
"fund": remaining_balance,
638641
"entitlement": rec.code,
639642
}
640643
# Stop the process and return an error

spp_programs/models/managers/entitlement_manager_cash.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,13 +406,16 @@ def approve_entitlements(self, entitlements):
406406
entitlements.mapped("partner_id.property_account_payable_id")
407407
entitlements.mapped("journal_id.currency_id")
408408

409+
# Fetch fund balance once for the whole batch instead of per entitlement
410+
fund_balance = self.check_fund_balance(entitlements[0].cycle_id.program_id.id)
411+
409412
state_err = 0
410413
message = ""
411414
sw = 0
412415
for rec in entitlements:
413416
if rec.state in ("draft", "pending_validation"):
414-
fund_balance = self.check_fund_balance(rec.cycle_id.program_id.id) - amt
415-
if fund_balance >= rec.initial_amount:
417+
remaining_balance = fund_balance - amt
418+
if remaining_balance >= rec.initial_amount:
416419
amt += rec.initial_amount
417420
# Prepare journal entry (account.move) via account.payment
418421
amount = rec.initial_amount
@@ -459,7 +462,7 @@ def approve_entitlements(self, entitlements):
459462
+ "is insufficient for the entitlement: %(entitlement)s"
460463
) % {
461464
"program": rec.cycle_id.program_id.name,
462-
"fund": fund_balance,
465+
"fund": remaining_balance,
463466
"entitlement": rec.code,
464467
}
465468
# Stop the process and return an error

spp_programs/readme/HISTORY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 19.0.2.0.4
2+
3+
- Fetch fund balance once per approval batch instead of per entitlement
4+
15
### 19.0.2.0.3
26

37
- Replace cycle computed fields (total_amount, entitlements_count, approval flags) with SQL aggregation queries

spp_programs/static/description/index.html

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -658,20 +658,26 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
658658
</div>
659659
</div>
660660
<div class="section" id="section-1">
661+
<h1>19.0.2.0.4</h1>
662+
<ul class="simple">
663+
<li>Fetch fund balance once per approval batch instead of per entitlement</li>
664+
</ul>
665+
</div>
666+
<div class="section" id="section-2">
661667
<h1>19.0.2.0.3</h1>
662668
<ul class="simple">
663669
<li>Replace cycle computed fields (total_amount, entitlements_count,
664670
approval flags) with SQL aggregation queries</li>
665671
</ul>
666672
</div>
667-
<div class="section" id="section-2">
673+
<div class="section" id="section-3">
668674
<h1>19.0.2.0.2</h1>
669675
<ul class="simple">
670676
<li>Add composite indexes for frequent query patterns on entitlements and
671677
program memberships</li>
672678
</ul>
673679
</div>
674-
<div class="section" id="section-3">
680+
<div class="section" id="section-4">
675681
<h1>19.0.2.0.1</h1>
676682
<ul class="simple">
677683
<li>Replace Python-level uniqueness checks with SQL UNIQUE constraints for
@@ -680,7 +686,7 @@ <h1>19.0.2.0.1</h1>
680686
constraint creation</li>
681687
</ul>
682688
</div>
683-
<div class="section" id="section-4">
689+
<div class="section" id="section-5">
684690
<h1>19.0.2.0.0</h1>
685691
<ul class="simple">
686692
<li>Initial migration to OpenSPP2</li>

spp_programs/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@
2121
from . import test_stock_rule
2222
from . import test_composite_indexes
2323
from . import test_cycle_computed_fields
24+
from . import test_fund_balance
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
import uuid
3+
from unittest.mock import patch
4+
5+
from odoo import fields
6+
from odoo.tests import TransactionCase
7+
8+
9+
class TestFundBalanceOptimization(TransactionCase):
10+
"""Test that fund balance is fetched once per approval batch, not per entitlement.
11+
12+
The approve_entitlements methods previously called check_fund_balance()
13+
(2 SQL queries) inside the per-entitlement loop. Now the balance is
14+
fetched once and tracked via a running total in Python.
15+
"""
16+
17+
def setUp(self):
18+
super().setUp()
19+
self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"})
20+
# Create a journal for the program
21+
self.journal = self.env["account.journal"].create(
22+
{
23+
"name": "Test Journal",
24+
"type": "bank",
25+
"code": f"TJ{uuid.uuid4().hex[:4].upper()}",
26+
}
27+
)
28+
self.program.journal_id = self.journal.id
29+
30+
self.cycle = self.env["spp.cycle"].create(
31+
{
32+
"name": "Test Cycle",
33+
"program_id": self.program.id,
34+
"start_date": fields.Date.today(),
35+
"end_date": fields.Date.today(),
36+
}
37+
)
38+
39+
self.registrants = self.env["res.partner"]
40+
for i in range(5):
41+
self.registrants |= self.env["res.partner"].create({"name": f"Registrant {i}", "is_registrant": True})
42+
43+
def _create_default_manager(self):
44+
return self.env["spp.program.entitlement.manager.default"].create(
45+
{
46+
"name": "Test Default Manager",
47+
"program_id": self.program.id,
48+
"amount_per_cycle": 100.0,
49+
}
50+
)
51+
52+
def _create_cash_manager(self):
53+
manager = self.env["spp.program.entitlement.manager.cash"].create(
54+
{
55+
"name": "Test Cash Manager",
56+
"program_id": self.program.id,
57+
}
58+
)
59+
self.env["spp.program.entitlement.manager.cash.item"].create(
60+
{
61+
"entitlement_id": manager.id,
62+
"amount": 100.0,
63+
}
64+
)
65+
return manager
66+
67+
def _create_entitlements(self, count=5, amount=100.0):
68+
"""Create multiple entitlements in pending_validation state."""
69+
entitlements = self.env["spp.entitlement"]
70+
for i in range(count):
71+
entitlements |= self.env["spp.entitlement"].create(
72+
{
73+
"partner_id": self.registrants[i].id,
74+
"cycle_id": self.cycle.id,
75+
"initial_amount": amount,
76+
"state": "pending_validation",
77+
"is_cash_entitlement": True,
78+
}
79+
)
80+
return entitlements
81+
82+
def test_default_manager_calls_check_fund_balance_once(self):
83+
"""DefaultCashEntitlementManager.approve_entitlements must call
84+
check_fund_balance at most once per batch."""
85+
manager = self._create_default_manager()
86+
entitlements = self._create_entitlements(count=3)
87+
88+
with patch.object(
89+
type(manager),
90+
"check_fund_balance",
91+
wraps=manager.check_fund_balance,
92+
) as mock_check:
93+
# Set fund balance high enough to approve all
94+
mock_check.return_value = 10000.0
95+
manager.approve_entitlements(entitlements)
96+
self.assertEqual(
97+
mock_check.call_count,
98+
1,
99+
f"check_fund_balance should be called exactly once, was called {mock_check.call_count} times",
100+
)
101+
102+
def test_cash_manager_calls_check_fund_balance_once(self):
103+
"""SppCashEntitlementManager.approve_entitlements must call
104+
check_fund_balance at most once per batch."""
105+
manager = self._create_cash_manager()
106+
entitlements = self._create_entitlements(count=3)
107+
108+
with patch.object(
109+
type(manager),
110+
"check_fund_balance",
111+
wraps=manager.check_fund_balance,
112+
) as mock_check:
113+
mock_check.return_value = 10000.0
114+
manager.approve_entitlements(entitlements)
115+
self.assertEqual(
116+
mock_check.call_count,
117+
1,
118+
f"check_fund_balance should be called exactly once, was called {mock_check.call_count} times",
119+
)
120+
121+
def test_fund_balance_insufficient_stops_early(self):
122+
"""When fund runs out mid-batch, remaining entitlements are not approved."""
123+
manager = self._create_default_manager()
124+
entitlements = self._create_entitlements(count=3, amount=100.0)
125+
126+
with patch.object(
127+
type(manager),
128+
"check_fund_balance",
129+
return_value=250.0,
130+
):
131+
state_err, message = manager.approve_entitlements(entitlements)
132+
self.assertEqual(state_err, 1)
133+
self.assertIn("insufficient", message)
134+
135+
def test_fund_balance_running_total_correct(self):
136+
"""Running total must correctly track cumulative approved amounts."""
137+
manager = self._create_default_manager()
138+
entitlements = self._create_entitlements(count=3, amount=100.0)
139+
140+
with patch.object(
141+
type(manager),
142+
"check_fund_balance",
143+
return_value=300.0,
144+
):
145+
state_err, _message = manager.approve_entitlements(entitlements)
146+
self.assertEqual(state_err, 0)
147+
# All 3 should be approved (300 fund, 3x100 = 300)
148+
for ent in entitlements:
149+
ent.invalidate_recordset(["state"])
150+
self.assertEqual(ent.state, "approved")

0 commit comments

Comments
 (0)