R21/R22: 补齐公网安全文档 试点观测与最小导入脚本
这个提交包含在:
@@ -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`,并继续完成周报、待办、项目更新
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
| 近 7 天登录人数 / 在职人数 | __ / __ | 全员每周至少登录 1 次 | __ |
|
||||
| 本周周报提交人数 / 在职人数 | __ / __ | 全员提交 | __ |
|
||||
| 今日状态更新人数 / 在职人数 | __ / __ | 全员工作日可见 | __ |
|
||||
| 近 7 天有任务更新的项目数 / 活跃项目数 | __ / __ | 项目每周持续更新 | __ |
|
||||
| 里程碑按期完成数 / 有截止里程碑数 | __ / __ | 核心项目可追踪 | __ |
|
||||
| 多平台重复录入次数 | __ | 趋近 0 | __ |
|
||||
| 表格替代率 | __% | 持续上升 | __ |
|
||||
| 在岗表是否已停止外部维护 | 是 / 否 | 是 | __ |
|
||||
| 离职交接表是否已停止外部维护 | 是 / 否 | 是 | __ |
|
||||
|
||||
|
||||
@@ -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 主表
|
||||
- 企业微信通知是否发生敏感字段泄露
|
||||
|
||||
@@ -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 来源不匹配与涉密资料查看记录。
|
||||
|
||||
@@ -45,3 +45,18 @@
|
||||
- 每周五提交周报
|
||||
- 状态有变化时及时更新 `团队`
|
||||
- 有通知先看站内消息,IM 只是补充提醒
|
||||
|
||||
## 9. 从企业微信进入
|
||||
- 如果公司启用了企业微信工作台入口,优先从企业微信打开 OA。
|
||||
- 企业微信只是入口和提醒,真正填写和修改仍在 OA 页面内完成。
|
||||
- 如果企业微信免登失败,直接回到账号密码登录即可。
|
||||
|
||||
## 10. 项目怎么用
|
||||
- 如果你是项目成员,优先在 `项目` 页面查看自己的里程碑、任务、风险和交付记录。
|
||||
- 差旅、报销、周报、待办都尽量挂到项目,这样老板不用再到多个平台拼状态。
|
||||
- 周报里可以按项目分别写本周进展、风险和下一步。
|
||||
|
||||
## 11. 涉密内容注意事项
|
||||
- 不要把涉密合同正文、涉密附件、敏感报价和成本细节上传到 OA 或企业微信通知里。
|
||||
- 如果系统里只要求你填合同编号、归档位置、离线档案号,这是正常设计,不是少功能。
|
||||
- 涉密资料正文继续按公司要求放在内网文件盘、NAS 或离线档案中。
|
||||
|
||||
@@ -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 里也只放元数据,不放正文摘要和附件路径明细。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
在新工单中引用
屏蔽一个用户