fix(fusion_accounting_bank_rec): partial-reconcile balance + unreconcile suspense restore
Two engine bugs caught by Task 19's integration tests: 1. Partial reconcile (bank_amount < invoice_residual) was creating an unbalanced bank move. Counterpart balance now clamped to min(remaining_bank_amount, abs(invoice_residual)) so the move stays balanced; Odoo's reconcile() handles the resulting partial. The counterpart's amount_currency is scaled proportionally so multi- currency lines stay consistent. 2. Unreconcile only removed account.partial.reconcile rows but didn't restore the suspense line on the bank move, leaving is_reconciled=True after unreconcile. Now delegates to V19's standard account.bank.statement.line.action_undo_reconciliation for any affected bank line, which both deletes partials and restores the suspense state in one shot. Made-with: Cursor
This commit is contained in:
@@ -73,16 +73,45 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
liquidity_lines, suspense_lines, other_lines = (
|
||||
statement_line._seek_for_lines())
|
||||
|
||||
# Build the new counterpart lines that replace suspense.
|
||||
# The bank move must stay balanced after we rewrite line_ids.
|
||||
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
|
||||
# the new counterparts must sum to the inverse. We allocate the
|
||||
# available bank amount across against_lines, clamped to each
|
||||
# invoice's residual; any leftover goes to the write-off line (or
|
||||
# raises if no write-off was requested).
|
||||
liq_balance = sum(liquidity_lines.mapped('balance'))
|
||||
# Available counterpart balance (positive magnitude) = abs(liq_balance)
|
||||
remaining = abs(liq_balance)
|
||||
# Counterparts mirror liquidity: opposite sign of liq_balance.
|
||||
cp_sign = -1 if liq_balance >= 0 else 1
|
||||
|
||||
new_counterpart_vals = []
|
||||
for inv_line in against_lines:
|
||||
inv_residual = inv_line.amount_residual
|
||||
# Clamp so we never write more than the invoice residual nor more
|
||||
# than what the bank line can pay.
|
||||
allocate = min(remaining, abs(inv_residual))
|
||||
new_counterpart_vals.append(self._build_counterpart_vals(
|
||||
statement_line, inv_line))
|
||||
statement_line, inv_line,
|
||||
allocated_balance=cp_sign * allocate,
|
||||
))
|
||||
remaining -= allocate
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
write_off_move_id = None
|
||||
if write_off_vals:
|
||||
# Write-off absorbs whatever the against_lines didn't cover.
|
||||
wo_balance = cp_sign * remaining
|
||||
# If user passed an explicit amount and there are no against_lines,
|
||||
# honour the explicit amount (covers the pure write-off case).
|
||||
if (write_off_vals.get('amount') is not None
|
||||
and not against_lines):
|
||||
wo_balance = -write_off_vals['amount']
|
||||
new_counterpart_vals.append(self._build_write_off_vals(
|
||||
statement_line, write_off_vals, against_lines))
|
||||
statement_line, write_off_vals, balance=wo_balance,
|
||||
))
|
||||
remaining = 0
|
||||
|
||||
# Replace the bank move line_ids: keep liquidity, drop everything
|
||||
# else, append new counterparts.
|
||||
@@ -101,10 +130,14 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
lambda line: line.id not in prior_line_ids)
|
||||
|
||||
# Reconcile each new counterpart with its matched invoice line.
|
||||
# The first N new lines correspond to the first N against_lines
|
||||
# (where N may be < len(against_lines) if the bank amount ran out).
|
||||
# Any trailing new line is a write-off and has no invoice pair.
|
||||
Partial = self.env['account.partial.reconcile']
|
||||
new_partial_ids = []
|
||||
for new_line, inv_line in zip(
|
||||
new_lines[:len(against_lines)], against_lines):
|
||||
invoice_counterparts = new_lines[:min(len(new_lines),
|
||||
len(against_lines))]
|
||||
for new_line, inv_line in zip(invoice_counterparts, against_lines):
|
||||
pair = new_line | inv_line
|
||||
existing = set(Partial.search([
|
||||
'|',
|
||||
@@ -255,6 +288,14 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
def unreconcile(self, partial_reconciles):
|
||||
"""Reverse a reconciliation. Handles full vs. partial chains.
|
||||
|
||||
Because ``reconcile_one`` rewrites the bank move's suspense line into
|
||||
one or more counterpart lines, simply deleting the
|
||||
``account.partial.reconcile`` rows is not enough — the bank move
|
||||
would still look reconciled (no suspense line, no residual). We
|
||||
delegate to V19's standard ``account.bank.statement.line.
|
||||
action_undo_reconciliation`` for any affected bank line, which
|
||||
clears the partials AND restores the original suspense state.
|
||||
|
||||
Returns: ``{'unreconciled_line_ids': [...]}``
|
||||
"""
|
||||
partial_reconciles = partial_reconciles.exists()
|
||||
@@ -265,7 +306,19 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
| partial_reconciles.mapped('credit_move_id')
|
||||
)
|
||||
line_ids = all_lines.ids
|
||||
partial_reconciles.unlink()
|
||||
# Find any bank statement lines whose move owns one of these journal
|
||||
# items; route them through the standard undo flow which both
|
||||
# deletes the partials and restores the suspense line.
|
||||
affected_bank_lines = self.env['account.bank.statement.line'].search([
|
||||
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||
])
|
||||
if affected_bank_lines:
|
||||
affected_bank_lines.action_undo_reconciliation()
|
||||
# Anything still hanging around (rare — non-bank-line reconciles)
|
||||
# gets a direct unlink as a fallback.
|
||||
remaining = partial_reconciles.exists()
|
||||
if remaining:
|
||||
remaining.unlink()
|
||||
return {'unreconciled_line_ids': line_ids}
|
||||
|
||||
# ============================================================
|
||||
@@ -287,44 +340,45 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
lock=lock_date,
|
||||
))
|
||||
|
||||
def _build_counterpart_vals(self, statement_line, inv_line):
|
||||
def _build_counterpart_vals(self, statement_line, inv_line, *,
|
||||
allocated_balance):
|
||||
"""Build the vals for one counterpart line that mirrors an invoice
|
||||
line on the bank move."""
|
||||
line on the bank move.
|
||||
|
||||
``allocated_balance`` is the signed company-currency balance to write
|
||||
on the counterpart. It is clamped (by the caller) so that the bank
|
||||
move stays balanced and no invoice gets over-paid. We scale
|
||||
``amount_currency`` proportionally for multi-currency lines.
|
||||
"""
|
||||
inv_residual = inv_line.amount_residual
|
||||
if inv_residual:
|
||||
scale = abs(allocated_balance) / abs(inv_residual)
|
||||
else:
|
||||
scale = 1.0
|
||||
amount_currency = -inv_line.amount_residual_currency * scale
|
||||
return {
|
||||
'name': inv_line.name or statement_line.payment_ref or '',
|
||||
'account_id': inv_line.account_id.id,
|
||||
'partner_id': (inv_line.partner_id.id
|
||||
if inv_line.partner_id else False),
|
||||
'currency_id': inv_line.currency_id.id,
|
||||
'amount_currency': -inv_line.amount_residual_currency,
|
||||
'balance': -inv_line.amount_residual,
|
||||
'amount_currency': amount_currency,
|
||||
'balance': allocated_balance,
|
||||
}
|
||||
|
||||
def _build_write_off_vals(self, statement_line, write_off_vals,
|
||||
against_lines):
|
||||
def _build_write_off_vals(self, statement_line, write_off_vals, *,
|
||||
balance):
|
||||
"""Build the vals for a write-off counterpart line on the bank move.
|
||||
|
||||
The write-off absorbs the (signed) residual not covered by
|
||||
``against_lines``: ``residual = bank_amount - sum(against_lines.balance)``.
|
||||
We post that residual to the write-off account, with the opposite
|
||||
sign so the bank move stays balanced.
|
||||
``balance`` is the signed company-currency balance the write-off
|
||||
line must carry to keep the bank move balanced.
|
||||
"""
|
||||
bank_amount = statement_line.amount
|
||||
already_covered = sum(
|
||||
-line.amount_residual for line in against_lines)
|
||||
residual = bank_amount - already_covered
|
||||
# The counterpart on the bank move must offset the liquidity line,
|
||||
# so its balance is -residual.
|
||||
wo_balance = -residual
|
||||
# If the user explicitly passed an amount, prefer it (overrides).
|
||||
if write_off_vals.get('amount') is not None and not against_lines:
|
||||
wo_balance = -write_off_vals['amount']
|
||||
vals = {
|
||||
'name': write_off_vals.get('label') or _('Write-off'),
|
||||
'account_id': write_off_vals['account_id'],
|
||||
'partner_id': (statement_line.partner_id.id
|
||||
if statement_line.partner_id else False),
|
||||
'balance': wo_balance,
|
||||
'balance': balance,
|
||||
}
|
||||
if write_off_vals.get('tax_id'):
|
||||
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
|
||||
|
||||
Reference in New Issue
Block a user