diff --git a/apps/api/src/customers/numid.service.ts b/apps/api/src/customers/numid.service.ts index 9e53d76..03799aa 100644 --- a/apps/api/src/customers/numid.service.ts +++ b/apps/api/src/customers/numid.service.ts @@ -33,7 +33,9 @@ export const UTILITIES_TABLE = "DATGRAL"; * insurance/DATGRAL is a SEPARATE id space that reuses the same sourceTable name * and runs past 4,000. It must never be read as a NUMid, and never allocated * from: the portal cannot resolve those ids. Every query here filters on BOTH - * columns for that reason, never on sourceTable alone. + * columns for that reason, never on sourceTable alone. A customer can also hold + * more than one insurance ref — 16 of them do, where several insurance rows + * folded into one customer — so those are tested with EXISTS rather than joined. */ const POOL = { sourceSystem: UTILITIES_SYSTEM, diff --git a/apps/api/src/ops/ops.controller.ts b/apps/api/src/ops/ops.controller.ts index a73f07a..129190f 100644 --- a/apps/api/src/ops/ops.controller.ts +++ b/apps/api/src/ops/ops.controller.ts @@ -117,11 +117,17 @@ export class OpsController { @Post("jobs") async startJob(@Body() dto: StartJobDto, @Req() req: Request) { const userId = this.actingId(req); - const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId); + const job = await this.ops.startJob( + dto.kind, + { file: dto.file, forceFull: dto.forceFull }, + userId, + ); void this.audit.log(userId, "ops.job.start", { jobId: job.id, kind: dto.kind, file: dto.file, + // Recorded because this is the flag that authorised deleting native rows. + forceFull: dto.forceFull, }); return job; } diff --git a/apps/api/src/ops/ops.service.ts b/apps/api/src/ops/ops.service.ts index 8b53cc9..a8407c8 100644 --- a/apps/api/src/ops/ops.service.ts +++ b/apps/api/src/ops/ops.service.ts @@ -415,12 +415,19 @@ export class OpsService implements OnModuleInit { const out = shq(path.join(this.backupDir, file)); const py = await this.pythonBin(); const runAll = shq(path.join(this.migrationDir, "run_all.py")); + // run_all.py runs native_guard.py before it truncates anything and exits + // without touching the database when the target holds rows that only + // exist here — allocated portal NUMids, app-created customers, OCR + // captures. --force-full is what the operator ticks to delete them + // anyway; without it the job fails with the list. + const force = params.forceFull === true; const cmd = `${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` + `${this.dumpCommand(flags, db, out)} && ` + `echo '== Reimportación desde carpeta de ingesta ==' && ` + - `${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`; - return { cmd, resolvedParams: { safetyBackup: file } }; + `${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage` + + (force ? " --force-full" : ""); + return { cmd, resolvedParams: { safetyBackup: file, forceFull: force } }; } throw new BadRequestException(`Operación no soportada: ${kind}`); diff --git a/apps/api/src/ops/start-job.dto.ts b/apps/api/src/ops/start-job.dto.ts index 80bf26d..75aa616 100644 --- a/apps/api/src/ops/start-job.dto.ts +++ b/apps/api/src/ops/start-job.dto.ts @@ -1,4 +1,4 @@ -import { IsEnum, IsOptional, IsString } from "class-validator"; +import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator"; import { OpsJobKind } from "@jorgecuadros/database"; export class StartJobDto { @@ -9,4 +9,13 @@ export class StartJobDto { @IsOptional() @IsString() file?: string; + + /** + * REIMPORT only: proceed even though the rebuild deletes rows that exist only + * in the platform. Off by default, so the guard in run_all.py stops the job + * and lists what would be lost rather than the operator finding out after. + */ + @IsOptional() + @IsBoolean() + forceFull?: boolean; } diff --git a/apps/web/src/app/operaciones/page.tsx b/apps/web/src/app/operaciones/page.tsx index 6e02e82..5a80a66 100644 --- a/apps/web/src/app/operaciones/page.tsx +++ b/apps/web/src/app/operaciones/page.tsx @@ -59,6 +59,7 @@ function Operaciones() { const [notice, setNotice] = useState(null); const [confirm, setConfirm] = useState(null); const [confirmText, setConfirmText] = useState(""); + const [forceFull, setForceFull] = useState(false); const [uploading, setUploading] = useState(null); const [progress, setProgress] = useState(null); const [starting, setStarting] = useState(false); @@ -159,12 +160,12 @@ function Operaciones() { } } - async function start(kind: OpsJobKind, file?: string) { + async function start(kind: OpsJobKind, file?: string, force?: boolean) { setError(null); setNotice(null); setStarting(true); try { - const job = await startOpsJob(kind, file); + const job = await startOpsJob(kind, file, force); setActiveJob(job); setJobs((prev) => (prev ? [job, ...prev] : [job])); } catch (e) { @@ -177,6 +178,9 @@ function Operaciones() { function askConfirm(state: ConfirmState) { setConfirm(state); setConfirmText(""); + // Always re-armed: ticking "delete native rows" once must not carry into + // the next reimport. + setForceFull(false); setError(null); setNotice(null); } @@ -185,7 +189,7 @@ function Operaciones() { if (!confirm) return; const c = confirm; setConfirm(null); - if (c.kind === "REIMPORT") await start("REIMPORT"); + if (c.kind === "REIMPORT") await start("REIMPORT", undefined, forceFull); else if (c.kind === "SYNC") await start("SYNC"); else await start("RESTORE", c.file); } @@ -478,11 +482,24 @@ function Operaciones() {

{confirm.kind === "REIMPORT" - ? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático." + ? "Esto BORRA todos los datos actuales y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático. Si la base contiene registros que sólo existen en la plataforma (clientes creados aquí, números de portal asignados, pólizas capturadas por OCR, movimientos capturados), la operación se detiene y los enumera sin tocar nada." : confirm.kind === "SYNC" ? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran." : `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}

+ {confirm.kind === "REIMPORT" && ( + + )}