From a878645007959644a3e6dd0fc00f92678c40c200 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 22 Mar 2026 08:34:26 +0800 Subject: [PATCH] =?UTF-8?q?R21/R22:=20=E8=A1=A5=E9=BD=90=E5=85=AC=E7=BD=91?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=96=87=E6=A1=A3=20=E8=AF=95=E7=82=B9?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=E4=B8=8E=E6=9C=80=E5=B0=8F=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/trialrun/checklist.md | 12 + deploy/trialrun/observation-template.md | 4 + deploy/trialrun/runbook.md | 42 ++ docs/admin-handbook.md | 32 ++ docs/employee-quickstart.md | 15 + docs/migration-mapping.md | 14 + .../scripts/export-small-team-observation.js | 43 ++ server/src/scripts/import-project-ledgers.js | 406 ++++++++++++++++++ server/src/services/workspaceSettings.js | 4 + 9 files changed, 572 insertions(+) create mode 100644 server/src/scripts/import-project-ledgers.js diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index a37afe16..67419444 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -66,3 +66,15 @@ - [ ] 至少 1 次入职/离职/资产交接已演练 - [ ] 全员已知晓每周周报和今日状态使用要求 - [ ] 已建立连续 4 周观测记录方式(建议直接使用 `deploy/trialrun/observation-template.md`) + +## R18-R22 上线补充检查项 +- [ ] `APP_ORIGIN` 已切到公网 HTTPS 域名 +- [ ] `COOKIE_SECURE=true` +- [ ] `PUBLIC_SECURITY_HARDENING_ENABLED=true` +- [ ] `OPENAPI_DOCS_ENABLED=false` +- [ ] 企业微信应用已配置工作台入口和回调地址 +- [ ] 至少 1 个管理员已完成企微绑定 +- [ ] 项目模块、项目关联、合同台账已在试点环境可见 +- [ ] 涉密正文与附件未上传到 OA +- [ ] 企业微信提醒仅包含脱敏标题 / 编号 / deeplink +- [ ] 员工可从企业微信进入 `Portal`,并继续完成周报、待办、项目更新 diff --git a/deploy/trialrun/observation-template.md b/deploy/trialrun/observation-template.md index bd3462f5..a95d8130 100644 --- a/deploy/trialrun/observation-template.md +++ b/deploy/trialrun/observation-template.md @@ -13,6 +13,10 @@ | 近 7 天登录人数 / 在职人数 | __ / __ | 全员每周至少登录 1 次 | __ | | 本周周报提交人数 / 在职人数 | __ / __ | 全员提交 | __ | | 今日状态更新人数 / 在职人数 | __ / __ | 全员工作日可见 | __ | +| 近 7 天有任务更新的项目数 / 活跃项目数 | __ / __ | 项目每周持续更新 | __ | +| 里程碑按期完成数 / 有截止里程碑数 | __ / __ | 核心项目可追踪 | __ | +| 多平台重复录入次数 | __ | 趋近 0 | __ | +| 表格替代率 | __% | 持续上升 | __ | | 在岗表是否已停止外部维护 | 是 / 否 | 是 | __ | | 离职交接表是否已停止外部维护 | 是 / 否 | 是 | __ | diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 1d348309..c148c6b9 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -227,3 +227,45 @@ 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` 导出当周快照,再补充人工结论。 - 若连续两周无人使用某入口,考虑继续降级模块可见性。 + +## R18-R22 公网 + 企微 + 项目域上线补充 + +### A. 公网安全基线 +1. 为 OA 准备 HTTPS 域名并反向代理到 `server`。 +2. 在 `deploy/trialrun/.env` 中至少设置: + - `APP_ORIGIN=https://oa.example.com` + - `COOKIE_SECURE=true` + - `PUBLIC_SECURITY_HARDENING_ENABLED=true` + - `OPENAPI_DOCS_ENABLED=false` +3. 重新执行: +```bash +docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server +``` +4. 验证浏览器和企业微信 WebView 都能正常登录、刷新 token 与退出登录。 + +### B. 企业微信工作台入口 +1. 在企业微信自建应用中配置工作台地址,默认落到:`https://oa.example.com/portal`。 +2. 在 `deploy/trialrun/.env` 中补齐: + - `WECOM_ENABLED=true` + - `WECOM_ENTRY_ENABLED=true` + - `WECOM_CORP_ID` + - `WECOM_AGENT_ID` + - `WECOM_AGENT_SECRET` + - `WECOM_OAUTH_ENABLED=true` + - `WECOM_REDIRECT_URI=https://oa.example.com/api/auth/wecom/callback` + - `WECOM_ALLOWED_RETURN_HOSTS=oa.example.com` +3. 管理员先在个人设置页完成企微绑定,再拉试点用户验证免登。 +4. 企微消息只用于提醒与打开 OA,不新增第二套录入页面。 + +### C. 项目与合同最小导入 +1. 先整理项目台账、项目成员、里程碑、风险、合同登记 CSV。 +2. 按 `docs/migration-mapping.md` 的最小列准备字段。 +3. 先在测试环境做 dry-run,再在试点环境 apply。 +4. 涉密正文、附件、报价成本继续保留在内网资料库或离线档案。 + +### D. 试点验收新增观察点 +- 企业微信进入 OA 的成功率 +- 项目任务与里程碑更新率 +- 报销 / 差旅 / 周报挂项目覆盖率 +- 是否还存在第二套项目台账或 Excel 主表 +- 企业微信通知是否发生敏感字段泄露 diff --git a/docs/admin-handbook.md b/docs/admin-handbook.md index 76e29a06..46481386 100644 --- a/docs/admin-handbook.md +++ b/docs/admin-handbook.md @@ -80,3 +80,35 @@ 2. 每周五在 `deploy/trialrun/observation-template.md` 填写本周结论、关键阻塞、是否需要调整提醒或导航。 3. 若某周有员工未登录、未提周报、未更新状态,优先通过公告或站内通知补提醒,不引入额外表格。 4. 连续 4 周都达到预期后,再将 `R17-6` 关单并形成最终验收结论。 + +## 12. 企业微信入口与登录策略 +- 建议把企业微信定位为 `入口 + 提醒 + 可选免登`,不要在企业微信里再维护第二套审批或项目录入路径。 +- 默认工作台入口落到 `/portal`,员工真正操作仍在 `hyb-oa` 内完成。 +- 企微免登建议先完成账号绑定,再逐步在试点部门开启;任何时候都保留账号密码回退。 +- 若 OA 部署到公网,生产建议同时满足:`HTTPS`、`COOKIE_SECURE=true`、登录失败锁定、限流、审计开启。 + +## 13. 轻项目管理使用建议 +- 项目主档只做轻量管理:成员、里程碑、任务、风险、交付记录、合同台账与资料引用。 +- 不要把 `hyb-oa` 推成完整项目组合平台;甘特图、资源排产、CRM 仍不在本主线。 +- 员工日常动作建议统一挂项目: + - 差旅申请挂项目 + - 报销单挂项目 + - 周报按项目拆分进展 + - 待办可直接挂项目 +- 管理层每周主要查看 `项目` 页中的逾期里程碑、高优任务、风险与费用/出差汇总,而不是再维护第二套项目台账。 + +## 14. 涉密边界 +- `hyb-oa` 只记录涉密合同或资料的元数据:编号、责任人、密级、归档位置、离线档案号。 +- 涉密正文、涉密附件、详细报价/成本/毛利、敏感客户资料不进入公网 OA。 +- 企业微信通知不得发送涉密正文,只保留最小必要信息和 deeplink。 +- 如项目被标记为涉密,管理员应要求正文继续保存在内网文件盘、NAS 或离线档案中。 + +## 15. 公网部署最低建议 +1. 为 OA 配置独立 HTTPS 域名,例如 `https://oa.example.com`。 +2. 在 `deploy/trialrun/.env` 中设置: + - `APP_ORIGIN=https://oa.example.com` + - `COOKIE_SECURE=true` + - `PUBLIC_SECURITY_HARDENING_ENABLED=true` +3. 若启用企业微信入口,补齐:`WECOM_*` 与 `WECOM_ALLOWED_RETURN_HOSTS`。 +4. 将 `OPENAPI_DOCS_ENABLED` 保持为 `false`,不要在公网生产暴露文档入口。 +5. 定期检查审计日志中的登录失败、Refresh 来源不匹配与涉密资料查看记录。 diff --git a/docs/employee-quickstart.md b/docs/employee-quickstart.md index 8cd97566..2f78928e 100644 --- a/docs/employee-quickstart.md +++ b/docs/employee-quickstart.md @@ -45,3 +45,18 @@ - 每周五提交周报 - 状态有变化时及时更新 `团队` - 有通知先看站内消息,IM 只是补充提醒 + +## 9. 从企业微信进入 +- 如果公司启用了企业微信工作台入口,优先从企业微信打开 OA。 +- 企业微信只是入口和提醒,真正填写和修改仍在 OA 页面内完成。 +- 如果企业微信免登失败,直接回到账号密码登录即可。 + +## 10. 项目怎么用 +- 如果你是项目成员,优先在 `项目` 页面查看自己的里程碑、任务、风险和交付记录。 +- 差旅、报销、周报、待办都尽量挂到项目,这样老板不用再到多个平台拼状态。 +- 周报里可以按项目分别写本周进展、风险和下一步。 + +## 11. 涉密内容注意事项 +- 不要把涉密合同正文、涉密附件、敏感报价和成本细节上传到 OA 或企业微信通知里。 +- 如果系统里只要求你填合同编号、归档位置、离线档案号,这是正常设计,不是少功能。 +- 涉密资料正文继续按公司要求放在内网文件盘、NAS 或离线档案中。 diff --git a/docs/migration-mapping.md b/docs/migration-mapping.md index 13f96c29..37831b9d 100644 --- a/docs/migration-mapping.md +++ b/docs/migration-mapping.md @@ -54,3 +54,17 @@ - 保存 dry-run 记录 ID、上传文件名、差异摘要。 - 保存 `CutoverScope` 启停时间、操作人和备注。 - 保存试点期首批新单编号、审批截图与付款批次号。 + +## 9. R18-R22 项目与合同最小导入映射 +| 现有表格 | hyb-oa 对象 | 建议最小列 | +| --- | --- | --- | +| 项目台账 | `Project` | `project_no,name,client_name,owner_employee_no,status,target_end_at,is_confidential` | +| 项目成员表 | `ProjectMember` | `project_no,employee_no,role` | +| 里程碑表 | `ProjectMilestone` | `project_no,title,status,due_date,sort_order` | +| 风险表 | `ProjectRisk` | `project_no,title,detail,level,status,owner_employee_no` | +| 合同登记表 | `ContractRegistry` | `contract_no,project_no,counterparty_name,summary,confidentiality_level,archive_location,offline_archive_no,status` | + +### 9.1 导入原则 +- 优先导入“仍在使用的活动项目”,不追求一次性搬空历史。 +- 项目资料正文不导入,`hyb-oa` 只保留资料目录、链接和归档位置。 +- 对于涉密合同,CSV 里也只放元数据,不放正文摘要和附件路径明细。 diff --git a/server/src/scripts/export-small-team-observation.js b/server/src/scripts/export-small-team-observation.js index 73f90963..bcba8939 100644 --- a/server/src/scripts/export-small-team-observation.js +++ b/server/src/scripts/export-small-team-observation.js @@ -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, diff --git a/server/src/scripts/import-project-ledgers.js b/server/src/scripts/import-project-ledgers.js new file mode 100644 index 00000000..e324bc7d --- /dev/null +++ b/server/src/scripts/import-project-ledgers.js @@ -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 [--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); +}); diff --git a/server/src/services/workspaceSettings.js b/server/src/services/workspaceSettings.js index 311a91f6..aa207860 100644 --- a/server/src/services/workspaceSettings.js +++ b/server/src/services/workspaceSettings.js @@ -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",