feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
+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, {
|
||||
|
||||
在新工单中引用
屏蔽一个用户