changes
This commit is contained in:
@@ -5,3 +5,5 @@ from . import accounting_match_history
|
||||
from . import accounting_rule
|
||||
from . import accounting_dashboard
|
||||
from . import account_move_hook
|
||||
from . import vendor_tax_profile
|
||||
from . import recurring_pattern
|
||||
|
||||
@@ -34,9 +34,14 @@ class AccountMoveAuditHook(models.Model):
|
||||
for line in move.line_ids:
|
||||
if not line.account_id:
|
||||
issues.append(f'Line missing account: {line.name}')
|
||||
if line.product_id and not line.tax_ids:
|
||||
if move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'):
|
||||
issues.append(f'Missing tax on product line: {line.product_id.name}')
|
||||
# M6: Only flag missing tax when the product has taxes configured
|
||||
# (avoids false positives for HST-exempt healthcare services)
|
||||
if (line.product_id and not line.tax_ids
|
||||
and move.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')):
|
||||
# Check if the product has default taxes configured
|
||||
product_taxes = line.product_id.taxes_id if move.move_type in ('out_invoice', 'out_refund') else line.product_id.supplier_taxes_id
|
||||
if product_taxes:
|
||||
issues.append(f'Missing tax on product line: {line.product_id.name} (product has taxes configured but line has none)')
|
||||
|
||||
if not move.line_ids:
|
||||
issues.append('Entry has no lines')
|
||||
|
||||
@@ -153,11 +153,15 @@ class FusionAccountingDashboard(models.TransientModel):
|
||||
if balance > 0.01:
|
||||
issues += 1
|
||||
|
||||
gaps = self.env['account.move'].search_count([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
])
|
||||
# M4: Guard against made_sequence_gap field not existing
|
||||
try:
|
||||
gaps = self.env['account.move'].search_count([
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('made_sequence_gap', '=', True),
|
||||
])
|
||||
except (ValueError, KeyError):
|
||||
gaps = 0
|
||||
issues += gaps
|
||||
|
||||
pending_approvals = self.env['fusion.accounting.match.history'].search_count([
|
||||
@@ -267,7 +271,7 @@ class FusionAccountingDashboard(models.TransientModel):
|
||||
rec.recent_activity_json = json.dumps([{
|
||||
'tool': r.tool_name,
|
||||
'decision': r.decision,
|
||||
'date': str(r.proposed_at),
|
||||
'date': r.proposed_at.isoformat() if r.proposed_at else '',
|
||||
'amount': r.amount,
|
||||
} for r in recent])
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class FusionAccountingRule(models.Model):
|
||||
if (rec.approval_tier == 'needs_approval'
|
||||
and rec.total_uses >= rec.min_sample_size
|
||||
and rec.confidence_score >= rec.promotion_threshold):
|
||||
rec.approval_tier = 'auto'
|
||||
rec.write({'approval_tier': 'auto'})
|
||||
_logger.info(
|
||||
"Rule '%s' promoted to auto-approved (confidence=%.2f, uses=%d)",
|
||||
rec.name, rec.confidence_score, rec.total_uses,
|
||||
@@ -116,5 +116,6 @@ class FusionAccountingRule(models.Model):
|
||||
def action_rollback(self):
|
||||
for rec in self:
|
||||
if rec.parent_rule_id:
|
||||
rec.active = False
|
||||
rec.parent_rule_id.active = True
|
||||
# M5: Use write() to trigger tracking on tracked fields
|
||||
rec.write({'active': False})
|
||||
rec.parent_rule_id.write({'active': True})
|
||||
|
||||
216
fusion_accounting/models/recurring_pattern.py
Normal file
216
fusion_accounting/models/recurring_pattern.py
Normal file
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionRecurringPattern(models.Model):
|
||||
_name = 'fusion.recurring.pattern'
|
||||
_description = 'Recurring Bank Transaction Pattern (AI Cache)'
|
||||
_order = 'occurrences desc'
|
||||
|
||||
name = fields.Char(string='Pattern Name', required=True)
|
||||
ref_keyword = fields.Char(
|
||||
string='Reference Keyword',
|
||||
help='The payment_ref substring that identifies this pattern.',
|
||||
index=True,
|
||||
)
|
||||
amount = fields.Float(string='Amount', digits=(12, 2))
|
||||
amount_is_fixed = fields.Boolean(
|
||||
string='Fixed Amount',
|
||||
help='True if the amount is always the same. False if it varies.',
|
||||
)
|
||||
journal_id = fields.Many2one('account.journal', string='Bank Journal')
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# How this was coded historically
|
||||
expense_account_id = fields.Many2one(
|
||||
'account.account', string='Expense Account',
|
||||
)
|
||||
expense_account_code = fields.Char(
|
||||
related='expense_account_id.code', string='Account Code', store=True,
|
||||
)
|
||||
has_hst = fields.Boolean(string='Has HST')
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
reconcile_model_id = fields.Many2one(
|
||||
'account.reconcile.model', string='Reconciliation Model',
|
||||
help='If this pattern was handled by a reconciliation model.',
|
||||
)
|
||||
|
||||
# AI-readable instructions
|
||||
action_note = fields.Text(
|
||||
string='Action (AI-Readable)',
|
||||
help='Plain English instructions for the AI on how to handle this pattern.',
|
||||
)
|
||||
|
||||
# Stats
|
||||
occurrences = fields.Integer(string='Times Seen')
|
||||
first_seen = fields.Date(string='First Seen')
|
||||
last_seen = fields.Date(string='Last Seen')
|
||||
last_computed = fields.Datetime(string='Last Computed')
|
||||
|
||||
_sql_constraints = [
|
||||
('pattern_uniq', 'unique(ref_keyword, amount, company_id)',
|
||||
'One pattern per keyword+amount per company'),
|
||||
]
|
||||
|
||||
def _rebuild_all_patterns(self, min_occurrences=3, since='2024-01-01'):
|
||||
"""Scan reconciled bank lines for recurring patterns and cache how they were coded."""
|
||||
_logger.info("Rebuilding recurring patterns (min=%d, since=%s)...", min_occurrences, since)
|
||||
companies = self.env['res.company'].search([])
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
|
||||
for company in companies:
|
||||
# Step 1: Find recurring ref+amount combinations
|
||||
self.env.cr.execute("""
|
||||
SELECT LEFT(bsl.payment_ref, 60) as ref_pattern,
|
||||
bsl.amount,
|
||||
count(*) as occurrences,
|
||||
MIN(am.date) as first_seen,
|
||||
MAX(am.date) as last_seen,
|
||||
MODE() WITHIN GROUP (ORDER BY am.journal_id) as journal_id
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON bsl.move_id = am.id
|
||||
WHERE bsl.is_reconciled = true
|
||||
AND am.company_id = %s
|
||||
AND am.date >= %s
|
||||
AND bsl.payment_ref IS NOT NULL
|
||||
AND bsl.payment_ref != ''
|
||||
GROUP BY LEFT(bsl.payment_ref, 60), bsl.amount
|
||||
HAVING count(*) >= %s
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 200
|
||||
""", (company.id, since, min_occurrences))
|
||||
patterns = self.env.cr.dictfetchall()
|
||||
|
||||
for pat in patterns:
|
||||
ref = pat['ref_pattern'].strip()
|
||||
if not ref or len(ref) < 3:
|
||||
continue
|
||||
|
||||
# Step 2: Trace how one instance was coded
|
||||
self.env.cr.execute("""
|
||||
SELECT aml.account_id, aml.tax_line_id, aml.partner_id
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON bsl.move_id = am.id
|
||||
JOIN account_move_line aml ON aml.move_id = am.id
|
||||
WHERE bsl.is_reconciled = true
|
||||
AND bsl.payment_ref ILIKE %s
|
||||
AND bsl.amount = %s
|
||||
AND am.company_id = %s
|
||||
AND aml.display_type NOT IN ('line_section', 'line_note')
|
||||
AND aml.account_id NOT IN (
|
||||
SELECT default_account_id FROM account_journal
|
||||
WHERE company_id = %s AND default_account_id IS NOT NULL
|
||||
)
|
||||
ORDER BY bsl.id DESC
|
||||
LIMIT 5
|
||||
""", (f'%{ref[:40]}%', pat['amount'], company.id, company.id))
|
||||
coded_lines = self.env.cr.dictfetchall()
|
||||
|
||||
expense_account_id = None
|
||||
has_hst = False
|
||||
partner_id = None
|
||||
|
||||
for cl in coded_lines:
|
||||
if cl['tax_line_id']:
|
||||
has_hst = True
|
||||
elif cl['account_id'] and not expense_account_id:
|
||||
acct = self.env['account.account'].browse(cl['account_id'])
|
||||
if acct.exists() and acct.account_type in (
|
||||
'expense', 'expense_direct_cost', 'expense_depreciation',
|
||||
'asset_non_current', 'liability_non_current',
|
||||
):
|
||||
expense_account_id = cl['account_id']
|
||||
if cl['partner_id'] and not partner_id:
|
||||
partner_id = cl['partner_id']
|
||||
|
||||
# Build a friendly name
|
||||
clean_ref = re.sub(r'[X*]{3,}[\w-]*', '', ref).strip()
|
||||
clean_ref = re.sub(r'\s{2,}', ' ', clean_ref)[:50]
|
||||
|
||||
# Build AI action note
|
||||
acct_name = ''
|
||||
if expense_account_id:
|
||||
acct = self.env['account.account'].browse(expense_account_id)
|
||||
acct_name = f'{acct.code} {acct.name}' if acct.exists() else ''
|
||||
|
||||
partner_name = ''
|
||||
if partner_id:
|
||||
p = self.env['res.partner'].browse(partner_id)
|
||||
partner_name = p.name if p.exists() else ''
|
||||
|
||||
action_parts = [f'RECURRING PAYMENT (seen {pat["occurrences"]} times).']
|
||||
if expense_account_id:
|
||||
action_parts.append(f'Post to account: {acct_name}.')
|
||||
if has_hst:
|
||||
action_parts.append('HST applies — split with 13% ITC.')
|
||||
else:
|
||||
action_parts.append('No HST — post without tax.')
|
||||
if partner_name:
|
||||
action_parts.append(f'Partner: {partner_name}.')
|
||||
action_parts.append('Apply same coding as previous occurrences — no user input needed.')
|
||||
|
||||
action_note = ' '.join(action_parts)
|
||||
|
||||
# Step 3: Check if a reconciliation model already handles this pattern
|
||||
reco_model_id = None
|
||||
try:
|
||||
reco_models = self.env['account.reconcile.model'].search([
|
||||
('company_id', '=', company.id),
|
||||
('active', '=', True),
|
||||
('match_label_param', '!=', False),
|
||||
])
|
||||
ref_lower = ref.lower()
|
||||
for rm in reco_models:
|
||||
if rm.match_label_param and rm.match_label_param.lower() in ref_lower:
|
||||
reco_model_id = rm.id
|
||||
action_parts.append(
|
||||
f'Reconciliation model "{rm.name}" (ID:{rm.id}) already handles this — '
|
||||
f'use apply_reconcile_model to apply it automatically.'
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Upsert
|
||||
existing = self.search([
|
||||
('ref_keyword', '=', ref),
|
||||
('amount', '=', pat['amount']),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'name': clean_ref,
|
||||
'ref_keyword': ref,
|
||||
'amount': pat['amount'],
|
||||
'amount_is_fixed': True,
|
||||
'journal_id': pat['journal_id'],
|
||||
'company_id': company.id,
|
||||
'expense_account_id': expense_account_id,
|
||||
'has_hst': has_hst,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_model_id': reco_model_id,
|
||||
'action_note': action_note,
|
||||
'occurrences': pat['occurrences'],
|
||||
'first_seen': pat['first_seen'],
|
||||
'last_seen': pat['last_seen'],
|
||||
'last_computed': fields.Datetime.now(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
total_updated += 1
|
||||
else:
|
||||
self.create(vals)
|
||||
total_created += 1
|
||||
|
||||
_logger.info("Recurring patterns rebuilt: %d created, %d updated", total_created, total_updated)
|
||||
return {'created': total_created, 'updated': total_updated}
|
||||
221
fusion_accounting/models/vendor_tax_profile.py
Normal file
221
fusion_accounting/models/vendor_tax_profile.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionVendorTaxProfile(models.Model):
|
||||
_name = 'fusion.vendor.tax.profile'
|
||||
_description = 'Vendor Tax Profile (AI Cache)'
|
||||
_order = 'total_bills desc'
|
||||
_rec_name = 'partner_id'
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Vendor', required=True, index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
total_bills = fields.Integer(string='Total Bills')
|
||||
bills_with_hst = fields.Integer(string='Bills with HST')
|
||||
bills_zero_rated = fields.Integer(string='Bills Zero-Rated')
|
||||
avg_tax_pct = fields.Float(string='Avg Tax %', digits=(5, 2))
|
||||
|
||||
# Classification
|
||||
tax_classification = fields.Selection([
|
||||
('always_hst', 'Always HST (13%)'),
|
||||
('mostly_hst', 'Mostly HST (>10%)'),
|
||||
('shipping_only', 'HST on Shipping Only (<2%)'),
|
||||
('never_hst', 'Never HST (0%)'),
|
||||
('mixed', 'Mixed / Inconsistent'),
|
||||
], string='Tax Classification')
|
||||
|
||||
# Most common expense account
|
||||
primary_account_id = fields.Many2one(
|
||||
'account.account', string='Primary Expense Account',
|
||||
)
|
||||
primary_account_code = fields.Char(
|
||||
related='primary_account_id.code', string='Account Code', store=True,
|
||||
)
|
||||
|
||||
# AI-readable note
|
||||
tax_note = fields.Text(
|
||||
string='Tax Note (AI-Readable)',
|
||||
help='Plain English note the AI reads to understand tax treatment.',
|
||||
)
|
||||
|
||||
# PO-tracked vendor — bills come from purchase orders, never from bank recon
|
||||
is_po_vendor = fields.Boolean(
|
||||
string='PO-Tracked Vendor',
|
||||
help='Bills for this vendor are created from Purchase Orders. '
|
||||
'Do NOT create bills during bank reconciliation — just match to existing bills.',
|
||||
)
|
||||
po_count = fields.Integer(string='Purchase Orders')
|
||||
|
||||
# Vendor details for matching
|
||||
is_foreign = fields.Boolean(string='Foreign Vendor')
|
||||
vendor_country = fields.Char(string='Vendor Country')
|
||||
|
||||
# Timestamps
|
||||
last_computed = fields.Datetime(string='Last Computed')
|
||||
|
||||
_sql_constraints = [
|
||||
('partner_company_uniq', 'unique(partner_id, company_id)',
|
||||
'One tax profile per vendor per company'),
|
||||
]
|
||||
|
||||
def _rebuild_all_profiles(self, min_bills=3):
|
||||
"""Rebuild all vendor tax profiles from posted bill history.
|
||||
Called by cron or manually."""
|
||||
_logger.info("Rebuilding vendor tax profiles (min_bills=%d)...", min_bills)
|
||||
companies = self.env['res.company'].search([])
|
||||
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
|
||||
for company in companies:
|
||||
# Find all vendors with enough bills
|
||||
self.env.cr.execute("""
|
||||
SELECT m.partner_id, count(*) as bill_count,
|
||||
SUM(CASE WHEN m.amount_tax > 0.01 THEN 1 ELSE 0 END) as with_tax,
|
||||
SUM(CASE WHEN m.amount_tax <= 0.01 THEN 1 ELSE 0 END) as no_tax,
|
||||
COALESCE(AVG(CASE WHEN m.amount_untaxed > 0
|
||||
THEN m.amount_tax / m.amount_untaxed * 100
|
||||
ELSE 0 END), 0) as avg_tax_pct
|
||||
FROM account_move m
|
||||
WHERE m.move_type = 'in_invoice'
|
||||
AND m.state = 'posted'
|
||||
AND m.company_id = %s
|
||||
AND m.partner_id IS NOT NULL
|
||||
GROUP BY m.partner_id
|
||||
HAVING count(*) >= %s
|
||||
""", (company.id, min_bills))
|
||||
vendor_stats = self.env.cr.dictfetchall()
|
||||
|
||||
for vs in vendor_stats:
|
||||
partner = self.env['res.partner'].browse(vs['partner_id'])
|
||||
if not partner.exists():
|
||||
continue
|
||||
|
||||
# Classify
|
||||
avg_pct = round(vs['avg_tax_pct'], 2)
|
||||
total = vs['bill_count']
|
||||
with_tax = vs['with_tax']
|
||||
no_tax = vs['no_tax']
|
||||
|
||||
if no_tax == total:
|
||||
classification = 'never_hst'
|
||||
note = f'{partner.name} NEVER charges HST. All {total} bills are zero-rated. Do NOT apply HST.'
|
||||
elif avg_pct >= 12.0:
|
||||
classification = 'always_hst'
|
||||
note = f'{partner.name} consistently charges HST at ~{avg_pct}%. Apply HST PURCHASE (13%) to all product lines.'
|
||||
elif avg_pct >= 10.0:
|
||||
classification = 'mostly_hst'
|
||||
note = f'{partner.name} usually charges HST (~{avg_pct}%). {no_tax} of {total} bills had no tax. Apply HST by default but verify zero-rated items.'
|
||||
elif avg_pct < 2.0 and with_tax > 0:
|
||||
classification = 'shipping_only'
|
||||
note = (
|
||||
f'{partner.name} products are zero-rated (avg tax only {avg_pct}% of subtotal). '
|
||||
f'HST applies ONLY to shipping/freight charges, NOT to product lines. '
|
||||
f'When creating a bill, use NO TAX PURCHASE on product lines and HST PURCHASE (13%) only on shipping lines.'
|
||||
)
|
||||
else:
|
||||
classification = 'mixed'
|
||||
note = (
|
||||
f'{partner.name} has mixed tax treatment ({with_tax} bills with HST, {no_tax} without, avg {avg_pct}%). '
|
||||
f'Check each bill individually — some items may be zero-rated while others have HST.'
|
||||
)
|
||||
|
||||
# Find primary expense account
|
||||
self.env.cr.execute("""
|
||||
SELECT aml.account_id, count(*) as cnt
|
||||
FROM account_move_line aml
|
||||
JOIN account_move m ON aml.move_id = m.id
|
||||
WHERE m.partner_id = %s
|
||||
AND m.move_type = 'in_invoice'
|
||||
AND m.state = 'posted'
|
||||
AND m.company_id = %s
|
||||
AND aml.display_type = 'product'
|
||||
GROUP BY aml.account_id
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 1
|
||||
""", (vs['partner_id'], company.id))
|
||||
acct_row = self.env.cr.fetchone()
|
||||
primary_account_id = acct_row[0] if acct_row else False
|
||||
|
||||
# Check if foreign vendor
|
||||
is_foreign = False
|
||||
country = ''
|
||||
if partner.country_id:
|
||||
country = partner.country_id.name
|
||||
is_foreign = partner.country_id.code != 'CA'
|
||||
elif partner.vat and not partner.vat.startswith('CA'):
|
||||
is_foreign = True
|
||||
|
||||
# Only override to never_hst if foreign AND bills actually confirm no tax
|
||||
# (Don't override if bill data shows they DO charge HST — e.g., Amazon Canada)
|
||||
if is_foreign and avg_pct < 1.0 and no_tax > with_tax:
|
||||
classification = 'never_hst'
|
||||
note = f'{partner.name} is a FOREIGN vendor ({country or "non-Canadian"}) and bills confirm no HST. Do NOT apply any Canadian tax.'
|
||||
|
||||
# Check if this is a PO-tracked vendor (has confirmed purchase orders)
|
||||
is_po_vendor = False
|
||||
vendor_po_count = 0
|
||||
try:
|
||||
self.env.cr.execute("""
|
||||
SELECT count(*) FROM purchase_order
|
||||
WHERE partner_id = %s AND state IN ('purchase', 'done')
|
||||
AND company_id = %s
|
||||
""", (vs['partner_id'], company.id))
|
||||
po_row = self.env.cr.fetchone()
|
||||
vendor_po_count = po_row[0] if po_row else 0
|
||||
is_po_vendor = vendor_po_count >= 3
|
||||
except Exception:
|
||||
pass # purchase module may not be installed
|
||||
|
||||
if is_po_vendor:
|
||||
note = (
|
||||
f'PO-TRACKED VENDOR ({vendor_po_count} purchase orders). '
|
||||
f'Bills are created from Purchase Orders — do NOT create bills during bank reconciliation. '
|
||||
f'Instead, find the existing unpaid bill and match the bank payment to it. '
|
||||
f'Tax treatment: {note}'
|
||||
)
|
||||
|
||||
# Upsert
|
||||
existing = self.search([
|
||||
('partner_id', '=', vs['partner_id']),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'partner_id': vs['partner_id'],
|
||||
'company_id': company.id,
|
||||
'total_bills': total,
|
||||
'bills_with_hst': with_tax,
|
||||
'bills_zero_rated': no_tax,
|
||||
'avg_tax_pct': avg_pct,
|
||||
'tax_classification': classification,
|
||||
'primary_account_id': primary_account_id,
|
||||
'tax_note': note,
|
||||
'is_po_vendor': is_po_vendor,
|
||||
'po_count': vendor_po_count,
|
||||
'is_foreign': is_foreign,
|
||||
'vendor_country': country,
|
||||
'last_computed': fields.Datetime.now(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
total_updated += 1
|
||||
else:
|
||||
self.create(vals)
|
||||
total_created += 1
|
||||
|
||||
_logger.info(
|
||||
"Vendor tax profiles rebuilt: %d created, %d updated",
|
||||
total_created, total_updated,
|
||||
)
|
||||
return {'created': total_created, 'updated': total_updated}
|
||||
Reference in New Issue
Block a user