R21/R22: 补齐公网安全文档 试点观测与最小导入脚本

这个提交包含在:
2026-03-22 08:34:26 +08:00
父节点 3f9738ac9c
当前提交 a878645007
共修改 9 个文件,包含 572 行新增和 0 行删除
@@ -125,6 +125,9 @@ function renderMarkdown(report) {
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}`);
@@ -135,6 +138,7 @@ function renderMarkdown(report) {
lines.push("## 建议动作");
lines.push("- 将本快照内容填入 `deploy/trialrun/observation-template.md` 对应周次。");
lines.push("- 若指标未达标,优先通过 Portal、公告与站内通知补提醒,不新增外部表格。");
lines.push("- 多平台重复录入次数和表格替代率仍建议在 observation template 中人工补记。");
return `${lines.join("\n")}\n`;
}
@@ -162,6 +166,9 @@ async function main() {
onboardingCount,
offboardingCount,
assetReturnPendingCount,
activeProjects,
updatedProjectTasks,
dueMilestones,
] = await Promise.all([
prisma.authSession.findMany({
where: {
@@ -185,6 +192,25 @@ async function main() {
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;
@@ -195,6 +221,15 @@ async function main() {
}
}
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,
@@ -210,6 +245,14 @@ async function main() {
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,
@@ -0,0 +1,406 @@
require('dotenv/config');
const fs = require('node:fs');
const path = require('node:path');
const ExcelJS = require('exceljs');
const { createPrismaClient } = require('../prismaClient');
const SUPPORTED_TYPES = ['projects', 'members', 'milestones', 'risks', 'contracts'];
function printHelp() {
console.log(`用法:
node src/scripts/import-project-ledgers.js --type <${SUPPORTED_TYPES.join('|')}> --file <csv路径> [--apply] [--json]
说明:
- 默认执行 dry-run,只做校验与差异摘要,不写数据库
- 传入 --apply 后才会写库
- CSV 第一行为表头,建议使用 docs/migration-mapping.md 中的最小列
示例:
node src/scripts/import-project-ledgers.js --type projects --file ./tmp/projects.csv
node src/scripts/import-project-ledgers.js --type contracts --file ./tmp/contracts.csv --apply
`);
}
function parseArgs(argv) {
const result = {
type: null,
file: null,
apply: false,
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--help' || arg === '-h') {
result.help = true;
continue;
}
if (arg === '--apply') {
result.apply = true;
continue;
}
if (arg === '--json') {
result.json = true;
continue;
}
if (arg === '--type') {
result.type = argv[index + 1] ?? null;
index += 1;
continue;
}
if (arg === '--file') {
result.file = argv[index + 1] ?? null;
index += 1;
continue;
}
}
return result;
}
function normalizeHeader(value) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
function normalizeCell(value) {
if (value == null) return '';
return String(value).trim();
}
function parseDateOnly(value) {
const raw = normalizeCell(value);
if (!raw) return null;
const date = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) {
throw new Error(`invalid_date:${raw}`);
}
return date;
}
function parseBoolean(value) {
const raw = normalizeCell(value).toLowerCase();
return ['1', 'true', 'yes', 'y', '是'].includes(raw);
}
async function readCsvRows(filePath) {
const workbook = new ExcelJS.Workbook();
const worksheet = await workbook.csv.readFile(filePath);
const headerRow = worksheet.getRow(1);
const headers = headerRow.values
.slice(1)
.map((value) => normalizeHeader(value));
const rows = [];
worksheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const item = {};
headers.forEach((header, index) => {
item[header] = normalizeCell(row.getCell(index + 1).value);
});
if (Object.values(item).some((value) => value !== '')) {
rows.push({ rowNumber, data: item });
}
});
return rows;
}
function assertRequiredHeaders(rows, required) {
const first = rows[0]?.data ?? {};
const missing = required.filter((key) => !(key in first));
if (missing.length) {
throw new Error(`missing_headers:${missing.join(',')}`);
}
}
async function buildReferenceMaps(prisma) {
const [employees, projects] = await Promise.all([
prisma.employee.findMany({ select: { id: true, employee_no: true, name: true, status: true } }),
prisma.project.findMany({ select: { id: true, project_no: true, name: true } }),
]);
return {
employeesByNo: new Map(employees.map((item) => [item.employee_no, item])),
projectsByNo: new Map(projects.map((item) => [item.project_no, item])),
};
}
function makeSummary(type, filePath, apply, totalRows) {
return {
type,
file: path.resolve(filePath),
mode: apply ? 'apply' : 'dry-run',
totalRows,
createCount: 0,
updateCount: 0,
skipCount: 0,
errorCount: 0,
errors: [],
};
}
function pushError(summary, rowNumber, message) {
summary.errorCount += 1;
summary.errors.push({ rowNumber, message });
}
async function handleProjects({ prisma, rows, apply, summary }) {
assertRequiredHeaders(rows, ['project_no', 'name', 'client_name', 'owner_employee_no']);
const refs = await buildReferenceMaps(prisma);
const existing = new Map((await prisma.project.findMany({ select: { id: true, project_no: true } })).map((item) => [item.project_no, item]));
for (const row of rows) {
const data = row.data;
const owner = refs.employeesByNo.get(data.owner_employee_no);
const manager = data.manager_employee_no ? refs.employeesByNo.get(data.manager_employee_no) : null;
if (!owner) {
pushError(summary, row.rowNumber, `owner_employee_no_not_found:${data.owner_employee_no}`);
continue;
}
if (data.manager_employee_no && !manager) {
pushError(summary, row.rowNumber, `manager_employee_no_not_found:${data.manager_employee_no}`);
continue;
}
const payload = {
project_no: data.project_no,
name: data.name,
client_name: data.client_name,
client_alias: data.client_alias || null,
owner_employee_id: owner.id,
manager_employee_id: manager?.id ?? null,
status: data.status || 'draft',
starts_at: data.starts_at ? parseDateOnly(data.starts_at) : null,
target_end_at: data.target_end_at ? parseDateOnly(data.target_end_at) : null,
actual_end_at: data.actual_end_at ? parseDateOnly(data.actual_end_at) : null,
summary: data.summary || null,
is_confidential: parseBoolean(data.is_confidential),
};
const current = existing.get(data.project_no);
if (!current) {
summary.createCount += 1;
if (apply) {
await prisma.project.create({
data: {
...payload,
members: { create: { employee_id: owner.id, role: 'owner' } },
},
});
}
continue;
}
summary.updateCount += 1;
if (apply) {
await prisma.project.update({ where: { id: current.id }, data: payload });
await prisma.projectMember.upsert({
where: { project_id_employee_id: { project_id: current.id, employee_id: owner.id } },
create: { project_id: current.id, employee_id: owner.id, role: 'owner' },
update: { role: 'owner', left_at: null },
});
}
}
}
async function handleMembers({ prisma, rows, apply, summary }) {
assertRequiredHeaders(rows, ['project_no', 'employee_no', 'role']);
const refs = await buildReferenceMaps(prisma);
for (const row of rows) {
const data = row.data;
const project = refs.projectsByNo.get(data.project_no);
const employee = refs.employeesByNo.get(data.employee_no);
if (!project) {
pushError(summary, row.rowNumber, `project_no_not_found:${data.project_no}`);
continue;
}
if (!employee) {
pushError(summary, row.rowNumber, `employee_no_not_found:${data.employee_no}`);
continue;
}
const current = await prisma.projectMember.findUnique({
where: { project_id_employee_id: { project_id: project.id, employee_id: employee.id } },
select: { id: true },
});
if (current) summary.updateCount += 1;
else summary.createCount += 1;
if (apply) {
await prisma.projectMember.upsert({
where: { project_id_employee_id: { project_id: project.id, employee_id: employee.id } },
create: { project_id: project.id, employee_id: employee.id, role: data.role || 'member' },
update: { role: data.role || 'member', left_at: null },
});
}
}
}
async function handleMilestones({ prisma, rows, apply, summary }) {
assertRequiredHeaders(rows, ['project_no', 'title']);
const refs = await buildReferenceMaps(prisma);
for (const row of rows) {
const data = row.data;
const project = refs.projectsByNo.get(data.project_no);
const owner = data.owner_employee_no ? refs.employeesByNo.get(data.owner_employee_no) : null;
if (!project) {
pushError(summary, row.rowNumber, `project_no_not_found:${data.project_no}`);
continue;
}
if (data.owner_employee_no && !owner) {
pushError(summary, row.rowNumber, `owner_employee_no_not_found:${data.owner_employee_no}`);
continue;
}
summary.createCount += 1;
if (apply) {
await prisma.projectMilestone.create({
data: {
project_id: project.id,
title: data.title,
description: data.description || null,
status: data.status || 'pending',
due_date: data.due_date ? parseDateOnly(data.due_date) : null,
owner_employee_id: owner?.id ?? null,
sort_order: Number(data.sort_order || 100),
},
});
}
}
}
async function handleRisks({ prisma, rows, apply, summary }) {
assertRequiredHeaders(rows, ['project_no', 'title', 'detail']);
const refs = await buildReferenceMaps(prisma);
for (const row of rows) {
const data = row.data;
const project = refs.projectsByNo.get(data.project_no);
const owner = data.owner_employee_no ? refs.employeesByNo.get(data.owner_employee_no) : null;
if (!project) {
pushError(summary, row.rowNumber, `project_no_not_found:${data.project_no}`);
continue;
}
if (data.owner_employee_no && !owner) {
pushError(summary, row.rowNumber, `owner_employee_no_not_found:${data.owner_employee_no}`);
continue;
}
summary.createCount += 1;
if (apply) {
await prisma.projectRisk.create({
data: {
project_id: project.id,
title: data.title,
detail: data.detail,
level: data.level || 'medium',
status: data.status || 'open',
owner_employee_id: owner?.id ?? null,
},
});
}
}
}
async function handleContracts({ prisma, rows, apply, summary }) {
assertRequiredHeaders(rows, ['contract_no', 'counterparty_name', 'summary', 'archive_location', 'owner_employee_no']);
const refs = await buildReferenceMaps(prisma);
const existing = new Map((await prisma.contractRegistry.findMany({ select: { id: true, contract_no: true } })).map((item) => [item.contract_no, item]));
for (const row of rows) {
const data = row.data;
const project = data.project_no ? refs.projectsByNo.get(data.project_no) : null;
const owner = refs.employeesByNo.get(data.owner_employee_no);
if (data.project_no && !project) {
pushError(summary, row.rowNumber, `project_no_not_found:${data.project_no}`);
continue;
}
if (!owner) {
pushError(summary, row.rowNumber, `owner_employee_no_not_found:${data.owner_employee_no}`);
continue;
}
const payload = {
contract_no: data.contract_no,
project_id: project?.id ?? null,
counterparty_name: data.counterparty_name,
summary: data.summary,
confidentiality_level: data.confidentiality_level || 'internal',
archive_location: data.archive_location,
offline_archive_no: data.offline_archive_no || null,
owner_employee_id: owner.id,
status: data.status || 'active',
};
const current = existing.get(data.contract_no);
if (!current) summary.createCount += 1;
else summary.updateCount += 1;
if (apply) {
await prisma.contractRegistry.upsert({
where: { contract_no: data.contract_no },
create: payload,
update: payload,
});
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.type || !args.file) {
printHelp();
process.exit(args.help ? 0 : 1);
}
if (!SUPPORTED_TYPES.includes(args.type)) {
throw new Error(`unsupported_type:${args.type}`);
}
if (!fs.existsSync(args.file)) {
throw new Error(`file_not_found:${args.file}`);
}
const rows = await readCsvRows(args.file);
const summary = makeSummary(args.type, args.file, args.apply, rows.length);
const { prisma, pool } = createPrismaClient();
try {
const handlers = {
projects: handleProjects,
members: handleMembers,
milestones: handleMilestones,
risks: handleRisks,
contracts: handleContracts,
};
await handlers[args.type]({ prisma, rows, apply: args.apply, summary });
const output = args.json ? JSON.stringify(summary, null, 2) : [
`type=${summary.type}`,
`mode=${summary.mode}`,
`file=${summary.file}`,
`totalRows=${summary.totalRows}`,
`createCount=${summary.createCount}`,
`updateCount=${summary.updateCount}`,
`skipCount=${summary.skipCount}`,
`errorCount=${summary.errorCount}`,
...(summary.errors.length ? ['errors=', ...summary.errors.map((item) => ` row ${item.rowNumber}: ${item.message}`)] : []),
].join('\n');
process.stdout.write(`${output}\n`);
if (summary.errorCount > 0) {
process.exitCode = 2;
}
} finally {
await prisma.$disconnect();
await pool.end();
}
}
main().catch((error) => {
console.error(error?.message ?? error);
process.exit(1);
});
+4
查看文件
@@ -15,9 +15,11 @@ const NAVIGATION_REGISTRY = Object.freeze({
weeklyReports: { key: "weeklyReports", label: "周报", icon: "article", href: "/weekly-reports", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.weeklyReport },
todos: { key: "todos", label: "待办", icon: "check_circle", href: "/todos", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.todoBoard },
announcements: { key: "announcements", label: "公告", icon: "campaign", href: "/announcements", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.announcements },
projects: { key: "projects", label: "项目", icon: "workspaces", href: "/projects", anyOf: ["project.view_own", "project.view_all", "project.manage"], featureFlagKey: FEATURE_FLAG_KEYS.projectDomain },
workspace: { key: "workspace", label: "工作区设置", icon: "tune", href: "/system/workspace", anyOf: ["system.settings"] },
people: { key: "people", label: "人员管理", icon: "group", href: "/people", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
contractsRegistry: { key: "contractsRegistry", label: "合同台账", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
systemSettings: { key: "systemSettings", label: "系统设置", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
featureFlags: { key: "featureFlags", label: "功能开关", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
@@ -51,9 +53,11 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
weeklyReports: "main",
todos: "main",
announcements: "main",
projects: "main",
workspace: "admin",
people: "admin",
contractsRegistry: "admin",
workflowDefinitions: "admin",
systemSettings: "admin",
featureFlags: "admin",