""" Phase 1 — Step 1.6: crm_quotations (SQLite → Postgres) NOTE: crm_quotations has a FK to crm_customers(id). The customer rows DO NOT exist in Postgres yet (they migrate in Phase 2). To avoid FK violations, this script temporarily disables FK checks for the session using SET CONSTRAINTS ALL DEFERRED — but since customer_id is a real FK with ON DELETE CASCADE, we instead insert with the constraint deferred. Safer approach used here: insert with `customer_id` as-is and rely on the fact that crm_customers will be populated in Phase 2 before any service reads join across the two tables. The FK is not deferred — instead we disable the FK constraint enforcement for this transaction only via a session-level SET session_replication_role = replica; which suppresses FK checks in Postgres. We restore it immediately after the transaction. Run on VPS: docker compose exec backend python -m migration.migrate_crm_quotations """ import asyncio import sys from decimal import Decimal from sqlalchemy import text from sqlalchemy.dialects.postgresql import insert as pg_insert from crm.orm import CrmQuotation from migration.utils import open_sqlite, AsyncPgSession, parse_dt, parse_json, log_run, pg_count SCRIPT = "migrate_crm_quotations" def _dec(val, default="0") -> Decimal: try: return Decimal(str(val)) if val is not None else Decimal(default) except Exception: return Decimal(default) async def run() -> None: sqlite = await open_sqlite() rows = await sqlite.execute_fetchall("SELECT * FROM crm_quotations ORDER BY created_at") await sqlite.close() source_count = len(rows) print(f"Source (SQLite): {source_count} crm_quotations rows") if source_count == 0: print("Nothing to migrate.") await log_run(SCRIPT, 0, 0, notes="source empty") return records = [] for r in rows: records.append({ "id": r["id"], "quotation_number": r["quotation_number"], "title": r["title"], "subtitle": r["subtitle"], "customer_id": r["customer_id"], "language": r["language"] or "en", "status": r["status"] or "draft", "order_type": r["order_type"], "shipping_method": r["shipping_method"], "estimated_shipping_date": r["estimated_shipping_date"], "global_discount_label": r["global_discount_label"], "global_discount_percent": _dec(r["global_discount_percent"]), "vat_percent": _dec(r["vat_percent"], "24"), "global_vat_percent": _dec(r["global_vat_percent"], "24"), "shipping_cost": _dec(r["shipping_cost"]), "shipping_cost_discount": _dec(r["shipping_cost_discount"]), "install_cost": _dec(r["install_cost"]), "install_cost_discount": _dec(r["install_cost_discount"]), "extras_label": r["extras_label"], "extras_cost": _dec(r["extras_cost"]), "comments": parse_json(r["comments"], default=[]), "quick_notes": parse_json(r["quick_notes"], default={}), "subtotal_before_discount": _dec(r["subtotal_before_discount"]), "global_discount_amount": _dec(r["global_discount_amount"]), "new_subtotal": _dec(r["new_subtotal"]), "vat_amount": _dec(r["vat_amount"]), "final_total": _dec(r["final_total"]), "nextcloud_pdf_path": r["nextcloud_pdf_path"], "nextcloud_pdf_url": r["nextcloud_pdf_url"], "client_org": r["client_org"], "client_name": r["client_name"], "client_location": r["client_location"], "client_phone": r["client_phone"], "client_email": r["client_email"], "is_legacy": bool(r["is_legacy"]) if r["is_legacy"] is not None else False, "legacy_date": r["legacy_date"], "legacy_pdf_path": r["legacy_pdf_path"], "created_at": parse_dt(r["created_at"]), "updated_at": parse_dt(r["updated_at"]), }) async with AsyncPgSession() as session: # Disable FK enforcement for this session so we can insert quotations # before the referenced crm_customers rows arrive in Phase 2. await session.execute(text("SET session_replication_role = replica")) async with session.begin(): stmt = pg_insert(CrmQuotation).values(records) stmt = stmt.on_conflict_do_nothing(index_elements=["id"]) await session.execute(stmt) dest_count = await pg_count(session, "crm_quotations") await session.execute(text("SET session_replication_role = DEFAULT")) if dest_count < source_count: msg = f"Count mismatch: source={source_count} postgres={dest_count}" print(f"ERROR: {msg}", file=sys.stderr) await log_run(SCRIPT, source_count, dest_count, success=False, notes=msg) sys.exit(1) print(f"Postgres: {dest_count} rows ✓") await log_run(SCRIPT, source_count, dest_count) if __name__ == "__main__": asyncio.run(run())