feat(nexa_coa_setup): batch-reclass 200 historical 411000 lines

All 123 historical out_invoices ($249k revenue, 2022-2023 mostly) had
been posted to the generic l10n_ca '411000 Inside Sales' account, since
the module they predated proper product setup and had no SKU attached.

Keyword-rule script (scripts/reclass_historical_411000.py) routes each
line by description text to the correct Nexa account:

  Pattern                                  -> Target account     Lines   Revenue
  Computer & Server Maintenance, Server   4030 Support &         165    $236,259
    Backup & Monitoring, Membership Fee    Maintenance Contracts
  [CUSTCOMP], Custom Computer, HP Desk,   4320 Hardware Resale    24    ~$8,200
    Server 2019, Server Rack, 16 Port
    POE, CPU:, Cleaning Supplies
  ONSITE-, OFFSITE-, Server Setup,        4230 Technical Support  11    ~$3,200
    Wiring for                             — Per-incident/Hourly

Match rate: 200/200 = 100%. Verified the legacy 411000 account now has
zero open-invoice lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-12 20:43:22 -04:00
parent a0f783ab14
commit 0e595e6129

View File

@@ -0,0 +1,62 @@
"""Reclassify all 200 lines on legacy 411000 to proper Nexa accounts based on
keyword rules. Run on prod via odoo-shell."""
acct_4030 = env['account.account'].search([('code', '=', '4030')], limit=1).id # Support & Maintenance
acct_4230 = env['account.account'].search([('code', '=', '4230')], limit=1).id # Tech Support hourly
acct_4320 = env['account.account'].search([('code', '=', '4320')], limit=1).id # Hardware Resale
legacy_acct = env['account.account'].with_context(active_test=False).search([('code', '=', '411000')], limit=1).id
# (priority, regex pattern (case-insensitive), target_account_id, label)
import re
RULES = [
('Computer & Server Maintenance', acct_4030, 'Support & Maintenance'),
('Server Backup & Monitoring', acct_4030, 'Support & Maintenance'),
('Membership Fee', acct_4030, 'Support & Maintenance (membership)'),
('[CUSTCOMP]', acct_4320, 'Hardware Resale (custom PC)'),
('Custom Computer', acct_4320, 'Hardware Resale (custom PC)'),
('ustom Computer', acct_4320, 'Hardware Resale (typo'), # the 'ustom Computer' typo entry
('HP Desk Computer', acct_4320, 'Hardware Resale (HP desktop)'),
('Server 2019', acct_4320, 'Hardware Resale (Windows Server license)'),
('Server Rack', acct_4320, 'Hardware Resale (rack)'),
('16 Port POE', acct_4320, 'Hardware Resale (switch)'),
('CPU:', acct_4320, 'Hardware Resale (custom build)'),
('Cleaning Supplies', acct_4320, 'Hardware Resale (consumables)'),
('ONSITE-', acct_4230, 'Tech Support — onsite'),
('OFFSITE-', acct_4230, 'Tech Support — offsite'),
('Onsite Sever Setup', acct_4230, 'Tech Support — setup'),
('Server Setup', acct_4230, 'Tech Support — setup'),
('Wiring for', acct_4230, 'Tech Support — installation'),
]
# Find all lines on 411000
env.cr.execute("""
SELECT aml.id, aml.name, aml.credit
FROM account_move_line aml
JOIN account_move m ON m.id = aml.move_id
WHERE aml.account_id = %s AND m.move_type = 'out_invoice'
""", (legacy_acct,))
lines = env.cr.fetchall()
bucket = {acct_4030: 0, acct_4230: 0, acct_4320: 0}
unmatched = []
for line_id, line_name, credit in lines:
name = (line_name or '').strip()
matched = False
for pattern, target, label in RULES:
if pattern.lower() in name.lower():
env.cr.execute("UPDATE account_move_line SET account_id = %s WHERE id = %s",
(target, line_id))
bucket[target] += 1
matched = True
break
if not matched:
unmatched.append((line_id, name[:60], credit))
print(f"Reclassified {bucket[acct_4030]} lines -> 4030 Support & Maintenance")
print(f"Reclassified {bucket[acct_4230]} lines -> 4230 Tech Support — Hourly")
print(f"Reclassified {bucket[acct_4320]} lines -> 4320 Hardware Resale")
print(f"Unmatched: {len(unmatched)}")
for u in unmatched[:20]:
print(f" id={u[0]} amount={u[2]} name={u[1]!r}")
env.cr.commit()
print(">>> done <<<")