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

这个提交包含在:
2026-03-21 20:31:34 +08:00
父节点 0826693a8f
当前提交 1e52f1c225
共修改 47 个文件,包含 4298 行新增和 306 行删除
+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,
});
});
}