"""The reconcile engine — orchestrator for all bank-line reconciliations. Public API: 6 methods. All other code (controllers, AI tools, wizards) must go through these methods; no direct ORM writes to ``account.partial.reconcile`` from anywhere else. V19 mechanics (per Enterprise's bank_rec_widget pattern): A bank statement line creates an ``account.move`` with two journal items: a *liquidity* line on the journal's default account, and a *suspense* line on the journal's suspense account. Reconciliation replaces the suspense line with one or more *counterpart* lines posted to the matched invoices' receivable / payable accounts (or the write-off account), then calls Odoo's standard ``account.move.line.reconcile()`` on each counterpart + invoice pair. Internal pipeline (per spec Section 3.3): 1. Validate (period not locked, mandatory args present). 2. Compute counterpart vals from ``against_lines`` and optional write-off. 3. Rewrite the bank move ``line_ids``: keep liquidity, drop suspense + any prior other lines, append the new counterparts. 4. Reconcile each counterpart with its matched invoice line. 5. Audit (``mail.message``) + record precedent for future learning. """ import logging from odoo import _, api, fields, models from odoo.exceptions import ValidationError from odoo.fields import Command from ..services.matching_strategies import ( AmountExactStrategy, Candidate, FIFOStrategy, MultiInvoiceStrategy, ) from ..services.confidence_scoring import score_candidates from ..services.memo_tokenizer import tokenize_memo _logger = logging.getLogger(__name__) class FusionReconcileEngine(models.AbstractModel): _name = "fusion.reconcile.engine" _description = "Fusion Bank Reconciliation Engine" # ============================================================ # PUBLIC API (6 methods) # ============================================================ @api.model def reconcile_one(self, statement_line, *, against_lines=None, write_off_vals=None): """Reconcile one bank line against a set of journal items. Returns: ``{'partial_ids': [...], 'exchange_diff_move_id': int|None, 'write_off_move_id': int|None}`` """ if not statement_line: raise ValidationError(_("statement_line is required")) statement_line.ensure_one() AML = self.env['account.move.line'] against_lines = against_lines or AML if not against_lines and not write_off_vals: raise ValidationError( _("Either against_lines or write_off_vals required")) self._validate_reconcile(statement_line, against_lines) bank_move = statement_line.move_id liquidity_lines, suspense_lines, other_lines = ( statement_line._seek_for_lines()) # 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, 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, balance=wo_balance, )) remaining = 0 # Replace the bank move line_ids: keep liquidity, drop everything # else, append new counterparts. ops = [] for line in (suspense_lines | other_lines): ops.append(Command.unlink(line.id)) for vals in new_counterpart_vals: ops.append(Command.create(vals)) editable_move = bank_move.with_context( force_delete=True, skip_readonly_check=True) prior_line_ids = set(bank_move.line_ids.ids) editable_move.write({'line_ids': ops}) new_lines = bank_move.line_ids.filtered( 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 = [] 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([ '|', ('debit_move_id', 'in', pair.ids), ('credit_move_id', 'in', pair.ids), ]).ids) pair.reconcile() added = Partial.search([ '|', ('debit_move_id', 'in', pair.ids), ('credit_move_id', 'in', pair.ids), ]).filtered(lambda p: p.id not in existing) new_partial_ids.extend(added.ids) self._post_audit( statement_line, new_partial_ids, source='engine.reconcile_one') if against_lines: self._record_precedent(statement_line, against_lines) return { 'partial_ids': new_partial_ids, 'exchange_diff_move_id': None, 'write_off_move_id': write_off_move_id, } @api.model def reconcile_batch(self, statement_lines, *, strategy='auto'): """Bulk-reconcile a recordset using the chosen strategy. Returns: ``{'reconciled_count': int, 'skipped': int, 'errors': [...]}`` """ reconciled = 0 skipped = 0 errors = [] for line in statement_lines: if line.is_reconciled: skipped += 1 continue # Per-line savepoint so a single DB-level failure (e.g. a # check-constraint violation on one bad line) doesn't poison # the whole batch's transaction. try: with self.env.cr.savepoint(): candidates = self._fetch_candidates(line) picked = self._apply_strategy( line, candidates, strategy) if picked: self.reconcile_one(line, against_lines=picked) reconciled += 1 else: skipped += 1 except Exception as e: # noqa: BLE001 errors.append({'line_id': line.id, 'error': str(e)}) _logger.warning( "reconcile_batch failed for line %s: %s", line.id, e) return { 'reconciled_count': reconciled, 'skipped': skipped, 'errors': errors, } @api.model def suggest_matches(self, statement_lines, *, limit_per_line=3): """Compute and persist AI suggestions per line. Returns: dict mapping ``line_id`` -> list of suggestion dicts. """ out = {} Suggestion = self.env['fusion.reconcile.suggestion'] for line in statement_lines: candidates_records = self._fetch_candidates(line) if not candidates_records: continue candidates_dataclasses = self._records_to_candidates( line, candidates_records) scored = score_candidates( self.env, statement_line=line, candidates=candidates_dataclasses, k=limit_per_line, use_ai=True, ) Suggestion.search([ ('statement_line_id', '=', line.id), ('state', '=', 'pending'), ]).write({'state': 'superseded'}) line_suggestions = [] for rank, s in enumerate(scored, start=1): sug = Suggestion.create({ 'company_id': line.company_id.id, 'statement_line_id': line.id, 'proposed_move_line_ids': [(6, 0, [s.candidate_id])], 'confidence': s.confidence, 'rank': rank, 'reasoning': s.reasoning, 'score_amount_match': s.score_amount_match, 'score_partner_pattern': s.score_partner_pattern, 'score_precedent_similarity': s.score_precedent_similarity, 'score_ai_rerank': s.score_ai_rerank, 'generated_by': 'on_demand', 'state': 'pending', }) line_suggestions.append({ 'id': sug.id, 'rank': rank, 'confidence': s.confidence, 'reasoning': s.reasoning, 'candidate_id': s.candidate_id, }) out[line.id] = line_suggestions return out @api.model def accept_suggestion(self, suggestion): """User clicked Accept on a suggestion -> reconcile via its proposal. Returns: same shape as ``reconcile_one``. """ if isinstance(suggestion, int): suggestion = self.env['fusion.reconcile.suggestion'].browse( suggestion) suggestion.ensure_one() line = suggestion.statement_line_id against = suggestion.proposed_move_line_ids result = self.reconcile_one(line, against_lines=against) suggestion.write({ 'state': 'accepted', 'accepted_at': fields.Datetime.now(), 'accepted_by': self.env.uid, }) return result @api.model def write_off(self, statement_line, *, account, amount, label, tax_id=None): """Create a write-off move + reconcile the bank line against it. Returns: same shape as ``reconcile_one``. """ write_off_vals = { 'account_id': account.id if hasattr(account, 'id') else account, 'amount': amount, 'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id')) else tax_id), 'label': label, } return self.reconcile_one( statement_line, against_lines=None, write_off_vals=write_off_vals) @api.model 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() if not partial_reconciles: return {'unreconciled_line_ids': []} all_lines = ( partial_reconciles.mapped('debit_move_id') | partial_reconciles.mapped('credit_move_id') ) line_ids = all_lines.ids # 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} # ============================================================ # PRIVATE HELPERS # ============================================================ def _validate_reconcile(self, statement_line, against_lines): """Phase 2: structural + safety checks.""" if not statement_line.exists(): raise ValidationError(_("Statement line does not exist")) company = statement_line.company_id line_date = statement_line.date lock_date = company.fiscalyear_lock_date if lock_date and line_date and line_date <= lock_date: raise ValidationError(_( "Cannot reconcile: line date %(line)s is on or before fiscal " "year lock date %(lock)s", line=line_date, lock=lock_date, )) 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. ``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': amount_currency, 'balance': allocated_balance, } 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. ``balance`` is the signed company-currency balance the write-off line must carry to keep the bank move balanced. """ 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': balance, } if write_off_vals.get('tax_id'): vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])] return vals def _fetch_candidates(self, statement_line): """SQL pre-filter: open journal items matching partner + reconcilable account.""" domain = [ ('parent_state', '=', 'posted'), ('account_id.reconcile', '=', True), ('reconciled', '=', False), ('display_type', 'not in', ('line_section', 'line_note')), ] if statement_line.partner_id: domain.append(('partner_id', '=', statement_line.partner_id.id)) return self.env['account.move.line'].search(domain, limit=200) def _records_to_candidates(self, statement_line, records): """Convert ``account.move.line`` recordset to ``Candidate`` dataclasses.""" today = fields.Date.today() result = [] for c in records: ref_date = c.date_maturity or c.date or today age_days = (today - ref_date).days result.append(Candidate( id=c.id, amount=abs(c.amount_residual) or abs(c.balance), partner_id=c.partner_id.id if c.partner_id else 0, age_days=age_days, )) return result def _apply_strategy(self, line, candidate_records, strategy): """Apply the named strategy. Returns matching ``account.move.line`` recordset, or empty recordset if nothing matched.""" AML = self.env['account.move.line'] if not candidate_records: return AML candidate_dcs = self._records_to_candidates(line, candidate_records) bank_amount = abs(line.amount) if strategy == 'auto': for strat_class in (AmountExactStrategy, MultiInvoiceStrategy, FIFOStrategy): result = strat_class().match( bank_amount=bank_amount, candidates=candidate_dcs) if result.picked_ids: return AML.browse(result.picked_ids) return AML def _post_audit(self, statement_line, partial_ids, source): """Append an audit log to the bank-line move's chatter.""" if not statement_line.move_id: return try: statement_line.move_id.message_post( body=_( "Reconciled via %(source)s; %(count)d partial(s) created: " "%(ids)s", source=source, count=len(partial_ids), ids=partial_ids, ), ) except Exception as e: # noqa: BLE001 _logger.debug( "Audit log skipped for line %s: %s", statement_line.id, e) def _record_precedent(self, statement_line, against_lines): """Append a precedent for future pattern learning. Best-effort.""" if not against_lines: return try: self.env['fusion.reconcile.precedent'].sudo().create({ 'company_id': statement_line.company_id.id, 'partner_id': (statement_line.partner_id.id if statement_line.partner_id else False), 'amount': abs(statement_line.amount), 'currency_id': statement_line.currency_id.id, 'date': statement_line.date, 'memo_tokens': ','.join( tokenize_memo(statement_line.payment_ref)), 'journal_id': statement_line.journal_id.id, 'matched_move_line_count': len(against_lines), 'matched_account_ids': ','.join( str(i) for i in against_lines.mapped('account_id').ids), 'reconciler_user_id': self.env.uid, 'reconciled_at': fields.Datetime.now(), 'source': 'manual', }) except Exception as e: # noqa: BLE001 _logger.warning( "Failed to record precedent for line %s: %s", statement_line.id, e)