Initial commit
This commit is contained in:
230
Fusion Accounting/models/three_way_match.py
Normal file
230
Fusion Accounting/models/three_way_match.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# 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()
|
||||
Reference in New Issue
Block a user