gigafibre-fsm/scripts/migration/fix_reversals.py
louispaulb 101faa21f1 feat: inline editing, search, notifications + full repo cleanup
- InlineField component + useInlineEdit composable for Odoo-style dblclick editing
- Client search by name, account ID, and legacy_customer_id (or_filters)
- SMS/Email notification panel on ContactCard via n8n webhooks
- Ticket reply thread via Communication docs
- All migration scripts (51 files) now tracked
- Client portal and field tech app added to monorepo
- README rewritten with full feature list, migration summary, architecture
- CHANGELOG updated with all recent work
- ROADMAP updated with current completion status
- Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN)
- .gitignore updated (docker/, .claude/, exports/, .quasar/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 07:34:41 -04:00

212 lines
7.4 KiB
Python

"""
Fix reversal invoices: link credit invoices to their original via return_against + PLE.
These are cancellation invoices created in legacy with no credit payment — just billed_amt = total_amt.
"""
import frappe, os, pymysql, time
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
print("Connected:", frappe.local.site)
t0 = time.time()
legacy = pymysql.connect(
host="10.100.80.100", user="facturation", password="*******",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)
# Get all unlinked settled return invoices
unlinked = frappe.db.sql("""
SELECT name, legacy_invoice_id, grand_total, customer, posting_date
FROM "tabSales Invoice"
WHERE docstatus = 1 AND is_return = 1
AND (return_against IS NULL OR return_against = '')
AND outstanding_amount < -0.005
ORDER BY outstanding_amount ASC
""", as_dict=True)
print("Unlinked settled returns: {}".format(len(unlinked)))
# Build legacy_invoice_id → SINV name map
inv_map = {}
map_rows = frappe.db.sql("""
SELECT name, legacy_invoice_id FROM "tabSales Invoice"
WHERE legacy_invoice_id IS NOT NULL AND docstatus = 1 AND is_return != 1
""", as_dict=True)
for r in map_rows:
inv_map[str(r['legacy_invoice_id'])] = r['name']
print("Invoice map: {} entries".format(len(inv_map)))
# Match each reversal to its original
matches = [] # (credit_sinv, target_sinv, amount)
unmatched = 0
with legacy.cursor() as cur:
for ret in unlinked:
leg_id = ret['legacy_invoice_id']
amount = float(ret['grand_total'])
target_amount = -amount
cur.execute("SELECT account_id, date_orig FROM invoice WHERE id = %s", (leg_id,))
credit_inv = cur.fetchone()
if not credit_inv:
unmatched += 1
continue
cur.execute("""
SELECT id FROM invoice
WHERE account_id = %s
AND ROUND(total_amt, 2) = ROUND(%s, 2)
AND total_amt > 0
AND date_orig <= %s
AND date_orig >= UNIX_TIMESTAMP('2024-04-08')
ORDER BY date_orig DESC
LIMIT 1
""", (credit_inv['account_id'], target_amount, credit_inv['date_orig']))
match = cur.fetchone()
if match:
target_sinv = inv_map.get(str(match['id']))
if target_sinv:
matches.append((ret['name'], target_sinv, amount))
else:
unmatched += 1
else:
unmatched += 1
legacy.close()
print("Matched: {} | Unmatched: {}".format(len(matches), unmatched))
# Load into temp table
frappe.db.sql("DROP TABLE IF EXISTS _tmp_reversal_match")
frappe.db.commit()
frappe.db.sql("""
CREATE TABLE _tmp_reversal_match (
id SERIAL PRIMARY KEY,
credit_sinv VARCHAR(140),
target_sinv VARCHAR(140),
amount DOUBLE PRECISION
)
""")
frappe.db.commit()
for i in range(0, len(matches), 5000):
batch = matches[i:i+5000]
values = ",".join(["('{}', '{}', {})".format(c, t, a) for c, t, a in batch])
frappe.db.sql("INSERT INTO _tmp_reversal_match (credit_sinv, target_sinv, amount) VALUES {}".format(values))
frappe.db.commit()
print("Loaded {} matches into temp table".format(len(matches)))
# Set return_against
frappe.db.sql("""
UPDATE "tabSales Invoice" si
SET return_against = rm.target_sinv
FROM _tmp_reversal_match rm
WHERE si.name = rm.credit_sinv
""")
frappe.db.commit()
print("Set return_against on {} credit invoices".format(len(matches)))
# Delete self-referencing PLE for these credit invoices
frappe.db.sql("""
DELETE FROM "tabPayment Ledger Entry"
WHERE name IN (
SELECT 'ple-' || rm.credit_sinv FROM _tmp_reversal_match rm
)
""")
frappe.db.commit()
# Insert PLE: credit allocation against target invoice
frappe.db.sql("""
INSERT INTO "tabPayment Ledger Entry" (
name, owner, creation, modified, modified_by, docstatus,
posting_date, company,
account_type, account, account_currency,
party_type, party,
due_date,
voucher_type, voucher_no,
against_voucher_type, against_voucher_no,
amount, amount_in_account_currency,
delinked, remarks
)
SELECT
'plr-' || rm.id, 'Administrator', NOW(), NOW(), 'Administrator', 1,
si.posting_date, 'TARGO',
'Receivable', 'Comptes clients - T', 'CAD',
'Customer', si.customer,
COALESCE(si.due_date, si.posting_date),
'Sales Invoice', rm.credit_sinv,
'Sales Invoice', rm.target_sinv,
rm.amount, rm.amount,
0, 'Reversal allocation'
FROM _tmp_reversal_match rm
JOIN "tabSales Invoice" si ON si.name = rm.credit_sinv
""")
frappe.db.commit()
new_ple = frappe.db.sql("SELECT COUNT(*) FROM \"tabPayment Ledger Entry\" WHERE name LIKE 'plr-%%'")[0][0]
print("Created {} reversal PLE entries".format(new_ple))
# Set outstanding = 0 on linked credit invoices
frappe.db.sql("""
UPDATE "tabSales Invoice"
SET outstanding_amount = 0, status = 'Return'
WHERE name IN (SELECT credit_sinv FROM _tmp_reversal_match)
""")
frappe.db.commit()
# Recalculate outstanding on target invoices (original invoices being cancelled)
frappe.db.sql("""
UPDATE "tabSales Invoice" si
SET outstanding_amount = COALESCE((
SELECT SUM(ple.amount)
FROM "tabPayment Ledger Entry" ple
WHERE ple.against_voucher_type = 'Sales Invoice'
AND ple.against_voucher_no = si.name
AND ple.delinked = 0
), si.grand_total)
WHERE si.name IN (SELECT target_sinv FROM _tmp_reversal_match)
""")
frappe.db.commit()
# Update statuses for affected target invoices
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Paid'
WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match)
AND is_return != 1 AND ROUND(outstanding_amount::numeric, 2) = 0""")
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Overdue'
WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match)
AND is_return != 1 AND outstanding_amount > 0.005
AND COALESCE(due_date, posting_date) < CURRENT_DATE""")
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Unpaid'
WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match)
AND is_return != 1 AND outstanding_amount > 0.005
AND COALESCE(due_date, posting_date) >= CURRENT_DATE""")
frappe.db.sql("""UPDATE "tabSales Invoice" SET status = 'Credit Note Issued'
WHERE name IN (SELECT target_sinv FROM _tmp_reversal_match)
AND is_return != 1 AND outstanding_amount < -0.005""")
frappe.db.commit()
# Cleanup
frappe.db.sql("DROP TABLE IF EXISTS _tmp_reversal_match")
frappe.db.commit()
# Verification
print("\n=== Verification ===")
outstanding = frappe.db.sql("""
SELECT
ROUND(SUM(CASE WHEN outstanding_amount > 0.005 THEN outstanding_amount ELSE 0 END)::numeric, 2) as owed,
ROUND(SUM(CASE WHEN outstanding_amount < -0.005 THEN outstanding_amount ELSE 0 END)::numeric, 2) as overpaid
FROM "tabSales Invoice" WHERE docstatus = 1
""", as_dict=True)[0]
print(" Outstanding: ${} owed | ${} overpaid".format(outstanding['owed'], outstanding['overpaid']))
statuses = frappe.db.sql("""
SELECT status, COUNT(*) as cnt
FROM "tabSales Invoice" WHERE docstatus = 1
GROUP BY status ORDER BY cnt DESC
""", as_dict=True)
print("\n Status breakdown:")
for s in statuses:
print(" {}: {}".format(s['status'], s['cnt']))
elapsed = time.time() - t0
print("\nDone in {:.0f}s".format(elapsed))