# Fusion Accounting - 3-Way Matching # Copyright (C) 2026 Nexa Systems Inc. (https://nexasystems.ca) # Original implementation for the Fusion Accounting module. # # Compares Purchase Order amount, receipt quantity, and bill amount # to flag discrepancies before payment approval. # Soft dependency on the 'purchase' module. import logging from odoo import api, fields, models, _ from odoo.tools import float_compare, float_is_zero _logger = logging.getLogger(__name__) class FusionThreeWayMatch(models.Model): """Extends account.move with 3-way matching for vendor bills. The 3-way match compares: 1. Purchase Order total 2. Goods-received quantity (from stock.picking receipts) 3. Vendor Bill amount The match status indicates whether the bill is safe to pay. Works only when the ``purchase`` module is installed. """ _inherit = 'account.move' # ===================================================================== # Fields # ===================================================================== fusion_3way_match_status = fields.Selection( selection=[ ('not_applicable', 'Not Applicable'), ('matched', 'Fully Matched'), ('partial', 'Partially Matched'), ('unmatched', 'Unmatched'), ], string="3-Way Match", compute='_compute_3way_match', store=True, readonly=True, copy=False, default='not_applicable', help="Indicates whether this vendor bill matches the corresponding " "Purchase Order and received goods quantities.\n" "- Fully Matched: PO, receipt, and bill all agree.\n" "- Partially Matched: some lines match but others don't.\n" "- Unmatched: significant discrepancies detected.\n" "- Not Applicable: not a vendor bill or no linked PO.", ) fusion_po_ref = fields.Char( string="Purchase Order Ref", copy=False, help="Reference to the linked purchase order for 3-way matching.", ) fusion_3way_po_amount = fields.Monetary( string="PO Amount", compute='_compute_3way_match', store=True, readonly=True, help="Total amount from the linked purchase order.", ) fusion_3way_received_qty_value = fields.Monetary( string="Received Value", compute='_compute_3way_match', store=True, readonly=True, help="Monetary value of goods actually received.", ) fusion_3way_bill_amount = fields.Monetary( string="Bill Amount", compute='_compute_3way_match', store=True, readonly=True, help="Total amount on this vendor bill.", ) # ===================================================================== # Purchase module availability check # ===================================================================== @api.model def _fusion_purchase_module_installed(self): """Return True if the purchase module is installed.""" return bool(self.env['ir.module.module'].sudo().search([ ('name', '=', 'purchase'), ('state', '=', 'installed'), ], limit=1)) # ===================================================================== # Compute 3-Way Match # ===================================================================== @api.depends( 'move_type', 'state', 'amount_total', 'invoice_line_ids.quantity', 'invoice_line_ids.price_subtotal', 'fusion_po_ref', ) def _compute_3way_match(self): """Compare PO amount, received quantity value, and bill amount. The tolerance for matching is set at the company currency's rounding precision. Each line is evaluated individually and then the overall status is determined: - ``matched``: all lines agree within tolerance - ``partial``: some lines match, others don't - ``unmatched``: no lines match or totals diverge significantly - ``not_applicable``: not a vendor bill or no linked PO """ purchase_installed = self._fusion_purchase_module_installed() for move in self: # Defaults move.fusion_3way_po_amount = 0.0 move.fusion_3way_received_qty_value = 0.0 move.fusion_3way_bill_amount = 0.0 # Only vendor bills if move.move_type not in ('in_invoice', 'in_refund'): move.fusion_3way_match_status = 'not_applicable' continue if not purchase_installed or not move.fusion_po_ref: # Try to auto-detect the PO from invoice origin if purchase_installed and move.invoice_origin: po = self.env['purchase.order'].search([ ('name', '=', move.invoice_origin), ('company_id', '=', move.company_id.id), ], limit=1) if po: move.fusion_po_ref = po else: move.fusion_3way_match_status = 'not_applicable' continue else: move.fusion_3way_match_status = 'not_applicable' continue po = move.fusion_po_ref currency = move.currency_id precision = currency.rounding # ---- Gather PO totals ---- po_amount = po.amount_total move.fusion_3way_po_amount = po_amount # ---- Gather received quantities value ---- received_value = 0.0 for po_line in po.order_line: received_qty = po_line.qty_received received_value += received_qty * po_line.price_unit * ( 1.0 - (po_line.discount or 0.0) / 100.0 ) move.fusion_3way_received_qty_value = received_value # ---- Gather bill totals ---- bill_amount = move.amount_total move.fusion_3way_bill_amount = bill_amount # ---- Determine match status ---- po_vs_bill = float_compare(po_amount, bill_amount, precision_rounding=precision) recv_vs_bill = float_compare(received_value, bill_amount, precision_rounding=precision) if po_vs_bill == 0 and recv_vs_bill == 0: # All three agree move.fusion_3way_match_status = 'matched' elif po_vs_bill == 0 or recv_vs_bill == 0: # Two of three agree move.fusion_3way_match_status = 'partial' else: # Per-line comparison for partial detection matched_lines = 0 total_lines = 0 for bill_line in move.invoice_line_ids.filtered( lambda l: l.display_type == 'product' and l.product_id ): total_lines += 1 po_line = po.order_line.filtered( lambda pl: pl.product_id == bill_line.product_id ) if po_line: po_line = po_line[0] # Compare quantity and price qty_match = float_compare( bill_line.quantity, po_line.qty_received, precision_rounding=0.01, ) == 0 price_match = float_compare( bill_line.price_unit, po_line.price_unit, precision_rounding=precision, ) == 0 if qty_match and price_match: matched_lines += 1 if total_lines == 0: move.fusion_3way_match_status = 'unmatched' elif matched_lines == total_lines: move.fusion_3way_match_status = 'matched' elif matched_lines > 0: move.fusion_3way_match_status = 'partial' else: move.fusion_3way_match_status = 'unmatched' # ===================================================================== # Actions # ===================================================================== def action_view_purchase_order(self): """Open the linked purchase order.""" self.ensure_one() if not self.fusion_po_ref: return return { 'type': 'ir.actions.act_window', 'res_model': 'purchase.order', 'res_id': self.fusion_po_ref.id, 'view_mode': 'form', 'target': 'current', } def action_refresh_3way_match(self): """Manually re-compute the 3-way match status.""" self._compute_3way_match()