feat: 完成 R13/R14/R15/R16 小团队管理核心能力
这个提交包含在:
+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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户