fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -1,5 +1,6 @@
|
||||
const ADVISORY_LOCK_NAMESPACES = Object.freeze({
|
||||
paymentBatchDailySequence: 1001,
|
||||
paymentReceiptClaims: 1002,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -70,6 +70,15 @@ const ERROR_REASONS = [
|
||||
{"code":"payment_batch.invalid_identifier","domain":"payment_batch","audience":"user","defaultMessage":"付款记录标识已失效,请刷新后重新选择。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.invalid_input","domain":"payment_batch","audience":"user","defaultMessage":"付款操作内容不完整或格式不正确,请核对页面必填项后重试。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.bank_receipt_required","domain":"payment_batch","audience":"user","defaultMessage":"银行付款登记前必须上传有效银行回单,请先补充回单。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.receipt_retry_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"回单状态已变化或仍在识别,请刷新后查看;处理中超过15分钟仍未完成时可重试。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.receipt_independent_review_required","domain":"payment_batch","audience":"user","defaultMessage":"回单人工核验需要独立审批人复核;制单人、核验人及报销归属人或提交人不能自审。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.receipt_transaction_reused","domain":"payment_batch","audience":"user","defaultMessage":"回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.receipt_manual_state_changed","domain":"payment_batch","audience":"user","defaultMessage":"回单或核验版本已变化,请刷新后重新查看当前状态。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.receipt_manual_verification_required","domain":"payment_batch","audience":"user","defaultMessage":"回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.receipt_key_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.receipt_key_rotation_required","domain":"payment_batch","audience":"user","defaultMessage":"回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.receipt_snapshot_unsupported","domain":"payment_batch","audience":"user","defaultMessage":"付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.receipt_identity_unresolved","domain":"payment_batch","audience":"user","defaultMessage":"无法完整确认报销参与人身份,请管理员核实人员关系后再复核;不能自行批准。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.department_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.source_not_found","domain":"payment_batch","audience":"user","defaultMessage":"部分来源单据已不存在或不可用,请刷新后重新选择。","recovery":"follow_guidance"},
|
||||
{"code":"payment_batch.daily_sequence_exhausted","domain":"payment_batch","audience":"user","defaultMessage":"今日付款批次编号已用完,请联系管理员处理,勿重复提交。","recovery":"follow_guidance"},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { startDelegationRevokeJob } = require("./delegationRevokeJob");
|
||||
const { startInvoiceArchiveJob } = require("./invoiceArchiveJob");
|
||||
const { startOcrJob } = require("./ocrJob");
|
||||
const { startReceiptOcrJob } = require("./paymentReceiptOcrJob");
|
||||
const { startIntegrationOutboxWorker } = require("./integrationOutboxWorker");
|
||||
const { startErpMasterDataSyncJob } = require("./erpMasterDataSyncJob");
|
||||
const { startPgBossJobs } = require("./pgBossJobs");
|
||||
@@ -18,6 +19,7 @@ async function startJobs(fastify) {
|
||||
|
||||
startDelegationRevokeJob(fastify);
|
||||
startOcrJob(fastify);
|
||||
startReceiptOcrJob(fastify);
|
||||
startInvoiceArchiveJob(fastify);
|
||||
startIntegrationOutboxWorker(fastify);
|
||||
startErpMasterDataSyncJob(fastify);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
const { createHash } = require("node:crypto");
|
||||
const { readFileBuffer } = require("../services/storage");
|
||||
const { recognizePaymentReceipt } = require("../services/paymentReceiptOcr");
|
||||
const { storeReceiptRecognition } = require("../services/paymentReceiptVerification");
|
||||
const { requiresBankReceipt } = require("../services/paymentProcessingProgress");
|
||||
const { resolveFieldEncryptionKey } = require("../services/crypto");
|
||||
|
||||
async function recordReceiptSystemFailure(prisma, attachment, state) {
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id = ${attachment.payment_batch_id}::uuid FOR UPDATE`;
|
||||
const batch = await tx.paymentBatch.findUnique({ where: { id: attachment.payment_batch_id } });
|
||||
if (!batch || !["reviewed", "failed"].includes(batch.status)) return { status: "discarded" };
|
||||
const result = await tx.paymentBatchReceiptAttachment.updateMany({
|
||||
where: { id: attachment.id, payment_batch_id: attachment.payment_batch_id,
|
||||
receipt_ocr_version: attachment.receipt_ocr_version, file_sha256: attachment.file_sha256,
|
||||
receipt_ocr_state: "processing", archived_at: null, governance_status: "approved" },
|
||||
data: { receipt_ocr_state: state },
|
||||
});
|
||||
return { status: result.count === 1 ? state : "discarded" };
|
||||
}, { maxWait: 5000, timeout: 10000 });
|
||||
} catch {
|
||||
return { status: "failure_recording_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async function runReceiptOcrTick(fastify) {
|
||||
try {
|
||||
const result = await runReceiptOcrOnce({ prisma: fastify.prisma, config: fastify.config });
|
||||
if (!["idle", "matched", "needs_review", "discarded"].includes(result.status)) {
|
||||
fastify.log.warn({ outcome: result.status }, "Receipt OCR requires attention");
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
fastify.log.warn({ outcome: "tick_failed" }, "Receipt OCR tick failed");
|
||||
return { status: "tick_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
function startReceiptOcrJob(fastify) {
|
||||
const cron = require("node-cron");
|
||||
let running = false;
|
||||
const task = cron.schedule(fastify.config.ocrPollCron, async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
try { await runReceiptOcrTick(fastify); }
|
||||
finally { running = false; }
|
||||
});
|
||||
fastify.addHook("onClose", async () => { task.stop(); task.destroy(); });
|
||||
return task;
|
||||
}
|
||||
|
||||
async function startReceiptPgBossJob(fastify, boss) {
|
||||
const queue = "payment.receipt.ocr";
|
||||
await boss.createQueue(queue, { retryLimit: 0, expireInSeconds: 900 });
|
||||
await boss.work(queue, { batchSize: 1 }, async () => runReceiptOcrTick(fastify));
|
||||
await boss.schedule(queue, fastify.config.ocrPollCron, null, {
|
||||
tz: fastify.config.appTimezone, singletonKey: queue, retryLimit: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function claimReceiptJob(prisma) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const batches = await tx.$queryRaw`
|
||||
SELECT batch.id FROM payment_batches batch
|
||||
WHERE batch.status IN ('reviewed', 'failed') AND EXISTS (
|
||||
SELECT 1 FROM payment_batch_receipt_attachments attachment
|
||||
WHERE attachment.payment_batch_id = batch.id
|
||||
AND attachment.receipt_ocr_state = 'queued'
|
||||
AND attachment.archived_at IS NULL AND attachment.governance_status = 'approved'
|
||||
) ORDER BY batch.id FOR UPDATE SKIP LOCKED LIMIT 1
|
||||
`;
|
||||
if (!batches.length) return null;
|
||||
const batch = await tx.paymentBatch.findUnique({ where: { id: batches[0].id }, include: { items: true } });
|
||||
if (batch?.entity_type !== "reimbursement" || !requiresBankReceipt(batch)) {
|
||||
await tx.paymentBatchReceiptAttachment.updateMany({
|
||||
where: { payment_batch_id: batches[0].id, receipt_ocr_state: "queued" },
|
||||
data: { receipt_ocr_state: "legacy" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const attachments = await tx.$queryRaw`
|
||||
SELECT id FROM payment_batch_receipt_attachments
|
||||
WHERE payment_batch_id = ${batches[0].id}::uuid AND receipt_ocr_state = 'queued'
|
||||
AND archived_at IS NULL AND governance_status = 'approved'
|
||||
ORDER BY created_at, id FOR UPDATE SKIP LOCKED LIMIT 1
|
||||
`;
|
||||
if (!attachments.length) return null;
|
||||
return tx.paymentBatchReceiptAttachment.update({
|
||||
where: { id: attachments[0].id },
|
||||
data: { receipt_ocr_state: "processing", receipt_ocr_started_at: new Date(),
|
||||
receipt_ocr_version: { increment: 1 } },
|
||||
});
|
||||
}, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function runReceiptOcrOnce({ prisma, config, readBuffer = readFileBuffer, recognize = recognizePaymentReceipt }) {
|
||||
const attachment = await claimReceiptJob(prisma);
|
||||
if (!attachment) return { status: "idle" };
|
||||
if (!resolveFieldEncryptionKey(config)) {
|
||||
return recordReceiptSystemFailure(prisma, attachment, "key_failed");
|
||||
}
|
||||
let recognition;
|
||||
try {
|
||||
if (attachment.file_size <= 0 || attachment.file_size > 5 * 1024 * 1024) throw new Error("size");
|
||||
const buffer = await readBuffer({ config, storagePath: attachment.storage_path });
|
||||
if (buffer.length !== attachment.file_size ||
|
||||
createHash("sha256").update(buffer).digest("hex") !== attachment.file_sha256) throw new Error("integrity");
|
||||
recognition = await recognize({ buffer, mimeType: attachment.mime_type, config });
|
||||
} catch {
|
||||
recognition = { status: "ocr_failed", pagesExpected: 0, pagesProcessed: 0, pages: [],
|
||||
reason: "receipt_processing_failed" };
|
||||
}
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const verification = await storeReceiptRecognition(tx, config, {
|
||||
batchId: attachment.payment_batch_id, attachmentId: attachment.id,
|
||||
sourceSha256: attachment.file_sha256, expectedVersion: attachment.receipt_ocr_version, recognition,
|
||||
});
|
||||
await tx.paymentBatchReceiptAttachment.update({
|
||||
where: { id: attachment.id },
|
||||
data: { receipt_ocr_state: recognition.status === "ocr_failed" ? "ocr_failed" : verification.status },
|
||||
});
|
||||
return { status: recognition.status === "ocr_failed" ? "ocr_failed" : verification.status };
|
||||
}, { timeout: 10000 });
|
||||
} catch (error) {
|
||||
if (["receipt_job_superseded", "receipt_source_changed", "receipt_unavailable", "receipt_batch_not_editable"]
|
||||
.includes(error.code)) return { status: "discarded" };
|
||||
const state = ["receipt_key_unavailable", "receipt_key_rotation_required"].includes(error.code) ? "key_failed" :
|
||||
["receipt_bank_unsupported", "receipt_business_unsupported"].includes(error.code) ? "snapshot_failed" : "system_failed";
|
||||
return recordReceiptSystemFailure(prisma, attachment, state);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { claimReceiptJob, runReceiptOcrOnce, runReceiptOcrTick, startReceiptOcrJob, startReceiptPgBossJob };
|
||||
@@ -1,4 +1,5 @@
|
||||
const PgBoss = require("pg-boss");
|
||||
const { startReceiptPgBossJob } = require("./paymentReceiptOcrJob");
|
||||
|
||||
const { revokeExpiredDelegations } = require("./delegationRevokeJob");
|
||||
const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
|
||||
@@ -45,6 +46,7 @@ async function startPgBossJobs(fastify) {
|
||||
});
|
||||
|
||||
await boss.createQueue(JOB_OCR_TICK);
|
||||
await startReceiptPgBossJob(fastify, boss);
|
||||
await boss.createQueue(JOB_OCR_INVOICE);
|
||||
await boss.createQueue(JOB_DELEGATION_REVOKE);
|
||||
await boss.createQueue(JOB_INVOICE_ARCHIVE);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { ERROR_REASONS } = require("../constants/errorReasons");
|
||||
const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification");
|
||||
const { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail } = require("../services/paymentReceiptManualPersistence");
|
||||
const { resolvePaymentBatchCreationReadiness, paymentReadinessError } = require("../services/paymentBatchReadiness");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
@@ -89,6 +91,51 @@ const REASON_SCHEMA = z.object({
|
||||
|
||||
const PAYMENT_ORDER_SCHEMA = REASON_SCHEMA.partial();
|
||||
|
||||
const RECEIPT_MANUAL_SCHEMA = z.object({
|
||||
expectedVersion: z.number().int().min(0).max(2147483646),
|
||||
reason: z.string().trim().min(1).max(500).optional(),
|
||||
pages: z.array(z.object({
|
||||
pageNumber: z.number().int().min(1).max(20),
|
||||
itemId: UUID_SCHEMA,
|
||||
fields: z.array(z.object({
|
||||
Name: z.string().max(64), Value: z.string().max(256),
|
||||
}).strict()).min(1).max(200).optional(),
|
||||
}).strict()).min(1).max(20),
|
||||
}).strict();
|
||||
|
||||
const RECEIPT_MANUAL_REVIEW_SCHEMA = z.object({
|
||||
expectedVersion: z.number().int().min(0).max(2147483647),
|
||||
decision: z.enum(["approve", "reject"]),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
|
||||
function mapManualReceiptError(error) {
|
||||
if (!(error instanceof ReceiptVerificationError)) throw error;
|
||||
if (error.code === "receipt_key_unavailable") {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_key_unavailable", {}, 409);
|
||||
}
|
||||
if (error.code === "receipt_key_rotation_required") {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_key_rotation_required", {}, 409);
|
||||
}
|
||||
if (["receipt_bank_unsupported", "receipt_business_unsupported"].includes(error.code)) {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_snapshot_unsupported", {}, 409);
|
||||
}
|
||||
if (error.code === "receipt_review_identity_unresolved") {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_identity_unresolved", {}, 409);
|
||||
}
|
||||
if (["receipt_review_permission_required", "receipt_review_identity_conflict"].includes(error.code)) {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_independent_review_required", {}, 403);
|
||||
}
|
||||
if (error.code === "receipt_transaction_reused") {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_transaction_reused", {}, 409);
|
||||
}
|
||||
if (["receipt_source_changed", "receipt_job_superseded", "receipt_unavailable",
|
||||
"receipt_manual_not_available", "receipt_manual_not_reviewable", "receipt_batch_not_editable"].includes(error.code)) {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_manual_state_changed", {}, 409);
|
||||
}
|
||||
throw buildBusinessValidationError("payment_batch.receipt_manual_verification_required", {}, 409);
|
||||
}
|
||||
|
||||
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
|
||||
mode: z.enum(["download", "preview"]).default("download"),
|
||||
reason: z.string().max(500).optional(),
|
||||
@@ -123,9 +170,14 @@ function normalizeNullableString(value) {
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function mapReceiptAttachment(item) {
|
||||
function mapReceiptAttachment(item, batch) {
|
||||
return {
|
||||
id: item.id,
|
||||
ocrState: item.receipt_ocr_state ?? "legacy",
|
||||
ocrVersion: item.receipt_ocr_version ?? 0,
|
||||
ocrStartedAt: item.receipt_ocr_started_at?.toISOString?.() ?? null,
|
||||
ocrRetryAvailable: batch?.entity_type === "reimbursement" && requiresBankReceipt(batch) &&
|
||||
canRetryReceiptRecognition(item),
|
||||
originalName: item.original_name,
|
||||
mimeType: item.mime_type,
|
||||
fileSize: item.file_size,
|
||||
@@ -1770,6 +1822,26 @@ async function paymentBatchesRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get("/payment-batches/:id/receipt-readiness", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureBatchManagePermission(fastify, request);
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const batchId = parseId(request.params?.id);
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
|
||||
const batch = await loadBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
if (!["reviewed", "failed"].includes(batch.status)) return { ready: false };
|
||||
try {
|
||||
if (batch.entity_type === "reimbursement") await claimVerifiedReceipts(tx, fastify.config, batch, { readOnly: true });
|
||||
return { ready: true };
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReceiptVerificationError)) throw error;
|
||||
return { ready: false };
|
||||
}
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, maxWait: 5000, timeout: 10000 });
|
||||
return sendData(reply, request, { item: result });
|
||||
});
|
||||
|
||||
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureBatchReadPermission(fastify, request);
|
||||
await ensureFeatureEnabled(fastify);
|
||||
@@ -1783,6 +1855,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
select: {
|
||||
id: true,
|
||||
original_name: true,
|
||||
receipt_ocr_state: true,
|
||||
receipt_ocr_version: true,
|
||||
receipt_ocr_started_at: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
@@ -1807,7 +1882,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, { items: items.map(mapReceiptAttachment) });
|
||||
return sendData(reply, request, { items: items.map((item) => mapReceiptAttachment(item, batch)) });
|
||||
});
|
||||
|
||||
fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -1857,6 +1932,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
file_size: file.fileSize,
|
||||
file_sha256: file.fileSha256,
|
||||
uploaded_by: request.employee.id,
|
||||
receipt_ocr_state: lockedBatch.entity_type === "reimbursement" && requiresBankReceipt(lockedBatch) ? "queued" : "legacy",
|
||||
...governanceCreateData({}, {
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
|
||||
@@ -1867,6 +1943,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
select: {
|
||||
id: true,
|
||||
original_name: true,
|
||||
receipt_ocr_state: true,
|
||||
receipt_ocr_version: true,
|
||||
receipt_ocr_started_at: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
@@ -1905,7 +1984,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
},
|
||||
}),
|
||||
});
|
||||
return created;
|
||||
return created.map((item) => mapReceiptAttachment(item, lockedBatch));
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -1918,7 +1997,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(reply, request, { items: createdItems.map(mapReceiptAttachment) });
|
||||
return sendData(reply, request, { items: createdItems });
|
||||
});
|
||||
|
||||
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -2106,6 +2185,95 @@ async function paymentBatchesRoutes(fastify) {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_void_required", { receiptAttachment: "void_required" }, 409);
|
||||
});
|
||||
|
||||
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureBatchReadPermission(fastify, request);
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const batchId = parseId(request.params?.id);
|
||||
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
||||
try {
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
|
||||
const batch = await loadBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
return readManualReceiptDetail(tx, fastify.config, { batchId, attachmentId, reviewer: request.employee,
|
||||
hasReviewPermission: fastify.hasPermission(request.employee, "payment.batch.review") });
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, maxWait: 5000, timeout: 10000 });
|
||||
return sendData(reply, request, { item: result });
|
||||
} catch (error) {
|
||||
mapManualReceiptError(error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureBatchManagePermission(fastify, request);
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const batchId = parseId(request.params?.id);
|
||||
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
||||
const body = parseBody(RECEIPT_MANUAL_SCHEMA, request.body);
|
||||
try {
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const batch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
return saveManualReceiptProposal(tx, fastify.config, request, { ...body, batchId, attachmentId });
|
||||
}, { timeout: 10000 });
|
||||
return sendData(reply, request, { item: result });
|
||||
} catch (error) {
|
||||
mapManualReceiptError(error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification/:proposalId/review", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureBatchReviewPermission(fastify, request);
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const batchId = parseId(request.params?.id);
|
||||
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
||||
const proposalId = parseId(request.params?.proposalId, "proposalId");
|
||||
const body = parseBody(RECEIPT_MANUAL_REVIEW_SCHEMA, request.body);
|
||||
try {
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const batch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
return reviewManualReceiptProposal(tx, fastify.config, request, { ...body, batchId, attachmentId, proposalId },
|
||||
fastify.hasPermission(request.employee, "payment.batch.review"));
|
||||
}, { timeout: 10000 });
|
||||
return sendData(reply, request, { item: result });
|
||||
} catch (error) {
|
||||
mapManualReceiptError(error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/ocr-retry", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureBatchManagePermission(fastify, request);
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const batchId = parseId(request.params?.id);
|
||||
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
||||
const { expectedVersion } = parseBody(z.object({ expectedVersion: z.number().int().min(0).max(2147483646) }).strict(), request.body);
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const batch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
if (batch.entity_type !== "reimbursement" || !requiresBankReceipt(batch)) {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_retry_unavailable", {}, 409);
|
||||
}
|
||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_not_editable", {}, 409);
|
||||
}
|
||||
let queued;
|
||||
try {
|
||||
queued = await queueReceiptRetry(tx, { batchId, attachmentId, expectedVersion });
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReceiptVerificationError)) throw error;
|
||||
throw buildBusinessValidationError("payment_batch.receipt_retry_unavailable", {}, 409);
|
||||
}
|
||||
await tx.auditLog.create({ data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id, action: "PAYMENT_BATCH_RECEIPT_OCR_REQUEUED",
|
||||
targetType: "payment_batch", targetId: batchId,
|
||||
extra: { attachmentId, version: queued.receipt_ocr_version },
|
||||
}) });
|
||||
return { id: queued.id, ocrState: queued.receipt_ocr_state, ocrVersion: queued.receipt_ocr_version };
|
||||
}, { timeout: 10000 });
|
||||
return sendData(reply, request, { item: result });
|
||||
});
|
||||
|
||||
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/void", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureBatchManagePermission(fastify, request);
|
||||
await ensureFeatureEnabled(fastify);
|
||||
@@ -2346,6 +2514,13 @@ async function paymentBatchesRoutes(fastify) {
|
||||
);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
await ensureBankReceiptForExecutableBatch(tx, batch);
|
||||
if (batch.entity_type === "reimbursement" && requiresBankReceipt(batch)) {
|
||||
try {
|
||||
await claimVerifiedReceipts(tx, fastify.config, batch);
|
||||
} catch (error) {
|
||||
mapManualReceiptError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const finalItemStatuses = new Map(batch.items.map((item) => [item.id, item.status]));
|
||||
let attemptSuccessCount = 0;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
const { ReceiptVerificationError, batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
|
||||
const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("./paymentReceiptManualReview");
|
||||
const { encryptStringOrThrow, decryptStringOrThrow } = require("./crypto");
|
||||
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { normalizeBankSlip } = require("./paymentReceiptMatching");
|
||||
const { maskBankAccount } = require("./crypto");
|
||||
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
|
||||
|
||||
function requireFact(condition, code) {
|
||||
if (!condition) throw new ReceiptVerificationError(code);
|
||||
}
|
||||
|
||||
async function lockContext(tx, { batchId, attachmentId, expectedVersion }) {
|
||||
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?.entity_type === "reimbursement" && ["reviewed", "failed"].includes(batch.status),
|
||||
"receipt_batch_not_editable");
|
||||
await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
|
||||
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?.payment_batch_id === batchId && !attachment.archived_at &&
|
||||
attachment.governance_status === "approved", "receipt_unavailable");
|
||||
requireFact(Number.isInteger(expectedVersion) && expectedVersion >= 0 && expectedVersion <= 2147483647 &&
|
||||
attachment.receipt_ocr_version === expectedVersion, "receipt_job_superseded");
|
||||
return { batch, attachment };
|
||||
}
|
||||
|
||||
async function ensureUnused(tx, config, proposal) {
|
||||
const current = proposal.recognition.pages.map((page) => transactionFingerprint(config, page));
|
||||
requireFact(await tx.paymentReceiptClaim.count({ where: { key_id: { not: current[0].key_id } } }) === 0,
|
||||
"receipt_key_rotation_required");
|
||||
requireFact(await tx.paymentReceiptClaim.count({ where: { OR: [
|
||||
{ fingerprint: { in: [...proposal.originalFingerprints, ...current.map((entry) => entry.fingerprint)] } },
|
||||
{ item_id: { in: proposal.associations.map((entry) => entry.itemId) } },
|
||||
] } }) === 0, "receipt_transaction_reused");
|
||||
requireFact(await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: current[0].key_id } } }) === 0,
|
||||
"receipt_key_rotation_required");
|
||||
requireFact(await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint: {
|
||||
in: [...proposal.originalFingerprints, ...current.map((entry) => entry.fingerprint)],
|
||||
} } }) === 0, "receipt_transaction_reused");
|
||||
}
|
||||
|
||||
async function loadApprovedManualReceiptEvidence(tx, config, batch, attachmentIds, readOnly = false) {
|
||||
const proposals = await tx.paymentReceiptManualProposal.findMany({
|
||||
where: { attachment_id: { in: attachmentIds }, status: { in: ["approved", "association_ready"] } },
|
||||
include: { attachment: true, original_verification: true },
|
||||
});
|
||||
const evidence = [];
|
||||
for (const saved of proposals) {
|
||||
const attachment = saved.attachment;
|
||||
if (attachment.archived_at || attachment.governance_status !== "approved" ||
|
||||
attachment.receipt_ocr_version !== saved.source_version || attachment.file_sha256 !== saved.source_sha256 ||
|
||||
!["manual_approved", "association_ready"].includes(attachment.receipt_ocr_state)) continue;
|
||||
requireFact(attachment.payment_batch_id === batch.id, "receipt_batch_mismatch");
|
||||
const original = saved.original_verification;
|
||||
requireFact(original.attachment_id === attachment.id && original.source_sha256 === saved.source_sha256 &&
|
||||
original.source_version + 1 === saved.source_version, "receipt_source_changed");
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
|
||||
const payload = JSON.parse(decryptStringOrThrow(config, saved.payload_encrypted));
|
||||
const proposal = buildManualReceiptProposal(config, {
|
||||
original: recognition, pages: safePages(payload.pages), candidates: batchCandidates(config, batch),
|
||||
});
|
||||
if (proposal.requiresIndependentReview) {
|
||||
requireFact(saved.status === "approved" && saved.reviewed_by && saved.reviewed_at, "receipt_review_permission_required");
|
||||
if (!readOnly) {
|
||||
await tx.$queryRaw`SELECT id FROM employees WHERE id = ${saved.reviewed_by}::uuid FOR SHARE`;
|
||||
await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id = ${saved.reviewed_by}::uuid FOR SHARE`;
|
||||
}
|
||||
const employee = await tx.employee.findUnique({ where: { id: saved.reviewed_by }, include: { roles: true } });
|
||||
requireFact(employee?.status === "active" && !employee.archived_at, "receipt_review_permission_required");
|
||||
const roleCodes = employee.roles.map((role) => role.role_code);
|
||||
const sources = await tx.reimbursementForm.findMany({
|
||||
where: { id: { in: batch.items.map((item) => item.entity_id) } },
|
||||
select: { id: true, owner_id: true, submitter_id: true },
|
||||
});
|
||||
requireFact(sources.length === new Set(batch.items.map((item) => item.entity_id)).size,
|
||||
"receipt_review_identity_unresolved");
|
||||
assertIndependentReceiptReviewer({ reviewer: { id: employee.id, roleCodes },
|
||||
hasReviewPermission: hasPermission(resolvePermissionsForRoles(roleCodes), "payment.batch.review"),
|
||||
makerId: batch.created_by, proposedBy: saved.proposed_by,
|
||||
participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) });
|
||||
} else {
|
||||
requireFact(saved.status === "association_ready", "receipt_match_changed");
|
||||
}
|
||||
evidence.push({ id: original.id, manual_proposal_id: saved.id, matches: proposal.recognition.pages.map((page) => {
|
||||
const identity = transactionFingerprint(config, page);
|
||||
const previous = recognition.pages.find((entry) => entry.pageNumber === page.pageNumber);
|
||||
const aliases = [identity.fingerprint];
|
||||
if (previous?.payerAccount && previous.reference) aliases.push(transactionFingerprint(config, previous).fingerprint);
|
||||
return { ...identity, page_number: page.pageNumber,
|
||||
item_id: proposal.associations.find((entry) => entry.pageNumber === page.pageNumber).itemId,
|
||||
aliases: [...new Set(aliases)].sort() };
|
||||
}) });
|
||||
}
|
||||
return evidence;
|
||||
}
|
||||
|
||||
function safePages(pages) {
|
||||
requireFact(Array.isArray(pages) && pages.length > 0 && pages.length <= 20, "receipt_pages_unresolved");
|
||||
return pages.map((page) => {
|
||||
requireFact(Array.isArray(page?.fields) && page.fields.length > 0 && page.fields.length <= 200 &&
|
||||
page.fields.every((field) => typeof field?.Name === "string" && field.Name.length <= 64 &&
|
||||
typeof field.Value === "string" && field.Value.length <= 256), "receipt_manual_fields_invalid");
|
||||
return { pageNumber: page.pageNumber, itemId: page.itemId,
|
||||
fields: page.fields.map(({ Name, Value }) => ({ Name, Value })) };
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePageFields(pages, original) {
|
||||
requireFact(Array.isArray(pages), "receipt_pages_unresolved");
|
||||
return pages.map((page) => {
|
||||
const source = original.pages?.find((entry) => entry.pageNumber === page?.pageNumber);
|
||||
requireFact(source || page?.fields, "receipt_manual_fields_invalid");
|
||||
const minor = source?.amount;
|
||||
const amount = typeof minor === "string" && /^\d+$/.test(minor) ?
|
||||
`${BigInt(minor) / 100n}.${(BigInt(minor) % 100n).toString().padStart(2, "0")}` : "";
|
||||
const values = {
|
||||
小写金额: amount, 币别: source?.currency ?? "", 付款人账号: source?.payerAccount ?? "",
|
||||
收款人账号: source?.payeeAccount ?? "", 凭证号: source?.reference ?? "",
|
||||
单据状态: source?.transactionStatus === "success" ? "交易成功" : "",
|
||||
};
|
||||
if (page.fields !== undefined) {
|
||||
requireFact(Array.isArray(page.fields) && page.fields.length <= 7 &&
|
||||
new Set(page.fields.map((field) => field?.Name)).size === page.fields.length &&
|
||||
page.fields.every((field) => Object.hasOwn(values, field?.Name) && typeof field.Value === "string" &&
|
||||
field.Value.length <= 256), "receipt_manual_fields_invalid");
|
||||
for (const field of page.fields) values[field.Name] = field.Value;
|
||||
}
|
||||
return { pageNumber: page.pageNumber, itemId: page.itemId,
|
||||
fields: Object.entries(values).map(([Name, Value]) => ({ Name, Value })) };
|
||||
});
|
||||
}
|
||||
|
||||
function pageSummary(page) {
|
||||
const amount = typeof page.amount === "string" && /^\d+$/.test(page.amount) ?
|
||||
`${BigInt(page.amount) / 100n}.${(BigInt(page.amount) % 100n).toString().padStart(2, "0")}` : null;
|
||||
return { pageNumber: page.pageNumber, amount, currency: page.currency ?? null,
|
||||
payerAccountMasked: page.payerAccount ? maskBankAccount(page.payerAccount) : null,
|
||||
payeeAccountMasked: page.payeeAccount ? maskBankAccount(page.payeeAccount) : null,
|
||||
transactionStatus: page.transactionStatus ?? null, reference: page.reference ?? null };
|
||||
}
|
||||
|
||||
async function readManualReceiptDetail(tx, config, { batchId, attachmentId, reviewer, hasReviewPermission }) {
|
||||
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
|
||||
requireFact(attachment?.payment_batch_id === batchId && !attachment.archived_at &&
|
||||
attachment.governance_status === "approved", "receipt_unavailable");
|
||||
const proposal = await tx.paymentReceiptManualProposal.findUnique({
|
||||
where: { attachment_id_source_version: { attachment_id: attachmentId, source_version: attachment.receipt_ocr_version } },
|
||||
include: { original_verification: true },
|
||||
});
|
||||
const original = proposal?.original_verification ?? await tx.paymentReceiptVerification.findFirst({
|
||||
where: { attachment_id: attachmentId }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
});
|
||||
if (!original) return { version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
|
||||
pagesExpected: 0, pages: [], proposal: null };
|
||||
requireFact(original.attachment_id === attachmentId && original.source_sha256 === attachment.file_sha256 &&
|
||||
original.source_version + (proposal ? 1 : 0) === attachment.receipt_ocr_version &&
|
||||
(!proposal || proposal.source_sha256 === attachment.file_sha256), "receipt_source_changed");
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
|
||||
const payload = proposal ? JSON.parse(decryptStringOrThrow(config, proposal.payload_encrypted)) : null;
|
||||
let canReview = false;
|
||||
if (proposal?.status === "pending_review" && reviewer) {
|
||||
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
|
||||
const sources = await tx.reimbursementForm.findMany({
|
||||
where: { id: { in: batch.items.map((item) => item.entity_id) } },
|
||||
select: { id: true, owner_id: true, submitter_id: true },
|
||||
});
|
||||
if (["reviewed", "failed"].includes(batch.status) &&
|
||||
sources.length === new Set(batch.items.map((item) => item.entity_id)).size) {
|
||||
try {
|
||||
assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId: batch.created_by,
|
||||
proposedBy: proposal.proposed_by,
|
||||
participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) });
|
||||
canReview = true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReceiptVerificationError)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
canReview,
|
||||
version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
|
||||
pagesExpected: recognition.pagesExpected, pages: recognition.pages.map(pageSummary),
|
||||
proposal: proposal ? { id: proposal.id, status: proposal.status, proposedBy: proposal.proposed_by,
|
||||
reviewedBy: proposal.reviewed_by, reason: payload.reason, changedFields: payload.changedFields,
|
||||
pages: payload.pages.map((page) => ({
|
||||
...pageSummary({ pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) }), itemId: page.itemId,
|
||||
})) } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(proposal) {
|
||||
return { id: proposal.id, status: proposal.status, version: proposal.source_version };
|
||||
}
|
||||
|
||||
async function audit(tx, request, batchId, action, proposal) {
|
||||
await tx.auditLog.create({ data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id, action, targetType: "payment_batch", targetId: batchId,
|
||||
extra: { proposalId: proposal.id, attachmentId: proposal.attachment_id,
|
||||
version: proposal.source_version, status: proposal.status },
|
||||
}) });
|
||||
}
|
||||
|
||||
async function saveManualReceiptProposal(tx, config, request, input) {
|
||||
const { batch, attachment } = await lockContext(tx, input);
|
||||
requireFact(attachment.receipt_ocr_version < 2147483647, "receipt_job_superseded");
|
||||
requireFact(["needs_review", "ocr_failed"].includes(attachment.receipt_ocr_state),
|
||||
"receipt_manual_not_available");
|
||||
const original = await tx.paymentReceiptVerification.findFirst({
|
||||
where: { attachment_id: attachment.id }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
});
|
||||
requireFact(original && original.source_version === attachment.receipt_ocr_version &&
|
||||
original.source_sha256 === attachment.file_sha256, "receipt_source_changed");
|
||||
const originalRecognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
|
||||
const pages = safePages(resolvePageFields(input.pages, originalRecognition));
|
||||
const proposal = buildManualReceiptProposal(config, {
|
||||
original: originalRecognition,
|
||||
pages, candidates: batchCandidates(config, batch),
|
||||
});
|
||||
requireFact(proposal.associations.every((association) => batch.items.some((item) =>
|
||||
item.id === association.itemId && ["pending", "failed"].includes(item.status))), "receipt_item_unavailable");
|
||||
await ensureUnused(tx, config, proposal);
|
||||
requireFact(!proposal.requiresIndependentReview || (typeof input.reason === "string" &&
|
||||
input.reason.trim().length > 0 && input.reason.length <= 500), "receipt_manual_reason_required");
|
||||
const status = proposal.requiresIndependentReview ? "pending_review" : "association_ready";
|
||||
const saved = await tx.paymentReceiptManualProposal.create({ data: {
|
||||
attachment_id: attachment.id, original_verification_id: original.id,
|
||||
source_sha256: attachment.file_sha256, source_version: attachment.receipt_ocr_version + 1,
|
||||
status, proposed_by: request.employee.id,
|
||||
payload_encrypted: encryptStringOrThrow(config, JSON.stringify({
|
||||
pages, reason: proposal.requiresIndependentReview ? input.reason.trim() : "明细关联",
|
||||
reasonSource: proposal.requiresIndependentReview ? "user" : "system", changedFields: proposal.changedFields,
|
||||
})),
|
||||
} });
|
||||
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: {
|
||||
receipt_ocr_version: { increment: 1 }, receipt_ocr_state: status, receipt_ocr_started_at: null,
|
||||
} });
|
||||
await audit(tx, request, batch.id, "PAYMENT_RECEIPT_MANUAL_PROPOSED", saved);
|
||||
return summarize(saved);
|
||||
}
|
||||
|
||||
async function reviewManualReceiptProposal(tx, config, request, input, hasReviewPermission) {
|
||||
const { batch, attachment } = await lockContext(tx, input);
|
||||
const saved = await tx.paymentReceiptManualProposal.findUnique({ where: { id: input.proposalId },
|
||||
include: { original_verification: true } });
|
||||
requireFact(saved?.attachment_id === attachment.id && saved.source_version === attachment.receipt_ocr_version &&
|
||||
saved.source_sha256 === attachment.file_sha256, "receipt_source_changed");
|
||||
requireFact(saved.status === "pending_review" && attachment.receipt_ocr_state === "pending_review",
|
||||
"receipt_manual_not_reviewable");
|
||||
const sources = await tx.reimbursementForm.findMany({
|
||||
where: { id: { in: batch.items.map((item) => item.entity_id) } },
|
||||
select: { id: true, owner_id: true, submitter_id: true },
|
||||
});
|
||||
requireFact(sources.length === new Set(batch.items.map((item) => item.entity_id)).size,
|
||||
"receipt_review_identity_unresolved");
|
||||
assertIndependentReceiptReviewer({ reviewer: request.employee, hasReviewPermission,
|
||||
makerId: batch.created_by, proposedBy: saved.proposed_by,
|
||||
participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) });
|
||||
requireFact(["approve", "reject"].includes(input.decision), "receipt_manual_decision_invalid");
|
||||
requireFact(typeof input.reason === "string" && input.reason.trim().length > 0 &&
|
||||
input.reason.length <= 500, "receipt_manual_reason_required");
|
||||
if (input.decision === "approve") {
|
||||
const original = saved.original_verification;
|
||||
requireFact(original.attachment_id === attachment.id && original.source_sha256 === saved.source_sha256 &&
|
||||
original.source_version + 1 === saved.source_version, "receipt_source_changed");
|
||||
const payload = JSON.parse(decryptStringOrThrow(config, saved.payload_encrypted));
|
||||
const proposal = buildManualReceiptProposal(config, {
|
||||
original: JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted)),
|
||||
pages: safePages(payload.pages), candidates: batchCandidates(config, batch),
|
||||
});
|
||||
requireFact(proposal.associations.every((association) => batch.items.some((item) =>
|
||||
item.id === association.itemId && ["pending", "failed"].includes(item.status))), "receipt_item_unavailable");
|
||||
await ensureUnused(tx, config, proposal);
|
||||
}
|
||||
const status = input.decision === "approve" ? "approved" : "rejected";
|
||||
const reviewed = await tx.paymentReceiptManualProposal.update({ where: { id: saved.id }, data: {
|
||||
status, reviewed_by: request.employee.id, reviewed_at: new Date(),
|
||||
review_note_encrypted: encryptStringOrThrow(config, input.reason.trim()),
|
||||
} });
|
||||
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: {
|
||||
receipt_ocr_state: status === "approved" ? "manual_approved" : "manual_rejected",
|
||||
} });
|
||||
await audit(tx, request, batch.id, "PAYMENT_RECEIPT_MANUAL_REVIEWED", reviewed);
|
||||
return summarize(reviewed);
|
||||
}
|
||||
|
||||
module.exports = { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail, loadApprovedManualReceiptEvidence };
|
||||
@@ -0,0 +1,80 @@
|
||||
const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching");
|
||||
const { ReceiptVerificationError, transactionFingerprint } = require("./paymentReceiptVerification");
|
||||
|
||||
const FIELDS = ["amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference"];
|
||||
const BLOCKED_ISSUES = ["transaction_failed", "transaction_pending"];
|
||||
|
||||
function requireFact(condition, code) {
|
||||
if (!condition) throw new ReceiptVerificationError(code);
|
||||
}
|
||||
|
||||
function assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId, proposedBy, participants }) {
|
||||
requireFact(reviewer?.id && hasReviewPermission === true &&
|
||||
Array.isArray(reviewer.roleCodes) &&
|
||||
reviewer.roleCodes.some((role) => ["manager", "ceo"].includes(role)),
|
||||
"receipt_review_permission_required");
|
||||
requireFact(typeof makerId === "string" && makerId.length > 0 &&
|
||||
typeof proposedBy === "string" && proposedBy.length > 0 &&
|
||||
Array.isArray(participants) && participants.length > 0 &&
|
||||
participants.every((entry) => typeof entry?.ownerId === "string" && entry.ownerId.length > 0 &&
|
||||
typeof entry.submitterId === "string" && entry.submitterId.length > 0),
|
||||
"receipt_review_identity_unresolved");
|
||||
requireFact(![makerId, proposedBy, ...participants.flatMap((entry) => [entry.ownerId, entry.submitterId])]
|
||||
.includes(reviewer.id), "receipt_review_identity_conflict");
|
||||
}
|
||||
|
||||
function buildManualReceiptProposal(config, { original, pages, candidates }) {
|
||||
requireFact(original && Number.isInteger(original.pagesExpected) &&
|
||||
original.pagesExpected > 0 && original.pagesExpected <= 20, "receipt_pages_unresolved");
|
||||
requireFact(Array.isArray(pages) && pages.length === original.pagesExpected &&
|
||||
pages.every((page, index) => page?.pageNumber === index + 1), "receipt_pages_unresolved");
|
||||
requireFact(Array.isArray(candidates) && candidates.length > 0 &&
|
||||
candidates.every((candidate) => typeof candidate.id === "string" && candidate.id.length > 0) &&
|
||||
new Set(candidates.map((candidate) => candidate.id)).size === candidates.length,
|
||||
"receipt_candidates_invalid");
|
||||
requireFact(Array.isArray(original.pages) &&
|
||||
original.pages.every((page) => Number.isInteger(page.pageNumber) &&
|
||||
page.pageNumber > 0 && page.pageNumber <= original.pagesExpected) &&
|
||||
new Set(original.pages.map((page) => page.pageNumber)).size === original.pages.length,
|
||||
"receipt_original_invalid");
|
||||
const originalFingerprints = original.pages
|
||||
.filter((page) => page.payerAccount && page.reference)
|
||||
.map((page) => transactionFingerprint(config, page).fingerprint);
|
||||
requireFact(new Set(originalFingerprints).size === originalFingerprints.length,
|
||||
"receipt_transaction_reused");
|
||||
const normalizedPages = [];
|
||||
const associations = [];
|
||||
const changedFields = [];
|
||||
const fingerprints = new Set();
|
||||
let requiresIndependentReview = original.status !== "recognized" ||
|
||||
original.pagesProcessed !== original.pagesExpected ||
|
||||
original.pages.length !== original.pagesExpected;
|
||||
for (const page of pages) {
|
||||
const previous = original.pages.find((entry) => entry.pageNumber === page.pageNumber);
|
||||
requireFact(!previous || (!["failed", "pending"].includes(previous.transactionStatus) &&
|
||||
!(previous.issues ?? []).some((issue) => BLOCKED_ISSUES.includes(issue))),
|
||||
"receipt_transaction_not_successful");
|
||||
const normalized = { pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) };
|
||||
const candidate = candidates.find((entry) => entry.id === page.itemId);
|
||||
requireFact(candidate, "receipt_item_unavailable");
|
||||
const match = matchBankSlip(normalized, [candidate]);
|
||||
requireFact(match.status === "matched", "receipt_manual_match_required");
|
||||
const changed = FIELDS.filter((field) => previous?.[field] !== normalized[field]);
|
||||
if (changed.length || previous?.issues?.length) requiresIndependentReview = true;
|
||||
const { fingerprint } = transactionFingerprint(config, normalized);
|
||||
requireFact(!fingerprints.has(fingerprint), "receipt_transaction_reused");
|
||||
fingerprints.add(fingerprint);
|
||||
normalizedPages.push(normalized);
|
||||
associations.push({ pageNumber: page.pageNumber, itemId: candidate.id });
|
||||
changedFields.push({ pageNumber: page.pageNumber, fields: changed });
|
||||
}
|
||||
requireFact(new Set(associations.map((entry) => entry.itemId)).size === associations.length,
|
||||
"receipt_multiple_transactions");
|
||||
return {
|
||||
requiresIndependentReview, changedFields, associations, originalFingerprints, bankVerified: false,
|
||||
recognition: { status: "recognized", pagesExpected: pages.length,
|
||||
pagesProcessed: pages.length, pages: normalizedPages },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildManualReceiptProposal, assertIndependentReceiptReviewer };
|
||||
@@ -0,0 +1,105 @@
|
||||
const FIELD_ALIASES = {
|
||||
amount: ["小写金额", "交易金额", "转账金额", "付款金额", "金额"],
|
||||
currency: ["币别", "币种"],
|
||||
payerAccount: ["付款人账号", "付款账号"],
|
||||
payeeAccount: ["收款人账号", "收款账号"],
|
||||
transactionStatus: ["单据状态", "交易状态"],
|
||||
reference: ["流水号", "交易流水号", "凭证号", "凭证号码"],
|
||||
};
|
||||
|
||||
function normalizeText(value) {
|
||||
return typeof value === "string" ? value.normalize("NFKC").trim() : "";
|
||||
}
|
||||
|
||||
function parseReceiptMinorUnits(value) {
|
||||
const text = normalizeText(value).replace(/^[¥¥]\s*/, "").replace(/\s*元$/, "");
|
||||
if (!/^(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d{1,2})?$/.test(text)) return null;
|
||||
const [whole, fraction = ""] = text.replace(/,/g, "").split(".");
|
||||
if (whole.length > 12) return null;
|
||||
return (BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"))).toString();
|
||||
}
|
||||
|
||||
function normalizeAccount(value) {
|
||||
const text = normalizeText(value).replace(/\s/g, "");
|
||||
return /^\d{6,40}$/.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function normalizeCurrency(value) {
|
||||
const text = normalizeText(value).toUpperCase();
|
||||
if (["人民币", "人民币元", "RMB", "CNY"].includes(text)) return "CNY";
|
||||
return /^[A-Z]{3}$/.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const text = normalizeText(value);
|
||||
if (["交易成功", "转账成功", "付款成功", "支付成功"].includes(text)) return "success";
|
||||
if (["交易失败", "转账失败", "付款失败", "支付失败", "已撤销", "已冲正", "已退汇"].includes(text)) return "failed";
|
||||
if (["已受理", "受理成功", "处理中", "待处理"].includes(text)) return "pending";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeBankSlip(fields) {
|
||||
const result = {
|
||||
amount: null, currency: null, payerAccount: null, payeeAccount: null,
|
||||
transactionStatus: null, reference: null, issues: [],
|
||||
};
|
||||
if (!Array.isArray(fields) || fields.length === 0 || fields.length > 200) {
|
||||
result.issues.push("response_invalid");
|
||||
return result;
|
||||
}
|
||||
const parsers = {
|
||||
amount: parseReceiptMinorUnits, currency: normalizeCurrency,
|
||||
payerAccount: normalizeAccount, payeeAccount: normalizeAccount,
|
||||
transactionStatus: normalizeStatus, reference: (value) => normalizeText(value) || null,
|
||||
};
|
||||
for (const [key, aliases] of Object.entries(FIELD_ALIASES)) {
|
||||
let entries = fields.filter((field) => aliases.includes(normalizeText(field?.Name)) && normalizeText(field?.Value));
|
||||
if (key === "reference" && entries.length) {
|
||||
const preferred = aliases.find((alias) => entries.some((field) => normalizeText(field.Name) === alias));
|
||||
entries = entries.filter((field) => normalizeText(field.Name) === preferred);
|
||||
}
|
||||
const names = entries.map((field) => normalizeText(field.Name));
|
||||
const values = entries.map((field) => parsers[key](field.Value));
|
||||
if (key === "transactionStatus") {
|
||||
if (values.includes("failed")) result.issues.push("transaction_failed");
|
||||
if (values.includes("pending")) result.issues.push("transaction_pending");
|
||||
}
|
||||
if (new Set(names).size !== names.length || new Set(values).size > 1) {
|
||||
result.issues.push(`${key}_ambiguous`);
|
||||
} else if (values.length && values[0] !== null) {
|
||||
result[key] = values[0];
|
||||
} else {
|
||||
result.issues.push(`${key}_unresolved`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function matchBankSlip(receipt, candidates) {
|
||||
const finish = (status, reasons, itemId = null) => ({ status, itemId, reasons, bankVerified: false });
|
||||
if (receipt.transactionStatus === "failed") return finish("mismatch", ["transaction_failed"]);
|
||||
const unresolved = [...receipt.issues];
|
||||
if (receipt.transactionStatus !== "success") unresolved.push("transaction_not_confirmed");
|
||||
if (unresolved.length) return finish("needs_review", [...new Set(unresolved)]);
|
||||
if (!Array.isArray(candidates) || !candidates.length) return finish("needs_review", ["candidates_missing"]);
|
||||
const comparisons = candidates.map((candidate) => {
|
||||
const expected = {
|
||||
amount: parseReceiptMinorUnits(candidate.amount), currency: normalizeCurrency(candidate.currency),
|
||||
payerAccount: normalizeAccount(candidate.payerAccount), payeeAccount: normalizeAccount(candidate.payeeAccount),
|
||||
};
|
||||
const invalid = !candidate.id || Object.values(expected).some((value) => value === null);
|
||||
const reasons = [];
|
||||
if (expected.amount !== receipt.amount) reasons.push("amount_mismatch");
|
||||
if (expected.currency !== receipt.currency) reasons.push("currency_mismatch");
|
||||
if (expected.payerAccount !== receipt.payerAccount) reasons.push("payer_account_mismatch");
|
||||
if (expected.payeeAccount !== receipt.payeeAccount) reasons.push("payee_account_mismatch");
|
||||
return { id: candidate.id, invalid, reasons };
|
||||
});
|
||||
if (comparisons.some((comparison) => comparison.invalid)) return finish("needs_review", ["candidate_invalid"]);
|
||||
const matches = comparisons.filter((comparison) => comparison.reasons.length === 0);
|
||||
if (matches.length === 1) return finish("matched", [], matches[0].id);
|
||||
if (matches.length > 1) return finish("needs_review", ["multiple_candidates"]);
|
||||
return finish("mismatch", [...new Set(comparisons.flatMap((comparison) => comparison.reasons))]);
|
||||
}
|
||||
|
||||
module.exports = { normalizeBankSlip, matchBankSlip, parseReceiptMinorUnits };
|
||||
@@ -0,0 +1,66 @@
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const sharp = require("sharp");
|
||||
const { normalizeBankSlip } = require("./paymentReceiptMatching");
|
||||
|
||||
function createReceiptOcrClient(config) {
|
||||
if (config?.ocrProvider && config.ocrProvider !== "tencent") return null;
|
||||
const Tencent = require("tencentcloud-sdk-nodejs");
|
||||
if (!config.tencentSecretId || !config.tencentSecretKey) return null;
|
||||
return new Tencent.ocr.v20181119.Client({
|
||||
credential: { secretId: config.tencentSecretId, secretKey: config.tencentSecretKey },
|
||||
region: config.tencentOcrRegion,
|
||||
profile: { httpProfile: { endpoint: "ocr.tencentcloudapi.com", reqTimeout: 30 } },
|
||||
});
|
||||
}
|
||||
|
||||
async function recognizePaymentReceipt({ buffer, mimeType, config, createClient = createReceiptOcrClient }) {
|
||||
const result = { status: "needs_review", pagesExpected: 0, pagesProcessed: 0, pages: [], reason: null };
|
||||
if (!Buffer.isBuffer(buffer) || !buffer.length || buffer.length > 5 * 1024 * 1024) {
|
||||
return { ...result, reason: "receipt_size_invalid" };
|
||||
}
|
||||
let payload = buffer;
|
||||
const isPdf = mimeType === "application/pdf";
|
||||
try {
|
||||
if (isPdf) {
|
||||
if (buffer.subarray(0, 5).toString() !== "%PDF-") throw new Error("invalid_pdf");
|
||||
const document = await PDFDocument.load(buffer);
|
||||
result.pagesExpected = document.getPageCount();
|
||||
if (result.pagesExpected < 1 || result.pagesExpected > 20) return { ...result, reason: "receipt_page_limit" };
|
||||
} else {
|
||||
if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
|
||||
return { ...result, reason: "receipt_format_unsupported" };
|
||||
}
|
||||
const image = sharp(buffer, { limitInputPixels: 40000000 });
|
||||
const metadata = await image.metadata();
|
||||
const expectedFormat = { "image/png": "png", "image/jpeg": "jpeg", "image/webp": "webp" }[mimeType];
|
||||
if (metadata.format !== expectedFormat || (metadata.pages ?? 1) !== 1) {
|
||||
return { ...result, reason: "receipt_image_invalid" };
|
||||
}
|
||||
payload = await image.rotate().png().toBuffer();
|
||||
result.pagesExpected = 1;
|
||||
}
|
||||
} catch {
|
||||
return { ...result, reason: "receipt_decode_failed" };
|
||||
}
|
||||
const encoded = payload.toString("base64");
|
||||
if (encoded.length > 7 * 1024 * 1024) return { ...result, reason: "receipt_encoded_size_limit" };
|
||||
let client;
|
||||
try {
|
||||
client = createClient(config);
|
||||
if (!client || typeof client.BankSlipOCR !== "function") {
|
||||
return { ...result, status: "ocr_failed", reason: "receipt_ocr_unavailable" };
|
||||
}
|
||||
for (let pageNumber = 1; pageNumber <= result.pagesExpected; pageNumber += 1) {
|
||||
const response = await client.BankSlipOCR({
|
||||
ImageBase64: encoded, IsPdf: isPdf, ...(isPdf ? { PdfPageNumber: pageNumber } : {}),
|
||||
});
|
||||
result.pages.push({ pageNumber, ...normalizeBankSlip(response?.BankSlipInfos) });
|
||||
result.pagesProcessed += 1;
|
||||
}
|
||||
} catch {
|
||||
return { ...result, status: "ocr_failed", reason: "receipt_ocr_request_failed" };
|
||||
}
|
||||
return { ...result, status: result.pages.every((page) => page.issues.length === 0) ? "recognized" : "needs_review" };
|
||||
}
|
||||
|
||||
module.exports = { recognizePaymentReceipt };
|
||||
@@ -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 };
|
||||
在新工单中引用
屏蔽一个用户