src/lib/import/snap-negative.ts
Linked repository file from this build.
import { businessKey, fiscalYear } from "@/features/reviews/review-key";
import type { Finding } from "@/features/reviews/types";
import { createImportTemplate } from "@/programs/snap-negative/review-template";
import type {
ReviewAnswers,
Scenario,
SnapReview,
SnapNegativeLineage,
SnapNegativeSources,
} from "@/programs/snap-negative/types";
import { RuleError } from "@/lib/errors";
/** Required in every input string. The parser refuses files that omit this marker. */
export const SYNTHETIC_ATTESTATION = "SYNTHETIC QCCRM SNAP-NEGATIVE FIXTURE";
const SNAP_NEGATIVE_PREFIXES = ["60", "61", "65", "66"] as const;
/** User-confirmed Negative layout, in source order. Never collapse empty fields. */
export const BIS_HEADERS = [
"QC Review Number",
"RECORD IND",
"SAMPLE TYPE",
"M01-QC-SERIAL-NBR",
"REVIEW MONTH",
"REVIEW YEAR",
"COUNTY",
"DISTRICT",
"RECORD NUMBER",
"CATEGORY",
"M01-DTE-ACTN",
"M01-TIME-ACTN",
"M01-CDE-STUS-BDGT",
"M01-APPLI-NBR",
"M01-APPLI-DTE",
"M01-SSN",
"M01-CDE-REASON-STUS",
"M01-CDE-STUS-PROGRAM",
"M01-DTE-NOTICE-SEND",
"M01-CDE-TYPE-NOTICE",
"M01-CDE-STUS-NOTICE",
"M01-CDE-RSN-NOTICE",
"M01-ADDRESS-1",
"M01-ADDRESS-2",
"M01-CITY",
"M01-STATE",
"M01-ZIP",
"M01-PHONE",
"M01-NAM-LAST",
"M01-NAM-FIRST",
"M01-NAM-MIDDLE-INITIAL",
"M01-DTE-DUE-SAR",
"M01-DTE-LAST-SAR",
"M01-CDE-ACTN-BDGT",
"M01-DTE-CLOSED-BDGT",
"M01-DTE-APPLN-BDGT",
"M01-DTE-DUE-REAPPLN",
"M01-DTE-REAPPLN",
"M01-DTE-BGN-EFFV",
"M01-DTE-END-EFFV",
"Examiner Number",
"Field Office",
] as const;
export const BIS_COLUMN_COUNT = BIS_HEADERS.length;
/** Closed/suspended numbers are five digits (60000–61999); rejected are six (650000–669999). */
export function isValidReviewNumberFormat(reviewNumber: string): boolean {
return /^(60|61)\d{3}$/.test(reviewNumber) || /^(65|66)\d{4}$/.test(reviewNumber);
}
export function formatSnapNegativeReviewNumber(
prefix: (typeof SNAP_NEGATIVE_PREFIXES)[number],
sequence: number,
): string {
if (prefix === "60" || prefix === "61") {
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 999) {
throw new RuleError(`${prefix} review sequence must be 0–999 (five-digit review number).`);
}
return `${prefix}${String(sequence).padStart(3, "0")}`;
}
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 9999) {
throw new RuleError(`${prefix} review sequence must be 0–9999 (six-digit review number).`);
}
return `${prefix}${String(sequence).padStart(4, "0")}`;
}
export const SNAP_NEGATIVE_FIXTURE_FILES = {
closedSuspended: "closed-suspended.txt",
rejected: "rejected.txt",
} as const;
const COL = Object.fromEntries(BIS_HEADERS.map((header, index) => [header, index])) as Record<
(typeof BIS_HEADERS)[number],
number
>;
const ACTION_NAMES = {
A: "Denial",
C: "Termination",
S: "Suspension",
} as const;
type ActionCode = keyof typeof ACTION_NAMES;
type SourceRole = "closed_suspended" | "rejected";
type InputKey = "closedSuspended" | "rejected";
export type SnapNegativeImportedReview = Omit<SnapReview, "id"> & {
lineage: SnapNegativeLineage[];
};
const FILE_NAME: Record<SourceRole, string> = {
closed_suspended: SNAP_NEGATIVE_FIXTURE_FILES.closedSuspended,
rejected: SNAP_NEGATIVE_FIXTURE_FILES.rejected,
};
const INPUT_KEY: Record<SourceRole, InputKey> = {
closed_suspended: "closedSuspended",
rejected: "rejected",
};
interface ParsedLine {
fileName: string;
role: SourceRole;
rowNumber: number;
rawText: string;
columns: string[];
}
interface BisRow {
line: ParsedLine;
reviewNumber: string;
sampleMonth: string;
actionType: ActionCode;
actionDate: string;
noticeDate: string;
}
interface IdentityRow {
line: ParsedLine;
reviewNumber: string;
sampleMonth: string;
caseNumber: string;
clientName: string;
address: string;
county: string;
district: string;
examiner: string;
}
function cell(columns: string[], index: number): string {
return (columns[index] ?? "").trim();
}
function fieldOf(role: SourceRole, field: string): string {
return `${INPUT_KEY[role]}.${field}`;
}
function finding(field: string, message: string): Finding {
return { field, message };
}
function reviewPrefix(reviewNumber: string): string {
return reviewNumber.slice(0, 2);
}
function isSupportedPrefix(reviewNumber: string): boolean {
return (
/^\d+$/.test(reviewNumber) &&
(SNAP_NEGATIVE_PREFIXES as readonly string[]).includes(reviewPrefix(reviewNumber)) &&
isValidReviewNumberFormat(reviewNumber)
);
}
function isActionCode(value: string): value is ActionCode {
return value === "A" || value === "C" || value === "S";
}
function validYmd(year: number, month: number, day: number): boolean {
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return false;
const date = new Date(Date.UTC(year, month - 1, day));
return (
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
);
}
function isoDate(year: number, month: number, day: number): string {
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/** ParseBISDate in populateMacrosV2 Core_Parse: YYYYMMDD first, then common date text. */
function parseBisDate(raw: string): string | undefined {
const text = raw.trim();
if (!text) return undefined;
if (/^\d{8}$/.test(text)) {
const year = Number(text.slice(0, 4));
const month = Number(text.slice(4, 6));
const day = Number(text.slice(6, 8));
return validYmd(year, month, day) ? isoDate(year, month, day) : undefined;
}
const iso = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (iso) {
const year = Number(iso[1]);
const month = Number(iso[2]);
const day = Number(iso[3]);
return validYmd(year, month, day) ? isoDate(year, month, day) : undefined;
}
const us = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (us) {
const month = Number(us[1]);
const day = Number(us[2]);
const year = Number(us[3]);
return validYmd(year, month, day) ? isoDate(year, month, day) : undefined;
}
return undefined;
}
function parseSampleMonth(monthRaw: string, yearRaw: string): string | undefined {
const month = Number(monthRaw);
const year = Number(yearRaw);
if (!Number.isInteger(month) || !Number.isInteger(year)) return undefined;
if (!validYmd(year, month, 1)) return undefined;
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`;
}
function splitPipeLine(rawText: string): string[] {
const fields: string[] = [];
let value = "";
let quoted = false;
for (let i = 0; i < rawText.length; i++) {
const char = rawText[i];
if (char === '"') {
if (quoted && rawText[i + 1] === '"') {
value += '"';
i++;
} else quoted = !quoted;
} else if (char === "|" && !quoted) {
fields.push(value.trim());
value = "";
} else value += char;
}
if (quoted) return [];
fields.push(value.trim());
return fields;
}
function parseSourceFile(
content: string,
role: SourceRole,
findings: Finding[],
fileName = FILE_NAME[role],
): ParsedLine[] {
const inputKey = INPUT_KEY[role];
if (!content.split(/\r?\n/).some((line) => line.trim() === `# ${SYNTHETIC_ATTESTATION}`)) {
findings.push(
finding(
`${inputKey}.attestation`,
`${inputKey} must include the explicit synthetic attestation.`,
),
);
return [];
}
const lines: ParsedLine[] = [];
for (const [index, raw] of content.split(/\r?\n/).entries()) {
const rawText = raw;
const trimmed = rawText.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const columns = splitPipeLine(trimmed);
if (columns.length !== BIS_COLUMN_COUNT) {
findings.push(
finding(
fieldOf(role, "columns"),
`${fileName} row ${index + 1} must have ${BIS_COLUMN_COUNT} pipe columns (A:AP).`,
),
);
continue;
}
if (columns[0] === BIS_HEADERS[0]) {
if (lines.length > 0 || !BIS_HEADERS.every((header, i) => columns[i] === header)) {
findings.push(
finding(
fieldOf(role, "headers"),
`${fileName} row ${index + 1} must use the confirmed BIS header order before data rows.`,
),
);
}
continue;
}
const recordType = cell(columns, COL["RECORD IND"]);
if (recordType && recordType !== "C" && recordType !== "A") {
findings.push(
finding(
fieldOf(role, "recordType"),
`${fileName} row ${index + 1} must be a case record, not an individual record.`,
),
);
continue;
}
lines.push({ role, fileName, rowNumber: index + 1, rawText, columns });
}
if (lines.length === 0) {
findings.push(finding(inputKey, `${inputKey} has no synthetic data rows.`));
}
return lines;
}
function readBisRow(line: ParsedLine, findings: Finding[]): BisRow | undefined {
const reviewNumber = cell(line.columns, COL["QC Review Number"]);
if (!reviewNumber) {
findings.push(finding(fieldOf(line.role, "reviewNumber"), "Review number is required."));
return undefined;
}
if (!isSupportedPrefix(reviewNumber)) {
findings.push(
finding(
fieldOf(line.role, "reviewNumber"),
`Review ${reviewNumber} uses unsupported prefix ${reviewPrefix(reviewNumber) || "(blank)"}.`,
),
);
return undefined;
}
const actionType = cell(line.columns, COL["SAMPLE TYPE"]).toUpperCase();
if (!isActionCode(actionType)) {
findings.push(
finding(
fieldOf(line.role, "actionType"),
`Review ${reviewNumber} action type must be A, C, or S.`,
),
);
return undefined;
}
if (
(line.role === "rejected" && actionType !== "A") ||
(line.role === "closed_suspended" && actionType === "A")
) {
findings.push(
finding(
fieldOf(line.role, "actionType"),
`Review ${reviewNumber} action marker does not match the selected file role.`,
),
);
return undefined;
}
const actionRaw = cell(line.columns, COL["M01-DTE-ACTN"]);
const actionDate = parseBisDate(actionRaw) ?? "";
if (actionRaw && !actionDate) {
findings.push(
finding(fieldOf(line.role, "actionDate"), `Review ${reviewNumber} action date is malformed.`),
);
return undefined;
}
const noticeRaw = cell(line.columns, COL["M01-DTE-NOTICE-SEND"]);
const noticeDate = actionType === "S" ? "" : (parseBisDate(noticeRaw) ?? "");
if (actionType !== "S" && noticeRaw && !noticeDate) {
findings.push(
finding(fieldOf(line.role, "noticeDate"), `Review ${reviewNumber} notice date is malformed.`),
);
return undefined;
}
const sampleMonth = parseSampleMonth(
cell(line.columns, COL["REVIEW MONTH"]),
cell(line.columns, COL["REVIEW YEAR"]),
);
if (!sampleMonth) {
findings.push(
finding(
fieldOf(line.role, "sampleMonth"),
`Review ${reviewNumber} sample month (E/F) is missing or malformed.`,
),
);
return undefined;
}
return { line, reviewNumber, sampleMonth, actionType, actionDate, noticeDate };
}
function readIdentityRow(line: ParsedLine, findings: Finding[]): IdentityRow | undefined {
const reviewNumber = cell(line.columns, COL["QC Review Number"]);
if (!reviewNumber) {
findings.push(finding(fieldOf(line.role, "reviewNumber"), "Review number is required."));
return undefined;
}
const recordType = cell(line.columns, COL["SAMPLE TYPE"]).toUpperCase();
if (!isActionCode(recordType)) {
findings.push(
finding(
fieldOf(line.role, "recordType"),
`Review ${reviewNumber} sample action type must be A, C, or S.`,
),
);
return undefined;
}
if (!isSupportedPrefix(reviewNumber)) {
findings.push(
finding(
fieldOf(line.role, "reviewNumber"),
`Review ${reviewNumber} uses unsupported prefix ${reviewPrefix(reviewNumber) || "(blank)"}.`,
),
);
return undefined;
}
const sampleMonth = parseSampleMonth(
cell(line.columns, COL["REVIEW MONTH"]),
cell(line.columns, COL["REVIEW YEAR"]),
);
if (!sampleMonth) {
findings.push(
finding(
fieldOf(line.role, "sampleMonth"),
`Review ${reviewNumber} sample month (E/F) is missing or malformed.`,
),
);
return undefined;
}
const caseNumber = cell(line.columns, COL["RECORD NUMBER"]);
if (!caseNumber) {
findings.push(
finding(fieldOf(line.role, "caseNumber"), `Review ${reviewNumber} case number is required.`),
);
return undefined;
}
const lastName = cell(line.columns, COL["M01-NAM-LAST"]);
const firstName = cell(line.columns, COL["M01-NAM-FIRST"]);
const street = cell(line.columns, COL["M01-ADDRESS-1"]);
const city = cell(line.columns, COL["M01-CITY"]);
const state = cell(line.columns, COL["M01-STATE"]);
const zip = cell(line.columns, COL["M01-ZIP"]).slice(0, 5);
const addressParts = [
street,
cell(line.columns, COL["M01-ADDRESS-2"]),
[city, [state, zip].filter(Boolean).join(" ")].filter(Boolean).join(", "),
].filter(Boolean);
return {
line,
reviewNumber,
sampleMonth,
caseNumber,
clientName: [firstName, cell(line.columns, COL["M01-NAM-MIDDLE-INITIAL"]), lastName]
.filter(Boolean)
.join(" "),
address: addressParts.join(", "),
county: cell(line.columns, COL["COUNTY"]),
district: cell(line.columns, COL["DISTRICT"]),
examiner: cell(line.columns, COL["Examiner Number"]),
};
}
function indexByReview<T extends { reviewNumber: string; sampleMonth: string; line: ParsedLine }>(
rows: T[],
findings: Finding[],
): Map<string, T> {
const seen = new Map<string, T[]>();
for (const row of rows) {
const list = seen.get(businessKey({ ...row, program: "SNAP" })) ?? [];
list.push(row);
seen.set(businessKey({ ...row, program: "SNAP" }), list);
}
const unique = new Map<string, T>();
for (const [reviewNumber, list] of seen) {
if (list.length === 1) {
unique.set(reviewNumber, list[0]);
continue;
}
for (const row of list) {
findings.push(
finding(
fieldOf(row.line.role, "reviewNumber"),
`Review ${row.reviewNumber}, FFY ${fiscalYear(row.sampleMonth)} is duplicated.`,
),
);
}
}
return unique;
}
function lineageFrom(line: ParsedLine): SnapNegativeLineage {
return {
sourceSystem: "sample_delim",
role: line.role,
fileName: line.fileName,
rowNumber: line.rowNumber,
rawText: line.rawText,
};
}
function clearedScenario(source: Scenario): Scenario {
return {
...source,
populated: false,
householdSize: 0,
income: [],
medicalDeductionCents: 0,
dependentCareCents: 0,
childSupportCents: 0,
rentCents: 0,
taxesInsuranceCents: 0,
installationCents: 0,
otherShelterCents: 0,
sua: "none",
elderlyDisabled: false,
categoricalEligibility: false,
homeless: false,
};
}
function clearedAnswers(source: ReviewAnswers): ReviewAnswers {
return {
...source,
disposition: "",
contactResponse: "",
actionInitiatedBy: "",
timelinessResponse: "",
finding: "",
narrative: "",
variances: [],
};
}
function importedCase(bis: BisRow, forRow: IdentityRow): SnapNegativeImportedReview {
const shape = createImportTemplate();
const { facts, id: _id, ...template } = shape;
return {
...template,
reviewNumber: bis.reviewNumber,
caseNumber: forRow.caseNumber,
sampleMonth: forRow.sampleMonth,
clientName: forRow.clientName,
address: forRow.address,
county: forRow.county,
district: forRow.district,
localAgency: "",
examiner: forRow.examiner,
supervisor: "",
state: "new_active",
outcome: "",
revision: 0,
validation: undefined,
pecDecision: undefined,
notes: [],
documents: [],
activity: [],
facts: {
...facts,
negativeAction: ACTION_NAMES[bis.actionType],
noticeDate: bis.noticeDate,
actionDate: bis.actionDate,
members: [],
inputsRevision: 0,
answers: {
...clearedAnswers(facts.answers),
telephone: cell(bis.line.columns, COL["M01-PHONE"]),
actionType: ({ A: "1", C: "2", S: "3" } as const)[bis.actionType],
narrative: `Synthetic intake: ${ACTION_NAMES[bis.actionType]} on ${bis.actionDate || "unknown date"}. Examiner must complete household, income, and review facts.`,
},
scenarios: {
eligibility: clearedScenario(facts.scenarios.eligibility),
compI: clearedScenario(facts.scenarios.compI),
compII: clearedScenario(facts.scenarios.compII),
},
final: undefined,
},
lineage: [lineageFrom(bis.line)],
};
}
export function parseSnapNegativeBatch(input: SnapNegativeSources): {
cases: SnapNegativeImportedReview[];
findings: Finding[];
} {
const findings: Finding[] = [];
const closedLines = parseSourceFile(
input.closedSuspended,
"closed_suspended",
findings,
input.fileNames?.closedSuspended,
);
const rejectedLines = parseSourceFile(
input.rejected,
"rejected",
findings,
input.fileNames?.rejected,
);
const rows = [...closedLines, ...rejectedLines];
const bisRows = rows
.map((line) => readBisRow(line, findings))
.filter((row): row is BisRow => row !== undefined);
const unique = indexByReview(bisRows, findings);
const cases: SnapNegativeImportedReview[] = [];
for (const bis of unique.values()) {
// Both maps read the same sampled row. No workbook join or second identity source.
const identity = readIdentityRow(bis.line, findings);
if (identity) cases.push(importedCase(bis, identity));
}
cases.sort((left, right) => businessKey(left).localeCompare(businessKey(right)));
return { cases, findings };
}