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 行删除
+92 -26
查看文件
@@ -6,10 +6,12 @@ const path = require("node:path");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { compactDateString } = require("../utils/date");
const { sha256Hex } = require("../utils/crypto");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { toMoneyString } = require("../utils/workflow");
const { findActiveDelegationForDelegate } = require("../utils/delegation");
const { isOcrEnabledForType } = require("../services/ocr");
const MIME_ALLOWLIST = new Set([
@@ -392,14 +394,57 @@ async function invoicePoolRoutes(fastify) {
});
}
const ownerEmployeeId = fields.owner_employee_id ?? request.employee.id;
if (ownerEmployeeId !== request.employee.id) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { owner_employee_id: "cross_owner_upload_not_allowed_in_mvp" },
const ownerEmployeeId =
typeof fields.owner_employee_id === "string" && fields.owner_employee_id.trim()
? fields.owner_employee_id.trim()
: request.employee.id;
const isCrossOwner = ownerEmployeeId !== request.employee.id;
let delegationGrantId = null;
if (isCrossOwner) {
const canManageUsers = fastify.hasPermission(request.employee, "system.users");
if (!canManageUsers) {
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,
details: { owner_employee_id: "cross_owner_upload_not_allowed" },
});
}
const delegation = await findActiveDelegationForDelegate(fastify.prisma, {
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;
}
const ownerEmployee = await fastify.prisma.employee.findUnique({
where: { id: ownerEmployeeId },
select: { id: true, status: true },
});
if (!ownerEmployee || ownerEmployee.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { owner_employee_id: "invalid_owner" },
});
}
}
const ocrType = fields.ocr_type ?? "general";
@@ -448,25 +493,46 @@ async function invoicePoolRoutes(fastify) {
const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available";
const invoice = await fastify.prisma.invoicePool.create({
data: {
owner_employee_id: ownerEmployeeId,
uploaded_by_employee_id: request.employee.id,
storage_path: storagePath,
original_name: filePart.filename ?? "upload",
mime_type: filePart.mimetype,
file_size: fileSize,
file_sha256: fileSha256,
ocr_type: ocrType,
status,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
file_sha256: true,
},
const invoice = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.invoicePool.create({
data: {
owner_employee_id: ownerEmployeeId,
uploaded_by_employee_id: request.employee.id,
storage_path: storagePath,
original_name: filePart.filename ?? "upload",
mime_type: filePart.mimetype,
file_size: fileSize,
file_sha256: fileSha256,
ocr_type: ocrType,
status,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
file_sha256: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVOICE_POOL_UPLOADED",
targetType: "invoice_pool",
targetId: created.id,
extra: {
ownerEmployeeId,
uploadedByEmployeeId: request.employee.id,
crossOwner: isCrossOwner,
delegationGrantId,
ocrType,
fileSha256,
},
}),
});
return created;
});
return sendData(reply, request, {