gigafibre-fsm/scripts/migration/fix_no_rebate_discounts.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

462 lines
17 KiB
Python

"""
Fix deliveries: restore catalog prices + create RAB-PROMO for discount absorption.
Handles BOTH:
A) Deliveries with existing rebates (catalog restore + adjust rebate)
B) Deliveries with NO rebate (catalog restore + create RAB-PROMO)
TEST MODE: Only runs on specific test accounts.
Set TEST_ACCOUNTS = None to run on ALL accounts.
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_no_rebate_discounts.py
"""
import frappe
import pymysql
import os
from datetime import datetime
DRY_RUN = False # Set False to actually write
# Test on specific accounts only — set to None for all
# 3673 = Expro Transit, others from the 310 no-rebate list
TEST_ACCOUNTS = {3673, 263, 343, 264, 166}
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
frappe.local.flags.ignore_permissions = True
print("Connected:", frappe.local.site)
print("DRY_RUN:", DRY_RUN)
conn = pymysql.connect(
host="10.100.80.100", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)
# ═══════════════════════════════════════════════════════════════
# STEP 1: Load legacy data
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("STEP 1: LOAD LEGACY DATA")
print("=" * 60)
with conn.cursor() as cur:
cur.execute("""
SELECT s.id, s.delivery_id, s.product_id, s.hijack, s.hijack_price, s.hijack_desc,
p.sku, p.price as base_price, d.account_id
FROM service s
JOIN product p ON p.id = s.product_id
JOIN delivery d ON d.id = s.delivery_id
WHERE s.status = 1
ORDER BY s.delivery_id, p.price DESC
""")
all_services = cur.fetchall()
conn.close()
# Group by delivery
deliveries = {}
for s in all_services:
did = s["delivery_id"]
if did not in deliveries:
deliveries[did] = []
base = float(s["base_price"] or 0)
actual = float(s["hijack_price"]) if s["hijack"] else base
is_rebate = base < 0
deliveries[did].append({
"svc_id": s["id"], "sku": s["sku"], "base_price": base,
"actual_price": actual, "is_rebate": is_rebate,
"hijack": s["hijack"],
"hijack_desc": (s["hijack_desc"] or "").strip(),
"account_id": s["account_id"],
})
# Classify deliveries
no_rebate_cases = []
has_rebate_cases = []
for did, services in deliveries.items():
acct_id = services[0]["account_id"]
if TEST_ACCOUNTS and acct_id not in TEST_ACCOUNTS:
continue
has_discount = has_rebate = False
discount_total = 0.0
discount_services = []
for s in services:
if not s["is_rebate"] and s["actual_price"] < s["base_price"] - 0.01:
has_discount = True
discount_total += s["base_price"] - s["actual_price"]
discount_services.append(s)
if s["is_rebate"]:
has_rebate = True
if not has_discount:
continue
entry = {
"delivery_id": did, "account_id": acct_id,
"discount_total": round(discount_total, 2),
"discount_services": discount_services, "all_services": services,
}
if has_rebate:
has_rebate_cases.append(entry)
else:
no_rebate_cases.append(entry)
print("Test accounts: {}".format(TEST_ACCOUNTS or "ALL"))
print("Deliveries with existing rebate to adjust: {}".format(len(has_rebate_cases)))
print("Deliveries needing RAB-PROMO creation: {}".format(len(no_rebate_cases)))
# ═══════════════════════════════════════════════════════════════
# STEP 2: Ensure RAB-PROMO Item exists
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("STEP 2: ENSURE RAB-PROMO ITEM")
print("=" * 60)
rab_exists = frappe.db.sql("""
SELECT name FROM "tabItem" WHERE name = 'RAB-PROMO'
""")
if not rab_exists:
if not DRY_RUN:
frappe.db.sql("""
INSERT INTO "tabItem" (
name, creation, modified, modified_by, owner, docstatus,
item_code, item_name, item_group, description,
is_stock_item, has_variants, disabled
) VALUES (
'RAB-PROMO', NOW(), NOW(), 'Administrator', 'Administrator', 0,
'RAB-PROMO', 'Rabais promotionnel', 'Rabais', 'Rabais promotionnel — créé automatiquement pour les services sans rabais existant',
0, 0, 0
)
""")
frappe.db.commit()
print(" Created RAB-PROMO item")
else:
print(" [DRY RUN] Would create RAB-PROMO item")
else:
print(" RAB-PROMO already exists")
# Also ensure Subscription Plan exists
plan_exists = frappe.db.sql("""
SELECT name FROM "tabSubscription Plan" WHERE name = 'PLAN-RAB-PROMO'
""")
if not plan_exists:
if not DRY_RUN:
frappe.db.sql("""
INSERT INTO "tabSubscription Plan" (
name, creation, modified, modified_by, owner, docstatus,
plan_name, item, cost, billing_interval, billing_interval_count,
currency, price_determination
) VALUES (
'PLAN-RAB-PROMO', NOW(), NOW(), 'Administrator', 'Administrator', 0,
'PLAN-RAB-PROMO', 'RAB-PROMO', 0, 'Month', 1,
'CAD', 'Fixed Rate'
)
""")
frappe.db.commit()
print(" Created PLAN-RAB-PROMO subscription plan")
else:
print(" [DRY RUN] Would create PLAN-RAB-PROMO subscription plan")
else:
print(" PLAN-RAB-PROMO already exists")
# ═══════════════════════════════════════════════════════════════
# STEP 3: Map delivery_id → ERPNext Subscription + Customer
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("STEP 3: MAP LEGACY → ERPNEXT")
print("=" * 60)
# Get all subscriptions with legacy_service_id
subs = frappe.db.sql("""
SELECT name, legacy_service_id, party, service_location, actual_price, item_code, status
FROM "tabSubscription"
WHERE legacy_service_id IS NOT NULL
""", as_dict=True)
sub_by_legacy = {}
for s in subs:
lid = s.get("legacy_service_id")
if lid:
sub_by_legacy[lid] = s
print("Mapped ERPNext subscriptions: {}".format(len(sub_by_legacy)))
# Map account_id → customer
customers = frappe.db.sql("""
SELECT name, legacy_account_id FROM "tabCustomer"
WHERE legacy_account_id IS NOT NULL
""", as_dict=True)
cust_by_acct = {}
for c in customers:
aid = c.get("legacy_account_id")
if aid:
cust_by_acct[int(aid)] = c["name"]
print("Mapped customers: {}".format(len(cust_by_acct)))
# ═══════════════════════════════════════════════════════════════
# STEP 4: Process each delivery
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("STEP 4: CREATE RAB-PROMO SUBSCRIPTIONS")
print("=" * 60)
created = 0
updated = 0
skipped_no_customer = 0
skipped_no_sub = 0
errors = 0
for case in no_rebate_cases:
did = case["delivery_id"]
acct_id = case["account_id"]
discount = case["discount_total"]
# Find customer + service_location from existing subscriptions (not from legacy_account_id)
service_location = None
customer = None
any_sub = None
for s in case["all_services"]:
erp_sub = sub_by_legacy.get(s["svc_id"])
if erp_sub:
service_location = erp_sub.get("service_location")
customer = erp_sub.get("party")
any_sub = erp_sub
break
if not customer:
# Fallback to legacy_account_id mapping
customer = cust_by_acct.get(acct_id)
if not customer:
skipped_no_customer += 1
continue
if not any_sub:
skipped_no_sub += 1
continue
# Build description from hijack_desc of discount services
descs = []
for s in case["discount_services"]:
if s["hijack_desc"]:
descs.append(s["hijack_desc"])
description = "; ".join(descs) if descs else "Rabais loyauté"
# Step 4a: Update positive products to catalog price
for s in case["discount_services"]:
erp_sub = sub_by_legacy.get(s["svc_id"])
if erp_sub:
# If it's a "fake rebate" (positive product with negative actual), restore to 0
# If it's a discounted positive, restore to base_price
if s["actual_price"] < 0:
new_price = 0 # Was used as a discount line, zero it out
else:
new_price = s["base_price"]
if not DRY_RUN:
frappe.db.sql("""
UPDATE "tabSubscription"
SET actual_price = %s
WHERE name = %s
""", (new_price, erp_sub["name"]))
updated += 1
# Step 4b: Create RAB-PROMO subscription
rabais_name = "SUB-RAB-{}-{}".format(did, acct_id)
# Check if already exists
existing = frappe.db.sql("""
SELECT name FROM "tabSubscription" WHERE name = %s
""", (rabais_name,))
if existing:
continue
if not DRY_RUN:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
today = datetime.now().strftime("%Y-%m-%d")
# Insert subscription
frappe.db.sql("""
INSERT INTO "tabSubscription" (
name, creation, modified, modified_by, owner, docstatus,
party_type, party, service_location, status,
actual_price, custom_description, item_code, item_group,
item_name, billing_frequency,
start_date
) VALUES (
%s, %s, %s, 'Administrator', 'Administrator', 0,
'Customer', %s, %s, 'Active',
%s, %s, 'RAB-PROMO', 'Rabais',
'Rabais promotionnel', 'M',
%s
)
""", (
rabais_name, now, now,
customer, service_location,
-discount, description,
today,
))
# Insert Subscription Plan Detail child
spd_name = "{}-plan".format(rabais_name)
frappe.db.sql("""
INSERT INTO "tabSubscription Plan Detail" (
name, creation, modified, modified_by, owner, docstatus,
parent, parentfield, parenttype, idx,
plan, qty
) VALUES (
%s, %s, %s, 'Administrator', 'Administrator', 0,
%s, 'plans', 'Subscription', 1,
'PLAN-RAB-PROMO', 1
)
""", (spd_name, now, now, rabais_name))
created += 1
if created % 50 == 0 and created > 0 and not DRY_RUN:
frappe.db.commit()
print(" Processed {}/{}...".format(created, len(no_rebate_cases)))
if not DRY_RUN:
frappe.db.commit()
print("\nRAB-PROMO subscriptions created: {}".format(created))
print("Positive products price-restored: {}".format(updated))
print("Skipped (no customer in ERP): {}".format(skipped_no_customer))
print("Skipped (no subscription in ERP): {}".format(skipped_no_sub))
print("Errors: {}".format(errors))
# ═══════════════════════════════════════════════════════════════
# STEP 5: FIX DELIVERIES WITH EXISTING REBATES
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("STEP 5: FIX DELIVERIES WITH EXISTING REBATES")
print("=" * 60)
clean_adjusted = 0
rebates_adjusted = 0
for case in has_rebate_cases:
services = case["all_services"]
discount_to_absorb = case["discount_total"]
biggest_rebate = min(
[s for s in services if s["is_rebate"]],
key=lambda s: s["actual_price"]
)
# Update positive products to catalog price
for s in case["discount_services"]:
erp_sub = sub_by_legacy.get(s["svc_id"])
if erp_sub:
new_price = s["base_price"] if s["actual_price"] >= 0 else 0
if not DRY_RUN:
frappe.db.sql("""
UPDATE "tabSubscription"
SET actual_price = %s
WHERE name = %s
""", (new_price, erp_sub["name"]))
clean_adjusted += 1
# Update biggest rebate to absorb difference
rebate_sub = sub_by_legacy.get(biggest_rebate["svc_id"])
if rebate_sub:
new_rebate_price = biggest_rebate["actual_price"] - discount_to_absorb
if not DRY_RUN:
frappe.db.sql("""
UPDATE "tabSubscription"
SET actual_price = %s
WHERE name = %s
""", (round(new_rebate_price, 2), rebate_sub["name"]))
rebates_adjusted += 1
if not DRY_RUN:
frappe.db.commit()
print("Positive products restored to catalog: {}".format(clean_adjusted))
print("Rebates adjusted to absorb discount: {}".format(rebates_adjusted))
# ═══════════════════════════════════════════════════════════════
# STEP 6: VERIFY ALL TEST ACCOUNTS
# ═══════════════════════════════════════════════════════════════
print("\n" + "=" * 60)
print("STEP 6: VERIFY TEST ACCOUNTS")
print("=" * 60)
if TEST_ACCOUNTS:
for acct_id in sorted(TEST_ACCOUNTS):
# Find customer from subscriptions (more reliable than legacy_account_id)
customer = None
for did_check, svcs_check in deliveries.items():
if svcs_check[0]["account_id"] == acct_id:
for sc in svcs_check:
erp_sc = sub_by_legacy.get(sc["svc_id"])
if erp_sc:
customer = erp_sc["party"]
break
if customer:
break
if not customer:
customer = cust_by_acct.get(acct_id)
if not customer:
print("\n Account {} — no customer in ERP".format(acct_id))
continue
cust_info = frappe.db.sql("""
SELECT customer_name FROM "tabCustomer" WHERE name = %s
""", (customer,), as_dict=True)
cust_name = cust_info[0]["customer_name"] if cust_info else customer
subs_list = frappe.db.sql("""
SELECT name, item_code, item_name, actual_price, custom_description,
service_location, status
FROM "tabSubscription"
WHERE party = %s
ORDER BY service_location, actual_price DESC
""", (customer,), as_dict=True)
print("\n {} (account {}) — {} subs".format(cust_name, acct_id, len(subs_list)))
current_loc = None
loc_total = 0
grand_total = 0
for s in subs_list:
loc = s.get("service_location") or "?"
if loc != current_loc:
if current_loc:
print(" SUBTOTAL: ${:.2f}".format(loc_total))
current_loc = loc
loc_total = 0
print(" [{}]".format(loc[:60]))
price = float(s["actual_price"] or 0)
loc_total += price
grand_total += price
is_rab = price < 0
indent = " " if is_rab else " "
desc = s.get("custom_description") or ""
print(" {}{:<14} {:>8.2f} {}{}".format(
indent, (s["item_code"] or "")[:14], price,
(s["item_name"] or "")[:40],
" [{}]".format(desc[:40]) if desc else ""))
if current_loc:
print(" SUBTOTAL: ${:.2f}".format(loc_total))
print(" GRAND TOTAL: ${:.2f}".format(grand_total))
# Global stats
total_subs = frappe.db.sql('SELECT COUNT(*) FROM "tabSubscription"')[0][0]
rab_promo_count = frappe.db.sql(
'SELECT COUNT(*) FROM "tabSubscription" WHERE item_code = %s', ('RAB-PROMO',)
)[0][0]
print("\nTotal subscriptions: {}".format(total_subs))
print("RAB-PROMO subscriptions: {}".format(rab_promo_count))
frappe.clear_cache()
print("\nDone — cache cleared")