feat(migration): refuse a full re-import that would delete native rows
A full run_all.py pass truncates and rebuilds every table it owns from the Access extract. That was harmless while the platform was a read-only mirror -- every row came from the extract, so wiping and rebuilding lost nothing. It stopped being harmless once the platform started minting rows Access has never heard of: allocated portal NUMids, customers created in the staff UI, OCR-captured policies, app-booked ledger rows, uploaded documents. REIMPORT is a button in /operaciones, so that was one click away. native_guard.py counts what only exists here and exits 3; run_all.py runs it before the first truncate and stops. Detecting an allocated NUMid needs the staged Parquet -- the customer holds an ordinary-looking (utilities, DATGRAL, '1172') ref, so "customer has no refs" cannot see it and only comparing against the extract can. Missing staging is therefore treated as blocking rather than as "nothing to protect". The guard does not teach full mode to preserve anything: --sync already upserts legacy rows against the existing refs and leaves the rest alone, and rebuilding that inside full mode would re-implement it. --force-full (checkbox in the REIMPORT confirm, recorded in the audit log) deletes them deliberately. Verified against dev: clean before, exit 3 listing utilities/1172 with a synthetic ref present, clean again after removing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,7 @@ function Operaciones() {
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [forceFull, setForceFull] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<UploadProgress | null>(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() {
|
||||
</h2>
|
||||
<p className="inline-form-note">
|
||||
{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.`}
|
||||
</p>
|
||||
{confirm.kind === "REIMPORT" && (
|
||||
<label className="inline-form-note" style={{ display: "block" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceFull}
|
||||
onChange={(e) => setForceFull(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
Borrar también los registros que sólo existen en la plataforma
|
||||
(ignorar la verificación). Sólo marque esto si de verdad quiere
|
||||
perderlos.
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
||||
<input
|
||||
|
||||
@@ -973,10 +973,16 @@ export function getOpsJob(id: string): Promise<OpsJob> {
|
||||
}
|
||||
|
||||
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
|
||||
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
|
||||
/** `forceFull` applies to REIMPORT only: proceed even though the rebuild
|
||||
* deletes rows that exist only in the platform. */
|
||||
export function startOpsJob(
|
||||
kind: OpsJobKind,
|
||||
file?: string,
|
||||
forceFull?: boolean,
|
||||
): Promise<OpsJob> {
|
||||
return apiFetch<OpsJob>("/ops/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, file }),
|
||||
body: JSON.stringify({ kind, file, forceFull }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user