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
这个提交包含在:
@@ -3,12 +3,87 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../../utils/AppError");
|
||||
const { sendData } = require("../../utils/reply");
|
||||
const { buildAuditLogData } = require("../../utils/audit");
|
||||
const { getProvider, isVerificationEnabled } = require("../../services/verification");
|
||||
const { getProvider, evaluateVerificationAccess } = require("../../services/verification");
|
||||
|
||||
const { mapVerificationRecord } = require("./shared/mappers");
|
||||
const { parseVerificationListQuery } = require("./shared/schemas");
|
||||
const { triggerVerificationJob } = require("./shared/helpers");
|
||||
|
||||
function throwVerificationDisabled(reason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled", featureFlag: reason },
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveVerificationDepartmentContext(tx, request, invoiceId) {
|
||||
const invoice = await tx.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
owner_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
department_id: true,
|
||||
},
|
||||
},
|
||||
linked_reimbursement: {
|
||||
select: {
|
||||
id: true,
|
||||
department_id: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const departmentId =
|
||||
invoice.linked_reimbursement?.department_id ??
|
||||
invoice.owner_employee?.department_id ??
|
||||
request.employee.department_id ??
|
||||
null;
|
||||
|
||||
return {
|
||||
invoice,
|
||||
departmentId,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureVerificationEnabledWithContext({ tx, fastify, request, invoiceId }) {
|
||||
const context = await resolveVerificationDepartmentContext(tx, request, invoiceId);
|
||||
const evaluation = await evaluateVerificationAccess({
|
||||
prisma: tx,
|
||||
config: fastify.config,
|
||||
departmentId: context.departmentId,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!evaluation.enabled) {
|
||||
throwVerificationDisabled(evaluation.reason);
|
||||
}
|
||||
|
||||
return {
|
||||
...context,
|
||||
evaluation,
|
||||
};
|
||||
}
|
||||
|
||||
function registerInvoiceVerificationRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/invoice-pool/:id/verify",
|
||||
@@ -16,15 +91,6 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
preHandler: fastify.requirePermission("invoice.verify"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!isVerificationEnabled(fastify.config)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
||||
if (!parsedInvoiceId.success) {
|
||||
throw new AppError({
|
||||
@@ -54,24 +120,12 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const invoice = await tx.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
total_amount: true,
|
||||
remarks: true,
|
||||
},
|
||||
const { invoice, departmentId, evaluation } = await ensureVerificationEnabledWithContext({
|
||||
tx,
|
||||
fastify,
|
||||
request,
|
||||
invoiceId,
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const latest = await tx.invoiceVerificationRecord.findFirst({
|
||||
where: { invoice_id: invoiceId },
|
||||
@@ -116,6 +170,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
invoiceCode: invoice.invoice_code ?? null,
|
||||
invoiceNumber: invoice.invoice_number ?? null,
|
||||
totalAmount: invoice.total_amount?.toString?.() ?? null,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
requested_by: request.employee.id,
|
||||
requested_at: new Date(now),
|
||||
@@ -135,6 +191,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
extra: {
|
||||
verificationId: created.id,
|
||||
provider,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -142,6 +200,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
return {
|
||||
verificationId: created.id,
|
||||
provider,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -157,6 +217,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
extra: {
|
||||
verificationId: response.verificationId,
|
||||
status: trigger.result?.status ?? null,
|
||||
departmentId: response.departmentId,
|
||||
featureFlagReason: response.featureFlagReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -259,15 +321,6 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
preHandler: fastify.requirePermission("invoice.verify"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!isVerificationEnabled(fastify.config)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { verification: "disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
||||
if (!parsedInvoiceId.success) {
|
||||
throw new AppError({
|
||||
@@ -282,6 +335,13 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
const provider = getProvider(fastify.config);
|
||||
|
||||
const created = await fastify.prisma.$transaction(async (tx) => {
|
||||
const { departmentId, evaluation } = await ensureVerificationEnabledWithContext({
|
||||
tx,
|
||||
fastify,
|
||||
request,
|
||||
invoiceId,
|
||||
});
|
||||
|
||||
const latest = await tx.invoiceVerificationRecord.findFirst({
|
||||
where: { invoice_id: invoiceId },
|
||||
orderBy: { created_at: "desc" },
|
||||
@@ -344,6 +404,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
requested_at: new Date(),
|
||||
request_payload: {
|
||||
retryOfId: latest.id,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -361,6 +423,8 @@ function registerInvoiceVerificationRoutes(fastify) {
|
||||
verificationId: createdRecord.id,
|
||||
retryOfId: latest.id,
|
||||
attempts,
|
||||
departmentId,
|
||||
featureFlagReason: evaluation.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户