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 行删除
+688 -19
查看文件
@@ -1,7 +1,9 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
const { findActiveDelegationForDelegate } = require("../utils/delegation");
const {
buildWorkflowStepsFromApprovalRule,
resolveActionableStep,
@@ -24,6 +26,18 @@ const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
scope: z.enum(["own", "all"]).optional(),
});
const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().trim().max(500).nullable().optional(),
});
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
expenseTypeId: z.string().uuid(),
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
description: z.string().trim().min(1).max(500),
});
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (canViewAll) {
@@ -50,6 +64,24 @@ function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
}
}
function ensureApplicantOrAdmin(request, reimbursement) {
if (request.employee?.roleCodes?.includes("admin")) {
return;
}
const isApplicant =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isApplicant) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
}
function formatFormNumber(sequence, timezone) {
const datePart = compactDateString(timezone);
const serial = String(sequence.current_number).padStart(sequence.number_length, "0");
@@ -88,6 +120,53 @@ function parseReimbursementId(id) {
return parsed.data;
}
function parseReimbursementUpdateBody(body) {
const parsed = REIMBURSEMENT_UPDATE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
if (!Object.keys(parsed.data).length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty" },
});
}
return parsed.data;
}
function parseItemBody(body) {
const parsed = REIMBURSEMENT_ITEM_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const amountNumber = Number(parsed.data.amount);
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { amount: "invalid" },
});
}
return parsed.data;
}
function parsePayBody(body) {
const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
@@ -214,6 +293,24 @@ function ensureNonNegativeAmount(totalAmount) {
}
}
async function recomputeTotalAmount(tx, reimbursementId, actorId) {
const aggregate = await tx.reimbursementItem.aggregate({
where: { reimbursement_id: reimbursementId },
_sum: { amount: true },
});
const nextTotal = aggregate._sum.amount ?? "0";
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
total_amount: nextTotal,
updated_by: actorId,
},
});
return nextTotal;
}
async function loadApprovalRuleForAmount(tx, totalAmount) {
const amountString = toMoneyString(totalAmount);
const approvalRule = await tx.approvalRule.findFirst({
@@ -246,6 +343,7 @@ async function loadApprovalRuleForAmount(tx, totalAmount) {
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
ownerEmployeeId: z.string().uuid().optional(),
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().max(500).optional(),
});
@@ -269,29 +367,102 @@ async function reimbursementRoutes(fastify) {
const reimbursementDate =
parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
const reimbursement = await fastify.prisma.reimbursementForm.create({
data: {
submitter_id: request.employee.id,
owner_id: request.employee.id,
department_id: request.employee.department_id,
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: parsed.data.description ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
reimbursement_date: true,
description: true,
total_amount: true,
form_number: true,
},
const ownerEmployeeId = parsed.data.ownerEmployeeId ?? request.employee.id;
const isProxyCreate = ownerEmployeeId !== request.employee.id;
const reimbursement = await fastify.prisma.$transaction(async (tx) => {
let departmentId = request.employee.department_id;
let delegationGrantId = null;
if (isProxyCreate) {
const isAdministration = request.employee.roleCodes?.includes("administration");
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
if (!isAdministration || !canProxySubmit) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const ownerEmployee = await tx.employee.findUnique({
where: { id: ownerEmployeeId },
select: { id: true, status: true, department_id: true },
});
if (!ownerEmployee || ownerEmployee.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { ownerEmployeeId: "invalid_owner" },
});
}
const delegation = await findActiveDelegationForDelegate(tx, {
principalEmployeeId: ownerEmployeeId,
delegateEmployeeId: request.employee.id,
allowedScopes: ["reimbursement_submit", "all"],
now: new Date(),
});
if (!delegation) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { delegation: "missing_active_grant" },
});
}
delegationGrantId = delegation.id;
departmentId = ownerEmployee.department_id;
}
const created = await tx.reimbursementForm.create({
data: {
submitter_id: request.employee.id,
owner_id: ownerEmployeeId,
department_id: departmentId,
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: parsed.data.description ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
reimbursement_date: true,
description: true,
total_amount: true,
form_number: true,
owner_id: true,
submitter_id: true,
},
});
if (isProxyCreate) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_PROXY_CREATED",
targetType: "reimbursement_form",
targetId: created.id,
extra: {
ownerEmployeeId,
submitterEmployeeId: request.employee.id,
delegationGrantId,
},
}),
});
}
return created;
});
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
submitterId: reimbursement.submitter_id,
ownerId: reimbursement.owner_id,
reimbursementDate,
status: reimbursement.status,
description: reimbursement.description,
@@ -402,6 +573,25 @@ async function reimbursementRoutes(fastify) {
submitter: {
select: { id: true, employee_no: true, name: true },
},
items: {
orderBy: { expense_date: "desc" },
select: {
id: true,
expense_type_id: true,
expense_date: true,
amount: true,
description: true,
expense_type: {
select: {
id: true,
code: true,
name: true,
category: true,
enabled: true,
},
},
},
},
invoice_pool_items: {
select: {
id: true,
@@ -458,6 +648,22 @@ async function reimbursementRoutes(fastify) {
description: reimbursement.description,
owner: reimbursement.owner,
submitter: reimbursement.submitter,
items: reimbursement.items.map((item) => ({
id: item.id,
expenseTypeId: item.expense_type_id,
expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null,
amount: toMoneyString(item.amount),
description: item.description,
expenseType: item.expense_type
? {
id: item.expense_type.id,
code: item.expense_type.code,
name: item.expense_type.name,
category: item.expense_type.category,
enabled: item.expense_type.enabled,
}
: null,
})),
invoices: reimbursement.invoice_pool_items.map((item) => ({
id: item.id,
status: item.status,
@@ -476,6 +682,453 @@ async function reimbursementRoutes(fastify) {
},
);
fastify.patch(
"/reimbursements/:id",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const body = parseReimbursementUpdateBody(request.body);
const updated = await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_edit_status" },
});
}
const nextDescription =
body.description === undefined ? undefined : body.description?.trim() || null;
return tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
...(body.reimbursementDate
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
: {}),
...(body.description !== undefined ? { description: nextDescription } : {}),
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
reimbursement_date: true,
description: true,
total_amount: true,
},
});
});
return sendData(reply, request, {
id: updated.id,
status: updated.status,
reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10),
description: updated.description,
totalAmount: toMoneyString(updated.total_amount),
});
},
);
fastify.delete(
"/reimbursements/:id",
{
preHandler: fastify.requirePermission("expense.delete_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const result = await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
if (reimbursement.status !== "draft") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_delete_status" },
});
}
const unbound = await tx.invoicePool.updateMany({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
data: {
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
updated_by: request.employee.id,
},
});
await tx.reimbursementForm.delete({
where: { id: reimbursementId },
});
return {
reimbursementId,
unboundInvoiceCount: unbound.count,
};
});
return sendData(reply, request, {
reimbursementId: result.reimbursementId,
outcome: "deleted",
unboundInvoiceCount: result.unboundInvoiceCount,
});
},
);
fastify.post(
"/reimbursements/:id/items",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const body = parseItemBody(request.body);
const result = await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_item_edit_status" },
});
}
const expenseType = await tx.expenseType.findUnique({
where: { id: body.expenseTypeId },
select: { id: true, enabled: true },
});
if (!expenseType || !expenseType.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { expenseTypeId: "invalid_or_disabled" },
});
}
const created = await tx.reimbursementItem.create({
data: {
reimbursement_id: reimbursementId,
expense_type_id: body.expenseTypeId,
expense_date: parseDateOnlyString(body.expenseDate),
amount: body.amount,
description: body.description,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
expense_type_id: true,
expense_date: true,
amount: true,
description: true,
},
});
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
return {
item: created,
totalAmount: nextTotal,
};
});
return sendData(reply, request, {
item: {
id: result.item.id,
expenseTypeId: result.item.expense_type_id,
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
amount: toMoneyString(result.item.amount),
description: result.item.description,
},
totalAmount: toMoneyString(result.totalAmount),
});
},
);
fastify.patch(
"/reimbursements/:id/items/:itemId",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId);
if (!parsedItemId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { itemId: "invalid_uuid" },
});
}
const parsedBody = REIMBURSEMENT_ITEM_BODY_SCHEMA.partial().safeParse(request.body ?? {});
if (!parsedBody.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedBody.error.flatten(),
});
}
if (!Object.keys(parsedBody.data).length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { body: "empty" },
});
}
if (typeof parsedBody.data.amount === "string") {
const amountNumber = Number(parsedBody.data.amount);
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { amount: "invalid" },
});
}
}
const result = await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_item_edit_status" },
});
}
const item = await tx.reimbursementItem.findFirst({
where: {
id: parsedItemId.data,
reimbursement_id: reimbursementId,
},
select: { id: true },
});
if (!item) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (typeof parsedBody.data.expenseTypeId === "string") {
const expenseType = await tx.expenseType.findUnique({
where: { id: parsedBody.data.expenseTypeId },
select: { id: true, enabled: true },
});
if (!expenseType || !expenseType.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { expenseTypeId: "invalid_or_disabled" },
});
}
}
const updated = await tx.reimbursementItem.update({
where: { id: parsedItemId.data },
data: {
...(typeof parsedBody.data.expenseTypeId === "string"
? { expense_type_id: parsedBody.data.expenseTypeId }
: {}),
...(typeof parsedBody.data.expenseDate === "string"
? { expense_date: parseDateOnlyString(parsedBody.data.expenseDate) }
: {}),
...(typeof parsedBody.data.amount === "string"
? { amount: parsedBody.data.amount }
: {}),
...(typeof parsedBody.data.description === "string"
? { description: parsedBody.data.description.trim() }
: {}),
updated_by: request.employee.id,
},
select: {
id: true,
expense_type_id: true,
expense_date: true,
amount: true,
description: true,
},
});
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
return {
item: updated,
totalAmount: nextTotal,
};
});
return sendData(reply, request, {
item: {
id: result.item.id,
expenseTypeId: result.item.expense_type_id,
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
amount: toMoneyString(result.item.amount),
description: result.item.description,
},
totalAmount: toMoneyString(result.totalAmount),
});
},
);
fastify.delete(
"/reimbursements/:id/items/:itemId",
{
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId);
if (!parsedItemId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { itemId: "invalid_uuid" },
});
}
const result = await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_item_edit_status" },
});
}
const deleted = await tx.reimbursementItem.deleteMany({
where: {
id: parsedItemId.data,
reimbursement_id: reimbursementId,
},
});
if (deleted.count === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
return { totalAmount: nextTotal };
});
return sendData(reply, request, {
outcome: "deleted",
totalAmount: toMoneyString(result.totalAmount),
});
},
);
fastify.post(
"/reimbursements/:id/submit",
{
@@ -502,7 +1155,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureOwnOrCanViewAll(fastify, request, reimbursement);
ensureApplicantOrAdmin(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -694,6 +1347,22 @@ async function reimbursementRoutes(fastify) {
},
});
if (reimbursement.submitter_id !== reimbursement.owner_id) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_PROXY_SUBMITTED",
targetType: "reimbursement_form",
targetId: reimbursementId,
extra: {
ownerEmployeeId: reimbursement.owner_id,
submitterEmployeeId: reimbursement.submitter_id,
formNumber,
},
}),
});
}
submitResult = {
reimbursementId,
formNumber,