← Files VeraARCHIVED FILE
modules/browser-automation/scripts/econs_review.mjs
22.1 KB · Oct 4, 2026 · 12:28 UTC
/**
* Acquire an ECONS review using saved, reviewed Playwright phase capabilities.
* Code owns exact identity, exclusions, counts and persistence. Optional processing
* delegates to reviewed phases and model-reviewed treatment; acquisition alone
* never changes a mapping or posts.
*/
import { execFile } from "node:child_process";
import { mkdir, realpath, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { canonicalJson, executeCapability, sha256Text } from "./capability_runtime.mjs";
import { hasEconsMappingException, processEconsInvoice, validateEconsProcessingProfile } from "./econs_processing.mjs";
import { defaultEconsSetupDirectory, saveEconsSetup } from "./econs_setup.mjs";
const runFile = promisify(execFile);
const scripts = dirname(fileURLToPath(import.meta.url));
const PHASES = ["companies", "invoices", "detail"];
const FIELDS = {
companies: ["company-code", "has-new-invoices"],
company: ["company-code"],
invoices: ["invoice-id", "invoice-number", "supplier", "status"],
invoice: ["company-code", "invoice-id", "invoice-number", "supplier", "status"],
lines: ["line-id", "description", "account", "vat-code", "amount"],
};
const OUTPUTS = {
companies: { companies: "record_set", "company-count": "scalar" },
invoices: { company: "record", invoices: "record_set", "invoice-count": "scalar" },
detail: { invoice: "record", lines: "record_set", "line-count": "scalar" },
};
const INPUTS = { companies: [], invoices: ["company-code"], detail: ["company-code", "invoice-id", "invoice-number"] };
class EconsReviewError extends Error {}
function requireCondition(condition, code) {
if (!condition) throw new EconsReviewError(code);
}
function sameKeys(value, keys) {
return value != null && !Array.isArray(value) && typeof value === "object" &&
Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
/** Check only executable shape; the host model must verify the actual UI binding. */
export function validateEconsProfile(profile) {
requireCondition(profile?.schema_version !== "econs-review-profile/v1", "company_new_invoice_signal_required");
requireCondition(sameKeys(profile, ["schema_version", "phases"]) &&
profile.schema_version === "econs-review-profile/v2" && sameKeys(profile.phases, PHASES), "invalid_econs_profile");
let origins;
for (const name of PHASES) {
const phase = profile.phases[name];
requireCondition(["discovered", "validated_local"].includes(phase?.status), "phase_requires_reviewed_discovery");
const currentOrigins = canonicalJson([...phase.site.allowed_origins].sort());
origins ??= currentOrigins;
requireCondition(currentOrigins === origins, "phase_origins_must_match");
requireCondition(phase.inputs.length === INPUTS[name].length &&
INPUTS[name].every((key) => phase.inputs.some((input) => input.name === key && input.required && input.type === "text")), "invalid_phase_inputs");
const declarations = Object.fromEntries(phase.outputs.map((output) => [output.name, output]));
requireCondition(phase.outputs.length === Object.keys(OUTPUTS[name]).length && sameKeys(declarations, Object.keys(OUTPUTS[name])), "invalid_phase_outputs");
for (const [key, type] of Object.entries(OUTPUTS[name])) {
const output = declarations[key];
requireCondition(output.type === type && output.delivery === "model_and_artifact", "invalid_phase_output_delivery");
if (FIELDS[key]) requireCondition(output.fields.length === FIELDS[key].length &&
FIELDS[key].every((field) => output.fields.some((item) => item.name === field)), "invalid_phase_fields");
}
for (const milestone of phase.milestones) {
for (const action of milestone.actions) {
requireCondition(["read_only", "reversible"].includes(action.effect) && action.confirmation === "none" &&
["goto", "wait_for", "click", "extract"].includes(action.operation), "review_phase_must_not_write");
}
}
}
}
function records(value, fields, identity) {
requireCondition(Array.isArray(value), "missing_record_population");
const seen = new Set();
for (const item of value) {
requireCondition(sameKeys(item, fields), "unexpected_record_fields");
for (const field of fields) {
requireCondition(field === "has-new-invoices" ? typeof item[field] === "boolean" : (typeof item[field] === "string" || (fields === FIELDS.lines && field !== identity && item[field] === null)), "invalid_record_value");
requireCondition(typeof item[field] !== "string" || item[field].length <= 10000, "field_exceeds_review_capacity");
}
requireCondition(item[identity].trim() && !seen.has(item[identity]), "missing_or_duplicate_identity");
seen.add(item[identity]);
}
return value;
}
function exactPopulation(items, count) {
// Exact counts detect a partial page, virtualized grid or silently truncated list.
requireCondition(typeof count === "string" && /^\d+$/.test(count) && Number(count) === items.length, "incomplete_population");
}
async function privateDirectory(path) {
requireCondition(isAbsolute(path), "absolute_private_directory_required");
const parent = await realpath(dirname(path));
for (let ancestor = parent; ; ancestor = dirname(ancestor)) {
try {
await stat(join(ancestor, ".git"));
throw new Error("run_directory_must_be_outside_git");
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
if (dirname(ancestor) === ancestor) break;
}
const directory = join(parent, path.slice(dirname(path).length + 1));
await mkdir(directory, { mode: 0o700 });
return directory;
}
async function writePrivate(path, value) {
await writeFile(path, canonicalJson(value), { flag: "wx", mode: 0o600 });
}
function reviewEntry(company, item, id) {
return {
id, document: `${company} · ${item["invoice-number"]} · ${item.supplier}`,
action: "Preparare la revisione della fattura passiva",
reason: "Acquisizione da ECONS; il trattamento contabile resta da valutare.",
status: "pending", proposed: [], actual: [],
evidence: [{ label: "Stato ECONS", value: item.status || "Non disponibile", source: "Elenco fatture passive" }],
outcome: "Dettaglio non ancora acquisito. Nessuna contabilizzazione.",
question: "", posting_reference: "", correction_of: "",
};
}
/**
* Run the read-only acquisition to a saved review. Reuse the profile on each run;
* use a fresh directory. A failed run retains every earlier invoice and receipt.
* pythonExecutable is the explicit managed interpreter, never PATH/default Python.
*/
export async function collectEconsReview({ tab, profile, excludedCompanyCodes,
runDirectory, pythonExecutable, maxCompanies = 50, maxInvoices = 200, invoiceSelection = null,
environment = {}, processing = null, setupDirectory = defaultEconsSetupDirectory(), setupId = null }) {
profile = structuredClone(profile);
validateEconsProfile(profile);
if (processing) {
processing = { ...processing, profile: structuredClone(processing.profile) };
validateEconsProcessingProfile(processing.profile);
requireCondition(canonicalJson([...processing.profile.phases.post.site.allowed_origins].sort()) ===
canonicalJson([...profile.phases.detail.site.allowed_origins].sort()), "processing_acquisition_origins_must_match");
requireCondition([processing.classifyInvoices, processing.reviewRedException, processing.reviewInvoice, processing.reviewJournal, processing.approvePosting].every((callback) => typeof callback === "function"), "model_review_callbacks_required");
}
requireCondition(Array.isArray(excludedCompanyCodes) && excludedCompanyCodes.every((code) => typeof code === "string" && code.trim()), "explicit_exclusion_list_required");
excludedCompanyCodes = [...excludedCompanyCodes];
requireCondition(isAbsolute(pythonExecutable ?? ""), "managed_python_required");
requireCondition(Number.isInteger(maxCompanies) && maxCompanies > 0 && maxCompanies <= 500 &&
Number.isInteger(maxInvoices) && maxInvoices > 0 && maxInvoices <= 1000, "invalid_batch_limits");
if (invoiceSelection !== null) {
// Explicit identities bound a trial mechanically; the model/operator chooses
// its invoices from observed evidence, never a guessed accounting classifier.
requireCondition(typeof invoiceSelection === "object" && !Array.isArray(invoiceSelection) &&
Object.keys(invoiceSelection).length > 0 && Object.entries(invoiceSelection).every(([company, ids]) =>
company.trim() && !excludedCompanyCodes.includes(company) && Array.isArray(ids) && ids.length > 0 &&
ids.every((id) => typeof id === "string" && id.trim()) && new Set(ids).size === ids.length), "invalid_invoice_selection");
invoiceSelection = structuredClone(invoiceSelection);
requireCondition(Object.keys(invoiceSelection).length <= maxCompanies &&
Object.values(invoiceSelection).reduce((count, ids) => count + ids.length, 0) <= maxInvoices,
"selection_exceeds_batch_limits");
}
requireCondition(typeof runDirectory === "string" && isAbsolute(runDirectory), "absolute_private_directory_required");
const directory = await privateDirectory(runDirectory);
let revision = 0;
let phaseNumber = 0;
let acquired = 0;
let activeEntry = null;
let failure = null;
const receipts = [];
const selection = [];
const companyReviews = new Map();
const review = {
schema_version: "browser-batch-review/v1", batch_id: "econs-review",
title: processing ? "Fatture passive ECONS elaborate" : "Fatture passive ECONS da rivedere",
scope: (processing ? "Elaborazione con revisione del modello e autorizzazione alla registrazione. " : "") + "Ditte con segnale osservato di nuove fatture arrivate, escluse quelle nella lista locale. Acquisizione completa e rapporto persistente per cliente. Il totale resta sconosciuto finché la raccolta non è completa.",
status: "paused", expected_items: null, entries: [], reviews: [],
};
if (invoiceSelection) {
const count = Object.values(invoiceSelection).reduce((total, ids) => total + ids.length, 0);
review.scope = `Selezione esplicita di ${count} ${count === 1 ? "fattura" : "fatture"} per la prova richiesta. ` +
"Il rapporto riguarda soltanto queste fatture; non attesta la revisione dell'intera popolazione. " +
(processing ? "Elaborazione con revisione del modello e autorizzazione alla registrazione." : "Acquisizione senza contabilizzazione.");
}
async function python(script, args) {
// Fixed local scripts and argv; no shell, package installation or external API.
await runFile(pythonExecutable, [join(scripts, script), ...args], { maxBuffer: 1024 * 1024 });
}
async function save() {
const input = join(directory, `review-input-${revision + 1}.json`);
await writePrivate(input, review);
await python("batch_review.py", ["save", join(directory, "review"), "--input", input, "--expected-revision", String(revision)]);
revision += 1;
for (const [code, state] of companyReviews) {
const clientReview = { ...review, batch_id: state.id, title: `Fatture passive · cliente ${code}`,
expected_items: state.count, entries: review.entries.filter((entry) => state.ids.has(entry.id)), reviews: [] };
const clientInput = join(directory, `${state.id}-input-${state.revision + 1}.json`);
await writePrivate(clientInput, clientReview);
await python("batch_review.py", ["save", join(directory, state.id), "--input", clientInput, "--expected-revision", String(state.revision)]);
state.revision += 1;
}
}
async function phase(name, inputs) {
phaseNumber += 1;
let result;
try {
result = await executeCapability({ tab, capability: profile.phases[name], inputs,
runDirectory: join(directory, `phase-${phaseNumber}`), runId: `econs-${name}-${phaseNumber}`, environment });
} catch (error) {
if (!error.runSummary) throw error;
result = error.runSummary;
}
await writePrivate(join(directory, `phase-${phaseNumber}`, "summary.private.json"), result);
receipts.push(result.receipt_path);
requireCondition(result.result === "passed", `phase_${name}_failed`);
return result.delivered_outputs;
}
await writePrivate(join(directory, "profile.json"), profile);
await writePrivate(join(directory, "selection.json"), { excluded_company_codes: excludedCompanyCodes,
max_companies: maxCompanies, max_invoices: maxInvoices, invoice_selection: invoiceSelection });
// Validate every phase before the first browser operation, not halfway through.
await python("check_installation.py", []);
await python("check_dependencies.py", []);
for (const name of PHASES) {
const path = join(directory, `${name}.capability.json`);
await writePrivate(path, profile.phases[name]);
await python("capability_pipeline.py", ["validate", path, "--kind", "capability"]);
}
if (processing) {
await writePrivate(join(directory, "processing-profile.json"), processing.profile);
for (const [name, capability] of Object.entries(processing.profile.phases)) {
const path = join(directory, `processing-${name}.capability.json`);
await writePrivate(path, capability);
await python("capability_pipeline.py", ["validate", path, "--kind", "capability"]);
}
}
await save();
const setup = await saveEconsSetup({ directory: setupDirectory, setupId, profile,
processingProfile: processing?.profile ?? null, excludedCompanyCodes, lastRunDirectory: directory });
try {
const companyOutput = await phase("companies", {});
const companies = records(companyOutput.companies, FIELDS.companies, "company-code");
exactPopulation(companies, companyOutput["company-count"]);
const excluded = new Set(excludedCompanyCodes);
const selected = companies.filter((company) => !excluded.has(company["company-code"]) &&
(invoiceSelection ? Object.hasOwn(invoiceSelection, company["company-code"]) : company["has-new-invoices"]));
if (invoiceSelection) requireCondition(selected.length === Object.keys(invoiceSelection).length, "selected_company_missing");
requireCondition(selected.length <= maxCompanies, "company_limit_exceeded");
for (const company of selected) {
const output = await phase("invoices", { "company-code": company["company-code"] });
requireCondition(sameKeys(output.company, FIELDS.company) && output.company["company-code"] === company["company-code"], "wrong_company");
const population = records(output.invoices, FIELDS.invoices, "invoice-id");
exactPopulation(population, output["invoice-count"]);
const requested = invoiceSelection ? new Set(invoiceSelection[company["company-code"]]) : null;
const invoices = requested ? population.filter((item) => requested.has(item["invoice-id"])) : population;
if (requested) requireCondition(invoices.length === requested.size, "selected_invoice_missing");
requireCondition(review.entries.length + invoices.length <= maxInvoices, "invoice_limit_exceeded");
selection.push({ company_code: company["company-code"], invoice_count: population.length, selected_invoice_count: invoices.length });
const pending = invoices.map((invoice) => {
const id = `invoice-${sha256Text(canonicalJson([company["company-code"], invoice["invoice-id"]])).slice(0, 24)}`;
const entry = reviewEntry(company["company-code"], invoice, id);
review.entries.push(entry);
return { invoice, entry };
});
companyReviews.set(company["company-code"], { id: `client-${sha256Text(company["company-code"]).slice(0, 24)}`,
ids: new Set(pending.map(({ entry }) => entry.id)), count: invoices.length, revision: 0 });
await save();
let redIds = new Set();
if (processing) {
const decision = await processing.classifyInvoices(structuredClone({ company, invoices }));
requireCondition(decision?.company_code === company["company-code"] && typeof decision.reason === "string" && decision.reason.trim() &&
Array.isArray(decision.red_invoice_ids) && new Set(decision.red_invoice_ids).size === decision.red_invoice_ids.length &&
decision.red_invoice_ids.every((id) => invoices.some((item) => item["invoice-id"] === id)), "explicit_model_queue_classification_required");
redIds = new Set(decision.red_invoice_ids);
await writePrivate(join(directory, `queue-${sha256Text(company["company-code"]).slice(0, 24)}.json`), decision);
}
let consecutiveRed = 0;
let stopCompany = false;
for (const { invoice, entry } of pending) {
const red = processing && redIds.has(invoice["invoice-id"]);
if (processing) {
consecutiveRed = red ? consecutiveRed + 1 : 0;
stopCompany ||= consecutiveRed > 2;
if (stopCompany) {
entry.status = "set_aside";
entry.outcome = stopCompany ? "Ditta sospesa dopo oltre due rossi consecutivi." : "Fattura rossa esclusa dalla registrazione secondo la classificazione del modello.";
entry.question = "Rivedere la fattura e riprendere il cliente dopo aver risolto le eccezioni.";
await save();
continue;
}
}
activeEntry = entry;
let detail = await phase("detail", { "company-code": company["company-code"], "invoice-id": invoice["invoice-id"], "invoice-number": invoice["invoice-number"] });
requireCondition(sameKeys(detail.invoice, FIELDS.invoice) && detail.invoice["company-code"] === company["company-code"] &&
FIELDS.invoices.every((field) => detail.invoice[field] === invoice[field]), "invoice_identity_or_state_changed");
const lines = records(detail.lines, FIELDS.lines, "line-id");
exactPopulation(lines, detail["line-count"]);
requireCondition(lines.length > 0 && lines.length <= 90, "missing_or_excessive_invoice_lines");
const missing = [];
for (const line of lines) {
for (const field of ["description", "account", "vat-code", "amount"]) {
const source = `ECONS · riga ${line["line-id"]} · ${field}`;
entry.evidence.push({ label: `${line["line-id"]} · ${field}`, value: (line[field] ?? "").trim() ? line[field] : "Non disponibile", source });
if (!(line[field] ?? "").trim()) missing.push(`${line["line-id"]}: ${field}`);
}
}
entry.outcome = "Dati e mappature esistenti acquisiti. Nessuna contabilizzazione e nessuna approvazione contabile automatica.";
if (missing.length) {
entry.status = "set_aside";
entry.question = `Verificare i campi mancanti: ${missing.join(", ")}`;
}
acquired += 1;
await save();
if (red) {
// The model interprets the exception from complete observed lines.
// The taught two-anchor condition is then checked by exact values.
const decision = await processing.reviewRedException(structuredClone({ company, invoice, detail }));
requireCondition(sameKeys(decision, ["eligible", "reason"]) && typeof decision.eligible === "boolean" &&
typeof decision.reason === "string" && decision.reason.trim(), "invalid_red_exception_review");
entry.evidence.push({ label: "Eccezione alla fattura rossa", value: decision.reason, source: "Revisione del modello sulle righe complete" });
const eligible = decision.eligible && hasEconsMappingException(detail);
if (!eligible) {
entry.status = "set_aside";
entry.outcome = "Fattura rossa lasciata da parte: eccezione delle due associazioni concordanti non verificata.";
entry.question = "Verificare l'eccezione e le associazioni senza registrare automaticamente il documento.";
await save();
activeEntry = null;
continue;
}
}
if (processing) {
await processEconsInvoice({ tab, profile: processing.profile,
invoice: { "company-code": company["company-code"], ...invoice }, detail, entry,
readDetail: () => phase("detail", { "company-code": company["company-code"], "invoice-id": invoice["invoice-id"], "invoice-number": invoice["invoice-number"] }),
phaseDirectory: async (name) => join(directory, `process-${entry.id}-${name}`),
save, reviewInvoice: processing.reviewInvoice, reviewJournal: processing.reviewJournal, approvePosting: processing.approvePosting, environment });
}
activeEntry = null;
}
}
review.expected_items = review.entries.length;
} catch (error) {
failure = { category: "acquisition_incomplete", reason_code: error instanceof EconsReviewError ? error.message : "runtime_or_persistence_failure", detail_sha256: sha256Text(String(error)) };
if (activeEntry) {
activeEntry.status = processing ? "unverified" : "failed";
activeEntry.question = "Riprendere l'acquisizione del dettaglio dopo aver verificato identità, campi e controlli ECONS.";
activeEntry.outcome = processing ? "Elaborazione interrotta: verificare lo stato esterno e le ricevute prima di ripetere azioni." : "Acquisizione interrotta; nessuna contabilizzazione.";
}
}
await save();
const summary = {
schema_version: "econs-review-acquisition/v1", status: failure ? "partial" : processing ? "processed" : "acquired",
profile_sha256: sha256Text(canonicalJson(profile)), acquired_invoices: acquired,
setup_id: setup.setupId, setup_path: setup.setupPath,
selection_mode: invoiceSelection ? "explicit_invoices" : "eligible_population",
pending_review: review.entries.filter((entry) => entry.status === "pending").length,
exceptions: review.entries.filter((entry) => entry.status !== "pending").length,
expected_items: review.expected_items,
posting_actions: processing ? null : 0,
completed: review.entries.filter((entry) => entry.status === "completed").length,
client_reviews: [...companyReviews.values()].map((state) => join(directory, state.id, `review-${String(state.revision).padStart(4, "0")}.html`)),
receipts,
review_path: join(directory, "review", `review-${String(revision).padStart(4, "0")}.html`),
review_directory: join(directory, "review"), error: failure,
};
await writePrivate(join(directory, "acquisition.json"), { ...summary, selected_companies: selection });
return summary;
}
SHA-256: 0e4e06e1966090ed7affa5613d33671870d1c6316d7c52a4ea548064fcf4d557