feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
+439
-20
@@ -3,8 +3,10 @@ const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { computeDelegationStatus } = require("../utils/delegation");
|
||||
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
|
||||
const CREATE_DELEGATION_SCHEMA = z.object({
|
||||
delegateEmployeeId: z.string().uuid(),
|
||||
scope: z.enum(["reimbursement_submit", "all"]).optional(),
|
||||
@@ -12,10 +14,20 @@ const CREATE_DELEGATION_SCHEMA = z.object({
|
||||
validTo: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const CREATE_DELEGATION_GRANT_SCHEMA = CREATE_DELEGATION_SCHEMA.extend({
|
||||
principalEmployeeId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const QUERY_STATUS_SCHEMA = z.object({
|
||||
status: z.enum(["active", "scheduled", "expired", "revoked", "all"]).optional(),
|
||||
});
|
||||
|
||||
const LIST_DELEGATION_GRANTS_QUERY_SCHEMA = z.object({
|
||||
includeInactive: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
principalEmployeeId: z.string().uuid().optional(),
|
||||
delegateEmployeeId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
function parseDelegationId(id) {
|
||||
const parsed = UUID_SCHEMA.safeParse(id);
|
||||
if (!parsed.success) {
|
||||
@@ -79,6 +91,56 @@ function parseCreateBody(body) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseCreateGrantBody(body) {
|
||||
const parsed = CREATE_DELEGATION_GRANT_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const validFrom = parsed.data.validFrom ? new Date(parsed.data.validFrom) : new Date();
|
||||
const validTo = parsed.data.validTo ? new Date(parsed.data.validTo) : null;
|
||||
|
||||
if (Number.isNaN(validFrom.getTime())) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { validFrom: "invalid_datetime" },
|
||||
});
|
||||
}
|
||||
|
||||
if (validTo && Number.isNaN(validTo.getTime())) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { validTo: "invalid_datetime" },
|
||||
});
|
||||
}
|
||||
|
||||
if (validTo && validTo <= validFrom) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { validTo: "must_be_after_validFrom" },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
principalEmployeeId: parsed.data.principalEmployeeId,
|
||||
delegateEmployeeId: parsed.data.delegateEmployeeId,
|
||||
scope: parsed.data.scope ?? "all",
|
||||
validFrom,
|
||||
validTo,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStatusQuery(query) {
|
||||
const parsed = QUERY_STATUS_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -93,6 +155,26 @@ function parseStatusQuery(query) {
|
||||
return parsed.data.status ?? "active";
|
||||
}
|
||||
|
||||
function parseListDelegationGrantsQuery(query) {
|
||||
const parsed = LIST_DELEGATION_GRANTS_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const includeInactive = ["true", "1"].includes(parsed.data.includeInactive ?? "false");
|
||||
|
||||
return {
|
||||
includeInactive,
|
||||
principalEmployeeId: parsed.data.principalEmployeeId,
|
||||
delegateEmployeeId: parsed.data.delegateEmployeeId,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesStatusFilter(record, status, now) {
|
||||
if (status === "all") {
|
||||
return true;
|
||||
@@ -101,6 +183,23 @@ function matchesStatusFilter(record, status, now) {
|
||||
return computeDelegationStatus(record, now) === status;
|
||||
}
|
||||
|
||||
function mapEmployeeEmbedded(employee) {
|
||||
if (!employee) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
departmentId: employee.department_id,
|
||||
status: employee.status,
|
||||
roleCodes: Array.isArray(employee.roles)
|
||||
? employee.roles.map((item) => item.role_code)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function toResponseItem(record, now) {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -112,10 +211,299 @@ function toResponseItem(record, now) {
|
||||
revokedAt: record.revoked_at ? record.revoked_at.toISOString() : null,
|
||||
createdAt: record.created_at.toISOString(),
|
||||
status: computeDelegationStatus(record, now),
|
||||
principal: record.principal_employee ? mapEmployeeEmbedded(record.principal_employee) : null,
|
||||
delegate: record.delegate_employee ? mapEmployeeEmbedded(record.delegate_employee) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function delegationRoutes(fastify) {
|
||||
async function ensureDelegateEligible(tx, delegateEmployeeId, scope) {
|
||||
const delegateEmployee = await tx.employee.findUnique({
|
||||
where: { id: delegateEmployeeId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!delegateEmployee || delegateEmployee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "invalid_delegate" },
|
||||
});
|
||||
}
|
||||
|
||||
const roleCodes = delegateEmployee.roles.map((item) => item.role_code);
|
||||
const permissionCodes = resolvePermissionsForRoles(roleCodes);
|
||||
|
||||
if (scope === "reimbursement_submit") {
|
||||
if (!hasPermission(permissionCodes, "expense.proxy_submit")) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "delegate_must_be_administration" },
|
||||
});
|
||||
}
|
||||
|
||||
return delegateEmployee;
|
||||
}
|
||||
|
||||
if (!hasPermission(permissionCodes, "expense.approve")) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "delegate_must_be_approver" },
|
||||
});
|
||||
}
|
||||
|
||||
return delegateEmployee;
|
||||
}
|
||||
|
||||
async function loadGrantById(tx, delegationId) {
|
||||
return tx.delegationGrant.findUnique({
|
||||
where: { id: delegationId },
|
||||
include: {
|
||||
principal_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
delegate_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fastify.get(
|
||||
"/delegation-grants",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseListDelegationGrantsQuery(request.query);
|
||||
const now = new Date();
|
||||
|
||||
const canManageAll = fastify.hasPermission(request.employee, "system.users");
|
||||
|
||||
const baseWhere = canManageAll
|
||||
? {
|
||||
...(query.principalEmployeeId ? { principal_employee_id: query.principalEmployeeId } : {}),
|
||||
...(query.delegateEmployeeId ? { delegate_employee_id: query.delegateEmployeeId } : {}),
|
||||
}
|
||||
: {
|
||||
OR: [
|
||||
{ principal_employee_id: request.employee.id },
|
||||
{ delegate_employee_id: request.employee.id },
|
||||
],
|
||||
};
|
||||
|
||||
const where = {
|
||||
...baseWhere,
|
||||
...(query.includeInactive
|
||||
? {}
|
||||
: {
|
||||
revoked_at: null,
|
||||
valid_from: { lte: now },
|
||||
OR: [{ valid_to: null }, { valid_to: { gt: now } }],
|
||||
}),
|
||||
};
|
||||
|
||||
const items = await fastify.prisma.delegationGrant.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
include: {
|
||||
principal_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
delegate_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
includeInactive: query.includeInactive,
|
||||
items: items.map((item) => toResponseItem(item, now)),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/delegation-grants",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const input = parseCreateGrantBody(request.body);
|
||||
const canManageAll = fastify.hasPermission(request.employee, "system.users");
|
||||
|
||||
const principalEmployeeId = input.principalEmployeeId ?? request.employee.id;
|
||||
if (principalEmployeeId !== request.employee.id && !canManageAll) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.delegateEmployeeId === principalEmployeeId) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "self_not_allowed" },
|
||||
});
|
||||
}
|
||||
|
||||
const delegation = await fastify.prisma.$transaction(async (tx) => {
|
||||
const principal = await tx.employee.findUnique({
|
||||
where: { id: principalEmployeeId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!principal || principal.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { principalEmployeeId: "invalid_principal" },
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDelegateEligible(tx, input.delegateEmployeeId, input.scope);
|
||||
|
||||
const created = await tx.delegationGrant.create({
|
||||
data: {
|
||||
principal_employee_id: principalEmployeeId,
|
||||
delegate_employee_id: input.delegateEmployeeId,
|
||||
scope: input.scope,
|
||||
valid_from: input.validFrom,
|
||||
valid_to: input.validTo,
|
||||
created_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "DELEGATION_CREATE",
|
||||
targetType: "delegation_grant",
|
||||
targetId: created.id,
|
||||
extra: {
|
||||
principalEmployeeId,
|
||||
delegateEmployeeId: input.delegateEmployeeId,
|
||||
scope: input.scope,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return loadGrantById(tx, created.id);
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
delegation: toResponseItem(delegation, new Date()),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/delegation-grants/:id/revoke",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const delegationId = parseDelegationId(request.params?.id);
|
||||
const now = new Date();
|
||||
const canManageAll = fastify.hasPermission(request.employee, "system.users");
|
||||
|
||||
const delegation = await loadGrantById(fastify.prisma, delegationId);
|
||||
if (!delegation) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const canRevoke =
|
||||
delegation.principal_employee_id === request.employee.id || canManageAll;
|
||||
if (!canRevoke) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (delegation.revoked_at) {
|
||||
return sendData(reply, request, {
|
||||
delegation: toResponseItem(delegation, now),
|
||||
outcome: "already_revoked",
|
||||
});
|
||||
}
|
||||
|
||||
const revoked = await fastify.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.delegationGrant.update({
|
||||
where: { id: delegationId },
|
||||
data: {
|
||||
revoked_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "DELEGATION_REVOKE",
|
||||
targetType: "delegation_grant",
|
||||
targetId: delegationId,
|
||||
extra: {
|
||||
principalEmployeeId: delegation.principal_employee_id,
|
||||
delegateEmployeeId: delegation.delegate_employee_id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return loadGrantById(tx, updated.id);
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
delegation: toResponseItem(revoked, now),
|
||||
outcome: "revoked",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/delegations",
|
||||
{
|
||||
@@ -133,24 +521,9 @@ async function delegationRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const delegateEmployee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: input.delegateEmployeeId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!delegateEmployee || delegateEmployee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { delegateEmployeeId: "invalid_delegate" },
|
||||
});
|
||||
}
|
||||
|
||||
const delegation = await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureDelegateEligible(tx, input.delegateEmployeeId, input.scope);
|
||||
|
||||
const created = await tx.delegationGrant.create({
|
||||
data: {
|
||||
principal_employee_id: request.employee.id,
|
||||
@@ -176,7 +549,7 @@ async function delegationRoutes(fastify) {
|
||||
}),
|
||||
});
|
||||
|
||||
return created;
|
||||
return loadGrantById(tx, created.id);
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
@@ -200,12 +573,56 @@ async function delegationRoutes(fastify) {
|
||||
principal_employee_id: request.employee.id,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
include: {
|
||||
principal_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
delegate_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
fastify.prisma.delegationGrant.findMany({
|
||||
where: {
|
||||
delegate_employee_id: request.employee.id,
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
include: {
|
||||
principal_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
delegate_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -246,6 +663,7 @@ async function delegationRoutes(fastify) {
|
||||
|
||||
const canRevoke =
|
||||
delegation.principal_employee_id === request.employee.id ||
|
||||
fastify.hasPermission(request.employee, "system.users") ||
|
||||
fastify.hasPermission(request.employee, "system.settings");
|
||||
if (!canRevoke) {
|
||||
throw new AppError({
|
||||
@@ -256,8 +674,9 @@ async function delegationRoutes(fastify) {
|
||||
}
|
||||
|
||||
if (delegation.revoked_at) {
|
||||
const embedded = await loadGrantById(fastify.prisma, delegationId);
|
||||
return sendData(reply, request, {
|
||||
delegation: toResponseItem(delegation, now),
|
||||
delegation: toResponseItem(embedded ?? delegation, now),
|
||||
outcome: "already_revoked",
|
||||
});
|
||||
}
|
||||
@@ -283,7 +702,7 @@ async function delegationRoutes(fastify) {
|
||||
}),
|
||||
});
|
||||
|
||||
return updated;
|
||||
return loadGrantById(tx, updated.id);
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
|
||||
在新工单中引用
屏蔽一个用户