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 行删除
@@ -291,6 +291,10 @@ function buildPrisma(options = {}) {
};
const tx = {
paymentReceiptClaim: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
paymentReceiptTransactionAlias: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
paymentReceiptManualProposal: { findMany: vi.fn(async () => []) },
paymentReceiptVerification: { findFirst: vi.fn(async () => null) },
paymentAccount: {
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
findMany: vi.fn(async ({ where }) => where.legal_entity_id.in.includes(state.paymentAccount.legal_entity_id) && state.paymentAccount.enabled && !state.paymentAccount.archived_at ? [{ ...state.paymentAccount }] : []),
@@ -383,6 +387,7 @@ function buildPrisma(options = {}) {
}),
},
paymentBatchReceiptAttachment: {
findUnique: vi.fn(async ({ where }) => state.receiptAttachments.find((item) => item.id === where.id) ?? null),
count: vi.fn(async ({ where }) =>
state.receiptAttachments.filter((item) =>
item.payment_batch_id === where.payment_batch_id &&
@@ -417,7 +422,10 @@ function buildPrisma(options = {}) {
update: vi.fn(async ({ where, data }) => {
const item = state.receiptAttachments.find((candidate) => candidate.id === where.id);
if (!item) throw new Error("receipt not found");
const nextVersion = data.receipt_ocr_version?.increment == null ? null :
item.receipt_ocr_version + data.receipt_ocr_version.increment;
Object.assign(item, data);
if (nextVersion !== null) item.receipt_ocr_version = nextVersion;
return { ...item };
}),
deleteMany: vi.fn(async ({ where }) => {
@@ -500,6 +508,9 @@ function buildPrisma(options = {}) {
$executeRaw: vi.fn(async () => 0),
$queryRaw: vi.fn(async (query, ...values) => {
const text = Array.isArray(query) ? query.join(" ") : String(query?.sql ?? query ?? "");
if (text.includes("FROM payment_batch_receipt_attachments") && text.includes("ORDER BY id")) {
return state.receiptAttachments.map((attachment) => ({ id: attachment.id }));
}
if (text.includes('ORDER BY "batch_no" DESC')) {
const pattern = values[1];
return typeof pattern === "string" && new RegExp(pattern).test(state.batch.batch_no)
@@ -510,6 +521,33 @@ function buildPrisma(options = {}) {
}),
};
if (options.verifiedReceipt) {
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
const { buildVerificationData } = require("../../../src/services/paymentReceiptVerification");
const payerAccount = "000011112222";
const payeeAccount = "000033334444";
state.batch.payment_account_snapshot_json = {
bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, payerAccount),
};
for (const item of state.batch.items) item.snapshot_json = { ...item.snapshot_json, payee: {
bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, payeeAccount),
} };
const attachment = state.receiptAttachments[0];
Object.assign(attachment, { receipt_ocr_version: 0, receipt_ocr_state: "matched" });
const pages = state.batch.items.filter((item) => ["pending", "failed"].includes(item.status)).map((item, index) => ({
pageNumber: index + 1, ...normalizeBankSlip(Object.entries({
小写金额: String(item.amount), 币别: item.currency_code, 付款人账号: payerAccount,
收款人账号: payeeAccount, 单据状态: "交易成功", 凭证号: `SYN-${item.id}`,
}).map(([Name, Value]) => ({ Name, Value }))),
}));
const data = buildVerificationData(config, state.batch, attachment, {
status: "recognized", pagesExpected: pages.length, pagesProcessed: pages.length, pages,
});
tx.paymentReceiptVerification.findFirst.mockResolvedValue({
id: PAYMENT_ACCOUNT_ID, ...data, attachment, matches: data.matches?.create ?? [],
});
}
const prisma = {
...tx,
$transaction: vi.fn(async (callback) => callback(tx)),
@@ -518,6 +556,246 @@ function buildPrisma(options = {}) {
return { prisma, tx, state };
}
describe("manual receipt verification routes", () => {
const headers = { authorization: "Bearer synthetic-test" };
const url = `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/manual-verification`;
function setupEvidence(tx, state) {
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
const fields = Object.entries({
小写金额: "1683.00", 币别: "人民币", 付款人账号: "000011112222",
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-HTTP-MANUAL",
}).map(([Name, Value]) => ({ Name, Value }));
state.batch.payment_account_snapshot_json = {
bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, "000011112222"),
};
state.batch.items[0].snapshot_json = { payee: {
bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, "000033334444"),
} };
const attachment = state.receiptAttachments[0];
Object.assign(attachment, { receipt_ocr_version: 0, receipt_ocr_state: "needs_review", governance_status: "approved" });
const original = { id: LEGAL_ENTITY_ID, attachment_id: attachment.id,
source_version: 0, source_sha256: attachment.file_sha256,
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
status: "needs_review", pagesExpected: 1, pagesProcessed: 1,
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), amount: null, issues: ["amount_unresolved"] }],
})) };
let stored;
tx.paymentReceiptVerification = { findFirst: vi.fn(async () => original) };
tx.paymentReceiptClaim = { count: vi.fn(async () => 0) };
tx.paymentReceiptTransactionAlias = { count: vi.fn(async () => 0) };
tx.paymentReceiptManualProposal = {
create: vi.fn(async ({ data }) => { stored = { id: PAYMENT_ACCOUNT_ID, ...data }; return stored; }),
findUnique: vi.fn(async () => stored ? { ...stored, original_verification: original } : null),
update: vi.fn(async ({ data }) => { Object.assign(stored, data); return stored; }),
};
return { expectedVersion: 0, reason: "合成更正", pages: [{ pageNumber: 1, itemId: ITEM_ID, fields }] };
}
it("saves encrypted proposal and rejects stale click without recording payment", async () => {
const { app, tx, state, tempDir } = await buildApp();
const payload = setupEvidence(tx, state);
try {
const response = await app.inject({ method: "POST", url, headers, payload });
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toEqual({ id: PAYMENT_ACCOUNT_ID, version: 1, status: "pending_review" });
expect(response.body).not.toContain("SYN-HTTP-MANUAL");
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
action: "PAYMENT_RECEIPT_MANUAL_PROPOSED",
}) }));
expect((await app.inject({ method: "POST", url, headers, payload })).statusCode).toBe(409);
expect(tx.paymentReceiptManualProposal.create).toHaveBeenCalledTimes(1);
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each(["amount", "state", "duplicate", "version", "fields"])("rejects invalid %s with no write", async (failure) => {
const { app, tx, state, tempDir } = await buildApp();
const payload = setupEvidence(tx, state);
if (failure === "amount") payload.pages[0].fields[0].Value = "1.00";
if (failure === "state") state.batch.status = "executed";
if (failure === "duplicate") tx.paymentReceiptClaim.count.mockResolvedValueOnce(0).mockResolvedValue(1);
if (failure === "version") payload.expectedVersion = 1;
if (failure === "fields") payload.pages[0].fields[0].unexpected = true;
try {
const response = await app.inject({ method: "POST", url, headers, payload });
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.statusCode).toBeLessThan(500);
expect(tx.paymentReceiptManualProposal.create).not.toHaveBeenCalled();
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it("reads masked facts and merges only changed fields without requiring account reentry", async () => {
const { app, tx, state, tempDir } = await buildApp();
const payload = setupEvidence(tx, state);
try {
const detail = await app.inject({ method: "GET", url, headers });
expect(detail.statusCode).toBe(200);
expect(detail.json().data.item.pagesExpected).toBe(1);
expect(detail.body).not.toContain("000011112222");
expect(detail.body).not.toContain("000033334444");
expect(tx.$executeRaw).toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
payload.pages[0].fields = [{ Name: "小写金额", Value: "1683.00" }];
expect((await app.inject({ method: "POST", url, headers, payload })).statusCode).toBe(200);
const proposed = await app.inject({ method: "GET", url, headers });
expect(proposed.statusCode).toBe(200);
expect(proposed.json().data.item.proposal.pages[0].amount).toBe("1683.00");
expect(proposed.json().data.item.canReview).toBe(false);
expect(proposed.body).not.toContain("000011112222");
expect(proposed.body).not.toContain("payload_encrypted");
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each(["foreign", "voided", "stale"])("does not expose details for %s evidence", async (stateKind) => {
const { app, tx, state, tempDir } = await buildApp();
setupEvidence(tx, state);
if (stateKind === "foreign") state.receiptAttachments[0].payment_batch_id = LEGAL_ENTITY_ID;
if (stateKind === "voided") state.receiptAttachments[0].archived_at = new Date();
if (stateKind === "stale") state.receiptAttachments[0].receipt_ocr_version = 5;
try {
const response = await app.inject({ method: "GET", url, headers });
expect(response.statusCode).toBe(409);
expect(response.body).not.toContain("SYN-HTTP-MANUAL");
expect(tx.auditLog.create).not.toHaveBeenCalled();
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each(["anonymous", "ceo", "disabled", "department"])("protects proposal boundary: %s", async (boundary) => {
const options = boundary === "ceo" ? { actor: {
id: ACTOR_ID, roleCodes: ["ceo"], permissionCodes: ["payment.batch.review"],
} } : boundary === "disabled" ? { paymentBatchEnabled: false } :
boundary === "department" ? { paymentBatchAllowedDepartmentIds: ["different"] } : {};
const { app, tx, state, tempDir } = await buildApp(options);
const payload = setupEvidence(tx, state);
try {
const response = await app.inject({ method: "POST", url, payload,
headers: boundary === "anonymous" ? {} : headers });
const read = await app.inject({ method: "GET", url, headers: boundary === "anonymous" ? {} : headers });
if (boundary !== "ceo") expect(read.statusCode).toBeGreaterThanOrEqual(400);
expect(read.body).not.toContain("000011112222");
expect(response.statusCode).toBeGreaterThanOrEqual(400);
expect(response.statusCode).toBeLessThan(500);
expect(tx.paymentReceiptManualProposal.create).not.toHaveBeenCalled();
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each(["finance", "admin"])("cannot approve a manual proposal as %s alone", async (role) => {
const { app, tx, tempDir } = await buildApp({ actor: {
id: ACTOR_ID, roleCodes: [role], permissionCodes: ["*"],
} });
try {
const response = await app.inject({ method: "POST", url: `${url}/${PAYMENT_ACCOUNT_ID}/review`, headers,
payload: { expectedVersion: 1, decision: "approve", reason: "合成复核" } });
expect(response.statusCode).toBe(403);
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each(["independent", "proposer", "owner", "submitter", "maker"])("review enforces %s identity", async (identity) => {
const actor = { id: ACTOR_ID, roleCodes: ["finance", "ceo"],
permissionCodes: ["payment.batch.manage", "payment.batch.review"] };
const { app, tx, state, tempDir } = await buildApp({ actor });
const payload = setupEvidence(tx, state);
try {
expect((await app.inject({ method: "POST", url, headers, payload })).statusCode).toBe(200);
actor.id = identity === "proposer" ? ACTOR_ID : identity === "owner" ? OWNER_ID :
identity === "submitter" ? SUBMITTER_ID : identity === "maker" ? MAKER_ID : REVIEWER_ID;
const detail = await app.inject({ method: "GET", url, headers });
expect(detail.json().data.item.canReview).toBe(identity === "independent");
const reviewUrl = `${url}/${PAYMENT_ACCOUNT_ID}/review`;
const response = await app.inject({ method: "POST", url: reviewUrl, headers,
payload: { expectedVersion: 1, decision: "approve", reason: "合成复核" } });
expect(response.statusCode).toBe(identity === "independent" ? 200 : 403);
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
if (identity === "independent") {
expect(response.json().data.item.status).toBe("approved");
expect(tx.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
action: "PAYMENT_RECEIPT_MANUAL_REVIEWED",
}) }));
} else {
expect(tx.paymentReceiptManualProposal.update).not.toHaveBeenCalled();
expect(response.json().error.details.reasonCode).toBe("payment_batch.receipt_independent_review_required");
}
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
});
describe("receipt OCR retry routes", () => {
const headers = { authorization: "Bearer synthetic-test" };
it("stores cash receipt evidence without queuing and rejects explicit OCR retry", async () => {
const { app, tx, tempDir } = await buildApp({
batch: { default_payment_channel: "cash" }, receiptAttachments: [],
});
try {
const multipart = buildMultipartRequest({ files: [{
filename: "synthetic-cash.pdf", contentType: "application/pdf", content: SYNTHETIC_RECEIPT_PDF,
}] });
const upload = await app.inject({ method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
headers: { ...headers, ...multipart.headers }, payload: multipart.payload });
expect(upload.statusCode).toBe(200);
const attachment = upload.json().data.items[0];
expect(attachment.ocrState).toBe("legacy");
expect(attachment.ocrRetryAvailable).toBe(false);
const listed = await app.inject({ method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers });
expect(listed.statusCode).toBe(200);
expect(listed.json().data.items[0].ocrRetryAvailable).toBe(false);
const auditCount = tx.auditLog.create.mock.calls.length;
const retry = await app.inject({ method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachment.id}/ocr-retry`,
headers, payload: { expectedVersion: 0 } });
expect(retry.statusCode).toBe(409);
expect(retry.json().error.details.reasonCode).toBe("payment_batch.receipt_retry_unavailable");
expect(tx.auditLog.create).toHaveBeenCalledTimes(auditCount);
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each(["legacy", "ocr_failed", "needs_review", "manual_rejected"])("queues %s with audit and stale click protection", async (ocrState) => {
const { app, tx, state, tempDir } = await buildApp();
Object.assign(state.receiptAttachments[0], { receipt_ocr_version: 0, receipt_ocr_state: ocrState, governance_status: "approved" });
const url = `/api/payment-batches/${BATCH_ID}/receipt-attachments/${state.receiptAttachments[0].id}/ocr-retry`;
try {
const response = await app.inject({ method: "POST", url, headers, payload: { expectedVersion: 0 } });
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({ ocrState: "queued", ocrVersion: 1 });
expect(tx.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
action: "PAYMENT_BATCH_RECEIPT_OCR_REQUEUED",
}) }));
expect((await app.inject({ method: "POST", url, headers, payload: { expectedVersion: 0 } })).statusCode).toBe(409);
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it.each(["queued", "matched", "processing"])("does not requeue %s immediately", async (ocrState) => {
const { app, tx, state, tempDir } = await buildApp();
Object.assign(state.receiptAttachments[0], { receipt_ocr_version: 0, receipt_ocr_state: ocrState,
receipt_ocr_started_at: new Date(), governance_status: "approved" });
try {
const response = await app.inject({ method: "POST", headers,
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${state.receiptAttachments[0].id}/ocr-retry`,
payload: { expectedVersion: 0 } });
expect(response.statusCode).toBe(409);
expect(response.json().error.details.reasonCode).toBe("payment_batch.receipt_retry_unavailable");
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it("forbids retry without finance management permission", async () => {
const { app, tx, tempDir } = await buildApp({ actor: {
id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.review"], roleCodes: ["ceo"],
} });
try {
const response = await app.inject({ method: "POST", headers,
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/ocr-retry`,
payload: { expectedVersion: 0 } });
expect(response.statusCode).toBe(403);
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
});
async function buildApp(options = {}) {
const { prisma, tx, state } = buildPrisma(options);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-cashv-payment-receipts-"));
@@ -650,8 +928,43 @@ afterAll(() => {
});
describe("payment batch reimbursement routes", () => {
it.each(["legacy", "superseded", "amount", "voided", "reused"])("blocks bank execution for %s evidence", async (condition) => {
const { app, state, tx, tempDir } = await buildApp({ verifiedReceipt: condition !== "legacy" });
if (condition === "superseded") state.receiptAttachments[0].receipt_ocr_version += 1;
if (condition === "amount") state.batch.items[0].amount = "1684.00";
if (condition === "voided") state.receiptAttachments[0].archived_at = new Date();
if (condition === "reused") tx.paymentReceiptTransactionAlias.findMany.mockResolvedValue([
{ claim_fingerprint: "different-transaction", key_id: "different-key" },
]);
const headers = { authorization: "Bearer synthetic-test" };
try {
const readiness = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-readiness`, headers });
expect(readiness.statusCode).toBe(200);
expect(readiness.json().data.item.ready).toBe(false);
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
expect(tx.paymentReceiptTransactionAlias.create).not.toHaveBeenCalled();
const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers, payload: {} });
expect(response.statusCode).toBe(409);
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it("readiness is read-only and valid evidence is claimed by execute", async () => {
const { app, tx, tempDir } = await buildApp({ verifiedReceipt: true });
const headers = { authorization: "Bearer synthetic-test" };
try {
const response = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-readiness`, headers });
expect(response.json().data.item.ready).toBe(true);
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
expect((await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers, payload: {} })).statusCode).toBe(200);
expect(tx.paymentReceiptClaim.create).toHaveBeenCalledTimes(1);
expect(tx.paymentReceiptTransactionAlias.create).toHaveBeenCalledTimes(1);
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
});
it("preserves internal failure summaries while refreshing safe reason metadata on an existing risk", async () => {
const { app, tx, state } = await buildApp({ reimbursement: { status: "reviewing" } });
const { app, tx, state } = await buildApp({ verifiedReceipt: true, reimbursement: { status: "reviewing" } });
const risk = {
id: "existing-risk", entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID,
risk_code: "payment_batch_execute_failed", level: "high", title: "支付批次执行失败",
@@ -788,7 +1101,7 @@ describe("payment batch reimbursement routes", () => {
{ id: "notif-2", recipient_employee_id: SUBMITTER_ID },
]);
const { app, tx, state } = await buildApp();
const { app, tx, state } = await buildApp({ verifiedReceipt: true });
const response = await app.inject({
method: "POST",
@@ -1495,6 +1808,7 @@ describe("payment batch reimbursement routes", () => {
it("uses the company-local registration date when no payment date was entered", async () => {
const { app, state } = await buildApp({
verifiedReceipt: true,
batch: { default_payment_date: null },
});
@@ -1789,6 +2103,7 @@ describe("payment batch reimbursement routes", () => {
it("returns a conflict without payment audit when the source was already paid", async () => {
const { app, state, tx } = await buildApp({
verifiedReceipt: true,
reimbursement: {
status: "paid",
payment_voucher_no: "PBX-001",
@@ -1817,7 +2132,7 @@ describe("payment batch reimbursement routes", () => {
});
it("rolls back the whole execution path on an unexpected database error", async () => {
const { app, state, tx } = await buildApp();
const { app, state, tx } = await buildApp({ verifiedReceipt: true });
tx.invoicePool.updateMany.mockRejectedValueOnce(new Error("synthetic_database_failure"));
const response = await app.inject({
@@ -2027,6 +2342,7 @@ describe("payment batch reimbursement routes", () => {
expect(uploadResponse.json().data.items[0]).toMatchObject({
originalName: "bank-receipt.pdf",
mimeType: "application/pdf",
ocrState: "queued",
governanceStatus: "approved",
sensitivityLevel: "confidential",
attachmentType: "receipt",
@@ -2455,6 +2771,7 @@ describe("payment batch reimbursement routes", () => {
it("retries only failed items and recomputes the batch summary from every final item state", async () => {
const baseItem = buildPaymentBatch().items[0];
const { app, tx, state } = await buildApp({
verifiedReceipt: true,
batch: {
status: "failed",
execution_summary_json: { successCount: 1, failedCount: 1, attemptedCount: 2 },
@@ -2560,6 +2877,7 @@ describe("payment batch reimbursement routes", () => {
it("marks batch item failed when reimbursement is not payable and does not queue follow-ups", async () => {
const { app, state } = await buildApp({
verifiedReceipt: true,
reimbursement: {
status: "reviewing",
},