From 5ecee1982b94ef450bcebf19610031c1b5666e03 Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Fri, 20 Mar 2026 10:10:04 +0700 Subject: [PATCH] perf(spp_programs): batch create entitlements and payments Cash entitlement manager now collects all entitlement dicts and calls create() once instead of per-beneficiary. Payment manager collects all payment dicts per batch tag and calls create() once, then batch-assigns via write(). Also prefetches bank_ids to avoid N+1 queries. --- .../managers/entitlement_manager_cash.py | 7 +- .../models/managers/payment_manager.py | 65 ++++--- spp_programs/tests/__init__.py | 1 + spp_programs/tests/test_batch_creation.py | 172 ++++++++++++++++++ 4 files changed, 217 insertions(+), 28 deletions(-) create mode 100644 spp_programs/tests/test_batch_creation.py diff --git a/spp_programs/models/managers/entitlement_manager_cash.py b/spp_programs/models/managers/entitlement_manager_cash.py index 790ae342..73fe6f9e 100644 --- a/spp_programs/models/managers/entitlement_manager_cash.py +++ b/spp_programs/models/managers/entitlement_manager_cash.py @@ -156,13 +156,16 @@ def prepare_entitlements(self, cycle, beneficiaries): if addl_fields: new_entitlements_to_create[beneficiary_id.id].update(addl_fields) - # Create entitlement records + # Create entitlement records in a single batch + vals_list = [] for ent in new_entitlements_to_create: initial_amount = new_entitlements_to_create[ent]["initial_amount"] new_entitlements_to_create[ent]["initial_amount"] = self._check_subsidy(initial_amount) # Create non-zero entitlements only if new_entitlements_to_create[ent]["initial_amount"] > 0.0: - self.env["spp.entitlement"].create(new_entitlements_to_create[ent]) + vals_list.append(new_entitlements_to_create[ent]) + if vals_list: + self.env["spp.entitlement"].create(vals_list) def _get_addl_entitlement_fields(self, beneficiary_id): """ diff --git a/spp_programs/models/managers/payment_manager.py b/spp_programs/models/managers/payment_manager.py index 5903a508..37ce2d17 100644 --- a/spp_programs/models/managers/payment_manager.py +++ b/spp_programs/models/managers/payment_manager.py @@ -211,8 +211,19 @@ def _prepare_payments(self, cycle, entitlements): entitlements -= tag_entitlements max_batch_size = batch_tag.max_batch_size - for i, entitlement_id in enumerate(tag_entitlements): - payment = self.env["spp.payment"].create( + if not tag_entitlements: + continue + + # Prefetch bank_ids to avoid N+1 queries + tag_entitlements.mapped("partner_id.bank_ids.acc_number") + + # Build all payment vals in one pass + payment_vals_list = [] + for entitlement_id in tag_entitlements: + account_number = None + if entitlement_id.partner_id.bank_ids: + account_number = entitlement_id.partner_id.bank_ids[0].acc_number + payment_vals_list.append( { "name": str(uuid4()), "entitlement_id": entitlement_id.id, @@ -220,33 +231,35 @@ def _prepare_payments(self, cycle, entitlements): "amount_issued": entitlement_id.initial_amount, "payment_fee": entitlement_id.transfer_fee, "state": "issued", + "account_number": account_number, } ) - if payment.partner_id.bank_ids: - payment.account_number = payment.partner_id.bank_ids[0].acc_number - else: - payment.account_number = None - if not payments: - payments = payment - else: - payments += payment - if create_batch: - if i % max_batch_size == 0: - curr_batch = self.env["spp.payment.batch"].create( - { - "name": str(uuid4()), - "cycle_id": cycle.id, - "stats_datetime": fields.Datetime.now(), - "tag_id": batch_tag.id, - } - ) - if not batches: - batches = curr_batch - else: - batches += curr_batch - curr_batch.payment_ids = [(4, payment.id)] - payment.batch_id = curr_batch + # Batch create all payments for this tag + tag_payments = self.env["spp.payment"].create(payment_vals_list) + + if not payments: + payments = tag_payments + else: + payments += tag_payments + + if create_batch: + # Assign payments to batches in chunks of max_batch_size + for i in range(0, len(tag_payments), max_batch_size): + batch_payments = tag_payments[i : i + max_batch_size] + curr_batch = self.env["spp.payment.batch"].create( + { + "name": str(uuid4()), + "cycle_id": cycle.id, + "stats_datetime": fields.Datetime.now(), + "tag_id": batch_tag.id, + } + ) + batch_payments.write({"batch_id": curr_batch.id}) + if not batches: + batches = curr_batch + else: + batches += curr_batch return payments, batches def _prepare_payments_async(self, cycle, entitlements, entitlements_count): diff --git a/spp_programs/tests/__init__.py b/spp_programs/tests/__init__.py index c56ebc92..192f9a98 100644 --- a/spp_programs/tests/__init__.py +++ b/spp_programs/tests/__init__.py @@ -15,3 +15,4 @@ from . import test_eligibility_cel_integration from . import test_compliance_cel from . import test_create_program_wizard_cel +from . import test_batch_creation diff --git a/spp_programs/tests/test_batch_creation.py b/spp_programs/tests/test_batch_creation.py new file mode 100644 index 00000000..2f40f9d3 --- /dev/null +++ b/spp_programs/tests/test_batch_creation.py @@ -0,0 +1,172 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import uuid +from unittest.mock import patch + +from odoo import fields +from odoo.tests import TransactionCase + + +class TestBatchEntitlementCreation(TransactionCase): + """Test that cash entitlement manager creates entitlements in a single + batch call instead of one-by-one.""" + + def setUp(self): + super().setUp() + self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"}) + self.journal = self.env["account.journal"].create( + { + "name": "Test Journal", + "type": "bank", + "code": f"TJ{uuid.uuid4().hex[:4].upper()}", + } + ) + self.program.journal_id = self.journal.id + + self.cycle = self.env["spp.cycle"].create( + { + "name": "Test Cycle", + "program_id": self.program.id, + "start_date": fields.Date.today(), + "end_date": fields.Date.today(), + } + ) + self.manager = self.env["spp.program.entitlement.manager.cash"].create( + { + "name": "Test Cash Manager", + "program_id": self.program.id, + } + ) + self.env["spp.program.entitlement.manager.cash.item"].create( + { + "entitlement_id": self.manager.id, + "amount": 100.0, + } + ) + + # Create beneficiaries with cycle memberships + self.registrants = self.env["res.partner"] + self.memberships = self.env["spp.cycle.membership"] + for i in range(5): + reg = self.env["res.partner"].create({"name": f"Registrant {i}", "is_registrant": True}) + self.registrants |= reg + self.memberships |= self.env["spp.cycle.membership"].create( + { + "partner_id": reg.id, + "cycle_id": self.cycle.id, + "state": "enrolled", + } + ) + + def test_cash_manager_batch_creates_entitlements(self): + """Cash entitlement manager must call create() at most once + (batch), not once per beneficiary.""" + original_create = type(self.env["spp.entitlement"]).create + + call_count = 0 + total_created = 0 + + def counting_create(self_model, vals_list): + nonlocal call_count, total_created + call_count += 1 + if isinstance(vals_list, list): + total_created += len(vals_list) + else: + total_created += 1 + return original_create(self_model, vals_list) + + with patch.object( + type(self.env["spp.entitlement"]), + "create", + counting_create, + ): + self.manager.prepare_entitlements(self.cycle, self.memberships) + + self.assertEqual( + call_count, + 1, + f"create() should be called once (batch), was called {call_count} times", + ) + self.assertEqual(total_created, 5) + + +class TestBatchPaymentCreation(TransactionCase): + """Test that payment manager creates payments in a single batch call + instead of one-by-one.""" + + def setUp(self): + super().setUp() + self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"}) + self.journal = self.env["account.journal"].create( + { + "name": "Test Journal", + "type": "bank", + "code": f"TJ{uuid.uuid4().hex[:4].upper()}", + } + ) + self.program.journal_id = self.journal.id + + self.cycle = self.env["spp.cycle"].create( + { + "name": "Test Cycle", + "program_id": self.program.id, + "start_date": fields.Date.today(), + "end_date": fields.Date.today(), + } + ) + self.payment_manager = self.env["spp.program.payment.manager.default"].create( + { + "name": "Test Payment Manager", + "program_id": self.program.id, + "create_batch": False, + } + ) + + # Create approved entitlements + self.entitlements = self.env["spp.entitlement"] + for i in range(5): + reg = self.env["res.partner"].create({"name": f"Registrant {i}", "is_registrant": True}) + self.entitlements |= self.env["spp.entitlement"].create( + { + "partner_id": reg.id, + "cycle_id": self.cycle.id, + "initial_amount": 100.0, + "state": "approved", + "is_cash_entitlement": True, + } + ) + + def test_payment_manager_batch_creates_payments(self): + """Payment manager must call create() at most once per batch tag + (batch), not once per entitlement.""" + original_create = type(self.env["spp.payment"]).create + + call_count = 0 + + def counting_create(self_model, vals_list): + nonlocal call_count + call_count += 1 + return original_create(self_model, vals_list) + + # Add a batch tag so we enter the loop + batch_tag = self.env["spp.payment.batch.tag"].create( + { + "name": "Test Tag", + "order": 1, + "domain": "[]", + "max_batch_size": 500, + } + ) + self.payment_manager.batch_tag_ids = [(4, batch_tag.id)] + + with patch.object( + type(self.env["spp.payment"]), + "create", + counting_create, + ): + self.payment_manager._prepare_payments(self.cycle, self.entitlements) + + self.assertEqual( + call_count, + 1, + f"create() should be called once (batch), was called {call_count} times", + )