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 行删除
@@ -0,0 +1,213 @@
const { createHmac, createHash } = require("node:crypto");
const { encryptStringOrThrow, decryptStringOrThrow, resolveFieldEncryptionKey } = require("./crypto");
const { matchBankSlip } = require("./paymentReceiptMatching");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { resolveItemPaymentChannel } = require("./paymentProcessingProgress");
class ReceiptVerificationError extends Error {
constructor(code) {
super(code);
this.name = "ReceiptVerificationError";
this.code = code;
}
}
function requireFact(condition, code) {
if (!condition) throw new ReceiptVerificationError(code);
}
function receiptKey(config) {
const key = resolveFieldEncryptionKey(config);
requireFact(key, "receipt_key_unavailable");
return { key, keyId: createHash("sha256").update(key).digest("hex") };
}
function transactionFingerprint(config, receipt) {
const { key, keyId } = receiptKey(config);
requireFact(receipt.payerAccount && receipt.reference, "receipt_identity_unresolved");
const fingerprint = createHmac("sha256", key)
.update(JSON.stringify(["ccb-receipt-v1", receipt.payerAccount, receipt.reference])).digest("hex");
return { fingerprint, key_id: keyId };
}
function batchCandidates(config, batch) {
receiptKey(config);
requireFact(batch.entity_type === "reimbursement", "receipt_business_unsupported");
const payer = batch.payment_account_snapshot_json;
requireFact(payer?.bankInstitutionCode === "CCB", "receipt_bank_unsupported");
const payerAccount = decryptStringOrThrow(config, payer.accountNumberEncrypted);
return batch.items.filter((item) => resolveItemPaymentChannel(batch, item) === "bank").map((item) => {
const payee = item.snapshot_json?.payee;
requireFact(payee?.bankInstitutionCode === "CCB", "receipt_bank_unsupported");
return {
id: item.id, amount: String(item.amount), currency: item.currency_code,
payerAccount, payeeAccount: decryptStringOrThrow(config, payee.bankAccountEncrypted),
};
});
}
function buildVerificationData(config, batch, attachment, recognition) {
requireFact(attachment.payment_batch_id === batch.id && !attachment.archived_at &&
attachment.governance_status === "approved", "receipt_unavailable");
const candidates = batchCandidates(config, batch);
const pages = recognition.pages ?? [];
const complete = recognition.status === "recognized" && recognition.pagesExpected > 0 &&
recognition.pagesExpected <= 20 && recognition.pagesProcessed === recognition.pagesExpected &&
pages.length === recognition.pagesExpected &&
pages.every((page, index) => page.pageNumber === index + 1);
const matches = [];
if (complete) {
for (const page of pages) {
const match = matchBankSlip(page, candidates);
if (match.status !== "matched") break;
matches.push({ page_number: page.pageNumber, item_id: match.itemId, ...transactionFingerprint(config, page) });
}
}
const matched = complete && matches.length === pages.length &&
new Set(matches.map((match) => match.item_id)).size === matches.length &&
new Set(matches.map((match) => match.fingerprint)).size === matches.length;
return {
attachment_id: attachment.id, source_sha256: attachment.file_sha256,
source_version: attachment.receipt_ocr_version,
status: matched ? "matched" : "needs_review",
pages_expected: recognition.pagesExpected, pages_processed: recognition.pagesProcessed,
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
status: recognition.status, pagesExpected: recognition.pagesExpected,
pagesProcessed: recognition.pagesProcessed, reason: recognition.reason,
pages: pages.map((page) => Object.fromEntries(
["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference", "issues"]
.map((key) => [key, page[key]]),
)),
})),
...(matched ? { matches: { create: matches } } : {}),
};
}
async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {}) {
const pending = batch.items.filter((item) => ["pending", "failed"].includes(item.status) &&
resolveItemPaymentChannel(batch, item) === "bank");
if (!pending.length) return;
const { keyId } = receiptKey(config);
if (!readOnly) await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
requireFact(await tx.paymentReceiptClaim.count({ where: { key_id: { not: keyId } } }) === 0,
"receipt_key_rotation_required");
requireFact(await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: keyId } } }) === 0,
"receipt_key_rotation_required");
const attachments = readOnly ? await tx.paymentBatchReceiptAttachment.findMany({
where: { payment_batch_id: batch.id }, select: { id: true }, orderBy: { id: "asc" },
}) : await tx.$queryRaw`
SELECT id FROM payment_batch_receipt_attachments
WHERE payment_batch_id = ${batch.id}::uuid ORDER BY id FOR UPDATE
`;
const verifications = [];
for (const attachment of attachments) {
const verification = await tx.paymentReceiptVerification.findFirst({
where: { attachment_id: attachment.id },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: { attachment: true, matches: true },
});
if (!verification || verification.status !== "matched" || verification.attachment.archived_at ||
verification.attachment.governance_status !== "approved" ||
verification.source_sha256 !== verification.attachment.file_sha256 ||
verification.source_version !== verification.attachment.receipt_ocr_version) continue;
requireFact(verification.attachment.payment_batch_id === batch.id, "receipt_batch_mismatch");
const recognition = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted));
const rebuilt = buildVerificationData(config, batch, verification.attachment, recognition);
requireFact(rebuilt.status === "matched", "receipt_match_changed");
requireFact(verification.pages_expected === recognition.pagesExpected &&
verification.pages_processed === recognition.pagesProcessed, "receipt_pages_changed");
const expected = rebuilt.matches.create;
requireFact(expected.length === verification.matches.length &&
expected.every((match) => verification.matches.some((saved) =>
["page_number", "item_id", "fingerprint", "key_id"].every((key) => match[key] === saved[key]))),
"receipt_match_changed");
verifications.push(verification);
}
const { loadApprovedManualReceiptEvidence } = require("./paymentReceiptManualPersistence");
verifications.push(...await loadApprovedManualReceiptEvidence(tx, config, batch, attachments.map((entry) => entry.id), readOnly));
const claims = [];
for (const item of pending) {
const options = verifications.flatMap((verification) => verification.matches
.filter((match) => match.item_id === item.id)
.map((match) => ({ ...match, aliases: match.aliases ?? [match.fingerprint],
verification_id: verification.id, manual_proposal_id: verification.manual_proposal_id ?? null })));
requireFact(options.length > 0, "receipt_verification_required");
requireFact(new Set(options.map((option) => option.fingerprint)).size === 1, "receipt_multiple_transactions");
requireFact(new Set(options.map((option) => JSON.stringify(option.aliases))).size === 1, "receipt_multiple_transactions");
const match = options[0];
requireFact(match.key_id === keyId, "receipt_key_rotation_required");
const existing = await tx.paymentReceiptClaim.findMany({
where: { OR: [{ fingerprint: { in: match.aliases } }, { item_id: item.id }] },
});
requireFact(existing.every((claim) => claim.fingerprint === match.fingerprint && claim.item_id === item.id),
"receipt_transaction_reused");
const existingAliases = await tx.paymentReceiptTransactionAlias.findMany({
where: { fingerprint: { in: match.aliases } },
});
requireFact(existingAliases.every((alias) => alias.claim_fingerprint === match.fingerprint && alias.key_id === keyId),
"receipt_transaction_reused");
claims.push({ match, exists: existing.length > 0, existingAliases });
}
requireFact(new Set(claims.map(({ match }) => match.fingerprint)).size === claims.length,
"receipt_transaction_reused");
const aliasOwners = new Map();
for (const { match } of claims) {
for (const alias of match.aliases) {
requireFact(!aliasOwners.has(alias) || aliasOwners.get(alias) === match.item_id, "receipt_transaction_reused");
aliasOwners.set(alias, match.item_id);
}
}
if (readOnly) return;
for (const { match, exists, existingAliases } of claims) {
if (!exists) {
await tx.paymentReceiptClaim.create({ data: {
fingerprint: match.fingerprint, key_id: match.key_id,
item_id: match.item_id, verification_id: match.verification_id, manual_proposal_id: match.manual_proposal_id,
} });
}
for (const fingerprint of match.aliases) {
if (!existingAliases.some((alias) => alias.fingerprint === fingerprint)) {
await tx.paymentReceiptTransactionAlias.create({ data: {
fingerprint, key_id: keyId, claim_fingerprint: match.fingerprint,
} });
}
}
}
}
async function storeReceiptRecognition(tx, config, { batchId, attachmentId, sourceSha256, expectedVersion, recognition }) {
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id = ${batchId}::uuid FOR UPDATE`;
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
requireFact(batch && ["reviewed", "failed"].includes(batch.status), "receipt_batch_not_editable");
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id = ${attachmentId}::uuid FOR UPDATE`;
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
requireFact(attachment && attachment.file_sha256 === sourceSha256, "receipt_source_changed");
requireFact(Number.isInteger(expectedVersion) && expectedVersion >= 0 &&
attachment.receipt_ocr_version === expectedVersion, "receipt_job_superseded");
const data = buildVerificationData(config, batch, attachment, recognition);
return tx.paymentReceiptVerification.create({ data, select: { id: true, status: true } });
}
function canRetryReceiptRecognition(attachment, now = new Date()) {
if (attachment.archived_at || attachment.governance_status !== "approved") return false;
const timedOut = attachment.receipt_ocr_state === "processing" && attachment.receipt_ocr_started_at &&
now.getTime() - new Date(attachment.receipt_ocr_started_at).getTime() >= 15 * 60 * 1000;
return Boolean(timedOut || ["legacy", "ocr_failed", "needs_review", "manual_rejected"].includes(attachment.receipt_ocr_state));
}
async function queueReceiptRetry(tx, { batchId, attachmentId, expectedVersion, now = new Date() }) {
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id = ${attachmentId}::uuid FOR UPDATE`;
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
requireFact(attachment && attachment.payment_batch_id === batchId && !attachment.archived_at &&
attachment.governance_status === "approved", "receipt_unavailable");
requireFact(Number.isInteger(expectedVersion) && attachment.receipt_ocr_version === expectedVersion,
"receipt_job_superseded");
requireFact(canRetryReceiptRecognition(attachment, now), "receipt_retry_not_ready");
return tx.paymentBatchReceiptAttachment.update({
where: { id: attachmentId },
data: { receipt_ocr_state: "queued", receipt_ocr_version: { increment: 1 }, receipt_ocr_started_at: null },
select: { id: true, receipt_ocr_state: true, receipt_ocr_version: true },
});
}
module.exports = { ReceiptVerificationError, buildVerificationData, claimVerifiedReceipts, transactionFingerprint, storeReceiptRecognition, queueReceiptRetry, canRetryReceiptRecognition, batchCandidates };