231 lines
8.7 KiB
Python
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()
|