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>
This commit is contained in:
@@ -31,6 +31,29 @@ export const INGEST_FILES = [
|
||||
] as const;
|
||||
export type IngestName = (typeof INGEST_FILES)[number];
|
||||
|
||||
/**
|
||||
* A prior-period archive: one Access snapshot per closed year, named for the
|
||||
* period it holds. `2025.accdb` is UTILITIES as it stood when 2025 was cut.
|
||||
*
|
||||
* The filename is the entire declaration of the period — nothing inside the
|
||||
* file names its year, because a snapshot's `datos2` is indistinguishable from
|
||||
* the live one — so this pattern is both the allowlist and the contract. It is
|
||||
* anchored and allows no separator, which is what keeps an upload from
|
||||
* escaping the ingest directory.
|
||||
*/
|
||||
const PERIOD_FILE_RE = /^(\d{4})\.accdb$/i;
|
||||
|
||||
/** Earliest period we will accept, so a typo'd year cannot mint a bogus one. */
|
||||
const PERIOD_MIN_YEAR = 1990;
|
||||
|
||||
export function periodYearOf(name: string): number | null {
|
||||
const m = PERIOD_FILE_RE.exec(name);
|
||||
if (!m) return null;
|
||||
const year = Number(m[1]);
|
||||
if (year < PERIOD_MIN_YEAR || year > new Date().getUTCFullYear()) return null;
|
||||
return year;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix for every command containing a pipe. Without it the exit status of
|
||||
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
|
||||
@@ -120,19 +143,30 @@ export class OpsService implements OnModuleInit {
|
||||
|
||||
/* -------------------------------------------------------------- ingest */
|
||||
|
||||
private assertIngestName(name: string): IngestName {
|
||||
if (!INGEST_FILES.includes(name as IngestName)) {
|
||||
throw new BadRequestException(
|
||||
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return name as IngestName;
|
||||
private assertIngestName(name: string): string {
|
||||
if (INGEST_FILES.includes(name as IngestName)) return name;
|
||||
if (periodYearOf(name) !== null) return name;
|
||||
throw new BadRequestException(
|
||||
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}` +
|
||||
`, o un archivo de periodo anterior con nombre AAAA.accdb (por ejemplo 2025.accdb).`,
|
||||
);
|
||||
}
|
||||
|
||||
async listIngest(): Promise<
|
||||
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
|
||||
{
|
||||
name: string;
|
||||
present: boolean;
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
/** Set only on a prior-period archive; null on the four fixed sources. */
|
||||
periodYear: number | null;
|
||||
}[]
|
||||
> {
|
||||
return Promise.all(
|
||||
// The four fixed sources are listed whether present or not — they are
|
||||
// required, so "missing" is the useful state to show. Period archives are
|
||||
// optional and unbounded, so they are listed only once uploaded, newest
|
||||
// year first.
|
||||
const fixed = await Promise.all(
|
||||
INGEST_FILES.map(async (name) => {
|
||||
try {
|
||||
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||
@@ -141,12 +175,46 @@ export class OpsService implements OnModuleInit {
|
||||
present: true,
|
||||
size: st.size,
|
||||
modifiedAt: st.mtime.toISOString(),
|
||||
periodYear: null as number | null,
|
||||
};
|
||||
} catch {
|
||||
return { name, present: false, size: null, modifiedAt: null };
|
||||
return {
|
||||
name,
|
||||
present: false,
|
||||
size: null,
|
||||
modifiedAt: null,
|
||||
periodYear: null as number | null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(this.ingestDir);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
const periods = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.map((name) => ({ name, year: periodYearOf(name) }))
|
||||
.filter((e): e is { name: string; year: number } => e.year !== null)
|
||||
.sort((a, b) => b.year - a.year)
|
||||
.map(async ({ name, year }) => {
|
||||
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||
return {
|
||||
name,
|
||||
present: true,
|
||||
size: st.size,
|
||||
modifiedAt: st.mtime.toISOString(),
|
||||
periodYear: year,
|
||||
};
|
||||
}),
|
||||
)
|
||||
).filter(Boolean);
|
||||
|
||||
return [...fixed, ...periods];
|
||||
}
|
||||
|
||||
async saveIngest(name: string, data: Buffer): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { periodYearOf } from "./ops.service";
|
||||
|
||||
/**
|
||||
* `periodYearOf` is the upload allowlist for prior-period archives, so it is
|
||||
* doing two jobs at once: deciding what counts as a period file, and keeping a
|
||||
* caller-supplied name from escaping the ingest directory. Both are pinned here.
|
||||
*/
|
||||
describe("periodYearOf", () => {
|
||||
it("accepts a four-digit year archive", () => {
|
||||
expect(periodYearOf("2025.accdb")).toBe(2025);
|
||||
expect(periodYearOf("1999.accdb")).toBe(1999);
|
||||
});
|
||||
|
||||
it("is case-insensitive on the extension", () => {
|
||||
expect(periodYearOf("2025.ACCDB")).toBe(2025);
|
||||
});
|
||||
|
||||
it("rejects a path that would escape the ingest directory", () => {
|
||||
// The name is joined onto the ingest path, so anything with a separator or
|
||||
// a parent reference has to fail before it reaches the filesystem.
|
||||
expect(periodYearOf("../2025.accdb")).toBeNull();
|
||||
expect(periodYearOf("../../etc/passwd")).toBeNull();
|
||||
expect(periodYearOf("sub/2025.accdb")).toBeNull();
|
||||
expect(periodYearOf("2025.accdb/../../x")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects names that only look like a period", () => {
|
||||
expect(periodYearOf("202.accdb")).toBeNull();
|
||||
expect(periodYearOf("20255.accdb")).toBeNull();
|
||||
expect(periodYearOf("2025.mdb")).toBeNull();
|
||||
expect(periodYearOf("copia 2025.accdb")).toBeNull();
|
||||
expect(periodYearOf("2025.accdb.bak")).toBeNull();
|
||||
expect(periodYearOf("UTILITIES.accdb")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects years outside the plausible range", () => {
|
||||
// A typo'd year would otherwise mint a period nobody can ever reconcile:
|
||||
// there is no BALANCE FORWARD for the year after it to check against.
|
||||
expect(periodYearOf("1889.accdb")).toBeNull();
|
||||
expect(periodYearOf(`${new Date().getUTCFullYear() + 1}.accdb`)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts the current year, which is the earliest a period can be cut", () => {
|
||||
expect(periodYearOf(`${new Date().getUTCFullYear()}.accdb`)).toBe(
|
||||
new Date().getUTCFullYear(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user