170 行
8.0 KiB
JavaScript
170 行
8.0 KiB
JavaScript
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
|
const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("../../../src/services/paymentReceiptManualReview");
|
|
|
|
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
|
|
|
function reviewFixture() {
|
|
return { reviewer: { id: "reviewer", roleCodes: ["ceo"] }, hasReviewPermission: true,
|
|
makerId: "maker", proposedBy: "operator", participants: [{ ownerId: "owner", submitterId: "submitter" }] };
|
|
}
|
|
|
|
describe("independent manual receipt review identity", () => {
|
|
test("independent business reviewer is eligible", () => {
|
|
expect(() => assertIndependentReceiptReviewer(reviewFixture())).not.toThrow();
|
|
});
|
|
|
|
test.each(["maker", "operator", "owner", "submitter"])("cannot review as %s", (id) => {
|
|
const input = reviewFixture();
|
|
input.reviewer.id = id;
|
|
expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_identity_conflict");
|
|
});
|
|
|
|
test.each([["admin"], ["finance"], ["*"]])("permission does not substitute for business role %j", (role) => {
|
|
const input = reviewFixture();
|
|
input.reviewer.roleCodes = [role];
|
|
expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_permission_required");
|
|
});
|
|
|
|
test("business role does not substitute for review permission", () => {
|
|
expect(() => assertIndependentReceiptReviewer({ ...reviewFixture(), hasReviewPermission: false }))
|
|
.toThrow("receipt_review_permission_required");
|
|
});
|
|
|
|
test.each(["makerId", "proposedBy", "participants"])("missing %s fails closed", (key) => {
|
|
const input = reviewFixture();
|
|
delete input[key];
|
|
expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_identity_unresolved");
|
|
});
|
|
|
|
test("every source participant is checked, not only the first reimbursement", () => {
|
|
const input = reviewFixture();
|
|
input.participants.push({ ownerId: "reviewer", submitterId: "another" });
|
|
expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_identity_conflict");
|
|
});
|
|
});
|
|
|
|
function fixture() {
|
|
const fields = Object.entries({
|
|
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
|
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-MANUAL",
|
|
}).map(([Name, Value]) => ({ Name, Value }));
|
|
return {
|
|
original: { status: "recognized", pagesExpected: 1, pagesProcessed: 1,
|
|
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields) }] },
|
|
pages: [{ pageNumber: 1, itemId: "item-2", fields }],
|
|
candidates: ["item-1", "item-2"].map((id) => ({
|
|
id, amount: "10.00", currency: "CNY", payerAccount: "000011112222", payeeAccount: "000033334444",
|
|
})),
|
|
};
|
|
}
|
|
|
|
describe("manual receipt proposal is evidence preparation, not authorization", () => {
|
|
test("explicit same-amount association needs no field reentry review", () => {
|
|
const input = fixture();
|
|
const before = JSON.stringify(input);
|
|
const result = buildManualReceiptProposal(config, input);
|
|
expect(result.requiresIndependentReview).toBe(false);
|
|
expect(result.associations).toEqual([{ pageNumber: 1, itemId: "item-2" }]);
|
|
expect(result.changedFields).toEqual([{ pageNumber: 1, fields: [] }]);
|
|
expect(result.bankVerified).toBe(false);
|
|
expect(JSON.stringify(input)).toBe(before);
|
|
});
|
|
|
|
test.each(["amount", "currency", "payerAccount", "payeeAccount", "reference", "transactionStatus"])(
|
|
"corrected %s cannot masquerade as an automatic match", (field) => {
|
|
const input = fixture();
|
|
input.original.pages[0][field] = null;
|
|
const result = buildManualReceiptProposal(config, input);
|
|
expect(result.requiresIndependentReview).toBe(true);
|
|
expect(result.changedFields[0].fields).toContain(field);
|
|
expect(input.original.pages[0][field]).toBeNull();
|
|
},
|
|
);
|
|
|
|
test("OCR failure requires independent review even with fully matching manual fields", () => {
|
|
const input = fixture();
|
|
input.original = { status: "ocr_failed", pagesExpected: 1, pagesProcessed: 0, pages: [] };
|
|
expect(buildManualReceiptProposal(config, input).requiresIndependentReview).toBe(true);
|
|
});
|
|
|
|
test.each(["failed", "pending"])("cannot rewrite original %s to success", (status) => {
|
|
const input = fixture();
|
|
input.original.pages[0].transactionStatus = status;
|
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_not_successful");
|
|
});
|
|
|
|
test.each(["交易失败", "处理中"])("conflicting status aliases retain %s veto", (status) => {
|
|
const input = fixture();
|
|
input.original.pages[0] = { pageNumber: 1, ...normalizeBankSlip([
|
|
...input.pages[0].fields, { Name: "交易状态", Value: status },
|
|
]) };
|
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_not_successful");
|
|
});
|
|
|
|
test.each([["小写金额", "11.00"], ["币别", "USD"], ["付款人账号", "111122223333"],
|
|
["收款人账号", "222233334444"], ["单据状态", "已受理"], ["凭证号", ""]])(
|
|
"%s mismatch cannot be overridden by item selection or reason", (name, value) => {
|
|
const input = fixture();
|
|
input.pages[0].fields.find((field) => field.Name === name).Value = value;
|
|
input.reason = "测试理由不能免除核验";
|
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_manual_match_required");
|
|
},
|
|
);
|
|
|
|
test("association cannot select another batch's item", () => {
|
|
const input = fixture();
|
|
input.pages[0].itemId = "outside";
|
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_item_unavailable");
|
|
});
|
|
|
|
test("missing PDF page cannot be waived", () => {
|
|
const input = fixture();
|
|
input.original.pagesExpected = 2;
|
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_pages_unresolved");
|
|
});
|
|
|
|
test("unknown original page count cannot be guessed from manual input", () => {
|
|
const input = fixture();
|
|
input.original.pagesExpected = null;
|
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_pages_unresolved");
|
|
});
|
|
|
|
test("duplicate transaction on two pages cannot pay two items", () => {
|
|
const input = fixture();
|
|
input.original.pagesExpected = 2;
|
|
input.pages.push({ ...input.pages[0], pageNumber: 2, itemId: "item-1" });
|
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_reused");
|
|
});
|
|
|
|
test("two transactions cannot be assigned to one reimbursement", () => {
|
|
const input = fixture();
|
|
input.original.pagesExpected = 2;
|
|
input.pages.push({ ...input.pages[0], pageNumber: 2,
|
|
fields: input.pages[0].fields.map((field) => field.Name === "凭证号" ?
|
|
{ ...field, Value: "SYN-SECOND" } : field) });
|
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_multiple_transactions");
|
|
});
|
|
|
|
test.each(["凭证号", "付款人账号"])("cannot hide original duplicate by correcting %s", (name) => {
|
|
const input = fixture();
|
|
input.original.pagesExpected = 2;
|
|
input.original.pagesProcessed = 2;
|
|
input.original.pages.push({ ...input.original.pages[0], pageNumber: 2 });
|
|
input.pages.push({ ...input.pages[0], pageNumber: 2, itemId: "item-1",
|
|
fields: input.pages[0].fields.map((field) => field.Name === name ?
|
|
{ ...field, Value: name === "凭证号" ? "SYN-ALTERED" : "999900001111" } : field) });
|
|
expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_reused");
|
|
});
|
|
|
|
test("retains original fingerprint for persistent cross-batch reuse checks", () => {
|
|
const input = fixture();
|
|
const original = buildManualReceiptProposal(config, input).originalFingerprints;
|
|
input.pages[0].fields.find((field) => field.Name === "凭证号").Value = "SYN-CORRECTED";
|
|
const changed = buildManualReceiptProposal(config, input);
|
|
expect(changed.originalFingerprints).toEqual(original);
|
|
expect(changed.requiresIndependentReview).toBe(true);
|
|
expect(original).toHaveLength(1);
|
|
expect(original[0]).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
});
|