Files
Odoo-Modules/Fusion Accounting/models/three_way_match.py
2026-02-22 01:22:18 -05:00

231 lines
8.7 KiB
Python

# 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()