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
这个提交包含在:
@@ -28,6 +28,7 @@ const employeesRoutes = require("./routes/employees");
|
||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||
const configReadRoutes = require("./routes/configRead");
|
||||
const approvalRulesRoutes = require("./routes/approvalRules");
|
||||
const featureFlagsRoutes = require("./routes/featureFlags");
|
||||
const notificationsRoutes = require("./routes/notifications");
|
||||
const exportsRoutes = require("./routes/exports");
|
||||
const dashboardRoutes = require("./routes/dashboard");
|
||||
@@ -229,6 +230,7 @@ async function buildApp() {
|
||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||
await app.register(configReadRoutes, { prefix: "/api" });
|
||||
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
||||
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
||||
await app.register(notificationsRoutes, { prefix: "/api" });
|
||||
await app.register(dashboardRoutes, { prefix: "/api" });
|
||||
await app.register(budgetsRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -91,6 +91,7 @@ function getConfig() {
|
||||
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000),
|
||||
|
||||
invoiceVerificationEnabled: process.env.INVOICE_VERIFICATION_ENABLED === "true",
|
||||
invoiceVerificationFlagEnv: process.env.INVOICE_VERIFICATION_ENABLED ?? "false",
|
||||
verificationProvider: process.env.VERIFICATION_PROVIDER ?? "mock",
|
||||
verificationPollCron: process.env.VERIFICATION_POLL_CRON ?? "* * * * *",
|
||||
verificationMaxAttempts: Number(process.env.VERIFICATION_MAX_ATTEMPTS ?? 3),
|
||||
@@ -102,6 +103,7 @@ function getConfig() {
|
||||
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
|
||||
|
||||
erpSyncEnabled: process.env.ERP_SYNC_ENABLED === "true",
|
||||
erpSyncFlagEnv: process.env.ERP_SYNC_ENABLED ?? "false",
|
||||
erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock",
|
||||
erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "",
|
||||
erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const {
|
||||
FEATURE_FLAG_KEY_LIST,
|
||||
OVERRIDE_MODE_LIST,
|
||||
ROLLOUT_TYPE_LIST,
|
||||
evaluateFeatureFlag,
|
||||
listFeatureFlags,
|
||||
updateFeatureFlag,
|
||||
} = require("../services/featureFlags");
|
||||
|
||||
const KEY_PARAM_SCHEMA = z.object({
|
||||
key: z.enum(FEATURE_FLAG_KEY_LIST),
|
||||
});
|
||||
|
||||
const UPDATE_BODY_SCHEMA = z.object({
|
||||
overrideMode: z.enum(OVERRIDE_MODE_LIST),
|
||||
rolloutType: z.enum(ROLLOUT_TYPE_LIST),
|
||||
departmentIds: z.array(z.string().uuid()).default([]),
|
||||
note: z.string().trim().max(500).optional().default(""),
|
||||
expectedVersion: z.coerce.number().int().min(1),
|
||||
});
|
||||
|
||||
function parseKey(params) {
|
||||
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data.key;
|
||||
}
|
||||
|
||||
function parseUpdateBody(body) {
|
||||
const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.rolloutType !== "department_allowlist" && parsed.data.departmentIds.length > 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { departmentIds: "rollout_type_global_disallows_department_ids" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function featureFlagsRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/system/feature-flags",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const items = await listFeatureFlags(fastify.prisma, fastify.config);
|
||||
return sendData(reply, request, { items });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
"/system/feature-flags/:key",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const key = parseKey(request.params);
|
||||
const body = parseUpdateBody(request.body);
|
||||
|
||||
const result = await updateFeatureFlag(fastify.prisma, {
|
||||
flagKey: key,
|
||||
overrideMode: body.overrideMode,
|
||||
rolloutType: body.rolloutType,
|
||||
departmentIds: body.departmentIds,
|
||||
note: body.note,
|
||||
expectedVersion: body.expectedVersion,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
|
||||
if (result.status === "not_found" || !result.current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === "version_conflict") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
featureFlag: "version_conflict",
|
||||
expectedVersion: body.expectedVersion,
|
||||
currentVersion: result.current.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status !== "updated" || !result.updated) {
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "服务器内部错误",
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "FEATURE_FLAG_UPDATED",
|
||||
targetType: "feature_flag",
|
||||
targetId: result.updated.id,
|
||||
extra: {
|
||||
flagKey: key,
|
||||
from: {
|
||||
overrideMode: result.current.overrideMode,
|
||||
rolloutType: result.current.rolloutType,
|
||||
departmentIds: result.current.departmentIds,
|
||||
note: result.current.note,
|
||||
version: result.current.version,
|
||||
},
|
||||
to: {
|
||||
overrideMode: result.updated.overrideMode,
|
||||
rolloutType: result.updated.rolloutType,
|
||||
departmentIds: result.updated.departmentIds,
|
||||
note: result.updated.note,
|
||||
version: result.updated.version,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const effective = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: key,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
...result.updated,
|
||||
envEnabled: effective.envEnabled,
|
||||
effectiveEnabled: effective.enabled,
|
||||
effectiveReason: effective.reason,
|
||||
matchedDepartmentId: effective.matchedDepartmentId,
|
||||
requiresDepartment: effective.requiresDepartment,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = featureFlagsRoutes;
|
||||
@@ -5,6 +5,7 @@ const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
|
||||
|
||||
async function integrationsRoutes(fastify) {
|
||||
@@ -14,15 +15,6 @@ async function integrationsRoutes(fastify) {
|
||||
preHandler: fastify.requirePermission("integration.manage"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!fastify.config.erpSyncEnabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { integration: "erp_sync_disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
|
||||
if (!parsedId.success) {
|
||||
throw new AppError({
|
||||
@@ -42,6 +34,7 @@ async function integrationsRoutes(fastify) {
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
@@ -67,6 +60,27 @@ async function integrationsRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const erpGate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!erpGate.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
integration: "feature_flag_blocked",
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
reason: erpGate.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payload = buildErpPayload({ reimbursement });
|
||||
const enqueued = await enqueueIntegrationEvent(fastify.prisma, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
@@ -85,6 +99,9 @@ async function integrationsRoutes(fastify) {
|
||||
extra: {
|
||||
outboxId: enqueued.id,
|
||||
duplicated: enqueued.duplicated,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
featureFlagReason: erpGate.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
resolveActionableStep,
|
||||
@@ -1691,7 +1692,7 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const reimbursementRows = await tx.$queryRaw`
|
||||
SELECT id, status, form_number, owner_id, submitter_id,
|
||||
SELECT id, status, form_number, owner_id, submitter_id, department_id,
|
||||
payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
|
||||
FROM "reimbursement_forms"
|
||||
WHERE id = ${reimbursementId}::uuid
|
||||
@@ -1772,6 +1773,7 @@ async function reimbursementRoutes(fastify) {
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
@@ -1779,14 +1781,40 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
});
|
||||
|
||||
if (refreshed && fastify.config.erpSyncEnabled) {
|
||||
await enqueueIntegrationEvent(tx, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
dedupeKey: refreshed.id,
|
||||
payload: buildErpPayload({ reimbursement: refreshed }),
|
||||
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
|
||||
actorId: request.employee.id,
|
||||
if (refreshed) {
|
||||
const departmentId =
|
||||
refreshed.department_id ?? reimbursement.department_id ?? request.employee.department_id ?? null;
|
||||
const erpGate = await evaluateFeatureFlag({
|
||||
prisma: tx,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (erpGate.enabled) {
|
||||
await enqueueIntegrationEvent(tx, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
dedupeKey: refreshed.id,
|
||||
payload: buildErpPayload({ reimbursement: refreshed }),
|
||||
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
} else {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: refreshed.id,
|
||||
extra: {
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId,
|
||||
reason: erpGate.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
const FEATURE_FLAG_KEYS = {
|
||||
erpSync: "erp_sync",
|
||||
invoiceVerification: "invoice_verification",
|
||||
};
|
||||
|
||||
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
|
||||
const OVERRIDE_MODE_LIST = Object.freeze(["inherit", "force_on", "force_off"]);
|
||||
const ROLLOUT_TYPE_LIST = Object.freeze(["global", "department_allowlist"]);
|
||||
|
||||
function isKnownFeatureFlagKey(flagKey) {
|
||||
return FEATURE_FLAG_KEY_LIST.includes(flagKey);
|
||||
}
|
||||
|
||||
function normalizeOverrideMode(value) {
|
||||
const mode = String(value ?? "force_off").trim().toLowerCase();
|
||||
if (OVERRIDE_MODE_LIST.includes(mode)) {
|
||||
return mode;
|
||||
}
|
||||
return "force_off";
|
||||
}
|
||||
|
||||
function normalizeRolloutType(value) {
|
||||
const type = String(value ?? "global").trim().toLowerCase();
|
||||
if (ROLLOUT_TYPE_LIST.includes(type)) {
|
||||
return type;
|
||||
}
|
||||
return "global";
|
||||
}
|
||||
|
||||
function sanitizeDepartmentIds(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const id of value) {
|
||||
if (typeof id !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.has(trimmed)) {
|
||||
seen.add(trimmed);
|
||||
items.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function resolveEnvGate(config, flagKey) {
|
||||
if (flagKey === FEATURE_FLAG_KEYS.erpSync) {
|
||||
return Boolean(config.erpSyncEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.invoiceVerification) {
|
||||
return Boolean(config.invoiceVerificationEnabled);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapFlagRecord(record) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const departmentIds = sanitizeDepartmentIds(record.department_ids);
|
||||
const overrideMode = normalizeOverrideMode(record.override_mode);
|
||||
const rolloutType = normalizeRolloutType(record.rollout_type);
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
flagKey: record.flag_key,
|
||||
overrideMode,
|
||||
rolloutType,
|
||||
departmentIds,
|
||||
note: record.note ?? "",
|
||||
version: Number(record.version ?? 1),
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
createdBy: record.created_by ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
updatedBy: record.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultRecord(flagKey) {
|
||||
return {
|
||||
id: null,
|
||||
flagKey,
|
||||
overrideMode: "force_off",
|
||||
rolloutType: "global",
|
||||
departmentIds: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
createdAt: null,
|
||||
createdBy: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
function withEvaluation(base, evaluation) {
|
||||
return {
|
||||
...base,
|
||||
envEnabled: evaluation.envEnabled,
|
||||
effectiveEnabled: evaluation.enabled,
|
||||
effectiveReason: evaluation.reason,
|
||||
matchedDepartmentId: evaluation.matchedDepartmentId,
|
||||
requiresDepartment: evaluation.requiresDepartment,
|
||||
};
|
||||
}
|
||||
|
||||
async function getFlagRecord(prisma, flagKey) {
|
||||
const row = await prisma.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
|
||||
return mapFlagRecord(row);
|
||||
}
|
||||
|
||||
async function evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey,
|
||||
departmentId = null,
|
||||
strictDepartment = true,
|
||||
}) {
|
||||
const envEnabled = resolveEnvGate(config, flagKey);
|
||||
const normalizedDepartmentId =
|
||||
typeof departmentId === "string" && departmentId.trim() ? departmentId.trim() : null;
|
||||
|
||||
if (!envEnabled) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "env_hard_off",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
const record = await getFlagRecord(prisma, flagKey);
|
||||
if (!record) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "missing_flag_record",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!OVERRIDE_MODE_LIST.includes(record.overrideMode)) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "invalid_override_mode",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!ROLLOUT_TYPE_LIST.includes(record.rolloutType)) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "invalid_rollout_type",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.overrideMode === "force_off") {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "flag_force_off",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
const modeEnabled = record.overrideMode === "force_on" || record.overrideMode === "inherit";
|
||||
if (!modeEnabled) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "mode_not_enabled",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.rolloutType === "global") {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: true,
|
||||
reason: record.overrideMode === "inherit" ? "env_inherit_global_on" : "flag_force_on_global",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: false,
|
||||
};
|
||||
}
|
||||
|
||||
const allowlist = record.departmentIds;
|
||||
if (!allowlist.length) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "department_allowlist_empty",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalizedDepartmentId) {
|
||||
if (strictDepartment) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "department_missing",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
flagKey,
|
||||
enabled: true,
|
||||
reason: "department_deferred",
|
||||
envEnabled,
|
||||
matchedDepartmentId: null,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
const matched = allowlist.includes(normalizedDepartmentId);
|
||||
if (!matched) {
|
||||
return {
|
||||
flagKey,
|
||||
enabled: false,
|
||||
reason: "department_not_allowed",
|
||||
envEnabled,
|
||||
matchedDepartmentId: normalizedDepartmentId,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
flagKey,
|
||||
enabled: true,
|
||||
reason:
|
||||
record.overrideMode === "inherit"
|
||||
? "env_inherit_department_allowed"
|
||||
: "flag_force_on_department_allowed",
|
||||
envEnabled,
|
||||
matchedDepartmentId: normalizedDepartmentId,
|
||||
requiresDepartment: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function listFeatureFlags(prisma, config) {
|
||||
const rows = await prisma.featureFlag.findMany({
|
||||
where: {
|
||||
flag_key: { in: FEATURE_FLAG_KEY_LIST },
|
||||
},
|
||||
orderBy: { flag_key: "asc" },
|
||||
});
|
||||
|
||||
const recordMap = new Map(rows.map((row) => [row.flag_key, mapFlagRecord(row)]));
|
||||
|
||||
const items = [];
|
||||
for (const flagKey of FEATURE_FLAG_KEY_LIST) {
|
||||
const base = recordMap.get(flagKey) ?? buildDefaultRecord(flagKey);
|
||||
const evaluation = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey,
|
||||
departmentId: null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
items.push(withEvaluation(base, evaluation));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function updateFeatureFlag(prisma, input) {
|
||||
const flagKey = String(input.flagKey ?? "").trim();
|
||||
if (!isKnownFeatureFlagKey(flagKey)) {
|
||||
return {
|
||||
status: "not_found",
|
||||
current: null,
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const overrideMode = normalizeOverrideMode(input.overrideMode);
|
||||
const rolloutType = normalizeRolloutType(input.rolloutType);
|
||||
const departmentIds =
|
||||
rolloutType === "department_allowlist" ? sanitizeDepartmentIds(input.departmentIds) : [];
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const currentRow = await tx.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
|
||||
if (!currentRow) {
|
||||
return {
|
||||
status: "not_found",
|
||||
current: null,
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const current = mapFlagRecord(currentRow);
|
||||
|
||||
if (Number(input.expectedVersion) !== current.version) {
|
||||
return {
|
||||
status: "version_conflict",
|
||||
current,
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const updateResult = await tx.featureFlag.updateMany({
|
||||
where: {
|
||||
id: current.id,
|
||||
version: current.version,
|
||||
},
|
||||
data: {
|
||||
override_mode: overrideMode,
|
||||
rollout_type: rolloutType,
|
||||
department_ids: departmentIds,
|
||||
note: input.note ?? "",
|
||||
version: {
|
||||
increment: 1,
|
||||
},
|
||||
updated_by: input.actorId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (updateResult.count !== 1) {
|
||||
const latest = await tx.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
return {
|
||||
status: "version_conflict",
|
||||
current: mapFlagRecord(latest),
|
||||
updated: null,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedRow = await tx.featureFlag.findUnique({
|
||||
where: { flag_key: flagKey },
|
||||
});
|
||||
|
||||
return {
|
||||
status: "updated",
|
||||
current,
|
||||
updated: mapFlagRecord(updatedRow),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FEATURE_FLAG_KEYS,
|
||||
FEATURE_FLAG_KEY_LIST,
|
||||
OVERRIDE_MODE_LIST,
|
||||
ROLLOUT_TYPE_LIST,
|
||||
isKnownFeatureFlagKey,
|
||||
sanitizeDepartmentIds,
|
||||
evaluateFeatureFlag,
|
||||
listFeatureFlags,
|
||||
updateFeatureFlag,
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const { runMockVerification } = require("./mock");
|
||||
const { runTaxVerification } = require("./tax");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../featureFlags");
|
||||
|
||||
function getProvider(config) {
|
||||
return String(config.verificationProvider ?? "mock").trim().toLowerCase();
|
||||
@@ -9,6 +10,28 @@ function isVerificationEnabled(config) {
|
||||
return Boolean(config.invoiceVerificationEnabled);
|
||||
}
|
||||
|
||||
async function evaluateVerificationAccess({
|
||||
prisma,
|
||||
config,
|
||||
departmentId = null,
|
||||
strictDepartment = true,
|
||||
}) {
|
||||
const evaluation = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
departmentId,
|
||||
strictDepartment,
|
||||
});
|
||||
|
||||
return {
|
||||
enabled: evaluation.enabled,
|
||||
reason: evaluation.reason,
|
||||
matchedDepartmentId: evaluation.matchedDepartmentId,
|
||||
envEnabled: evaluation.envEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyInvoiceByProvider({ config, invoice }) {
|
||||
const provider = getProvider(config);
|
||||
|
||||
@@ -33,5 +56,6 @@ async function verifyInvoiceByProvider({ config, invoice }) {
|
||||
module.exports = {
|
||||
getProvider,
|
||||
isVerificationEnabled,
|
||||
evaluateVerificationAccess,
|
||||
verifyInvoiceByProvider,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
async function runTaxVerification({ config, invoice }) {
|
||||
if (!config.invoiceVerificationEnabled) {
|
||||
return {
|
||||
status: "error",
|
||||
resultCode: "verification_disabled",
|
||||
resultMessage: "invoice verification disabled",
|
||||
payload: {
|
||||
provider: "tax",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
resultCode: "tax_provider_not_configured",
|
||||
|
||||
在新工单中引用
屏蔽一个用户