feat: 补充 R17-4 试点观测导出脚本
这个提交包含在:
@@ -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`。
|
||||
@@ -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` 导出当周快照,再补充人工结论。
|
||||
- 若连续两周无人使用某入口,考虑继续降级模块可见性。
|
||||
|
||||
@@ -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 周试点观测与全员连续使用证据后再关单
|
||||
|
||||
|
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户