P2AUTO 收口自动化回归和样票门禁

这个提交包含在:
2026-05-16 20:53:08 +08:00
父节点 7a59fd715f
当前提交 edabce8561
共修改 7 个文件,包含 387 行新增和 8 行删除
+351
查看文件
@@ -0,0 +1,351 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
process.noDeprecation = true;
const require = createRequire(import.meta.url);
const { runTencentVatOcr } = require("../../server/src/services/ocr/tencent");
const SUPPORTED_PROVIDERS = new Set(["tencent"]);
const MIME_BY_EXT = new Map([
[".pdf", "application/pdf"],
[".jpg", "image/jpeg"],
[".jpeg", "image/jpeg"],
[".png", "image/png"],
]);
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]);
const PDF_EXTENSIONS = new Set([".pdf"]);
const REQUIRED_FIELDS = ["invoiceCode", "invoiceNumber", "invoiceDate", "totalAmount", "sellerName"];
const OPTIONAL_FIELDS = ["buyerName", "amount", "taxAmount"];
function parseArgs(argv) {
const options = {
provider: process.env.OCR_PROVIDER_SAMPLE_PROVIDER || "tencent",
json: false,
strict: false,
manifest: process.env.OCR_PROVIDER_SAMPLE_MANIFEST || "",
};
for (const arg of argv) {
if (arg === "--json") {
options.json = true;
} else if (arg === "--strict") {
options.strict = true;
} else if (arg.startsWith("--provider=")) {
options.provider = arg.slice("--provider=".length);
} else if (arg.startsWith("--manifest=")) {
options.manifest = arg.slice("--manifest=".length);
}
}
return options;
}
function splitEnvList(value) {
return String(value || "")
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function resolveSamplePath(rawPath, baseDir) {
if (!rawPath || typeof rawPath !== "string") return "";
return path.isAbsolute(rawPath) ? rawPath : path.resolve(baseDir, rawPath);
}
function inferKind(filePath, declaredKind) {
const normalizedKind = String(declaredKind || "").trim().toLowerCase();
if (["pdf", "pdf_vat", "vat_pdf"].includes(normalizedKind)) return "pdf";
if (["image", "image_vat", "vat_image"].includes(normalizedKind)) return "image";
const ext = path.extname(filePath).toLowerCase();
if (PDF_EXTENSIONS.has(ext)) return "pdf";
if (IMAGE_EXTENSIONS.has(ext)) return "image";
return "unknown";
}
function safeAlias(value, fallback) {
const alias = String(value || "").trim();
if (!alias) return fallback;
if (/^SAMPLE-(PDF|IMG)-[A-Z0-9_-]+$/i.test(alias)) return alias.toUpperCase();
return fallback;
}
function pushSample(samples, source) {
const kind = inferKind(source.path, source.kind);
const fallbackPrefix = kind === "pdf" ? "SAMPLE-PDF" : kind === "image" ? "SAMPLE-IMG" : "SAMPLE-UNKNOWN";
samples.push({
alias: safeAlias(source.alias, `${fallbackPrefix}-${String(samples.length + 1).padStart(3, "0")}`),
filePath: source.path,
kind,
source: source.source,
});
}
function loadManifestSamples(manifestPath) {
if (!manifestPath) return { samples: [], error: null };
if (!fs.existsSync(manifestPath)) {
return { samples: [], error: "manifest_not_found" };
}
try {
const baseDir = path.dirname(manifestPath);
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const items = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.samples) ? parsed.samples : [];
const samples = [];
for (const item of items) {
const samplePath = resolveSamplePath(item?.path, baseDir);
if (!samplePath) continue;
pushSample(samples, {
alias: item?.alias,
path: samplePath,
kind: item?.kind,
source: "manifest",
});
}
return { samples, error: null };
} catch {
return { samples: [], error: "manifest_parse_failed" };
}
}
function loadEnvSamples() {
const samples = [];
const pdfFiles = [
...splitEnvList(process.env.PLAYWRIGHT_OCR_LIVE_FILE),
...splitEnvList(process.env.LIVE_OCR_INVOICE_FILE),
...splitEnvList(process.env.PLAYWRIGHT_OCR_LIVE_FILES),
];
for (const item of pdfFiles) {
pushSample(samples, { path: item, kind: "pdf", source: "env_pdf" });
}
const pdfDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim();
if (pdfDir && fs.existsSync(pdfDir)) {
for (const entry of fs.readdirSync(pdfDir).sort((left, right) => left.localeCompare(right, "zh-CN"))) {
const filePath = path.join(pdfDir, entry);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile() && PDF_EXTENSIONS.has(path.extname(entry).toLowerCase())) {
pushSample(samples, { path: filePath, kind: "pdf", source: "env_pdf_dir" });
}
}
}
const imageFiles = [
...splitEnvList(process.env.PLAYWRIGHT_OCR_IMAGE_FILE),
...splitEnvList(process.env.PLAYWRIGHT_OCR_IMAGE_FILES),
...splitEnvList(process.env.LIVE_OCR_IMAGE_FILE),
];
for (const item of imageFiles) {
pushSample(samples, { path: item, kind: "image", source: "env_image" });
}
const imageDirs = [
process.env.PLAYWRIGHT_OCR_IMAGE_DIR?.trim(),
process.env.LIVE_OCR_IMAGE_DIR?.trim(),
].filter(Boolean);
for (const dir of imageDirs) {
if (!fs.existsSync(dir)) continue;
for (const entry of fs.readdirSync(dir).sort((left, right) => left.localeCompare(right, "zh-CN"))) {
const filePath = path.join(dir, entry);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile() && IMAGE_EXTENSIONS.has(path.extname(entry).toLowerCase())) {
pushSample(samples, { path: filePath, kind: "image", source: "env_image_dir" });
}
}
}
return samples;
}
function dedupeSamples(samples) {
const seen = new Set();
const result = [];
for (const sample of samples) {
const key = `${sample.alias}:${sample.filePath}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(sample);
}
return result;
}
function getTencentConfig() {
return {
tencentSecretId: process.env.TENCENT_SECRET_ID || "",
tencentSecretKey: process.env.TENCENT_SECRET_KEY || "",
tencentOcrRegion: process.env.TENCENT_OCR_REGION || "ap-beijing",
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS || 5000),
};
}
function hasTencentCredentials(config) {
return Boolean(config.tencentSecretId && config.tencentSecretKey);
}
function summarizeFile(sample) {
try {
if (!fs.existsSync(sample.filePath)) {
return { ok: false, errorCode: "sample_not_found" };
}
const stat = fs.statSync(sample.filePath);
if (!stat.isFile()) {
return { ok: false, errorCode: "sample_not_file" };
}
const ext = path.extname(sample.filePath).toLowerCase();
const mimeType = MIME_BY_EXT.get(ext);
if (!mimeType) {
return { ok: false, errorCode: "unsupported_sample_type" };
}
return {
ok: true,
mimeType,
fileSizeBytes: stat.size,
transportMode: mimeType === "application/pdf" ? "pdf_native" : "image_native",
};
} catch {
return { ok: false, errorCode: "sample_stat_failed" };
}
}
function summarizeNormalizedResult(result) {
const fieldHits = {};
for (const field of [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS]) {
const value = result?.[field];
fieldHits[field] = value != null && value !== "";
}
const missingRequiredFields = REQUIRED_FIELDS.filter((field) => !fieldHits[field]);
return {
fieldHits,
missingRequiredFields,
transportMode: result?.raw?.transportMode || null,
};
}
async function checkSample(provider, config, sample) {
const fileSummary = summarizeFile(sample);
const base = {
alias: sample.alias,
kind: sample.kind,
source: sample.source,
status: "SKIP",
mimeType: fileSummary.mimeType || null,
fileSizeBytes: fileSummary.fileSizeBytes || null,
transportMode: fileSummary.transportMode || null,
};
if (!fileSummary.ok) {
return { ...base, status: "SKIP", errorCode: fileSummary.errorCode };
}
if (provider !== "tencent") {
return { ...base, status: "SKIP", errorCode: "unsupported_provider" };
}
if (!hasTencentCredentials(config)) {
return { ...base, status: "SKIP", errorCode: "missing_tencent_credentials" };
}
try {
const imageBase64 = fs.readFileSync(sample.filePath).toString("base64");
const result = await runTencentVatOcr(config, { imageBase64, sourceMimeType: fileSummary.mimeType });
const normalizedSummary = summarizeNormalizedResult(result);
const missingRequired = normalizedSummary.missingRequiredFields;
return {
...base,
status: missingRequired.length ? "HOLD" : "PASS",
fieldHits: normalizedSummary.fieldHits,
missingRequiredFields: missingRequired,
transportMode: normalizedSummary.transportMode || base.transportMode,
};
} catch (error) {
return {
...base,
status: "HOLD",
errorCode: typeof error?.code === "string" ? error.code : "provider_failed",
transportMode: error?.transportMode || base.transportMode,
};
}
}
function buildDecision(results, manifestError) {
if (manifestError) return "HOLD_FOR_SAMPLE";
if (!results.length) return "HOLD_FOR_SAMPLE";
const imageResults = results.filter((item) => item.kind === "image");
if (!imageResults.length) return "HOLD_FOR_SAMPLE";
if (imageResults.every((item) => item.status === "PASS")) return "GO_TO_IMPLEMENT";
return "HOLD_FOR_SAMPLE";
}
function printHuman(summary) {
console.log(`[ocr-provider-samples] status=${summary.status} decision=${summary.decision}`);
console.log(`[ocr-provider-samples] provider=${summary.provider} samples=${summary.counts.total} pdf=${summary.counts.pdf} image=${summary.counts.image}`);
if (summary.manifestError) {
console.log(`[ocr-provider-samples] manifest=${summary.manifestError}`);
}
for (const result of summary.results) {
const error = result.errorCode ? ` error=${result.errorCode}` : "";
console.log(
`[ocr-provider-samples] ${result.alias} kind=${result.kind} status=${result.status} transport=${result.transportMode || "-"} size=${result.fileSizeBytes ?? "-"}${error}`,
);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (!SUPPORTED_PROVIDERS.has(options.provider)) {
throw new Error(`unsupported provider: ${options.provider}`);
}
const manifest = loadManifestSamples(options.manifest);
const samples = dedupeSamples([...manifest.samples, ...loadEnvSamples()]);
const config = getTencentConfig();
const results = [];
for (const sample of samples) {
results.push(await checkSample(options.provider, config, sample));
}
const decision = buildDecision(results, manifest.error);
const failed = results.some((item) => item.status === "HOLD");
const skipped = !results.length || results.some((item) => item.status === "SKIP");
const summary = {
checkedAt: new Date().toISOString(),
provider: options.provider,
status: failed ? "HOLD" : skipped ? "SKIP" : "PASS",
decision,
manifestProvided: Boolean(options.manifest),
manifestError: manifest.error,
credentialReady: hasTencentCredentials(config),
counts: {
total: results.length,
pdf: results.filter((item) => item.kind === "pdf").length,
image: results.filter((item) => item.kind === "image").length,
pass: results.filter((item) => item.status === "PASS").length,
hold: results.filter((item) => item.status === "HOLD").length,
skip: results.filter((item) => item.status === "SKIP").length,
},
results,
};
if (options.json) {
console.log(JSON.stringify(summary, null, 2));
} else {
printHuman(summary);
}
if (options.strict && summary.status !== "PASS") {
process.exit(1);
}
}
main().catch((error) => {
console.error(`[ocr-provider-samples][ERROR] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});