require("dotenv/config"); const fs = require("node:fs"); const path = require("node:path"); const { createPrismaClient } = require("../prismaClient"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); 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 parseArgs(argv) { const result = { format: "markdown", date: null, out: null, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--json") { result.format = "json"; continue; } if (arg === "--markdown") { result.format = "markdown"; continue; } if (arg === "--date") { result.date = argv[index + 1] ?? null; index += 1; continue; } if (arg === "--out") { result.out = argv[index + 1] ?? null; index += 1; continue; } } return result; } 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 }, }); if (leave) { return { status: "leave" }; } const travel = await prisma.travelRequest.findFirst({ where: { applicant_employee_id: employeeId, status: "approved", start_date: { lte: date }, end_date: { gte: date }, }, select: { id: true }, }); if (travel) { return { status: "travel" }; } const manual = await prisma.employeePresence.findUnique({ where: { employee_id_date: { employee_id: employeeId, date, }, }, select: { status: true }, }); return manual ? { status: manual.status } : null; } function buildHints(metrics) { const hints = []; if (metrics.login7dCount < metrics.activeEmployeeCount) { hints.push(`近 7 天仍有 ${Math.max(0, metrics.activeEmployeeCount - metrics.login7dCount)} 人未登录系统`); } if (metrics.weeklyMissingCount > 0) { hints.push(`本周仍有 ${metrics.weeklyMissingCount} 人未提交周报`); } if (metrics.presenceMissingCount > 0) { hints.push(`今日仍有 ${metrics.presenceMissingCount} 人未更新团队状态`); } if (metrics.offboardingCount > 0 || metrics.assetReturnPendingCount > 0) { hints.push(`仍有 ${metrics.offboardingCount} 条离职流程和 ${metrics.assetReturnPendingCount} 项资产交接待收口`); } if (!hints.length) { hints.push("近 7 天登录、周报、团队状态与离职交接均满足试点观测口径"); } return hints; } function toPercent(numerator, denominator) { if (!denominator) return 0; return Number(((numerator / denominator) * 100).toFixed(1)); } function renderMarkdown(report) { const lines = []; lines.push("# 小团队试点观测快照"); lines.push(""); lines.push(`- 生成时间:${report.generatedAt}`); lines.push(`- 观测日期:${report.date}`); lines.push(`- 周起始日:${report.weekStart}`); lines.push(`- 在职人数:${report.activeEmployeeCount}`); lines.push(""); lines.push("## 指标摘要"); lines.push(`- 近 7 天登录:${report.login7dCount}/${report.activeEmployeeCount}(${report.login7dRate}%)`); lines.push(`- 本周周报提交:${report.weeklySubmittedCount}/${report.activeEmployeeCount}(${report.weeklySubmissionRate}%)`); lines.push(`- 今日状态更新:${report.presenceUpdatedCount}/${report.activeEmployeeCount}(${report.presenceCoverageRate}%)`); lines.push(`- 当前公告数:${report.announcementCount}`); lines.push(`- 活跃项目数:${report.activeProjectCount}`); lines.push(`- 近 7 天有任务更新的项目:${report.projectTaskUpdated7dCount}/${report.activeProjectCount}(${report.projectTaskUpdateRate}%)`); lines.push(`- 里程碑按期完成:${report.milestoneOnTimeCount}/${report.milestoneDueCount}(${report.milestoneOnTimeRate}%)`); lines.push(`- 入职中:${report.onboardingCount}`); lines.push(`- 离职中:${report.offboardingCount}`); lines.push(`- 资产待归还:${report.assetReturnPendingCount}`); lines.push(""); lines.push("## 观测提示"); report.hints.forEach((hint) => lines.push(`- ${hint}`)); lines.push(""); lines.push("## 建议动作"); lines.push("- 将本快照内容填入 `deploy/trialrun/observation-template.md` 对应周次。"); lines.push("- 若指标未达标,优先通过 Portal、公告与站内通知补提醒,不新增外部表格。"); lines.push("- 多平台重复录入次数和表格替代率仍建议在 observation template 中人工补记。"); lines.push(""); lines.push("## 人工补录项"); lines.push("- 多平台重复录入次数:__"); lines.push("- 表格替代率:__%"); lines.push("- 在岗表是否已停止外部维护:是 / 否"); lines.push("- 离职交接表是否已停止外部维护:是 / 否"); lines.push("- 本周仍靠聊天或表格补流程的案例:__"); lines.push(""); lines.push("## 本周关键动作"); lines.push("- Portal / 公告 / 团队状态 / 周报 / 待办使用情况:"); lines.push("- 入职 / 离职 / 资产交接是否发生:"); lines.push("- 项目更新、风险或客户沟通回填情况:"); lines.push(""); lines.push("## 本周阻塞"); lines.push("- 阻塞 1:"); lines.push("- 阻塞 2:"); lines.push("- 阻塞 3:"); lines.push(""); lines.push("## 下周调整"); lines.push("- 是否继续隐藏考勤、采购、差旅、预算、AP、ERP、AI 等备用模块:"); lines.push("- 是否需要补充培训、公告提醒或管理员代配置:"); lines.push("- 是否达到试点预期:是 / 否"); return `${lines.join("\n")}\n`; } async function main() { const args = parseArgs(process.argv.slice(2)); const timeZone = process.env.APP_TIMEZONE ?? "Asia/Shanghai"; const dateString = args.date ?? currentDateString(timeZone); const weekStart = resolveWeekStart(dateString); const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const { prisma, pool } = createPrismaClient(); try { const activeEmployees = await prisma.employee.findMany({ where: { status: "active" }, select: { id: true }, orderBy: { employee_no: "asc" }, }); const activeEmployeeCount = activeEmployees.length; const [ loginRows, weeklySubmittedCount, weeklyNeedsHelpCount, announcementCount, onboardingCount, offboardingCount, assetReturnPendingCount, activeProjects, updatedProjectTasks, dueMilestones, ] = await Promise.all([ prisma.authSession.findMany({ where: { revoked_at: null, expires_at: { gt: new Date() }, last_seen_at: { gte: since }, employee: { status: "active" }, }, select: { employee_id: true }, distinct: ["employee_id"], }), prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(weekStart), status: "submitted" } }), prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(weekStart), needs_help: true } }), prisma.portalAnnouncement.count({ where: { enabled: true, OR: [{ starts_at: null }, { starts_at: { lte: new Date() } }], AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: new Date() } }] }], }, }), prisma.employee.count({ where: { employment_status: "onboarding", status: "active" } }), prisma.employee.count({ where: { employment_status: "offboarding" } }), prisma.assetCard.count({ where: { assignee: { is: { employment_status: "offboarding" } } } }), prisma.project.findMany({ where: { status: { in: ["draft", "active", "paused"] } }, select: { id: true }, }), prisma.projectTask.findMany({ where: { updated_at: { gte: since }, project: { status: { in: ["draft", "active", "paused"] } }, }, distinct: ["project_id"], select: { project_id: true }, }), prisma.projectMilestone.findMany({ where: { due_date: { not: null }, project: { status: { in: ["draft", "active", "paused"] } }, }, select: { due_date: true, completed_at: true, status: true }, }), ]); let presenceUpdatedCount = 0; for (const employee of activeEmployees) { const status = await resolvePresenceForEmployee(prisma, employee.id, dateString); if (status) { presenceUpdatedCount += 1; } } const milestoneOnTimeCount = dueMilestones.filter((item) => { if (item.status !== "done" || !item.completed_at || !item.due_date) { return false; } const dueEnd = new Date(item.due_date); dueEnd.setUTCHours(23, 59, 59, 999); return item.completed_at.getTime() <= dueEnd.getTime(); }).length; const report = { generatedAt: new Date().toISOString(), date: dateString, weekStart, activeEmployeeCount, login7dCount: loginRows.length, login7dRate: toPercent(loginRows.length, activeEmployeeCount), weeklySubmittedCount, weeklyMissingCount: Math.max(0, activeEmployeeCount - weeklySubmittedCount), weeklyNeedsHelpCount, weeklySubmissionRate: toPercent(weeklySubmittedCount, activeEmployeeCount), presenceUpdatedCount, presenceMissingCount: Math.max(0, activeEmployeeCount - presenceUpdatedCount), presenceCoverageRate: toPercent(presenceUpdatedCount, activeEmployeeCount), announcementCount, activeProjectCount: activeProjects.length, projectTaskUpdated7dCount: updatedProjectTasks.length, projectTaskUpdateRate: toPercent(updatedProjectTasks.length, activeProjects.length), milestoneDueCount: dueMilestones.length, milestoneOnTimeCount, milestoneOnTimeRate: toPercent(milestoneOnTimeCount, dueMilestones.length), duplicateEntryCount: null, spreadsheetReplacementRate: null, onboardingCount, offboardingCount, assetReturnPendingCount, }; report.hints = buildHints(report); const output = args.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderMarkdown(report); if (args.out) { const outputPath = path.resolve(process.cwd(), args.out); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, output, "utf8"); console.log(`Observation exported to ${outputPath}`); } else { process.stdout.write(output); } } finally { await prisma.$disconnect(); await pool.end(); } } main().catch((error) => { console.error(error); process.exit(1); });