MEGAINFRA-WEBAPP/src/lib/document-status.ts
DEls 8654c65e6f feat(ingest): add honest document status resolution
The three synchronous importers (timesheets, fuel, plant hours) used to
write status: "processed" on their audit row before a single insert ran,
and never updated it. Production had 21 such phantom-processed batches
across payroll and plant hours. resolveDocumentOutcome() computes the
real status from actual insert counts, so no route can claim success it
didn't earn.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 13:43:15 +02:00

75 lines
2.4 KiB
TypeScript

// src/lib/document-status.ts
/**
* Turns a parse outcome into the status fields written back onto an
* uploaded document row.
*
* This exists because the three synchronous importers used to create
* their audit row with `status: "processed"` hardcoded *before* a single
* data row was inserted, and never updated it again. Production ended up
* with 19 plant_hours batches marked processed against one actual log
* row, and no way for anyone to notice.
*
* Pure: no PocketBase, no clock, no I/O. The caller supplies `now`.
*/
import type { DocumentStatus, RowError } from "./pb-types";
/** How many per-row errors we persist. Enough to fix a sheet, bounded. */
export const MAX_ROW_ERRORS = 200;
export type ParseOutcome = {
/** Rows the parser found in the file, before validation. */
rowsTotal: number;
/** Rows that actually became records. */
rowsCreated: number;
rowErrors: RowError[];
};
export type DocumentOutcomeFields = {
processing_status: DocumentStatus;
processed_at: string;
rows_total: number;
rows_created: number;
rows_failed: number;
row_errors: RowError[];
last_error: string;
};
export function resolveDocumentOutcome(
outcome: ParseOutcome,
now: Date,
): DocumentOutcomeFields {
const { rowsTotal, rowsCreated, rowErrors } = outcome;
const rowsFailed = rowErrors.length;
let processing_status: DocumentStatus;
let last_error: string;
if (rowsTotal === 0) {
// A file we could open but that held no data rows. Almost always the
// wrong file or the wrong sheet — the operator needs to know.
processing_status = "failed";
last_error = "no data rows found in file";
} else if (rowsCreated === 0) {
// Parsed fine, landed nothing. Never call this processed.
processing_status = "failed";
last_error = rowErrors[0]?.reason ?? "no rows could be imported";
} else if (rowsFailed > 0) {
// Partial. The rows that landed are real, so this is processed — but
// the failures are recorded and surfaced.
processing_status = "processed";
last_error = `${rowsFailed} of ${rowsTotal} rows failed`;
} else {
processing_status = "processed";
last_error = "";
}
return {
processing_status,
processed_at: now.toISOString(),
rows_total: rowsTotal,
rows_created: rowsCreated,
rows_failed: rowsFailed,
row_errors: rowErrors.slice(0, MAX_ROW_ERRORS),
last_error,
};
}