Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec499ca5f5 | ||
|
|
b5983ba687 |
@@ -12,3 +12,6 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
packages/database/generated/
|
packages/database/generated/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# local package-manager shim (machine npm is pnpm-aliased); npm is canonical
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|||||||
@@ -169,11 +169,24 @@ Homebrew. No Access ODBC driver, `node_modules` not installed, staging Parquet n
|
|||||||
(folio collides; migrate both), the billing tables are disjoint period runs (union all, no
|
(folio collides; migrate both), the billing tables are disjoint period runs (union all, no
|
||||||
de-dup), and COBRO3 is a charge batch not a customer snapshot (DATGRAL is sole master). The
|
de-dup), and COBRO3 is a charge batch not a customer snapshot (DATGRAL is sole master). The
|
||||||
decided union/de-dup rules are in `PLAN.md` migration step 2.
|
decided union/de-dup rules are in `PLAN.md` migration step 2.
|
||||||
4. **Transform + load** (plan step 3) — NEXT. Start with `Customer`/`CustomerLegacyRef` (every
|
4. **Transform + load** (plan step 3) — IN PROGRESS.
|
||||||
other module depends on it): DATGRAL (utilities) is the master; join insurance `DATGRAL`
|
- **Customers — DONE** (`migration/transform_customers.py`). Loaded into the dev DB: 1682
|
||||||
via its `NUM UTIL` cross-ref + name/address matching; COBRO3 excluded from customers. Then
|
customers (1172 utilities master + 510 insurance-only), 2242 legacy refs (all traceable),
|
||||||
the ledger union per the reconciliation rules (both EFECTIVO tables, all three billing tables,
|
560 insurance rows linked via `num_util` with 0 broken refs, **542 merged identities**
|
||||||
provenance-keyed; normalize `monedas` currency variants).
|
spanning both business lines; linked customers enriched with insurance-only ID-doc fields.
|
||||||
|
COBRO3 excluded. Re-runnable (truncate+rebuild); needs staged Parquet present
|
||||||
|
(`load_staging.py --output-dir ./output` first).
|
||||||
|
- **NEXT:** properties+services (DATMEX/PROFILE), policies (+installments/vehicles/drivers/
|
||||||
|
beneficiaries/claims), then the ledger union per the reconciliation rules (both EFECTIVO
|
||||||
|
tables, all three billing tables, provenance-keyed; normalize `monedas` currency variants),
|
||||||
|
SCOTHIA bank register. Each resolves its customer FK through `customer_legacy_refs`.
|
||||||
|
5b. **Infra done:** dev MySQL deployed to the cubex Swarm via Portainer API as stack
|
||||||
|
`jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled
|
||||||
|
`jorgecuadros_db=true`); Prisma schema pushed (26 tables). Stack file:
|
||||||
|
`deploy/jorgecuadros-db.stack.yml` (same file deploys prod as `jorgecuadros-prod-db` :3306).
|
||||||
|
Creds in gitignored `deploy/.env.dev`. NOTE: machine `npm` is pnpm-aliased and pnpm ignores
|
||||||
|
the `workspaces` field — full workspace install needs `pnpm-workspace.yaml` or real npm; for
|
||||||
|
now Prisma CLI is run via `npx prisma@5`.
|
||||||
5. **Customer module** in `apps/api`/`apps/web` (list/search/detail) — first real feature,
|
5. **Customer module** in `apps/api`/`apps/web` (list/search/detail) — first real feature,
|
||||||
Spanish-first UI. Run `npm install` at repo root first (node_modules absent here).
|
Spanish-first UI. Run `npm install` at repo root first (node_modules absent here).
|
||||||
6. **Sync design finalization** — now unblocked: map the internal→VPS replicated subset and the
|
6. **Sync design finalization** — now unblocked: map the internal→VPS replicated subset and the
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Canonical internal MySQL for the Jorge Cuadros platform.
|
||||||
|
#
|
||||||
|
# Target: Portainer "local" endpoint on cubex (192.168.4.212:9443), which is a
|
||||||
|
# 3-node Docker Swarm. This is the source-of-truth database from the plan's
|
||||||
|
# "internal server" — the migration transform+load and the NestJS API both
|
||||||
|
# point at it, and (later) it becomes the replication SOURCE for the VPS.
|
||||||
|
#
|
||||||
|
# DEV / PROD as two stacks from this one file. Deploy it twice with different
|
||||||
|
# stack names + env_data:
|
||||||
|
# dev : stack jorgecuadros-dev-db MYSQL_PORT=3307 MYSQL_SERVER_ID=11
|
||||||
|
# prod: stack jorgecuadros-prod-db MYSQL_PORT=3306 MYSQL_SERVER_ID=1
|
||||||
|
# Swarm namespaces the named volume by stack name, so the two environments get
|
||||||
|
# fully isolated data (jorgecuadros-dev-db_mysql_data vs -prod-db_...) with no
|
||||||
|
# extra config. Distinct published ports let both run on the same node at once.
|
||||||
|
# server-id must be unique per environment (prod=1 is the replication source).
|
||||||
|
#
|
||||||
|
# Swarm statefulness rules (see portainer-gitea-deploy skill):
|
||||||
|
# - named volume, never a relative bind mount (the API deploy path won't
|
||||||
|
# create host dirs -> "bind source path does not exist").
|
||||||
|
# - a named volume is LOCAL to whichever node the task lands on. With no
|
||||||
|
# shared storage, the DB MUST be pinned to one node or a reschedule would
|
||||||
|
# start against a fresh empty volume. Pinned here via a node label so it is
|
||||||
|
# not tied to a hostname: label exactly one node with
|
||||||
|
# docker node update --label-add jorgecuadros_db=true <node>
|
||||||
|
# and MySQL will always run there, reusing the same volume.
|
||||||
|
#
|
||||||
|
# Secrets (MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD) are injected at deploy time via
|
||||||
|
# Portainer env_data / stack environment, not committed here.
|
||||||
|
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
command:
|
||||||
|
# (caching_sha2_password is already the default in 8.4; the old
|
||||||
|
# --default-authentication-plugin flag was REMOVED in 8.4 and aborts boot.)
|
||||||
|
# binlog + GTID on from day one so this node can be the replication
|
||||||
|
# SOURCE for the VPS replica later without a restart/reconfigure.
|
||||||
|
- --server-id=${MYSQL_SERVER_ID:-1}
|
||||||
|
- --log-bin=mysql-bin
|
||||||
|
- --binlog-format=ROW
|
||||||
|
- --gtid-mode=ON
|
||||||
|
- --enforce-gtid-consistency=ON
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-jorgecuadros}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-jorgecuadros}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
||||||
|
ports:
|
||||||
|
# Swarm ingress publishes on every node, so this is reachable at
|
||||||
|
# 192.168.4.212:${MYSQL_PORT} regardless of which node the task pins to.
|
||||||
|
- target: 3306
|
||||||
|
published: ${MYSQL_PORT:-3306}
|
||||||
|
protocol: tcp
|
||||||
|
mode: ingress
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.labels.jorgecuadros_db == true
|
||||||
|
restart_policy:
|
||||||
|
condition: any
|
||||||
|
update_config:
|
||||||
|
order: stop-first
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""
|
||||||
|
Migration plan step 3 (customers): build the unified customer master.
|
||||||
|
|
||||||
|
Reads staged Parquet and loads `customers` + `customer_legacy_refs` in the
|
||||||
|
Prisma-managed MySQL. This is the core of the whole project — one customer
|
||||||
|
record shared by both business lines — so every later module (policies,
|
||||||
|
properties, transactions) resolves its customer FK through the legacy refs
|
||||||
|
written here.
|
||||||
|
|
||||||
|
Rules come from the reconciliation pass (RECONCILIATION.md):
|
||||||
|
- Utilities `DATGRAL` (1172) is the customer master; one Customer each.
|
||||||
|
- Insurance `DATGRAL` (1070) links to a utilities customer via its
|
||||||
|
`num_util` cross-reference. Matches fold into the existing customer (and
|
||||||
|
enrich it with the ID-document fields the utilities master lacks);
|
||||||
|
non-matches become new insurance-only customers.
|
||||||
|
- `COBRO3` is a charge batch, NOT a customer source -> excluded here.
|
||||||
|
|
||||||
|
Every legacy row folded in gets a `customer_legacy_refs` row
|
||||||
|
(sourceSystem, sourceTable=DATGRAL, legacyId) so the merge is auditable and
|
||||||
|
the load is idempotent (re-run = truncate + rebuild).
|
||||||
|
|
||||||
|
Run: ./.venv/bin/python transform_customers.py
|
||||||
|
(reads deploy/.env.dev for the dev DATABASE credentials)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
STG = Path(__file__).parent / "output"
|
||||||
|
NULL = "∅"
|
||||||
|
NOW = datetime.now(timezone.utc).replace(tzinfo=None) # naive UTC for MySQL DATETIME
|
||||||
|
|
||||||
|
_TRUE = {"1", "-1", "true", "verdadero", "si", "sí", "activo", "yes", "y", "t"}
|
||||||
|
_FALSE = {"0", "false", "falso", "no", "inactivo", "n", "f"}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------- source normalization --------------------------- #
|
||||||
|
def load(source: str, table: str) -> pd.DataFrame:
|
||||||
|
df = pd.read_parquet(STG / source / f"{table}.parquet")
|
||||||
|
df = df[[c for c in df.columns if c not in ("_legacy_source_table", "_row_num")]].copy()
|
||||||
|
for c in df.columns:
|
||||||
|
df[c] = df[c].astype("string").str.strip()
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def s(v) -> str | None:
|
||||||
|
"""Cell -> clean string or None (empty/sentinel -> None)."""
|
||||||
|
if v is None or pd.isna(v):
|
||||||
|
return None
|
||||||
|
v = str(v).strip()
|
||||||
|
return None if v in ("", NULL, "0000-00-00") else v
|
||||||
|
|
||||||
|
|
||||||
|
def norm_id(v) -> str | None:
|
||||||
|
v = s(v)
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if v.endswith(".0"): # some numeric ids serialize as "521.0"
|
||||||
|
v = v[:-2]
|
||||||
|
return v if v not in ("0",) else None
|
||||||
|
|
||||||
|
|
||||||
|
def as_bool(v, default=True) -> int:
|
||||||
|
v = s(v)
|
||||||
|
if v is None:
|
||||||
|
return 1 if default else 0
|
||||||
|
lv = v.lower()
|
||||||
|
if lv in _TRUE:
|
||||||
|
return 1
|
||||||
|
if lv in _FALSE:
|
||||||
|
return 0
|
||||||
|
return 1 if default else 0
|
||||||
|
|
||||||
|
|
||||||
|
def as_date(v):
|
||||||
|
v = s(v)
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
dt = pd.to_datetime(v, errors="coerce")
|
||||||
|
if pd.isna(dt):
|
||||||
|
return None
|
||||||
|
return dt.to_pydatetime()
|
||||||
|
|
||||||
|
|
||||||
|
def as_decimal(v):
|
||||||
|
v = s(v)
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
v = v.replace(",", "")
|
||||||
|
try:
|
||||||
|
return Decimal(v)
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------- record builders ---------------------------- #
|
||||||
|
def customer_from_utilities(row) -> dict:
|
||||||
|
return dict(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=s(row["nombre"]) or "(SIN NOMBRE)",
|
||||||
|
addressLine1=s(row["direccion"]),
|
||||||
|
addressLine2=s(row["colonia"]),
|
||||||
|
city=s(row["ciudad"]),
|
||||||
|
state=s(row["estado"]),
|
||||||
|
zipCode=s(row["codigo"]),
|
||||||
|
country=s(row["pais"]),
|
||||||
|
phone=s(row["telusa"]),
|
||||||
|
mobile=s(row["cel"]),
|
||||||
|
fax=s(row["fax"]),
|
||||||
|
email=s(row["email"]),
|
||||||
|
notes=s(row["observaciones"]),
|
||||||
|
identificationType=None,
|
||||||
|
identificationNumber=None,
|
||||||
|
identificationExpiration=None,
|
||||||
|
customerSince=as_date(row["cliente_desde"]),
|
||||||
|
status=as_bool(row["status"]),
|
||||||
|
feeAmount=as_decimal(row["fee"]),
|
||||||
|
updatedAt=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def customer_from_insurance(row) -> dict:
|
||||||
|
return dict(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=s(row["nombre"]) or "(SIN NOMBRE)",
|
||||||
|
addressLine1=s(row["direccion_1"]),
|
||||||
|
addressLine2=s(row["direccion_2"]),
|
||||||
|
city=s(row["ciudad"]),
|
||||||
|
state=s(row["estado"]),
|
||||||
|
zipCode=s(row["codigo"]),
|
||||||
|
country=s(row["pais"]),
|
||||||
|
phone=s(row["telusa"]),
|
||||||
|
mobile=s(row["tel"]),
|
||||||
|
fax=s(row["fax"]),
|
||||||
|
email=s(row["emailaddress"]),
|
||||||
|
notes=s(row["observaciones"]),
|
||||||
|
identificationType=s(row["tipo_identificacion"]),
|
||||||
|
identificationNumber=s(row["no_identificacion"]),
|
||||||
|
identificationExpiration=as_date(row["expira_identificacion"]),
|
||||||
|
customerSince=None,
|
||||||
|
status=1,
|
||||||
|
feeAmount=None,
|
||||||
|
updatedAt=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CUST_COLS = [
|
||||||
|
"id", "name", "addressLine1", "addressLine2", "city", "state", "zipCode",
|
||||||
|
"country", "phone", "mobile", "fax", "email", "notes", "identificationType",
|
||||||
|
"identificationNumber", "identificationExpiration", "customerSince",
|
||||||
|
"status", "feeAmount", "updatedAt",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
envf = REPO / "deploy" / ".env.dev"
|
||||||
|
env = dict(
|
||||||
|
l.strip().split("=", 1) for l in envf.read_text().splitlines()
|
||||||
|
if "=" in l and not l.startswith("#") and not l.startswith("DATABASE_URL")
|
||||||
|
)
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="192.168.4.212", port=int(env["MYSQL_PORT"]), user=env["MYSQL_USER"],
|
||||||
|
password=env["MYSQL_PASSWORD"], database=env["MYSQL_DATABASE"], autocommit=False,
|
||||||
|
)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Fresh, idempotent rebuild.
|
||||||
|
cur.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
|
cur.execute("TRUNCATE TABLE customer_legacy_refs")
|
||||||
|
cur.execute("TRUNCATE TABLE customers")
|
||||||
|
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
|
|
||||||
|
util = load("stg_utilities", "datgral")
|
||||||
|
ins = load("stg_seguros", "datgral")
|
||||||
|
|
||||||
|
customers: list[dict] = []
|
||||||
|
refs: list[tuple] = [] # (id, customerId, sourceSystem, sourceTable, legacyId)
|
||||||
|
util_map: dict[str, str] = {} # utilities num_id -> customer_id
|
||||||
|
|
||||||
|
# Phase A: utilities DATGRAL = the master.
|
||||||
|
for _, row in util.iterrows():
|
||||||
|
rec = customer_from_utilities(row)
|
||||||
|
customers.append(rec)
|
||||||
|
nid = norm_id(row["num_id"])
|
||||||
|
legacy = nid or f"rownum_{len(customers)}"
|
||||||
|
refs.append((str(uuid.uuid4()), rec["id"], "utilities", "DATGRAL", legacy))
|
||||||
|
if nid:
|
||||||
|
util_map[nid] = rec["id"]
|
||||||
|
|
||||||
|
# Phase B: insurance DATGRAL links via num_util, else new customer.
|
||||||
|
linked = new_ins = unmatched_numutil = 0
|
||||||
|
enrich: list[tuple] = [] # (customerId, insurance record) for fill-in
|
||||||
|
for _, row in ins.iterrows():
|
||||||
|
ins_id = norm_id(row["num_id"]) or f"insrow_{new_ins+linked}"
|
||||||
|
nutil = norm_id(row["num_util"])
|
||||||
|
if nutil and nutil in util_map:
|
||||||
|
cust_id = util_map[nutil]
|
||||||
|
linked += 1
|
||||||
|
enrich.append((cust_id, customer_from_insurance(row)))
|
||||||
|
else:
|
||||||
|
if nutil and nutil not in util_map:
|
||||||
|
unmatched_numutil += 1
|
||||||
|
rec = customer_from_insurance(row)
|
||||||
|
customers.append(rec)
|
||||||
|
cust_id = rec["id"]
|
||||||
|
new_ins += 1
|
||||||
|
refs.append((str(uuid.uuid4()), cust_id, "insurance", "DATGRAL", ins_id))
|
||||||
|
|
||||||
|
# Insert customers.
|
||||||
|
placeholders = ",".join(["%s"] * len(_CUST_COLS))
|
||||||
|
cur.executemany(
|
||||||
|
f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders})",
|
||||||
|
[tuple(rec[c] for c in _CUST_COLS) for rec in customers],
|
||||||
|
)
|
||||||
|
# Insert legacy refs.
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO customer_legacy_refs (id, customerId, sourceSystem, sourceTable, legacyId) "
|
||||||
|
"VALUES (%s,%s,%s,%s,%s)",
|
||||||
|
refs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich linked customers with insurance-only ID-doc fields, and fill any
|
||||||
|
# contact fields the utilities master left empty (COALESCE keeps master's).
|
||||||
|
for cust_id, ir in enrich:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE customers SET "
|
||||||
|
"identificationType = COALESCE(identificationType, %s), "
|
||||||
|
"identificationNumber = COALESCE(identificationNumber, %s), "
|
||||||
|
"identificationExpiration = COALESCE(identificationExpiration, %s), "
|
||||||
|
"email = COALESCE(email, %s), "
|
||||||
|
"phone = COALESCE(phone, %s), "
|
||||||
|
"mobile = COALESCE(mobile, %s), "
|
||||||
|
"updatedAt = %s "
|
||||||
|
"WHERE id = %s",
|
||||||
|
(ir["identificationType"], ir["identificationNumber"],
|
||||||
|
ir["identificationExpiration"], ir["email"], ir["phone"],
|
||||||
|
ir["mobile"], NOW, cust_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ---- validation / report ----
|
||||||
|
cur.execute("SELECT COUNT(*) FROM customers")
|
||||||
|
n_cust = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT COUNT(*) FROM customer_legacy_refs")
|
||||||
|
n_refs = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT sourceSystem, COUNT(*) FROM customer_legacy_refs GROUP BY sourceSystem")
|
||||||
|
by_sys = dict(cur.fetchall())
|
||||||
|
cur.execute("SELECT COUNT(*) FROM customer_legacy_refs "
|
||||||
|
"GROUP BY customerId HAVING COUNT(*) > 1")
|
||||||
|
merged = len(cur.fetchall())
|
||||||
|
|
||||||
|
print("=== Customer load complete ===")
|
||||||
|
print(f" utilities DATGRAL rows : {len(util)}")
|
||||||
|
print(f" insurance DATGRAL rows : {len(ins)}")
|
||||||
|
print(f" linked to a utilities customer : {linked}")
|
||||||
|
print(f" new insurance-only customers : {new_ins}")
|
||||||
|
print(f" num_util set but not in utilities master (data-quality) : {unmatched_numutil}")
|
||||||
|
print(f" -> customers : {n_cust} (expected {len(util)} + {new_ins} = {len(util)+new_ins})")
|
||||||
|
print(f" -> customer_legacy_refs : {n_refs} (expected {len(util)+len(ins)} = {len(util)+len(ins)})")
|
||||||
|
print(f" refs by system : {by_sys}")
|
||||||
|
print(f" customers with >1 ref (merged identities) : {merged}")
|
||||||
|
assert n_cust == len(util) + new_ins, "customer count mismatch"
|
||||||
|
assert n_refs == len(util) + len(ins), "legacy ref count mismatch"
|
||||||
|
print(" validation: OK")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user