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
这个提交包含在:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
在新工单中引用
屏蔽一个用户