diff --git a/deploy/trialrun/observations/README.md b/deploy/trialrun/observations/README.md new file mode 100644 index 00000000..2004b51f --- /dev/null +++ b/deploy/trialrun/observations/README.md @@ -0,0 +1,5 @@ +# 试点观测输出目录 + +- 建议每周导出一次:`cd server && npm run report:small-team-observation -- --out ../deploy/trialrun/observations/week-01.md` +- 导出后,再按 `deploy/trialrun/observation-template.md` 补充人工观察、阻塞与结论。 +- 本目录建议按周保存:`week-01.md`、`week-02.md`、`week-03.md`、`week-04.md`。 diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index baae9711..1d348309 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -225,4 +225,5 @@ docker logs -f hyb-oa-trialrun-server - 公告阅读反馈 - 入职/离职/资产交接演练情况 - 建议统一使用 `deploy/trialrun/observation-template.md` 填写每周结论与阻塞。 +- 可执行 `cd server && npm run report:small-team-observation -- --out ../deploy/trialrun/observations/week-01.md` 导出当周快照,再补充人工结论。 - 若连续两周无人使用某入口,考虑继续降级模块可见性。 diff --git a/issues/2026-03-21_12-40-00-r17-small-team-go-live.csv b/issues/2026-03-21_12-40-00-r17-small-team-go-live.csv index d04769a8..e4ea4154 100644 --- a/issues/2026-03-21_12-40-00-r17-small-team-go-live.csv +++ b/issues/2026-03-21_12-40-00-r17-small-team-go-live.csv @@ -3,6 +3,6 @@ R17-0,R17执行合同建档与上线边界冻结,创建并校验小团队上线 R17-1,默认配置与功能开关收口,为 trialrun 和 seed 打开 small_team_internal profile 与核心小团队功能开关 并默认降级重模块,trialrun 默认开启 Portal 公告 周报 待办 团队状态 人员生命周期 并默认关闭 payment batch cutover 与重模块主导航,cd server && npm test && git diff --check,manual,DONE,DONE,DONE,server/src/scripts/seed-config.js|server/.env.example|deploy/trialrun/.env.example|server/src/services/featureFlags/index.js,"[""R17-0""]",none R17-2,存量员工与资产数据整理支撑,提供小团队上线所需的人员档案 资产归属和部门关系整理支撑接口或导入校验,管理员可在上线前核对员工档案 部门关系和资产归属缺口,cd server && npm test && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,server/src/routes/employees.js|server/src/routes/assets.js|client/src/pages/PeoplePage.tsx|client/src/pages/AssetRegistryPage.tsx,"[""R17-1""]",none R17-3,管理员手册与员工使用手册,新增 admin-handbook 和 employee-quickstart 并补齐试运行 runbook/checklist 的小团队模式步骤,管理员和员工均有可执行手册 支撑首次配置 入职 离职 周报与团队状态的真实演练,git diff --check,manual,DONE,DONE,DONE,docs/admin-handbook.md|docs/employee-quickstart.md|deploy/trialrun/runbook.md|deploy/trialrun/checklist.md,"[""R17-1""]",none -R17-4,全员试点与观测留痕,补齐试点观测字段 页面或文档留痕 支撑 4 周使用观测和真实验收记录,系统可记录或沉淀全员使用情况 周报提交率 关键阻塞和试点观察结论,cd server && npm test && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,server/src/routes/portal.js|client/src/pages/PortalHomePage.tsx|docs/admin-handbook.md|docs/feature-matrix.md|deploy/trialrun/observation-template.md,"[""R17-2"",""R17-3""]",Portal 已提供试点观测摘要并补齐 4 周观测模板 +R17-4,全员试点与观测留痕,补齐试点观测字段 页面或文档留痕 支撑 4 周使用观测和真实验收记录,系统可记录或沉淀全员使用情况 周报提交率 关键阻塞和试点观察结论,cd server && npm test && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,server/src/routes/portal.js|client/src/pages/PortalHomePage.tsx|docs/admin-handbook.md|docs/feature-matrix.md|deploy/trialrun/observation-template.md|server/src/scripts/export-small-team-observation.js|deploy/trialrun/observations/README.md,"[""R17-2"",""R17-3""]",Portal 已提供试点观测摘要,补齐 4 周观测模板,并支持周度观测快照导出 R17-5,默认收口无用主导航模块,按小团队产品模式彻底收口全员默认导航 仅为管理员保留备用企业模块入口,全员默认主导航仅剩 Portal 团队 请假 报销 资产 周报 待办 公告 管理员附加入口符合约定,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/components/navigation/NavLinks.tsx|client/src/router/index.tsx|client/src/pages/PortalHomePage.tsx,"[""R17-1""]",none R17-6,R17最终验收,完成默认配置 文档手册 试点观测和主导航收口后的最终验收,全员账户 档案 部门关系已补齐 入职离职资产交接至少各跑通一次 全员连续使用周报与团队状态且主回归 E2E 全绿,cd server && npm test && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build && npm run test:e2e,manual,DOING,TODO,TODO,issues/2026-03-21_12-40-00-r17-small-team-go-live.csv|server/scripts/regression-all.sh|client/tests/e2e|docs/admin-handbook.md|docs/employee-quickstart.md,"[""R17-4"",""R17-5""]",代码/文档/回归基线已完成;仍待真实 4 周试点观测与全员连续使用证据后再关单 diff --git a/server/package.json b/server/package.json index 9abfd024..15cbf94d 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "seed:dev-users": "node src/scripts/seed-dev.js", "seed:dev": "npm run seed:config && npm run seed:dev-users", "seed:trialrun": "node src/scripts/seed-trialrun.js", + "report:small-team-observation": "node src/scripts/export-small-team-observation.js", "job:revoke-expired-delegations": "node src/scripts/revoke-expired-delegations.js", "job:ocr-once": "node src/scripts/run-ocr-once.js", "job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js", diff --git a/server/src/scripts/export-small-team-observation.js b/server/src/scripts/export-small-team-observation.js new file mode 100644 index 00000000..73f90963 --- /dev/null +++ b/server/src/scripts/export-small-team-observation.js @@ -0,0 +1,237 @@ +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.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、公告与站内通知补提醒,不新增外部表格。"); + 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, + ] = 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" } } } }), + ]); + + let presenceUpdatedCount = 0; + for (const employee of activeEmployees) { + const status = await resolvePresenceForEmployee(prisma, employee.id, dateString); + if (status) { + presenceUpdatedCount += 1; + } + } + + 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, + 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); +});