- 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>
264 lines
12 KiB
Python
264 lines
12 KiB
Python
"""
|
|
Analyze pricing cleanup: show catalog price for services, keep rebates as-is,
|
|
adjust biggest rebate to absorb the difference when hijack < catalog.
|
|
|
|
Rules:
|
|
1. Positive products: show MAX(hijack_price, base_price) — catalog or higher
|
|
2. Negative products (rebates): show hijack_price as-is
|
|
3. If hijack_price < base_price on a positive product, the difference
|
|
gets added to the biggest rebate at that delivery address
|
|
4. Total per delivery must stay the same
|
|
|
|
Run inside erpnext-backend-1:
|
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/analyze_pricing_cleanup.py
|
|
"""
|
|
import frappe
|
|
import pymysql
|
|
import os
|
|
from datetime import datetime
|
|
|
|
os.chdir("/home/frappe/frappe-bench/sites")
|
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
|
frappe.connect()
|
|
print("Connected:", frappe.local.site)
|
|
|
|
conn = pymysql.connect(
|
|
host="10.100.80.100",
|
|
user="facturation",
|
|
password="*******",
|
|
database="gestionclient",
|
|
cursorclass=pymysql.cursors.DictCursor
|
|
)
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# LOAD LEGACY DATA
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("Loading legacy data...")
|
|
|
|
with conn.cursor() as cur:
|
|
# All active services with product info
|
|
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, p.category,
|
|
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()
|
|
|
|
print("Active services loaded: {}".format(len(all_services)))
|
|
|
|
# Group by delivery_id
|
|
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 # Product is inherently a rebate (SKU like RAB24M, RAB2X)
|
|
|
|
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 "",
|
|
"account_id": s["account_id"],
|
|
})
|
|
|
|
print("Deliveries with active services: {}".format(len(deliveries)))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# APPLY PRICING RULES
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("PRICING ANALYSIS")
|
|
print("=" * 70)
|
|
|
|
total_deliveries_affected = 0
|
|
total_services_adjusted = 0
|
|
adjustments = [] # [(delivery_id, account_id, old_total, new_total, services)]
|
|
|
|
for did, services in deliveries.items():
|
|
old_total = sum(s["actual_price"] for s in services)
|
|
|
|
# Step 1: Determine new display prices
|
|
discount_to_absorb = 0.0
|
|
for s in services:
|
|
if s["is_rebate"]:
|
|
# Rebate product: keep actual price
|
|
s["new_price"] = s["actual_price"]
|
|
else:
|
|
# Positive product
|
|
if s["actual_price"] < s["base_price"]:
|
|
# Hijack made it cheaper → show catalog, track discount
|
|
discount_to_absorb += (s["base_price"] - s["actual_price"])
|
|
s["new_price"] = s["base_price"]
|
|
s["adjusted"] = True
|
|
else:
|
|
# Hijack same or higher → keep actual (custom service)
|
|
s["new_price"] = s["actual_price"]
|
|
s["adjusted"] = False
|
|
|
|
# Step 2: Find biggest rebate and add discount_to_absorb to it
|
|
rebate_adjusted = False
|
|
if discount_to_absorb > 0.005:
|
|
# Find the rebate with the most negative price
|
|
rebates = [s for s in services if s["is_rebate"]]
|
|
if rebates:
|
|
biggest_rebate = min(rebates, key=lambda s: s["new_price"])
|
|
biggest_rebate["new_price"] -= discount_to_absorb
|
|
biggest_rebate["absorbed"] = discount_to_absorb
|
|
rebate_adjusted = True
|
|
total_deliveries_affected += 1
|
|
else:
|
|
# No rebate exists — need to create one? Or leave as-is
|
|
# For now, mark as needing attention
|
|
pass
|
|
|
|
new_total = sum(s["new_price"] for s in services)
|
|
|
|
# Verify totals match
|
|
if abs(old_total - new_total) > 0.02:
|
|
adjustments.append((did, services[0]["account_id"], old_total, new_total, services, "MISMATCH"))
|
|
elif rebate_adjusted:
|
|
adjustments.append((did, services[0]["account_id"], old_total, new_total, services, "OK"))
|
|
|
|
for s in services:
|
|
if s.get("adjusted"):
|
|
total_services_adjusted += 1
|
|
|
|
print("\nDeliveries affected (rebate adjusted): {}".format(total_deliveries_affected))
|
|
print("Individual services price-restored to catalog: {}".format(total_services_adjusted))
|
|
|
|
# Count mismatches
|
|
mismatches = [a for a in adjustments if a[5] == "MISMATCH"]
|
|
ok_adjustments = [a for a in adjustments if a[5] == "OK"]
|
|
print("Clean adjustments (total preserved): {}".format(len(ok_adjustments)))
|
|
print("MISMATCHES (total changed): {}".format(len(mismatches)))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# SHOW DETAILED EXAMPLES
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
# Find Expro Transit (account 3673) deliveries
|
|
expro = [a for a in adjustments if a[1] == 3673]
|
|
print("\n" + "=" * 70)
|
|
print("EXPRO TRANSIT (account 3673) — {} deliveries affected".format(len(expro)))
|
|
print("=" * 70)
|
|
|
|
for did, acct_id, old_total, new_total, services, status in expro:
|
|
print("\n Delivery {} — {} [total: {:.2f}]".format(did, status, old_total))
|
|
print(" {:<14} {:>10} {:>10} {:>10} {}".format(
|
|
"SKU", "BASE", "ACTUAL", "NEW", "NOTE"))
|
|
print(" " + "-" * 66)
|
|
for s in sorted(services, key=lambda x: (x["is_rebate"], -x["new_price"])):
|
|
note = ""
|
|
if s.get("adjusted"):
|
|
note = "← restored to catalog (+{:.2f} to rebate)".format(s["base_price"] - s["actual_price"])
|
|
if s.get("absorbed"):
|
|
note = "← absorbed {:.2f} discount".format(s["absorbed"])
|
|
|
|
marker = " " if not s["is_rebate"] else " "
|
|
print(" {}{:<12} {:>10.2f} {:>10.2f} {:>10.2f} {}".format(
|
|
marker, s["sku"], s["base_price"], s["actual_price"], s["new_price"], note))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# SHOW OTHER SAMPLE CUSTOMERS
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("SAMPLE OTHER CUSTOMERS")
|
|
print("=" * 70)
|
|
|
|
# Get customer names for sample accounts
|
|
sample_accts = set()
|
|
for a in ok_adjustments[:20]:
|
|
sample_accts.add(a[1])
|
|
|
|
if sample_accts:
|
|
acct_list = list(sample_accts)[:5]
|
|
for acct_id in acct_list:
|
|
cust = frappe.db.sql("""
|
|
SELECT name, customer_name FROM "tabCustomer"
|
|
WHERE legacy_account_id = %s LIMIT 1
|
|
""", (acct_id,), as_dict=True)
|
|
cust_name = cust[0]["customer_name"] if cust else "account {}".format(acct_id)
|
|
|
|
acct_adjustments = [a for a in adjustments if a[1] == acct_id]
|
|
print("\n {} (account {}) — {} deliveries".format(cust_name, acct_id, len(acct_adjustments)))
|
|
|
|
for did, _, old_total, new_total, services, status in acct_adjustments[:2]:
|
|
print(" Delivery {} [total: {:.2f}] {}".format(did, old_total, status))
|
|
for s in sorted(services, key=lambda x: (x["is_rebate"], -x["new_price"])):
|
|
if s.get("adjusted") or s.get("absorbed"):
|
|
marker = " " if not s["is_rebate"] else " "
|
|
note = ""
|
|
if s.get("adjusted"):
|
|
note = "catalog:{:.2f} actual:{:.2f} → NEW:{:.2f}".format(
|
|
s["base_price"], s["actual_price"], s["new_price"])
|
|
if s.get("absorbed"):
|
|
note = "absorbed {:.2f} → NEW:{:.2f}".format(s["absorbed"], s["new_price"])
|
|
print(" {}{:<12} {}".format(marker, s["sku"], note))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# DELIVERIES WITHOUT REBATES (discount but no rebate to absorb)
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("PROBLEM CASES: discount but NO rebate to absorb it")
|
|
print("=" * 70)
|
|
|
|
no_rebate_discount = 0
|
|
for did, services in deliveries.items():
|
|
has_discount = False
|
|
has_rebate = False
|
|
for s in services:
|
|
if not s["is_rebate"] and s["actual_price"] < s["base_price"] - 0.01:
|
|
has_discount = True
|
|
if s["is_rebate"]:
|
|
has_rebate = True
|
|
if has_discount and not has_rebate:
|
|
no_rebate_discount += 1
|
|
if no_rebate_discount <= 5:
|
|
print(" Delivery {}: services with discount but no rebate product".format(did))
|
|
for s in services:
|
|
if s["actual_price"] < s["base_price"] - 0.01:
|
|
print(" {} base={:.2f} actual={:.2f} diff={:.2f}".format(
|
|
s["sku"], s["base_price"], s["actual_price"],
|
|
s["base_price"] - s["actual_price"]))
|
|
|
|
print("\nTotal deliveries with discount but no rebate: {}".format(no_rebate_discount))
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# GLOBAL STATS
|
|
# ═══════════════════════════════════════════════════════════════
|
|
print("\n" + "=" * 70)
|
|
print("GLOBAL SUMMARY")
|
|
print("=" * 70)
|
|
|
|
total_svc = len(all_services)
|
|
hijacked = sum(1 for s in all_services if s["hijack"])
|
|
hijacked_lower = sum(1 for did, svcs in deliveries.items() for s in svcs
|
|
if not s["is_rebate"] and s["actual_price"] < s["base_price"] - 0.01)
|
|
hijacked_higher = sum(1 for did, svcs in deliveries.items() for s in svcs
|
|
if not s["is_rebate"] and s["actual_price"] > s["base_price"] + 0.01 and s["hijack"])
|
|
|
|
print("Total active services: {:,}".format(total_svc))
|
|
print("Hijacked (custom price): {:,}".format(hijacked))
|
|
print(" ↳ cheaper than catalog: {:,} (restore to catalog + absorb in rebate)".format(hijacked_lower))
|
|
print(" ↳ more expensive than catalog: {:,} (keep actual — custom service)".format(hijacked_higher))
|
|
print("Deliveries needing rebate adjustment: {:,}".format(total_deliveries_affected))
|
|
print("Deliveries with no rebate to absorb: {:,}".format(no_rebate_discount))
|
|
|
|
print("\n" + "=" * 70)
|
|
print("ANALYSIS COMPLETE — no changes made")
|
|
print("=" * 70)
|