feat: 完成 R13/R14/R15/R16 小团队管理核心能力

这个提交包含在:
2026-03-21 20:31:34 +08:00
父节点 0826693a8f
当前提交 1e52f1c225
共修改 47 个文件,包含 4298 行新增和 306 行删除
@@ -0,0 +1,205 @@
-- CreateEnum
CREATE TYPE "WorkspaceProfile" AS ENUM ('small_team_internal');
-- CreateEnum
CREATE TYPE "WorkspaceOrgMode" AS ENUM ('single_company');
-- CreateEnum
CREATE TYPE "WorkspacePrimaryEntryMode" AS ENUM ('password');
-- CreateEnum
CREATE TYPE "WorkspaceNotificationStrategy" AS ENUM ('in_app');
-- CreateEnum
CREATE TYPE "WorkspaceDevicePriority" AS ENUM ('desktop');
-- CreateEnum
CREATE TYPE "EmploymentLifecycleStatus" AS ENUM ('onboarding', 'probation', 'active', 'offboarding', 'inactive');
-- CreateEnum
CREATE TYPE "EmployeeLifecycleEventType" AS ENUM ('onboard', 'regularize', 'transfer', 'offboard');
-- CreateEnum
CREATE TYPE "EmployeeChecklistScene" AS ENUM ('onboarding', 'offboarding', 'transfer');
-- CreateEnum
CREATE TYPE "EmployeeChecklistTaskType" AS ENUM ('account_setup', 'asset_issue', 'policy_ack', 'asset_return', 'access_revoke', 'expense_clearance', 'handover_note');
-- CreateEnum
CREATE TYPE "EmployeeChecklistTaskStatus" AS ENUM ('pending', 'completed', 'skipped');
-- CreateEnum
CREATE TYPE "EmployeePresenceStatus" AS ENUM ('office', 'remote', 'out', 'travel', 'leave');
-- CreateEnum
CREATE TYPE "EmployeePresenceTimeRange" AS ENUM ('full_day', 'morning', 'afternoon');
-- CreateEnum
CREATE TYPE "EmployeePresenceSource" AS ENUM ('manual', 'leave_sync');
-- CreateEnum
CREATE TYPE "WeeklyReportStatus" AS ENUM ('draft', 'submitted');
-- CreateEnum
CREATE TYPE "TodoPriority" AS ENUM ('low', 'normal', 'high');
-- CreateEnum
CREATE TYPE "TodoStatus" AS ENUM ('todo', 'doing', 'done', 'cancelled');
-- CreateEnum
CREATE TYPE "TodoSource" AS ENUM ('manual', 'weekly_report');
-- AlterTable
ALTER TABLE "employees" ADD COLUMN "emergency_contact_name" TEXT,
ADD COLUMN "emergency_contact_phone" TEXT,
ADD COLUMN "employment_status" "EmploymentLifecycleStatus" NOT NULL DEFAULT 'active',
ADD COLUMN "last_working_date" DATE,
ADD COLUMN "notes" TEXT,
ADD COLUMN "probation_end_date" DATE,
ADD COLUMN "work_location" TEXT;
-- CreateTable
CREATE TABLE "workspace_settings" (
"id" UUID NOT NULL,
"profile" "WorkspaceProfile" NOT NULL DEFAULT 'small_team_internal',
"org_mode" "WorkspaceOrgMode" NOT NULL DEFAULT 'single_company',
"primary_entry_mode" "WorkspacePrimaryEntryMode" NOT NULL DEFAULT 'password',
"notification_strategy" "WorkspaceNotificationStrategy" NOT NULL DEFAULT 'in_app',
"device_priority" "WorkspaceDevicePriority" NOT NULL DEFAULT 'desktop',
"module_visibility_json" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "workspace_settings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employee_lifecycle_events" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"event_type" "EmployeeLifecycleEventType" NOT NULL,
"effective_date" DATE NOT NULL,
"payload_json" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
CONSTRAINT "employee_lifecycle_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employee_checklist_tasks" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"scene" "EmployeeChecklistScene" NOT NULL,
"task_type" "EmployeeChecklistTaskType" NOT NULL,
"title" TEXT NOT NULL,
"status" "EmployeeChecklistTaskStatus" NOT NULL DEFAULT 'pending',
"due_date" DATE,
"completed_at" TIMESTAMP(3),
"completed_by" UUID,
"payload_json" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "employee_checklist_tasks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employee_presence" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"date" DATE NOT NULL,
"status" "EmployeePresenceStatus" NOT NULL,
"time_range" "EmployeePresenceTimeRange" NOT NULL DEFAULT 'full_day',
"note" TEXT,
"source" "EmployeePresenceSource" NOT NULL DEFAULT 'manual',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "employee_presence_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "weekly_reports" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"week_start" DATE NOT NULL,
"status" "WeeklyReportStatus" NOT NULL DEFAULT 'draft',
"highlights_text" TEXT NOT NULL DEFAULT '',
"blockers_text" TEXT NOT NULL DEFAULT '',
"next_week_text" TEXT NOT NULL DEFAULT '',
"needs_help" BOOLEAN NOT NULL DEFAULT false,
"submitted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "weekly_reports_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "todo_items" (
"id" UUID NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"assignee_employee_id" UUID NOT NULL,
"created_by" UUID NOT NULL,
"due_date" DATE,
"priority" "TodoPriority" NOT NULL DEFAULT 'normal',
"status" "TodoStatus" NOT NULL DEFAULT 'todo',
"source" "TodoSource" NOT NULL DEFAULT 'manual',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
CONSTRAINT "todo_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "idx_employee_lifecycle_events_employee_date" ON "employee_lifecycle_events"("employee_id", "effective_date");
-- CreateIndex
CREATE INDEX "idx_employee_lifecycle_events_type_created" ON "employee_lifecycle_events"("event_type", "created_at");
-- CreateIndex
CREATE INDEX "idx_employee_checklist_tasks_employee_scene_status" ON "employee_checklist_tasks"("employee_id", "scene", "status");
-- CreateIndex
CREATE INDEX "idx_employee_checklist_tasks_scene_due" ON "employee_checklist_tasks"("scene", "due_date");
-- CreateIndex
CREATE INDEX "idx_employee_presence_date_status" ON "employee_presence"("date", "status");
-- CreateIndex
CREATE UNIQUE INDEX "employee_presence_employee_date_ux" ON "employee_presence"("employee_id", "date");
-- CreateIndex
CREATE INDEX "idx_weekly_reports_week_status" ON "weekly_reports"("week_start", "status");
-- CreateIndex
CREATE UNIQUE INDEX "weekly_reports_employee_week_start_ux" ON "weekly_reports"("employee_id", "week_start");
-- CreateIndex
CREATE INDEX "idx_todo_items_assignee_status_due" ON "todo_items"("assignee_employee_id", "status", "due_date");
-- CreateIndex
CREATE INDEX "idx_todo_items_creator_created" ON "todo_items"("created_by", "created_at");
-- AddForeignKey
ALTER TABLE "employee_lifecycle_events" ADD CONSTRAINT "employee_lifecycle_events_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employee_checklist_tasks" ADD CONSTRAINT "employee_checklist_tasks_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employee_presence" ADD CONSTRAINT "employee_presence_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "weekly_reports" ADD CONSTRAINT "weekly_reports_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "todo_items" ADD CONSTRAINT "todo_items_assignee_employee_id_fkey" FOREIGN KEY ("assignee_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "todo_items" ADD CONSTRAINT "todo_items_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+243 -13
查看文件
@@ -265,6 +265,105 @@ enum BudgetControlRuleEntityType {
travel_request
}
enum WorkspaceProfile {
small_team_internal
}
enum WorkspaceOrgMode {
single_company
}
enum WorkspacePrimaryEntryMode {
password
}
enum WorkspaceNotificationStrategy {
in_app
}
enum WorkspaceDevicePriority {
desktop
}
enum EmploymentLifecycleStatus {
onboarding
probation
active
offboarding
inactive
}
enum EmployeeLifecycleEventType {
onboard
regularize
transfer
offboard
}
enum EmployeeChecklistScene {
onboarding
offboarding
transfer
}
enum EmployeeChecklistTaskType {
account_setup
asset_issue
policy_ack
asset_return
access_revoke
expense_clearance
handover_note
}
enum EmployeeChecklistTaskStatus {
pending
completed
skipped
}
enum EmployeePresenceStatus {
office
remote
out
travel
leave
}
enum EmployeePresenceTimeRange {
full_day
morning
afternoon
}
enum EmployeePresenceSource {
manual
leave_sync
}
enum WeeklyReportStatus {
draft
submitted
}
enum TodoPriority {
low
normal
high
}
enum TodoStatus {
todo
doing
done
cancelled
}
enum TodoSource {
manual
weekly_report
}
model Department {
id String @id @default(uuid()) @db.Uuid
name String
@@ -296,17 +395,24 @@ model Department {
}
model Employee {
id String @id @default(uuid()) @db.Uuid
employee_no String @unique
name String
phone String @unique
email String @unique
department_id String @db.Uuid
legal_entity_id String? @db.Uuid
title String
direct_manager_id String? @db.Uuid
hire_date DateTime @db.Date
status EmployeeStatus @default(active)
id String @id @default(uuid()) @db.Uuid
employee_no String @unique
name String
phone String @unique
email String @unique
department_id String @db.Uuid
legal_entity_id String? @db.Uuid
title String
direct_manager_id String? @db.Uuid
hire_date DateTime @db.Date
status EmployeeStatus @default(active)
employment_status EmploymentLifecycleStatus @default(active)
probation_end_date DateTime? @db.Date
last_working_date DateTime? @db.Date
work_location String?
emergency_contact_name String?
emergency_contact_phone String?
notes String?
bank_account_encrypted String
bank_name String
@@ -373,9 +479,15 @@ model Employee {
workflow_tasks_assigned WorkflowTask[] @relation("WorkflowTaskAssignee")
workflow_tasks_completed WorkflowTask[] @relation("WorkflowTaskCompleter")
approval_transfers_from ApprovalTransfer[] @relation("TransferFrom")
approval_transfers_to ApprovalTransfer[] @relation("TransferTo")
approval_transfers_from ApprovalTransfer[] @relation("TransferFrom")
approval_transfers_to ApprovalTransfer[] @relation("TransferTo")
password_reset_codes PasswordResetCode[]
lifecycle_events EmployeeLifecycleEvent[]
checklist_tasks EmployeeChecklistTask[]
presence_records EmployeePresence[]
weekly_reports WeeklyReport[]
todo_items_assigned TodoItem[] @relation("TodoAssignee")
todo_items_created TodoItem[] @relation("TodoCreator")
@@index([department_id])
@@index([legal_entity_id], map: "idx_employees_legal_entity")
@@ -1408,6 +1520,124 @@ model PortalAnnouncement {
@@map("portal_announcements")
}
model WorkspaceSetting {
id String @id @default(uuid()) @db.Uuid
profile WorkspaceProfile @default(small_team_internal)
org_mode WorkspaceOrgMode @default(single_company)
primary_entry_mode WorkspacePrimaryEntryMode @default(password)
notification_strategy WorkspaceNotificationStrategy @default(in_app)
device_priority WorkspaceDevicePriority @default(desktop)
module_visibility_json Json?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
@@map("workspace_settings")
}
model EmployeeLifecycleEvent {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
event_type EmployeeLifecycleEventType
effective_date DateTime @db.Date
payload_json Json?
created_at DateTime @default(now())
created_by String? @db.Uuid
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@index([employee_id, effective_date], map: "idx_employee_lifecycle_events_employee_date")
@@index([event_type, created_at], map: "idx_employee_lifecycle_events_type_created")
@@map("employee_lifecycle_events")
}
model EmployeeChecklistTask {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
scene EmployeeChecklistScene
task_type EmployeeChecklistTaskType
title String
status EmployeeChecklistTaskStatus @default(pending)
due_date DateTime? @db.Date
completed_at DateTime?
completed_by String? @db.Uuid
payload_json Json?
created_at DateTime @default(now())
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@index([employee_id, scene, status], map: "idx_employee_checklist_tasks_employee_scene_status")
@@index([scene, due_date], map: "idx_employee_checklist_tasks_scene_due")
@@map("employee_checklist_tasks")
}
model EmployeePresence {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
date DateTime @db.Date
status EmployeePresenceStatus
time_range EmployeePresenceTimeRange @default(full_day)
note String?
source EmployeePresenceSource @default(manual)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@unique([employee_id, date], map: "employee_presence_employee_date_ux")
@@index([date, status], map: "idx_employee_presence_date_status")
@@map("employee_presence")
}
model WeeklyReport {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
week_start DateTime @db.Date
status WeeklyReportStatus @default(draft)
highlights_text String @default("")
blockers_text String @default("")
next_week_text String @default("")
needs_help Boolean @default(false)
submitted_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@unique([employee_id, week_start], map: "weekly_reports_employee_week_start_ux")
@@index([week_start, status], map: "idx_weekly_reports_week_status")
@@map("weekly_reports")
}
model TodoItem {
id String @id @default(uuid()) @db.Uuid
title String
description String?
assignee_employee_id String @db.Uuid
created_by String @db.Uuid
due_date DateTime? @db.Date
priority TodoPriority @default(normal)
status TodoStatus @default(todo)
source TodoSource @default(manual)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
completed_at DateTime?
assignee Employee @relation("TodoAssignee", fields: [assignee_employee_id], references: [id], onDelete: Restrict)
creator Employee @relation("TodoCreator", fields: [created_by], references: [id], onDelete: Restrict)
@@index([assignee_employee_id, status, due_date], map: "idx_todo_items_assignee_status_due")
@@index([created_by, created_at], map: "idx_todo_items_creator_created")
@@map("todo_items")
}
model MigrationRun {
id String @id @default(uuid()) @db.Uuid
run_type String @db.VarChar(32)
+8
查看文件
@@ -48,6 +48,10 @@ const announcementsRoutes = require("./routes/announcements");
const workflowDefinitionsRoutes = require("./routes/workflowDefinitions");
const migrationRunsRoutes = require("./routes/migrationRuns");
const cutoverRoutes = require("./routes/cutover");
const systemWorkspaceRoutes = require("./routes/systemWorkspace");
const presenceRoutes = require("./routes/presence");
const weeklyReportsRoutes = require("./routes/weeklyReports");
const todosRoutes = require("./routes/todos");
const purchaseRequestRoutes = require("./routes/purchaseRequests");
const assetsRoutes = require("./routes/assets");
const assetMovementsRoutes = require("./routes/assetMovements");
@@ -324,6 +328,10 @@ async function buildApp() {
await app.register(workflowDefinitionsRoutes, { prefix: "/api" });
await app.register(migrationRunsRoutes, { prefix: "/api" });
await app.register(cutoverRoutes, { prefix: "/api" });
await app.register(systemWorkspaceRoutes, { prefix: "/api" });
await app.register(presenceRoutes, { prefix: "/api" });
await app.register(weeklyReportsRoutes, { prefix: "/api" });
await app.register(todosRoutes, { prefix: "/api" });
await app.register(purchaseRequestRoutes, { prefix: "/api" });
await app.register(assetsRoutes, { prefix: "/api" });
await app.register(assetMovementsRoutes, { prefix: "/api" });
+4
查看文件
@@ -111,6 +111,10 @@ function getConfig() {
announcementsEnabled: process.env.ANNOUNCEMENTS_ENABLED === "true",
workflowDefinitionAdminEnabled: process.env.WORKFLOW_DEFINITION_ADMIN_ENABLED === "true",
cutoverConsoleEnabled: process.env.CUTOVER_CONSOLE_ENABLED === "true",
presenceBoardEnabled: process.env.PRESENCE_BOARD_ENABLED === "true",
weeklyReportEnabled: process.env.WEEKLY_REPORT_ENABLED === "true",
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
aiAssistEnabled: process.env.AI_ASSIST_ENABLED === "true",
aiProvider: process.env.AI_PROVIDER ?? "mock",
+516 -1
查看文件
@@ -1,8 +1,523 @@
const { z } = require("zod");
const usersRoutes = require("./users");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const UUID_SCHEMA = z.string().uuid();
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const PROFILE_PATCH_SCHEMA = z
.object({
departmentId: UUID_SCHEMA.optional(),
directManagerId: UUID_SCHEMA.nullable().optional(),
title: z.string().trim().min(1).max(100).optional(),
probationEndDate: DATE_SCHEMA.nullable().optional(),
lastWorkingDate: DATE_SCHEMA.nullable().optional(),
workLocation: z.string().trim().max(100).nullable().optional(),
emergencyContactName: z.string().trim().max(100).nullable().optional(),
emergencyContactPhone: z.string().trim().max(50).nullable().optional(),
notes: z.string().trim().max(1000).nullable().optional(),
})
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const ONBOARDING_SCHEMA = z.object({
effectiveDate: DATE_SCHEMA.optional(),
probationEndDate: DATE_SCHEMA.nullable().optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
const REGULARIZE_SCHEMA = z.object({
effectiveDate: DATE_SCHEMA.optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
const TRANSFER_SCHEMA = z.object({
effectiveDate: DATE_SCHEMA.optional(),
departmentId: UUID_SCHEMA.optional(),
directManagerId: UUID_SCHEMA.nullable().optional(),
title: z.string().trim().min(1).max(100).optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
const OFFBOARDING_SCHEMA = z.object({
effectiveDate: DATE_SCHEMA.optional(),
lastWorkingDate: DATE_SCHEMA.nullable().optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parseId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.employeeLifecycle,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409);
}
}
function resolveEffectiveDate(rawValue) {
return rawValue ? new Date(`${rawValue}T00:00:00.000Z`) : new Date();
}
function canManageProfile(fastify, request, employeeId) {
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view");
}
function canManageLifecycle(fastify, request) {
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*");
}
function mapChecklistTask(item) {
return {
id: item.id,
employeeId: item.employee_id,
scene: item.scene,
taskType: item.task_type,
title: item.title,
status: item.status,
dueDate: item.due_date?.toISOString?.()?.slice(0, 10) ?? null,
completedAt: item.completed_at?.toISOString?.() ?? null,
completedBy: item.completed_by ?? null,
payloadJson: item.payload_json ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
function mapLifecycleEvent(item) {
return {
id: item.id,
employeeId: item.employee_id,
eventType: item.event_type,
effectiveDate: item.effective_date?.toISOString?.()?.slice(0, 10) ?? null,
payloadJson: item.payload_json ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
createdBy: item.created_by ?? null,
};
}
async function getLifecycleBlockers(prisma, employeeId) {
const [openAssetCount, pendingWorkflowTaskCount] = await Promise.all([
prisma.assetCard.count({ where: { assignee_employee_id: employeeId } }),
prisma.workflowTask.count({ where: { assignee_id: employeeId, status: "pending" } }),
]);
return {
openAssetCount,
pendingWorkflowTaskCount,
canCompleteOffboarding: openAssetCount === 0 && pendingWorkflowTaskCount === 0,
};
}
async function ensureEmployeeExists(prisma, employeeId) {
const employee = await prisma.employee.findUnique({ where: { id: employeeId } });
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return employee;
}
function buildDefaultChecklist(scene, employeeId, actorId) {
const common = { employee_id: employeeId };
if (scene === "onboarding") {
return [
{ ...common, scene, task_type: "account_setup", title: "完成账号与权限初始化", status: "pending" },
{ ...common, scene, task_type: "asset_issue", title: "确认办公资产发放", status: "pending" },
{ ...common, scene, task_type: "policy_ack", title: "确认制度与入职须知", status: "pending" },
];
}
if (scene === "transfer") {
return [{ ...common, scene, task_type: "handover_note", title: "确认调岗交接说明", status: "pending" }];
}
return [
{ ...common, scene, task_type: "asset_return", title: "确认资产归还/转交", status: "pending" },
{ ...common, scene, task_type: "access_revoke", title: "确认账号和权限回收", status: "pending" },
{ ...common, scene, task_type: "expense_clearance", title: "确认费用与审批待办已清理", status: "pending" },
{ ...common, scene, task_type: "handover_note", title: "确认离职交接说明", status: "pending" },
];
}
async function maybeAdvanceLifecycle(tx, employeeId) {
const employee = await tx.employee.findUnique({ where: { id: employeeId } });
if (!employee) {
return { autoChanged: false, completedOffboarding: false, blockers: null };
}
const pendingOnboarding = await tx.employeeChecklistTask.count({ where: { employee_id: employeeId, scene: "onboarding", status: "pending" } });
if (employee.employment_status === "onboarding" && pendingOnboarding === 0) {
await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "probation" } });
return { autoChanged: true, completedOffboarding: false, blockers: null };
}
const pendingOffboarding = await tx.employeeChecklistTask.count({ where: { employee_id: employeeId, scene: "offboarding", status: "pending" } });
if (employee.employment_status === "offboarding" && pendingOffboarding === 0) {
const [openAssetCount, pendingWorkflowTaskCount] = await Promise.all([
tx.assetCard.count({ where: { assignee_employee_id: employeeId } }),
tx.workflowTask.count({ where: { assignee_id: employeeId, status: "pending" } }),
]);
if (openAssetCount === 0 && pendingWorkflowTaskCount === 0) {
await tx.employee.update({
where: { id: employeeId },
data: {
employment_status: "inactive",
status: "archived",
archived_at: new Date(),
token_version: { increment: 1 },
},
});
return { autoChanged: true, completedOffboarding: true, blockers: { openAssetCount, pendingWorkflowTaskCount } };
}
return { autoChanged: false, completedOffboarding: false, blockers: { openAssetCount, pendingWorkflowTaskCount } };
}
return { autoChanged: false, completedOffboarding: false, blockers: null };
}
async function buildEmployeeProfile(prisma, employeeId) {
const [employee, checklistTasks, lifecycleEvents, assignedAssets, blockers] = await Promise.all([
prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
employee_no: true,
name: true,
phone: true,
email: true,
status: true,
employment_status: true,
department_id: true,
title: true,
direct_manager_id: true,
hire_date: true,
probation_end_date: true,
last_working_date: true,
work_location: true,
emergency_contact_name: true,
emergency_contact_phone: true,
notes: true,
archived_at: true,
roles: { select: { role_code: true } },
department: { select: { id: true, name: true } },
direct_manager: { select: { id: true, employee_no: true, name: true } },
},
}),
prisma.employeeChecklistTask.findMany({ where: { employee_id: employeeId }, orderBy: [{ created_at: "desc" }] }),
prisma.employeeLifecycleEvent.findMany({ where: { employee_id: employeeId }, orderBy: [{ effective_date: "desc" }, { created_at: "desc" }] }),
prisma.assetCard.findMany({
where: { assignee_employee_id: employeeId },
orderBy: { asset_no: "asc" },
select: { id: true, asset_no: true, name: true, status: true, location: true },
}),
getLifecycleBlockers(prisma, employeeId),
]);
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return {
employee: {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
phone: employee.phone,
email: employee.email,
status: employee.status,
employmentStatus: employee.employment_status,
departmentId: employee.department_id,
title: employee.title,
directManagerId: employee.direct_manager_id ?? null,
hireDate: employee.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
probationEndDate: employee.probation_end_date?.toISOString?.()?.slice(0, 10) ?? null,
lastWorkingDate: employee.last_working_date?.toISOString?.()?.slice(0, 10) ?? null,
workLocation: employee.work_location ?? null,
emergencyContactName: employee.emergency_contact_name ?? null,
emergencyContactPhone: employee.emergency_contact_phone ?? null,
notes: employee.notes ?? null,
archivedAt: employee.archived_at?.toISOString?.() ?? null,
roleCodes: employee.roles.map((item) => item.role_code),
department: employee.department ? { id: employee.department.id, name: employee.department.name } : null,
directManager: employee.direct_manager ? { id: employee.direct_manager.id, employeeNo: employee.direct_manager.employee_no, name: employee.direct_manager.name } : null,
},
lifecycle: {
events: lifecycleEvents.map(mapLifecycleEvent),
checklistTasks: checklistTasks.map(mapChecklistTask),
blockers,
},
assignedAssets: assignedAssets.map((item) => ({
id: item.id,
assetNo: item.asset_no,
name: item.name,
status: item.status,
location: item.location ?? null,
})),
};
}
async function employeesRoutes(fastify) {
await usersRoutes(fastify, { basePath: "/employees" });
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
if (!canManageProfile(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.view" } });
}
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.patch("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
if (!canManageProfile(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.write" } });
}
const body = parseBody(PROFILE_PATCH_SCHEMA, request.body);
const managerMode = canManageLifecycle(fastify, request);
const selfEditableKeys = ["workLocation", "emergencyContactName", "emergencyContactPhone", "notes"];
if (!managerMode && request.employee.id === employeeId) {
const invalidKeys = Object.keys(body).filter((key) => !selfEditableKeys.includes(key));
if (invalidKeys.length) {
throw buildValidationError({ body: `self_profile_fields_forbidden:${invalidKeys.join(",")}` }, 403);
}
}
await ensureEmployeeExists(fastify.prisma, employeeId);
await fastify.prisma.employee.update({
where: { id: employeeId },
data: {
...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}),
...(body.directManagerId !== undefined ? { direct_manager_id: body.directManagerId } : {}),
...(body.title !== undefined ? { title: body.title } : {}),
...(body.probationEndDate !== undefined ? { probation_end_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null } : {}),
...(body.lastWorkingDate !== undefined ? { last_working_date: body.lastWorkingDate ? new Date(`${body.lastWorkingDate}T00:00:00.000Z`) : null } : {}),
...(body.workLocation !== undefined ? { work_location: body.workLocation ?? null } : {}),
...(body.emergencyContactName !== undefined ? { emergency_contact_name: body.emergencyContactName ?? null } : {}),
...(body.emergencyContactPhone !== undefined ? { emergency_contact_phone: body.emergencyContactPhone ?? null } : {}),
...(body.notes !== undefined ? { notes: body.notes ?? null } : {}),
updated_by: request.employee.id,
},
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.post("/employees/:id/onboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(ONBOARDING_SCHEMA, request.body);
const effectiveDate = resolveEffectiveDate(body.effectiveDate);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
await tx.employee.update({
where: { id: employeeId },
data: {
employment_status: "onboarding",
probation_end_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null,
notes: body.notes !== undefined ? body.notes ?? null : undefined,
updated_by: request.employee.id,
},
});
await tx.employeeLifecycleEvent.create({
data: {
employee_id: employeeId,
event_type: "onboard",
effective_date: effectiveDate,
payload_json: { notes: body.notes ?? null },
created_by: request.employee.id,
},
});
const checklist = buildDefaultChecklist("onboarding", employeeId, request.employee.id).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null }));
await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false });
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.post("/employees/:id/regularize", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(REGULARIZE_SCHEMA, request.body);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "active", updated_by: request.employee.id } });
await tx.employeeLifecycleEvent.create({
data: {
employee_id: employeeId,
event_type: "regularize",
effective_date: resolveEffectiveDate(body.effectiveDate),
payload_json: { notes: body.notes ?? null },
created_by: request.employee.id,
},
});
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.post("/employees/:id/transfer", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(TRANSFER_SCHEMA, request.body);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
await tx.employee.update({
where: { id: employeeId },
data: {
...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}),
...(body.directManagerId !== undefined ? { direct_manager_id: body.directManagerId } : {}),
...(body.title !== undefined ? { title: body.title } : {}),
updated_by: request.employee.id,
},
});
await tx.employeeLifecycleEvent.create({
data: {
employee_id: employeeId,
event_type: "transfer",
effective_date: resolveEffectiveDate(body.effectiveDate),
payload_json: {
departmentId: body.departmentId ?? null,
directManagerId: body.directManagerId ?? null,
title: body.title ?? null,
notes: body.notes ?? null,
},
created_by: request.employee.id,
},
});
await tx.employeeChecklistTask.createMany({
data: buildDefaultChecklist("transfer", employeeId, request.employee.id),
});
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.post("/employees/:id/offboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(OFFBOARDING_SCHEMA, request.body);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
await tx.employee.update({
where: { id: employeeId },
data: {
employment_status: "offboarding",
...(body.lastWorkingDate !== undefined ? { last_working_date: body.lastWorkingDate ? new Date(`${body.lastWorkingDate}T00:00:00.000Z`) : null } : {}),
updated_by: request.employee.id,
},
});
await tx.employeeLifecycleEvent.create({
data: {
employee_id: employeeId,
event_type: "offboard",
effective_date: resolveEffectiveDate(body.effectiveDate),
payload_json: { notes: body.notes ?? null, lastWorkingDate: body.lastWorkingDate ?? null },
created_by: request.employee.id,
},
});
await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId, request.employee.id) });
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.get("/employees/:id/lifecycle", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
if (!canManageProfile(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.lifecycle.view" } });
}
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile.lifecycle);
});
async function updateChecklistTaskStatus(request, reply, nextStatus) {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
const taskId = parseId(request.params?.taskId);
if (!canManageProfile(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.lifecycle.write" } });
}
const result = await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
const task = await tx.employeeChecklistTask.findFirst({ where: { id: taskId, employee_id: employeeId } });
if (!task) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
await tx.employeeChecklistTask.update({
where: { id: taskId },
data: {
status: nextStatus,
completed_at: nextStatus === "completed" ? new Date() : task.completed_at,
completed_by: nextStatus === "completed" ? request.employee.id : task.completed_by,
},
});
return maybeAdvanceLifecycle(tx, employeeId);
});
if (result.completedOffboarding) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EMPLOYEE_OFFBOARDING_COMPLETED",
targetType: "employee",
targetId: employeeId,
extra: result.blockers ?? {},
}),
});
}
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, { ...profile.lifecycle, autoAdvanced: result.autoChanged });
}
fastify.post("/employees/:id/checklist-tasks/:taskId/complete", { preHandler: fastify.authenticate }, async (request, reply) => {
return updateChecklistTaskStatus(request, reply, "completed");
});
fastify.post("/employees/:id/checklist-tasks/:taskId/skip", { preHandler: fastify.authenticate }, async (request, reply) => {
return updateChecklistTaskStatus(request, reply, "skipped");
});
}
module.exports = employeesRoutes;
+302 -40
查看文件
@@ -1,6 +1,8 @@
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { getWorkspaceSetting } = require("../services/workspaceSettings");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
async function buildTaskVisibilityFilters(tx, request) {
const roleFilters = (request.employee.roleCodes ?? []).length
@@ -27,78 +29,310 @@ async function ensureFeatureEnabled(fastify, request) {
}
}
function buildQuickLinks(request) {
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
async function evaluateOptionalFlag(fastify, request, flagKey) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
return gate.enabled;
}
function resolveWeekStart(rawDateString) {
const date = parseDateOnlyString(rawDateString);
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() - (day - 1));
return date.toISOString().slice(0, 10);
}
function formatPresenceStatus(item) {
if (!item) return "未更新";
if (item.status === "office") return "在岗";
if (item.status === "remote") return "居家";
if (item.status === "out") return "外出";
if (item.status === "travel") return "出差";
if (item.status === "leave") return "请假";
return item.status;
}
async function resolvePresenceForEmployee(prisma, employeeId, dateString) {
const date = parseDateOnlyString(dateString);
const leave = await prisma.leaveRequest.findFirst({
where: {
owner_id: employeeId,
status: "approved",
start_date: { lte: date },
end_date: { gte: date },
},
select: { id: true, reason: true },
});
if (leave) {
return { status: "leave", note: leave.reason ?? null, source: "leave_sync" };
}
const travel = await prisma.travelRequest.findFirst({
where: {
applicant_employee_id: employeeId,
status: "approved",
start_date: { lte: date },
end_date: { gte: date },
},
select: { id: true, purpose: true },
});
if (travel) {
return { status: "travel", note: travel.purpose ?? null, source: "leave_sync" };
}
const manual = await prisma.employeePresence.findUnique({
where: {
employee_id_date: {
employee_id: employeeId,
date,
},
},
});
if (!manual) {
return null;
}
return {
status: manual.status,
note: manual.note ?? null,
source: manual.source,
timeRange: manual.time_range,
};
}
async function buildTeamPresenceSummary(prisma, dateString) {
const employees = await prisma.employee.findMany({ where: { status: "active" }, select: { id: true } });
const summary = { office: 0, remote: 0, out: 0, travel: 0, leave: 0, unspecified: 0, total: employees.length };
for (const employee of employees) {
const item = await resolvePresenceForEmployee(prisma, employee.id, dateString);
const status = item?.status;
if (!status || !(status in summary)) {
summary.unspecified += 1;
continue;
}
summary[status] += 1;
}
return summary;
}
function buildQuickLinks({ request, featureEnabled }) {
const items = [];
items.push({ key: "team", title: "团队状态", href: "/team" });
if (request.employee.permissionCodes.includes("leave.view_own") || request.employee.permissionCodes.includes("leave.view_all")) {
items.push({ key: "leave", title: "请假", href: "/leave-requests" });
}
if (request.employee.permissionCodes.includes("expense.view_own") || request.employee.permissionCodes.includes("expense.view_all")) {
items.push({ key: "expense", title: "我的报销", href: "/reimbursements" });
items.push({ key: "expense", title: "报销", href: "/reimbursements" });
}
if (request.employee.permissionCodes.includes("procurement.view_own") || request.employee.permissionCodes.includes("procurement.view_all")) {
items.push({ key: "procurement", title: "采购申请", href: "/purchase-requests" });
items.push({ key: "assets", title: "资产", href: "/assets" });
if (featureEnabled.weeklyReport) {
items.push({ key: "weekly", title: "周报", href: "/weekly-reports" });
}
if (request.employee.permissionCodes.includes("travel.view_own") || request.employee.permissionCodes.includes("travel.view_all")) {
items.push({ key: "travel", title: "差旅申请", href: "/travel-requests" });
if (featureEnabled.todoBoard) {
items.push({ key: "todo", title: "待办", href: "/todos" });
}
if (request.employee.permissionCodes.includes("budget.view") || request.employee.permissionCodes.includes("budget.manage")) {
items.push({ key: "budget", title: "预算管理", href: "/budgets" });
if (request.employee.permissionCodes.includes("system.users") || request.employee.permissionCodes.includes("system.settings")) {
items.push({ key: "people", title: "人员管理", href: "/people" });
items.push({ key: "workspace", title: "工作区设置", href: "/system/workspace" });
}
if (request.employee.permissionCodes.includes("payment.batch.manage")) {
items.push({ key: "payment-batches", title: "支付批次", href: "/payment-batches" });
}
return items;
return items.slice(0, 8);
}
async function portalRoutes(fastify) {
fastify.get("/portal/home", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const workspace = await getWorkspaceSetting(fastify.prisma);
const today = currentDateString(fastify.config.appTimezone);
const currentWeekStart = resolveWeekStart(today);
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
const now = new Date();
const isManagerView =
fastify.hasPermission(request.employee, "system.users") ||
fastify.hasPermission(request.employee, "report.dashboard.view") ||
fastify.hasPermission(request.employee, "*");
const [pendingTaskCount, reimbursementsCount, leaveCount, purchaseCount, travelCount, riskCount, announcementRows, paymentBatchCount] = await Promise.all([
const featureEnabled = {
announcements: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.announcements),
presenceBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.presenceBoard),
weeklyReport: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.weeklyReport),
employeeLifecycle: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.employeeLifecycle),
todoBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.todoBoard),
paymentBatch: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.paymentBatch),
};
const [
pendingTaskCount,
reimbursementsCount,
leaveCount,
assetCount,
riskCount,
announcementRows,
todoRows,
myWeeklyReport,
onboardingCount,
offboardingCount,
assetReturnPendingCount,
] = await Promise.all([
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }),
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
fastify.prisma.purchaseRequest.count({ where: { applicant_id: request.employee.id } }),
fastify.prisma.travelRequest.count({ where: { OR: [{ applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
(fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage"))
fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage")
? fastify.prisma.entityRiskFlag.count({ where: { status: "open" } })
: Promise.resolve(0),
fastify.prisma.portalAnnouncement.findMany({
where: {
enabled: true,
OR: [{ starts_at: null }, { starts_at: { lte: now } }],
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }],
},
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
take: 6,
}),
fastify.hasPermission(request.employee, "payment.batch.manage")
? fastify.prisma.paymentBatch.count({ where: { status: { in: ["draft", "reviewed", "failed"] } } })
featureEnabled.announcements
? fastify.prisma.portalAnnouncement.findMany({
where: {
enabled: true,
OR: [{ starts_at: null }, { starts_at: { lte: new Date() } }],
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: new Date() } }] }],
},
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
take: 6,
})
: Promise.resolve([]),
featureEnabled.todoBoard
? fastify.prisma.todoItem.findMany({
where: { assignee_employee_id: request.employee.id, status: { in: ["todo", "doing"] } },
orderBy: [{ priority: "desc" }, { due_date: "asc" }, { updated_at: "desc" }],
take: 10,
})
: Promise.resolve([]),
featureEnabled.weeklyReport
? fastify.prisma.weeklyReport.findUnique({
where: {
employee_id_week_start: {
employee_id: request.employee.id,
week_start: parseDateOnlyString(currentWeekStart),
},
},
})
: Promise.resolve(null),
featureEnabled.employeeLifecycle && isManagerView
? fastify.prisma.employee.count({ where: { employment_status: "onboarding", status: "active" } })
: Promise.resolve(0),
featureEnabled.employeeLifecycle && isManagerView
? fastify.prisma.employee.count({ where: { employment_status: "offboarding" } })
: Promise.resolve(0),
featureEnabled.employeeLifecycle && isManagerView
? fastify.prisma.assetCard.count({ where: { assignee: { is: { employment_status: "offboarding" } } } })
: Promise.resolve(0),
]);
const cards = [
{ key: "pending_tasks", title: "我的待办", value: pendingTaskCount, href: "/workflow/tasks", severity: pendingTaskCount > 0 ? "warning" : "normal" },
{ key: "my_reimbursements", title: "报销单", value: reimbursementsCount, href: "/reimbursements", severity: "normal" },
{ key: "my_purchases", title: "采购单", value: purchaseCount, href: "/purchase-requests", severity: "normal" },
{ key: "my_travel", title: "差旅单", value: travelCount, href: "/travel-requests", severity: "normal" },
];
if (paymentBatchCount > 0) {
cards.push({ key: "payment_batches", title: "待处理支付批次", value: paymentBatchCount, href: "/payment-batches", severity: "warning" });
const todayPresence = featureEnabled.presenceBoard
? await resolvePresenceForEmployee(fastify.prisma, request.employee.id, today)
: null;
const teamPresenceSummary = featureEnabled.presenceBoard && isManagerView
? await buildTeamPresenceSummary(fastify.prisma, today)
: null;
const teamWeeklySummary = featureEnabled.weeklyReport && isManagerView
? await (async () => {
const [employeeCount, submittedCount, needsHelpCount] = await Promise.all([
fastify.prisma.employee.count({ where: { status: "active" } }),
fastify.prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(currentWeekStart), status: "submitted" } }),
fastify.prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(currentWeekStart), needs_help: true } }),
]);
return {
weekStart: currentWeekStart,
submittedCount,
missingCount: Math.max(0, employeeCount - submittedCount),
needsHelpCount,
};
})()
: null;
const cards = [];
if (isManagerView) {
cards.push({
key: "team_presence",
title: "今日团队状态",
value: teamPresenceSummary ? `${teamPresenceSummary.office}/${teamPresenceSummary.total} 在岗` : "-",
href: "/team",
severity: teamPresenceSummary && (teamPresenceSummary.leave > 0 || teamPresenceSummary.unspecified > 0) ? "warning" : "normal",
});
cards.push({
key: "weekly_missing",
title: "未提交周报",
value: teamWeeklySummary?.missingCount ?? 0,
href: "/weekly-reports",
severity: (teamWeeklySummary?.missingCount ?? 0) > 0 ? "warning" : "normal",
});
cards.push({
key: "weekly_help",
title: "需协助员工",
value: teamWeeklySummary?.needsHelpCount ?? 0,
href: "/weekly-reports",
severity: (teamWeeklySummary?.needsHelpCount ?? 0) > 0 ? "high" : "normal",
});
cards.push({
key: "people_lifecycle",
title: "入/离职待办",
value: (onboardingCount ?? 0) + (offboardingCount ?? 0),
href: "/people",
severity: (offboardingCount ?? 0) > 0 ? "warning" : "normal",
});
cards.push({
key: "asset_return_pending",
title: "资产待归还",
value: assetReturnPendingCount ?? 0,
href: "/people",
severity: (assetReturnPendingCount ?? 0) > 0 ? "warning" : "normal",
});
cards.push({
key: "pending_approvals",
title: "审批待办",
value: pendingTaskCount,
href: "/workflow/tasks",
severity: pendingTaskCount > 0 ? "warning" : "normal",
});
} else {
cards.push({
key: "my_todos",
title: "我的待办",
value: featureEnabled.todoBoard ? todoRows.length : 0,
href: "/todos",
severity: todoRows.some((item) => item.priority === "high") ? "warning" : "normal",
});
cards.push({
key: "today_presence",
title: "今日状态",
value: featureEnabled.presenceBoard ? formatPresenceStatus(todayPresence) : "未启用",
href: "/team",
severity: todayPresence?.status === "leave" ? "warning" : "normal",
});
cards.push({ key: "my_leave", title: "我的请假", value: leaveCount, href: "/leave-requests", severity: "normal" });
cards.push({ key: "my_reimbursements", title: "我的报销", value: reimbursementsCount, href: "/reimbursements", severity: "normal" });
cards.push({ key: "my_assets", title: "我的资产", value: assetCount, href: "/assets", severity: "normal" });
cards.push({
key: "my_weekly_report",
title: "本周周报",
value: featureEnabled.weeklyReport ? (myWeeklyReport?.status === "submitted" ? "已提交" : "待填写") : "未启用",
href: "/weekly-reports",
severity: myWeeklyReport?.status === "submitted" ? "normal" : "warning",
});
}
if (riskCount > 0) {
if (featureEnabled.paymentBatch && riskCount > 0) {
cards.push({ key: "risk_flags", title: "待复核风险", value: riskCount, href: "/ap-operations", severity: "high" });
}
return sendData(reply, request, {
workspace,
greeting: { name: request.employee.name ?? request.employee.id },
cards,
pendingTasks: { count: pendingTaskCount, href: "/workflow/tasks" },
myApplications: [
{ domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" },
{ domain: "leave", count: leaveCount, href: "/leave-requests" },
{ domain: "purchase_request", count: purchaseCount, href: "/purchase-requests" },
{ domain: "travel_request", count: travelCount, href: "/travel-requests" },
],
riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null,
announcements: announcementRows.map((item) => ({
@@ -107,7 +341,35 @@ async function portalRoutes(fastify) {
summary: item.summary,
href: item.link_url ?? null,
})),
quickLinks: buildQuickLinks(request),
quickLinks: buildQuickLinks({ request, featureEnabled }),
todayPresence: featureEnabled.presenceBoard
? { date: today, status: todayPresence?.status ?? null, label: formatPresenceStatus(todayPresence), note: todayPresence?.note ?? null, href: "/team" }
: null,
teamPresence: teamPresenceSummary ? { date: today, href: "/team", ...teamPresenceSummary } : null,
weeklyReport: featureEnabled.weeklyReport
? {
weekStart: currentWeekStart,
status: myWeeklyReport?.status ?? "draft",
needsHelp: myWeeklyReport?.needs_help ?? false,
href: "/weekly-reports",
}
: null,
weeklySummary: teamWeeklySummary ? { ...teamWeeklySummary, href: "/weekly-reports" } : null,
todoSummary: featureEnabled.todoBoard
? {
openCount: todoRows.length,
highPriorityCount: todoRows.filter((item) => item.priority === "high").length,
href: "/todos",
}
: null,
peopleLifecycle: featureEnabled.employeeLifecycle && isManagerView
? {
onboardingCount,
offboardingCount,
assetReturnPendingCount,
href: "/people",
}
: null,
});
});
}
+310
查看文件
@@ -0,0 +1,310 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { parseDateOnlyString, currentDateString } = require("../utils/date");
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const QUERY_SCHEMA = z.object({ date: DATE_SCHEMA.optional() });
const WRITE_SCHEMA = z
.object({
date: DATE_SCHEMA.optional(),
status: z.enum(["office", "remote", "out", "travel"]),
timeRange: z.enum(["full_day", "morning", "afternoon"]).optional(),
note: z.string().trim().max(300).nullable().optional(),
})
.strict();
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function formatDateOnly(value) {
if (!value) {
return null;
}
return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`).toISOString().slice(0, 10);
}
function resolveDateInput(rawDate, timeZone) {
return rawDate ? rawDate : currentDateString(timeZone ?? "Asia/Shanghai");
}
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.presenceBoard,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "presence_board_v1_disabled", reason: gate.reason }, 409);
}
}
function computeLeaveTimeRange(leave, dateString) {
const startDate = formatDateOnly(leave.start_date);
const endDate = formatDateOnly(leave.end_date);
if (startDate === dateString && endDate === dateString) {
if (leave.start_session === "pm" && leave.end_session === "pm") return "afternoon";
if (leave.start_session === "am" && leave.end_session === "am") return "morning";
return "full_day";
}
if (startDate === dateString && leave.start_session === "pm") {
return "afternoon";
}
if (endDate === dateString && leave.end_session === "am") {
return "morning";
}
return "full_day";
}
function mapPresenceRow(row) {
if (!row) {
return null;
}
return {
id: row.id,
employeeId: row.employee_id,
date: formatDateOnly(row.date),
status: row.status,
timeRange: row.time_range,
note: row.note ?? null,
source: row.source,
updatedAt: row.updated_at?.toISOString?.() ?? null,
};
}
async function findLeaveOverlay(prisma, employeeIds, dateString) {
if (!employeeIds.length) {
return new Map();
}
const date = parseDateOnlyString(dateString);
const rows = await prisma.leaveRequest.findMany({
where: {
owner_id: { in: employeeIds },
status: "approved",
start_date: { lte: date },
end_date: { gte: date },
},
select: {
id: true,
owner_id: true,
start_date: true,
end_date: true,
start_session: true,
end_session: true,
reason: true,
},
});
const map = new Map();
for (const row of rows) {
if (!map.has(row.owner_id)) {
map.set(row.owner_id, {
id: `leave-${row.id}`,
employeeId: row.owner_id,
date: dateString,
status: "leave",
timeRange: computeLeaveTimeRange(row, dateString),
note: row.reason ?? null,
source: "leave_sync",
updatedAt: null,
});
}
}
return map;
}
async function findTravelOverlay(prisma, employeeIds, dateString) {
if (!employeeIds.length) {
return new Map();
}
const date = parseDateOnlyString(dateString);
const rows = await prisma.travelRequest.findMany({
where: {
applicant_employee_id: { in: employeeIds },
status: "approved",
start_date: { lte: date },
end_date: { gte: date },
},
select: {
id: true,
applicant_employee_id: true,
purpose: true,
},
});
const map = new Map();
for (const row of rows) {
if (!map.has(row.applicant_employee_id)) {
map.set(row.applicant_employee_id, {
id: `travel-${row.id}`,
employeeId: row.applicant_employee_id,
date: dateString,
status: "travel",
timeRange: "full_day",
note: row.purpose ?? null,
source: "leave_sync",
updatedAt: null,
});
}
}
return map;
}
async function buildPresenceState(prisma, employeeIds, dateString) {
const [manualRows, leaveOverlay, travelOverlay] = await Promise.all([
prisma.employeePresence.findMany({
where: { employee_id: { in: employeeIds }, date: parseDateOnlyString(dateString) },
}),
findLeaveOverlay(prisma, employeeIds, dateString),
findTravelOverlay(prisma, employeeIds, dateString),
]);
const manualMap = new Map(manualRows.map((row) => [row.employee_id, mapPresenceRow(row)]));
const result = new Map();
for (const employeeId of employeeIds) {
if (leaveOverlay.has(employeeId)) {
result.set(employeeId, leaveOverlay.get(employeeId));
continue;
}
if (travelOverlay.has(employeeId)) {
result.set(employeeId, travelOverlay.get(employeeId));
continue;
}
if (manualMap.has(employeeId)) {
result.set(employeeId, manualMap.get(employeeId));
}
}
return result;
}
function buildSummary(items) {
const summary = {
office: 0,
remote: 0,
out: 0,
travel: 0,
leave: 0,
unspecified: 0,
total: items.length,
};
for (const item of items) {
const status = item.presence?.status;
if (!status || !(status in summary)) {
summary.unspecified += 1;
continue;
}
summary[status] += 1;
}
return summary;
}
async function presenceRoutes(fastify) {
fastify.get("/presence/me", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const dateString = resolveDateInput(parsed.data.date, fastify.config.appTimezone);
const map = await buildPresenceState(fastify.prisma, [request.employee.id], dateString);
return sendData(reply, request, { item: map.get(request.employee.id) ?? null, date: dateString });
});
fastify.put("/presence/me", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const body = parseBody(WRITE_SCHEMA, request.body);
const dateString = resolveDateInput(body.date, fastify.config.appTimezone);
const leaveOverlay = await findLeaveOverlay(fastify.prisma, [request.employee.id], dateString);
if (leaveOverlay.has(request.employee.id)) {
throw buildValidationError({ date: "covered_by_approved_leave" }, 409);
}
const record = await fastify.prisma.employeePresence.upsert({
where: {
employee_id_date: {
employee_id: request.employee.id,
date: parseDateOnlyString(dateString),
},
},
update: {
status: body.status,
time_range: body.timeRange ?? "full_day",
note: body.note ?? null,
source: "manual",
updated_by: request.employee.id,
},
create: {
employee_id: request.employee.id,
date: parseDateOnlyString(dateString),
status: body.status,
time_range: body.timeRange ?? "full_day",
note: body.note ?? null,
source: "manual",
updated_by: request.employee.id,
},
});
return sendData(reply, request, { item: mapPresenceRow(record) });
});
fastify.get("/presence/team", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const dateString = resolveDateInput(parsed.data.date, fastify.config.appTimezone);
const employees = await fastify.prisma.employee.findMany({
where: { status: "active" },
orderBy: [{ department_id: "asc" }, { employee_no: "asc" }],
select: {
id: true,
employee_no: true,
name: true,
title: true,
employment_status: true,
department: { select: { id: true, name: true } },
},
});
const map = await buildPresenceState(fastify.prisma, employees.map((item) => item.id), dateString);
const items = employees.map((item) => ({
employee: {
id: item.id,
employeeNo: item.employee_no,
name: item.name,
title: item.title,
employmentStatus: item.employment_status,
department: item.department ? { id: item.department.id, name: item.department.name } : null,
},
presence: map.get(item.id) ?? null,
}));
return sendData(reply, request, { date: dateString, summary: buildSummary(items), items });
});
fastify.get("/presence/summary", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const dateString = resolveDateInput(parsed.data.date, fastify.config.appTimezone);
const employeeIds = (
await fastify.prisma.employee.findMany({ where: { status: "active" }, select: { id: true } })
).map((item) => item.id);
const map = await buildPresenceState(fastify.prisma, employeeIds, dateString);
const items = employeeIds.map((employeeId) => ({ employeeId, presence: map.get(employeeId) ?? null }));
return sendData(reply, request, { date: dateString, summary: buildSummary(items) });
});
}
module.exports = presenceRoutes;
+198 -51
查看文件
@@ -9,6 +9,18 @@ const QUERY_SCHEMA = z.object({
limit: z.coerce.number().int().min(1).max(20).optional(),
});
const ENTITY_PRIORITY = {
employee: 0,
announcement: 1,
asset: 2,
leave: 3,
reimbursement: 4,
weekly_report: 5,
todo: 6,
purchase_request: 7,
travel_request: 8,
};
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
@@ -39,6 +51,17 @@ function compareMatchedBy(left, right) {
return score[left] - score[right];
}
async function isFlagEnabled(fastify, request, flagKey) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
return gate.enabled;
}
async function searchRoutes(fastify) {
fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
@@ -47,8 +70,131 @@ async function searchRoutes(fastify) {
const q = parsed.data.q.trim();
const limit = parsed.data.limit ?? 10;
const tasks = [];
const isAdmin = fastify.hasPermission(request.employee, "*");
const featureEnabled = {
announcements: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.announcements),
weeklyReport: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.weeklyReport),
todoBoard: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.todoBoard),
};
const tasks = [];
tasks.push(
fastify.prisma.employee.findMany({
where: {
status: "active",
OR: [
{ employee_no: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, employee_no: true, name: true, email: true, title: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "employee",
entityId: item.id,
title: item.name,
subtitle: `${item.employee_no} · ${item.title ?? item.email ?? "成员"}`,
status: "active",
href: `/people/${item.id}`,
matchedBy: matchByValue(q, item.employee_no, item.name, item.email),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
if (featureEnabled.announcements) {
tasks.push(
fastify.prisma.portalAnnouncement.findMany({
where: {
enabled: true,
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ summary: { contains: q, mode: "insensitive" } },
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, title: true, summary: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "announcement",
entityId: item.id,
title: item.title,
subtitle: item.summary,
status: null,
href: "/announcements",
matchedBy: matchByValue(q, item.title, item.summary),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
if (
fastify.hasPermission(request.employee, "asset.view_own") ||
fastify.hasPermission(request.employee, "asset.view_all") ||
fastify.hasPermission(request.employee, "asset.manage") ||
fastify.hasPermission(request.employee, "asset.transfer") ||
fastify.hasPermission(request.employee, "asset.inventory")
) {
tasks.push(
fastify.prisma.assetCard.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin
? {}
: { assignee_employee_id: request.employee.id },
{
OR: [
{ asset_no: { contains: q, mode: "insensitive" } },
{ serial_no: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
],
},
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "asset",
entityId: item.id,
title: item.asset_no,
subtitle: item.name ?? item.serial_no ?? null,
status: item.status,
href: "/assets",
matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) {
tasks.push(
fastify.prisma.leaveRequest.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin
? {}
: { owner_id: request.employee.id },
{ OR: [{ reason: { contains: q, mode: "insensitive" } }] },
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "leave",
entityId: item.id,
title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`,
subtitle: item.reason ?? null,
status: item.status,
href: "/leave-requests",
matchedBy: matchByValue(q, item.reason),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) {
tasks.push(
@@ -82,28 +228,66 @@ async function searchRoutes(fastify) {
);
}
if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) {
if (featureEnabled.weeklyReport) {
tasks.push(
fastify.prisma.leaveRequest.findMany({
fastify.prisma.weeklyReport.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin
fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin
? {}
: { owner_id: request.employee.id },
{ OR: [{ reason: { contains: q, mode: "insensitive" } }] },
: { employee_id: request.employee.id },
{
OR: [
{ highlights_text: { contains: q, mode: "insensitive" } },
{ blockers_text: { contains: q, mode: "insensitive" } },
{ next_week_text: { contains: q, mode: "insensitive" } },
],
},
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true },
include: { employee: { select: { name: true } } },
}).then((rows) => rows.map((item) => ({
entityType: "leave",
entityType: "weekly_report",
entityId: item.id,
title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`,
subtitle: item.reason ?? null,
title: `${item.employee?.name ?? "成员"} 周报 ${item.week_start?.toISOString?.()?.slice(0, 10) ?? ""}`,
subtitle: item.highlights_text || item.next_week_text || item.blockers_text || null,
status: item.status,
href: "/leave-requests",
matchedBy: matchByValue(q, item.reason),
href: "/weekly-reports",
matchedBy: matchByValue(q, item.highlights_text, item.blockers_text, item.next_week_text),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
if (featureEnabled.todoBoard) {
tasks.push(
fastify.prisma.todoItem.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin
? {}
: { OR: [{ assignee_employee_id: request.employee.id }, { created_by: request.employee.id }] },
{
OR: [
{ title: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: "insensitive" } },
],
},
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, title: true, description: true, status: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "todo",
entityId: item.id,
title: item.title,
subtitle: item.description ?? null,
status: item.status,
href: "/todos",
matchedBy: matchByValue(q, item.title, item.description),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
@@ -173,49 +357,12 @@ async function searchRoutes(fastify) {
);
}
if (
fastify.hasPermission(request.employee, "asset.view_own") ||
fastify.hasPermission(request.employee, "asset.view_all") ||
fastify.hasPermission(request.employee, "asset.manage") ||
fastify.hasPermission(request.employee, "asset.transfer") ||
fastify.hasPermission(request.employee, "asset.inventory")
) {
tasks.push(
fastify.prisma.assetCard.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin
? {}
: { assignee_employee_id: request.employee.id },
{
OR: [
{ asset_no: { contains: q, mode: "insensitive" } },
{ serial_no: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
],
},
],
},
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true },
}).then((rows) => rows.map((item) => ({
entityType: "asset",
entityId: item.id,
title: item.asset_no,
subtitle: item.name ?? item.serial_no ?? null,
status: item.status,
href: "/assets",
matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name),
updatedAt: item.updated_at?.toISOString?.() ?? null,
}))),
);
}
const items = (await Promise.all(tasks)).flat();
items.sort((left, right) => {
const matchedDiff = compareMatchedBy(left.matchedBy, right.matchedBy);
if (matchedDiff !== 0) return matchedDiff;
const entityDiff = (ENTITY_PRIORITY[left.entityType] ?? 999) - (ENTITY_PRIORITY[right.entityType] ?? 999);
if (entityDiff !== 0) return entityDiff;
return String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""));
});
+80
查看文件
@@ -0,0 +1,80 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const {
DEFAULT_MODULE_VISIBILITY,
getWorkspaceSetting,
getSystemNavigation,
normalizeModuleVisibilityJson,
upsertWorkspaceSetting,
} = require("../services/workspaceSettings");
const MODULE_VISIBILITY_SCHEMA = z.record(z.string(), z.enum(["main", "admin", "hidden"])).optional();
const PATCH_SCHEMA = z
.object({
moduleVisibility: MODULE_VISIBILITY_SCHEMA,
})
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
async function writeWorkspaceAuditLog(fastify, request, workspace) {
try {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "WORKSPACE_SETTING_UPDATED",
targetType: "workspace_setting",
targetId: workspace.id,
extra: { moduleVisibility: workspace.moduleVisibility },
}),
});
} catch (error) {
request.log.error({ err: error, workspaceId: workspace.id }, "workspace setting audit failed");
}
}
async function systemWorkspaceRoutes(fastify) {
fastify.get("/system/workspace", { preHandler: fastify.authenticate }, async (request, reply) => {
const workspace = await getWorkspaceSetting(fastify.prisma);
return sendData(reply, request, {
...workspace,
defaults: DEFAULT_MODULE_VISIBILITY,
});
});
fastify.patch("/system/workspace", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
const body = parseBody(PATCH_SCHEMA, request.body);
const workspace = await upsertWorkspaceSetting(fastify.prisma, request.employee.id, {
moduleVisibility: normalizeModuleVisibilityJson(body.moduleVisibility ?? null),
});
await writeWorkspaceAuditLog(fastify, request, workspace);
return sendData(reply, request, { item: workspace });
});
fastify.get("/system/navigation", { preHandler: fastify.authenticate }, async (request, reply) => {
const data = await getSystemNavigation({
prisma: fastify.prisma,
config: fastify.config,
employee: request.employee,
hasPermission: fastify.hasPermission,
});
return sendData(reply, request, data);
});
}
module.exports = systemWorkspaceRoutes;
+238
查看文件
@@ -0,0 +1,238 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { createNotifications } = require("../services/notifications");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const UUID_SCHEMA = z.string().uuid();
const QUERY_SCHEMA = z.object({
status: z.enum(["todo", "doing", "done", "cancelled"]).optional(),
scope: z.enum(["mine", "team"]).optional(),
assigneeId: UUID_SCHEMA.optional(),
q: z.string().trim().max(100).optional(),
});
const CREATE_SCHEMA = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(2000).nullable().optional(),
assigneeEmployeeId: UUID_SCHEMA.optional(),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
priority: z.enum(["low", "normal", "high"]).optional(),
source: z.enum(["manual", "weekly_report"]).optional(),
}).strict();
const PATCH_SCHEMA = z.object({
title: z.string().trim().min(1).max(200).optional(),
description: z.string().trim().max(2000).nullable().optional(),
assigneeEmployeeId: UUID_SCHEMA.optional(),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
priority: z.enum(["low", "normal", "high"]).optional(),
status: z.enum(["todo", "doing", "done", "cancelled"]).optional(),
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function parseUuid(value, fieldName = "id") {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [fieldName]: "invalid_uuid" });
}
return parsed.data;
}
function mapTodo(row) {
return {
id: row.id,
title: row.title,
description: row.description ?? null,
assigneeEmployeeId: row.assignee_employee_id,
createdBy: row.created_by,
dueDate: row.due_date?.toISOString?.()?.slice(0, 10) ?? null,
priority: row.priority,
status: row.status,
source: row.source,
createdAt: row.created_at?.toISOString?.() ?? null,
updatedAt: row.updated_at?.toISOString?.() ?? null,
completedAt: row.completed_at?.toISOString?.() ?? null,
assignee: row.assignee ? { id: row.assignee.id, employeeNo: row.assignee.employee_no, name: row.assignee.name } : null,
creator: row.creator ? { id: row.creator.id, employeeNo: row.creator.employee_no, name: row.creator.name } : null,
};
}
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.todoBoard,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "todo_board_v1_disabled", reason: gate.reason }, 409);
}
}
function canManageTeamTodos(fastify, employee) {
return fastify.hasPermission(employee, "report.dashboard.view") || fastify.hasPermission(employee, "system.users");
}
async function todosRoutes(fastify) {
fastify.get("/todos", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const query = parsed.data;
const canManage = canManageTeamTodos(fastify, request.employee);
const scope = query.scope ?? "mine";
const where = {
AND: [
query.status ? { status: query.status } : {},
query.q
? {
OR: [
{ title: { contains: query.q, mode: "insensitive" } },
{ description: { contains: query.q, mode: "insensitive" } },
],
}
: {},
scope === "team" && canManage
? query.assigneeId
? { assignee_employee_id: query.assigneeId }
: {}
: {
OR: [{ assignee_employee_id: request.employee.id }, { created_by: request.employee.id }],
},
],
};
const rows = await fastify.prisma.todoItem.findMany({
where,
orderBy: [{ status: "asc" }, { priority: "desc" }, { due_date: "asc" }, { updated_at: "desc" }],
include: {
assignee: { select: { id: true, employee_no: true, name: true } },
creator: { select: { id: true, employee_no: true, name: true } },
},
});
return sendData(reply, request, { items: rows.map(mapTodo) });
});
fastify.post("/todos", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const body = parseBody(CREATE_SCHEMA, request.body);
const assigneeEmployeeId = body.assigneeEmployeeId ?? request.employee.id;
if (assigneeEmployeeId !== request.employee.id && !canManageTeamTodos(fastify, request.employee)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
}
const created = await fastify.prisma.todoItem.create({
data: {
title: body.title,
description: body.description ?? null,
assignee_employee_id: assigneeEmployeeId,
created_by: request.employee.id,
due_date: body.dueDate ? new Date(`${body.dueDate}T00:00:00.000Z`) : null,
priority: body.priority ?? "normal",
status: "todo",
source: body.source ?? "manual",
},
include: {
assignee: { select: { id: true, employee_no: true, name: true } },
creator: { select: { id: true, employee_no: true, name: true } },
},
});
if (assigneeEmployeeId !== request.employee.id) {
await createNotifications(fastify.prisma, {
recipientEmployeeIds: [assigneeEmployeeId],
type: "todo.assigned",
title: `新的待办:${created.title}`,
body: created.description ?? "请到待办页查看详情。",
targetType: "todo_item",
targetId: created.id,
});
}
return reply.code(201).send({ requestId: request.id, data: { item: mapTodo(created) } });
});
fastify.patch("/todos/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const todoId = parseUuid(request.params?.id);
const body = parseBody(PATCH_SCHEMA, request.body);
const current = await fastify.prisma.todoItem.findUnique({ where: { id: todoId } });
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const canManage = canManageTeamTodos(fastify, request.employee);
if (!(canManage || current.assignee_employee_id === request.employee.id || current.created_by === request.employee.id)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "todo.write" } });
}
if (body.assigneeEmployeeId && body.assigneeEmployeeId !== request.employee.id && !canManage) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
}
const updated = await fastify.prisma.todoItem.update({
where: { id: todoId },
data: {
...(body.title !== undefined ? { title: body.title } : {}),
...(body.description !== undefined ? { description: body.description ?? null } : {}),
...(body.assigneeEmployeeId !== undefined ? { assignee_employee_id: body.assigneeEmployeeId } : {}),
...(body.dueDate !== undefined ? { due_date: body.dueDate ? new Date(`${body.dueDate}T00:00:00.000Z`) : null } : {}),
...(body.priority !== undefined ? { priority: body.priority } : {}),
...(body.status !== undefined ? { status: body.status, completed_at: body.status === "done" ? new Date() : body.status === "cancelled" ? current.completed_at : null } : {}),
},
include: {
assignee: { select: { id: true, employee_no: true, name: true } },
creator: { select: { id: true, employee_no: true, name: true } },
},
});
return sendData(reply, request, { item: mapTodo(updated) });
});
fastify.post("/todos/:id/complete", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const todoId = parseUuid(request.params?.id);
const current = await fastify.prisma.todoItem.findUnique({ where: { id: todoId } });
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!(canManageTeamTodos(fastify, request.employee) || current.assignee_employee_id === request.employee.id || current.created_by === request.employee.id)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "todo.complete" } });
}
const updated = await fastify.prisma.todoItem.update({
where: { id: todoId },
data: { status: "done", completed_at: new Date() },
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
});
return sendData(reply, request, { item: mapTodo(updated) });
});
fastify.post("/todos/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const todoId = parseUuid(request.params?.id);
const current = await fastify.prisma.todoItem.findUnique({ where: { id: todoId } });
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!(canManageTeamTodos(fastify, request.employee) || current.assignee_employee_id === request.employee.id || current.created_by === request.employee.id)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "todo.cancel" } });
}
const updated = await fastify.prisma.todoItem.update({
where: { id: todoId },
data: { status: "cancelled", completed_at: null },
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
});
return sendData(reply, request, { item: mapTodo(updated) });
});
}
module.exports = todosRoutes;
+213
查看文件
@@ -0,0 +1,213 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const QUERY_SCHEMA = z.object({ weekStart: DATE_SCHEMA.optional() });
const WRITE_SCHEMA = z
.object({
weekStart: DATE_SCHEMA.optional(),
highlightsText: z.string().max(5000).optional(),
blockersText: z.string().max(5000).optional(),
nextWeekText: z.string().max(5000).optional(),
needsHelp: z.boolean().optional(),
})
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function normalizeDateString(value) {
return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10);
}
function resolveWeekStart(rawValue, timeZone) {
const base = rawValue ? normalizeDateString(rawValue) : currentDateString(timeZone ?? "Asia/Shanghai");
const date = parseDateOnlyString(base);
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() - (day - 1));
return date.toISOString().slice(0, 10);
}
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.weeklyReport,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "weekly_report_v1_disabled", reason: gate.reason }, 409);
}
}
function mapReport(row) {
return {
id: row.id,
employeeId: row.employee_id,
weekStart: row.week_start?.toISOString?.()?.slice(0, 10) ?? null,
status: row.status,
highlightsText: row.highlights_text,
blockersText: row.blockers_text,
nextWeekText: row.next_week_text,
needsHelp: row.needs_help,
submittedAt: row.submitted_at?.toISOString?.() ?? null,
createdAt: row.created_at?.toISOString?.() ?? null,
updatedAt: row.updated_at?.toISOString?.() ?? null,
};
}
async function ensureReportDraft(prisma, employeeId, weekStart) {
const existing = await prisma.weeklyReport.findUnique({
where: {
employee_id_week_start: {
employee_id: employeeId,
week_start: parseDateOnlyString(weekStart),
},
},
});
if (existing) {
return existing;
}
return prisma.weeklyReport.create({
data: {
employee_id: employeeId,
week_start: parseDateOnlyString(weekStart),
status: "draft",
highlights_text: "",
blockers_text: "",
next_week_text: "",
needs_help: false,
},
});
}
async function weeklyReportsRoutes(fastify) {
fastify.get("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
const report = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
return sendData(reply, request, { item: mapReport(report) });
});
fastify.put("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const body = parseBody(WRITE_SCHEMA, request.body);
const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone);
const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
if (current.status === "submitted") {
throw buildValidationError({ status: "submitted_readonly" }, 409);
}
const updated = await fastify.prisma.weeklyReport.update({
where: { id: current.id },
data: {
...(body.highlightsText !== undefined ? { highlights_text: body.highlightsText } : {}),
...(body.blockersText !== undefined ? { blockers_text: body.blockersText } : {}),
...(body.nextWeekText !== undefined ? { next_week_text: body.nextWeekText } : {}),
...(body.needsHelp !== undefined ? { needs_help: body.needsHelp } : {}),
},
});
return sendData(reply, request, { item: mapReport(updated) });
});
fastify.post("/weekly-reports/me/submit", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const body = parseBody(QUERY_SCHEMA, request.body);
const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone);
const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
const updated = await fastify.prisma.weeklyReport.update({
where: { id: current.id },
data: {
status: "submitted",
submitted_at: new Date(),
},
});
return sendData(reply, request, { item: mapReport(updated) });
});
fastify.get("/weekly-reports/team", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!(fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users"))) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
}
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
const [employees, reports] = await Promise.all([
fastify.prisma.employee.findMany({
where: { status: "active" },
orderBy: { employee_no: "asc" },
select: {
id: true,
employee_no: true,
name: true,
title: true,
department: { select: { id: true, name: true } },
},
}),
fastify.prisma.weeklyReport.findMany({
where: { week_start: parseDateOnlyString(weekStart) },
select: {
id: true,
employee_id: true,
week_start: true,
status: true,
highlights_text: true,
blockers_text: true,
next_week_text: true,
needs_help: true,
submitted_at: true,
created_at: true,
updated_at: true,
},
}),
]);
const reportMap = new Map(reports.map((item) => [item.employee_id, item]));
const items = employees.map((employee) => {
const report = reportMap.get(employee.id) ?? null;
return {
employee: {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
title: employee.title,
department: employee.department ? { id: employee.department.id, name: employee.department.name } : null,
},
report: report ? mapReport(report) : null,
};
});
const summary = {
totalEmployees: employees.length,
submittedCount: items.filter((item) => item.report?.status === "submitted").length,
draftCount: items.filter((item) => item.report?.status === "draft").length,
missingCount: items.filter((item) => !item.report).length,
needsHelpCount: items.filter((item) => item.report?.needsHelp).length,
};
return sendData(reply, request, { weekStart, summary, items });
});
}
module.exports = weeklyReportsRoutes;
+20
查看文件
@@ -10,6 +10,10 @@ const FEATURE_FLAG_KEYS = {
announcements: "announcements_v1",
workflowDefinitionAdmin: "workflow_definition_admin_v1",
cutoverConsole: "cutover_console_v1",
presenceBoard: "presence_board_v1",
weeklyReport: "weekly_report_v1",
employeeLifecycle: "employee_lifecycle_v1",
todoBoard: "todo_board_v1",
};
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
@@ -107,6 +111,22 @@ function resolveEnvGate(config, flagKey) {
return Boolean(config.cutoverConsoleEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.presenceBoard) {
return Boolean(config.presenceBoardEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.weeklyReport) {
return Boolean(config.weeklyReportEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.employeeLifecycle) {
return Boolean(config.employeeLifecycleEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.todoBoard) {
return Boolean(config.todoBoardEnabled);
}
return false;
}
+248
查看文件
@@ -0,0 +1,248 @@
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
const WORKSPACE_PROFILES = Object.freeze({
smallTeamInternal: "small_team_internal",
});
const MODULE_VISIBILITY_MODES = Object.freeze(["main", "admin", "hidden"]);
const NAVIGATION_REGISTRY = Object.freeze({
portal: { key: "portal", label: "Portal", icon: "home_app_logo", href: "/portal", anyOf: [] },
team: { key: "team", label: "团队", icon: "groups", href: "/team", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.presenceBoard },
leaveRequests: { key: "leaveRequests", label: "请假", icon: "event_available", href: "/leave-requests", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
reimbursements: { key: "reimbursements", label: "报销", icon: "request_quote", href: "/reimbursements", anyOf: ["expense.view_own", "expense.view_all"] },
assets: { key: "assets", label: "资产", icon: "inventory_2", href: "/assets", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
weeklyReports: { key: "weeklyReports", label: "周报", icon: "article", href: "/weekly-reports", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.weeklyReport },
todos: { key: "todos", label: "待办", icon: "check_circle", href: "/todos", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.todoBoard },
announcements: { key: "announcements", label: "公告", icon: "campaign", href: "/announcements", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.announcements },
workspace: { key: "workspace", label: "工作区设置", icon: "tune", href: "/system/workspace", anyOf: ["system.settings"] },
people: { key: "people", label: "人员管理", icon: "group", href: "/people", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
systemSettings: { key: "systemSettings", label: "系统设置", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
featureFlags: { key: "featureFlags", label: "功能开关", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
auditLogs: { key: "auditLogs", label: "审计日志", icon: "manage_search", href: "/audit-logs", anyOf: ["system.audit.view"] },
approvalRules: { key: "approvalRules", label: "审批规则", icon: "rule", href: "/system/approval-rules", anyOf: ["system.settings"] },
userManagement: { key: "userManagement", label: "用户管理", icon: "badge", href: "/system/users", anyOf: ["system.users"] },
departments: { key: "departments", label: "部门管理", icon: "account_tree", href: "/system/departments", anyOf: ["system.users"] },
masterData: { key: "masterData", label: "主数据", icon: "dataset", href: "/system/master-data", anyOf: ["masterdata.view", "masterdata.manage"] },
notificationChannels: { key: "notificationChannels", label: "通知渠道", icon: "notifications_active", href: "/system/notification-channels", anyOf: ["notification.channel.manage"] },
workflowTasks: { key: "workflowTasks", label: "审批待办", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
dashboard: { key: "dashboard", label: "仪表盘", icon: "dashboard", href: "/dashboard", anyOf: ["report.dashboard.view"] },
purchaseRequests: { key: "purchaseRequests", label: "采购申请", icon: "shopping_cart", href: "/purchase-requests", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.create", "travel.view_own", "travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] },
budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] },
paymentBatches: { key: "paymentBatches", label: "支付批次", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
apOperations: { key: "apOperations", label: "AP 运营", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
invoicePool: { key: "invoicePool", label: "票据中心", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"] },
inventoryChecks: { key: "inventoryChecks", label: "资产盘点", icon: "inventory", href: "/inventory-checks", anyOf: ["asset.inventory"] },
cutover: { key: "cutover", label: "切换控制台", icon: "swap_calls", href: "/system/cutover", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.cutoverConsole },
erpIntegrations: { key: "erpIntegrations", label: "ERP 集成", icon: "hub", href: "/system/integrations/erp", anyOf: ["integration.manage"] },
profile: { key: "profile", label: "个人设置", icon: "manage_accounts", href: "/settings/profile", anyOf: [] },
});
const DEFAULT_MODULE_VISIBILITY = Object.freeze({
portal: "main",
team: "main",
leaveRequests: "main",
reimbursements: "main",
assets: "main",
weeklyReports: "main",
todos: "main",
announcements: "main",
workspace: "admin",
people: "admin",
workflowDefinitions: "admin",
systemSettings: "admin",
featureFlags: "admin",
auditLogs: "admin",
approvalRules: "admin",
userManagement: "admin",
departments: "admin",
masterData: "admin",
notificationChannels: "admin",
workflowTasks: "admin",
dashboard: "hidden",
purchaseRequests: "admin",
travelRequests: "admin",
budgets: "admin",
paymentBatches: "admin",
apOperations: "admin",
invoicePool: "admin",
inventoryChecks: "admin",
cutover: "admin",
erpIntegrations: "admin",
profile: "hidden",
});
function normalizeModuleVisibilityJson(input) {
const result = { ...DEFAULT_MODULE_VISIBILITY };
if (!input || typeof input !== "object" || Array.isArray(input)) {
return result;
}
for (const [key, rawValue] of Object.entries(input)) {
if (!(key in DEFAULT_MODULE_VISIBILITY)) {
continue;
}
const value = String(rawValue ?? "").trim();
if (MODULE_VISIBILITY_MODES.includes(value)) {
result[key] = value;
}
}
return result;
}
function buildDefaultWorkspaceSetting() {
return {
id: null,
persisted: false,
profile: WORKSPACE_PROFILES.smallTeamInternal,
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "desktop",
moduleVisibility: { ...DEFAULT_MODULE_VISIBILITY },
createdAt: null,
updatedAt: null,
updatedBy: null,
};
}
function mapWorkspaceSetting(record) {
if (!record) {
return buildDefaultWorkspaceSetting();
}
return {
id: record.id,
persisted: true,
profile: record.profile,
orgMode: record.org_mode,
primaryEntryMode: record.primary_entry_mode,
notificationStrategy: record.notification_strategy,
devicePriority: record.device_priority,
moduleVisibility: normalizeModuleVisibilityJson(record.module_visibility_json),
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
updatedBy: record.updated_by ?? null,
};
}
async function getWorkspaceSettingRecord(prisma) {
return prisma.workspaceSetting.findFirst({ orderBy: { created_at: "asc" } });
}
async function getWorkspaceSetting(prisma) {
const record = await getWorkspaceSettingRecord(prisma);
return mapWorkspaceSetting(record);
}
async function upsertWorkspaceSetting(prisma, actorId, input = {}) {
const current = await getWorkspaceSettingRecord(prisma);
const data = {
profile: WORKSPACE_PROFILES.smallTeamInternal,
org_mode: "single_company",
primary_entry_mode: "password",
notification_strategy: "in_app",
device_priority: "desktop",
module_visibility_json: normalizeModuleVisibilityJson(input.moduleVisibility ?? input.module_visibility_json ?? current?.module_visibility_json ?? null),
updated_by: actorId ?? null,
};
const record = current
? await prisma.workspaceSetting.update({ where: { id: current.id }, data })
: await prisma.workspaceSetting.create({ data });
return mapWorkspaceSetting(record);
}
function hasAnyPermission(hasPermission, employee, anyOf = []) {
if (!anyOf.length) {
return true;
}
return anyOf.some((permissionCode) => hasPermission(employee, permissionCode));
}
async function resolveModuleGate({ prisma, config, employee, moduleKey, registryItem }) {
if (!registryItem?.featureFlagKey) {
return { enabled: true, reason: "no_feature_gate" };
}
return evaluateFeatureFlag({
prisma,
config,
flagKey: registryItem.featureFlagKey,
departmentId: employee?.department_id ?? null,
strictDepartment: false,
});
}
async function getSystemNavigation({ prisma, config, employee, hasPermission }) {
const workspace = await getWorkspaceSetting(prisma);
const sections = { main: [], admin: [], backup: [] };
for (const [moduleKey, registryItem] of Object.entries(NAVIGATION_REGISTRY)) {
const placement = workspace.moduleVisibility[moduleKey] ?? DEFAULT_MODULE_VISIBILITY[moduleKey] ?? "hidden";
if (placement === "hidden") {
continue;
}
if (!hasAnyPermission(hasPermission, employee, registryItem.anyOf)) {
continue;
}
const gate = await resolveModuleGate({ prisma, config, employee, moduleKey, registryItem });
if (!gate.enabled) {
continue;
}
const entry = {
key: registryItem.key,
label: registryItem.label,
icon: registryItem.icon,
href: registryItem.href,
placement,
permissionCodes: registryItem.anyOf,
featureFlagKey: registryItem.featureFlagKey ?? null,
};
if (["purchaseRequests", "travelRequests", "budgets", "paymentBatches", "apOperations", "invoicePool", "inventoryChecks", "cutover", "erpIntegrations", "approvalRules", "userManagement", "departments", "masterData", "notificationChannels", "workflowTasks", "dashboard"].includes(moduleKey)) {
sections.backup.push(entry);
continue;
}
if (placement === "admin") {
sections.admin.push(entry);
} else {
sections.main.push(entry);
}
}
return {
workspace,
sections,
recommendedRolePresets: [
{ key: "owner", label: "老板", roleCodes: ["ceo"], description: "查看团队状态、审批汇总与关键管理入口。" },
{ key: "admin", label: "管理员", roleCodes: ["admin"], description: "管理工作区、人员、流程、开关与审计。" },
{ key: "employee", label: "员工", roleCodes: ["employee"], description: "日常使用 Portal、请假、报销、周报、待办。" },
{ key: "manager", label: "主管", roleCodes: ["manager"], description: "处理审批、查看团队周报与在岗状态。" },
],
};
}
module.exports = {
WORKSPACE_PROFILES,
NAVIGATION_REGISTRY,
DEFAULT_MODULE_VISIBILITY,
normalizeModuleVisibilityJson,
buildDefaultWorkspaceSetting,
mapWorkspaceSetting,
getWorkspaceSetting,
upsertWorkspaceSetting,
getSystemNavigation,
};