303 行
11 KiB
JavaScript
303 行
11 KiB
JavaScript
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);
|
||
});
|