Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
222 lines
7.5 KiB
Python
222 lines
7.5 KiB
Python
"""
|
|
Fix income_account on Sales Invoice Items and GL Entries.
|
|
Uses legacy_invoice_id to join with legacy invoice_item → SKU → product_cat → GL account.
|
|
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_income_accounts.py
|
|
"""
|
|
import frappe
|
|
import pymysql
|
|
import os, sys, time
|
|
|
|
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
|
|
|
|
os.chdir("/home/frappe/frappe-bench/sites")
|
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
|
frappe.connect()
|
|
print("Connected:", frappe.local.site)
|
|
|
|
DEFAULT_INCOME = "Autres produits d'exploitation - T"
|
|
|
|
# ── Step 1: Build SKU → ERPNext income account ──
|
|
print("\n=== Step 1: Build SKU → GL mapping ===")
|
|
|
|
legacy = pymysql.connect(
|
|
host="legacy-db", user="facturation", password="VD67owoj",
|
|
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
|
|
)
|
|
|
|
with legacy.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT p.sku, pc.num_compte
|
|
FROM product p JOIN product_cat pc ON p.category = pc.id
|
|
WHERE p.sku IS NOT NULL AND p.sku != '' AND pc.num_compte IS NOT NULL
|
|
""")
|
|
sku_to_gl_num = {}
|
|
for r in cur.fetchall():
|
|
if r['num_compte']:
|
|
sku_to_gl_num[r['sku']] = str(int(r['num_compte']))
|
|
print(f" {len(sku_to_gl_num)} SKU → GL number mappings")
|
|
|
|
# ERPNext account_number → account name
|
|
accts = frappe.db.sql("""
|
|
SELECT account_number, name FROM "tabAccount"
|
|
WHERE root_type IN ('Income', 'Expense') AND company = 'TARGO' AND is_group = 0
|
|
AND account_number IS NOT NULL AND account_number != ''
|
|
""")
|
|
gl_by_number = {r[0]: r[1] for r in accts}
|
|
print(f" {len(gl_by_number)} numbered GL accounts")
|
|
|
|
sku_to_income = {}
|
|
for sku, num in sku_to_gl_num.items():
|
|
if num in gl_by_number:
|
|
sku_to_income[sku] = gl_by_number[num]
|
|
print(f" {len(sku_to_income)} SKUs → named accounts")
|
|
|
|
# ── Step 2: Load legacy invoice_items grouped by invoice, ordered by id ──
|
|
print("\n=== Step 2: Load legacy invoice items with row numbers ===")
|
|
t0 = time.time()
|
|
|
|
with legacy.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT invoice_id, sku,
|
|
ROW_NUMBER() OVER (PARTITION BY invoice_id ORDER BY id) as rn
|
|
FROM invoice_item
|
|
ORDER BY invoice_id, id
|
|
""")
|
|
legacy_items = cur.fetchall()
|
|
legacy.close()
|
|
print(f" {len(legacy_items)} legacy items [{time.time()-t0:.0f}s]")
|
|
|
|
# Build: { legacy_invoice_id → { idx → income_account } }
|
|
inv_item_map = {}
|
|
mapped = unmapped = 0
|
|
for li in legacy_items:
|
|
sku = li['sku'] or ''
|
|
acct = sku_to_income.get(sku)
|
|
if acct:
|
|
inv_id = li['invoice_id']
|
|
idx = li['rn']
|
|
if inv_id not in inv_item_map:
|
|
inv_item_map[inv_id] = {}
|
|
inv_item_map[inv_id][idx] = acct
|
|
mapped += 1
|
|
else:
|
|
unmapped += 1
|
|
|
|
print(f" {mapped} items mapped, {unmapped} unmapped")
|
|
print(f" {len(inv_item_map)} invoices with mappable items")
|
|
|
|
# ── Step 3: Build ERPNext item name → (legacy_invoice_id, idx) lookup ──
|
|
print("\n=== Step 3: Update Sales Invoice Items via legacy_invoice_id join ===")
|
|
t0 = time.time()
|
|
|
|
# Process in chunks using legacy_invoice_id from tabSales Invoice
|
|
chunk_size = 5000
|
|
total_updated = 0
|
|
acct_totals = {}
|
|
|
|
# Get all invoices with legacy_invoice_id
|
|
inv_rows = frappe.db.sql("""
|
|
SELECT name, legacy_invoice_id FROM "tabSales Invoice"
|
|
WHERE legacy_invoice_id > 0
|
|
ORDER BY legacy_invoice_id
|
|
""")
|
|
print(f" {len(inv_rows)} invoices to process")
|
|
|
|
for ci in range(0, len(inv_rows), chunk_size):
|
|
chunk = inv_rows[ci:ci+chunk_size]
|
|
inv_names = [r[0] for r in chunk]
|
|
inv_id_map = {r[0]: int(r[1]) for r in chunk} # erpnext_name → legacy_id
|
|
|
|
# Get all items for these invoices
|
|
placeholders = ','.join(['%s'] * len(inv_names))
|
|
items = frappe.db.sql(
|
|
f'SELECT name, parent, idx FROM "tabSales Invoice Item" WHERE parent IN ({placeholders}) AND income_account = %s ORDER BY parent, idx',
|
|
inv_names + [DEFAULT_INCOME]
|
|
)
|
|
|
|
updates = []
|
|
for item_name, parent, idx in items:
|
|
legacy_id = inv_id_map.get(parent)
|
|
if legacy_id and legacy_id in inv_item_map and idx in inv_item_map[legacy_id]:
|
|
new_acct = inv_item_map[legacy_id][idx]
|
|
updates.append((new_acct, item_name))
|
|
acct_totals[new_acct] = acct_totals.get(new_acct, 0) + 1
|
|
|
|
# Execute updates in batches
|
|
for i in range(0, len(updates), 500):
|
|
batch = updates[i:i+500]
|
|
for new_acct, iname in batch:
|
|
frappe.db.sql(
|
|
'UPDATE "tabSales Invoice Item" SET income_account = %s WHERE name = %s',
|
|
(new_acct, iname)
|
|
)
|
|
frappe.db.commit()
|
|
|
|
total_updated += len(updates)
|
|
if (ci // chunk_size) % 10 == 0 or ci + chunk_size >= len(inv_rows):
|
|
elapsed = time.time() - t0
|
|
print(f" [{ci+len(chunk)}/{len(inv_rows)}] updated={total_updated} [{elapsed:.0f}s]")
|
|
|
|
print(f"\n Total items updated: {total_updated}")
|
|
print(" By account:")
|
|
for acct, cnt in sorted(acct_totals.items(), key=lambda x: -x[1]):
|
|
print(f" {cnt:>10} {acct}")
|
|
|
|
remaining = frappe.db.sql(
|
|
'SELECT COUNT(*) FROM "tabSales Invoice Item" WHERE income_account = %s',
|
|
(DEFAULT_INCOME,)
|
|
)[0][0]
|
|
total = frappe.db.sql('SELECT COUNT(*) FROM "tabSales Invoice Item"')[0][0]
|
|
print(f"\n Remaining on default: {remaining} / {total}")
|
|
print(f" Time: {time.time()-t0:.0f}s")
|
|
|
|
# ── Step 4: Update GL Entries based on updated invoice items ──
|
|
print("\n=== Step 4: Update GL Entries ===")
|
|
t0 = time.time()
|
|
|
|
# Get aggregated income per invoice per account (non-default only)
|
|
inv_accounts = frappe.db.sql("""
|
|
SELECT parent, income_account, SUM(base_net_amount) as total
|
|
FROM "tabSales Invoice Item"
|
|
WHERE income_account != %s
|
|
GROUP BY parent, income_account
|
|
""", (DEFAULT_INCOME,))
|
|
print(f" {len(inv_accounts)} (invoice, account) pairs")
|
|
|
|
# Group by invoice
|
|
inv_acct_map = {}
|
|
for r in inv_accounts:
|
|
inv_name = r[0]
|
|
if inv_name not in inv_acct_map:
|
|
inv_acct_map[inv_name] = []
|
|
inv_acct_map[inv_name].append({'account': r[1], 'total': float(r[2])})
|
|
|
|
# For simplicity: assign the GL entry to the dominant (highest total) income account
|
|
# A proper fix would split the GL entry, but that requires complex double-entry bookkeeping
|
|
gl_updates = {}
|
|
for inv_name, accts_list in inv_acct_map.items():
|
|
best = max(accts_list, key=lambda a: a['total'])
|
|
acct = best['account']
|
|
if acct not in gl_updates:
|
|
gl_updates[acct] = []
|
|
gl_updates[acct].append(inv_name)
|
|
|
|
gl_total = 0
|
|
for acct_name, inv_names in gl_updates.items():
|
|
for i in range(0, len(inv_names), 500):
|
|
batch = inv_names[i:i+500]
|
|
placeholders = ','.join(['%s'] * len(batch))
|
|
frappe.db.sql(
|
|
f"""UPDATE "tabGL Entry"
|
|
SET account = %s
|
|
WHERE voucher_type = 'Sales Invoice'
|
|
AND voucher_no IN ({placeholders})
|
|
AND account = %s""",
|
|
[acct_name] + batch + [DEFAULT_INCOME]
|
|
)
|
|
frappe.db.commit()
|
|
gl_total += len(inv_names)
|
|
print(f" {acct_name}: {len(inv_names)} invoices")
|
|
|
|
print(f" Total: {gl_total} invoices GL updated [{time.time()-t0:.0f}s]")
|
|
|
|
# ── Step 5: Verify ──
|
|
print("\n=== Step 5: Verify ===")
|
|
r = frappe.db.sql("""
|
|
SELECT account, COUNT(*) as cnt, ROUND(SUM(credit)::numeric, 2) as total_credit
|
|
FROM "tabGL Entry"
|
|
WHERE voucher_type = 'Sales Invoice'
|
|
AND credit > 0
|
|
GROUP BY account
|
|
ORDER BY cnt DESC
|
|
LIMIT 25
|
|
""")
|
|
print(" GL credit entries by account (Sales Invoice):")
|
|
for row in r:
|
|
print(f" {row[1]:>10} ${row[2]:>14} {row[0]}")
|
|
|
|
frappe.destroy()
|
|
print("\nDone!")
|