fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-15 07:21:55 +08:00
父节点 229867f5fd
当前提交 8d08d46d16
共修改 33 个文件,包含 3691 行新增和 11 行删除
+520
查看文件
@@ -0,0 +1,520 @@
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 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");
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 }))) };
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" });
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" });
const financeHeaders = { authorization: "Bearer synthetic-finance" };
const 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 {
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(); }
} 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: "synthetic_actor_fixture" }));
}
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;
});