R66 A1-A6:小团队试点收敛与上线验收
这个提交包含在:
@@ -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 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 }> = [
|
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||||
{ path: "/portal", title: "Portal", group: "工作台" },
|
{ path: "/portal", title: "Portal", group: "工作台" },
|
||||||
{ path: "/announcements", title: "公告", group: "主导航" },
|
{ path: "/announcements", title: "公告", group: "主导航" },
|
||||||
{ path: "/team", title: "团队", group: "主导航" },
|
{ path: "/team", title: "团队", group: "主导航" },
|
||||||
{ path: "/attendance", title: "考勤", group: "主导航" },
|
{ path: "/attendance", title: "考勤", group: "备用模块" },
|
||||||
{ path: "/projects", title: "项目", group: "主导航" },
|
{ path: "/projects", title: "项目", group: "主导航" },
|
||||||
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
|
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
|
||||||
{ path: "/weekly-reports", title: "周报", group: "主导航" },
|
{ path: "/weekly-reports", title: "周报", group: "主导航" },
|
||||||
|
|||||||
@@ -12,24 +12,38 @@ const MODULE_LABELS: Record<string, string> = {
|
|||||||
portal: "Portal",
|
portal: "Portal",
|
||||||
team: "团队",
|
team: "团队",
|
||||||
leaveRequests: "请假",
|
leaveRequests: "请假",
|
||||||
|
attendance: "考勤",
|
||||||
reimbursements: "报销",
|
reimbursements: "报销",
|
||||||
assets: "资产",
|
assets: "资产",
|
||||||
weeklyReports: "周报",
|
weeklyReports: "周报",
|
||||||
todos: "待办",
|
todos: "待办",
|
||||||
announcements: "公告",
|
announcements: "公告",
|
||||||
|
projects: "项目",
|
||||||
workspace: "工作区设置",
|
workspace: "工作区设置",
|
||||||
people: "人员管理",
|
people: "人员管理",
|
||||||
|
organization: "组织架构",
|
||||||
|
contractsRegistry: "合同台账",
|
||||||
workflowDefinitions: "流程定义",
|
workflowDefinitions: "流程定义",
|
||||||
systemSettings: "系统设置",
|
systemSettings: "系统设置",
|
||||||
featureFlags: "功能开关",
|
featureFlags: "功能开关",
|
||||||
auditLogs: "审计日志",
|
auditLogs: "审计日志",
|
||||||
|
approvalRules: "审批规则",
|
||||||
|
userManagement: "用户管理",
|
||||||
|
departments: "部门管理",
|
||||||
|
masterData: "主数据",
|
||||||
|
notificationChannels: "通知渠道",
|
||||||
|
workflowTasks: "审批待办",
|
||||||
|
dashboard: "仪表盘",
|
||||||
purchaseRequests: "采购申请",
|
purchaseRequests: "采购申请",
|
||||||
travelRequests: "差旅申请",
|
travelRequests: "差旅申请",
|
||||||
budgets: "预算管理",
|
budgets: "预算管理",
|
||||||
paymentBatches: "支付批次",
|
paymentBatches: "支付批次",
|
||||||
apOperations: "AP 运营",
|
apOperations: "AP 运营",
|
||||||
|
invoicePool: "票据中心",
|
||||||
|
inventoryChecks: "资产盘点",
|
||||||
cutover: "切换控制台",
|
cutover: "切换控制台",
|
||||||
erpIntegrations: "ERP 集成",
|
erpIntegrations: "ERP 集成",
|
||||||
|
profile: "个人设置",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkspaceSetupPage() {
|
export function WorkspaceSetupPage() {
|
||||||
|
|||||||
+14
-2
@@ -80,8 +80,8 @@
|
|||||||
|
|
||||||
## F. R17 小团队上线检查
|
## F. R17 小团队上线检查
|
||||||
- [ ] 工作区模式已确认为 `small_team_internal`
|
- [ ] 工作区模式已确认为 `small_team_internal`
|
||||||
- [ ] 主导航已收敛为 `Portal / 团队 / 请假 / 报销 / 资产 / 周报 / 待办 / 公告`
|
- [ ] 主导航已收敛为 `Portal / 团队 / 请假 / 报销 / 资产 / 周报 / 待办 / 公告 / 项目`
|
||||||
- [ ] 采购 / 差旅 / 预算 / 支付批次 / AP 运营已降级为管理员备用入口
|
- [ ] 考勤 / 采购 / 差旅 / 预算 / 支付批次 / AP 运营 / ERP / AI 已降级为隐藏或管理员备用入口
|
||||||
- [ ] 已打开 `portal_home_v1`、`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`todo_board_v1`
|
- [ ] 已打开 `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`)
|
- [ ] 已建立连续 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 上线补充检查项
|
## R18-R22 上线补充检查项
|
||||||
- [ ] `APP_ORIGIN` 已切到公网 HTTPS 域名
|
- [ ] `APP_ORIGIN` 已切到公网 HTTPS 域名
|
||||||
- [ ] `COOKIE_SECURE=true`
|
- [ ] `COOKIE_SECURE=true`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 小团队试点 4 周观测模板
|
# 小团队试点 4 周观测模板
|
||||||
|
|
||||||
> 建议由管理员或老板每周五填写一次,用于 `R17-6` 最终验收留痕。
|
> 建议由管理员或老板每周五填写一次,用于 R66 小团队试点验收留痕。
|
||||||
|
|
||||||
## 基本信息
|
## 基本信息
|
||||||
- 工作区模式:`small_team_internal`
|
- 工作区模式:`small_team_internal`
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
## 下周调整
|
## 下周调整
|
||||||
- 是否需要调整提醒频率:
|
- 是否需要调整提醒频率:
|
||||||
- 是否需要继续隐藏或降级某模块:
|
- 是否需要继续隐藏或降级某模块:
|
||||||
|
- 是否需要恢复考勤、采购、差旅、预算、AP、ERP、AI 等备用模块:
|
||||||
- 是否需要补充培训或说明:
|
- 是否需要补充培训或说明:
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# 试点观测输出目录
|
# 试点观测输出目录
|
||||||
|
|
||||||
- 建议每周导出一次:`cd server && npm run report:small-team-observation -- --out ../deploy/trialrun/observations/week-01.md`
|
R66 试点按 4 周闭环执行,建议每周五由管理员或老板导出一次基础指标,再补充人工观察。
|
||||||
- 导出后,再按 `deploy/trialrun/observation-template.md` 补充人工观察、阻塞与结论。
|
|
||||||
- 本目录建议按周保存:`week-01.md`、`week-02.md`、`week-03.md`、`week-04.md`。
|
## 每周导出
|
||||||
|
|
||||||
|
```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` 补充:
|
||||||
|
|
||||||
|
- 多平台重复录入次数、表格替代率、外部在岗表和离职交接表是否停用。
|
||||||
|
- 仍靠聊天或表格补流程的案例。
|
||||||
|
- 本周阻塞、下周是否继续隐藏或恢复备用模块。
|
||||||
|
- 是否达到试点预期,以及是否可进入上线验收。
|
||||||
|
|||||||
@@ -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 等备用模块:
|
||||||
|
- 是否需要补充培训、公告提醒或管理员代配置:
|
||||||
|
- 是否达到试点预期:是 / 否
|
||||||
@@ -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
|
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. 升级流程(试运行)
|
## 2. 升级流程(试运行)
|
||||||
建议版本化方式:
|
建议版本化方式:
|
||||||
- 每次发布打 tag,例如 `trialrun-2026-02-08-01`
|
- 每次发布打 tag,例如 `trialrun-2026-02-08-01`
|
||||||
|
|||||||
+8
-4
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
## 3. 首次上线最小配置
|
## 3. 首次上线最小配置
|
||||||
1. 进入 `工作区设置`,确认产品模式为 `small_team_internal`。
|
1. 进入 `工作区设置`,确认产品模式为 `small_team_internal`。
|
||||||
2. 仅保留主导航:`Portal`、`团队`、`请假`、`报销`、`资产`、`周报`、`待办`、`公告`。
|
2. 仅保留主导航:`Portal`、`团队`、`请假`、`报销`、`资产`、`周报`、`待办`、`公告`、`项目`。
|
||||||
3. 将 `采购`、`差旅`、`预算`、`支付批次`、`AP 运营` 等模块降级为管理员备用。
|
3. 将 `考勤`、`采购`、`差旅`、`预算`、`支付批次`、`AP 运营`、`ERP`、`AI` 等模块降级为隐藏或管理员备用。
|
||||||
4. 在 `用户管理` 中按推荐预设配置:老板 / 管理员 / 主管 / 员工。
|
4. 在 `用户管理` 中按推荐预设配置:老板 / 管理员 / 主管 / 员工。
|
||||||
5. 检查功能开关:
|
5. 检查功能开关:
|
||||||
- 建议开启:`portal_home_v1`、`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`todo_board_v1`、`global_search_v1`
|
- 建议开启:`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. 备用模块治理
|
## 8. 备用模块治理
|
||||||
- 采购、差旅、预算、支付批次、AP 运营保留代码和历史数据,但不作为全员主路径。
|
- 考勤、采购、差旅、预算、支付批次、AP 运营、ERP、AI 保留代码和历史数据,但不作为全员主路径。
|
||||||
- 若短期完全不用,保持管理员可见即可,不必删除。
|
- 若短期完全不用,保持管理员可见即可,不必删除。
|
||||||
- 只有出现真实场景时,再恢复到主导航或单独扩展。
|
- 只有出现真实场景时,再恢复到主导航或单独扩展。
|
||||||
|
|
||||||
@@ -74,12 +74,14 @@
|
|||||||
- 至少 1 次真实离职或模拟离职跑通
|
- 至少 1 次真实离职或模拟离职跑通
|
||||||
- 全员连续 4 周提交周报
|
- 全员连续 4 周提交周报
|
||||||
- 管理员不再额外维护“今日在岗表”或“离职交接表”
|
- 管理员不再额外维护“今日在岗表”或“离职交接表”
|
||||||
|
- 上线前完成 1 次 DB + 文件存储备份和恢复演练,并记录恢复后的 `/ready` 结果
|
||||||
|
- 普通员工主导航不出现考勤、采购、差旅、预算、AP、ERP、AI 等备用模块
|
||||||
|
|
||||||
## 11. 4 周观测操作法
|
## 11. 4 周观测操作法
|
||||||
1. 每周一进入 `Portal` 查看“试点观测摘要”,记录近 7 天登录覆盖率、周报提交率、团队状态覆盖率。
|
1. 每周一进入 `Portal` 查看“试点观测摘要”,记录近 7 天登录覆盖率、周报提交率、团队状态覆盖率。
|
||||||
2. 每周五在 `deploy/trialrun/observation-template.md` 填写本周结论、关键阻塞、是否需要调整提醒或导航。
|
2. 每周五在 `deploy/trialrun/observation-template.md` 填写本周结论、关键阻塞、是否需要调整提醒或导航。
|
||||||
3. 若某周有员工未登录、未提周报、未更新状态,优先通过公告或站内通知补提醒,不引入额外表格。
|
3. 若某周有员工未登录、未提周报、未更新状态,优先通过公告或站内通知补提醒,不引入额外表格。
|
||||||
4. 连续 4 周都达到预期后,再将 `R17-6` 关单并形成最终验收结论。
|
4. 连续 4 周都达到预期后,再将 R66 试点验收关单并形成最终验收结论。
|
||||||
|
|
||||||
## 12. 企业微信入口与登录策略
|
## 12. 企业微信入口与登录策略
|
||||||
- 建议把企业微信定位为 `入口 + 提醒 + 可选免登`,不要在企业微信里再维护第二套审批或项目录入路径。
|
- 建议把企业微信定位为 `入口 + 提醒 + 可选免登`,不要在企业微信里再维护第二套审批或项目录入路径。
|
||||||
@@ -151,6 +153,8 @@
|
|||||||
3. 若启用企业微信入口,补齐:`WECOM_*` 与 `WECOM_ALLOWED_RETURN_HOSTS`。
|
3. 若启用企业微信入口,补齐:`WECOM_*` 与 `WECOM_ALLOWED_RETURN_HOSTS`。
|
||||||
4. 将 `OPENAPI_DOCS_ENABLED` 保持为 `false`,不要在公网生产暴露文档入口。
|
4. 将 `OPENAPI_DOCS_ENABLED` 保持为 `false`,不要在公网生产暴露文档入口。
|
||||||
5. 定期检查审计日志中的登录失败、Refresh 来源不匹配与涉密资料查看记录。
|
5. 定期检查审计日志中的登录失败、Refresh 来源不匹配与涉密资料查看记录。
|
||||||
|
6. 准备独立 smoke 账号,不使用老板、管理员或真实员工账号跑自动化。
|
||||||
|
7. 每次正式发布前记录备份文件名、恢复负责人和上个可回滚 tag。
|
||||||
|
|
||||||
## 17. 网页自动化门禁(R28/R30)
|
## 17. 网页自动化门禁(R28/R30)
|
||||||
- 推荐发布时直接执行:`bash scripts/qa/run-release-gate.sh all`。
|
- 推荐发布时直接执行:`bash scripts/qa/run-release-gate.sh all`。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
- `报销`:继续提交和跟踪报销。
|
- `报销`:继续提交和跟踪报销。
|
||||||
- `周报`:每周写一次三段式周报。
|
- `周报`:每周写一次三段式周报。
|
||||||
- `待办`:记录和跟进个人事项。
|
- `待办`:记录和跟进个人事项。
|
||||||
|
- `项目`:查看自己的任务、里程碑、风险和交付记录。
|
||||||
|
|
||||||
## 2. 第一次登录后建议先做三件事
|
## 2. 第一次登录后建议先做三件事
|
||||||
1. 检查个人资料是否正确。
|
1. 检查个人资料是否正确。
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
- `ERP_SYNC_ENABLED` / `INVOICE_VERIFICATION_ENABLED` 通过 Feature Flag 治理,默认保持关闭。
|
- `ERP_SYNC_ENABLED` / `INVOICE_VERIFICATION_ENABLED` 通过 Feature Flag 治理,默认保持关闭。
|
||||||
- `npm audit --omit=dev --audit-level=high` 输出需评审并记录处理策略。
|
- `npm audit --omit=dev --audit-level=high` 输出需评审并记录处理策略。
|
||||||
|
|
||||||
|
## R66 小团队上线最低要求
|
||||||
|
- 公网试点必须使用 HTTPS,并设置 `APP_ORIGIN=https://<oa-domain>` 与 `COOKIE_SECURE=true`。
|
||||||
|
- `OPENAPI_DOCS_ENABLED=false`,不要在公网生产暴露接口文档。
|
||||||
|
- 管理员账号、smoke 账号和真实员工账号分离;自动化不得使用真实业务账号。
|
||||||
|
- 上线前完成 1 次 DB + 文件存储备份恢复演练,并在 `deploy/trialrun/checklist.md` 记录备份文件名与恢复结果。
|
||||||
|
- 普通员工默认只看到主路径模块;考勤、采购、差旅、预算、AP、ERP、AI 默认隐藏或放入管理员备用区。
|
||||||
|
|
||||||
## 性能基线(Dashboard)
|
## 性能基线(Dashboard)
|
||||||
- 使用 `server/scripts/benchmark-dashboard.sh` 对以下端点做基线采样:
|
- 使用 `server/scripts/benchmark-dashboard.sh` 对以下端点做基线采样:
|
||||||
- `/api/dashboard/expense-overview`
|
- `/api/dashboard/expense-overview`
|
||||||
|
|||||||
@@ -19,3 +19,20 @@
|
|||||||
## CI Integration
|
## CI Integration
|
||||||
流水线集成了 Vitest `npm run test:coverage` 与 node 自定义的 `scripts/coverage_gate.js` 保障上述策略。执行结果会自动附着于 Actions Job Summary。
|
流水线集成了 Vitest `npm run test:coverage` 与 node 自定义的 `scripts/coverage_gate.js` 保障上述策略。执行结果会自动附着于 Actions Job Summary。
|
||||||
机房脚本将自动解析 coverage-summary.json 从而提取对应业务模块的 `lines.pct` 并在 GITHUB_REF_NAME 为依据判定通过。
|
机房脚本将自动解析 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 小团队试点收敛检查中记录不可执行原因和手工替代步骤。
|
||||||
|
|||||||
@@ -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:小团队试点收敛与上线验收。"
|
||||||
|
@@ -10,7 +10,7 @@ mkdir -p "$ARTIFACT_ROOT"
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<USAGE
|
cat <<USAGE
|
||||||
Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|deploy|ci-release|help>
|
Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|deploy|ci-release|r66-trialrun|help>
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
static 运行部署契约、server Prisma 校验、client typecheck/build
|
static 运行部署契约、server Prisma 校验、client typecheck/build
|
||||||
@@ -19,6 +19,7 @@ Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|de
|
|||||||
deploy 调用 deploy/trialrun/deploy.sh,内部会串联 /ready、external deps、runtime smoke 与 live smoke
|
deploy 调用 deploy/trialrun/deploy.sh,内部会串联 /ready、external deps、runtime smoke 与 live smoke
|
||||||
all 依次执行 static -> smoke-local -> full-local -> deploy
|
all 依次执行 static -> smoke-local -> full-local -> deploy
|
||||||
ci-release 依次执行 static -> full-local -> deploy
|
ci-release 依次执行 static -> full-local -> deploy
|
||||||
|
r66-trialrun R66 小团队试点门禁:static -> server regression-all -> smoke-local
|
||||||
|
|
||||||
可选环境变量:
|
可选环境变量:
|
||||||
RELEASE_GATE_ARTIFACT_DIR 失败产物输出目录
|
RELEASE_GATE_ARTIFACT_DIR 失败产物输出目录
|
||||||
@@ -73,6 +74,13 @@ stage_full_local() {
|
|||||||
bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" 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() {
|
stage_deploy() {
|
||||||
bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
|
bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
|
||||||
}
|
}
|
||||||
@@ -98,6 +106,11 @@ case "$MODE" in
|
|||||||
run_stage full-local stage_full_local
|
run_stage full-local stage_full_local
|
||||||
run_stage deploy stage_deploy
|
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)
|
all)
|
||||||
run_stage static stage_static
|
run_stage static stage_static
|
||||||
run_stage smoke-local stage_smoke_local
|
run_stage smoke-local stage_smoke_local
|
||||||
|
|||||||
+77
-6
@@ -14,6 +14,9 @@ QA_DB_PORT="${QA_DB_PORT:-5432}"
|
|||||||
QA_DB_USER="${QA_DB_USER:-hyboa_user}"
|
QA_DB_USER="${QA_DB_USER:-hyboa_user}"
|
||||||
QA_DB_PASSWORD="${QA_DB_PASSWORD:-hyboa_password}"
|
QA_DB_PASSWORD="${QA_DB_PASSWORD:-hyboa_password}"
|
||||||
QA_DB_ADMIN_DB="${QA_DB_ADMIN_DB:-postgres}"
|
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_DB_PREFIX="${QA_DB_PREFIX:-hyb_oa_webgate}"
|
||||||
QA_KEEP_DB="${QA_KEEP_DB:-0}"
|
QA_KEEP_DB="${QA_KEEP_DB:-0}"
|
||||||
QA_SERVER_LOG="${QA_SERVER_LOG:-/tmp/hyb-oa-web-gates-server.log}"
|
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 <smoke-local|full-local|smoke-live>
|
|||||||
可选环境变量:
|
可选环境变量:
|
||||||
QA_PORT / QA_HOST
|
QA_PORT / QA_HOST
|
||||||
QA_DB_HOST / QA_DB_PORT / QA_DB_USER / QA_DB_PASSWORD / QA_DB_ADMIN_DB
|
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
|
QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB
|
||||||
LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS
|
LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS
|
||||||
USAGE
|
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=<container>。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[ERROR] QA_DB_CLIENT 仅支持 host 或 docker,当前: $QA_DB_CLIENT" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
qa_port_in_use() {
|
qa_port_in_use() {
|
||||||
python3 - "$QA_HOST" "$QA_PORT" <<'PY'
|
python3 - "$QA_HOST" "$QA_PORT" <<'PY'
|
||||||
import socket, sys
|
import socket, sys
|
||||||
@@ -129,10 +186,22 @@ qa_wait_ready() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
qa_psql() {
|
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" "$@"
|
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() {
|
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"
|
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
|
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
|
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
|
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
|
case "$MODE" in
|
||||||
smoke-local)
|
smoke-local)
|
||||||
qa_require_cmd curl
|
qa_require_cmd curl
|
||||||
qa_require_cmd psql
|
qa_require_db_client
|
||||||
qa_require_cmd createdb
|
|
||||||
qa_require_cmd dropdb
|
|
||||||
qa_require_cmd node
|
qa_require_cmd node
|
||||||
qa_require_cmd npm
|
qa_require_cmd npm
|
||||||
trap qa_cleanup EXIT
|
trap qa_cleanup EXIT
|
||||||
@@ -353,9 +426,7 @@ case "$MODE" in
|
|||||||
;;
|
;;
|
||||||
full-local)
|
full-local)
|
||||||
qa_require_cmd curl
|
qa_require_cmd curl
|
||||||
qa_require_cmd psql
|
qa_require_db_client
|
||||||
qa_require_cmd createdb
|
|
||||||
qa_require_cmd dropdb
|
|
||||||
qa_require_cmd node
|
qa_require_cmd node
|
||||||
qa_require_cmd npm
|
qa_require_cmd npm
|
||||||
trap qa_cleanup EXIT
|
trap qa_cleanup EXIT
|
||||||
|
|||||||
@@ -41,11 +41,74 @@ FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT
|
|||||||
export FIELD_ENCRYPTION_KEY
|
export FIELD_ENCRYPTION_KEY
|
||||||
|
|
||||||
TMP_DIR="$(mktemp -d)"
|
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() {
|
cleanup() {
|
||||||
|
disable_channels_by_ids
|
||||||
rm -rf "$TMP_DIR"
|
rm -rf "$TMP_DIR"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
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() {
|
assert_status() {
|
||||||
local actual="$1"
|
local actual="$1"
|
||||||
local expected="$2"
|
local expected="$2"
|
||||||
@@ -374,7 +437,7 @@ create_and_submit_purchase() {
|
|||||||
printf '%s\n' "$purchase_id"
|
printf '%s\n' "$purchase_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
query_im_outboxes_for_entity() {
|
query_im_deliveries_for_entity() {
|
||||||
local entity_id="$1"
|
local entity_id="$1"
|
||||||
local channel_ids_csv="$2"
|
local channel_ids_csv="$2"
|
||||||
DATABASE_URL="$DATABASE_URL" ENTITY_ID="$entity_id" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE'
|
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();
|
const { prisma, pool } = createPrismaClient();
|
||||||
await prisma.$connect();
|
await prisma.$connect();
|
||||||
try {
|
try {
|
||||||
const items = await prisma.integrationOutbox.findMany({
|
const instances = await prisma.workflowInstance.findMany({
|
||||||
where: { event_type: 'notification.im' },
|
where: { purchase_request_id: entityId },
|
||||||
orderBy: { created_at: 'desc' },
|
|
||||||
take: 200,
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
workflow_tasks: {
|
||||||
status: true,
|
select: { id: true },
|
||||||
last_error: true,
|
},
|
||||||
payload: 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 items = deliveries.map((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) => ({
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
notificationId: item.notification_id,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
lastError: item.last_error,
|
attemptNo: item.attempt_no,
|
||||||
channelId: item.payload.channelId,
|
resultCode: item.result_code,
|
||||||
code: item.payload.notificationType,
|
errorMessage: item.error_message,
|
||||||
|
channelId: item.channel_id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
process.stdout.write(JSON.stringify(filtered));
|
process.stdout.write(JSON.stringify(items));
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await pool.end();
|
await pool.end();
|
||||||
@@ -568,7 +654,9 @@ const {
|
|||||||
NODE
|
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")"
|
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")"
|
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]}"
|
DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}"
|
||||||
BUDGET_POOL_ID="${FIXTURE_IDS[4]}"
|
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")"
|
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")"
|
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")"
|
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"
|
query_im_deliveries_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-a.json"
|
||||||
assert_jq "$TMP_DIR/outboxes-a.json" 'length == 2 and all(.[]; .status == "pending")' "submit purchase enqueues two IM outboxes"
|
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"
|
echo "[4/10] verify failing webhook is captured without failing business submission"
|
||||||
OUTBOX_IDS_A="$(jq -r "map(.id) | join(\",\")" "$TMP_DIR/outboxes-a.json")"
|
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"
|
||||||
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 "[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" "$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"
|
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")"
|
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"
|
query_im_deliveries_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-b.json"
|
||||||
assert_jq "$TMP_DIR/outboxes-b.json" 'length == 0' "disabled channels should not enqueue IM outbox"
|
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"
|
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")"
|
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"
|
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"
|
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")"
|
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"
|
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" '{}')"
|
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"
|
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"
|
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}")"
|
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"
|
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"
|
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" '{}')"
|
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" '{}')"
|
||||||
|
|||||||
@@ -139,6 +139,28 @@ function renderMarkdown(report) {
|
|||||||
lines.push("- 将本快照内容填入 `deploy/trialrun/observation-template.md` 对应周次。");
|
lines.push("- 将本快照内容填入 `deploy/trialrun/observation-template.md` 对应周次。");
|
||||||
lines.push("- 若指标未达标,优先通过 Portal、公告与站内通知补提醒,不新增外部表格。");
|
lines.push("- 若指标未达标,优先通过 Portal、公告与站内通知补提醒,不新增外部表格。");
|
||||||
lines.push("- 多平台重复录入次数和表格替代率仍建议在 observation template 中人工补记。");
|
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`;
|
return `${lines.join("\n")}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
|
|||||||
portal: "main",
|
portal: "main",
|
||||||
team: "main",
|
team: "main",
|
||||||
leaveRequests: "main",
|
leaveRequests: "main",
|
||||||
attendance: "main",
|
attendance: "hidden",
|
||||||
reimbursements: "main",
|
reimbursements: "main",
|
||||||
assets: "main",
|
assets: "main",
|
||||||
weeklyReports: "main",
|
weeklyReports: "main",
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ async function buildApp(options = {}) {
|
|||||||
|
|
||||||
describe("portal routes", () => {
|
describe("portal routes", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-12T02:00:00.000Z"));
|
||||||
featureFlagState.portalHome = true;
|
featureFlagState.portalHome = true;
|
||||||
featureFlagState.announcements = false;
|
featureFlagState.announcements = false;
|
||||||
featureFlagState.presenceBoard = false;
|
featureFlagState.presenceBoard = false;
|
||||||
@@ -342,6 +344,10 @@ describe("portal routes", () => {
|
|||||||
buildTeamPresenceSummary.mockClear();
|
buildTeamPresenceSummary.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("extends /portal/home with normalized recentActivities and teamStatusBoard", async () => {
|
it("extends /portal/home with normalized recentActivities and teamStatusBoard", async () => {
|
||||||
const recentWorkflowRows = Array.from({ length: 8 }, (_, index) =>
|
const recentWorkflowRows = Array.from({ length: 8 }, (_, index) =>
|
||||||
buildWorkflowRow({
|
buildWorkflowRow({
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户