完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)
这个提交包含在:
@@ -10,7 +10,7 @@ const {
|
||||
listFinanceLedger,
|
||||
} = require("../services/financeLedger");
|
||||
|
||||
const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
|
||||
const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "payment.batch.review", "risk.review.manage", "expense.finance.pay"];
|
||||
|
||||
const FINANCE_LEDGER_QUERY_SCHEMA = z.object({
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
|
||||
@@ -4,18 +4,21 @@ const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { ROLE_PERMISSIONS } = require("../constants/permissions");
|
||||
const { BUSINESS_APPROVAL_ROLE_CODES } = require("../utils/workflow");
|
||||
|
||||
const ROLE_CODES = Object.keys(ROLE_PERMISSIONS);
|
||||
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(\.\d{1,2})?$/);
|
||||
const OWNER_SELF_FALLBACK_ROLE_CODES = ["finance_mgr"];
|
||||
const REIMBURSEMENT_APPROVAL_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter(
|
||||
(roleCode) => (ROLE_PERMISSIONS[roleCode] ?? []).includes("expense.approve"),
|
||||
);
|
||||
|
||||
const CREATE_BODY_SCHEMA = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
minAmount: MONEY_STRING_SCHEMA,
|
||||
maxAmount: MONEY_STRING_SCHEMA.nullable().optional(),
|
||||
level1Role: z.enum(ROLE_CODES),
|
||||
level2Role: z.enum(ROLE_CODES).nullable().optional(),
|
||||
level3Role: z.enum(ROLE_CODES).nullable().optional(),
|
||||
level1Role: z.enum(REIMBURSEMENT_APPROVAL_ROLE_CODES),
|
||||
level2Role: z.enum(REIMBURSEMENT_APPROVAL_ROLE_CODES).nullable().optional(),
|
||||
level3Role: z.enum(REIMBURSEMENT_APPROVAL_ROLE_CODES).nullable().optional(),
|
||||
ownerSelfFallbackRole: z.enum(OWNER_SELF_FALLBACK_ROLE_CODES).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
priority: z.number().int().min(0).max(10000).optional(),
|
||||
|
||||
+418
-125
@@ -1,4 +1,5 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
|
||||
const sharp = require("sharp");
|
||||
@@ -25,7 +26,18 @@ const {
|
||||
runWecomHrMasterSync,
|
||||
} = require("../services/integrations/wecomHrMaster");
|
||||
const { listOrganizationDepartments } = require("../services/organization");
|
||||
const { StorageNotFoundError, openReadStream, parseStoragePath, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
deleteObject,
|
||||
StorageNotFoundError,
|
||||
openReadStream,
|
||||
parseStoragePath,
|
||||
writeBuffer,
|
||||
} = require("../services/storage");
|
||||
const {
|
||||
buildAttachmentContentDisposition,
|
||||
PurchaseAttachmentPolicyError,
|
||||
validateAndNormalizePurchaseAttachment,
|
||||
} = require("../services/purchaseAttachmentPolicy");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
@@ -103,13 +115,16 @@ const CONTRACT_CREATE_SCHEMA = z.object({
|
||||
startDate: DATE_SCHEMA,
|
||||
endDate: DATE_SCHEMA,
|
||||
terminatedDate: DATE_SCHEMA.nullable().optional(),
|
||||
attachmentPath: z.string().trim().max(500).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const CONTRACT_PATCH_SCHEMA = CONTRACT_CREATE_SCHEMA.partial()
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const CONTRACT_DELETE_SCHEMA = z.object({
|
||||
reason: z.string().trim().min(2).max(200),
|
||||
}).strict();
|
||||
|
||||
const TRAINING_CREATE_SCHEMA = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
trainingType: TRAINING_TYPE_SCHEMA,
|
||||
@@ -170,7 +185,7 @@ async function ensureFeatureEnabled(fastify, request) {
|
||||
}
|
||||
|
||||
function canManageProfile(fastify, request, employeeId) {
|
||||
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view");
|
||||
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users");
|
||||
}
|
||||
|
||||
function canViewPeopleDirectory(fastify, request) {
|
||||
@@ -178,7 +193,7 @@ function canViewPeopleDirectory(fastify, request) {
|
||||
}
|
||||
|
||||
function canManageLifecycle(fastify, request) {
|
||||
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*");
|
||||
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "*");
|
||||
}
|
||||
|
||||
function canUseWecomDirectorySync(fastify) {
|
||||
@@ -311,7 +326,7 @@ function mapEmployeeContract(contract, timeZone) {
|
||||
startDate: contract.start_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
endDate: contract.end_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
terminatedDate: contract.terminated_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
attachmentPath: contract.attachment_path ?? null,
|
||||
hasAttachment: Boolean(contract.attachment_path),
|
||||
attachmentUrl: buildContractAttachmentUrl(contract.employee_id, contract.id, contract.attachment_path),
|
||||
status: deriveEmployeeContractStatus(contract, timeZone),
|
||||
createdAt: contract.created_at?.toISOString?.() ?? null,
|
||||
@@ -369,20 +384,6 @@ function resolveContractAttachmentDescriptor(mimetype) {
|
||||
return CONTRACT_ATTACHMENT_ALLOWED_TYPES.get(mimetype) ?? null;
|
||||
}
|
||||
|
||||
function inferAttachmentContentType(storagePath) {
|
||||
const extension = path.extname(parseStoragePath(storagePath).key ?? "").toLowerCase();
|
||||
if (extension === ".pdf") {
|
||||
return "application/pdf";
|
||||
}
|
||||
if (extension === ".jpg" || extension === ".jpeg") {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (extension === ".png") {
|
||||
return "image/png";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function buildContractAttachmentFilename(contract) {
|
||||
const key = parseStoragePath(contract.attachment_path).key ?? "";
|
||||
const extension = path.extname(key) || ".bin";
|
||||
@@ -390,6 +391,116 @@ function buildContractAttachmentFilename(contract) {
|
||||
return `${safeContractNo}${extension}`;
|
||||
}
|
||||
|
||||
class ContractAttachmentSizeLimitError extends Error {
|
||||
constructor() {
|
||||
super("contract_attachment_size_limit");
|
||||
this.name = "ContractAttachmentSizeLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
function destroyAttachmentStream(stream) {
|
||||
if (typeof stream?.destroy === "function" && !stream.destroyed) {
|
||||
try {
|
||||
stream.destroy();
|
||||
return;
|
||||
} catch {
|
||||
// Continue with Web Stream cancellation when available.
|
||||
}
|
||||
}
|
||||
if (typeof stream?.cancel === "function") {
|
||||
try {
|
||||
Promise.resolve(stream.cancel()).catch(() => {});
|
||||
} catch {
|
||||
// Cleanup must not replace the original validation failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedContractAttachmentStream(stream, maxBytes) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += bufferChunk.length;
|
||||
if (size > maxBytes) {
|
||||
destroyAttachmentStream(stream);
|
||||
throw new ContractAttachmentSizeLimitError();
|
||||
}
|
||||
chunks.push(bufferChunk);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ContractAttachmentSizeLimitError) {
|
||||
throw error;
|
||||
}
|
||||
destroyAttachmentStream(stream);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (stream?.truncated) {
|
||||
destroyAttachmentStream(stream);
|
||||
throw new ContractAttachmentSizeLimitError();
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks, size);
|
||||
}
|
||||
|
||||
async function writeEmployeeContractAudit(fastify, request, {
|
||||
action,
|
||||
employeeId,
|
||||
contractId = null,
|
||||
extra = {},
|
||||
}, database = fastify.prisma) {
|
||||
await database.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action,
|
||||
targetType: contractId ? "employee_contract" : "employee",
|
||||
targetId: contractId ?? employeeId,
|
||||
extra: {
|
||||
employeeId,
|
||||
...extra,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function buildContractAttachmentBlockedError() {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "合同附件未通过安全校验",
|
||||
statusCode: 409,
|
||||
details: { attachment: "security_validation_failed" },
|
||||
});
|
||||
}
|
||||
|
||||
async function auditBlockedContractAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
employeeId,
|
||||
contractId,
|
||||
reasonCode,
|
||||
}) {
|
||||
try {
|
||||
await writeEmployeeContractAudit(fastify, request, {
|
||||
action: "EMPLOYEE_CONTRACT_ATTACHMENT_DOWNLOAD_BLOCKED",
|
||||
employeeId,
|
||||
contractId,
|
||||
extra: { reasonCode },
|
||||
});
|
||||
} catch {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
contractId,
|
||||
errorCode: "contract_attachment_blocked_audit_failed",
|
||||
},
|
||||
"Employee contract attachment blocked-download audit failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLocalStorageAbsolutePath(config, storagePath) {
|
||||
const parsed = parseStoragePath(storagePath);
|
||||
if (!parsed.key || parsed.driver !== "local") {
|
||||
@@ -421,6 +532,41 @@ async function unlinkStoragePath(config, storagePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStorageCleanupErrorCode(error) {
|
||||
const code = typeof error?.code === "string" ? error.code.trim().toUpperCase() : "UNKNOWN";
|
||||
return /^[A-Z0-9_]{1,64}$/u.test(code) ? code : "UNKNOWN";
|
||||
}
|
||||
|
||||
async function unlinkCommittedContractAttachment(fastify, contractId, storagePath) {
|
||||
try {
|
||||
await deleteObject({ config: fastify.config, storagePath });
|
||||
} catch (error) {
|
||||
fastify.log.warn(
|
||||
{
|
||||
contractId,
|
||||
errorCode: normalizeStorageCleanupErrorCode(error),
|
||||
},
|
||||
"Committed contract attachment cleanup failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function lockEmployeeContractForUpdate(tx, employeeId, contractId) {
|
||||
const locked = await tx.$queryRaw`
|
||||
SELECT id
|
||||
FROM "employee_contracts"
|
||||
WHERE id = ${contractId}::uuid
|
||||
AND employee_id = ${employeeId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
|
||||
if (!Array.isArray(locked) || locked.length === 0) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
return ensureEmployeeContractExists(tx, employeeId, contractId);
|
||||
}
|
||||
|
||||
function mapChecklistTask(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -474,7 +620,10 @@ async function getLifecycleBlockers(prisma, employeeId) {
|
||||
}
|
||||
|
||||
async function ensureEmployeeExists(prisma, employeeId) {
|
||||
const employee = await prisma.employee.findUnique({ where: { id: employeeId } });
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!employee) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
@@ -709,7 +858,9 @@ async function employeesRoutes(fastify) {
|
||||
await usersRoutes(fastify, {
|
||||
basePath: "/employees",
|
||||
allowDepartmentFilter: true,
|
||||
listPermissionCodes: ["system.users", "report.dashboard.view"],
|
||||
listPermissionCodes: ["expense.view_own", "system.users", "report.dashboard.view"],
|
||||
directoryOnly: true,
|
||||
managementPermissionCode: "system.users",
|
||||
enableBatchActions: false,
|
||||
});
|
||||
|
||||
@@ -844,8 +995,8 @@ async function employeesRoutes(fastify) {
|
||||
|
||||
const stats = await buildEmployeeStats(fastify.prisma, {
|
||||
timeZone: fastify.config.appTimezone,
|
||||
includeSalarySummary: fastify.hasPermission(request.employee, "system.settings"),
|
||||
includePerformanceSummary: fastify.hasPermission(request.employee, "system.settings"),
|
||||
includeSalarySummary: fastify.hasPermission(request.employee, "payroll.manage"),
|
||||
includePerformanceSummary: fastify.hasPermission(request.employee, "system.users_sensitive"),
|
||||
});
|
||||
return sendData(reply, request, stats);
|
||||
});
|
||||
@@ -1005,11 +1156,8 @@ async function employeesRoutes(fastify) {
|
||||
return sendData(reply, request, profile);
|
||||
});
|
||||
|
||||
fastify.get("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
fastify.get("/employees/:id/contracts", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
|
||||
const employeeId = parseId(request.params?.id);
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
@@ -1019,16 +1167,19 @@ async function employeesRoutes(fastify) {
|
||||
orderBy: [{ start_date: "desc" }, { created_at: "desc" }],
|
||||
});
|
||||
|
||||
await writeEmployeeContractAudit(fastify, request, {
|
||||
action: "EMPLOYEE_CONTRACT_LIST_VIEWED",
|
||||
employeeId,
|
||||
extra: { itemCount: items.length },
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
items: items.map((item) => mapEmployeeContract(item, fastify.config.appTimezone)),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
fastify.get("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const contractId = parseId(request.params?.contractId);
|
||||
@@ -1038,28 +1189,111 @@ async function employeesRoutes(fastify) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
let opened;
|
||||
try {
|
||||
const opened = await openReadStream({
|
||||
opened = await openReadStream({
|
||||
config: fastify.config,
|
||||
storagePath: contract.attachment_path,
|
||||
});
|
||||
|
||||
reply.header("cache-control", "no-store");
|
||||
reply.header("content-disposition", `attachment; filename="${buildContractAttachmentFilename(contract)}"`);
|
||||
return reply.type(inferAttachmentContentType(contract.attachment_path)).send(opened.stream);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageNotFoundError) {
|
||||
await auditBlockedContractAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
employeeId,
|
||||
contractId,
|
||||
reasonCode: "missing_object",
|
||||
});
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let storedBuffer;
|
||||
try {
|
||||
storedBuffer = await readBoundedContractAttachmentStream(
|
||||
opened.stream,
|
||||
CONTRACT_ATTACHMENT_MAX_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
await auditBlockedContractAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
employeeId,
|
||||
contractId,
|
||||
reasonCode: error instanceof ContractAttachmentSizeLimitError ? "size_limit" : "storage_error",
|
||||
});
|
||||
if (error instanceof ContractAttachmentSizeLimitError) {
|
||||
throw buildContractAttachmentBlockedError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let validatedAttachment;
|
||||
try {
|
||||
validatedAttachment = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: storedBuffer,
|
||||
fileName: buildContractAttachmentFilename(contract),
|
||||
declaredMimeType: null,
|
||||
maxBytes: CONTRACT_ATTACHMENT_MAX_BYTES,
|
||||
normalizeImages: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof PurchaseAttachmentPolicyError)) {
|
||||
throw error;
|
||||
}
|
||||
await auditBlockedContractAttachmentDownload({
|
||||
fastify,
|
||||
request,
|
||||
employeeId,
|
||||
contractId,
|
||||
reasonCode: error.reasonCode,
|
||||
});
|
||||
throw buildContractAttachmentBlockedError();
|
||||
}
|
||||
|
||||
try {
|
||||
await writeEmployeeContractAudit(fastify, request, {
|
||||
action: "EMPLOYEE_CONTRACT_ATTACHMENT_DOWNLOADED",
|
||||
employeeId,
|
||||
contractId,
|
||||
});
|
||||
} catch {
|
||||
fastify.log.error(
|
||||
{
|
||||
requestId: request.id,
|
||||
contractId,
|
||||
errorCode: "contract_attachment_download_audit_failed",
|
||||
},
|
||||
"Employee contract attachment download audit failed",
|
||||
);
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "合同附件下载留痕失败,请稍后重试",
|
||||
statusCode: 500,
|
||||
details: { attachment: "audit_failed" },
|
||||
});
|
||||
}
|
||||
|
||||
reply.type("application/octet-stream");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
buildAttachmentContentDisposition(validatedAttachment.originalName),
|
||||
);
|
||||
reply.header("x-content-type-options", "nosniff");
|
||||
reply.header("cache-control", "private, no-store, max-age=0");
|
||||
reply.header("pragma", "no-cache");
|
||||
reply.header("referrer-policy", "no-referrer");
|
||||
reply.header(
|
||||
"content-security-policy",
|
||||
"sandbox; default-src 'none'; base-uri 'none'; form-action 'none'",
|
||||
);
|
||||
reply.header("cross-origin-resource-policy", "same-origin");
|
||||
return reply.send(validatedAttachment.buffer);
|
||||
});
|
||||
|
||||
fastify.post("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
fastify.post("/employees/:id/contracts", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const body = parseBody(CONTRACT_CREATE_SCHEMA, request.body);
|
||||
@@ -1067,18 +1301,26 @@ async function employeesRoutes(fastify) {
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
|
||||
try {
|
||||
const contract = await fastify.prisma.employeeContract.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
contract_no: body.contractNo,
|
||||
contract_type: body.contractType,
|
||||
start_date: parseDateOnly(body.startDate),
|
||||
end_date: parseDateOnly(body.endDate),
|
||||
terminated_date: parseDateOnly(body.terminatedDate ?? null),
|
||||
attachment_path: body.attachmentPath ?? null,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
const contract = await fastify.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.employeeContract.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
contract_no: body.contractNo,
|
||||
contract_type: body.contractType,
|
||||
start_date: parseDateOnly(body.startDate),
|
||||
end_date: parseDateOnly(body.endDate),
|
||||
terminated_date: parseDateOnly(body.terminatedDate ?? null),
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await writeEmployeeContractAudit(fastify, request, {
|
||||
action: "EMPLOYEE_CONTRACT_CREATED",
|
||||
employeeId,
|
||||
contractId: created.id,
|
||||
extra: { contractType: created.contract_type },
|
||||
}, tx);
|
||||
return created;
|
||||
});
|
||||
|
||||
return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone));
|
||||
@@ -1090,39 +1332,43 @@ async function employeesRoutes(fastify) {
|
||||
}
|
||||
});
|
||||
|
||||
fastify.patch("/employees/:id/contracts/:contractId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
fastify.patch("/employees/:id/contracts/:contractId", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const contractId = parseId(request.params?.contractId);
|
||||
const body = parseBody(CONTRACT_PATCH_SCHEMA, request.body);
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
|
||||
const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
|
||||
validateContractDates({
|
||||
startDate: body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10),
|
||||
endDate: body.endDate ?? current.end_date?.toISOString?.()?.slice(0, 10),
|
||||
terminatedDate:
|
||||
body.terminatedDate !== undefined
|
||||
? body.terminatedDate
|
||||
: current.terminated_date?.toISOString?.()?.slice(0, 10),
|
||||
});
|
||||
|
||||
try {
|
||||
const contract = await fastify.prisma.employeeContract.update({
|
||||
where: { id: contractId },
|
||||
data: {
|
||||
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
|
||||
...(body.contractType !== undefined ? { contract_type: body.contractType } : {}),
|
||||
...(body.startDate !== undefined ? { start_date: parseDateOnly(body.startDate) } : {}),
|
||||
...(body.endDate !== undefined ? { end_date: parseDateOnly(body.endDate) } : {}),
|
||||
...(body.terminatedDate !== undefined ? { terminated_date: parseDateOnly(body.terminatedDate ?? null) } : {}),
|
||||
...(body.attachmentPath !== undefined ? { attachment_path: body.attachmentPath ?? null } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
const contract = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await ensureEmployeeContractExists(tx, employeeId, contractId);
|
||||
validateContractDates({
|
||||
startDate: body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10),
|
||||
endDate: body.endDate ?? current.end_date?.toISOString?.()?.slice(0, 10),
|
||||
terminatedDate:
|
||||
body.terminatedDate !== undefined
|
||||
? body.terminatedDate
|
||||
: current.terminated_date?.toISOString?.()?.slice(0, 10),
|
||||
});
|
||||
const updated = await tx.employeeContract.update({
|
||||
where: { id: contractId },
|
||||
data: {
|
||||
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
|
||||
...(body.contractType !== undefined ? { contract_type: body.contractType } : {}),
|
||||
...(body.startDate !== undefined ? { start_date: parseDateOnly(body.startDate) } : {}),
|
||||
...(body.endDate !== undefined ? { end_date: parseDateOnly(body.endDate) } : {}),
|
||||
...(body.terminatedDate !== undefined ? { terminated_date: parseDateOnly(body.terminatedDate ?? null) } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await writeEmployeeContractAudit(fastify, request, {
|
||||
action: "EMPLOYEE_CONTRACT_UPDATED",
|
||||
employeeId,
|
||||
contractId,
|
||||
extra: { changedFields: Object.keys(body) },
|
||||
}, tx);
|
||||
return updated;
|
||||
});
|
||||
|
||||
return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone));
|
||||
@@ -1134,16 +1380,13 @@ async function employeesRoutes(fastify) {
|
||||
}
|
||||
});
|
||||
|
||||
fastify.put("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
fastify.put("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const contractId = parseId(request.params?.contractId);
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
|
||||
await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
|
||||
|
||||
if (!request.isMultipart()) {
|
||||
throw buildValidationError({ file: "invalid_multipart" });
|
||||
@@ -1170,63 +1413,113 @@ async function employeesRoutes(fastify) {
|
||||
throw buildValidationError({ file: "file_too_large" });
|
||||
}
|
||||
|
||||
let validatedAttachment;
|
||||
try {
|
||||
validatedAttachment = await validateAndNormalizePurchaseAttachment({
|
||||
buffer: rawBuffer,
|
||||
fileName: filePart.filename,
|
||||
declaredMimeType: filePart.mimetype,
|
||||
maxBytes: CONTRACT_ATTACHMENT_MAX_BYTES,
|
||||
normalizeImages: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PurchaseAttachmentPolicyError) {
|
||||
throw buildValidationError({ file: error.reasonCode });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextAttachmentPath = await writeBuffer({
|
||||
config: fastify.config,
|
||||
key: `contracts/${contractId}.${descriptor.extension}`,
|
||||
buffer: rawBuffer,
|
||||
contentType: descriptor.contentType,
|
||||
key: `contracts/${contractId}-${crypto.randomUUID()}${validatedAttachment.extension}`,
|
||||
buffer: validatedAttachment.buffer,
|
||||
contentType: validatedAttachment.mimeType,
|
||||
});
|
||||
|
||||
const contract = await fastify.prisma.employeeContract.update({
|
||||
where: { id: contractId },
|
||||
data: {
|
||||
attachment_path: nextAttachmentPath,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (current.attachment_path && current.attachment_path !== nextAttachmentPath) {
|
||||
await unlinkStoragePath(fastify.config, current.attachment_path);
|
||||
let replacement;
|
||||
try {
|
||||
replacement = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await lockEmployeeContractForUpdate(tx, employeeId, contractId);
|
||||
const updated = await tx.employeeContract.update({
|
||||
where: { id: contractId },
|
||||
data: {
|
||||
attachment_path: nextAttachmentPath,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await writeEmployeeContractAudit(fastify, request, {
|
||||
action: "EMPLOYEE_CONTRACT_ATTACHMENT_REPLACED",
|
||||
employeeId,
|
||||
contractId,
|
||||
extra: { replacedExisting: Boolean(current.attachment_path) },
|
||||
}, tx);
|
||||
return {
|
||||
contract: updated,
|
||||
previousAttachmentPath: current.attachment_path ?? null,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
await unlinkCommittedContractAttachment(fastify, contractId, nextAttachmentPath);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone));
|
||||
if (
|
||||
replacement.previousAttachmentPath &&
|
||||
replacement.previousAttachmentPath !== nextAttachmentPath
|
||||
) {
|
||||
await unlinkCommittedContractAttachment(
|
||||
fastify,
|
||||
contractId,
|
||||
replacement.previousAttachmentPath,
|
||||
);
|
||||
}
|
||||
|
||||
return sendData(
|
||||
reply,
|
||||
request,
|
||||
mapEmployeeContract(replacement.contract, fastify.config.appTimezone),
|
||||
);
|
||||
});
|
||||
|
||||
fastify.delete("/employees/:id/contracts/:contractId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
|
||||
fastify.delete("/employees/:id/contracts/:contractId", { preHandler: fastify.requirePermission("employee.contract.manage") }, async (request, reply) => {
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const contractId = parseId(request.params?.contractId);
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
|
||||
const currentStatus = deriveEmployeeContractStatus(current, fastify.config.appTimezone);
|
||||
const body = parseBody(CONTRACT_DELETE_SCHEMA, request.body);
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
|
||||
if (currentStatus === "active") {
|
||||
throw buildValidationError({ contract: "active_contract_must_be_terminated" }, 409);
|
||||
}
|
||||
const current = await fastify.prisma.$transaction(async (tx) => {
|
||||
const contract = await ensureEmployeeContractExists(tx, employeeId, contractId);
|
||||
const status = deriveEmployeeContractStatus(contract, fastify.config.appTimezone);
|
||||
if (status === "active") {
|
||||
throw buildValidationError({ contract: "active_contract_must_be_terminated" }, 409);
|
||||
}
|
||||
|
||||
await fastify.prisma.employeeContract.delete({ where: { id: contractId } });
|
||||
if (current.attachment_path) {
|
||||
await unlinkStoragePath(fastify.config, current.attachment_path);
|
||||
}
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "EMPLOYEE_CONTRACT_DELETED",
|
||||
targetType: "employee_contract",
|
||||
targetId: contractId,
|
||||
extra: {
|
||||
employeeId,
|
||||
contractNo: current.contract_no,
|
||||
status: currentStatus,
|
||||
attachmentPath: current.attachment_path ?? null,
|
||||
},
|
||||
}),
|
||||
await tx.employeeContract.delete({ where: { id: contractId } });
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "EMPLOYEE_CONTRACT_DELETED",
|
||||
targetType: "employee_contract",
|
||||
targetId: contractId,
|
||||
extra: {
|
||||
employeeId,
|
||||
status,
|
||||
hadAttachment: Boolean(contract.attachment_path),
|
||||
reason: body.reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return contract;
|
||||
});
|
||||
|
||||
if (current.attachment_path) {
|
||||
await unlinkCommittedContractAttachment(
|
||||
fastify,
|
||||
contractId,
|
||||
current.attachment_path,
|
||||
);
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
contractId,
|
||||
deleted: true,
|
||||
|
||||
@@ -1762,7 +1762,7 @@ async function exportsRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/exports/payslips",
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.settings"),
|
||||
preHandler: fastify.requirePermission("payroll.manage"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const query = parseQuery(PAYSLIP_EXPORT_QUERY_SCHEMA, request.query);
|
||||
|
||||
+791
-279
文件差异内容过多而无法显示
加载差异
@@ -21,7 +21,7 @@ const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const PAY_MONTH_SCHEMA = z.string().regex(/^\d{4}-\d{2}$/);
|
||||
const MONEY_INPUT_SCHEMA = z.union([z.number(), z.string()]);
|
||||
const SALARY_ITEM_TYPE_SCHEMA = z.enum(["earning", "deduction"]);
|
||||
const PAYROLL_MANAGE_PERMISSION = "system.settings";
|
||||
const PAYROLL_MANAGE_PERMISSION = "payroll.manage";
|
||||
|
||||
const SALARY_ITEM_CREATE_SCHEMA = z
|
||||
.object({
|
||||
|
||||
@@ -115,6 +115,12 @@ const PURCHASE_ATTACHMENT_DOWNLOAD_BODY_SCHEMA = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PURCHASE_ATTACHMENT_DELETE_BODY_SCHEMA = z
|
||||
.object({
|
||||
reason: z.string().trim().min(2).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PURCHASE_SUMMARY_SELECT = {
|
||||
id: true,
|
||||
request_no: true,
|
||||
@@ -1062,6 +1068,14 @@ function parsePurchaseAttachmentDownloadBody(body) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parsePurchaseAttachmentDeleteBody(body) {
|
||||
const parsed = PURCHASE_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function buildAttachmentBlockedError(reasonCode, message = "附件未通过安全校验", statusCode = 409) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
@@ -2521,6 +2535,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
|
||||
async (request, reply) => {
|
||||
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
|
||||
const attachmentId = parseAttachmentId(request.params?.attachmentId);
|
||||
const { reason } = parsePurchaseAttachmentDeleteBody(request.body);
|
||||
|
||||
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
|
||||
where: { id: purchaseRequestId },
|
||||
@@ -2570,6 +2585,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
|
||||
targetId: attachmentId,
|
||||
extra: {
|
||||
purchaseRequestId,
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -70,6 +70,12 @@ const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
|
||||
description: z.string().trim().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
|
||||
.object({
|
||||
reason: z.string().trim().min(2).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
||||
.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
@@ -371,6 +377,19 @@ function parseReimbursementUpdateBody(body) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseReimbursementAttachmentDeleteBody(body) {
|
||||
const parsed = REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseQuery(schema, query) {
|
||||
const parsed = schema.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -2621,6 +2640,7 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const { reason } = parseReimbursementAttachmentDeleteBody(request.body);
|
||||
const parsedAttachmentId = UUID_SCHEMA.safeParse(request.params?.attachmentId);
|
||||
if (!parsedAttachmentId.success) {
|
||||
throw new AppError({
|
||||
@@ -2671,6 +2691,19 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_ATTACHMENT_DELETED",
|
||||
targetType: "reimbursement_attachment",
|
||||
targetId: parsedAttachmentId.data,
|
||||
extra: {
|
||||
reimbursementId,
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return { attachmentId: parsedAttachmentId.data };
|
||||
});
|
||||
|
||||
@@ -3696,6 +3729,34 @@ async function reimbursementRoutes(fastify) {
|
||||
domain: "expense",
|
||||
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
|
||||
});
|
||||
const paymentBatchGate = await evaluateFeatureFlag({
|
||||
prisma: tx,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.paymentBatch,
|
||||
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
const activePaymentBatchItem = await tx.paymentBatchItem.findFirst({
|
||||
where: {
|
||||
entity_id: reimbursementId,
|
||||
payment_batch: {
|
||||
entity_type: "reimbursement",
|
||||
status: { not: "cancelled" },
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (paymentBatchGate.enabled || activePaymentBatchItem) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
payment: "payment_batch_v1_required",
|
||||
...(activePaymentBatchItem ? { paymentBatch: "active_batch_exists" } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!["approved", "paid"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
|
||||
+264
-169
@@ -23,7 +23,6 @@ const { hashPassword } = require("../utils/crypto");
|
||||
const { validatePassword } = require("../utils/passwordPolicy");
|
||||
const {
|
||||
decryptStringOrThrow,
|
||||
decryptStringSafe,
|
||||
encryptStringOrThrow,
|
||||
maskBankAccount,
|
||||
maskIdNumber,
|
||||
@@ -38,12 +37,18 @@ const roleCodeSchema = z.enum([
|
||||
"finance_mgr",
|
||||
"administration",
|
||||
"hr",
|
||||
"system_admin",
|
||||
"admin",
|
||||
]);
|
||||
|
||||
const PROTECTED_ROLE_CODES = new Set(["admin", "system_admin"]);
|
||||
const ACCOUNT_MANAGE_PERMISSION = "system.accounts.manage";
|
||||
const ROLE_MANAGE_PERMISSION = "system.roles.manage";
|
||||
|
||||
const listPaginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
scope: z.enum(["directory", "management"]).optional(),
|
||||
});
|
||||
|
||||
const normalizedEmailSchema = z
|
||||
@@ -143,10 +148,23 @@ function parseListQuery(query, options = {}) {
|
||||
return {
|
||||
page: parsed.data.page ?? 1,
|
||||
pageSize: parsed.data.pageSize ?? 20,
|
||||
scope: options.allowManagementScope && parsed.data.scope === "management" ? "management" : "directory",
|
||||
...parseEmployeeDirectoryFilters(query, options),
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmployeeDirectoryItem(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
employeeNo: row.employee_no,
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
department: row.department ? { id: row.department.id, name: row.department.name } : null,
|
||||
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCreateBody(body) {
|
||||
const parsed = createBodySchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -236,21 +254,12 @@ function mapEmployeeSummary(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmployeeDetail(row, options = {}) {
|
||||
const config = options.config ?? null;
|
||||
const logger = options.logger ?? null;
|
||||
|
||||
const bankAccountPlain = decryptStringSafe(config, row.bank_account_encrypted, logger);
|
||||
const idNumberPlain = decryptStringSafe(config, row.id_number_encrypted, logger);
|
||||
|
||||
function mapEmployeeDetail(row) {
|
||||
return {
|
||||
...mapEmployeeSummary(row),
|
||||
directManagerId: row.direct_manager_id ?? null,
|
||||
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
archivedAt: row.archived_at?.toISOString?.() ?? null,
|
||||
bankName: row.bank_name ? String(row.bank_name) : null,
|
||||
bankAccountMasked: bankAccountPlain ? maskBankAccount(bankAccountPlain) : null,
|
||||
idNumberMasked: idNumberPlain ? maskIdNumber(idNumberPlain) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -330,6 +339,87 @@ function ensureHasAnyPermission(fastify, request, permissionCodes) {
|
||||
});
|
||||
}
|
||||
|
||||
function isEmergencyAdmin(fastify, request) {
|
||||
return fastify.hasPermission(request.employee, "*")
|
||||
|| (request.employee.roleCodes ?? []).includes("admin");
|
||||
}
|
||||
|
||||
function assertProtectedRoleBoundary(fastify, request, previousRoles, nextRoles) {
|
||||
if (isEmergencyAdmin(fastify, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousProtected = new Set((previousRoles ?? []).filter((roleCode) => PROTECTED_ROLE_CODES.has(roleCode)));
|
||||
const nextProtected = new Set((nextRoles ?? []).filter((roleCode) => PROTECTED_ROLE_CODES.has(roleCode)));
|
||||
const changed = [...PROTECTED_ROLE_CODES].some(
|
||||
(roleCode) => previousProtected.has(roleCode) !== nextProtected.has(roleCode),
|
||||
);
|
||||
|
||||
if (changed) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { role: "protected_role_change_not_allowed" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertCanManageOrdinaryAccount(fastify, request, roleCodes) {
|
||||
if (isEmergencyAdmin(fastify, request)) {
|
||||
return roleCodes;
|
||||
}
|
||||
if (roleCodes.some((roleCode) => PROTECTED_ROLE_CODES.has(roleCode))) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { user: "protected_account_not_manageable" },
|
||||
});
|
||||
}
|
||||
return roleCodes;
|
||||
}
|
||||
|
||||
async function lockEmployeeRoleCodes(tx, employeeId) {
|
||||
const employeeRows = await tx.$queryRaw`
|
||||
SELECT id
|
||||
FROM "employees"
|
||||
WHERE id = ${employeeId}::uuid
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!Array.isArray(employeeRows) || employeeRows.length !== 1) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
details: { employeeId },
|
||||
});
|
||||
}
|
||||
|
||||
const roleRows = await tx.$queryRaw`
|
||||
SELECT role_code::text AS role_code
|
||||
FROM "employee_roles"
|
||||
WHERE employee_id = ${employeeId}::uuid
|
||||
ORDER BY role_code
|
||||
FOR UPDATE
|
||||
`;
|
||||
return Array.isArray(roleRows) ? roleRows.map((item) => item.role_code) : [];
|
||||
}
|
||||
|
||||
async function lockEmployeeRoleCodesById(tx, employeeIds) {
|
||||
const rolesByEmployeeId = new Map();
|
||||
for (const employeeId of Array.from(new Set(employeeIds)).sort()) {
|
||||
rolesByEmployeeId.set(employeeId, await lockEmployeeRoleCodes(tx, employeeId));
|
||||
}
|
||||
return rolesByEmployeeId;
|
||||
}
|
||||
|
||||
function assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId) {
|
||||
for (const roleCodes of rolesByEmployeeId.values()) {
|
||||
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLifecycleFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
@@ -406,24 +496,6 @@ async function ensureManagerUsable(prisma, managerId, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureEmployeesExist(prisma, employeeIds) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { id: { in: employeeIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (employees.length !== employeeIds.length) {
|
||||
const existingIds = new Set(employees.map((item) => item.id));
|
||||
const missingId = employeeIds.find((item) => !existingIds.has(item));
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
details: { employeeId: missingId ?? null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function setEmployeeAccountStatus(tx, request, options) {
|
||||
const updated = await tx.employee.update({
|
||||
where: { id: options.employeeId },
|
||||
@@ -992,10 +1064,12 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`;
|
||||
const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
|
||||
const allowDepartmentFilter = opts.allowDepartmentFilter === true;
|
||||
const directoryOnly = opts.directoryOnly === true;
|
||||
const allowManagementScope = directoryOnly && typeof opts.managementPermissionCode === "string";
|
||||
const listPermissionCodes =
|
||||
Array.isArray(opts.listPermissionCodes) && opts.listPermissionCodes.length
|
||||
? [...new Set(opts.listPermissionCodes)]
|
||||
: ["system.users"];
|
||||
: [ACCOUNT_MANAGE_PERMISSION];
|
||||
const enableBatchActions = routeBasePath === "/users" && opts.enableBatchActions !== false;
|
||||
|
||||
fastify.get(
|
||||
@@ -1004,10 +1078,16 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
ensureHasAnyPermission(fastify, request, listPermissionCodes);
|
||||
const query = parseListQuery(request.query, { allowDepartmentFilter });
|
||||
const query = parseListQuery(request.query, { allowDepartmentFilter, allowManagementScope });
|
||||
const managementScope = allowManagementScope && query.scope === "management";
|
||||
if (managementScope) {
|
||||
fastify.assertPermission(request.employee, opts.managementPermissionCode);
|
||||
} else {
|
||||
ensureHasAnyPermission(fastify, request, listPermissionCodes);
|
||||
}
|
||||
const skip = (query.page - 1) * query.pageSize;
|
||||
const where = buildEmployeeDirectoryWhere(query);
|
||||
const where = buildEmployeeDirectoryWhere(query, { includeContactSearch: !directoryOnly || managementScope });
|
||||
const narrowDirectory = directoryOnly && !managementScope;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.employee.findMany({
|
||||
@@ -1015,32 +1095,42 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
orderBy: { created_at: "desc" },
|
||||
skip,
|
||||
take: query.pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
direct_manager_id: true,
|
||||
title: true,
|
||||
employment_status: true,
|
||||
hire_date: true,
|
||||
avatar_path: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
direct_manager: { select: { id: true, employee_no: true, name: true } },
|
||||
roles: { select: { role_code: true } },
|
||||
wecom_identity_binding: {
|
||||
select: { external_user_id: true, external_open_user_id: true },
|
||||
},
|
||||
wecom_directory_sync_runs: {
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
select: narrowDirectory
|
||||
? {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
avatar_path: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
}
|
||||
: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
direct_manager_id: true,
|
||||
title: true,
|
||||
employment_status: true,
|
||||
hire_date: true,
|
||||
avatar_path: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
direct_manager: { select: { id: true, employee_no: true, name: true } },
|
||||
roles: { select: { role_code: true } },
|
||||
wecom_identity_binding: {
|
||||
select: { external_user_id: true, external_open_user_id: true },
|
||||
},
|
||||
wecom_directory_sync_runs: {
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
fastify.prisma.employee.count({ where }),
|
||||
]);
|
||||
@@ -1049,7 +1139,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
items: items.map(mapEmployeeSummary),
|
||||
items: items.map(narrowDirectory ? mapEmployeeDirectoryItem : mapEmployeeSummary),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1061,7 +1151,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const canManage = fastify.hasPermission(request.employee, "system.users");
|
||||
const canManage = fastify.hasPermission(request.employee, ACCOUNT_MANAGE_PERMISSION);
|
||||
|
||||
if (!canManage) {
|
||||
const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own");
|
||||
@@ -1088,9 +1178,6 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
@@ -1124,10 +1211,11 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
routeBasePath,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = parseCreateBody(request.body);
|
||||
assertProtectedRoleBoundary(fastify, request, [], body.roles);
|
||||
const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date();
|
||||
const departmentId = body.departmentId ?? request.employee.department_id;
|
||||
const oneTimePassword = body.password ?? generateOneTimePassword();
|
||||
@@ -1167,9 +1255,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
targetId: employee.id,
|
||||
extra: {
|
||||
employeeNo: employee.employee_no,
|
||||
email: employee.email,
|
||||
phone: employee.phone,
|
||||
roles: body.roles,
|
||||
roleCodes: body.roles,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -1215,7 +1301,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/batch/transfer-department`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureLifecycleFeatureEnabled(fastify, request);
|
||||
@@ -1223,7 +1309,8 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
await ensureDepartmentUsable(fastify.prisma, body.departmentId);
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureEmployeesExist(tx, body.employeeIds);
|
||||
const rolesByEmployeeId = await lockEmployeeRoleCodesById(tx, body.employeeIds);
|
||||
assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId);
|
||||
|
||||
for (const employeeId of body.employeeIds) {
|
||||
await applyEmployeeTransfer(tx, {
|
||||
@@ -1264,7 +1351,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/batch/set-manager`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureLifecycleFeatureEnabled(fastify, request);
|
||||
@@ -1275,7 +1362,8 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
});
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureEmployeesExist(tx, body.employeeIds);
|
||||
const rolesByEmployeeId = await lockEmployeeRoleCodesById(tx, body.employeeIds);
|
||||
assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId);
|
||||
|
||||
for (const employeeId of body.employeeIds) {
|
||||
await applyEmployeeTransfer(tx, {
|
||||
@@ -1316,7 +1404,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/batch/disable`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body);
|
||||
@@ -1326,7 +1414,8 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
|
||||
const now = new Date();
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureEmployeesExist(tx, body.employeeIds);
|
||||
const rolesByEmployeeId = await lockEmployeeRoleCodesById(tx, body.employeeIds);
|
||||
assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId);
|
||||
|
||||
for (const employeeId of body.employeeIds) {
|
||||
await setEmployeeAccountStatus(tx, request, {
|
||||
@@ -1367,14 +1456,15 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/batch/enable`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body);
|
||||
const now = new Date();
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureEmployeesExist(tx, body.employeeIds);
|
||||
const rolesByEmployeeId = await lockEmployeeRoleCodesById(tx, body.employeeIds);
|
||||
assertCanManageOrdinaryAccounts(fastify, request, rolesByEmployeeId);
|
||||
|
||||
for (const employeeId of body.employeeIds) {
|
||||
await setEmployeeAccountStatus(tx, request, {
|
||||
@@ -1417,7 +1507,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/import/dry-run`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { filename, rows } = await readUserImportPayload(request);
|
||||
@@ -1448,7 +1538,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/import/apply`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { filename, rows } = await readUserImportPayload(request);
|
||||
@@ -1470,6 +1560,10 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
for (const row of preview.validRows) {
|
||||
assertProtectedRoleBoundary(fastify, request, [], row.normalized.roles);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const createdRows = [];
|
||||
const createdByEmployeeNo = new Map();
|
||||
@@ -1574,7 +1668,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.patch(
|
||||
`${routeBasePath}/:id`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
@@ -1607,6 +1701,9 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
let updated = null;
|
||||
try {
|
||||
updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
|
||||
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
|
||||
|
||||
const current = await tx.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -1620,9 +1717,6 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
@@ -1696,9 +1790,6 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
@@ -1720,7 +1811,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
action: "SYSTEM_USER_UPDATED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: { changes },
|
||||
extra: { changedFields: Object.keys(changes) },
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1763,18 +1854,28 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.get(
|
||||
`${routeBasePath}/:id/sensitive`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users_sensitive"),
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const canReadBank = fastify.hasPermission(request.employee, "employee.bank.read")
|
||||
|| fastify.hasPermission(request.employee, "employee.bank.manage");
|
||||
const canReadIdentity = fastify.hasPermission(request.employee, "employee.identity.manage");
|
||||
if (!canReadBank && !canReadIdentity) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
id_number_encrypted: true,
|
||||
...(canReadBank
|
||||
? {
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
}
|
||||
: {}),
|
||||
...(canReadIdentity ? { id_number_encrypted: true } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1786,11 +1887,19 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
let bankAccount = null;
|
||||
let idNumber = null;
|
||||
const data = {};
|
||||
try {
|
||||
bankAccount = decryptStringOrThrow(fastify.config, employee.bank_account_encrypted);
|
||||
idNumber = decryptStringOrThrow(fastify.config, employee.id_number_encrypted);
|
||||
if (canReadBank) {
|
||||
data.bankName = employee.bank_name ? String(employee.bank_name) : null;
|
||||
data.bankAccount = employee.bank_account_encrypted
|
||||
? decryptStringOrThrow(fastify.config, employee.bank_account_encrypted)
|
||||
: null;
|
||||
}
|
||||
if (canReadIdentity) {
|
||||
data.idNumber = employee.id_number_encrypted
|
||||
? decryptStringOrThrow(fastify.config, employee.id_number_encrypted)
|
||||
: null;
|
||||
}
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
|
||||
throw new AppError({
|
||||
@@ -1807,58 +1916,46 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
action: "SYSTEM_USER_SENSITIVE_VIEWED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: {},
|
||||
extra: {
|
||||
scopes: [
|
||||
...(canReadBank ? ["bank"] : []),
|
||||
...(canReadIdentity ? ["identity"] : []),
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
bankName: employee.bank_name ? String(employee.bank_name) : null,
|
||||
bankAccount,
|
||||
idNumber,
|
||||
});
|
||||
return sendData(reply, request, data);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
`${routeBasePath}/:id/sensitive`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users_sensitive"),
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const body = parseSensitiveBody(request.body);
|
||||
const writesBank = Object.prototype.hasOwnProperty.call(body, "bankName")
|
||||
|| Object.prototype.hasOwnProperty.call(body, "bankAccount");
|
||||
const writesIdentity = Object.prototype.hasOwnProperty.call(body, "idNumber");
|
||||
if (writesBank) {
|
||||
fastify.assertPermission(request.employee, "employee.bank.manage");
|
||||
}
|
||||
if (writesIdentity) {
|
||||
fastify.assertPermission(request.employee, "employee.identity.manage");
|
||||
}
|
||||
const now = new Date();
|
||||
|
||||
let updated = null;
|
||||
let updatedFields = [];
|
||||
try {
|
||||
updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
updatedFields = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
id_number_encrypted: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
wecom_identity_binding: {
|
||||
select: { external_user_id: true, external_open_user_id: true },
|
||||
},
|
||||
wecom_directory_sync_runs: {
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
...(writesBank ? { bank_name: true } : {}),
|
||||
},
|
||||
});
|
||||
if (!current) {
|
||||
@@ -1903,35 +2000,10 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
changes.idNumberMasked = nextIdNumber ? maskIdNumber(nextIdNumber) : null;
|
||||
}
|
||||
|
||||
const employee = await tx.employee.update({
|
||||
await tx.employee.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
wecom_identity_binding: {
|
||||
select: { external_user_id: true, external_open_user_id: true },
|
||||
},
|
||||
wecom_directory_sync_runs: {
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
@@ -1944,7 +2016,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
}),
|
||||
});
|
||||
|
||||
return employee;
|
||||
return Object.keys(changes);
|
||||
});
|
||||
} catch (error) {
|
||||
if (String(error?.message ?? "").includes("FIELD_ENCRYPTION_KEY")) {
|
||||
@@ -1960,18 +2032,14 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(
|
||||
reply,
|
||||
request,
|
||||
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
|
||||
);
|
||||
return sendData(reply, request, { id: userId, updatedFields });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
`${routeBasePath}/:id/archive`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
@@ -1983,9 +2051,11 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
details: { user: "self_disable_not_allowed" },
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const employee = await fastify.prisma.$transaction(async (tx) => {
|
||||
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
|
||||
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
|
||||
|
||||
const updated = await tx.employee.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
@@ -2034,7 +2104,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/:id/disable`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
@@ -2046,9 +2116,11 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
details: { user: "self_disable_not_allowed" },
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const employee = await fastify.prisma.$transaction(async (tx) => {
|
||||
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
|
||||
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
|
||||
|
||||
return setEmployeeAccountStatus(tx, request, {
|
||||
employeeId: userId,
|
||||
actorId: request.employee.id,
|
||||
@@ -2072,13 +2144,16 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/:id/enable`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const now = new Date();
|
||||
|
||||
const employee = await fastify.prisma.$transaction(async (tx) => {
|
||||
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
|
||||
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
|
||||
|
||||
return setEmployeeAccountStatus(tx, request, {
|
||||
employeeId: userId,
|
||||
actorId: request.employee.id,
|
||||
@@ -2102,7 +2177,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/:id/reset-password`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
@@ -2115,7 +2190,6 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const newPassword =
|
||||
typeof parsed.data.newPassword === "string" && parsed.data.newPassword.trim()
|
||||
? parsed.data.newPassword.trim()
|
||||
@@ -2136,6 +2210,9 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
const now = new Date();
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
|
||||
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
|
||||
|
||||
await tx.employee.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
@@ -2192,13 +2269,16 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/:id/unlock`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ACCOUNT_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const now = new Date();
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const roleCodes = await lockEmployeeRoleCodes(tx, userId);
|
||||
assertCanManageOrdinaryAccount(fastify, request, roleCodes);
|
||||
|
||||
const current = await tx.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -2256,11 +2336,19 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
fastify.post(
|
||||
`${routeBasePath}/:id/roles`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users"),
|
||||
preHandler: fastify.requirePermission(ROLE_MANAGE_PERMISSION),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
if (userId === request.employee.id) {
|
||||
if (!isEmergencyAdmin(fastify, request)) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { user: "self_role_change_not_allowed" },
|
||||
});
|
||||
}
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
@@ -2283,6 +2371,10 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
const now = new Date();
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const previousRoleCodes = await lockEmployeeRoleCodes(tx, userId);
|
||||
assertCanManageOrdinaryAccount(fastify, request, previousRoleCodes);
|
||||
assertProtectedRoleBoundary(fastify, request, previousRoleCodes, roles);
|
||||
|
||||
await tx.employeeRole.deleteMany({
|
||||
where: { employee_id: userId },
|
||||
});
|
||||
@@ -2316,7 +2408,10 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
action: "SYSTEM_USER_ROLES_SET",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: { roles },
|
||||
extra: {
|
||||
beforeRoleCodes: previousRoleCodes,
|
||||
afterRoleCodes: roles,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,13 @@ const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
|
||||
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||
const {
|
||||
BUSINESS_APPROVAL_ROLE_CODES,
|
||||
getWorkflowStepsSnapshot,
|
||||
isBusinessApprovalRoleCode,
|
||||
resolveActionableStep,
|
||||
toMoneyString,
|
||||
} = require("../utils/workflow");
|
||||
const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
|
||||
@@ -75,7 +81,7 @@ const APPROVER_LIST_QUERY_SCHEMA = z.object({
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
const APPROVER_ROLE_CODES = Object.keys(ROLE_PERMISSIONS).filter((roleCode) =>
|
||||
const APPROVER_ROLE_CODES = BUSINESS_APPROVAL_ROLE_CODES.filter((roleCode) =>
|
||||
ROLE_PERMISSIONS[roleCode].some((permissionCode) => permissionCode.endsWith(".approve")),
|
||||
);
|
||||
|
||||
@@ -878,7 +884,7 @@ function resolveBusinessApprovePermission(entityType) {
|
||||
}
|
||||
|
||||
function roleHasBusinessApprovePermission(roleCode, approvePermission) {
|
||||
if (!roleCode || roleCode === "admin" || !approvePermission) {
|
||||
if (!isBusinessApprovalRoleCode(roleCode) || !approvePermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3225,6 +3231,7 @@ module.exports.__testables = {
|
||||
buildWorkflowEntityVisibilityWhere,
|
||||
calculateWorkflowTaskDueAt,
|
||||
canViewWorkflowEntityType,
|
||||
employeeHasBusinessApproveRole,
|
||||
handlePurchaseApprovalErpSync,
|
||||
isWorkflowTaskOverdue,
|
||||
listAllowedActionsForTask,
|
||||
|
||||
在新工单中引用
屏蔽一个用户