import re from math import copysign from odoo import _, models, Command from odoo.exceptions import UserError class ReconcileModelLine(models.Model): """Extends reconciliation model lines with methods for computing journal item values across manual and bank reconciliation contexts.""" _inherit = 'account.reconcile.model.line' # ------------------------------------------------------------------ # Core helpers # ------------------------------------------------------------------ def _resolve_taxes_for_partner(self, partner): """Return the tax recordset that should be applied, taking fiscal position mapping into account when a partner is provided.""" tax_records = self.tax_ids if not tax_records or not partner: return tax_records fpos = self.env['account.fiscal.position']._get_fiscal_position(partner) if fpos: tax_records = fpos.map_tax(tax_records) return tax_records def _prepare_aml_vals(self, partner): """Build a base dictionary of account.move.line values derived from this reconciliation model line. Fiscal-position tax remapping is applied automatically when the supplied *partner* record has a matching fiscal position. Args: partner: ``res.partner`` record to attach to the move line. Returns: ``dict`` suitable for later account.move.line creation. """ self.ensure_one() mapped_taxes = self._resolve_taxes_for_partner(partner) result_values = { 'name': self.label, 'partner_id': partner.id, 'analytic_distribution': self.analytic_distribution, 'tax_ids': [Command.set(mapped_taxes.ids)], 'reconcile_model_id': self.model_id.id, } if self.account_id: result_values['account_id'] = self.account_id.id return result_values # ------------------------------------------------------------------ # Manual reconciliation # ------------------------------------------------------------------ def _compute_manual_amount(self, remaining_balance, currency): """Derive the line amount for manual reconciliation based on the configured amount type (percentage or fixed). Raises ``UserError`` for amount types that are only valid inside the bank reconciliation widget (e.g. regex, percentage_st_line). """ if self.amount_type == 'percentage': return currency.round(remaining_balance * self.amount / 100.0) if self.amount_type == 'fixed': direction = 1 if remaining_balance > 0.0 else -1 return currency.round(self.amount * direction) raise UserError( _("This reconciliation model cannot be applied in the manual " "reconciliation widget because its amount type is not supported " "in that context.") ) def _apply_in_manual_widget(self, residual_amount_currency, partner, currency): """Produce move-line values for the manual reconciliation widget. The ``journal_id`` field is deliberately included in the result even though it is a related (read-only) field on the move line. The manual reconciliation widget relies on its presence to group lines into a single journal entry per journal. Args: residual_amount_currency: Open balance in the account's currency. partner: ``res.partner`` record for the counterpart. currency: ``res.currency`` record used by the account. Returns: ``dict`` ready for account.move.line creation. """ self.ensure_one() computed_amount = self._compute_manual_amount(residual_amount_currency, currency) line_data = self._prepare_aml_vals(partner) line_data.update({ 'currency_id': currency.id, 'amount_currency': computed_amount, 'journal_id': self.journal_id.id, }) return line_data # ------------------------------------------------------------------ # Bank reconciliation # ------------------------------------------------------------------ def _extract_regex_amount(self, payment_ref, residual_balance): """Try to extract a numeric amount from *payment_ref* using the regex pattern stored on this line. Returns the parsed amount with the correct sign, or ``0.0`` when parsing fails or the pattern does not match. """ pattern_match = re.search(self.amount_string, payment_ref) if not pattern_match: return 0.0 separator = self.model_id.decimal_separator direction = 1 if residual_balance > 0.0 else -1 try: raw_group = pattern_match.group(1) digits_only = re.sub(r'[^\d' + separator + ']', '', raw_group) parsed_value = float(digits_only.replace(separator, '.')) return copysign(parsed_value * direction, residual_balance) except (ValueError, IndexError): return 0.0 def _compute_percentage_st_line_amount(self, st_line, currency): """Calculate the move-line amount and currency when the amount type is ``percentage_st_line``. Depending on the model configuration the calculation uses either the raw transaction figures or the journal-currency figures. Returns a ``(computed_amount, target_currency)`` tuple. """ ( txn_amount, txn_currency, jnl_amount, jnl_currency, _comp_amount, _comp_currency, ) = st_line._get_accounting_amounts_and_currencies() ratio = self.amount / 100.0 is_invoice_writeoff = ( self.model_id.rule_type == 'writeoff_button' and self.model_id.counterpart_type in ('sale', 'purchase') ) if is_invoice_writeoff: # Invoice creation – use the original transaction currency. return currency.round(-txn_amount * ratio), txn_currency, ratio # Standard write-off – follow the journal currency. return currency.round(-jnl_amount * ratio), jnl_currency, None def _apply_in_bank_widget(self, residual_amount_currency, partner, st_line): """Produce move-line values for the bank reconciliation widget. Handles three amount-type strategies: * ``percentage_st_line`` – percentage of the statement line amount * ``regex`` – amount extracted from the payment reference * fallback – delegates to :meth:`_apply_in_manual_widget` Args: residual_amount_currency: Open balance in the statement line currency. partner: ``res.partner`` record for the counterpart. st_line: ``account.bank.statement.line`` being reconciled. Returns: ``dict`` ready for account.move.line creation. """ self.ensure_one() line_currency = ( st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id ) # -- percentage of statement line --------------------------------- if self.amount_type == 'percentage_st_line': computed_amount, target_cur, pct_ratio = ( self._compute_percentage_st_line_amount(st_line, line_currency) ) entry_data = self._prepare_aml_vals(partner) entry_data['currency_id'] = target_cur.id entry_data['amount_currency'] = computed_amount if pct_ratio is not None: entry_data['percentage_st_line'] = pct_ratio if not entry_data.get('name'): entry_data['name'] = st_line.payment_ref return entry_data # -- regex extraction from payment reference ---------------------- if self.amount_type == 'regex': extracted = self._extract_regex_amount( st_line.payment_ref, residual_amount_currency, ) entry_data = self._prepare_aml_vals(partner) entry_data['currency_id'] = line_currency.id entry_data['amount_currency'] = extracted if not entry_data.get('name'): entry_data['name'] = st_line.payment_ref return entry_data # -- percentage / fixed – reuse manual widget logic --------------- entry_data = self._apply_in_manual_widget( residual_amount_currency, partner, line_currency, ) if not entry_data.get('name'): entry_data['name'] = st_line.payment_ref return entry_data