- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
717 行
20 KiB
JavaScript
717 行
20 KiB
JavaScript
const { z } = require("zod");
|
|
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(),
|
|
validFrom: z.string().datetime().optional(),
|
|
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) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { id: "invalid_uuid" },
|
|
});
|
|
}
|
|
|
|
return parsed.data;
|
|
}
|
|
|
|
function parseCreateBody(body) {
|
|
const parsed = CREATE_DELEGATION_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 {
|
|
delegateEmployeeId: parsed.data.delegateEmployeeId,
|
|
scope: parsed.data.scope ?? "all",
|
|
validFrom,
|
|
validTo,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
principalEmployeeId: record.principal_employee_id,
|
|
delegateEmployeeId: record.delegate_employee_id,
|
|
scope: record.scope,
|
|
validFrom: record.valid_from.toISOString(),
|
|
validTo: record.valid_to ? record.valid_to.toISOString() : null,
|
|
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",
|
|
{
|
|
preHandler: fastify.requirePermission("expense.proxy_manage"),
|
|
},
|
|
async (request, reply) => {
|
|
const input = parseCreateBody(request.body);
|
|
|
|
if (input.delegateEmployeeId === request.employee.id) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { delegateEmployeeId: "self_not_allowed" },
|
|
});
|
|
}
|
|
|
|
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,
|
|
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: request.employee.id,
|
|
delegateEmployeeId: input.delegateEmployeeId,
|
|
scope: input.scope,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return loadGrantById(tx, created.id);
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
delegation: toResponseItem(delegation, new Date()),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/delegations/mine",
|
|
{
|
|
preHandler: fastify.requirePermission("expense.proxy_manage"),
|
|
},
|
|
async (request, reply) => {
|
|
const status = parseStatusQuery(request.query);
|
|
const now = new Date();
|
|
|
|
const [initiatedRaw, receivedRaw] = await Promise.all([
|
|
fastify.prisma.delegationGrant.findMany({
|
|
where: {
|
|
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 } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const initiated = initiatedRaw
|
|
.filter((item) => matchesStatusFilter(item, status, now))
|
|
.map((item) => toResponseItem(item, now));
|
|
const received = receivedRaw
|
|
.filter((item) => matchesStatusFilter(item, status, now))
|
|
.map((item) => toResponseItem(item, now));
|
|
|
|
return sendData(reply, request, {
|
|
status,
|
|
initiated,
|
|
received,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/delegations/:id/revoke",
|
|
{
|
|
preHandler: fastify.requirePermission("expense.proxy_manage"),
|
|
},
|
|
async (request, reply) => {
|
|
const delegationId = parseDelegationId(request.params?.id);
|
|
const now = new Date();
|
|
|
|
const delegation = await fastify.prisma.delegationGrant.findUnique({
|
|
where: { id: delegationId },
|
|
});
|
|
if (!delegation) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
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({
|
|
code: "PERM_001",
|
|
message: "无权限",
|
|
statusCode: 403,
|
|
});
|
|
}
|
|
|
|
if (delegation.revoked_at) {
|
|
const embedded = await loadGrantById(fastify.prisma, delegationId);
|
|
return sendData(reply, request, {
|
|
delegation: toResponseItem(embedded ?? 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",
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
module.exports = delegationRoutes;
|