区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。 正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。 本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。 本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。 限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
564 行
38 KiB
JavaScript
564 行
38 KiB
JavaScript
const assert = require("node:assert/strict");
|
|
const { execFileSync } = require("node:child_process");
|
|
const { randomUUID } = require("node:crypto");
|
|
const { readFileSync, readdirSync } = require("node:fs");
|
|
const { createRequire } = require("node:module");
|
|
const path = require("node:path");
|
|
const { setTimeout: delay } = require("node:timers/promises");
|
|
const { syntheticDocumentEvidence } = require("./synthetic-bank-receipt.cjs");
|
|
|
|
const root = path.resolve(__dirname, "../..");
|
|
const serverRequire = createRequire(path.join(root, "server/package.json"));
|
|
const docker = (args, input) => {
|
|
try {
|
|
return execFileSync("docker", args, {
|
|
input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 60000,
|
|
}).trim();
|
|
} catch (error) {
|
|
error.dockerOperation = args[0];
|
|
error.dockerExit = Number.isInteger(error.status) ? error.status : null;
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
async function main() {
|
|
assert.equal(process.env.RUN_ISOLATED_DB_TEST, "1");
|
|
const realAuthentication = process.env.RUN_RECEIPT_REAL_AUTH === "1";
|
|
let container;
|
|
let prisma;
|
|
let pool;
|
|
const passed = [];
|
|
try {
|
|
const password = randomUUID();
|
|
container = docker(["run", "-d", "--rm", "--pull=never", "--tmpfs", "/var/lib/postgresql/data",
|
|
"-p", "127.0.0.1::5432", "-e", `POSTGRES_PASSWORD=${password}`, "postgres:16-alpine"]);
|
|
let ready = false;
|
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
try { docker(["exec", container, "pg_isready", "-U", "postgres"]); ready = true; break; }
|
|
catch { await delay(1000); }
|
|
}
|
|
assert.ok(ready);
|
|
const migrations = path.join(root, "server/prisma/migrations");
|
|
for (const directory of readdirSync(migrations).sort()) {
|
|
if (directory === "migration_lock.toml") continue;
|
|
docker(["exec", "-i", container, "psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", "-U", "postgres"],
|
|
readFileSync(path.join(migrations, directory, "migration.sql")));
|
|
}
|
|
passed.push("all_migrations");
|
|
const address = docker(["port", container, "5432/tcp"]);
|
|
assert.match(address, /^127\.0\.0\.1:\d+$/);
|
|
process.env.DATABASE_URL = `postgresql://postgres:${password}@${address}/postgres`;
|
|
({ prisma, pool } = serverRequire("./src/prismaClient").createPrismaClient());
|
|
const { encryptStringOrThrow } = serverRequire("./src/services/crypto");
|
|
const { normalizeBankSlip } = serverRequire("./src/services/paymentReceiptMatching");
|
|
const { claimVerifiedReceipts, storeReceiptRecognition, queueReceiptRetry } = serverRequire("./src/services/paymentReceiptVerification");
|
|
const { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail } = serverRequire("./src/services/paymentReceiptManualPersistence");
|
|
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
|
const department = await prisma.department.create({ data: { name: "synthetic-receipt" } });
|
|
const employee = await prisma.employee.create({ data: {
|
|
name: "synthetic", email: "[email protected]", employee_no: "synthetic-receipt",
|
|
department_id: department.id, title: "synthetic", hire_date: new Date("2026-09-13"),
|
|
bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash",
|
|
} });
|
|
async function fixture(reference, manual = false) {
|
|
const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({
|
|
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: reference,
|
|
}).map(([Name, Value]) => ({ Name, Value }))), document: syntheticDocumentEvidence() };
|
|
const batch = await prisma.paymentBatch.create({ data: {
|
|
batch_no: `SYN-${randomUUID()}`, entity_type: "reimbursement", name: "synthetic", status: "reviewed",
|
|
created_by: employee.id,
|
|
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, page.payerAccount) },
|
|
items: { create: { entity_id: randomUUID(), entity_code: "SYN", entity_title: "synthetic",
|
|
amount: "10.00", snapshot_json: { payee: { bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, page.payeeAccount) } } } },
|
|
}, include: { items: true } });
|
|
const attachment = await prisma.paymentBatchReceiptAttachment.create({ data: {
|
|
payment_batch_id: batch.id, storage_path: "synthetic-no-file", original_name: "synthetic.pdf",
|
|
mime_type: "application/pdf", file_size: 1, file_sha256: "a".repeat(64), uploaded_by: employee.id,
|
|
} });
|
|
const recognition = { status: "recognized", pagesExpected: 1, pagesProcessed: 1, pages: [page] };
|
|
if (manual) {
|
|
recognition.status = "needs_review";
|
|
page.amount = null;
|
|
page.issues = ["amount_unresolved"];
|
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: attachment.id },
|
|
data: { receipt_ocr_state: "needs_review" } });
|
|
}
|
|
await prisma.$transaction((tx) => storeReceiptRecognition(tx, config, {
|
|
batchId: batch.id, attachmentId: attachment.id, sourceSha256: attachment.file_sha256, expectedVersion: 0, recognition,
|
|
}));
|
|
return { batch, attachment, recognition };
|
|
}
|
|
async function claim(batch, abort = false) {
|
|
return prisma.$transaction(async (tx) => {
|
|
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id=${batch.id}::uuid FOR UPDATE`;
|
|
await claimVerifiedReceipts(tx, config, batch);
|
|
if (abort) throw new Error("synthetic_abort");
|
|
}, { timeout: 10000 });
|
|
}
|
|
const first = await fixture("SYN-SAME");
|
|
await assert.rejects(claim(first.batch, true), /synthetic_abort/);
|
|
assert.equal(await prisma.paymentReceiptClaim.count(), 0);
|
|
passed.push("claim_rolls_back_with_transaction");
|
|
await claim(first.batch);
|
|
await claim(first.batch);
|
|
assert.equal(await prisma.paymentReceiptClaim.count(), 1);
|
|
passed.push("idempotent_claim");
|
|
const second = await fixture("SYN-SAME");
|
|
await assert.rejects(claim(second.batch), { code: "receipt_transaction_reused" });
|
|
passed.push("cross_batch_duplicate_blocked");
|
|
const concurrentFirst = await fixture("SYN-CONCURRENT");
|
|
const concurrentSecond = await fixture("SYN-CONCURRENT");
|
|
const outcomes = await Promise.allSettled([claim(concurrentFirst.batch), claim(concurrentSecond.batch)]);
|
|
assert.equal(outcomes.filter((outcome) => outcome.status === "fulfilled").length, 1);
|
|
assert.equal(outcomes.find((outcome) => outcome.status === "rejected").reason.code, "receipt_transaction_reused");
|
|
passed.push("concurrent_duplicate_blocked");
|
|
const voided = await fixture("SYN-VOID");
|
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: voided.attachment.id }, data: { archived_at: new Date() } });
|
|
await assert.rejects(claim(voided.batch), { code: "receipt_verification_required" });
|
|
passed.push("voided_receipt_blocked");
|
|
const verificationCount = await prisma.paymentReceiptVerification.count();
|
|
await assert.rejects(prisma.$transaction((tx) => storeReceiptRecognition(tx, config, {
|
|
batchId: voided.batch.id, attachmentId: voided.attachment.id,
|
|
sourceSha256: voided.attachment.file_sha256, expectedVersion: 0, recognition: voided.recognition,
|
|
})), { code: "receipt_unavailable" });
|
|
assert.equal(await prisma.paymentReceiptVerification.count(), verificationCount);
|
|
passed.push("late_result_cannot_revive_voided_receipt");
|
|
await assert.rejects(prisma.$transaction((tx) => storeReceiptRecognition(tx, config, {
|
|
batchId: first.batch.id, attachmentId: first.attachment.id,
|
|
sourceSha256: "b".repeat(64), expectedVersion: 0, recognition: first.recognition,
|
|
})), { code: "receipt_source_changed" });
|
|
passed.push("changed_source_rejected");
|
|
const superseded = await fixture("SYN-SUPERSEDED");
|
|
await prisma.paymentBatchReceiptAttachment.update({
|
|
where: { id: superseded.attachment.id }, data: { receipt_ocr_version: { increment: 1 } },
|
|
});
|
|
await assert.rejects(prisma.$transaction((tx) => storeReceiptRecognition(tx, config, {
|
|
batchId: superseded.batch.id, attachmentId: superseded.attachment.id, expectedVersion: 0,
|
|
sourceSha256: superseded.attachment.file_sha256, recognition: superseded.recognition,
|
|
})), { code: "receipt_job_superseded" });
|
|
await assert.rejects(claim(superseded.batch), { code: "receipt_verification_required" });
|
|
passed.push("superseded_job_and_old_match_blocked");
|
|
const { claimReceiptJob, runReceiptOcrOnce } = serverRequire("./src/jobs/paymentReceiptOcrJob");
|
|
for (const scenario of ["cash", "travel", "settled"]) {
|
|
const outside = await fixture(`SYN-SCOPE-${scenario}`);
|
|
if (scenario === "settled") {
|
|
await prisma.paymentBatchItem.update({ where: { id: outside.batch.items[0].id }, data: { status: "executed" } });
|
|
} else {
|
|
await prisma.paymentBatch.update({ where: { id: outside.batch.id },
|
|
data: scenario === "cash" ? { default_payment_channel: "cash" } : { entity_type: "travel_order" } });
|
|
}
|
|
await prisma.paymentBatchReceiptAttachment.update({
|
|
where: { id: outside.attachment.id }, data: { receipt_ocr_state: "queued" },
|
|
});
|
|
let externalOperations = 0;
|
|
const forbidden = async () => { externalOperations += 1; throw new Error("synthetic_forbidden"); };
|
|
assert.deepEqual(await runReceiptOcrOnce({ prisma, config, readBuffer: forbidden, recognize: forbidden }), { status: "idle" });
|
|
assert.equal(externalOperations, 0);
|
|
const retained = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: outside.attachment.id } });
|
|
assert.equal(retained.receipt_ocr_state, "legacy");
|
|
assert.equal(retained.receipt_ocr_version, 0);
|
|
passed.push(`worker_${scenario}_retains_evidence_without_file_read_or_cloud_call`);
|
|
}
|
|
const queued = await fixture("SYN-QUEUE");
|
|
const keyFailure = await fixture("SYN-KEY-FAILURE");
|
|
await prisma.paymentBatchReceiptAttachment.update({
|
|
where: { id: keyFailure.attachment.id }, data: { receipt_ocr_state: "queued" },
|
|
});
|
|
let forbiddenReads = 0;
|
|
const forbiddenRead = async () => { forbiddenReads += 1; throw new Error("synthetic_forbidden"); };
|
|
assert.deepEqual(await runReceiptOcrOnce({ prisma, config: {}, readBuffer: forbiddenRead, recognize: forbiddenRead }),
|
|
{ status: "key_failed" });
|
|
assert.equal(forbiddenReads, 0);
|
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({
|
|
where: { id: keyFailure.attachment.id },
|
|
})).receipt_ocr_state, "key_failed");
|
|
passed.push("missing_key_terminal_state_persisted_without_cloud");
|
|
const persistFailure = await fixture("SYN-PERSIST-FAILURE");
|
|
await prisma.paymentBatchReceiptAttachment.update({
|
|
where: { id: persistFailure.attachment.id }, data: { receipt_ocr_state: "queued" },
|
|
});
|
|
const faultyPrisma = { $transaction: (callback, options) => prisma.$transaction(async (tx) => {
|
|
const proxy = new Proxy(tx, { get(target, property) {
|
|
if (property === "paymentReceiptVerification") return { create: async () => {
|
|
await tx.$executeRawUnsafe('SELECT 1 / 0');
|
|
} };
|
|
return target[property];
|
|
} });
|
|
return callback(proxy);
|
|
}, options) };
|
|
assert.deepEqual(await runReceiptOcrOnce({ prisma: faultyPrisma, config,
|
|
readBuffer: forbiddenRead, recognize: forbiddenRead }), { status: "system_failed" });
|
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({
|
|
where: { id: persistFailure.attachment.id },
|
|
})).receipt_ocr_state, "system_failed");
|
|
passed.push("aborted_database_transaction_records_failure_in_separate_transaction");
|
|
await prisma.paymentBatchReceiptAttachment.update({
|
|
where: { id: queued.attachment.id }, data: { receipt_ocr_state: "queued" },
|
|
});
|
|
const workers = await Promise.all([claimReceiptJob(prisma), claimReceiptJob(prisma)]);
|
|
assert.equal(workers.filter(Boolean).length, 1);
|
|
assert.equal(workers.find(Boolean).receipt_ocr_version, 1);
|
|
await assert.rejects(claim(queued.batch), { code: "receipt_verification_required" });
|
|
passed.push("concurrent_worker_single_claim_and_version_invalidation");
|
|
const brokenFile = await fixture("SYN-QUEUE-FAIL");
|
|
await prisma.paymentBatchReceiptAttachment.update({
|
|
where: { id: brokenFile.attachment.id }, data: { receipt_ocr_state: "queued" },
|
|
});
|
|
let externalCalls = 0;
|
|
const failedJob = await runReceiptOcrOnce({ prisma, config,
|
|
readBuffer: async () => Buffer.from("wrong-file"),
|
|
recognize: async () => { externalCalls += 1; throw new Error("must_not_call"); },
|
|
});
|
|
assert.equal(failedJob.status, "ocr_failed");
|
|
assert.equal(externalCalls, 0);
|
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({
|
|
where: { id: brokenFile.attachment.id },
|
|
})).receipt_ocr_state, "ocr_failed");
|
|
passed.push("worker_integrity_failure_persisted_without_external_call");
|
|
const failedAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: brokenFile.attachment.id } });
|
|
async function retryReceipt() {
|
|
return prisma.$transaction(async (tx) => {
|
|
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id=${brokenFile.batch.id}::uuid FOR UPDATE`;
|
|
return queueReceiptRetry(tx, { batchId: brokenFile.batch.id, attachmentId: failedAttachment.id,
|
|
expectedVersion: failedAttachment.receipt_ocr_version });
|
|
});
|
|
}
|
|
const retries = await Promise.allSettled([retryReceipt(), retryReceipt()]);
|
|
assert.equal(retries.filter((outcome) => outcome.status === "fulfilled").length, 1);
|
|
assert.equal(retries.find((outcome) => outcome.status === "rejected").reason.code, "receipt_job_superseded");
|
|
passed.push("concurrent_retry_single_version");
|
|
const existing = await prisma.paymentReceiptClaim.findFirst();
|
|
await assert.rejects(prisma.paymentReceiptClaim.create({ data: {
|
|
fingerprint: existing.fingerprint, key_id: existing.key_id,
|
|
item_id: second.batch.items[0].id, verification_id: existing.verification_id,
|
|
} }), { code: "P2002" });
|
|
passed.push("database_unique_constraint");
|
|
const reviewer = await prisma.employee.create({ data: {
|
|
name: "synthetic-reviewer", email: "[email protected]", employee_no: "synthetic-reviewer",
|
|
department_id: department.id, title: "synthetic", hire_date: new Date("2026-09-13"),
|
|
bank_account_encrypted: "synthetic-not-account", bank_name: "synthetic", password_hash: "not-a-login-hash",
|
|
roles: { create: { role_code: "ceo" } },
|
|
} });
|
|
function requestFor(actor, roleCodes = ["finance"]) {
|
|
return { id: randomUUID(), headers: {}, employee: { id: actor.id, roleCodes } };
|
|
}
|
|
async function manualFixture(reference) {
|
|
const context = await fixture(reference, true);
|
|
await prisma.reimbursementForm.create({ data: {
|
|
id: context.batch.items[0].entity_id, submitter_id: employee.id, owner_id: employee.id,
|
|
department_id: department.id, reimbursement_date: new Date("2026-09-14"),
|
|
status: "approved", total_amount: "10.00",
|
|
} });
|
|
return context;
|
|
}
|
|
function proposalInput(context, reference) {
|
|
return { batchId: context.batch.id, attachmentId: context.attachment.id, expectedVersion: 0,
|
|
reason: "合成测试更正", pages: [{ pageNumber: 1, itemId: context.batch.items[0].id,
|
|
fields: Object.entries({
|
|
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: reference,
|
|
}).map(([Name, Value]) => ({ Name, Value })) }] };
|
|
}
|
|
const manual = await manualFixture("SYN-MANUAL-SAVE");
|
|
const manualInput = proposalInput(manual, "SYN-MANUAL-SAVE");
|
|
const auditBefore = await prisma.auditLog.count();
|
|
await assert.rejects(prisma.$transaction(async (tx) => {
|
|
await saveManualReceiptProposal(tx, config, requestFor(employee), manualInput);
|
|
throw new Error("synthetic_abort");
|
|
}), /synthetic_abort/);
|
|
assert.equal(await prisma.paymentReceiptManualProposal.count(), 0);
|
|
assert.equal(await prisma.auditLog.count(), auditBefore);
|
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: manual.attachment.id } })).receipt_ocr_version, 0);
|
|
passed.push("manual_proposal_and_audit_rollback");
|
|
const saves = await Promise.allSettled([1, 2].map(() => prisma.$transaction((tx) =>
|
|
saveManualReceiptProposal(tx, config, requestFor(employee), manualInput))));
|
|
assert.equal(saves.filter((outcome) => outcome.status === "fulfilled").length, 1);
|
|
assert.equal(saves.find((outcome) => outcome.status === "rejected").reason.code, "receipt_job_superseded");
|
|
const saved = saves.find((outcome) => outcome.status === "fulfilled").value;
|
|
assert.equal(saved.status, "pending_review");
|
|
assert.equal(saved.version, 1);
|
|
assert.deepEqual(Object.keys(saved).sort(), ["id", "status", "version"]);
|
|
const stored = await prisma.paymentReceiptManualProposal.findUnique({ where: { id: saved.id } });
|
|
assert.ok(!stored.payload_encrypted.includes("SYN-MANUAL-SAVE"));
|
|
await assert.rejects(claim(manual.batch), { code: "receipt_verification_required" });
|
|
passed.push("manual_concurrent_save_encrypted_and_not_payment");
|
|
const reviewInput = { ...manualInput, expectedVersion: saved.version, proposalId: saved.id,
|
|
decision: "approve", reason: "合成独立核对" };
|
|
await assert.rejects(prisma.$transaction((tx) =>
|
|
reviewManualReceiptProposal(tx, config, requestFor(employee, ["ceo"]), reviewInput, true)),
|
|
{ code: "receipt_review_identity_conflict" });
|
|
passed.push("manual_self_review_blocked");
|
|
const reviews = await Promise.allSettled([1, 2].map(() => prisma.$transaction((tx) =>
|
|
reviewManualReceiptProposal(tx, config, requestFor(reviewer, ["ceo"]), reviewInput, true))));
|
|
assert.equal(reviews.filter((outcome) => outcome.status === "fulfilled").length, 1);
|
|
assert.equal(reviews.find((outcome) => outcome.status === "rejected").reason.code, "receipt_manual_not_reviewable");
|
|
assert.equal((await prisma.paymentReceiptManualProposal.findUnique({ where: { id: saved.id } })).status, "approved");
|
|
await claim(manual.batch);
|
|
const manualClaim = await prisma.paymentReceiptClaim.findUnique({ where: { item_id: manual.batch.items[0].id } });
|
|
assert.equal(manualClaim.manual_proposal_id, saved.id);
|
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: manual.batch.items[0].entity_id } })).paid_at, null);
|
|
passed.push("manual_claim_links_approved_proposal_without_recording_payment");
|
|
const reusedOriginal = await manualFixture("SYN-SAME");
|
|
await assert.rejects(prisma.$transaction((tx) => saveManualReceiptProposal(tx, config,
|
|
requestFor(employee), proposalInput(reusedOriginal, "SYN-NEW-REFERENCE"))),
|
|
{ code: "receipt_transaction_reused" });
|
|
passed.push("manual_original_cross_batch_duplicate_cannot_be_corrected_away");
|
|
const changed = await manualFixture("SYN-MANUAL-CHANGED");
|
|
const changedInput = proposalInput(changed, "SYN-MANUAL-CHANGED");
|
|
const changedSaved = await prisma.$transaction((tx) => saveManualReceiptProposal(tx, config, requestFor(employee), changedInput));
|
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: changed.attachment.id },
|
|
data: { file_sha256: "b".repeat(64) } });
|
|
await assert.rejects(prisma.$transaction((tx) => reviewManualReceiptProposal(tx, config,
|
|
requestFor(reviewer, ["ceo"]), { ...changedInput, expectedVersion: 1,
|
|
proposalId: changedSaved.id, decision: "approve", reason: "合成测试" }, true)),
|
|
{ code: "receipt_source_changed" });
|
|
passed.push("manual_changed_source_blocks_review");
|
|
const rejected = await manualFixture("SYN-MANUAL-REJECT");
|
|
const rejectedInput = proposalInput(rejected, "SYN-MANUAL-REJECT");
|
|
const rejectedSaved = await prisma.$transaction((tx) =>
|
|
saveManualReceiptProposal(tx, config, requestFor(employee), rejectedInput));
|
|
const rejectedDecision = await prisma.$transaction((tx) => reviewManualReceiptProposal(tx, config,
|
|
requestFor(reviewer, ["ceo"]), { ...rejectedInput, expectedVersion: 1,
|
|
proposalId: rejectedSaved.id, decision: "reject", reason: "合成拒绝" }, true));
|
|
assert.equal(rejectedDecision.status, "rejected");
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id=${rejected.batch.id}::uuid FOR UPDATE`;
|
|
await queueReceiptRetry(tx, { batchId: rejected.batch.id, attachmentId: rejected.attachment.id, expectedVersion: 1 });
|
|
});
|
|
assert.equal((await prisma.paymentReceiptManualProposal.findUnique({ where: { id: rejectedSaved.id } })).status, "rejected");
|
|
passed.push("manual_rejection_preserved_and_explicit_retry_available");
|
|
const auditFailure = await manualFixture("SYN-AUDIT-FAILURE");
|
|
await assert.rejects(prisma.$transaction((tx) => saveManualReceiptProposal(tx, config,
|
|
requestFor({ id: randomUUID() }), proposalInput(auditFailure, "SYN-AUDIT-FAILURE"))), { code: "P2003" });
|
|
assert.equal(await prisma.paymentReceiptManualProposal.count({ where: { attachment_id: auditFailure.attachment.id } }), 0);
|
|
assert.equal((await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: auditFailure.attachment.id } })).receipt_ocr_version, 0);
|
|
passed.push("manual_audit_failure_rolls_back_proposal_and_version");
|
|
const association = await fixture("SYN-ASSOCIATION");
|
|
await prisma.paymentBatchItem.create({ data: {
|
|
payment_batch_id: association.batch.id, entity_id: randomUUID(), entity_code: "SYN-OTHER",
|
|
entity_title: "synthetic", amount: "10.00", snapshot_json: association.batch.items[0].snapshot_json,
|
|
} });
|
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: association.attachment.id },
|
|
data: { receipt_ocr_state: "needs_review" } });
|
|
const associated = await prisma.$transaction((tx) => saveManualReceiptProposal(tx, config, requestFor(employee), {
|
|
batchId: association.batch.id, attachmentId: association.attachment.id, expectedVersion: 0,
|
|
pages: [{ pageNumber: 1, itemId: association.batch.items[0].id }],
|
|
}));
|
|
assert.equal(associated.status, "association_ready");
|
|
assert.equal((await prisma.paymentReceiptManualProposal.findUnique({ where: { id: associated.id } })).reviewed_by, null);
|
|
passed.push("manual_unchanged_association_needs_no_reentry_or_second_approval");
|
|
const lastVersion = await manualFixture("SYN-LAST-VERSION");
|
|
await prisma.paymentBatchReceiptAttachment.update({ where: { id: lastVersion.attachment.id },
|
|
data: { receipt_ocr_version: 2147483646 } });
|
|
await prisma.paymentReceiptVerification.updateMany({ where: { attachment_id: lastVersion.attachment.id },
|
|
data: { source_version: 2147483646 } });
|
|
const lastInput = { ...proposalInput(lastVersion, "SYN-LAST-VERSION"), expectedVersion: 2147483646 };
|
|
const lastSaved = await prisma.$transaction((tx) => saveManualReceiptProposal(tx, config, requestFor(employee), lastInput));
|
|
assert.equal(lastSaved.version, 2147483647);
|
|
const lastReview = await prisma.$transaction((tx) => reviewManualReceiptProposal(tx, config,
|
|
requestFor(reviewer, ["ceo"]), { ...lastInput, proposalId: lastSaved.id,
|
|
expectedVersion: lastSaved.version, decision: "approve", reason: "合成边界测试" }, true));
|
|
assert.equal(lastReview.status, "approved");
|
|
passed.push("manual_final_integer_version_still_reviewable_without_overflow");
|
|
const detailBefore = await prisma.auditLog.count();
|
|
const detail = await prisma.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
|
|
return readManualReceiptDetail(tx, config, { batchId: manual.batch.id, attachmentId: manual.attachment.id,
|
|
reviewer: requestFor(reviewer, ["ceo"]).employee, hasReviewPermission: true });
|
|
}, { isolationLevel: "RepeatableRead" });
|
|
assert.equal(detail.proposal.status, "approved");
|
|
assert.equal(detail.proposal.pages[0].amount, "10.00");
|
|
assert.ok(!JSON.stringify(detail).includes("000011112222"));
|
|
assert.ok(!JSON.stringify(detail).includes("000033334444"));
|
|
assert.equal(await prisma.auditLog.count(), detailBefore);
|
|
passed.push("manual_detail_readonly_and_accounts_masked");
|
|
async function approvedCorrection(originalReference, correctedReference) {
|
|
const context = await manualFixture(originalReference);
|
|
const input = proposalInput(context, correctedReference);
|
|
const proposal = await prisma.$transaction((tx) => saveManualReceiptProposal(tx, config, requestFor(employee), input));
|
|
await prisma.$transaction((tx) => reviewManualReceiptProposal(tx, config, requestFor(reviewer, ["ceo"]),
|
|
{ ...input, proposalId: proposal.id, expectedVersion: proposal.version, decision: "approve", reason: "合成独立复核" }, true));
|
|
return context;
|
|
}
|
|
const aliased = await approvedCorrection("SYN-ORIGINAL-ALIAS", "SYN-CORRECTED-ALIAS");
|
|
const aliasesBefore = await prisma.paymentReceiptTransactionAlias.count();
|
|
await assert.rejects(claim(aliased.batch, true), /synthetic_abort/);
|
|
assert.equal(await prisma.paymentReceiptTransactionAlias.count(), aliasesBefore);
|
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: aliased.batch.items[0].id } }), 0);
|
|
passed.push("manual_claim_and_original_alias_roll_back_together");
|
|
await claim(aliased.batch);
|
|
await claim(aliased.batch);
|
|
const aliasedClaim = await prisma.paymentReceiptClaim.findUnique({ where: { item_id: aliased.batch.items[0].id } });
|
|
assert.equal(await prisma.paymentReceiptTransactionAlias.count({ where: { claim_fingerprint: aliasedClaim.fingerprint } }), 2);
|
|
const originalReuse = await fixture("SYN-ORIGINAL-ALIAS");
|
|
const correctedReuse = await fixture("SYN-CORRECTED-ALIAS");
|
|
await assert.rejects(claim(originalReuse.batch), { code: "receipt_transaction_reused" });
|
|
await assert.rejects(claim(correctedReuse.batch), { code: "receipt_transaction_reused" });
|
|
passed.push("original_and_corrected_transaction_identity_both_unique_and_idempotent");
|
|
const identityChanged = await approvedCorrection("SYN-ROLE-ORIGINAL", "SYN-ROLE-CORRECTED");
|
|
await prisma.employeeRole.delete({ where: { employee_id_role_code: { employee_id: reviewer.id, role_code: "ceo" } } });
|
|
await assert.rejects(claim(identityChanged.batch), { code: "receipt_review_permission_required" });
|
|
await prisma.employeeRole.create({ data: { employee_id: reviewer.id, role_code: "ceo" } });
|
|
await prisma.reimbursementForm.update({ where: { id: identityChanged.batch.items[0].entity_id },
|
|
data: { owner_id: reviewer.id } });
|
|
await assert.rejects(claim(identityChanged.batch), { code: "receipt_review_identity_conflict" });
|
|
passed.push("manual_execution_rechecks_current_reviewer_role_and_participants");
|
|
const concurrentManual = await approvedCorrection("SYN-RACE-ORIGINAL", "SYN-RACE-CORRECTED");
|
|
const concurrentAuto = await fixture("SYN-RACE-ORIGINAL");
|
|
const aliasRace = await Promise.allSettled([claim(concurrentManual.batch), claim(concurrentAuto.batch)]);
|
|
assert.equal(aliasRace.filter((result) => result.status === "fulfilled").length, 1);
|
|
assert.equal(aliasRace.find((result) => result.status === "rejected").reason.code, "receipt_transaction_reused");
|
|
passed.push("concurrent_manual_and_auto_original_identity_only_one_claim");
|
|
const mixed = await fixture("SYN-MIXED-BANK");
|
|
const cashItem = await prisma.paymentBatchItem.create({ data: {
|
|
payment_batch_id: mixed.batch.id, entity_id: randomUUID(), entity_code: "SYN-CASH",
|
|
entity_title: "synthetic", amount: "10.00", payment_channel_override: "cash", snapshot_json: {},
|
|
} });
|
|
const mixedBatch = await prisma.paymentBatch.findUnique({ where: { id: mixed.batch.id }, include: { items: true } });
|
|
await claim(mixedBatch);
|
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: cashItem.id } }), 0);
|
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: mixed.batch.items[0].id } }), 1);
|
|
passed.push("mixed_history_only_bank_item_requires_and_consumes_receipt");
|
|
const { FEATURE_FLAG_KEYS } = serverRequire("./src/services/featureFlags");
|
|
await prisma.featureFlag.upsert({ where: { flag_key: FEATURE_FLAG_KEYS.paymentBatch },
|
|
create: { flag_key: FEATURE_FLAG_KEYS.paymentBatch, override_mode: "force_on", rollout_type: "global" },
|
|
update: { override_mode: "force_on", rollout_type: "global" } });
|
|
await prisma.employeeRole.create({ data: { employee_id: employee.id, role_code: "finance" } });
|
|
const { hasPermission, resolvePermissionsForRoles } = serverRequire("./src/constants/permissions");
|
|
const { AppError } = serverRequire("./src/utils/AppError");
|
|
const app = serverRequire("fastify")();
|
|
app.decorate("prisma", prisma);
|
|
app.decorate("config", { ...config, paymentBatchEnabled: true, appTimezone: "Asia/Shanghai",
|
|
erpSyncEnabled: false, emailEnabled: false, imNotifierEnabled: false, appOrigin: "http://127.0.0.1",
|
|
jwtSecret: randomUUID(), accessTokenTtl: "15m", refreshTtlSeconds: 3600,
|
|
refreshCookieName: "synthetic_refresh", cookieSecure: false,
|
|
loginFailMaxAttempts: 5, loginFailWindowSeconds: 300, loginLockSeconds: 60 });
|
|
if (realAuthentication) {
|
|
await app.register(serverRequire("@fastify/cookie"));
|
|
await app.register(serverRequire("./src/plugins/auth"));
|
|
await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" });
|
|
} else {
|
|
app.decorate("authenticate", async (request) => {
|
|
const actorId = request.headers.authorization === "Bearer synthetic-finance" ? employee.id :
|
|
request.headers.authorization === "Bearer synthetic-reviewer" ? reviewer.id : null;
|
|
if (!actorId) throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
|
const actor = await prisma.employee.findUnique({ where: { id: actorId }, include: { roles: true } });
|
|
request.employee = { ...actor, roleCodes: actor.roles.map((role) => role.role_code) };
|
|
});
|
|
app.decorate("hasPermission", (actor, permission) => hasPermission(resolvePermissionsForRoles(actor.roleCodes), permission));
|
|
}
|
|
app.setErrorHandler((error, request, reply) => reply.code(error instanceof AppError ? error.statusCode : 500)
|
|
.send({ error: { code: error.code ?? "unexpected", details: error instanceof AppError ? error.details : undefined } }));
|
|
await app.register(serverRequire("./src/routes/paymentBatches"), { prefix: "/api" });
|
|
let financeHeaders = { authorization: "Bearer synthetic-finance" };
|
|
let reviewerHeaders = { authorization: "Bearer synthetic-reviewer" };
|
|
async function readyHttp(context) {
|
|
await prisma.paymentBatch.update({ where: { id: context.batch.id }, data: {
|
|
reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: "bank",
|
|
} });
|
|
return `/api/payment-batches/${context.batch.id}`;
|
|
}
|
|
try {
|
|
if (realAuthentication) {
|
|
const loginPassword = `Synthetic!${randomUUID()}`;
|
|
const passwordHash = await serverRequire("./src/utils/crypto").hashPassword(loginPassword);
|
|
await prisma.employee.updateMany({ where: { id: { in: [employee.id, reviewer.id] } },
|
|
data: { password_hash: passwordHash, must_change_password: false } });
|
|
async function loginActor(actorId) {
|
|
const actor = await prisma.employee.findUniqueOrThrow({ where: { id: actorId } });
|
|
const response = await app.inject({ method: "POST", url: "/api/auth/login",
|
|
headers: { "x-device-fingerprint": `synthetic-${actorId}` },
|
|
payload: { identifier: actor.email, password: loginPassword } });
|
|
assert.equal(response.statusCode, 200, "synthetic password login failed");
|
|
const token = response.json().data.accessToken;
|
|
assert.equal(typeof token, "string");
|
|
assert.ok(response.cookies.some(cookie => cookie.name === "synthetic_refresh"));
|
|
return { authorization: `Bearer ${token}` };
|
|
}
|
|
financeHeaders = await loginActor(employee.id);
|
|
reviewerHeaders = await loginActor(reviewer.id);
|
|
passed.push("actual_password_login_jwt_and_refresh_cookie");
|
|
const forged = await app.inject({ method: "GET", url: `/api/payment-batches/${randomUUID()}/receipt-readiness`,
|
|
headers: { authorization: "Bearer synthetic-finance" } });
|
|
assert.equal(forged.statusCode, 401);
|
|
passed.push("synthetic_actor_header_rejected_by_real_authentication");
|
|
}
|
|
const httpManual = await manualFixture("SYN-HTTP-ORIGINAL");
|
|
const httpBase = await readyHttp(httpManual);
|
|
const httpProposalUrl = `${httpBase}/receipt-attachments/${httpManual.attachment.id}/manual-verification`;
|
|
const input = proposalInput(httpManual, "SYN-HTTP-CORRECTED");
|
|
const payload = { expectedVersion: input.expectedVersion, reason: input.reason, pages: input.pages };
|
|
assert.equal((await app.inject({ method: "POST", url: httpProposalUrl, payload })).statusCode, 401);
|
|
assert.equal((await app.inject({ method: "POST", url: httpProposalUrl, headers: reviewerHeaders, payload })).statusCode, 403);
|
|
const submitted = await app.inject({ method: "POST", url: httpProposalUrl, headers: financeHeaders, payload });
|
|
assert.equal(submitted.statusCode, 200);
|
|
const submittedItem = submitted.json().data.item;
|
|
assert.equal((await app.inject({ method: "GET", url: `${httpBase}/receipt-readiness`, headers: financeHeaders })).json().data.item.ready, false);
|
|
assert.equal((await app.inject({ method: "POST", url: `${httpBase}/execute`, headers: financeHeaders, payload: {} })).statusCode, 409);
|
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: httpManual.batch.items[0].id } }), 0);
|
|
const httpReview = await app.inject({ method: "POST", url: `${httpProposalUrl}/${submittedItem.id}/review`,
|
|
headers: reviewerHeaders, payload: { expectedVersion: submittedItem.version, decision: "approve", reason: "合成HTTP复核" } });
|
|
assert.equal(httpReview.statusCode, 200);
|
|
const claimCount = await prisma.paymentReceiptClaim.count();
|
|
const aliasesCount = await prisma.paymentReceiptTransactionAlias.count();
|
|
assert.equal((await app.inject({ method: "GET", url: `${httpBase}/receipt-readiness`, headers: financeHeaders })).json().data.item.ready, true);
|
|
assert.equal(await prisma.paymentReceiptClaim.count(), claimCount);
|
|
assert.equal(await prisma.paymentReceiptTransactionAlias.count(), aliasesCount);
|
|
const completed = await app.inject({ method: "POST", url: `${httpBase}/execute`, headers: financeHeaders, payload: {} });
|
|
assert.equal(completed.statusCode, 200);
|
|
assert.equal(completed.json().data.item.status, "executed");
|
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: httpManual.batch.items[0].entity_id } })).status, "paid");
|
|
assert.equal((await prisma.paymentReceiptClaim.findUnique({ where: { item_id: httpManual.batch.items[0].id } })).manual_proposal_id, submittedItem.id);
|
|
assert.equal((await app.inject({ method: "POST", url: `${httpBase}/execute`, headers: financeHeaders, payload: {} })).statusCode, 409);
|
|
passed.push("real_db_http_manual_review_readiness_execution_and_replay_guard");
|
|
const rollback = await approvedCorrection("SYN-HTTP-ROLLBACK-OLD", "SYN-HTTP-ROLLBACK-NEW");
|
|
const rollbackBase = await readyHttp(rollback);
|
|
await prisma.reimbursementForm.update({ where: { id: rollback.batch.items[0].entity_id }, data: { total_amount: "11.00" } });
|
|
const beforeAliases = await prisma.paymentReceiptTransactionAlias.count();
|
|
const refused = await app.inject({ method: "POST", url: `${rollbackBase}/execute`, headers: financeHeaders, payload: {} });
|
|
assert.equal(refused.statusCode, 409);
|
|
assert.equal(refused.json().error.details.payment, "source_amount_changed");
|
|
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: rollback.batch.items[0].id } }), 0);
|
|
assert.equal(await prisma.paymentReceiptTransactionAlias.count(), beforeAliases);
|
|
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: rollback.batch.items[0].entity_id } })).status, "approved");
|
|
passed.push("real_db_http_source_amount_change_rolls_back_claim_alias_and_payment");
|
|
} finally { await app.close(); }
|
|
if (process.env.RUN_RECEIPT_BROWSER === "1") {
|
|
assert.ok(realAuthentication);
|
|
await require("./test-payment-receipt-browser.cjs")({
|
|
root, prisma, config, employee, reviewer, manualFixture, serverRequire, passed,
|
|
});
|
|
}
|
|
} finally {
|
|
try { if (prisma) await prisma.$disconnect(); }
|
|
finally {
|
|
try { if (pool) await pool.end(); }
|
|
finally {
|
|
if (container) {
|
|
try { docker(["rm", "-f", container]); }
|
|
catch (error) {
|
|
if (docker(["ps", "-a", "--no-trunc", "--format", "{{.ID}}"]).split("\n").includes(container)) throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
console.log(JSON.stringify({ status: "PASS_ISOLATED_RECEIPT_DB", passed, cleanupComplete: true,
|
|
productionAccess: false, cloudCalls: false, httpTested: true,
|
|
authentication: realAuthentication ? "actual_password_login_and_jwt" : "synthetic_actor_fixture",
|
|
browserTested: process.env.RUN_RECEIPT_BROWSER === "1" }));
|
|
}
|
|
|
|
main().catch((error) => {
|
|
const location = String(error.stack ?? "").match(/test-payment-receipt-db\.cjs:(\d+):(\d+)/);
|
|
console.error(JSON.stringify({ status: "ISOLATED_RECEIPT_DB_FAILED",
|
|
dockerOperation: error.dockerOperation ?? null, dockerExit: error.dockerExit ?? null,
|
|
code: /^[A-Za-z0-9_]+$/.test(error.code ?? "") ? error.code : null,
|
|
actualCode: /^[A-Za-z0-9_]+$/.test(error.actual?.code ?? "") ? error.actual.code : null,
|
|
actualName: /^[A-Za-z0-9_]+$/.test(error.actual?.name ?? "") ? error.actual.name : null,
|
|
databaseCode: /^[A-Za-z0-9_]+$/.test(error.actual?.meta?.code ?? "") ? error.actual.meta.code : null,
|
|
line: location ? Number(location[1]) : null }));
|
|
process.exitCode = 1;
|
|
});
|