"""Import PRINZIP CRM SQLite dump → Postgres prinzip_leads / prinzip_deals. Источник: 49 MB SQLite-дамп с analytics.prinzipevent.ru. user скачивает руками (доступ подтверждён) и кладёт в data/raw/prinzip_crm.sqlite (gitignored). Запуск: PYTHONIOENCODING=utf-8 python data/sql/52_import_prinzip_crm.py \\ --sqlite data/raw/prinzip_crm.sqlite Скрипт идемпотентный — UPSERT по lead_id / deal_id. Можно перезапускать после очередного экспорта SQLite. ⚠ Точная схема таблиц в SQLite неизвестна заранее. Скрипт сначала логирует sqlite_master + table_info по каждой таблице, чтобы можно было поправить mapping (поле LEAD_FIELD_MAP внизу). PII compliance (152-ФЗ): - phone → SHA256(phone)[:16] в phone_hash + last 4 digits в phone_last4 - email → MD5(email) в email_hash - name остаётся (legal: client может попросить удалить) """ from __future__ import annotations import argparse import hashlib import logging import os import sqlite3 import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) from sqlalchemy import create_engine, text # noqa: E402 logger = logging.getLogger(__name__) # ── PII helpers ────────────────────────────────────────────────────────────── def hash_phone(phone: str | None) -> tuple[str | None, str | None]: """Return (sha256_short, last4_digits).""" if not phone: return None, None digits = "".join(c for c in str(phone) if c.isdigit()) if not digits: return None, None h = hashlib.sha256(digits.encode("utf-8")).hexdigest()[:16] last4 = digits[-4:] if len(digits) >= 4 else digits return h, last4 def hash_email(email: str | None) -> str | None: if not email: return None return hashlib.md5(str(email).strip().lower().encode("utf-8")).hexdigest() # ── SQLite → Postgres column mapping (правится после первого dry-run) ─────── # # Скрипт ВПЕРВЫЕ логирует table_info — посмотри логи и поправь LEAD_FIELD_MAP # и DEAL_FIELD_MAP под реальные имена в SQLite. LEAD_FIELD_MAP = { "lead_id": ["id", "lead_id", "request_id"], "created_at": ["created_at", "createdAt", "datetime", "date"], "source": ["source", "utm_source", "channel"], "channel": ["channel", "utm_campaign"], "name": ["name", "client_name", "fio"], "phone": ["phone", "tel", "telephone"], "email": ["email", "mail"], "obj_interest": ["obj_id", "object_id", "complex_id", "jk_id"], "rooms": ["rooms", "room_count"], "budget_min": ["budget_min", "price_min"], "budget_max": ["budget_max", "price_max"], "status": ["status", "state"], } DEAL_FIELD_MAP = { "deal_id": ["id", "deal_id"], "lead_id": ["lead_id", "request_id"], "closed_at": ["closed_at", "deal_date", "date", "datetime"], "obj_id": ["obj_id", "object_id", "complex_id"], "flat_ods_id": ["flat_id", "flat_ods_id", "ods_id"], "rooms": ["rooms", "room_count"], "total_area": ["area", "total_area"], "deal_price": ["price", "deal_price", "amount"], "payment_type": ["payment_type", "payment_method"], "mortgage_bank": ["bank", "mortgage_bank"], "mortgage_rate": ["rate", "mortgage_rate"], "deal_stage": ["stage", "deal_stage"], } def find_field(row: sqlite3.Row, candidates: list[str]): """Pick first existing column from candidates in a sqlite Row.""" keys = row.keys() if hasattr(row, "keys") else [] for c in candidates: if c in keys: return row[c] return None # ── Main import ────────────────────────────────────────────────────────────── UPSERT_LEAD = text( """ INSERT INTO prinzip_leads ( lead_id, created_at, source, channel, name, phone_hash, phone_last4, email_hash, obj_interest, rooms, budget_min, budget_max, status, converted, deal_id ) VALUES ( :lead_id, :created_at, :source, :channel, :name, :phone_hash, :phone_last4, :email_hash, :obj_interest, :rooms, :budget_min, :budget_max, :status, :converted, :deal_id ) ON CONFLICT (lead_id) DO UPDATE SET status = EXCLUDED.status, converted = EXCLUDED.converted, deal_id = EXCLUDED.deal_id """ ) UPSERT_DEAL = text( """ INSERT INTO prinzip_deals ( deal_id, lead_id, closed_at, obj_id, flat_ods_id, rooms, total_area, deal_price, price_per_m2, payment_type, mortgage_bank, mortgage_rate, deal_stage ) VALUES ( :deal_id, :lead_id, :closed_at, :obj_id, :flat_ods_id, :rooms, :total_area, :deal_price, :price_per_m2, :payment_type, :mortgage_bank, :mortgage_rate, :deal_stage ) ON CONFLICT (deal_id) DO UPDATE SET deal_stage = EXCLUDED.deal_stage, deal_price = EXCLUDED.deal_price, mortgage_bank = EXCLUDED.mortgage_bank """ ) def inspect_sqlite(sqlite_path: Path) -> None: """Log all tables + columns for first-time schema discovery.""" conn = sqlite3.connect(sqlite_path) conn.row_factory = sqlite3.Row cur = conn.cursor() tables = [ r[0] for r in cur.execute( "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" ).fetchall() ] logger.info("SQLite contains %d tables: %s", len(tables), tables) for t in tables: cols = cur.execute(f"PRAGMA table_info({t})").fetchall() col_names = [c[1] for c in cols] cnt = cur.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0] logger.info(" %s (%d rows): %s", t, cnt, col_names) conn.close() def import_leads(sqlite_path: Path, pg_engine, table_name: str) -> int: conn = sqlite3.connect(sqlite_path) conn.row_factory = sqlite3.Row cur = conn.execute(f"SELECT * FROM {table_name}") n = 0 with pg_engine.begin() as pg_conn: for row in cur: phone_h, phone_last4 = hash_phone(find_field(row, LEAD_FIELD_MAP["phone"])) try: pg_conn.execute( UPSERT_LEAD, { "lead_id": find_field(row, LEAD_FIELD_MAP["lead_id"]), "created_at": find_field(row, LEAD_FIELD_MAP["created_at"]), "source": find_field(row, LEAD_FIELD_MAP["source"]), "channel": find_field(row, LEAD_FIELD_MAP["channel"]), "name": find_field(row, LEAD_FIELD_MAP["name"]), "phone_hash": phone_h, "phone_last4": phone_last4, "email_hash": hash_email(find_field(row, LEAD_FIELD_MAP["email"])), "obj_interest": find_field(row, LEAD_FIELD_MAP["obj_interest"]), "rooms": find_field(row, LEAD_FIELD_MAP["rooms"]), "budget_min": find_field(row, LEAD_FIELD_MAP["budget_min"]), "budget_max": find_field(row, LEAD_FIELD_MAP["budget_max"]), "status": find_field(row, LEAD_FIELD_MAP["status"]), "converted": (find_field(row, ["converted", "is_converted"]) or False), "deal_id": find_field(row, ["deal_id"]), }, ) n += 1 except Exception as e: logger.warning("lead %s skipped: %s", find_field(row, LEAD_FIELD_MAP["lead_id"]), e) conn.close() return n def import_deals(sqlite_path: Path, pg_engine, table_name: str) -> int: conn = sqlite3.connect(sqlite_path) conn.row_factory = sqlite3.Row cur = conn.execute(f"SELECT * FROM {table_name}") n = 0 with pg_engine.begin() as pg_conn: for row in cur: area = find_field(row, DEAL_FIELD_MAP["total_area"]) price = find_field(row, DEAL_FIELD_MAP["deal_price"]) ppm = (price / area) if area and price else None try: pg_conn.execute( UPSERT_DEAL, { "deal_id": find_field(row, DEAL_FIELD_MAP["deal_id"]), "lead_id": find_field(row, DEAL_FIELD_MAP["lead_id"]), "closed_at": find_field(row, DEAL_FIELD_MAP["closed_at"]), "obj_id": find_field(row, DEAL_FIELD_MAP["obj_id"]), "flat_ods_id": find_field(row, DEAL_FIELD_MAP["flat_ods_id"]), "rooms": find_field(row, DEAL_FIELD_MAP["rooms"]), "total_area": area, "deal_price": price, "price_per_m2": ppm, "payment_type": find_field(row, DEAL_FIELD_MAP["payment_type"]), "mortgage_bank": find_field(row, DEAL_FIELD_MAP["mortgage_bank"]), "mortgage_rate": find_field(row, DEAL_FIELD_MAP["mortgage_rate"]), "deal_stage": find_field(row, DEAL_FIELD_MAP["deal_stage"]), }, ) n += 1 except Exception as e: logger.warning("deal %s skipped: %s", find_field(row, DEAL_FIELD_MAP["deal_id"]), e) conn.close() return n def refresh_views(pg_engine) -> None: with pg_engine.begin() as pg_conn: pg_conn.execute(text("REFRESH MATERIALIZED VIEW prinzip_funnel_monthly")) pg_conn.execute(text("REFRESH MATERIALIZED VIEW prinzip_funnel_by_object")) def main() -> None: parser = argparse.ArgumentParser(description="PRINZIP CRM SQLite → Postgres importer") parser.add_argument( "--sqlite", type=Path, default=Path("data/raw/prinzip_crm.sqlite"), help="Путь к SQLite-дампу", ) parser.add_argument( "--inspect-only", action="store_true", help="Только перечислить таблицы и колонки SQLite (для подбора field-mapping)", ) parser.add_argument( "--leads-table", default="leads", help="Имя таблицы leads в SQLite" ) parser.add_argument( "--deals-table", default="deals", help="Имя таблицы deals в SQLite" ) parser.add_argument("--verbose", "-v", action="store_true") args = parser.parse_args() logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) if not args.sqlite.exists(): logger.error( "SQLite file not found: %s. Скачай файл с analytics.prinzipevent.ru " "и положи туда.", args.sqlite, ) sys.exit(1) inspect_sqlite(args.sqlite) if args.inspect_only: return db_url = os.environ.get( "DATABASE_URL", "postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign", ) engine = create_engine(db_url) logger.info("Importing leads from table %s …", args.leads_table) n_leads = import_leads(args.sqlite, engine, args.leads_table) logger.info("Imported %d leads.", n_leads) logger.info("Importing deals from table %s …", args.deals_table) n_deals = import_deals(args.sqlite, engine, args.deals_table) logger.info("Imported %d deals.", n_deals) logger.info("Refreshing materialized views…") refresh_views(engine) logger.info("Done.") if __name__ == "__main__": main()