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>
75 lines
2.4 KiB
TypeScript
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,
|
|
};
|
|
}
|