Files
jorgecuadros-platform/migration/config.py
T
rmancinasandClaude Opus 5 29ae9fa5bc
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m43s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m19s
feat(migration): import prior periods so a closed year can be shown on its own
Legacy ran a year-end corte: it summed the closing year, wrote that total back
as each customer's Jan-1 BALANCE FORWARD, and started the next year clean. The
platform inherited those opening rows but never the years behind them — only
the current year's charges were ever staged, so every prior year held receipts
and no bills. Rendering one would have shown a customer credits with nothing
owed against them, which is worse than showing nothing.

The archives are whole-database Access snapshots named for the period they
hold, so `2025.accdb` is discovered by filename, staged, and loaded through the
existing DATOS2 branch — a snapshot's `datos2` is the identical shape, one year
older. Only the ledger and DATGRAL come out of a snapshot; everything else in
it is a year-stale copy of a live table. The cash side is deliberately left
behind: EFECTIVO is a lifetime journal, so the snapshot's copy is a subset of
the live one and importing it would double-book every prior-year receipt.

Period travels with the row, in legacySourceTable as `datos2@2025`. That tag,
not the date, is what a year view should filter on: the archives are not
cleanly bounded (2025 carries ten undated rows and two dated into 2026) and
legacy never filtered by date either — its reader is `SELECT ... FROM \`2025\``.
The tag also keeps legacyId safe, since it is a positional ordinal that
restarts at 0 in every archive and would otherwise collide row-for-row.

Two guards, because attaching a prior year by NUMid is the one thing here that
can go quietly wrong:

  - Reissued numbers are skipped, not imported. Comparing each archive's
    DATGRAL against the live one, 13 names moved since 2025 and 40 since 2024;
    most are the same customer re-described, but a few are a different
    household holding a recycled number, and filing their ledger under the new
    owner would show a stranger's charges. Sharing any word of three or more
    characters separates a rename from a reissue. Names are compared
    legacy-to-legacy: `customers.name` has been through blank-name recovery,
    and comparing to it reported 121 drifts where there are 13.
  - Every period is checked against the corte identity it must satisfy —
    SUM(year N) == BALANCE FORWARD(N+1) — and the result is reported per year.
    A truncated export, a file dropped under the wrong year, or a botched
    customer match all fail loudly here. 2025 reconciles 1,159/1,167 (99.3%)
    and 2024 1,144/1,156 (99.0%); the recycle guard raised 2024 from 98.2%.

Balances are untouched: BALANCE_FLOOR_JOIN floors on the newest BALANCE FORWARD
per customer, so rows behind it are already excluded from every balance read.

Uploads go through the existing ingest endpoint, allowlisted by an anchored
`AAAA.accdb` pattern that also keeps a caller-supplied name inside the ingest
directory. The Operaciones page grows an entry point for an archive that has no
row yet, reading the period off the chosen file's own name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:26:58 -07:00

155 lines
5.6 KiB
Python

"""
Source-database manifest for the staging load (migration plan step 1).
Exclusions here are deliberately conservative: only tables that are either
(a) confirmed empty (0 rows — nothing is lost by skipping them) or (b) have
rows but are structurally not customer/business data (mail-merge document
templates, materialized Access query results) are excluded. Anything with
real rows and an ambiguous purpose (e.g. PROPANO, datosfreak, pagos email)
is loaded into staging anyway — the reconciliation pass decides what to do
with it, per the migration plan's "don't guess the rule up front" principle.
See PLAN.md (repo root) for the full rationale per table group.
Environment note: this project moved from Windows to macOS. The original
extraction path (pyodbc + the Windows Access ODBC driver in extract.py) does
not work on macOS; extraction is being reworked to use mdbtools
(`brew install mdbtools`). This SOURCE_ROOT points at the macOS location of
the four Access source files.
"""
import os
import re
from pathlib import Path
# The folder holding the four Access source files. Overridable via INGEST_DIR so
# the web "Operaciones" ingest folder (a mounted volume in the API container)
# feeds the same pipeline. Falls back to the original macOS download location
# for a plain local run.
SOURCE_ROOT = Path(
os.environ.get("INGEST_DIR")
or (Path.home() / "Downloads" / "JorgeCuadros-Legacy")
)
SOURCES = {
"utilities": {
"path": SOURCE_ROOT / "UTILITIES.accdb",
"schema": "stg_utilities",
# Confirmed empty (0 rows) working/scratch tables.
"exclude": {
"BANCO EDITOR",
"Errores de pegado",
"TABLE1",
"PARA BILLING SIN",
"PARA BILLING1",
"PARA BILLING2",
"PARA EDO",
"FALTANTES AGUA",
"FALTANTES TEL",
"LUZ TODOS",
"TELEFONOS FECHAS",
"TRUSTHFEE",
"faltantes luz",
"billing", # 0 rows; superseded by datos2/FEE ANUAL/fee15
"TIT", # 1 row, default Access "Contacts" template shell — not real data
},
},
"seguros": {
# SEGUROS 16.mdb is an empty linked front-end; all data lives in _be.
"path": SOURCE_ROOT / "SEGUROS 16_be.mdb",
"schema": "stg_seguros",
"exclude": {
# Mail-merge document templates (letters/certificates), not data.
"AMPL MENS",
"AMPL R MENS",
"IN MENS",
"LIC MENS",
"MCA2 MENS",
"ME MENS",
"MF MENS",
"RC MENSAJE",
"RC R MENS",
# Materialized Access query results, not source-of-truth data.
"TODOSJC",
"TODOS",
"vigenta casa y auto unicos",
# Confirmed empty scratch tables.
"ID TABLA",
"ID TABLATLAS",
"TABLA LIQUIDA MCA2",
"TABLA LIQUIDA MF",
"TABLA LIQUIDA RES",
"TABLA LIQUIDA TUR",
"TABLA LIQUIDA TUR ENDOSO",
"TABLA AUTOS LIMIT R",
"BORRA",
"GENERICO_OLD",
"TIT",
},
},
"scothia": {
"path": SOURCE_ROOT / "SCOTHIA.mdb",
"schema": "stg_scothia",
"exclude": {
"INFORME",
"INFORME BA",
"FECHAIF", # date-range UI parameter table, not data
},
},
}
# --- prior-period archives ----------------------------------------------
#
# Legacy ran a year-end *corte*: it summed the closing year, wrote that total
# back as each customer's Jan-1 BALANCE FORWARD, and started the next year
# clean. Access keeps the closed year as a whole-database snapshot named for
# the period it holds — `2025.accdb` is UTILITIES as it stood when 2025 was
# cut — and the office archives one per year.
#
# Only the ledger is staged out of a snapshot. Everything else in it (DATMEX,
# PROFILE, EFECTIVO, ...) is a year-stale copy of a table the live
# UTILITIES.accdb already provides, and staging all ~50 of them would triple
# the extract time to import data we would then have to ignore. DATGRAL comes
# along solely to check that a NUMid still means the same customer it did that
# year; see the recycle guard in transform_transactions.py.
#
# The cash side is deliberately NOT taken from the snapshot: `EFECTIVO` is a
# lifetime journal, so the snapshot's copy is a subset of the live one and
# importing it would double-book every prior-year receipt.
PERIOD_FILE_RE = re.compile(r"^(\d{4})\.accdb$", re.IGNORECASE)
PERIOD_TABLES = {"datos2", "DATGRAL"}
def period_schema(year: int) -> str:
return f"stg_period_{year}"
def discover_periods(root: Path) -> dict[str, dict]:
"""Find every `YYYY.accdb` archive sitting in the ingest folder.
Discovery is by filename because that is the whole upload contract: the
operator drops `2025.accdb` on the Operaciones page and the period is 2025.
Nothing inside the file names the year — a snapshot's `datos2` looks
identical to the live one — so the name is the only declaration of intent
we get, and it is what the allowlist on the upload endpoint enforces.
"""
found: dict[str, dict] = {}
if not root.is_dir():
return found
for path in sorted(root.iterdir()):
m = PERIOD_FILE_RE.match(path.name)
if not m:
continue
year = int(m.group(1))
found[f"period_{year}"] = {
"path": path,
"schema": period_schema(year),
"exclude": set(),
"include": set(PERIOD_TABLES),
"period_year": year,
}
return found
SOURCES.update(discover_periods(SOURCE_ROOT))