feat(server): 票据增强闭环(F9/F10/F11/F12/F13/F28)
- /invoice-pool 新增 unbind、PATCH 修正、manual archive、OCR retry、判重例外 override(含 VALID_004/006/007) - signed-url 签发/下载写入审计日志(脱敏 extra) - 新增未绑定票据自动归档作业 + 一次性执行脚本 - 回归脚本增强:workflow-actions/dedup/audit/ocr 覆盖上述场景;v3 CSV 状态推进至 DONE
这个提交包含在:
@@ -28,6 +28,7 @@ const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||
const configReadRoutes = require("./routes/configRead");
|
||||
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
||||
const { startOcrJob } = require("./jobs/ocrJob");
|
||||
const { startInvoiceArchiveJob } = require("./jobs/invoiceArchiveJob");
|
||||
const { AppError } = require("./utils/AppError");
|
||||
|
||||
async function buildApp() {
|
||||
@@ -202,6 +203,7 @@ async function buildApp() {
|
||||
|
||||
startDelegationRevokeJob(app);
|
||||
startOcrJob(app);
|
||||
startInvoiceArchiveJob(app);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ function getConfig() {
|
||||
ocrMaxAttempts: Number(process.env.OCR_MAX_ATTEMPTS ?? 3),
|
||||
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000),
|
||||
|
||||
invoiceArchiveCron: process.env.INVOICE_ARCHIVE_CRON ?? "30 2 * * *",
|
||||
invoiceArchiveUnusedDays: Number(process.env.INVOICE_ARCHIVE_UNUSED_DAYS ?? 90),
|
||||
invoiceArchiveBatchSize: Number(process.env.INVOICE_ARCHIVE_BATCH_SIZE ?? 200),
|
||||
|
||||
tencentSecretId: process.env.TENCENT_SECRET_ID ?? "",
|
||||
tencentSecretKey: process.env.TENCENT_SECRET_KEY ?? "",
|
||||
tencentOcrRegion: process.env.TENCENT_OCR_REGION ?? "ap-beijing",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
const cron = require("node-cron");
|
||||
|
||||
async function claimAndArchiveUnusedInvoices(tx, batchSize, unusedDays) {
|
||||
return tx.$queryRaw`
|
||||
WITH picked AS (
|
||||
SELECT id
|
||||
FROM "invoice_pool"
|
||||
WHERE linked_reimbursement_id IS NULL
|
||||
AND status IN ('available', 'ocr_failed')
|
||||
AND created_at < NOW() - make_interval(days => ${unusedDays})
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ${batchSize}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE "invoice_pool" AS i
|
||||
SET status = 'archived',
|
||||
archived_reason = 'unused_timeout',
|
||||
archived_at = NOW(),
|
||||
linked_item_id = NULL,
|
||||
ocr_locked_at = NULL,
|
||||
ocr_lock_owner = NULL,
|
||||
updated_by = NULL
|
||||
FROM picked
|
||||
WHERE i.id = picked.id
|
||||
AND i.linked_reimbursement_id IS NULL
|
||||
AND i.status IN ('available', 'ocr_failed')
|
||||
RETURNING i.id, i.owner_employee_id, i.status, i.archived_at
|
||||
`;
|
||||
}
|
||||
|
||||
async function archiveUnusedInvoices(prisma, logger, options = {}) {
|
||||
const batchSize = Math.max(1, Number(options.batchSize ?? 200));
|
||||
const unusedDays = Math.max(1, Number(options.unusedDays ?? 90));
|
||||
|
||||
let total = 0;
|
||||
let round = 0;
|
||||
|
||||
while (true) {
|
||||
round += 1;
|
||||
|
||||
const archivedRows = await prisma.$transaction(async (tx) => {
|
||||
return claimAndArchiveUnusedInvoices(tx, batchSize, unusedDays);
|
||||
});
|
||||
|
||||
if (!archivedRows.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
total += archivedRows.length;
|
||||
logger.info(
|
||||
{
|
||||
round,
|
||||
claimed: archivedRows.length,
|
||||
updated: archivedRows.length,
|
||||
archived: archivedRows.length,
|
||||
unusedDays,
|
||||
},
|
||||
"Unused invoices archived batch processed",
|
||||
);
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
logger.info({ archived: total, unusedDays }, "Unused invoices auto-archived");
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function startInvoiceArchiveJob(fastify) {
|
||||
let running = false;
|
||||
|
||||
const task = cron.schedule(fastify.config.invoiceArchiveCron, async () => {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
try {
|
||||
await archiveUnusedInvoices(fastify.prisma, fastify.log, {
|
||||
batchSize: fastify.config.invoiceArchiveBatchSize,
|
||||
unusedDays: fastify.config.invoiceArchiveUnusedDays,
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error }, "Invoice archive job failed");
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
});
|
||||
|
||||
fastify.addHook("onClose", async () => {
|
||||
task.stop();
|
||||
task.destroy();
|
||||
});
|
||||
|
||||
fastify.log.info(
|
||||
{
|
||||
cron: fastify.config.invoiceArchiveCron,
|
||||
unusedDays: fastify.config.invoiceArchiveUnusedDays,
|
||||
},
|
||||
"Invoice archive job scheduled",
|
||||
);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
archiveUnusedInvoices,
|
||||
startInvoiceArchiveJob,
|
||||
};
|
||||
|
||||
+1164
-1
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,25 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { getConfig } = require("../config");
|
||||
const { archiveUnusedInvoices } = require("../jobs/invoiceArchiveJob");
|
||||
|
||||
(async () => {
|
||||
const config = getConfig();
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
const archived = await archiveUnusedInvoices(prisma, console, {
|
||||
batchSize: config.invoiceArchiveBatchSize,
|
||||
unusedDays: config.invoiceArchiveUnusedDays,
|
||||
});
|
||||
process.stdout.write(`archived=${archived}\n`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ const { runMockOcr } = require("./mock");
|
||||
|
||||
function isOcrEnabledForType(config, ocrType) {
|
||||
if (config.ocrProvider === "mock") {
|
||||
return true;
|
||||
return ocrType === "vat";
|
||||
}
|
||||
|
||||
return config.ocrProvider === "tencent" && ocrType === "vat";
|
||||
|
||||
在新工单中引用
屏蔽一个用户