feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)

- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40)

- 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5)

- 行政代提交/代上传后端校验与审计(F7/F8)

- 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
2026-02-09 16:20:31 +08:00
父节点 a8080b05f8
当前提交 f755ff0dda
共修改 27 个文件,包含 3572 行新增和 97 行删除
+147
查看文件
@@ -0,0 +1,147 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const ENABLED_QUERY_SCHEMA = z.object({
enabled: z.enum(["true", "false", "1", "0"]).optional(),
});
function parseEnabledQuery(query) {
const parsed = ENABLED_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const enabledRaw = parsed.data.enabled;
const enabled =
enabledRaw == null
? null
: ["true", "1"].includes(enabledRaw)
? true
: ["false", "0"].includes(enabledRaw)
? false
: null;
return { enabled };
}
function mapApprovalRule(item) {
return {
id: item.id,
name: item.name,
minAmount: item.min_amount?.toString?.() ?? String(item.min_amount ?? "0"),
maxAmount: item.max_amount == null ? null : item.max_amount?.toString?.() ?? String(item.max_amount),
level1Role: item.level1_role,
level2Role: item.level2_role ?? null,
level3Role: item.level3_role ?? null,
enabled: item.enabled,
priority: item.priority,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function mapDocumentSequence(item, exists) {
const fallback = {
documentType: "reimbursement",
prefix: "BX",
dateFormat: "YYYYMMDD",
separator: "-",
numberLength: 4,
resetPeriod: "none",
currentNumber: 0,
lastResetDate: null,
updatedAt: null,
updatedBy: null,
};
if (!item) {
return { exists, ...fallback };
}
return {
exists,
documentType: item.document_type,
prefix: item.prefix,
dateFormat: item.date_format,
separator: item.separator,
numberLength: item.number_length,
resetPeriod: item.reset_period,
currentNumber: item.current_number,
lastResetDate: item.last_reset_date?.toISOString?.()?.slice(0, 10) ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
updatedBy: item.updated_by ?? null,
};
}
async function configReadRoutes(fastify) {
fastify.get(
"/approval-rules",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseEnabledQuery(request.query);
const items = await fastify.prisma.approvalRule.findMany({
where: {
...(query.enabled == null ? {} : { enabled: query.enabled }),
},
orderBy: [{ priority: "asc" }, { min_amount: "asc" }],
select: {
id: true,
name: true,
min_amount: true,
max_amount: true,
level1_role: true,
level2_role: true,
level3_role: true,
enabled: true,
priority: true,
created_at: true,
updated_at: true,
},
});
return sendData(reply, request, {
enabled: query.enabled,
items: items.map(mapApprovalRule),
});
},
);
fastify.get(
"/document-sequences/reimbursement",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const sequence = await fastify.prisma.documentSequence.findUnique({
where: { document_type: "reimbursement" },
select: {
document_type: true,
prefix: true,
date_format: true,
separator: true,
number_length: true,
reset_period: true,
current_number: true,
last_reset_date: true,
updated_at: true,
updated_by: true,
},
});
return sendData(reply, request, mapDocumentSequence(sequence, Boolean(sequence)));
},
);
}
module.exports = configReadRoutes;