feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力

关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
这个提交包含在:
2026-03-21 18:03:20 +08:00
父节点 9b7cd818f7
当前提交 e39e41287e
共修改 45 个文件,包含 4571 行新增和 328 行删除
+127
查看文件
@@ -0,0 +1,127 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const UUID_SCHEMA = z.string().uuid();
const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}T/));
const QUERY_SCHEMA = z.object({ manage: z.enum(["0", "1", "true", "false"]).optional() });
const WRITE_SCHEMA = z.object({
title: z.string().trim().min(1).max(120),
summary: z.string().trim().min(1).max(500),
linkUrl: z.string().trim().url().nullable().optional(),
startsAt: DATE_TIME_SCHEMA.nullable().optional(),
endsAt: DATE_TIME_SCHEMA.nullable().optional(),
enabled: z.boolean().optional(),
priority: z.coerce.number().int().min(1).max(9999).optional(),
}).strict();
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parseId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" });
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
return parsed.data;
}
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.announcements, strictDepartment: false });
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "announcements_v1_disabled", reason: gate.reason }, 409);
}
}
function mapItem(item) {
return {
id: item.id,
title: item.title,
summary: item.summary,
linkUrl: item.link_url ?? null,
startsAt: item.starts_at?.toISOString?.() ?? null,
endsAt: item.ends_at?.toISOString?.() ?? null,
enabled: item.enabled,
priority: item.priority,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
async function announcementsRoutes(fastify) {
fastify.get("/announcements", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const queryParsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
const query = queryParsed.data;
const isManage = query.manage === "1" || query.manage === "true";
const now = new Date();
const where = isManage && fastify.hasPermission(request.employee, "system.settings")
? {}
: {
enabled: true,
OR: [{ starts_at: null }, { starts_at: { lte: now } }],
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }],
};
const items = await fastify.prisma.portalAnnouncement.findMany({
where,
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
});
return sendData(reply, request, { items: items.map(mapItem) });
});
fastify.post("/announcements", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const body = parseBody(WRITE_SCHEMA, request.body);
const created = await fastify.prisma.portalAnnouncement.create({
data: {
title: body.title,
summary: body.summary,
link_url: body.linkUrl ?? null,
starts_at: body.startsAt ? new Date(body.startsAt) : null,
ends_at: body.endsAt ? new Date(body.endsAt) : null,
enabled: body.enabled ?? true,
priority: body.priority ?? 100,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "PORTAL_ANNOUNCEMENT_CREATED", targetType: "portal_announcement", targetId: created.id }) });
return reply.code(201).send({ requestId: request.id, data: { item: mapItem(created) } });
});
fastify.patch("/announcements/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const announcementId = parseId(request.params?.id);
const body = parseBody(PATCH_SCHEMA, request.body);
const updated = await fastify.prisma.portalAnnouncement.update({
where: { id: announcementId },
data: {
...(body.title !== undefined ? { title: body.title } : {}),
...(body.summary !== undefined ? { summary: body.summary } : {}),
...(body.linkUrl !== undefined ? { link_url: body.linkUrl ?? null } : {}),
...(body.startsAt !== undefined ? { starts_at: body.startsAt ? new Date(body.startsAt) : null } : {}),
...(body.endsAt !== undefined ? { ends_at: body.endsAt ? new Date(body.endsAt) : null } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
...(body.priority !== undefined ? { priority: body.priority } : {}),
updated_by: request.employee.id,
},
});
return sendData(reply, request, { item: mapItem(updated) });
});
fastify.post("/announcements/:id/archive", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const announcementId = parseId(request.params?.id);
const updated = await fastify.prisma.portalAnnouncement.update({
where: { id: announcementId },
data: { enabled: false, ends_at: new Date(), updated_by: request.employee.id },
});
return sendData(reply, request, { item: mapItem(updated) });
});
}
module.exports = announcementsRoutes;
+125
查看文件
@@ -0,0 +1,125 @@
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl");
function ensureReadPermission(fastify, request) {
if (
!fastify.hasPermission(request.employee, "payment.batch.manage") &&
!fastify.hasPermission(request.employee, "risk.review.manage")
) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
strictDepartment: false,
});
if (!gate.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { featureFlag: "payment_batch_v1_disabled", reason: gate.reason },
});
}
}
function mapBatchSummary(item) {
return {
id: item.id,
batchNo: item.batch_no,
entityType: item.entity_type,
name: item.name,
status: item.status,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
async function apOperationsRoutes(fastify) {
fastify.get("/ap-operations", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureReadPermission(fastify, request);
const [batches, failedItems, openRiskFlags, anomalies] = await Promise.all([
fastify.prisma.paymentBatch.findMany({
where: { status: { in: ["draft", "reviewed", "failed"] } },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 20,
}),
fastify.prisma.paymentBatchItem.findMany({
where: { status: "failed" },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
}),
fastify.prisma.entityRiskFlag.findMany({
where: { status: "open" },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
}),
fastify.prisma.travelReconciliationItem.findMany({
where: {
resolution_status: "pending",
match_status: { not: "matched" },
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
select: {
id: true,
travel_reconciliation_batch_id: true,
travel_order_id: true,
travel_request_id: true,
match_status: true,
resolution_status: true,
anomaly_code: true,
order_amount: true,
transaction_amount: true,
currency: true,
created_at: true,
},
}),
]);
return sendData(reply, request, {
summary: {
openPaymentBatchCount: batches.length,
failedPaymentBatchItemCount: failedItems.length,
openRiskFlagCount: openRiskFlags.length,
reconciliationAnomalyCount: anomalies.length,
},
openPaymentBatches: batches.map(mapBatchSummary),
failedPaymentBatchItems: failedItems.map((item) => ({
id: item.id,
paymentBatchId: item.payment_batch_id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
})),
openRiskFlags: openRiskFlags.map(buildPersistedRiskFlag),
reconciliationAnomalies: anomalies.map((item) => ({
id: item.id,
travelReconciliationBatchId: item.travel_reconciliation_batch_id,
travelOrderId: item.travel_order_id ?? null,
travelRequestId: item.travel_request_id ?? null,
matchStatus: item.match_status,
resolutionStatus: item.resolution_status,
anomalyCode: item.anomaly_code ?? null,
orderAmount: item.order_amount != null ? toMoneyString(item.order_amount) : null,
transactionAmount: item.transaction_amount != null ? toMoneyString(item.transaction_amount) : null,
currency: item.currency,
createdAt: item.created_at?.toISOString?.() ?? null,
})),
});
});
}
module.exports = apOperationsRoutes;
+54
查看文件
@@ -497,6 +497,60 @@ async function authRoutes(fastify) {
return sendData(reply, request, { accessToken });
});
fastify.get("/sso/providers", async (request, reply) => {
if (!fastify.config.oidcEnabled) {
return reply.send({ requestId: request.id, data: [] });
}
return reply.send({
requestId: request.id,
data: [
{
id: "oidc",
label: process.env.OIDC_PROVIDER_LABEL || "企业统一登录",
enabled: true,
loginUrl: "/api/auth/oidc/login",
},
],
});
});
fastify.get("/deeplink", async (request, reply) => {
const token = String(request.query?.token ?? "").trim();
const verified = verifyToken(token, fastify.config.jwtSecret);
if (!verified.ok) {
throw new AppError({
code: "AUTH_001",
message: "无效深链",
statusCode: 401,
details: { token: verified.reason },
});
}
const payload = verified.payload && typeof verified.payload === "object" ? verified.payload : {};
const targetPath = typeof payload.targetPath === "string" ? payload.targetPath.trim() : "";
const exp = Number(payload.exp ?? 0);
if (!targetPath || !targetPath.startsWith("/")) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { targetPath: "invalid_target_path" },
});
}
if (!Number.isFinite(exp) || Date.now() / 1000 > exp) {
throw new AppError({
code: "AUTH_001",
message: "深链已过期",
statusCode: 401,
details: { token: "expired" },
});
}
const origin = String(fastify.config.appOrigin || "").split(",")[0].trim().replace(/\/+$/, "");
return reply.redirect(origin ? `${origin}${targetPath}` : targetPath);
});
fastify.get("/oidc/login", async (request, reply) => {
if (!fastify.config.oidcEnabled) {
throw new AppError({
+227
查看文件
@@ -0,0 +1,227 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["all", "reimbursement", "purchase_request", "travel_request"]);
const CONTROL_MODE_SCHEMA = z.enum(["warn", "block", "manual_review"]);
const LIST_QUERY_SCHEMA = z.object({
entityType: ENTITY_TYPE_SCHEMA.optional(),
enabled: z.enum(["true", "false"]).optional(),
});
const WRITE_SCHEMA = z.object({
name: z.string().trim().min(1).max(120),
entityType: ENTITY_TYPE_SCHEMA,
departmentId: UUID_SCHEMA.nullable().optional(),
legalEntityId: UUID_SCHEMA.nullable().optional(),
costCenterId: UUID_SCHEMA.nullable().optional(),
expenseTypeId: UUID_SCHEMA.nullable().optional(),
controlMode: CONTROL_MODE_SCHEMA,
priority: z.coerce.number().int().min(1).max(9999).optional(),
enabled: z.boolean().optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, {
message: "empty_patch",
});
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseRuleId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
function parseQuery(query) {
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return {
entityType: parsed.data.entityType ?? null,
enabled: parsed.data.enabled == null ? null : parsed.data.enabled === "true",
};
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
if (parsed.data.costCenterId && !parsed.data.legalEntityId) {
throw buildValidationError({ costCenterId: "legalEntityId_required" }, 409);
}
if (parsed.data.entityType !== "reimbursement" && parsed.data.expenseTypeId) {
throw buildValidationError({ expenseTypeId: "only_reimbursement_supported" }, 409);
}
return parsed.data;
}
function mapRule(item) {
return {
id: item.id,
name: item.name,
entityType: item.entity_type,
departmentId: item.department_id ?? null,
legalEntityId: item.legal_entity_id ?? null,
costCenterId: item.cost_center_id ?? null,
expenseTypeId: item.expense_type_id ?? null,
controlMode: item.control_mode,
priority: item.priority,
enabled: item.enabled,
notes: item.notes ?? null,
archivedAt: item.archived_at?.toISOString?.() ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
async function budgetControlRulesRoutes(fastify) {
fastify.get(
"/budget-control-rules",
{ preHandler: fastify.requirePermission("budget.manage") },
async (request, reply) => {
const query = parseQuery(request.query);
const items = await fastify.prisma.budgetControlRule.findMany({
where: {
...(query.entityType ? { entity_type: query.entityType } : {}),
...(query.enabled == null ? {} : { enabled: query.enabled }),
archived_at: null,
},
orderBy: [{ priority: "asc" }, { created_at: "asc" }],
});
return sendData(reply, request, { items: items.map(mapRule) });
},
);
fastify.post(
"/budget-control-rules",
{ preHandler: fastify.requirePermission("budget.manage") },
async (request, reply) => {
const body = parseBody(WRITE_SCHEMA, request.body);
const created = await fastify.prisma.budgetControlRule.create({
data: {
name: body.name,
entity_type: body.entityType,
department_id: body.departmentId ?? null,
legal_entity_id: body.legalEntityId ?? null,
cost_center_id: body.costCenterId ?? null,
expense_type_id: body.expenseTypeId ?? null,
control_mode: body.controlMode,
priority: body.priority ?? 100,
enabled: body.enabled ?? true,
notes: body.notes ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_CONTROL_RULE_CREATED",
targetType: "budget_control_rule",
targetId: created.id,
extra: mapRule(created),
}),
});
return reply.code(201).send({ requestId: request.id, data: { item: mapRule(created) } });
},
);
fastify.patch(
"/budget-control-rules/:id",
{ preHandler: fastify.requirePermission("budget.manage") },
async (request, reply) => {
const ruleId = parseRuleId(request.params?.id);
const body = parseBody(PATCH_SCHEMA, request.body);
const current = await fastify.prisma.budgetControlRule.findUnique({ where: { id: ruleId } });
if (!current || current.archived_at) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const updated = await fastify.prisma.budgetControlRule.update({
where: { id: ruleId },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.entityType !== undefined ? { entity_type: body.entityType } : {}),
...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}),
...(body.legalEntityId !== undefined ? { legal_entity_id: body.legalEntityId } : {}),
...(body.costCenterId !== undefined ? { cost_center_id: body.costCenterId } : {}),
...(body.expenseTypeId !== undefined ? { expense_type_id: body.expenseTypeId } : {}),
...(body.controlMode !== undefined ? { control_mode: body.controlMode } : {}),
...(body.priority !== undefined ? { priority: body.priority } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
...(body.notes !== undefined ? { notes: body.notes } : {}),
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_CONTROL_RULE_UPDATED",
targetType: "budget_control_rule",
targetId: updated.id,
extra: { fields: Object.keys(body) },
}),
});
return sendData(reply, request, { item: mapRule(updated) });
},
);
fastify.post(
"/budget-control-rules/:id/archive",
{ preHandler: fastify.requirePermission("budget.manage") },
async (request, reply) => {
const ruleId = parseRuleId(request.params?.id);
const current = await fastify.prisma.budgetControlRule.findUnique({ where: { id: ruleId } });
if (!current || current.archived_at) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const archived = await fastify.prisma.budgetControlRule.update({
where: { id: ruleId },
data: {
enabled: false,
archived_at: new Date(),
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "BUDGET_CONTROL_RULE_ARCHIVED",
targetType: "budget_control_rule",
targetId: archived.id,
}),
});
return sendData(reply, request, { item: mapRule(archived) });
},
);
}
module.exports = budgetControlRulesRoutes;
+80
查看文件
@@ -0,0 +1,80 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const ID_SCHEMA = z.string().uuid();
const SCOPE_TYPE_SCHEMA = z.enum(["department", "legal_entity", "domain"]);
const WRITE_SCHEMA = z.object({
scopeType: SCOPE_TYPE_SCHEMA,
scopeKey: z.string().trim().min(1).max(64),
enabled: z.boolean().optional(),
startsAt: z.string().datetime({ offset: true }).nullable().optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); }
function parseId(value) { const parsed = ID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; }
function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) throw buildValidationError(parsed.error.flatten()); return parsed.data; }
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.cutoverConsole, strictDepartment: false });
if (!gate.enabled) throw buildValidationError({ featureFlag: "cutover_console_v1_disabled", reason: gate.reason }, 409);
}
function mapScope(item) {
return {
id: item.id,
scopeType: item.scope_type,
scopeKey: item.scope_key,
enabled: item.enabled,
startsAt: item.starts_at?.toISOString?.() ?? null,
notes: item.notes ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
updatedBy: item.updated_by ?? null,
};
}
async function cutoverRoutes(fastify) {
fastify.get("/cutover/scopes", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const items = await fastify.prisma.cutoverScope.findMany({ orderBy: [{ scope_type: "asc" }, { scope_key: "asc" }] });
return sendData(reply, request, { items: items.map(mapScope) });
});
fastify.post("/cutover/scopes", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const body = parseBody(WRITE_SCHEMA, request.body);
const created = await fastify.prisma.cutoverScope.create({
data: {
scope_type: body.scopeType,
scope_key: body.scopeKey,
enabled: body.enabled ?? false,
starts_at: body.startsAt ? new Date(body.startsAt) : null,
notes: body.notes ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
return reply.code(201).send({ requestId: request.id, data: { item: mapScope(created) } });
});
fastify.patch("/cutover/scopes/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const scopeId = parseId(request.params?.id);
const body = parseBody(PATCH_SCHEMA, request.body);
const updated = await fastify.prisma.cutoverScope.update({
where: { id: scopeId },
data: {
...(body.scopeType !== undefined ? { scope_type: body.scopeType } : {}),
...(body.scopeKey !== undefined ? { scope_key: body.scopeKey } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
...(body.startsAt !== undefined ? { starts_at: body.startsAt ? new Date(body.startsAt) : null } : {}),
...(body.notes !== undefined ? { notes: body.notes ?? null } : {}),
updated_by: request.employee.id,
},
});
return sendData(reply, request, { item: mapScope(updated) });
});
}
module.exports = cutoverRoutes;
+22
查看文件
@@ -4,6 +4,7 @@ const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { resolveActionableStep } = require("../utils/workflow");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
DEFAULT_ATTACHMENT_MIME_TYPES,
calculateRequestedDays,
@@ -476,6 +477,13 @@ async function leaveRoutes(fastify) {
preHandler: fastify.requireEntityPermission("leave", "create"),
},
async (request, reply) => {
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "leave",
departmentId: request.employee.department_id ?? null,
});
const { body, filePart } = await collectCreatePayload(request);
const leaveType = await loadLeaveTypeOrThrow(fastify.prisma, body.leaveTypeId);
const attachment = await assertLeaveTypeAttachment(
@@ -648,6 +656,13 @@ async function leaveRoutes(fastify) {
}
const leave = leaveRows[0];
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "leave",
departmentId: leave.department_id ?? request.employee.department_id ?? null,
});
if (leave.owner_id !== request.employee.id) {
throw new AppError({
code: "PERM_001",
@@ -877,6 +892,13 @@ async function leaveRoutes(fastify) {
}
const leave = leaveRows[0];
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "leave",
departmentId: leave.department_id ?? request.employee.department_id ?? null,
});
if (leave.owner_id !== request.employee.id) {
throw new AppError({
code: "PERM_001",
+86
查看文件
@@ -0,0 +1,86 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const ID_SCHEMA = z.string().uuid();
const RUN_TYPE_SCHEMA = z.enum(["employees", "departments", "budgets", "masterdata_validation"]);
function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); }
function parseId(value) { const parsed = ID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; }
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.cutoverConsole, strictDepartment: false });
if (!gate.enabled) throw buildValidationError({ featureFlag: "cutover_console_v1_disabled", reason: gate.reason }, 409);
}
function parseCsv(content) {
const rows = String(content ?? "").trim().split(/\r?\n/).filter(Boolean);
if (!rows.length) return { headers: [], records: [] };
const headers = rows[0].split(",").map((item) => item.trim());
const records = rows.slice(1).map((line) => {
const cells = line.split(",");
const record = {};
headers.forEach((header, index) => { record[header] = (cells[index] ?? "").trim(); });
return record;
});
return { headers, records };
}
function requiredHeaders(runType) {
if (runType === "employees") return ["employee_no", "name", "department_name"];
if (runType === "departments") return ["name"];
if (runType === "budgets") return ["code", "name"];
return ["type", "code"];
}
function mapRun(item) {
return {
id: item.id,
runType: item.run_type,
status: item.status,
sourceFilename: item.source_filename,
summaryJson: item.summary_json ?? null,
errorJson: item.error_json ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
createdBy: item.created_by ?? null,
};
}
async function migrationRunsRoutes(fastify) {
fastify.post("/migration-runs/dry-run", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const part = await request.file();
if (!part) throw buildValidationError({ file: "required" });
const runType = RUN_TYPE_SCHEMA.safeParse(part.fields?.runType?.value ?? request.query?.runType ?? request.body?.runType);
if (!runType.success) throw buildValidationError({ runType: "invalid" });
const content = await part.toBuffer();
const parsed = parseCsv(content.toString("utf8"));
const required = requiredHeaders(runType.data);
const missingHeaders = required.filter((header) => !parsed.headers.includes(header));
const summaryJson = { rowCount: parsed.records.length, headers: parsed.headers, requiredHeaders: required };
const errorJson = missingHeaders.length ? { missingHeaders } : null;
const status = missingHeaders.length || parsed.records.length === 0 ? "dry_run_failed" : "dry_run_ok";
const created = await fastify.prisma.migrationRun.create({
data: {
run_type: runType.data,
status,
source_filename: part.filename,
summary_json: summaryJson,
error_json: errorJson,
created_by: request.employee.id,
},
});
return reply.code(201).send({ requestId: request.id, data: { item: mapRun(created) } });
});
fastify.get("/migration-runs", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const items = await fastify.prisma.migrationRun.findMany({ orderBy: [{ created_at: "desc" }, { id: "desc" }] });
return sendData(reply, request, { items: items.map(mapRun) });
});
fastify.get("/migration-runs/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const run = await fastify.prisma.migrationRun.findUnique({ where: { id: parseId(request.params?.id) } });
if (!run) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
return sendData(reply, request, { item: mapRun(run) });
});
}
module.exports = migrationRunsRoutes;
+747
查看文件
@@ -0,0 +1,747 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString } = require("../utils/date");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
const PAYMENT_CHANNEL_SCHEMA = z.enum(["bank", "alipay", "wechat", "cash", "other"]);
const LIST_QUERY_SCHEMA = z.object({
entityType: ENTITY_TYPE_SCHEMA.optional(),
status: z.enum(["draft", "reviewed", "executed", "failed", "cancelled"]).optional(),
});
const CREATE_SCHEMA = z.object({
entityType: ENTITY_TYPE_SCHEMA,
name: z.string().trim().min(1).max(120),
entityIds: z.array(UUID_SCHEMA).min(1).max(200),
defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
defaultPaymentChannel: PAYMENT_CHANNEL_SCHEMA.optional(),
voucherPrefix: z.string().trim().min(1).max(32).optional(),
}).strict();
const PATCH_ITEM_SCHEMA = z.object({
paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(),
paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
paymentChannelOverride: PAYMENT_CHANNEL_SCHEMA.nullable().optional(),
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const REVIEW_SCHEMA = z.object({
comment: z.string().trim().max(500).optional(),
resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(),
}).strict();
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseId(value, key = "id") {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [key]: "invalid_uuid" });
}
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function normalizeNullableString(value) {
if (value == null) {
return null;
}
const trimmed = String(value).trim();
return trimmed || null;
}
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "payment_batch_v1_disabled", reason: gate.reason }, 409);
}
return gate;
}
function ensureBatchManagePermission(fastify, request) {
if (!fastify.hasPermission(request.employee, "payment.batch.manage")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function ensureBatchReadPermission(fastify, request) {
if (
!fastify.hasPermission(request.employee, "payment.batch.manage") &&
!fastify.hasPermission(request.employee, "risk.review.manage")
) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function ensureBatchReviewPermission(fastify, request) {
if (!fastify.hasPermission(request.employee, "risk.review.manage")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function buildBatchNo() {
const now = new Date();
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
const time = now.toISOString().slice(11, 19).replace(/:/g, "");
return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}`;
}
async function loadBatchWithItems(tx, batchId) {
const batch = await tx.paymentBatch.findUnique({
where: { id: batchId },
include: {
items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
},
},
});
if (!batch) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return batch;
}
async function loadEntitySnapshots(tx, entityType, entityIds) {
if (entityType === "reimbursement") {
const items = await tx.reimbursementForm.findMany({
where: { id: { in: entityIds } },
select: {
id: true,
form_number: true,
description: true,
total_amount: true,
status: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
},
});
return items.map((item) => ({
entityId: item.id,
entityCode: item.form_number ?? item.id,
entityTitle: item.description || item.form_number || item.id,
amount: toMoneyString(item.total_amount),
currencyCode: "CNY",
status: item.status,
snapshotJson: {
formNumber: item.form_number ?? null,
description: item.description ?? null,
paymentVoucherNo: item.payment_voucher_no ?? null,
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel ?? null,
paidAt: item.paid_at?.toISOString?.() ?? null,
},
}));
}
const items = await tx.travelOrder.findMany({
where: { id: { in: entityIds } },
select: {
id: true,
source: true,
external_order_no: true,
amount: true,
currency: true,
status: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
},
});
return items.map((item) => ({
entityId: item.id,
entityCode: item.external_order_no,
entityTitle: `${item.source}:${item.external_order_no}`,
amount: toMoneyString(item.amount),
currencyCode: item.currency,
status: item.status,
snapshotJson: {
source: item.source,
externalOrderNo: item.external_order_no,
paymentVoucherNo: item.payment_voucher_no ?? null,
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel ?? null,
paidAt: item.paid_at?.toISOString?.() ?? null,
},
}));
}
function ensureBatchEntityStatus(entityType, snapshot) {
if (entityType === "reimbursement" && !["approved", "paid"].includes(snapshot.status)) {
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
}
if (entityType === "travel_order" && !["confirmed", "paid"].includes(snapshot.status)) {
throw buildValidationError({ entity: `${snapshot.entityCode}:invalid_pay_status` }, 409);
}
}
function mapBatch(batch, openRiskFlags = []) {
const riskMap = new Map();
for (const item of openRiskFlags) {
const entityId = item.entityId;
if (!riskMap.has(entityId)) {
riskMap.set(entityId, []);
}
riskMap.get(entityId).push(item);
}
return {
id: batch.id,
batchNo: batch.batch_no,
entityType: batch.entity_type,
name: batch.name,
status: batch.status,
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null,
defaultPaymentChannel: batch.default_payment_channel ?? null,
voucherPrefix: batch.voucher_prefix ?? null,
reviewComment: batch.review_comment ?? null,
executionSummaryJson: batch.execution_summary_json ?? null,
createdAt: batch.created_at?.toISOString?.() ?? null,
createdBy: batch.created_by ?? null,
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
reviewedBy: batch.reviewed_by ?? null,
executedAt: batch.executed_at?.toISOString?.() ?? null,
executedBy: batch.executed_by ?? null,
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
cancelledBy: batch.cancelled_by ?? null,
items: (batch.items ?? []).map((item) => ({
id: item.id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: item.payment_channel_override ?? null,
status: item.status,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
snapshotJson: item.snapshot_json,
createdAt: item.created_at?.toISOString?.() ?? null,
openRiskFlags: riskMap.get(item.entity_id) ?? [],
})),
};
}
async function listOpenRiskFlagsForBatch(tx, batch) {
const entityIds = batch.items.map((item) => item.entity_id);
if (!entityIds.length) {
return [];
}
const items = await tx.entityRiskFlag.findMany({
where: {
entity_type: batch.entity_type,
entity_id: { in: entityIds },
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return items.map(buildPersistedRiskFlag);
}
async function executeReimbursementPayment(tx, { batch, item, actorId, index }) {
const current = await tx.reimbursementForm.findUnique({
where: { id: item.entity_id },
select: {
id: true,
status: true,
form_number: true,
owner_id: true,
submitter_id: true,
department_id: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
paid_by: true,
},
});
if (!current) {
throw buildNotFoundError();
}
if (!["approved", "paid"].includes(current.status)) {
throw buildValidationError({ status: "invalid_pay_status" }, 409);
}
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
(batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
?? batch.default_payment_date?.toISOString?.()?.slice(0, 10)
?? null;
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null;
const existingPaymentDate = current.payment_date?.toISOString?.()?.slice(0, 10) ?? null;
if (current.status === "paid") {
const conflict =
(voucher != null && voucher !== (current.payment_voucher_no ?? null)) ||
(paymentDate != null && paymentDate !== existingPaymentDate) ||
(paymentChannel != null && paymentChannel !== (current.payment_channel ?? null));
if (conflict) {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
}
return { idempotent: true };
}
const now = new Date();
await tx.invoicePool.updateMany({
where: { linked_reimbursement_id: item.entity_id, status: "bound" },
data: {
status: "archived",
archived_reason: "used_complete",
archived_at: now,
updated_by: actorId,
},
});
await tx.reimbursementForm.update({
where: { id: item.entity_id },
data: {
status: "paid",
payment_voucher_no: voucher,
payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null,
payment_channel: paymentChannel,
paid_at: now,
paid_by: actorId,
updated_by: actorId,
},
});
return { idempotent: false };
}
async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) {
const current = await tx.travelOrder.findUnique({
where: { id: item.entity_id },
select: {
id: true,
status: true,
amount: true,
currency: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
paid_by: true,
},
});
if (!current) {
throw buildNotFoundError();
}
if (!["confirmed", "paid"].includes(current.status)) {
throw buildValidationError({ status: "invalid_pay_status" }, 409);
}
const voucher = normalizeNullableString(item.payment_voucher_no_override) ??
(batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
?? batch.default_payment_date?.toISOString?.()?.slice(0, 10)
?? null;
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null;
const existingPaymentDate = current.payment_date?.toISOString?.()?.slice(0, 10) ?? null;
if (current.status === "paid") {
const conflict =
voucher !== (current.payment_voucher_no ?? null) ||
paymentDate !== existingPaymentDate ||
paymentChannel !== (current.payment_channel ?? null);
if (conflict) {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
}
return { idempotent: true };
}
const matchedTransaction = await tx.cardTransaction.findFirst({
where: {
travel_order_id: item.entity_id,
txn_type: "charge",
status: "settled",
amount: current.amount,
currency: current.currency,
},
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
select: { id: true },
});
if (!matchedTransaction) {
throw buildValidationError({ payment: "missing_settled_transaction" }, 409);
}
await tx.travelOrder.update({
where: { id: item.entity_id },
data: {
status: "paid",
payment_voucher_no: voucher,
payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null,
payment_channel: paymentChannel,
paid_at: new Date(),
paid_by: actorId,
updated_by: actorId,
},
});
return { idempotent: false };
}
function buildNotFoundError() {
return new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
async function paymentBatchesRoutes(fastify) {
fastify.get("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const queryParsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
const query = queryParsed.data;
const items = await fastify.prisma.paymentBatch.findMany({
where: {
...(query.entityType ? { entity_type: query.entityType } : {}),
...(query.status ? { status: query.status } : {}),
},
include: { items: { orderBy: [{ created_at: "asc" }, { id: "asc" }] } },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
const mapped = [];
for (const batch of items) {
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
mapped.push(mapBatch(batch, openRiskFlags));
}
return sendData(reply, request, { items: mapped });
});
fastify.post("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const body = parseBody(CREATE_SCHEMA, request.body);
const entityIds = Array.from(new Set(body.entityIds));
const created = await fastify.prisma.$transaction(async (tx) => {
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds);
if (snapshots.length !== entityIds.length) {
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
}
for (const snapshot of snapshots) {
ensureBatchEntityStatus(body.entityType, snapshot);
}
const batch = await tx.paymentBatch.create({
data: {
batch_no: buildBatchNo(),
entity_type: body.entityType,
name: body.name,
status: "draft",
default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null,
default_payment_channel: body.defaultPaymentChannel ?? null,
voucher_prefix: normalizeNullableString(body.voucherPrefix),
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
for (const snapshot of snapshots) {
await tx.paymentBatchItem.create({
data: {
payment_batch_id: batch.id,
entity_id: snapshot.entityId,
entity_code: snapshot.entityCode,
entity_title: snapshot.entityTitle,
amount: snapshot.amount,
currency_code: snapshot.currencyCode,
status: "pending",
snapshot_json: snapshot.snapshotJson,
},
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_CREATED",
targetType: "payment_batch",
targetId: batch.id,
extra: {
entityType: body.entityType,
entityCount: snapshots.length,
},
}),
});
return loadBatchWithItems(tx, batch.id);
});
return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } });
});
fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const batchId = parseId(request.params?.id);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) });
});
fastify.patch("/payment-batches/:id/items/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const itemId = parseId(request.params?.itemId, "itemId");
const body = parseBody(PATCH_ITEM_SCHEMA, request.body);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
if (batch.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
}
const item = batch.items.find((entry) => entry.id === itemId);
if (!item) {
throw buildNotFoundError();
}
if (item.status !== "pending") {
throw buildValidationError({ item: "only_pending_editable" }, 409);
}
const updated = await fastify.prisma.paymentBatchItem.update({
where: { id: itemId },
data: {
...(body.paymentVoucherNoOverride !== undefined
? { payment_voucher_no_override: normalizeNullableString(body.paymentVoucherNoOverride) }
: {}),
...(body.paymentDateOverride !== undefined
? { payment_date_override: body.paymentDateOverride ? parseDateOnlyString(body.paymentDateOverride) : null }
: {}),
...(body.paymentChannelOverride !== undefined
? { payment_channel_override: body.paymentChannelOverride ?? null }
: {}),
},
});
return sendData(reply, request, {
item: {
id: updated.id,
paymentVoucherNoOverride: updated.payment_voucher_no_override ?? null,
paymentDateOverride: updated.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: updated.payment_channel_override ?? null,
},
});
});
fastify.post("/payment-batches/:id/review", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReviewPermission(fastify, request);
const batchId = parseId(request.params?.id);
const body = parseBody(REVIEW_SCHEMA, request.body);
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await loadBatchWithItems(tx, batchId);
if (batch.status !== "draft") {
throw buildValidationError({ status: "only_draft_reviewable" }, 409);
}
if (batch.created_by && batch.created_by === request.employee.id) {
throw buildValidationError({ review: "maker_checker_conflict" }, 409);
}
if (batch.items.some((item) => item.status !== "pending")) {
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
}
const openRiskFlags = await tx.entityRiskFlag.findMany({
where: {
entity_type: batch.entity_type,
entity_id: { in: batch.items.map((item) => item.entity_id) },
status: "open",
},
});
const resolveIds = new Set(body.resolveRiskFlagIds ?? []);
const unresolved = openRiskFlags.filter((item) => !resolveIds.has(item.id));
if (unresolved.length) {
throw buildValidationError({ resolveRiskFlagIds: "open_risk_flags_must_be_resolved" }, 409);
}
if (openRiskFlags.length) {
await tx.entityRiskFlag.updateMany({
where: { id: { in: openRiskFlags.map((item) => item.id) } },
data: {
status: "resolved",
resolved_at: new Date(),
resolved_by: request.employee.id,
},
});
}
const reviewed = await tx.paymentBatch.update({
where: { id: batchId },
data: {
status: "reviewed",
review_comment: normalizeNullableString(body.comment),
reviewed_at: new Date(),
reviewed_by: request.employee.id,
updated_by: request.employee.id,
},
include: { items: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_REVIEWED",
targetType: "payment_batch",
targetId: batchId,
extra: {
resolvedRiskFlagCount: openRiskFlags.length,
},
}),
});
return reviewed;
});
return sendData(reply, request, { item: mapBatch(result, []) });
});
fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await loadBatchWithItems(tx, batchId);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_reviewed_or_failed_executable" }, 409);
}
let successCount = 0;
let failedCount = 0;
for (const [index, item] of batch.items.entries()) {
if (!["pending", "failed"].includes(item.status)) {
continue;
}
try {
if (batch.entity_type === "reimbursement") {
await executeReimbursementPayment(tx, { batch, item, actorId: request.employee.id, index });
} else {
await executeTravelOrderPayment(tx, { batch, item, actorId: request.employee.id, index });
}
await tx.paymentBatchItem.update({
where: { id: item.id },
data: {
status: "executed",
failure_code: null,
failure_message: null,
},
});
successCount += 1;
} catch (error) {
const failureCode = error instanceof AppError ? error.code : "SYS_001";
const failureMessage = error instanceof AppError ? error.message : error instanceof Error ? error.message : "支付执行失败";
await tx.paymentBatchItem.update({
where: { id: item.id },
data: {
status: "failed",
failure_code: failureCode,
failure_message: failureMessage,
},
});
await ensureRiskFlag(tx, {
entityType: batch.entity_type,
entityId: item.entity_id,
riskCode: "payment_batch_execute_failed",
level: "high",
title: "支付批次执行失败",
detail: `${item.entity_code} 执行失败:${failureMessage}`,
source: "payment_batch",
actorId: request.employee.id,
payloadJson: {
batchId: batch.id,
itemId: item.id,
failureCode,
},
});
failedCount += 1;
}
}
const refreshed = await tx.paymentBatch.update({
where: { id: batchId },
data: {
status: failedCount > 0 ? "failed" : "executed",
execution_summary_json: {
successCount,
failedCount,
attemptedCount: successCount + failedCount,
},
executed_at: new Date(),
executed_by: request.employee.id,
updated_by: request.employee.id,
},
include: { items: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_EXECUTED",
targetType: "payment_batch",
targetId: batchId,
extra: {
successCount,
failedCount,
},
}),
});
return refreshed;
});
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result);
return sendData(reply, request, { item: mapBatch(result, openRiskFlags) });
});
fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
if (["executed", "cancelled"].includes(batch.status)) {
throw buildValidationError({ status: "batch_not_cancellable" }, 409);
}
const cancelled = await fastify.prisma.paymentBatch.update({
where: { id: batchId },
data: {
status: "cancelled",
cancelled_at: new Date(),
cancelled_by: request.employee.id,
updated_by: request.employee.id,
},
include: { items: true },
});
return sendData(reply, request, { item: mapBatch(cancelled, []) });
});
}
module.exports = paymentBatchesRoutes;
+115
查看文件
@@ -0,0 +1,115 @@
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
async function buildTaskVisibilityFilters(tx, request) {
const roleFilters = (request.employee.roleCodes ?? []).length
? [{ assignee_role: { in: request.employee.roleCodes } }]
: [];
return [{ assignee_id: request.employee.id }, ...roleFilters];
}
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.portalHome,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { featureFlag: "portal_home_v1_disabled", reason: gate.reason },
});
}
}
function buildQuickLinks(request) {
const items = [];
if (request.employee.permissionCodes.includes("expense.view_own") || request.employee.permissionCodes.includes("expense.view_all")) {
items.push({ key: "expense", title: "我的报销", href: "/reimbursements" });
}
if (request.employee.permissionCodes.includes("procurement.view_own") || request.employee.permissionCodes.includes("procurement.view_all")) {
items.push({ key: "procurement", title: "采购申请", href: "/purchase-requests" });
}
if (request.employee.permissionCodes.includes("travel.view_own") || request.employee.permissionCodes.includes("travel.view_all")) {
items.push({ key: "travel", title: "差旅申请", href: "/travel-requests" });
}
if (request.employee.permissionCodes.includes("budget.view") || request.employee.permissionCodes.includes("budget.manage")) {
items.push({ key: "budget", title: "预算管理", href: "/budgets" });
}
if (request.employee.permissionCodes.includes("payment.batch.manage")) {
items.push({ key: "payment-batches", title: "支付批次", href: "/payment-batches" });
}
return items;
}
async function portalRoutes(fastify) {
fastify.get("/portal/home", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
const now = new Date();
const [pendingTaskCount, reimbursementsCount, leaveCount, purchaseCount, travelCount, riskCount, announcementRows, paymentBatchCount] = await Promise.all([
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }),
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
fastify.prisma.purchaseRequest.count({ where: { applicant_id: request.employee.id } }),
fastify.prisma.travelRequest.count({ where: { OR: [{ applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
(fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage"))
? fastify.prisma.entityRiskFlag.count({ where: { status: "open" } })
: Promise.resolve(0),
fastify.prisma.portalAnnouncement.findMany({
where: {
enabled: true,
OR: [{ starts_at: null }, { starts_at: { lte: now } }],
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }],
},
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
take: 6,
}),
fastify.hasPermission(request.employee, "payment.batch.manage")
? fastify.prisma.paymentBatch.count({ where: { status: { in: ["draft", "reviewed", "failed"] } } })
: Promise.resolve(0),
]);
const cards = [
{ key: "pending_tasks", title: "我的待办", value: pendingTaskCount, href: "/workflow/tasks", severity: pendingTaskCount > 0 ? "warning" : "normal" },
{ key: "my_reimbursements", title: "报销单", value: reimbursementsCount, href: "/reimbursements", severity: "normal" },
{ key: "my_purchases", title: "采购单", value: purchaseCount, href: "/purchase-requests", severity: "normal" },
{ key: "my_travel", title: "差旅单", value: travelCount, href: "/travel-requests", severity: "normal" },
];
if (paymentBatchCount > 0) {
cards.push({ key: "payment_batches", title: "待处理支付批次", value: paymentBatchCount, href: "/payment-batches", severity: "warning" });
}
if (riskCount > 0) {
cards.push({ key: "risk_flags", title: "待复核风险", value: riskCount, href: "/ap-operations", severity: "high" });
}
return sendData(reply, request, {
greeting: { name: request.employee.name ?? request.employee.id },
cards,
pendingTasks: { count: pendingTaskCount, href: "/workflow/tasks" },
myApplications: [
{ domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" },
{ domain: "leave", count: leaveCount, href: "/leave-requests" },
{ domain: "purchase_request", count: purchaseCount, href: "/purchase-requests" },
{ domain: "travel_request", count: travelCount, href: "/travel-requests" },
],
riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null,
announcements: announcementRows.map((item) => ({
id: item.id,
title: item.title,
summary: item.summary,
href: item.link_url ?? null,
})),
quickLinks: buildQuickLinks(request),
});
});
}
module.exports = portalRoutes;
+124 -4
查看文件
@@ -12,6 +12,12 @@ const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("..
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const { issueDocumentNumber } = require("../services/documentSequence");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildPurchaseRequestPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const {
PROCUREMENT_ERP_TARGETS,
enqueuePurchaseAccrualJournalExport,
@@ -306,6 +312,31 @@ function normalizeBudgetControlMode(config) {
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
precheck: precheck.blockingReasons,
},
});
}
function ensurePurchaseReadPermission(fastify, request) {
const canViewOwn = fastify.hasPermission(request.employee, "procurement.view_own");
const canViewAll =
@@ -1010,6 +1041,13 @@ async function purchaseRequestRoutes(fastify) {
preHandler: fastify.requirePermission("procurement.create"),
},
async (request, reply) => {
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "procurement",
departmentId: request.employee.department_id ?? null,
});
const purchaseRequest = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.purchaseRequest.create({
data: {
@@ -1078,7 +1116,19 @@ async function purchaseRequestRoutes(fastify) {
});
}
return sendData(reply, request, mapPurchaseRequestDetail(purchaseRequest, request));
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "purchase_request",
entity_id: purchaseRequestId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
...mapPurchaseRequestDetail(purchaseRequest, request),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
@@ -1178,6 +1228,41 @@ async function purchaseRequestRoutes(fastify) {
},
);
fastify.post(
"/purchase-requests/:id/precheck",
{
preHandler: fastify.requirePermission("procurement.submit"),
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: { id: true, department_id: true },
});
if (!purchaseRequest) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("purchase_request", purchaseRequestId));
}
const precheck = await buildPurchaseRequestPrecheck(fastify.prisma, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/purchase-requests/:id/submit",
{
@@ -1197,6 +1282,15 @@ async function purchaseRequestRoutes(fastify) {
throw buildValidationError({ status: "only_draft_submittable" }, 409);
}
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "procurement",
departmentId: locked.department_id ?? request.employee.department_id ?? null,
legalEntityId: locked.legal_entity_id ?? null,
});
const purchaseRequest = await tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
@@ -1244,6 +1338,30 @@ async function purchaseRequestRoutes(fastify) {
const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id);
const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id);
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildPurchaseRequestPrecheck(tx, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("purchase_request", purchaseRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "purchase_request",
entityId: purchaseRequestId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const effectiveDate = new Date();
let resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
departmentId: purchaseRequest.department_id,
@@ -1263,12 +1381,14 @@ async function purchaseRequestRoutes(fastify) {
}
const warnings = [];
const budgetControlMode = normalizeBudgetControlMode(fastify.config);
const budgetControlMode = authoritativePrecheck?.budgetHint?.controlMode ?? normalizeBudgetControlMode(fastify.config);
if (!selectedBudgetPoolId) {
if (budgetControlMode === "block") {
if (!budgetPrecheckGate.enabled && budgetControlMode === "block") {
throw buildValidationError({ budget: "missing_budget_pool" }, 409);
}
warnings.push("missing_budget_pool");
if (!budgetPrecheckGate.enabled) {
warnings.push("missing_budget_pool");
}
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
+123
查看文件
@@ -13,7 +13,13 @@ const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const {
buildPersistedRiskFlag,
buildReimbursementPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const {
buildWorkflowStepsFromApprovalRule,
@@ -385,6 +391,31 @@ function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
};
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
precheck: precheck.blockingReasons,
},
});
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
ownerEmployeeId: z.string().uuid().optional(),
@@ -399,6 +430,13 @@ async function reimbursementRoutes(fastify) {
preHandler: fastify.requirePermission("expense.create"),
},
async (request, reply) => {
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "expense",
departmentId: request.employee.department_id ?? null,
});
const parsed = createBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
@@ -688,6 +726,14 @@ async function reimbursementRoutes(fastify) {
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
id: reimbursement.id,
@@ -732,6 +778,7 @@ async function reimbursementRoutes(fastify) {
task: pendingTask,
}
: null,
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
@@ -763,6 +810,13 @@ async function reimbursementRoutes(fastify) {
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "expense",
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
});
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -1183,6 +1237,42 @@ async function reimbursementRoutes(fastify) {
},
);
fastify.post(
"/reimbursements/:id/precheck",
{
preHandler: fastify.requirePermission("expense.submit"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, department_id: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("reimbursement", reimbursementId));
}
const precheck = await buildReimbursementPrecheck(fastify.prisma, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/reimbursements/:id/submit",
{
@@ -1256,6 +1346,30 @@ async function reimbursementRoutes(fastify) {
});
}
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildReimbursementPrecheck(tx, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("reimbursement", reimbursementId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "reimbursement",
entityId: reimbursementId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const budgetPool = await resolveApplicableBudgetPool(tx, reimbursement);
if (budgetPool) {
const allocatedAmount = Number(budgetPool.allocated_amount ?? 0);
@@ -1518,6 +1632,8 @@ async function reimbursementRoutes(fastify) {
currentTaskId: workflowTask.id,
status: "reviewing",
budget: budgetCheck,
precheckDecision: authoritativePrecheck.decision,
openedRiskFlagCount: openedRiskFlags.length,
};
});
@@ -1700,6 +1816,13 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "expense",
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
});
if (!["approved", "paid"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
+117
查看文件
@@ -0,0 +1,117 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { buildPersistedRiskFlag } = require("../services/feeControl");
const UUID_SCHEMA = z.string().uuid();
const QUERY_SCHEMA = z.object({
entityType: z.string().trim().max(32).optional(),
entityId: UUID_SCHEMA.optional(),
status: z.enum(["open", "resolved", "dismissed"]).optional(),
source: z.string().trim().max(32).optional(),
});
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function hasListPermission(fastify, request) {
return (
fastify.hasPermission(request.employee, "budget.view") ||
fastify.hasPermission(request.employee, "payment.batch.manage") ||
fastify.hasPermission(request.employee, "risk.review.manage")
);
}
function ensureListPermission(fastify, request) {
if (!hasListPermission(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function ensureManagePermission(fastify, request) {
if (!fastify.hasPermission(request.employee, "risk.review.manage")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
function parseQuery(query) {
const parsed = QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function parseId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
async function riskFlagsRoutes(fastify) {
fastify.get("/risk-flags", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureListPermission(fastify, request);
const query = parseQuery(request.query);
const items = await fastify.prisma.entityRiskFlag.findMany({
where: {
...(query.entityType ? { entity_type: query.entityType } : {}),
...(query.entityId ? { entity_id: query.entityId } : {}),
...(query.status ? { status: query.status } : {}),
...(query.source ? { source: query.source } : {}),
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, { items: items.map(buildPersistedRiskFlag) });
});
for (const action of ["resolve", "dismiss"]) {
fastify.post(`/risk-flags/:id/${action}`, { preHandler: fastify.authenticate }, async (request, reply) => {
ensureManagePermission(fastify, request);
const riskFlagId = parseId(request.params?.id);
const current = await fastify.prisma.entityRiskFlag.findUnique({ where: { id: riskFlagId } });
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (current.status !== "open") {
throw buildValidationError({ status: "risk_flag_not_open" }, 409);
}
const updated = await fastify.prisma.entityRiskFlag.update({
where: { id: riskFlagId },
data: {
status: action === "resolve" ? "resolved" : "dismissed",
resolved_at: new Date(),
resolved_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: action === "resolve" ? "ENTITY_RISK_FLAG_RESOLVED" : "ENTITY_RISK_FLAG_DISMISSED",
targetType: "entity_risk_flag",
targetId: updated.id,
extra: {
entityType: updated.entity_type,
entityId: updated.entity_id,
riskCode: updated.risk_code,
},
}),
});
return sendData(reply, request, { item: buildPersistedRiskFlag(updated) });
});
}
}
module.exports = riskFlagsRoutes;
+225
查看文件
@@ -0,0 +1,225 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const QUERY_SCHEMA = z.object({
q: z.string().trim().min(2).max(100),
limit: z.coerce.number().int().min(1).max(20).optional(),
});
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.globalSearch,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "global_search_v1_disabled", reason: gate.reason }, 409);
}
}
function matchByValue(query, ...values) {
const lower = query.toLowerCase();
const normalized = values.filter(Boolean).map((item) => String(item).toLowerCase());
if (normalized.some((item) => item === lower)) return "exact";
if (normalized.some((item) => item.startsWith(lower))) return "prefix";
return "contains";
}
function compareMatchedBy(left, right) {
const score = { exact: 0, prefix: 1, contains: 2 };
return score[left] - score[right];
}
async function searchRoutes(fastify) {
fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
const q = parsed.data.q.trim();
const limit = parsed.data.limit ?? 10;
const tasks = [];
const isAdmin = fastify.hasPermission(request.employee, "*");
if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) {
tasks.push(
fastify.prisma.reimbursementForm.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "expense.view_all") || isAdmin
? {}
: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] },
{
OR: [
{ form_number: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: "insensitive" } },
],
},
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, form_number: true, description: true, status: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "reimbursement",
entityId: item.id,
title: item.form_number ?? item.id,
subtitle: item.description ?? null,
status: item.status,
href: `/reimbursements/${item.id}`,
matchedBy: matchByValue(q, item.form_number, item.description),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) {
tasks.push(
fastify.prisma.leaveRequest.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin
? {}
: { owner_id: request.employee.id },
{ OR: [{ reason: { contains: q, mode: "insensitive" } }] },
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "leave",
entityId: item.id,
title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`,
subtitle: item.reason ?? null,
status: item.status,
href: "/leave-requests",
matchedBy: matchByValue(q, item.reason),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
if (fastify.hasPermission(request.employee, "procurement.view_own") || fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage")) {
tasks.push(
fastify.prisma.purchaseRequest.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage") || isAdmin
? {}
: { applicant_id: request.employee.id },
{
OR: [
{ request_no: { contains: q, mode: "insensitive" } },
{ title: { contains: q, mode: "insensitive" } },
],
},
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, request_no: true, title: true, status: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "purchase_request",
entityId: item.id,
title: item.request_no ?? item.title,
subtitle: item.title,
status: item.status,
href: `/purchase-requests?id=${item.id}`,
matchedBy: matchByValue(q, item.request_no, item.title),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
if (fastify.hasPermission(request.employee, "travel.view_own") || fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage")) {
tasks.push(
fastify.prisma.travelRequest.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage") || isAdmin
? {}
: { OR: [{ applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }] },
{
OR: [
{ request_no: { contains: q, mode: "insensitive" } },
{ purpose: { contains: q, mode: "insensitive" } },
],
},
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, request_no: true, purpose: true, status: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "travel_request",
entityId: item.id,
title: item.request_no ?? item.purpose,
subtitle: item.purpose,
status: item.status,
href: `/travel-requests?id=${item.id}`,
matchedBy: matchByValue(q, item.request_no, item.purpose),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
if (
fastify.hasPermission(request.employee, "asset.view_own") ||
fastify.hasPermission(request.employee, "asset.view_all") ||
fastify.hasPermission(request.employee, "asset.manage") ||
fastify.hasPermission(request.employee, "asset.transfer") ||
fastify.hasPermission(request.employee, "asset.inventory")
) {
tasks.push(
fastify.prisma.assetCard.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin
? {}
: { assignee_employee_id: request.employee.id },
{
OR: [
{ asset_no: { contains: q, mode: "insensitive" } },
{ serial_no: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
],
},
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "asset",
entityId: item.id,
title: item.asset_no,
subtitle: item.name ?? item.serial_no ?? null,
status: item.status,
href: "/assets",
matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
const items = (await Promise.all(tasks)).flat();
items.sort((left, right) => {
const matchedDiff = compareMatchedBy(left.matchedBy, right.matchedBy);
if (matchedDiff !== 0) return matchedDiff;
return String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""));
});
return sendData(reply, request, { items: items.slice(0, limit) });
});
}
module.exports = searchRoutes;
+119 -1
查看文件
@@ -12,6 +12,13 @@ const { sendNotificationEmailSafe } = require("../services/email");
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { buildTravelPaymentTrack } = require("../services/travelPayments");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildTravelRequestPrecheck,
createRiskFlagsFromPrecheck,
} = require("../services/feeControl");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const UUID_SCHEMA = z.string().uuid();
@@ -281,6 +288,29 @@ function ensureDateRangeValid(startDate, endDate) {
}
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { precheck: precheck.blockingReasons },
});
}
async function ensureFinancialDimensions(tx, { legalEntityId, costCenterId }) {
const [legalEntity, costCenter] = await Promise.all([
tx.legalEntity.findUnique({
@@ -641,6 +671,14 @@ async function createTravelRequestsRoutes(fastify) {
},
async (request, reply) => {
const body = parseBody(TRAVEL_CREATE_BODY_SCHEMA, request.body);
await ensureCutoverAllowed({
prisma: fastify.prisma,
config: fastify.config,
request,
domain: "travel",
departmentId: request.employee.department_id ?? null,
legalEntityId: body.legalEntityId,
});
const startDate = parseDateOnlyString(body.startDate);
const endDate = parseDateOnlyString(body.endDate);
ensureDateRangeValid(startDate, endDate);
@@ -719,7 +757,18 @@ async function createTravelRequestsRoutes(fastify) {
ensureTravelReadPermission(fastify, request);
const travelRequestId = parseTravelRequestId(request.params?.id);
const detail = await loadReadableTravelRequest(fastify.prisma, travelRequestId, request);
return sendData(reply, request, mapTravelRequestDetail(detail, request));
const riskFlags = await fastify.prisma.entityRiskFlag.findMany({
where: {
entity_type: "travel_request",
entity_id: travelRequestId,
status: "open",
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return sendData(reply, request, {
...mapTravelRequestDetail(detail, request),
riskFlags: riskFlags.map(buildPersistedRiskFlag),
});
},
);
@@ -836,6 +885,41 @@ async function createTravelRequestsRoutes(fastify) {
},
);
fastify.post(
"/travel-requests/:id/precheck",
{
preHandler: fastify.requirePermission("travel.submit"),
},
async (request, reply) => {
const travelRequestId = parseTravelRequestId(request.params?.id);
const travelRequest = await fastify.prisma.travelRequest.findUnique({
where: { id: travelRequestId },
select: { id: true, department_id: true },
});
if (!travelRequest) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("travel_request", travelRequestId));
}
const precheck = await buildTravelRequestPrecheck(fastify.prisma, {
travelRequestId,
config: fastify.config,
logger: fastify.log,
});
return sendData(reply, request, precheck);
},
);
fastify.post(
"/travel-requests/:id/submit",
{
@@ -849,6 +933,14 @@ async function createTravelRequestsRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const travelRequest = await loadTravelRequestForUpdate(tx, travelRequestId);
ensureDraftOwner(request, travelRequest);
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
request,
domain: "travel",
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
legalEntityId: travelRequest.legal_entity_id ?? null,
});
if (travelRequest.status !== "draft") {
throw buildValidationError({ status: "only_draft_submittable" }, 409);
}
@@ -856,6 +948,30 @@ async function createTravelRequestsRoutes(fastify) {
throw buildValidationError({ segments: "required" }, 409);
}
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildTravelRequestPrecheck(tx, {
travelRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("travel_request", travelRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "travel_request",
entityId: travelRequestId,
actorId: request.employee.id,
precheck: authoritativePrecheck,
});
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: { entity_type: "travel_request", is_active: true },
orderBy: { version: "desc" },
@@ -1050,6 +1166,8 @@ async function createTravelRequestsRoutes(fastify) {
currentTaskId: workflowTask.id,
budgetPoolId: selectedBudgetPoolId,
budgetReservation,
precheckDecision: authoritativePrecheck.decision,
openedRiskFlagCount: openedRiskFlags.length,
};
});
+194
查看文件
@@ -0,0 +1,194 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]);
const ASSIGNEE_RULE_SCHEMA = z.union([
z.object({ type: z.literal("department_manager") }).strict(),
z.object({ type: z.literal("role"), value: z.string().trim().min(1).max(32) }).strict(),
z.object({ type: z.literal("specific_user"), value: UUID_SCHEMA }).strict(),
]);
const WRITE_SCHEMA = z.object({
entityType: ENTITY_TYPE_SCHEMA,
name: z.string().trim().min(1).max(120),
steps: z.array(z.object({ id: z.string().trim().min(1).max(64), name: z.string().trim().min(1).max(120), assigneeRule: ASSIGNEE_RULE_SCHEMA }).strict()).min(1).max(20),
notes: z.string().trim().max(500).optional(),
}).strict();
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); }
function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ id: "invalid_uuid" }); return parsed.data; }
function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) throw buildValidationError(parsed.error.flatten()); return parsed.data; }
function parseEntityType(value) { const parsed = ENTITY_TYPE_SCHEMA.safeParse(value); if (!parsed.success) throw buildValidationError({ entityType: "invalid" }); return parsed.data; }
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin, strictDepartment: false });
if (!gate.enabled) throw buildValidationError({ featureFlag: "workflow_definition_admin_v1_disabled", reason: gate.reason }, 409);
}
function mapStatus(item) {
if (item.archived_at) return "archived";
if (item.published_at && item.is_active) return "published";
return "draft";
}
function mapDefinition(item) {
return {
id: item.id,
entityType: item.entity_type,
name: item.name,
version: item.version,
status: mapStatus(item),
source: "manual",
notes: item.definition_json?.notes ?? "",
steps: (item.definition_json?.steps ?? []).map((step) => ({
id: step.id,
name: step.name,
assigneeRule: step.assignee_rule,
})),
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
publishedAt: item.published_at?.toISOString?.() ?? null,
archivedAt: item.archived_at?.toISOString?.() ?? null,
};
}
async function buildReimbursementProjection(tx) {
const rules = await tx.approvalRule.findMany({ where: { enabled: true }, orderBy: { priority: "asc" } });
const steps = [];
const seen = new Set();
for (const rule of rules) {
for (const roleCode of [rule.level1_role, rule.level2_role, rule.level3_role].filter(Boolean)) {
if (seen.has(roleCode)) continue;
seen.add(roleCode);
steps.push({
id: `approval_${roleCode}`,
name: roleCode === "manager" ? "直属经理审批" : `${roleCode} 审批`,
assigneeRule: roleCode === "manager" ? { type: "department_manager" } : { type: "role", value: roleCode },
});
}
}
return {
id: "reimbursement-derived",
entityType: "reimbursement",
name: "报销流程(审批规则投影)",
version: 1,
status: "derived_readonly",
source: "approval_rules_projection",
notes: "实际报销审批链由 approval_rules 按金额区间投影生成。",
steps,
createdAt: null,
updatedAt: null,
publishedAt: null,
archivedAt: null,
};
}
async function workflowDefinitionsRoutes(fastify) {
fastify.get("/workflow-definitions", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const items = await fastify.prisma.workflowDefinition.findMany({
where: { entity_type: { in: ["leave", "purchase_request", "travel_request"] } },
orderBy: [{ entity_type: "asc" }, { version: "desc" }],
});
const reimbursement = await buildReimbursementProjection(fastify.prisma);
return sendData(reply, request, { items: [reimbursement, ...items.map(mapDefinition)] });
});
fastify.get("/workflow-definitions/entity/:entityType/active", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const entityType = parseEntityType(request.params?.entityType);
if (entityType === "reimbursement") {
return sendData(reply, request, { item: await buildReimbursementProjection(fastify.prisma) });
}
const item = await fastify.prisma.workflowDefinition.findFirst({ where: { entity_type: entityType, is_active: true, archived_at: null }, orderBy: { version: "desc" } });
return sendData(reply, request, { item: item ? mapDefinition(item) : null });
});
fastify.get("/workflow-definitions/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const id = String(request.params?.id ?? "");
if (id === "reimbursement-derived") {
return sendData(reply, request, { item: await buildReimbursementProjection(fastify.prisma) });
}
const definitionId = parseId(id);
const item = await fastify.prisma.workflowDefinition.findUnique({ where: { id: definitionId } });
if (!item) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
return sendData(reply, request, { item: mapDefinition(item) });
});
fastify.post("/workflow-definitions", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const body = parseBody(WRITE_SCHEMA, request.body);
if (body.entityType === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409);
const latest = await fastify.prisma.workflowDefinition.findFirst({ where: { entity_type: body.entityType }, orderBy: { version: "desc" }, select: { version: true } });
const created = await fastify.prisma.workflowDefinition.create({
data: {
entity_type: body.entityType,
name: body.name,
version: (latest?.version ?? 0) + 1,
is_active: false,
definition_json: { version: 1, notes: body.notes ?? "", steps: body.steps.map((step) => ({ id: step.id, name: step.name, type: "approval", assignee_rule: step.assigneeRule })) },
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
return reply.code(201).send({ requestId: request.id, data: { item: mapDefinition(created) } });
});
fastify.patch("/workflow-definitions/:id", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const definitionId = parseId(request.params?.id);
const body = parseBody(PATCH_SCHEMA, request.body);
const current = await fastify.prisma.workflowDefinition.findUnique({ where: { id: definitionId } });
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
if (current.entity_type === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409);
if (current.archived_at || (current.published_at && current.is_active)) throw buildValidationError({ status: "published_or_archived_readonly" }, 409);
const updated = await fastify.prisma.workflowDefinition.update({
where: { id: definitionId },
data: {
...(body.name !== undefined ? { name: body.name } : {}),
...(body.steps !== undefined || body.notes !== undefined
? {
definition_json: {
version: 1,
notes: body.notes ?? current.definition_json?.notes ?? "",
steps: (body.steps ?? current.definition_json?.steps ?? []).map((step) => ({ id: step.id, name: step.name, type: "approval", assignee_rule: step.assigneeRule ?? step.assignee_rule })) ,
},
}
: {}),
updated_by: request.employee.id,
},
});
return sendData(reply, request, { item: mapDefinition(updated) });
});
fastify.post("/workflow-definitions/:id/publish", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const definitionId = parseId(request.params?.id);
const result = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.workflowDefinition.findUnique({ where: { id: definitionId } });
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
if (current.entity_type === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409);
if (current.archived_at) throw buildValidationError({ status: "archived_not_publishable" }, 409);
await tx.workflowDefinition.updateMany({ where: { entity_type: current.entity_type, id: { not: definitionId }, is_active: true }, data: { is_active: false, updated_by: request.employee.id } });
return tx.workflowDefinition.update({ where: { id: definitionId }, data: { is_active: true, published_at: new Date(), updated_by: request.employee.id } });
});
return sendData(reply, request, { item: mapDefinition(result) });
});
fastify.post("/workflow-definitions/:id/archive", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const definitionId = parseId(request.params?.id);
const current = await fastify.prisma.workflowDefinition.findUnique({ where: { id: definitionId } });
if (!current) throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
if (current.entity_type === "reimbursement") throw buildValidationError({ entityType: "unsupported_entity_type" }, 409);
const archived = await fastify.prisma.workflowDefinition.update({ where: { id: definitionId }, data: { is_active: false, archived_at: new Date(), updated_by: request.employee.id } });
return sendData(reply, request, { item: mapDefinition(archived) });
});
}
module.exports = workflowDefinitionsRoutes;