feat(P11-P12): 落地治理开关与移动端MVP(G0-G6/H1/M1-M4)
- 新增 Feature Flag 模型、评估服务与管理 API(erp_sync/invoice_verification)\n- 完成 ERP 与验真灰度收口(路由/服务/作业统一判定)\n- 补齐 pay 与手动 sync 的 department_id 灰度上下文\n- 增加管理后台开关页、PWA 基础设施与移动拍照/审批页面\n- 增加回归脚本、安全基线与压测脚本,更新文档与执行合同\n\nIssue: G0,G1,G2,G3,G4,G5,G6,H1,M1,M2,M3,M4
这个提交包含在:
@@ -5,6 +5,10 @@ const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
|
||||
const { runOcrOnce } = require("./ocrJob");
|
||||
const { runVerificationForRecord } = require("./verificationJob");
|
||||
const { runIntegrationOutboxOnce } = require("./integrationOutboxJob");
|
||||
const {
|
||||
FEATURE_FLAG_KEYS,
|
||||
evaluateFeatureFlag,
|
||||
} = require("../services/featureFlags");
|
||||
|
||||
const JOB_OCR_TICK = "ocr.tick";
|
||||
const JOB_DELEGATION_REVOKE = "delegation.revoke-expired";
|
||||
@@ -267,7 +271,14 @@ async function startPgBossJobs(fastify) {
|
||||
await boss.unschedule(JOB_OCR_TICK).catch(() => {});
|
||||
}
|
||||
|
||||
if (fastify.config.invoiceVerificationEnabled) {
|
||||
const verificationScheduling = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
if (verificationScheduling.enabled) {
|
||||
await boss.schedule(JOB_INVOICE_VERIFY, fastify.config.verificationPollCron, null, {
|
||||
tz: fastify.config.appTimezone,
|
||||
singletonKey: JOB_INVOICE_VERIFY,
|
||||
@@ -288,7 +299,8 @@ async function startPgBossJobs(fastify) {
|
||||
schema: fastify.config.pgBossSchema,
|
||||
ocrProvider: fastify.config.ocrProvider,
|
||||
ocrCron: fastify.config.ocrPollCron,
|
||||
verificationEnabled: fastify.config.invoiceVerificationEnabled,
|
||||
verificationEnabled: verificationScheduling.enabled,
|
||||
verificationFlagReason: verificationScheduling.reason,
|
||||
verificationCron: fastify.config.verificationPollCron,
|
||||
delegationCron: fastify.config.delegationRevokeCron,
|
||||
invoiceArchiveCron: fastify.config.invoiceArchiveCron,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const { verifyInvoiceByProvider } = require("../services/verification");
|
||||
const {
|
||||
evaluateVerificationAccess,
|
||||
verifyInvoiceByProvider,
|
||||
} = require("../services/verification");
|
||||
|
||||
function toResultStatus(status) {
|
||||
if (["passed", "failed", "error"].includes(status)) {
|
||||
@@ -32,6 +35,16 @@ async function runVerificationForRecord(prisma, config, recordId) {
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
status: true,
|
||||
owner_employee: {
|
||||
select: {
|
||||
department_id: true,
|
||||
},
|
||||
},
|
||||
linked_reimbursement: {
|
||||
select: {
|
||||
department_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -54,6 +67,37 @@ async function runVerificationForRecord(prisma, config, recordId) {
|
||||
return { skipped: true, reason: "status_not_pending" };
|
||||
}
|
||||
|
||||
const departmentId =
|
||||
record.invoice.linked_reimbursement?.department_id ??
|
||||
record.invoice.owner_employee?.department_id ??
|
||||
null;
|
||||
|
||||
const access = await evaluateVerificationAccess({
|
||||
prisma: tx,
|
||||
config,
|
||||
departmentId,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!access.enabled) {
|
||||
await tx.invoiceVerificationRecord.update({
|
||||
where: { id: recordId },
|
||||
data: {
|
||||
status: "error",
|
||||
error_code: "verification_disabled_by_flag",
|
||||
error_message: access.reason,
|
||||
completed_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
status: "error",
|
||||
durationMs: 0,
|
||||
resultCode: "verification_disabled_by_flag",
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const verification = await verifyInvoiceByProvider({
|
||||
config,
|
||||
|
||||
在新工单中引用
屏蔽一个用户