From 1977e87be44d74d1a7522e9a920d111e7223fa11 Mon Sep 17 00:00:00 2001 From: skanndar Date: Thu, 27 Aug 2026 21:23:23 +0200 Subject: [PATCH] [FIX] auditlog: flush pending recomputations before swapping the cache With a full-log rule on account.move, registering a payment leaves the payment's liquidity line with amount_residual, amount_residual_currency and reconciled NULL in the database. The outstanding-credits widget domain requires a non-NULL residual, so the payment is never proposed as an outstanding credit on the invoice. The old/new-values reads of the full log run inside ThrowAwayCache while records touched earlier in the transaction still have pending recomputations of stored computed fields. Tracing the transaction shows the context manager restores tocompute intact on exit, but after the swap/read/restore cycle those pending recomputations are consumed by the subsequent write and flush without ever being persisted: they leave tocompute, never appear in field_dirty, and the columns stay NULL. Flushing everything still pending before setting the cache aside removes the hazard: with nothing pending, there is nothing the swap can lose. Fixes #3635 --- auditlog/models/auditlog_rule.py | 7 +++ test_auditlog/tests/__init__.py | 1 + test_auditlog/tests/test_payment_residual.py | 57 ++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 test_auditlog/tests/test_payment_residual.py diff --git a/auditlog/models/auditlog_rule.py b/auditlog/models/auditlog_rule.py index 137cf766938..964612e78fe 100644 --- a/auditlog/models/auditlog_rule.py +++ b/auditlog/models/auditlog_rule.py @@ -70,6 +70,7 @@ class ThrowAwayCache: ] def __init__(self, env): + self._env = env self._transaction = env.transaction def __enter__(self): @@ -80,6 +81,12 @@ def __enter__(self): to the cursor, so if we want to keep using the same cursor, we need to patch out these properties. """ + # Flush any pending updates and recomputations first. If the cache is + # set aside while recomputations of stored computed fields are still + # pending, they end up being consumed after the swap/read/restore + # cycle without ever being persisted, leaving NULL columns in the + # database (see OCA/server-tools#3635). + self._env.flush_all() for attribute in self.transaction_attributes: instance = getattr(self._transaction, attribute) setattr( diff --git a/test_auditlog/tests/__init__.py b/test_auditlog/tests/__init__.py index 10c5f5c6aae..1b92fbaf3b7 100644 --- a/test_auditlog/tests/__init__.py +++ b/test_auditlog/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_account_move_reverse from . import test_product_tax_multicompany from . import test_account_reentrancy +from . import test_payment_residual diff --git a/test_auditlog/tests/test_payment_residual.py b/test_auditlog/tests/test_payment_residual.py new file mode 100644 index 00000000000..e720b50cc11 --- /dev/null +++ b/test_auditlog/tests/test_payment_residual.py @@ -0,0 +1,57 @@ +from odoo.tests import tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.auditlog.tests.common import AuditLogRuleCommon + + +@tagged("post_install", "-at_install") +class TestPaymentResidual(AccountTestInvoicingCommon, AuditLogRuleCommon): + def setUp(self): + super().setUp() + self.rule = self.env["auditlog.rule"].create( + { + "name": __name__, + "model_id": self.env.ref("account.model_account_move").id, + "log_read": True, + "log_create": True, + "log_write": True, + "log_unlink": True, + "log_type": "full", + } + ) + self.rule.set_to_confirmed() + + def test_register_payment_computes_residual(self): + """Payment lines keep their stored computed values with a full rule. + + With a full-log rule on account.move, the swapped-cache reads of the + log diff must not cause the pending recomputations of the payment + lines' stored computed fields to be lost, or those columns end up + NULL in the database (issue #3635: the payment is then never offered + as an outstanding credit on the invoice). + """ + invoice = self.init_invoice("out_invoice", products=self.product_a, post=True) + wizard = ( + self.env["account.payment.register"] + .with_context(active_model="account.move", active_ids=invoice.ids) + .create({"journal_id": self.company_data["default_journal_bank"].id}) + ) + payments = wizard._create_payments() + self.env.flush_all() + self.env.cr.execute( + """ + SELECT id, amount_residual, amount_residual_currency, reconciled + FROM account_move_line + WHERE move_id = %s + """, + (payments.move_id.id,), + ) + rows = self.env.cr.fetchall() + self.assertTrue(rows) + for line_id, residual, residual_currency, reconciled in rows: + self.assertIsNotNone(residual, f"line {line_id}: amount_residual is NULL") + self.assertIsNotNone( + residual_currency, + f"line {line_id}: amount_residual_currency is NULL", + ) + self.assertIsNotNone(reconciled, f"line {line_id}: reconciled is NULL")