diff --git a/client/src/features/navigation/schema.ts b/client/src/features/navigation/schema.ts index afc98b43..f7dc8527 100644 --- a/client/src/features/navigation/schema.ts +++ b/client/src/features/navigation/schema.ts @@ -1,12 +1,12 @@ -export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/attendance", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"]; +export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"]; export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"]; -export const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"]; +export const BACKUP_NAV_ORDER = ["/attendance", "/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"]; const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [ { path: "/portal", title: "Portal", group: "工作台" }, { path: "/announcements", title: "公告", group: "主导航" }, { path: "/team", title: "团队", group: "主导航" }, - { path: "/attendance", title: "考勤", group: "主导航" }, + { path: "/attendance", title: "考勤", group: "备用模块" }, { path: "/projects", title: "项目", group: "主导航" }, { path: "/contracts-registry", title: "合同台账", group: "备用模块" }, { path: "/weekly-reports", title: "周报", group: "主导航" }, diff --git a/client/src/pages/WorkspaceSetupPage.tsx b/client/src/pages/WorkspaceSetupPage.tsx index 682e8f27..b93318b1 100644 --- a/client/src/pages/WorkspaceSetupPage.tsx +++ b/client/src/pages/WorkspaceSetupPage.tsx @@ -12,24 +12,38 @@ const MODULE_LABELS: Record = { portal: "Portal", team: "团队", leaveRequests: "请假", + attendance: "考勤", reimbursements: "报销", assets: "资产", weeklyReports: "周报", todos: "待办", announcements: "公告", + projects: "项目", workspace: "工作区设置", people: "人员管理", + organization: "组织架构", + contractsRegistry: "合同台账", workflowDefinitions: "流程定义", systemSettings: "系统设置", featureFlags: "功能开关", auditLogs: "审计日志", + approvalRules: "审批规则", + userManagement: "用户管理", + departments: "部门管理", + masterData: "主数据", + notificationChannels: "通知渠道", + workflowTasks: "审批待办", + dashboard: "仪表盘", purchaseRequests: "采购申请", travelRequests: "差旅申请", budgets: "预算管理", paymentBatches: "支付批次", apOperations: "AP 运营", + invoicePool: "票据中心", + inventoryChecks: "资产盘点", cutover: "切换控制台", erpIntegrations: "ERP 集成", + profile: "个人设置", }; export function WorkspaceSetupPage() { diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index cd40f444..02474bb8 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -80,8 +80,8 @@ ## F. R17 小团队上线检查 - [ ] 工作区模式已确认为 `small_team_internal` -- [ ] 主导航已收敛为 `Portal / 团队 / 请假 / 报销 / 资产 / 周报 / 待办 / 公告` -- [ ] 采购 / 差旅 / 预算 / 支付批次 / AP 运营已降级为管理员备用入口 +- [ ] 主导航已收敛为 `Portal / 团队 / 请假 / 报销 / 资产 / 周报 / 待办 / 公告 / 项目` +- [ ] 考勤 / 采购 / 差旅 / 预算 / 支付批次 / AP 运营 / ERP / AI 已降级为隐藏或管理员备用入口 - [ ] 已打开 `portal_home_v1`、`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`todo_board_v1` - [ ] 员工账号、档案、部门、直属上级已补齐 - [ ] 资产台账与员工领用关系已核对 @@ -90,6 +90,18 @@ - [ ] 全员已知晓每周周报和今日状态使用要求 - [ ] 已建立连续 4 周观测记录方式(建议直接使用 `deploy/trialrun/observation-template.md`) +## G. R66 小团队试点收敛检查 +- [ ] 普通员工账号登录后,主导航只出现高频主路径,不出现考勤、采购、差旅、预算、AP、ERP、AI。 +- [ ] 经理或老板账号登录后,Portal 首屏优先看到审批、团队状态、周报缺口、需协助员工和项目风险。 +- [ ] 管理员账号仍可进入工作区设置、人员、组织、流程定义、系统设置、功能开关和审计日志。 +- [ ] 已确认不删除备用模块数据;恢复某模块只通过工作区设置或功能开关完成。 +- [ ] 已生成 `week-01.md` 到 `week-04.md` 的观测计划,至少完成第 1 周导出。 +- [ ] 已完成 DB + 文件存储备份,并记录输出文件名。 +- [ ] 已在同机或临时环境完成一次恢复演练,恢复后 `/ready` 通过。 +- [ ] 已核对管理员账号清单和 smoke 账号,自动化不使用真实业务账号。 +- [ ] 已执行 `cd server && npm audit --omit=dev --audit-level=high` 并完成高危/严重漏洞评审;若仍失败,不进入公网正式试点。 +- [ ] 已执行 `bash scripts/qa/run-release-gate.sh r66-trialrun`;若数据库或公网账号条件不足,已记录手工替代步骤。 + ## R18-R22 上线补充检查项 - [ ] `APP_ORIGIN` 已切到公网 HTTPS 域名 - [ ] `COOKIE_SECURE=true` diff --git a/deploy/trialrun/observation-template.md b/deploy/trialrun/observation-template.md index df297bc3..0e9d5b10 100644 --- a/deploy/trialrun/observation-template.md +++ b/deploy/trialrun/observation-template.md @@ -1,6 +1,6 @@ # 小团队试点 4 周观测模板 -> 建议由管理员或老板每周五填写一次,用于 `R17-6` 最终验收留痕。 +> 建议由管理员或老板每周五填写一次,用于 R66 小团队试点验收留痕。 ## 基本信息 - 工作区模式:`small_team_internal` @@ -33,6 +33,7 @@ ## 下周调整 - 是否需要调整提醒频率: - 是否需要继续隐藏或降级某模块: +- 是否需要恢复考勤、采购、差旅、预算、AP、ERP、AI 等备用模块: - 是否需要补充培训或说明: ## 结论 diff --git a/deploy/trialrun/observations/README.md b/deploy/trialrun/observations/README.md index 2004b51f..beb55c5b 100644 --- a/deploy/trialrun/observations/README.md +++ b/deploy/trialrun/observations/README.md @@ -1,5 +1,33 @@ # 试点观测输出目录 -- 建议每周导出一次:`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`。 +R66 试点按 4 周闭环执行,建议每周五由管理员或老板导出一次基础指标,再补充人工观察。 + +## 每周导出 + +```bash +cd server +npm run report:small-team-observation -- --out ../deploy/trialrun/observations/week-01.md +``` + +如需指定观测日期: + +```bash +cd server +npm run report:small-team-observation -- --date 2026-04-26 --out ../deploy/trialrun/observations/week-01.md +``` + +## 文件约定 + +- 第 1 周:`week-01.md` +- 第 2 周:`week-02.md` +- 第 3 周:`week-03.md` +- 第 4 周:`week-04.md` + +## 人工补录 + +导出后按 `deploy/trialrun/observation-template.md` 补充: + +- 多平台重复录入次数、表格替代率、外部在岗表和离职交接表是否停用。 +- 仍靠聊天或表格补流程的案例。 +- 本周阻塞、下周是否继续隐藏或恢复备用模块。 +- 是否达到试点预期,以及是否可进入上线验收。 diff --git a/deploy/trialrun/observations/week-00.md b/deploy/trialrun/observations/week-00.md new file mode 100644 index 00000000..feaff646 --- /dev/null +++ b/deploy/trialrun/observations/week-00.md @@ -0,0 +1,50 @@ +# 小团队试点观测快照 + +- 生成时间:2026-04-26T03:35:08.844Z +- 观测日期:2026-04-26 +- 周起始日:2026-04-20 +- 在职人数:1 + +## 指标摘要 +- 近 7 天登录:0/1(0%) +- 本周周报提交:0/1(0%) +- 今日状态更新:0/1(0%) +- 当前公告数:0 +- 活跃项目数:0 +- 近 7 天有任务更新的项目:0/0(0%) +- 里程碑按期完成:0/0(0%) +- 入职中:0 +- 离职中:0 +- 资产待归还:0 + +## 观测提示 +- 近 7 天仍有 1 人未登录系统 +- 本周仍有 1 人未提交周报 +- 今日仍有 1 人未更新团队状态 + +## 建议动作 +- 将本快照内容填入 `deploy/trialrun/observation-template.md` 对应周次。 +- 若指标未达标,优先通过 Portal、公告与站内通知补提醒,不新增外部表格。 +- 多平台重复录入次数和表格替代率仍建议在 observation template 中人工补记。 + +## 人工补录项 +- 多平台重复录入次数:__ +- 表格替代率:__% +- 在岗表是否已停止外部维护:是 / 否 +- 离职交接表是否已停止外部维护:是 / 否 +- 本周仍靠聊天或表格补流程的案例:__ + +## 本周关键动作 +- Portal / 公告 / 团队状态 / 周报 / 待办使用情况: +- 入职 / 离职 / 资产交接是否发生: +- 项目更新、风险或客户沟通回填情况: + +## 本周阻塞 +- 阻塞 1: +- 阻塞 2: +- 阻塞 3: + +## 下周调整 +- 是否继续隐藏考勤、采购、差旅、预算、AP、ERP、AI 等备用模块: +- 是否需要补充培训、公告提醒或管理员代配置: +- 是否达到试点预期:是 / 否 diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index e7e7c728..179b297d 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -139,6 +139,24 @@ docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ curl -fsS http://127.0.0.1:3000/ready ``` +### 1.5.3 R66 小团队上线前演练记录 +上线前至少记录一次演练结果: + +- 演练日期: +- 执行人: +- DB 备份文件: +- 文件存储备份文件: +- 恢复目标机器或目录: +- 恢复后 `/ready` 结果: +- 上个可回滚 tag: +- 回滚负责人: + +需要人工配合的事项: + +- 提供或确认真实 `deploy/trialrun/.env`,特别是 `JWT_SECRET`、`APP_ORIGIN`、`COOKIE_SECURE`、`OPENAPI_DOCS_ENABLED`。 +- 确认备份文件保存位置和保留周期。 +- 准备独立 smoke 账号,避免使用老板、管理员或真实员工账号跑自动化。 + ## 2. 升级流程(试运行) 建议版本化方式: - 每次发布打 tag,例如 `trialrun-2026-02-08-01` diff --git a/docs/admin-handbook.md b/docs/admin-handbook.md index 5d748c73..424882af 100644 --- a/docs/admin-handbook.md +++ b/docs/admin-handbook.md @@ -12,8 +12,8 @@ ## 3. 首次上线最小配置 1. 进入 `工作区设置`,确认产品模式为 `small_team_internal`。 -2. 仅保留主导航:`Portal`、`团队`、`请假`、`报销`、`资产`、`周报`、`待办`、`公告`。 -3. 将 `采购`、`差旅`、`预算`、`支付批次`、`AP 运营` 等模块降级为管理员备用。 +2. 仅保留主导航:`Portal`、`团队`、`请假`、`报销`、`资产`、`周报`、`待办`、`公告`、`项目`。 +3. 将 `考勤`、`采购`、`差旅`、`预算`、`支付批次`、`AP 运营`、`ERP`、`AI` 等模块降级为隐藏或管理员备用。 4. 在 `用户管理` 中按推荐预设配置:老板 / 管理员 / 主管 / 员工。 5. 检查功能开关: - 建议开启:`portal_home_v1`、`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`todo_board_v1`、`global_search_v1` @@ -58,7 +58,7 @@ - 审批待办 ## 8. 备用模块治理 -- 采购、差旅、预算、支付批次、AP 运营保留代码和历史数据,但不作为全员主路径。 +- 考勤、采购、差旅、预算、支付批次、AP 运营、ERP、AI 保留代码和历史数据,但不作为全员主路径。 - 若短期完全不用,保持管理员可见即可,不必删除。 - 只有出现真实场景时,再恢复到主导航或单独扩展。 @@ -74,12 +74,14 @@ - 至少 1 次真实离职或模拟离职跑通 - 全员连续 4 周提交周报 - 管理员不再额外维护“今日在岗表”或“离职交接表” +- 上线前完成 1 次 DB + 文件存储备份和恢复演练,并记录恢复后的 `/ready` 结果 +- 普通员工主导航不出现考勤、采购、差旅、预算、AP、ERP、AI 等备用模块 ## 11. 4 周观测操作法 1. 每周一进入 `Portal` 查看“试点观测摘要”,记录近 7 天登录覆盖率、周报提交率、团队状态覆盖率。 2. 每周五在 `deploy/trialrun/observation-template.md` 填写本周结论、关键阻塞、是否需要调整提醒或导航。 3. 若某周有员工未登录、未提周报、未更新状态,优先通过公告或站内通知补提醒,不引入额外表格。 -4. 连续 4 周都达到预期后,再将 `R17-6` 关单并形成最终验收结论。 +4. 连续 4 周都达到预期后,再将 R66 试点验收关单并形成最终验收结论。 ## 12. 企业微信入口与登录策略 - 建议把企业微信定位为 `入口 + 提醒 + 可选免登`,不要在企业微信里再维护第二套审批或项目录入路径。 @@ -151,6 +153,8 @@ 3. 若启用企业微信入口,补齐:`WECOM_*` 与 `WECOM_ALLOWED_RETURN_HOSTS`。 4. 将 `OPENAPI_DOCS_ENABLED` 保持为 `false`,不要在公网生产暴露文档入口。 5. 定期检查审计日志中的登录失败、Refresh 来源不匹配与涉密资料查看记录。 +6. 准备独立 smoke 账号,不使用老板、管理员或真实员工账号跑自动化。 +7. 每次正式发布前记录备份文件名、恢复负责人和上个可回滚 tag。 ## 17. 网页自动化门禁(R28/R30) - 推荐发布时直接执行:`bash scripts/qa/run-release-gate.sh all`。 diff --git a/docs/employee-quickstart.md b/docs/employee-quickstart.md index 460ac6eb..6eff4345 100644 --- a/docs/employee-quickstart.md +++ b/docs/employee-quickstart.md @@ -7,6 +7,7 @@ - `报销`:继续提交和跟踪报销。 - `周报`:每周写一次三段式周报。 - `待办`:记录和跟进个人事项。 +- `项目`:查看自己的任务、里程碑、风险和交付记录。 ## 2. 第一次登录后建议先做三件事 1. 检查个人资料是否正确。 diff --git a/docs/ops/non-functional-baseline.md b/docs/ops/non-functional-baseline.md index 43485932..9af7e724 100644 --- a/docs/ops/non-functional-baseline.md +++ b/docs/ops/non-functional-baseline.md @@ -9,6 +9,13 @@ - `ERP_SYNC_ENABLED` / `INVOICE_VERIFICATION_ENABLED` 通过 Feature Flag 治理,默认保持关闭。 - `npm audit --omit=dev --audit-level=high` 输出需评审并记录处理策略。 +## R66 小团队上线最低要求 +- 公网试点必须使用 HTTPS,并设置 `APP_ORIGIN=https://` 与 `COOKIE_SECURE=true`。 +- `OPENAPI_DOCS_ENABLED=false`,不要在公网生产暴露接口文档。 +- 管理员账号、smoke 账号和真实员工账号分离;自动化不得使用真实业务账号。 +- 上线前完成 1 次 DB + 文件存储备份恢复演练,并在 `deploy/trialrun/checklist.md` 记录备份文件名与恢复结果。 +- 普通员工默认只看到主路径模块;考勤、采购、差旅、预算、AP、ERP、AI 默认隐藏或放入管理员备用区。 + ## 性能基线(Dashboard) - 使用 `server/scripts/benchmark-dashboard.sh` 对以下端点做基线采样: - `/api/dashboard/expense-overview` diff --git a/docs/ops/testing-strategy.md b/docs/ops/testing-strategy.md index 457eb16c..d493c2a7 100644 --- a/docs/ops/testing-strategy.md +++ b/docs/ops/testing-strategy.md @@ -19,3 +19,20 @@ ## CI Integration 流水线集成了 Vitest `npm run test:coverage` 与 node 自定义的 `scripts/coverage_gate.js` 保障上述策略。执行结果会自动附着于 Actions Job Summary。 机房脚本将自动解析 coverage-summary.json 从而提取对应业务模块的 `lines.pct` 并在 GITHUB_REF_NAME 为依据判定通过。 + +## R66 小团队试点门禁 +R66 面向 10 人以下团队试点,放行重点是主路径可用、备用模块不干扰、发布可回滚。 + +建议上线前执行: + +```bash +bash scripts/qa/run-release-gate.sh r66-trialrun +``` + +该模式串联: + +- `static`:部署契约、`server prisma:validate`、`client typecheck/build`。 +- `server-regression-all`:后端全量业务回归。 +- `smoke-local`:本地网页 smoke,覆盖登录、Portal、导航和企业微信入口基础 UI。 + +若本机数据库、Playwright 或公网 smoke 账号条件不足,必须在 `deploy/trialrun/checklist.md` 的 R66 小团队试点收敛检查中记录不可执行原因和手工替代步骤。 diff --git a/issues/2026-04-26_r66-small-team-trialrun-readiness.csv b/issues/2026-04-26_r66-small-team-trialrun-readiness.csv new file mode 100644 index 00000000..7857c8b3 --- /dev/null +++ b/issues/2026-04-26_r66-small-team-trialrun-readiness.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R66 执行合同建档与范围冻结","创建并校验 R66 小团队试点收敛与上线验收执行合同 CSV,冻结范围:1) 面向 10 人以下团队收敛主路径;2) 建立 4 周试点观测闭环;3) 补齐上线前安全与恢复演练口径;4) 不新增 Prisma 迁移,不新增 npm 依赖,不改审批状态机与 auth API 协议。","issues/2026-04-26_r66-small-team-trialrun-readiness.csv 列完整、Dependencies 为 JSON 数组字符串、A2-A6 范围与批准方案一致,Notes 明确无迁移、无新依赖、不改 workflow/auth 协议与回滚方式。","命令:python3 - <<'PY' 手工解析 CSV 并校验 12 列、状态值、Dependencies JSON 数组字符串;业务:对照 AGENTS.md 与 issues/README.md 检查 A2-A6 是否只覆盖小团队试点收敛、观测、上线安全恢复与验收门禁。","manual","DONE","DONE","DONE","issues/2026-04-26_r66-small-team-trialrun-readiness.csv","[]","回滚路径:删除本次 CSV 不影响业务。无 Prisma 迁移,无新依赖,不改 workflow/auth API 协议。验证:CSV 结构校验通过。" +"A2","小团队主路径与备用模块收敛","收敛 10 人以下团队试点可见范围,明确普通员工、管理者、管理员三类入口:主路径保留 Portal、团队、请假、报销、资产、周报、待办、公告、项目;采购、差旅、预算、支付批次、AP 运营、ERP、AI 等模块降级为管理员备用或开关控制。","1) docs/admin-handbook.md 与 deploy/trialrun/checklist.md 的小团队上线口径一致;2) Portal/导航主路径与员工快速上手文档一致;3) 备用模块不作为普通员工首屏或主导航默认路径;4) 不删除历史模块与数据,只收敛可见入口和上线说明。","命令:cd client && npm run typecheck && npm run build;业务:使用 employee、manager、admin 试点账号分别检查 Portal 首屏与导航,确认普通员工只看到高频主路径,管理员仍可进入备用模块。","manual","DONE","DONE","TODO","docs/admin-handbook.md|docs/employee-quickstart.md|deploy/trialrun/checklist.md|client/src/layouts/AppShellLayout.tsx|client/src/router/index.tsx|client/src/pages/PortalHomePage.tsx","[""A1""]","回滚路径:恢复导航与文档到 R65 口径;不删除模块,不做数据迁移。若实现需改前端,仅限入口显隐与文案收敛。验证:client typecheck/build 与 workspaceSettings.navigation.test 通过;待试点账号人工复核。" +"A3","4 周试点观测闭环","把小团队试点观测从模板变成可执行周循环:完善观测模板、README 与导出说明,必要时增强现有 small-team observation 导出脚本,覆盖登录覆盖率、周报提交率、团队状态覆盖率、项目更新、聊天/表格绕行案例与管理员结论。","1) deploy/trialrun/observations/README.md 明确 week-01 到 week-04 产出方式;2) observation-template.md 包含量化指标、人工观察、阻塞、下周调整和验收结论;3) 导出脚本可生成基础报告或明确缺失数据的人工补录点;4) 观测流程不依赖新增外部服务。","命令:cd server && node --check src/scripts/export-small-team-observation.js && npm run report:small-team-observation -- --out ../deploy/trialrun/observations/week-00.md;业务:管理员可按模板在每周五完成一次试点复盘,并能识别是否应继续隐藏或恢复某模块。","manual","DONE","DONE","DONE","deploy/trialrun/observation-template.md|deploy/trialrun/observations/README.md|server/src/scripts/export-small-team-observation.js|docs/admin-handbook.md","[""A1""]","回滚路径:恢复观测模板与导出脚本到 R65 口径;无 Prisma 迁移,无新依赖。验证:node --check 通过;已使用本地试点数据库导出 deploy/trialrun/observations/week-00.md,人工补录区与 4 周循环说明齐全。" +"A4","上线前安全与备份恢复演练包","将公网试点前的最小安全、备份、恢复、账号与权限审计动作收敛为一套可执行 Runbook,强调 JWT_SECRET、COOKIE_SECURE、OPENAPI_DOCS_ENABLED、管理员账号清单、最小权限、DB+文件存储备份与至少一次恢复演练。","1) deploy/trialrun/runbook.md 与 checklist.md 明确上线前必须完成安全检查、备份、恢复演练和管理员账号核对;2) docs/ops/non-functional-baseline.md 同步小团队上线最低安全口径;3) scripts/backup-db.sh、backup-storage.sh、restore-db.sh、restore-storage.sh 的使用路径在文档中可复现;4) 不引入新的安全产品或外部依赖。","命令:bash -n scripts/backup-db.sh && bash -n scripts/backup-storage.sh && bash -n scripts/restore-db.sh && bash -n scripts/restore-storage.sh && node deploy/trialrun/validate-env-contract.js;业务:在试点环境完成一次备份恢复演练并记录备份文件名、恢复目标、/ready 结果和回滚负责人。","manual","DONE","DONE","TODO","deploy/trialrun/runbook.md|deploy/trialrun/checklist.md|docs/ops/non-functional-baseline.md|scripts/backup-db.sh|scripts/backup-storage.sh|scripts/restore-db.sh|scripts/restore-storage.sh","[""A1""]","回滚路径:恢复 Runbook 与 checklist 文档;脚本若有改动仅限帮助信息或校验提示,不改变备份格式。验证:备份/恢复脚本 bash -n 与 validate-env-contract 通过;实际备份恢复演练待试点环境执行。" +"A5","试点发布门禁与验收矩阵收口","定义 R66 试点发布的最小门禁和验收矩阵:本地静态门禁、核心后端回归、前端 typecheck/build、smoke-local、可选 live smoke、4 周观测结论;避免每次发布依赖大量手工点检。","1) checklist.md 中 R66 放行条件清晰区分必须项、建议项和人工观测项;2) docs/ops/testing-strategy.md 或相关发布文档补充 R66 小团队试点门禁;3) 影响范围验证命令覆盖 server prisma:validate、server regression-all、client typecheck/build;4) 若无法执行某项自动化,必须记录原因与手工替代步骤。","命令:bash scripts/qa/run-release-gate.sh static && cd server && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build;业务:发布负责人可按 checklist 判断本轮是否允许部署到 trialrun,并知道失败时查看哪类产物。","manual","DONE","DONE","DONE","deploy/trialrun/checklist.md|docs/ops/testing-strategy.md|scripts/qa/run-release-gate.sh|scripts/qa/run-web-gates.sh|scripts/check-minimal.sh|server/scripts/regression-im-notifier.sh","[""A2"",""A3"",""A4""]","回滚路径:恢复发布门禁文档和脚本到 R65 口径;脚本改动仅限 R66 模式封装、Docker PostgreSQL client 支持与 IM 回归断言对齐,不降低既有门禁。验证:QA_DB_CLIENT=docker QA_DB_DOCKER_CONTAINER=hyb-oa-trialrun-db QA_DOCKER_USE_SG=1 bash scripts/qa/run-release-gate.sh r66-trialrun 通过;包含 static、server regression-all、smoke-local Playwright 8/8;server npm audit 仍有 27 个漏洞(11 high、4 critical),需依赖升级合同评审。" +"A6","R66 验证、状态回填与提交收口","在 A2-A5 完成后执行相关验证,回填 Issue CSV 状态,形成 R66 小团队试点上线建议和后续 4 周观测计划,并提交代码。","1) A2-A5 的 Test_Method 按影响范围通过或记录不可执行原因;2) issues/2026-04-26_r66-small-team-trialrun-readiness.csv 状态按实测结果回填;3) 输出试点上线建议,包含开放模块、隐藏模块、管理员每周动作、备份恢复演练结论;4) git commit 成功,提交信息引用 R66 A1-A6。","命令:git diff --check && cd server && npm run prisma:validate && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build;业务:使用 10 人以下团队口径复核 Portal、导航、试点观测、备份恢复 Runbook 与发布 checklist 是否能支持一周真实试用。","manual","DOING","TODO","TODO","issues/2026-04-26_r66-small-team-trialrun-readiness.csv|docs/admin-handbook.md|docs/employee-quickstart.md|deploy/trialrun/checklist.md|deploy/trialrun/runbook.md|deploy/trialrun/observation-template.md|deploy/trialrun/observations/README.md|scripts/qa/run-web-gates.sh|server/scripts/regression-im-notifier.sh","[""A5""]","回滚路径:若验证失败,不回填 DONE,不提交实现;必要时回退 R66 文档、脚本与前端入口收敛改动。当前未提交:自动验证已完成 git diff --check、CSV 校验、server prisma:validate、server regression-all、client typecheck/build、IM 专项回归、Docker client smoke-local 与 r66-trialrun;仍待试点账号人工复核、备份恢复演练和 server audit 风险处理。commit message:R66 A1-A6:小团队试点收敛与上线验收。" diff --git a/scripts/qa/run-release-gate.sh b/scripts/qa/run-release-gate.sh index e73dc5b7..77076ac1 100755 --- a/scripts/qa/run-release-gate.sh +++ b/scripts/qa/run-release-gate.sh @@ -10,7 +10,7 @@ mkdir -p "$ARTIFACT_ROOT" usage() { cat < +Usage: bash scripts/qa/run-release-gate.sh 说明: static 运行部署契约、server Prisma 校验、client typecheck/build @@ -19,6 +19,7 @@ Usage: bash scripts/qa/run-release-gate.sh smoke-local -> full-local -> deploy ci-release 依次执行 static -> full-local -> deploy + r66-trialrun R66 小团队试点门禁:static -> server regression-all -> smoke-local 可选环境变量: RELEASE_GATE_ARTIFACT_DIR 失败产物输出目录 @@ -73,6 +74,13 @@ stage_full_local() { bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" full-local } +stage_server_regression_all() { + ( + cd "$ROOT_DIR/server" + ./scripts/regression-all.sh + ) +} + stage_deploy() { bash "$ROOT_DIR/deploy/trialrun/deploy.sh" } @@ -98,6 +106,11 @@ case "$MODE" in run_stage full-local stage_full_local run_stage deploy stage_deploy ;; + r66-trialrun) + run_stage static stage_static + run_stage server-regression-all stage_server_regression_all + run_stage smoke-local stage_smoke_local + ;; all) run_stage static stage_static run_stage smoke-local stage_smoke_local diff --git a/scripts/qa/run-web-gates.sh b/scripts/qa/run-web-gates.sh index 2c0a1e0a..5f4af853 100755 --- a/scripts/qa/run-web-gates.sh +++ b/scripts/qa/run-web-gates.sh @@ -14,6 +14,9 @@ QA_DB_PORT="${QA_DB_PORT:-5432}" QA_DB_USER="${QA_DB_USER:-hyboa_user}" QA_DB_PASSWORD="${QA_DB_PASSWORD:-hyboa_password}" QA_DB_ADMIN_DB="${QA_DB_ADMIN_DB:-postgres}" +QA_DB_CLIENT="${QA_DB_CLIENT:-host}" +QA_DB_DOCKER_CONTAINER="${QA_DB_DOCKER_CONTAINER:-hyb-oa-trialrun-db}" +QA_DOCKER_USE_SG="${QA_DOCKER_USE_SG:-0}" QA_DB_PREFIX="${QA_DB_PREFIX:-hyb_oa_webgate}" QA_KEEP_DB="${QA_KEEP_DB:-0}" QA_SERVER_LOG="${QA_SERVER_LOG:-/tmp/hyb-oa-web-gates-server.log}" @@ -38,11 +41,65 @@ Usage: bash scripts/qa/run-web-gates.sh 可选环境变量: QA_PORT / QA_HOST QA_DB_HOST / QA_DB_PORT / QA_DB_USER / QA_DB_PASSWORD / QA_DB_ADMIN_DB + QA_DB_CLIENT=host|docker + QA_DB_DOCKER_CONTAINER / QA_DOCKER_USE_SG=1 QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS USAGE } +qa_join_command() { + local command="$1" + shift + local rendered="$command" + local arg + local quoted + + for arg in "$@"; do + printf -v quoted '%q' "$arg" + rendered+=" $quoted" + done + + printf '%s' "$rendered" +} + +qa_docker() { + if [[ "$QA_DOCKER_USE_SG" == "1" ]]; then + local command + command="$(qa_join_command docker "$@")" + sg docker -c "$command" + return + fi + + docker "$@" +} + +qa_require_db_client() { + case "$QA_DB_CLIENT" in + host) + qa_require_cmd psql + qa_require_cmd createdb + qa_require_cmd dropdb + ;; + docker) + qa_require_cmd docker + if [[ "$QA_DOCKER_USE_SG" == "1" ]]; then + qa_require_cmd sg + fi + + if ! qa_docker inspect "$QA_DB_DOCKER_CONTAINER" >/dev/null 2>&1; then + echo "[ERROR] PostgreSQL 容器不可用: $QA_DB_DOCKER_CONTAINER" >&2 + echo "[ERROR] 请确认容器运行,或设置 QA_DB_DOCKER_CONTAINER=。" >&2 + exit 1 + fi + ;; + *) + echo "[ERROR] QA_DB_CLIENT 仅支持 host 或 docker,当前: $QA_DB_CLIENT" >&2 + exit 1 + ;; + esac +} + qa_port_in_use() { python3 - "$QA_HOST" "$QA_PORT" <<'PY' import socket, sys @@ -129,10 +186,22 @@ qa_wait_ready() { } qa_psql() { + if [[ "$QA_DB_CLIENT" == "docker" ]]; then + qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \ + psql -v ON_ERROR_STOP=1 -U "$QA_DB_USER" -d "$QA_DB_ADMIN_DB" "$@" + return + fi + PGPASSWORD="$QA_DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" -d "$QA_DB_ADMIN_DB" "$@" } qa_createdb() { + if [[ "$QA_DB_CLIENT" == "docker" ]]; then + qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \ + createdb -U "$QA_DB_USER" "$QA_DB_NAME" + return + fi + PGPASSWORD="$QA_DB_PASSWORD" createdb -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" } @@ -143,6 +212,12 @@ qa_dropdb() { fi qa_psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${QA_DB_NAME}' AND pid <> pg_backend_pid();" >/dev/null || true + if [[ "$QA_DB_CLIENT" == "docker" ]]; then + qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \ + dropdb --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true + return + fi + PGPASSWORD="$QA_DB_PASSWORD" dropdb --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true } @@ -343,9 +418,7 @@ qa_run_live_smoke() { case "$MODE" in smoke-local) qa_require_cmd curl - qa_require_cmd psql - qa_require_cmd createdb - qa_require_cmd dropdb + qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT @@ -353,9 +426,7 @@ case "$MODE" in ;; full-local) qa_require_cmd curl - qa_require_cmd psql - qa_require_cmd createdb - qa_require_cmd dropdb + qa_require_db_client qa_require_cmd node qa_require_cmd npm trap qa_cleanup EXIT diff --git a/server/scripts/regression-im-notifier.sh b/server/scripts/regression-im-notifier.sh index 05c9b081..01c115e8 100755 --- a/server/scripts/regression-im-notifier.sh +++ b/server/scripts/regression-im-notifier.sh @@ -41,11 +41,74 @@ FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT export FIELD_ENCRYPTION_KEY TMP_DIR="$(mktemp -d)" +REGRESSION_CHANNEL_IDS=() + +track_regression_channel() { + local channel_id="$1" + REGRESSION_CHANNEL_IDS+=("$channel_id") +} + +disable_channels_by_ids() { + if [[ "${#REGRESSION_CHANNEL_IDS[@]}" -eq 0 || -z "${DATABASE_URL:-}" ]]; then + return 0 + fi + + local channel_ids_csv + channel_ids_csv="$(IFS=,; printf '%s' "${REGRESSION_CHANNEL_IDS[*]}")" + DATABASE_URL="$DATABASE_URL" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE' >/dev/null 2>&1 || true +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const channelIds = (process.env.CHANNEL_IDS || '').split(',').filter(Boolean); + if (!channelIds.length) return; + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + await prisma.notificationChannel.updateMany({ + where: { id: { in: channelIds } }, + data: { enabled: false }, + }); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch(() => {}); +NODE +} + cleanup() { + disable_channels_by_ids rm -rf "$TMP_DIR" } trap cleanup EXIT +disable_stale_regression_channels() { + DATABASE_URL="$DATABASE_URL" node - <<'NODE' >/dev/null 2>&1 || true +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + await prisma.notificationChannel.updateMany({ + where: { + OR: [ + { name: { startsWith: 'IM Mock ' } }, + { name: { startsWith: 'IM Failing ' } }, + { name: { startsWith: 'IM Secret ' } }, + { name: { startsWith: 'IM Legacy ' } }, + ], + }, + data: { enabled: false }, + }); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch(() => {}); +NODE +} + assert_status() { local actual="$1" local expected="$2" @@ -374,7 +437,7 @@ create_and_submit_purchase() { printf '%s\n' "$purchase_id" } -query_im_outboxes_for_entity() { +query_im_deliveries_for_entity() { local entity_id="$1" local channel_ids_csv="$2" DATABASE_URL="$DATABASE_URL" ENTITY_ID="$entity_id" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE' @@ -386,33 +449,56 @@ const { createPrismaClient } = require('./src/prismaClient'); const { prisma, pool } = createPrismaClient(); await prisma.$connect(); try { - const items = await prisma.integrationOutbox.findMany({ - where: { event_type: 'notification.im' }, - orderBy: { created_at: 'desc' }, - take: 200, + const instances = await prisma.workflowInstance.findMany({ + where: { purchase_request_id: entityId }, select: { - id: true, - status: true, - last_error: true, - payload: true, + workflow_tasks: { + select: { id: true }, + }, }, }); + const taskIds = instances.flatMap((instance) => instance.workflow_tasks.map((task) => task.id)); + const notifications = taskIds.length + ? await prisma.notification.findMany({ + where: { + type: 'workflow.task_assigned', + target_type: 'workflow_task', + target_id: { in: taskIds }, + }, + select: { id: true }, + }) + : []; + const notificationIds = notifications.map((notification) => notification.id); + const deliveries = notificationIds.length + ? await prisma.notificationDelivery.findMany({ + where: { + notification_id: { in: notificationIds }, + channel_id: { in: channelIds }, + }, + orderBy: [{ channel_id: 'asc' }, { attempt_no: 'asc' }], + select: { + id: true, + notification_id: true, + channel_id: true, + status: true, + attempt_no: true, + result_code: true, + error_message: true, + }, + }) + : []; - const filtered = items.filter((item) => { - const payload = item.payload && typeof item.payload === 'object' ? item.payload : null; - return payload - && payload.entityId === entityId - && payload.notificationType === 'workflow.task_assigned' - && channelIds.includes(payload.channelId); - }).map((item) => ({ + const items = deliveries.map((item) => ({ id: item.id, + notificationId: item.notification_id, status: item.status, - lastError: item.last_error, - channelId: item.payload.channelId, - code: item.payload.notificationType, + attemptNo: item.attempt_no, + resultCode: item.result_code, + errorMessage: item.error_message, + channelId: item.channel_id, })); - process.stdout.write(JSON.stringify(filtered)); + process.stdout.write(JSON.stringify(items)); } finally { await prisma.$disconnect(); await pool.end(); @@ -568,7 +654,9 @@ const { NODE } -echo "[1/7] login employee + finance_mgr" +disable_stale_regression_channels + +echo "[1/10] login employee + finance_mgr" EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/employee-login.json")" FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")" @@ -579,30 +667,28 @@ SUPPLIER_ID="${FIXTURE_IDS[2]}" DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}" BUDGET_POOL_ID="${FIXTURE_IDS[4]}" -echo "[2/7] create mock + failing wecom channels" +echo "[2/10] create mock + failing wecom channels" MOCK_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Mock ${RUN_SUFFIX}" "mock" "" "" "$TMP_DIR/create-mock-channel.json")" +track_regression_channel "$MOCK_CHANNEL_ID" FAIL_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Failing ${RUN_SUFFIX}" "wecom_webhook" "http://127.0.0.1:9/wecom" "" "$TMP_DIR/create-failing-channel.json")" +track_regression_channel "$FAIL_CHANNEL_ID" -echo "[3/7] submit purchase request and verify IM outbox written" +echo "[3/10] submit purchase request and verify IM deliveries" PURCHASE_ID_A="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression A ${RUN_SUFFIX}" "purchase-a" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")" -query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a.json" -assert_jq "$TMP_DIR/outboxes-a.json" 'length == 2 and all(.[]; .status == "pending")' "submit purchase enqueues two IM outboxes" +query_im_deliveries_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-a.json" +assert_jq "$TMP_DIR/deliveries-a.json" 'length == 4 and (map(select(.status == "success" and .resultCode == "mock_sent")) | length) == 1 and (map(select(.status == "failed" and .resultCode == "wecom_network_error" and (.errorMessage | type == "string") and (.errorMessage | length > 0))) | length) == 3' "submit purchase writes IM delivery attempts" -echo "[4/7] run outbox job => mock succeeds, failing webhook does not block business transaction" -OUTBOX_IDS_A="$(jq -r "map(.id) | join(\",\")" "$TMP_DIR/outboxes-a.json")" -dispatch_outboxes_by_ids "$OUTBOX_IDS_A" "true" > "$TMP_DIR/outbox-job-1.log" -query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a-after-job.json" -assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(.status) | sort == ["failed","succeeded"]' "outbox success and failure statuses" -assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(select(.status == "failed"))[0].lastError | type == "string" and length > 0' "failing webhook captured error" +echo "[4/10] verify failing webhook is captured without failing business submission" +assert_jq "$TMP_DIR/deliveries-a.json" '(map(select(.status == "failed")) | length) >= 1 and (map(select(.status == "failed"))[0].errorMessage | type == "string")' "failing webhook captured delivery error" -echo "[5/7] disable channels and verify business submission safely skips IM enqueue" +echo "[5/10] disable channels and verify business submission safely skips IM delivery" patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "false" "$TMP_DIR/disable-mock-channel.json" patch_channel_enabled "$FINANCE_MGR_TOKEN" "$FAIL_CHANNEL_ID" "false" "$TMP_DIR/disable-fail-channel.json" PURCHASE_ID_B="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression B ${RUN_SUFFIX}" "purchase-b" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")" -query_im_outboxes_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-b.json" -assert_jq "$TMP_DIR/outboxes-b.json" 'length == 0' "disabled channels should not enqueue IM outbox" +query_im_deliveries_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-b.json" +assert_jq "$TMP_DIR/deliveries-b.json" 'length == 0' "disabled channels should not write IM deliveries" -echo "[6/7] verify IM_NOTIFIER_ENABLED=false marks outbox as skipped success" +echo "[6/10] verify IM_NOTIFIER_ENABLED=false marks outbox as skipped success" patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "true" "$TMP_DIR/re-enable-mock-channel.json" MANUAL_OUTBOX_ID="$(enqueue_manual_im_outbox "$MOCK_CHANNEL_ID")" dispatch_outboxes_by_ids "$MANUAL_OUTBOX_ID" "false" > "$TMP_DIR/outbox-job-im-disabled.log" @@ -616,6 +702,7 @@ assert_jq "$TMP_DIR/channel-test.json" '.data.testResult.ok == true and .data.te echo "[8/10] create encrypted secret channel and verify masked read model" SECRET_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Secret ${RUN_SUFFIX}" "mock" "" "secret-${RUN_SUFFIX}" "$TMP_DIR/create-secret-channel.json")" +track_regression_channel "$SECRET_CHANNEL_ID" assert_jq "$TMP_DIR/create-secret-channel.json" '.data.channel.configJson.secretConfigured == true and (.data.channel.configJson.secretMasked | type == "string") and (.data.channel.configJson | has("secret") | not)' "create channel masks secret on read" secret_test_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$SECRET_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/secret-channel-test.json" '{}')" assert_status "$secret_test_status" "200" "test encrypted channel by id" "$TMP_DIR/secret-channel-test.json" @@ -623,6 +710,7 @@ assert_jq "$TMP_DIR/secret-channel-test.json" '.data.testResult.ok == true and . echo "[9/10] migrate legacy plain secret channel to encrypted storage and keep test path working" LEGACY_CHANNEL_ID="$(create_legacy_secret_channel "IM Legacy ${RUN_SUFFIX}" "http://127.0.0.1:9/wecom" "legacy-${RUN_SUFFIX}")" +track_regression_channel "$LEGACY_CHANNEL_ID" query_channel_secret_state "$LEGACY_CHANNEL_ID" > "$TMP_DIR/legacy-secret-before.json" assert_jq "$TMP_DIR/legacy-secret-before.json" '.hasSecret == true and .hasSecretEncrypted == false' "legacy secret starts as plain text" legacy_test_before_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$LEGACY_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/legacy-channel-test-before.json" '{}')" diff --git a/server/src/scripts/export-small-team-observation.js b/server/src/scripts/export-small-team-observation.js index bcba8939..da2a4061 100644 --- a/server/src/scripts/export-small-team-observation.js +++ b/server/src/scripts/export-small-team-observation.js @@ -139,6 +139,28 @@ function renderMarkdown(report) { 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`; } diff --git a/server/src/services/workspaceSettings.js b/server/src/services/workspaceSettings.js index bc43ce40..e14c5205 100644 --- a/server/src/services/workspaceSettings.js +++ b/server/src/services/workspaceSettings.js @@ -50,7 +50,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({ portal: "main", team: "main", leaveRequests: "main", - attendance: "main", + attendance: "hidden", reimbursements: "main", assets: "main", weeklyReports: "main", diff --git a/server/tests/unit/routes/portal.test.js b/server/tests/unit/routes/portal.test.js index 618e9ead..12a0fb21 100644 --- a/server/tests/unit/routes/portal.test.js +++ b/server/tests/unit/routes/portal.test.js @@ -328,6 +328,8 @@ async function buildApp(options = {}) { describe("portal routes", () => { beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-12T02:00:00.000Z")); featureFlagState.portalHome = true; featureFlagState.announcements = false; featureFlagState.presenceBoard = false; @@ -342,6 +344,10 @@ describe("portal routes", () => { buildTeamPresenceSummary.mockClear(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("extends /portal/home with normalized recentActivities and teamStatusBoard", async () => { const recentWorkflowRows = Array.from({ length: 8 }, (_, index) => buildWorkflowRow({ diff --git a/server/tests/unit/workspaceSettings.navigation.test.js b/server/tests/unit/workspaceSettings.navigation.test.js new file mode 100644 index 00000000..c43c6849 --- /dev/null +++ b/server/tests/unit/workspaceSettings.navigation.test.js @@ -0,0 +1,47 @@ +const { + DEFAULT_MODULE_VISIBILITY, + buildDefaultWorkspaceSetting, + normalizeModuleVisibilityJson, +} = require("../../src/services/workspaceSettings"); + +describe("workspace small-team navigation defaults", () => { + it("keeps only the R66 daily path in default main navigation", () => { + const mainModules = Object.entries(DEFAULT_MODULE_VISIBILITY) + .filter(([, placement]) => placement === "main") + .map(([key]) => key); + + expect(mainModules).toEqual([ + "portal", + "team", + "leaveRequests", + "reimbursements", + "assets", + "weeklyReports", + "todos", + "announcements", + "projects", + ]); + expect(DEFAULT_MODULE_VISIBILITY.attendance).toBe("hidden"); + }); + + it("normalizes unknown or invalid module visibility back to the R66 defaults", () => { + const normalized = normalizeModuleVisibilityJson({ + attendance: "main", + purchaseRequests: "hidden", + unknownModule: "main", + budgets: "invalid", + }); + + expect(normalized.attendance).toBe("main"); + expect(normalized.purchaseRequests).toBe("hidden"); + expect(normalized.budgets).toBe(DEFAULT_MODULE_VISIBILITY.budgets); + expect(normalized.unknownModule).toBeUndefined(); + }); + + it("builds a small-team internal workspace by default", () => { + const workspace = buildDefaultWorkspaceSetting(); + + expect(workspace.profile).toBe("small_team_internal"); + expect(workspace.moduleVisibility.attendance).toBe("hidden"); + }); +});