From a1f0fec4241348100f83ae78c3bfefe314336f00 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 11 Aug 2026 21:15:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E7=A0=94(08-11-internal-oa-usage-cens?= =?UTF-8?q?us-and-simplification):=20=E5=9B=BA=E5=8C=96=E9=87=87=E7=94=A8?= =?UTF-8?q?=E5=BA=A6=E6=99=AE=E6=9F=A5=E4=B8=8E=E5=87=8F=E6=B3=95=E5=86=B3?= =?UTF-8?q?=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/project-governance.md | 32 + .../check.jsonl | 1 + .../design.md | 185 +++++ .../implement.jsonl | 1 + .../implement.md | 76 ++ .../prd.md | 101 +++ .../research/module-catalog.md | 57 ++ .../research/portfolio-decision-matrix.md | 131 ++++ .../research/production-usage-snapshot.json | 677 ++++++++++++++++++ .../research/read-only-review.md | 52 ++ .../research/run-production-usage-census.sh | 154 ++++ .../research/usage-census.cjs | 430 +++++++++++ .../research/usage-census.contract.test.cjs | 84 +++ .../task.json | 32 + docs/产品口径.md | 8 + 15 files changed, 2021 insertions(+) create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/check.jsonl create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/design.md create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/implement.jsonl create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/implement.md create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/prd.md create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/module-catalog.md create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/portfolio-decision-matrix.md create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/production-usage-snapshot.json create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/read-only-review.md create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/run-production-usage-census.sh create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/usage-census.cjs create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/usage-census.contract.test.cjs create mode 100644 .trellis/tasks/08-11-internal-oa-usage-census-and-simplification/task.json diff --git a/.trellis/spec/hyb-oa/project-governance.md b/.trellis/spec/hyb-oa/project-governance.md index f6ca1ddb..1f8a7bc9 100644 --- a/.trellis/spec/hyb-oa/project-governance.md +++ b/.trellis/spec/hyb-oa/project-governance.md @@ -76,6 +76,38 @@ otherwise -> defer, hide, freeze or keep as a manual SOP **Related**: the evidence-backed baseline and initial retirement candidates are recorded in task `07-26-small-team-internal-oa-review`. +### Design Decision: Adoption Mode Before Product Expansion + +**Context**: the 2026-08-11 production read-only census found extremely low +team adoption across the system. This does not prove that missing features are +the cause; several workflows intentionally live in Enterprise WeChat or manual +processes. + +**Decision**: management must choose one operating mode before any new product +feature task starts: + +1. **Enforced use**: designate a real near-term workflow, require the team to + complete it in OA, and use observed blockers—not guessed needs—as product + input. +2. **Ledger fallback**: keep OA as a low-frequency record, control and recovery + system; reduce navigation and maintenance surface instead of expanding it. + +Until the mode is recorded in the business fact source, freeze new product +features. Real production defects and the security/recovery baseline remain +eligible. Reversible navigation reduction is valid under either mode, but it +still requires its own Trellis task and approval for production configuration +writes. Do not create that follow-up task as a side effect of a census or review +task. + +```text +adoption mode undecided -> no new product feature task +mode A + observed team blocker -> consider one small product task +mode B -> prefer hide, freeze, retire review, manual SOP or necessary upkeep +``` + +**Related**: task `08-11-internal-oa-usage-census-and-simplification` contains +the evidence boundary and module decision matrix. + ## Stop Conditions - Production config, deployment, database reset, migration drift, real data diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/check.jsonl b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/design.md b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/design.md new file mode 100644 index 00000000..1bcaa876 --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/design.md @@ -0,0 +1,185 @@ +# 内部 OA 使用度普查与减法路线图:设计说明 + +## 1. 设计目标与边界 + +本任务是一次性生产测量和产品组合裁决,不是监控平台建设,也不是功能精简实施。设计优先保证三件事:查询确实只读、活动归因不把自动化当用户、结论不会由单一数字机械推出。 + +```text +仓库事实源建立显式模块目录 + -> 本地校验查询合同与输出白名单 + -> 生产 identity / runtime SHA / migration PRE + -> 单个 READ ONLY 事务读取聚合 + -> 脱敏快照 + 去污染分桶 + -> 业务必要性裁决 + -> 四类矩阵 + 30 天路线图 + -> 只读 reviewer +``` + +任何阶段失败都停止在证据层,不执行 feature flag、导航、代码、schema 或数据变更。 + +## 2. 模块目录 + +### 2.1 目录来源 + +目录由四个来源交叉形成: + +1. `workspaceSettings.js`:用户实际可能看到的主、管理、备用和隐藏入口; +2. `featureFlags/index.js`:运行环境 gate 与数据库 override 的组合开关; +3. `schema.prisma`:真实持久化表、时间字段和 actor 关系; +4. 07-30 组合盘点与当前业务事实源:业务必要性、替代品和既有建议。 + +不以 Prisma model 数量直接代表模块数量。认证 challenge、session、迁移、通知投递、同步 item 等辅助表并入所属模块或列为系统活动,不单独宣称用户价值。 + +### 2.2 模块组 + +| 模块组 | 主记录信号 | 人工活动信号 | 典型系统/辅助信号 | +| --- | --- | --- | --- | +| 工作台/通知 | notifications、announcements | 公告创建/签收、真人通知阅读 | 通知投递重试 | +| 统一审批 | workflow_instances/tasks/history | 提交、审批完成、转交 | 超时提醒、自动撤权 | +| 报销/票据/OCR | reimbursement_forms、invoice_pool | 真人提交、附件、财务处理 | OCR/验真 job | +| 付款/AP | payment_batches/items/receipts | 创建、复核、执行、回单 | outbox/ERP skip | +| 采购 | purchase_requests/items/attachments | 申请、提交、归档 | 通知与工作流辅助 | +| 合同 | contract_registry、confidential refs | 创建、维护、终止/导出 | 到期类系统提示 | +| 项目 | projects 及里程碑/任务/风险 | 负责人创建更新、成员活动 | 关联索引 | +| 资产/盘点 | asset_cards、assignments、inventory_checks | 建档、领用、转移、盘点 | 批量导入 | +| 请假/企微留存 | leave_requests、wecom_approval_* | OA/企微发起和真人审批 | callback、compensation、reconcile | +| 考勤/团队状态 | attendance/presence、wecom_checkin_* | 真人修正、管理员校正 | 同步 run、日/月摘要 | +| 周报/普通待办/公告 | weekly_reports、todo_items、portal_announcements | 提交、完成、创建/签收 | 自动待办/提醒 | +| 预算 | budget_pools/ledger/rules | 预算维护与人工调整 | 阈值命中 | +| 差旅 | travel_requests/orders/reconciliation | 申请、导入确认、支付 | 卡流水导入/ERP | +| 人员生命周期 | employees、lifecycle/checklist、employee contracts/training/certs | 入离职和资料维护 | 企微 HR 同步 | +| 薪资/绩效 | salary/payslip/performance_* | 生成、发放、评价 | 批处理生成 | +| 系统候选 | feature flags、cutover、integration_* | 管理员明确操作 | 定时同步、迁移期任务 | + +实施阶段把该逻辑展开为逐表 allowlist;每个 SQL 标识符必须来自审核后的静态清单,不接受运行时动态拼接。 + +## 3. 数据采集合同 + +### 3.1 生产身份 + +- 复用 `production-identity-bootstrap` 解析唯一 trialrun 目标,不把仓库占位符、其他数据库或本地容器当生产。 +- 读取并记录实际运行镜像的完整/短 SHA 与 migration 摘要。比较对象是当前运行容器,不要求本地文档提交后的 `HEAD` 等于已部署应用 SHA。 +- 不为本任务复制脚本到生产、不发布新候选、不修改远端部署输入。 + +### 3.2 事务 + +审阅后的 task-scoped 查询通过 stdin 送入现有运行环境,不持久化在生产。核心事务合同为: + +```sql +BEGIN TRANSACTION READ ONLY; +SET LOCAL statement_timeout = '30s'; +SET LOCAL lock_timeout = '2s'; +-- assert current_setting('transaction_read_only') = 'on' +-- execute static allowlisted aggregate SELECTs +COMMIT; +``` + +- 禁止 DDL、DML、`SELECT ... FOR UPDATE`、存储过程调用、临时表和动态 SQL。 +- 聚合只用 `count`、`max`、条件计数和 `count(distinct ...)`;不返回原始业务行。 +- 任一表不存在、字段不一致、超时或 JSON 输出不可解析,整体结果标记 `QUERY_FAILED`,不得把缺失值补成 0。 +- 如果连接或输出通道不确定,先以只读证据定性;不得在未知状态下反复 SSH 执行。 + +### 3.3 PRE/POST + +- PRE:生产 identity、运行 SHA、migration pending、数据库事务只读能力均明确。 +- POST:事务成功提交、`transaction_read_only=on` 证据存在、输出仅含白名单字段;再次确认运行 SHA/migration 未被本任务改变。 +- PRE/POST 间可能有真实用户并发新增记录。若重复只读校验数值变化,记为并发观察,不归因于普查,也不为了“稳定结果”暂停用户。 + +## 4. 输出模型与隐私 + +每个模块只允许输出如下稳定字段: + +```text +moduleKey +totalRows +created30d / created90d +activity30d / activity90d +latestActivityAt +humanActors30d / humanActors90d +humanEvents30d / humanEvents90d +smokeEvents30d / smokeEvents90d +systemEvents30d / systemEvents90d +unknownEvents30d / unknownEvents90d +navigationMode +effectiveFlagState +queryStatus +``` + +时间按生产数据库当前时间计算并统一输出 ISO 时间;最终文档可只保留日期粒度。禁止输出 employee id、姓名、员工编号、邮箱、手机号、IP、user agent、业务编号、金额、正文、附件路径、原始 `extra` 或 payload。 + +### 4.1 smoke 识别 + +复用现有 helper 的 smoke-like 语义:已知 smoke 前缀、受控测试编号及“测试/试运行”标志。匹配只发生在生产事务内,外部只收到 `human/smoke/system/unknown` 聚合计数。规则命中不删除数据,也不改变账号状态。 + +### 4.2 system 识别 + +系统活动由静态 action/表映射识别,例如同步 run、callback/compensation、通知投递、OCR/验真 job、ERP outbox/skip、逾期提醒和自动撤权。管理员手工触发的 job 可以同时具有真人触发者和系统结果;矩阵分别记录“真人触发”和“系统产物”,避免重复解释为两名用户。 + +### 4.3 unknown + +旧数据没有 actor、actor 已归档、action 语义混合或来源无法证明时进入 `unknown`。unknown 可以证明“存在数据”,不能证明真人持续使用。 + +## 5. 有效导航与开关 + +- 导航以生产 `workspace_settings` 的模块可见性为准,缺记录时才回退当前应用默认配置。 +- feature flag 的有效状态同时考虑运行环境 gate、数据库 override、rollout 类型和部门 allowlist;只输出 `on/off/partial/unknown`,不输出环境变量原值或部门标识。 +- “隐藏但有真实使用”可能来自直达链接或管理员入口,是需要核实的发现;“显示但无人使用”才构成导航减法候选。 +- flag 关闭只能解释当前不可用,不能证明数据无价值或可以物理退役。 + +## 6. 分类算法 + +分类采用证据卡而非硬阈值自动打标。每张卡包含: + +```text +production usage + people/frequency + exposure ++ owner + failure cost + substitute +historical recommendation + confidence +=> decision + rationale + trigger +``` + +判定顺序: + +1. 先看是否存在真实人工活动,而不是总行数; +2. 再看 30/90 天频率、实际 actor 数和最近活动; +3. 再应用小团队决策门,识别低频但必要能力; +4. 最后考虑当前入口是否造成认知负担,以及是否有企微/手工 SOP 等可靠替代; +5. 证据冲突或缺业务确认时,保守落入 `保持冻结` 并列出确认问题。 + +分类不会自动执行动作: + +| 分类 | 本任务产物 | 后续动作 | +| --- | --- | --- | +| 重点优化 | 最多两条真实问题候选 | 另建产品 task,先说明具体用户与问题 | +| 保持冻结 | 保持可用、不扩建 | 事件触发或真实问题出现再评估 | +| 隐藏入口 | 可逆显隐建议 | 另建 workspace/flag task 并批准生产变更 | +| 候选退役 | 调研清单 | 另做数据导出、保留、依赖、回滚审查后再决定 | + +## 7. 失败矩阵 + +| 失败 | 处理 | +| --- | --- | +| 生产 identity、SHA 或 migration 不明确 | 停止,不查询 | +| 事务不是 read-only | 立即回滚并停止 | +| allowlist 与 schema 不一致 | 标记合同失败,回本地修正,不推断使用度 | +| 查询超时/通道中断/JSON 不可解析 | `QUERY_FAILED`;保留首个失败现场,不连续重跑 | +| smoke/system 无法可靠区分 | 进入 unknown,不计入真人活动 | +| 导航或 flag 有效状态无法计算 | 标记 unknown,不以代码默认代替生产事实 | +| 数据显示零使用但业务必要性未确认 | 保持冻结,登记负责人确认项 | +| 发现疑似真实业务缺陷 | 停止产品处置,另建缺陷 task;本任务只记录证据 | + +## 8. 证据与复核 + +任务目录只保存查询合同、脱敏聚合快照、决策矩阵和执行摘要。生产连接信息、原始 SQL 输出、完整账号和 payload 不进入仓库、task 或对话。 + +由于结论会影响后续模块显隐和退役决策,执行后启动只读 reviewer,重点核对: + +- 实际查询是否严格等于 allowlist 和只读合同; +- 人工/smoke/system/unknown 分桶是否有证据; +- 零行、flag off、自动审计量是否被误用; +- 四类结论是否符合小团队决策门; +- 路线图是否把产品价值置于工具债之前; +- 本任务是否未执行任何产品或生产变更。 + +## 9. 回滚 + +本任务没有生产写入,因此没有数据回滚。若聚合、归因或分类错误,撤回相应结论并重新审阅查询合同即可。任何未来隐藏/退役动作的回滚由其独立 task 设计,本任务不预先授权。 diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/implement.jsonl b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/implement.md b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/implement.md new file mode 100644 index 00000000..c7322ef4 --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/implement.md @@ -0,0 +1,76 @@ +# 内部 OA 使用度普查与减法路线图:执行计划 + +## 0. 启动门禁 + +- [x] 用户已审查并明确批准本版 `prd.md`、`design.md` 和 `implement.md`。 +- [x] 批准后运行 `task.py start`;在此之前未查询生产。 +- [x] 重新确认工作区只有当前 task 规划改动,不夹带产品、部署或历史任务变更。 +- [x] 读取 `project-governance.md`、当前 backend spec、生产运维事实源和 07-30 组合盘点。 + +## 1. 建立模块 allowlist + +- [x] `research/module-catalog.md` 逐项列出表、时间字段、actor/审计信号、导航、feature flag 和历史建议。 +- [x] 仅纳入用户可见业务、低频必要后台和明确退役候选;session/challenge、迁移、投递 item 等并入所属模块或系统活动。 +- [x] 对照 `schema.prisma` 验证每个表/字段真实存在,对照 workspace/flag registry 验证模块键和开关键。 +- [x] 固定 smoke-like 规则和 system action allowlist;未知来源进入 `unknown`。 + +## 2. 实现 task-scoped 只读查询合同 + +- [x] 在当前 task 下新增审阅用查询器和 runner,未修改 `client/src`、`server/src`、部署配置或 migration。 +- [x] 查询显式 `BEGIN TRANSACTION READ ONLY`,设置 statement/lock timeout 并断言 `transaction_read_only=on`。 +- [x] 所有表和列来自静态 allowlist;无动态 schema 扫描、DML/DDL、锁行、临时表和存储过程。 +- [x] 只返回模块级聚合字段;敏感原始字段均不出事务。 +- [x] 输出包含 `queryStatus`,合同错误统一 `QUERY_FAILED`,不会补成 0。 + +## 3. 本地验证 + +- [x] 合同测试静态拒绝写 SQL/锁行,并验证固定 module/字段白名单。 +- [x] 本地真实 schema 的 read-only 执行验证 20 个模块、30 个输出字段、空表与时间聚合。 +- [x] synthetic fixture 覆盖 smoke、真人、system 和 unknown 四分桶。 +- [x] 模块数/字段合同错误 fail-closed;不存在“缺失当零”的结果路径。 +- [x] 敏感字段白名单与 `check-sensitive-content` 通过。 + +## 4. 生产只读执行(需启动批准) + +- [x] PRE 复用 identity bootstrap,确认唯一 trialrun;运行 SHA 精确等于 `origin/main`、产品差异 0、migration pending=none、rollback window OPEN/0。 +- [x] workspace setting 与有效 feature flag 由生产运行代码在同一 read-only 事务内计算。 +- [x] 查询器通过 stdin 送入现有 server 容器;未复制/落盘、未重建镜像、未部署。 +- [x] 唯一有效执行严格一次、零 retry。一次 stdin 入口拦截发生在 `run()` 前,证明普查事务执行数为 0 后才修正;最终 `effectiveQueryExecutions=1`。 +- [x] 脱敏快照保存为 `research/production-usage-snapshot.json`;不保存个人/业务明细。 +- [x] POST 证据由同一事务的 `transactionReadOnly=true`、source build match 和全模块 `queryStatus=PASS` 构成;本任务无并发归因。 + +## 5. 分析与路线图 + +- [x] `research/portfolio-decision-matrix.md` 合并生产快照与 07-30 历史建议。 +- [x] 先区分真人/smoke/system/unknown,再判断真实人工活跃度;未按总行数或审计总量排序。 +- [x] 输出四类矩阵,并逐项说明理由和触发条件。 +- [x] 结论收敛为“整体采用度极低”;A/B 采用模式明确前冻结产品功能开发,财务闭环不再被预设为下一项开发。 +- [x] 对仓库外业务事实只列负责人确认项,不替业务方回答。 +- [x] 显隐、退役或产品优化均为独立后续 task 候选,本任务未实施。 + +## 6. 只读 review + +- [x] Codex inline 模式由主会话执行只读 reviewer;review 阶段未修改产品代码或生产状态,结论记录在 `research/read-only-review.md`。 +- [x] reviewer 复核生产身份、只读事务、严格一次、输出白名单和失败处理。 +- [x] reviewer 抽查真人、smoke、system 和 unknown 各一项归因。 +- [x] reviewer 复核零行/flag off/自动审计没有被机械用于隐藏或退役。 +- [x] reviewer 复核原 30 天路线图没有扩建零使用模块;用户复核后进一步收紧为 A/B 定位决策门。 + +## 7. 验证与收口 + +- [x] `python3 ./.trellis/scripts/task.py validate 08-11-internal-oa-usage-census-and-simplification` +- [x] `node scripts/qa/check-process-contract.mjs --baseline --json` +- [x] `node scripts/qa/audit-docs.mjs --check-registry`(0 error;仓库既有 22 个未登记 docs warning) +- [x] `node scripts/qa/check-trellis-governance.mjs` +- [x] 项目敏感内容扫描 findingCount=0,`git diff --check` 与 task 文件尾随空白检查通过。 +- [x] 用户已接受分类矩阵;已将“整体采用度极低”、A/B 采用模式决策门和共同导航减法方向更新到现有事实源与治理 spec,未另建平行路线图文档或导航 task。 +- [ ] 使用中文 commit message 并引用 `08-11-internal-oa-usage-census-and-simplification`,归档 task;不自动推送。 + +## 风险点与停止条件 + +- 生产身份或运行 SHA 不清楚:停止。 +- 查询不是只读、SQL 合同超出 allowlist:停止并回滚事务。 +- 输出包含敏感明细:销毁现场输出,修正合同并重新审查;不得降低扫描器。 +- 第一次执行状态不确定:不连续重跑。 +- 发现真实认证、权限、财务或数据缺陷:只记录并停下,另建相应任务。 +- 分类需要业务负责人确认:标记 `保持冻结/待确认`,不代替决定。 diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/prd.md b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/prd.md new file mode 100644 index 00000000..1cb9205a --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/prd.md @@ -0,0 +1,101 @@ +# 内部 OA 使用度普查与减法路线图 + +## Goal + +用一次脱敏、只读的生产使用度普查回答“5–10 人内部 OA 里哪些能力真的有人用”,并据此给出未来 30 天的减法路线图。任务的价值不是继续扩充功能,而是把有限开发时间集中到真实人工使用且失败成本高的流程,冻结、隐藏或候选退役其余维护面。 + +## Background + +- hyb-oa 永久定位为本公司 5–10 人内部系统,不作为 SaaS、商业产品或通用 OA 发布。 +- 2026-07 的 `07-30-current-issues-feature-portfolio-review` 已完成代码、导航和业务必要性的组合盘点,但没有连接生产,也没有真实使用度数据。 +- 最近数日主要投入部署、认证恢复、测试定性和证据治理,用户可见功能变化很少;继续由“上一个任务发现的新债务”驱动,会偏离真实内部使用价值。 +- 生产合同台账当前为零行,但业务事实源将合同列入首批真实使用范围。这证明“零行”既可能是低频必要,也可能是流程尚未落地,不能自动解释为模块无价值或系统缺陷。 +- 现有工程原则已经固定低维护决策门:`clear owner + recurring internal use + material failure cost + no cheaper reliable substitute`。本任务为这道门补生产证据,不改写原则。 + +## Confirmed Facts + +- 当前服务端工作区注册表包含工作台、审批、报销、采购、合同、项目、资产、付款/AP、票据、请假、考勤、周报、公告、预算、差旅、盘点和管理类入口,并已有 `main/admin/hidden` 三种显隐层级。 +- 当前存在 26 个功能开关键,数据库 override 与运行环境 gate 共同决定有效状态;只看代码默认值或数据库记录都不能代表生产实际可见性。 +- Prisma schema 含业务数据、认证、审计、企微留存、系统配置和集成运行表。动态扫描全部表会把 session、challenge、迁移、通知投递等技术活动误判成业务使用,因此必须采用显式模块 allowlist。 +- `audit_logs` 有 actor、action、target type 和时间,但自动任务、smoke/fixture 与真人操作可能共用审计链;审计量不能不加区分地等同于用户使用量。 +- 仓库已有 OA-only smoke 判据,可按员工编号、邮箱本地部分、名称中的受控模式识别测试账号;本任务复用其语义,但不输出完整账号、姓名或联系方式。 +- 生产只有一套名为 trialrun 的环境,没有预发。本任务只读测量,不需要也不允许为普查发布新应用候选。 + +## Scope + +- 盘点当前用户可见业务、低频必要后台及已有退役候选,建立模块到生产表、审计、导航和功能开关的显式映射。 +- 在另获启动批准后,对唯一 trialrun 生产环境执行一次脱敏聚合只读普查,区分真人、smoke、system 和 unknown 活动。 +- 将生产快照与既有小团队业务裁决结合,输出四类模块矩阵、未来 30 天开发边界、负责人确认项和后续独立 task 候选。 +- 本任务只产出测量证据和决策建议;所有实际产品、配置、生产和数据变更均留给后续单独授权任务。 + +## Requirements + +### R1. 模块目录与生产映射 + +- 以 `server/prisma/schema.prisma`、`server/src/services/workspaceSettings.js`、`server/src/services/featureFlags/index.js`、当前业务事实源和 07-30 组合盘点为依据,建立显式模块目录。 +- 每个模块记录:主表/辅助表、主要活动时间字段、可用 actor 字段或审计 action、当前导航层级、关联 feature flag、历史组合建议和业务必要性来源。 +- 至少覆盖:工作台/通知、统一审批、报销/票据/OCR、付款/AP、采购、合同、项目、资产/盘点、请假/企微审批留存、考勤/团队状态、周报、普通待办、公告、预算、差旅、人员生命周期、薪资、绩效,以及 ERP/切换等系统候选。 + +### R2. 只读生产快照 + +- 生产查询必须先通过既有生产 identity bootstrap,并确认实际运行环境、已部署 SHA 与 migration 状态;本地 `HEAD` 领先生产的文档提交不得被误当成应用版本漂移。 +- 查询必须在单个显式 `READ ONLY` 事务内运行,设置有限的 statement/lock timeout;任何事务只读断言、表/列合同或查询解析失败均 fail-closed,不能把失败解释成零使用。 +- 只查询 R1 allowlist 中的表和字段,不动态遍历 schema,不读取附件正文、合同正文、银行信息、密码、token、Secret、原始 payload 或完整账号。 +- 对每个模块输出稳定聚合:总记录数、近 30/90 天新增或业务活动数、最近活动时间、可用时的 distinct actor 数、真人/smoke/自动系统活动分桶、生产导航层级及有效 feature flag 状态。 +- 只读快照允许反映执行期间其他真实用户的并发活动,但不得把并发变化归因于本任务;查询本身不得产生业务或审计写入。 + +### R3. 活动去污染 + +- 测试/smoke 活动使用与 `repair-oa-only-smoke-accounts.js` 一致的 smoke-like 语义识别,并只输出聚合计数。 +- system/job 活动按显式 action/表映射单独分桶;定时同步、投递重试、巡检和自动留存不能证明真人使用该模块。 +- 无法可靠归因的活动标记为 `unknown`,不得强行计入真人使用。 +- 真实人工使用至少需要真人 actor 或明确真人拥有/提交字段支持;只有表总数、缓存、seed、fixture 或 system job 不足以成立。 + +### R4. 分类与决策矩阵 + +- 每个模块基于生产证据、导航暴露、实际使用人数/频率、失败成本、明确负责人、可靠替代品和历史业务事实进行裁决,而不是只按行数排序。 +- 最终分类固定为四类: + 1. `重点优化`:有真实人工使用,且流程高频或失败成本高;未来开发只从这里产生。 + 2. `保持冻结`:低频但必要、已有历史/审计价值,或目前无足够证据扩建;保持可用,不主动加功能。 + 3. `隐藏入口`:近期无真实人工使用、当前仍造成导航负担,且有更便宜可靠替代;只提出后续可逆隐藏建议。 + 4. `候选退役`:无真实数据/活动、无明确负责人或必要性、存在可靠替代;只进入后续调研,不在本任务删代码或数据。 +- “零行”“90 天无活动”或“flag 关闭”都不能单独触发隐藏/退役。合同、资产、备份恢复、权限、审计和付款 maker-checker 等低频高失败成本能力必须经过业务必要性判断。 +- 每个分类给出证据、置信度、缺失信息、负责人确认项和解除/触发条件;证据不足时选择风险更低的 `保持冻结`,不得猜测。 + +### R5. 30 天减法路线图 + +- 输出未来 30 天只做什么、不做什么:最多选择 1–2 条有真人使用证据的重点流程作为产品改进候选,其余功能开发冻结。 +- 后续工作按独立 Trellis task 列出,注明真实用户问题、预计收益、验证方式、触发条件和是否需要生产批准。 +- 实际修改 feature flag、导航、产品代码、schema 或生产数据,必须另建 task 并获得对应批准。 +- 低优先工具债只有在再次触达相关工具或能直接降低生产风险时才进入任务,不得挤占活跃业务流程优化预算。 + +## Acceptance Criteria + +- [x] AC1:形成覆盖 R1 范围的模块 allowlist,逐项映射业务表、时间/actor 信号、审计 action、导航与 feature flag,且没有动态全库扫描。 +- [x] AC2:生产 identity、实际运行 SHA 和 migration 只读门禁通过;快照在显式 `READ ONLY` 事务中完成,查询失败项被标记为失败/未知而不是零使用。 +- [x] AC3:每个模块具有总量、30/90 天活动、最近活动、可用 actor 数和真人/smoke/system/unknown 分桶;输出不含敏感原始字段或可识别个人明细。 +- [x] AC4:真人、smoke/fixture、system job 三类证据被明确分开;至少用一组已知 smoke 数据和一组自动任务活动验证去污染规则不会把它们计为真人使用。 +- [x] AC5:产出四类模块决策矩阵;每项同时引用生产证据和小团队业务必要性,不存在“零行自动关闭”或“审计多自动判活跃”的结论。 +- [x] AC6:产出未来 30 天路线图,重点优化候选不超过两条,并明确冻结清单、后续独立 task、负责人确认项及事件触发条件。 +- [x] AC7:本任务没有改 feature flag、导航、应用代码、生产配置、schema 或业务数据,没有制造测试业务记录,也没有发布新候选。 +- [x] AC8:执行结果通过 task validate、Trellis governance、文档/流程相关门禁、敏感扫描与 `git diff --check`;生产只读执行与最终分类经过只读 reviewer 复核。 + +## Out of Scope + +- 不在本任务关闭 feature flag、隐藏导航、删除路由、删除 schema/table 或清理任何真实/测试数据。 +- 不新增功能、不修复低优先工具债、不做 UI 改版、不部署应用候选。 +- 不通过登录、浏览器点击或制造业务记录测量使用度;生产访问仅限批准后的数据库/运行配置聚合读取。 +- 不把模块缺少记录直接定性为系统缺陷、流程缺陷或“尚未启用”;需要业务负责人确认的事实只登记,不代替确认。 +- 不把一次性普查扩成长期埋点、BI 平台、数据仓库或通用分析框架。 + +## Test Plan + +- 静态复核 module/table/action/navigation/flag 映射与当前 schema、registry 一致。 +- 在非生产环境或静态 fixture 上验证查询输出 schema、smoke/system 分桶、失败项 fail-closed 和敏感字段白名单。 +- 获得另一次明确启动批准后,执行生产 identity/migration PRE、单次只读普查、POST 只读检查;零 retry,失败后先定性再决定是否重跑。 +- 复核结果矩阵满足四类定义和低维护决策门,启动只读 reviewer。 +- 运行 `python3 ./.trellis/scripts/task.py validate 08-11-internal-oa-usage-census-and-simplification`、`node scripts/qa/check-trellis-governance.mjs`、相关流程/文档门禁、敏感扫描和 `git diff --check`。 + +## Execution Approval And Boundary + +用户于 2026-08-11 审查规划后明确批准启动,授权本 task 的本地查询合同实现和一次生产只读普查。授权不包含发布、登录、feature flag/导航修改、schema/data 写入或任何模块退役;这些动作仍需后续独立 task 和对应批准。 diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/module-catalog.md b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/module-catalog.md new file mode 100644 index 00000000..4e32bb90 --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/module-catalog.md @@ -0,0 +1,57 @@ +# 生产使用度模块 allowlist + +## 口径 + +- `主/辅表` 只用于模块级聚合,不返回原始行。`totalRows` 是所列主/辅表的记录合计,不代表独立业务单据数。 +- `activityAt` 优先使用 `updated_at`,不可变记录使用 `created_at`;`created30d/90d` 始终使用 `created_at`。 +- `actor` 只用于事务内分桶,输出只有 distinct count 和 `human/smoke/system/unknown` 计数。 +- `system` 表示由同步、投递、OCR、外部留存或 outbox 产生,不等于真人在 OA 中直接操作。 +- 审计 action 只做独立交叉验证,不能与表记录相加后解释为“操作次数”。 + +## 模块映射 + +| moduleKey | 产品含义 | 主/辅表与 actor | 审计 action 映射 | 导航/flag | 07-30 基线 | +| --- | --- | --- | --- | --- | --- | +| `portal_notifications` | 工作台通知 | `notifications(recipient_employee_id, system)` | 不以通知投递量证明真人使用 | `portal(main)` / `portal_home_v1` | 保留并简化 | +| `workflow_approvals` | 待我处理、统一审批 | `workflow_instances(submitter_id)` | `APPROVAL_*`、`DELEGATION_*`、`WORKFLOW_*` | `workflowTasks(admin)`;流程定义另受 flag | 保留并优先验证 | +| `reimbursements_invoices` | 报销、发票、OCR | `reimbursement_forms(owner_id)`、`invoice_pool(uploaded_by_employee_id)`、`ocr_results(system)` | `REIMBURSEMENT_*`、`INVOICE_*`、报销导出 | `reimbursements(main)`、`invoicePool(admin)` | 保留并优先验证 | +| `payments_ap` | 付款、AP、银行回单 | `payment_batches(created_by)`、`payment_batch_receipt_attachments(uploaded_by)` | `PAYMENT_*`、`EXPORT_AP_OPERATIONS` | `paymentBatches/apOperations(admin)` / `payment_batch_v1` | 保留并优先验证 | +| `procurement` | 采购 | `purchase_requests(applicant_id)` | `PURCHASE_*` | `purchaseRequests(main)` | 保留但按需使用 | +| `contracts` | 合同台账 | `contract_registry(owner_employee_id)` | `CONTRACT_*`、`EXPORT_CONTRACTS_REGISTRY` | `contractsRegistry(main)` / `project_linking_v1` | 保留但简化 | +| `projects` | 项目轻管理 | `projects(owner_employee_id)` | `PROJECT_*` | `projects(main)` / `project_domain_v1` | 保留但简化 | +| `assets_inventory` | 资产与盘点 | `asset_cards(created_by)`、`inventory_checks(created_by)` | `ASSET_*`、`INVENTORY_*` | `assets(main)`、`inventoryChecks(admin)` | 资产保留;盘点默认隐藏 | +| `leave_wecom_history` | OA 请假与企微审批留存 | `leave_requests(owner_id)`、`wecom_approval_archives(system)` | 留存主要由 workflow/企微表证明 | `leaveRequests(main)` | 企微主办、OA 后台留存 | +| `attendance_presence` | 考勤修正、打卡留存、团队状态 | `attendance_corrections(owner_id)`、`wecom_checkin_records(system)`、`employee_presence(updated_by)` | `ATTENDANCE_*`、`PRESENCE_*` | `attendance(hidden)` / unified;`team(main)` / presence | 企微主办;团队状态默认隐藏冻结 | +| `weekly_reports` | 周报 | `weekly_reports(employee_id)` | 无稳定专用 action | `weeklyReports(admin)` / `weekly_report_v1` | 默认隐藏、冻结开发 | +| `todos` | 普通待办板 | `todo_items(created_by)` | 无稳定专用 action | `todos(hidden)` / `todo_board_v1` | 继续隐藏、退役前调研 | +| `announcements` | 公告与制度签收 | `portal_announcements(created_by)`、签收记录以公告所属模块计数 | `PORTAL_ANNOUNCEMENT_*` | `announcements(admin)` / `announcements_v1` | 默认隐藏,必要时使用 | +| `budgets` | 预算池与规则 | `budget_pools(created_by)` | `BUDGET_*` | `budgets(admin)` | 冻结开发 | +| `travel` | 差旅申请、统付与对账 | `travel_requests(applicant_employee_id)`、`travel_orders(created_by)` | `TRAVEL_*` | `travelRequests(admin)` | 冻结开发 | +| `people_lifecycle` | 人员生命周期、员工合同与培训 | `employee_lifecycle_events(created_by)`、`employee_contracts(created_by)`、`employee_trainings(created_by)` | `EMPLOYEE_*`、`SYSTEM_USER*`、`DEPARTMENT_*` | `people/organization(admin)` / lifecycle | 保留最小版 | +| `payroll` | 薪资与工资条 | `payslips(created_by)` | salary/payslip 生成、更新、导出 actions | 无正式导航 | 默认隐藏、退役前调研 | +| `performance` | 绩效 | `performance_reviews(reviewer_employee_id)` | `PERFORMANCE_*` | 无正式导航 | 默认隐藏、退役前调研 | +| `system_configuration` | workspace、feature flags、cutover | `feature_flags(updated_by)`、`cutover_scopes(updated_by)` | `FEATURE_FLAG_*`、`WORKSPACE_*` | 管理入口;cutover flag | 基础配置保留;cutover 退役前调研 | +| `integrations` | ERP/outbox 等集成运行 | `integration_outbox(system)` | `ERP_*` | 无日常入口 / `erp_sync` | ERP 已明确不接,退役前调研 | + +## smoke 判据 + +事务内对 actor 的 `employee_no`、邮箱本地部分和名称复用现有 helper 语义: + +- 邮箱本地部分以 `smoke-`、`smoke_`、`smoke.`、`oa-smoke`、`oa_smoke` 或 `oasmoke` 开头;最后一种是本次快照使用的保守扩展,只可能将极端命名的真人低估为 smoke,不会把 smoke 高估为真人。 +- 员工编号/标识以 `SMOKE`、`OSMOKE`、`WFULL`、`PRELAUNCH`、`LAUNCHAUTO`、`HRWDTEST` 开头; +- 名称或标识含“测试”或“试运行”。 + +匹配结果只输出聚合计数,不输出命中的账号或个人字段。 + +## system action / table 口径 + +- 强制 `system` 表:`notifications`、`ocr_results`、`wecom_approval_archives`、`wecom_checkin_records`、`integration_outbox`。 +- 其余记录有 actor 时按 human/smoke 分类;actor 为空或已无法关联时为 unknown。 +- 审计 actor 无法关联员工时为 unknown;审计 action 未映射到上述模块时不进入模块 activity,但认证成功单独输出全局真人/smoke actor 聚合。 + +## 明确排除 + +- `auth_sessions`、password/email challenge、password hash、token version; +- 附件正文、存储路径、合同/发票/银行/薪资金额和业务编号; +- `audit_logs.extra`、IP、user agent、原始企业微信 payload; +- migration、pgboss 内部表和动态 `information_schema` 扫描。 diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/portfolio-decision-matrix.md b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/portfolio-decision-matrix.md new file mode 100644 index 00000000..2c4b56f1 --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/portfolio-decision-matrix.md @@ -0,0 +1,131 @@ +# 内部 OA 生产使用度结论与减法路线图 + +## 1. 执行边界 + +- 生产身份、运行 SHA、migration 和回滚窗口 PRE 均通过;运行候选为 `origin/main`,其后本地变更不含 `client/src`、`server/src` 或 Compose 产品差异。 +- 既有部署 wrapper 曾在 provenance 阶段因本地文档 HEAD 领先运行候选而 fail-closed;当时只完成 identity/13 项聚合体检,未进入 migration 或普查。随后用 task-scoped PRE 精确绑定已部署 `origin/main`,未放宽部署工具。 +- 唯一有效普查在一个 `READ ONLY` 事务内完成,20/20 模块 `queryStatus=PASS`,`effectiveQueryExecutions=1`、`zeroRetry=true`。 +- 第一次 `--execute` 在 stdin 入口条件处被拦截,`run()` 未调用、普查事务未开始;它记为 `preQueryInterceptions=1`,不计有效查询。 +- 本轮没有发布、登录、feature flag/导航修改、schema/data 写入或测试业务记录。 +- 完整脱敏计数见 `production-usage-snapshot.json`;本文只解释聚合,不包含个人、金额、正文或原始 payload。 +- 本次执行的邮箱 smoke 判据对无分隔符 `oasmoke…` 做了保守扩展;因此真人计数是下界。它不影响零记录模块,也不影响财务闭环已有真人证据的成立;未来复跑前可收紧为 helper 的精确分隔符规则。 + +## 2. 一句话结论 + +当前 OA **整体采用度极低,尚不能称为 5–10 人团队已经在稳定使用的办公系统**。问题不是继续补功能,而是管理层必须先决定:要求团队把明确流程真正迁入 OA,还是把 OA 收缩为低频台账和治理兜底;在该定位明确前停止产品功能开发。 + +| 核心指标 | 生产只读证据 | +| --- | ---: | +| 20 个模块聚合总行数 | 70(其中系统配置 26) | +| 零行模块 | 13 / 20 | +| 近 90 天真人归属/创建记录证据 | 13 | +| 近 90 天认证成功审计中的真人 actor | 1 | +| 近 30 天认证成功审计中的真人 actor | 1 | + +这里的 `13` 是可归属真人的记录证据,不等于可精确还原的 13 次页面点击;认证审计只观察到 1 个真人 actor,也不排除代理提交或审计覆盖外的历史使用。两项口径都指向同一结论:采用度极低,而不是已形成全员稳定使用。 + +## 3. 关键观察 + +### 3.1 只有一条可证明的真人业务链 + +- `reimbursements_invoices`:13 条聚合记录中,近 30/90 天均有 5 条真人记录证据、2 个真人 actor;另有 4 条 smoke、4 条 system,说明不去污染会明显高估使用度。 +- `workflow_approvals`:2 条真人记录、1 个真人 actor,审计交叉验证 6 条真人 action。 +- `payments_ap`:3 条真人记录、1 个真人 actor,审计交叉验证 7 条真人 action。 +- 三者合计 18 条业务记录、10 条真人归属/创建记录证据,是当前唯一可证明的端到端财务链。它仍更接近少量流程验证或零星使用,不能据此声称团队已采用;三者也不应拆成三个并行产品路线。 + +### 3.2 自动化比真人活动更多 + +- 20 个模块近 30 天合计:真人记录证据 13、smoke 4、system 24、unknown 14。 +- 工作台/通知的 21 条记录没有真人交互证据;其中近 30 天的 18 条、近 90 天的 21 条均归为 system。企微请假留存的 3 条记录也全部是 system 证据。它们证明后台机制工作,不证明用户在 OA 页面主动使用;本次查询未统计通知 `read_at`,因此也不把它解释为“已证明没人看”。 +- 系统配置的 26 条记录中近 30 天只有 1 条真人、14 条 unknown;配置表初始化/历史更新时间不能解释成管理员日常使用。 + +### 3.3 主导航存在明确噪音 + +生产 workspace 将公告、普通待办、周报和项目都暴露在 `main`,对应 flag 为 on 或无总开关,但四者 90 天均为 0 记录、0 真人活动、0 审计活动。 + +其中公告、普通待办和周报还偏离当前代码默认层级(默认分别为 admin、hidden、admin)。这不是产品代码故障,而是生产 workspace 配置保留了历史扩张期入口。 + +### 3.4 零使用不等于同一种结论 + +- 合同、采购、资产虽均为 0,但有低频高失败成本、附件/审计和离职交接价值,应保留最小能力并冻结开发。 +- 周报、普通待办、公告和项目存在企微群、项目文档或直接沟通等便宜替代,当前主导航暴露没有生产证据支撑。 +- 薪资、绩效、预算、完整差旅和 ERP 集成同时满足“零数据/零真人活动 + 无日常入口或关闭 + 有可靠替代”,可进入退役前调研。 + +### 3.5 下一步是管理决策,不是工程猜测 + +- 近 30/90 天认证成功审计只观察到 1 个真人 actor、5/6 次真人登录;smoke 登录为 11 次。 +- 该数字不能单独证明其他员工从未使用(可能存在代理提交或审计覆盖差异),但足以否定“系统已经被全员稳定使用”的假设。 +- “功能不够好导致没人用”没有生产证据支持。请假、考勤本来由企微主办,其他零使用模块也可能只是业务未发生或流程仍在线下。 +- 管理层必须选择 OA 的实际定位:A 为要求团队把明确业务迁入 OA;B 为不强推日常使用、收缩成低频台账和治理兜底。该选择属于组织流程,不应伪装成代码需求。 +- 无论选择 A 或 B,先隐藏四个零使用主导航入口都成立;但隐藏动作必须另建 task 并单独批准,本任务只提供证据。 + +## 4. 四类模块矩阵 + +| 模块 | 生产证据(30/90 天) | 当前暴露 | 分类 | 理由与后续触发 | +| --- | --- | --- | --- | --- | +| 报销/票据/OCR | 真人 5/5、2 actors;smoke 4、system 4 | main | **重点优化(财务闭环)** | 唯一有多人真人记录的主流程;先收集实际报销人/财务的摩擦点,再开小任务 | +| 统一审批 | 真人 2/2、1 actor;审计真人 6/6 | admin | **重点优化(同一闭环)** | 与报销/付款合并看待,不单独扩建通用 BPM | +| 付款/AP | 真人 3/3、1 actor;审计真人 7/7 | admin/on | **重点优化(同一闭环)** | maker-checker 与付款证据不可省;只优化真实闭环痛点 | +| 工作台/通知 | system 18/21,真人 0 | main/on | **保持冻结** | 是真实流程的必要通知基础,但记录量不能证明页面价值;不扩成大门户 | +| 采购 | 0/0 | main | **保持冻结** | 低频金额/附件/审批留痕有价值;建议收进二级入口,首个真实采购触发只读验收,不继续做复杂招采 | +| 合同 | 0/0 | main/on | **保持冻结** | 低频高失败成本;建议收进二级入口,首条生产合同仍是 UI 验收触发条件,需业务确认是否应录未录 | +| 资产/盘点 | 0/0 | main | **保持冻结** | 设备、电子资产和离职交接必要;建议收进二级入口,首批资产建档前不扩建盘点能力 | +| 请假/企微历史 | system 2/3,真人 0 | main | **保持冻结** | 企微主办、OA 留存;不把系统同步当 OA 页面使用,不重造请假流程 | +| 考勤/团队状态 | 0/0 | hidden/on | **保持冻结** | 企微主办且当前已隐藏;保留留存/兜底边界,不扩建 OA 原生打卡 | +| 人员生命周期 | 真人 2/2、1 actor;审计真人 51/51 | admin/on | **保持冻结** | 账号、入离职、角色和主管是基础,但高审计量含近期账号治理,不代表 HR 套件高频使用 | +| 系统配置 | 真人 1/1、unknown 14/25 | admin | **保持冻结** | 权限、workspace 和开关必要;保持后台化,工具债触达时再修 | +| 公告 | 0/0 | main/on | **隐藏入口** | 小团队企微群可替代;需要制度签收时按事件启用,数据与路由先保留 | +| 普通待办 | 0/0 | main/on | **隐藏入口** | 与审批待办、企微消息和项目事项重复;先恢复 hidden,不立即删表/路由 | +| 周报 | 0/0 | main/on | **隐藏入口** | 无提交和阅读责任人证据;周会/项目纪要可替代,先恢复 admin/hidden | +| 项目 | 0/0 | main/on | **隐藏入口** | 当前没有主档或关联数据;保留最小代码,真实项目关联需求出现时再开放 | +| 预算 | 0/0 | admin | **候选退役** | 小团队可由报销/采购审批和表格控制;先确认无预算负责人及保留义务 | +| 完整差旅 | 0/0 | admin | **候选退役** | 当前报销闭环可承接差旅费用;先确认无统付/卡交易真实流程 | +| 薪资 | 0/0 | 无导航 | **候选退役** | 高敏低频,财务/代账与受控表格更合适;物理删除前确认无保留数据 | +| 绩效 | 0/0 | 无导航 | **候选退役** | 5–10 人更适合直接反馈;无周期、无记录、无负责人证据 | +| ERP/集成 | 主表 0;仅 1 条真人“跳过/关闭”类审计 | 无导航/off | **候选退役** | ERP 已明确不接,关闭审计不是业务使用;后续检查依赖/outbox/保留后分批移除 | + +## 5. 后续决策与行动顺序 + +### 先做共同动作 + +1. **暂停产品功能开发**:在管理层明确选择 A 或 B 前,不以补功能、修视觉或完善测试矩阵解释采用度问题;真实线上缺陷和安全/恢复底线不在冻结范围内。 +2. **另批可逆导航减法**:建立独立 Trellis task,将公告、普通待办、周报、项目从主导航隐藏;合同、采购、资产保留最小能力但评估收进二级入口。保留路由、数据和恢复路径,生产 workspace/flag 写入另行批准。本任务不创建该 task。 + +### 管理层二选一 + +**A:要求团队真正使用 OA。** 选择一个明确且近期必然发生的流程作为团队规则;最小建议是下一轮报销统一从 OA 提交,不再用微信消息替代。目标不是先优化页面,而是让 5–10 人产生一次真实使用样本,再按实际阻塞点决定是否开一个小任务。若推动失败,先记录组织/流程原因,不默认归因于产品。 + +**B:不强推,OA 作为低频台账和治理兜底。** 大幅收缩日常入口与维护面;保留报销/付款、合同、采购、资产、认证、权限、审计、备份恢复和离职撤权等必要能力,冻结或分批审查预算、完整差旅、薪资、绩效、ERP/集成等超出实际需要的模块。 + +### 产品开发门禁 + +- A/B 未明确前,不启动新产品功能 task。 +- 选择 A 后,只有团队真实使用暴露出的最高优先级摩擦点可进入小型产品 task;不预先猜需求。 +- 选择 B 后,默认只做减法、必要维护和安全恢复,不再追求通用 OA 的功能完整度。 +- 低优先工具债只有再次触达对应工具时才处理,不挤占真实业务或减法预算。 +- 认证、审计、权限、付款 maker-checker、备份恢复和离职撤权继续作为安全底线,不用低使用频率否定。 + +### 明确不做 + +- 不新增 OA 模块、通用工作流/BPM、BI 看板、ERP、AI 助手或完整 HR/薪资/绩效套件。 +- 不为零使用模块继续补测试矩阵、视觉完善或“功能完整度”。 +- 不主动处理 SHA 比较器、repair helper 等低优先工具债;只有再次触达对应工具时才顺手纳入门禁。 +- 不因本次零数据物理删除表、路由或历史证据。 + +## 6. 后续独立 task 候选 + +| 优先级 | 候选 task | 触发/输入 | 验收 | 生产批准 | +| --- | --- | --- | --- | --- | +| P1 | 小团队主导航减法 | 用户接受本矩阵;另行批准建 task | 四个零使用入口不再占主导航;合同/采购/资产转二级入口的范围另审;直达/恢复能力保留 | 需要,workspace/flag 写入 | +| P1 | OA 采用模式决策 | 管理层选择 A 或 B | 事实源明确强推使用或台账兜底;选择前产品开发保持冻结 | 无生产动作 | +| P2 | 财务闭环单点优化 | 仅限选择 A 且团队真实使用产生明确摩擦点 | 只解决排名第一的问题;公网部署后才能通知测试 | 代码/部署时分别批准 | +| P2 | 低频业务落地确认 | 负责人回答采购/合同/资产/差旅/预算事实 | 更新既有事实源或入口决策,不造生产数据 | 只读确认无需;配置变更需要 | +| P3 | 退役前依赖与保留审查 | 再次触达预算/差旅/薪资/绩效/ERP 代码 | 数据导出、审计保留、依赖、回滚均明确 | 删除/迁移另批 | + +## 7. 仍需业务负责人回答 + +- 过去 90 天是否签过合同、发生采购或新增/转移资产;若发生,为什么未进入 OA? +- 实际报销人、审批人和财务是否都知道并愿意使用当前 OA 主流程? +- 是否存在必须由 OA 承担的预算、差旅统付、薪资或绩效流程及明确负责人? + +在 A/B 定位和这些业务事实得到回答前,相关模块保持冻结或候选状态,不做写入、不扩大范围。 diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/production-usage-snapshot.json b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/production-usage-snapshot.json new file mode 100644 index 00000000..e647c3c2 --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/production-usage-snapshot.json @@ -0,0 +1,677 @@ +{ + "status": "PASS", + "evidenceBoundary": "production_aggregate_read_only", + "classificationBoundary": "human counts are a conservative lower bound because production v1 also treated an unseparated oasmoke email prefix as smoke-like", + "preflight": { + "exactIdentity": true, + "runtimeBuildExact": true, + "deployedSource": "origin/main", + "productCodeDelta": 0, + "migrationPending": 0, + "rollbackWindow": "OPEN", + "terminatedRows": 0 + }, + "execution": { + "preflightToolInterceptions": 1, + "preflightToolReason": "deploy wrapper compared current documentation HEAD instead of the deployed source", + "preQueryInterceptions": 1, + "preQueryReason": "stdin_entrypoint_not_invoked", + "effectiveQueryExecutions": 1, + "zeroRetry": true, + "transactionReadOnly": true + }, + "sourceBuildSha": "ec682565885a", + "generatedAt": "2026-08-11T12:47:38.127Z", + "authentication": { + "humanActors30d": 1, + "humanActors90d": 1, + "humanEvents30d": 5, + "humanEvents90d": 6, + "smokeEvents30d": 11, + "smokeEvents90d": 11, + "unknownEvents30d": 0, + "unknownEvents90d": 0 + }, + "modules": [ + { + "moduleKey": "announcements", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "on", + "queryStatus": "PASS" + }, + { + "moduleKey": "assets_inventory", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + }, + { + "moduleKey": "attendance_presence", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "hidden", + "effectiveFlagState": "on", + "queryStatus": "PASS" + }, + { + "moduleKey": "budgets", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "admin", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + }, + { + "moduleKey": "contracts", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "on", + "queryStatus": "PASS" + }, + { + "moduleKey": "integrations", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 1, + "auditEvents90d": 1, + "auditHumanActors30d": 1, + "auditHumanActors90d": 1, + "auditHumanEvents30d": 1, + "auditHumanEvents90d": 1, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "no_navigation", + "effectiveFlagState": "off", + "queryStatus": "PASS" + }, + { + "moduleKey": "leave_wecom_history", + "totalRows": 3, + "created30d": 2, + "created90d": 3, + "activity30d": 2, + "activity90d": 3, + "latestActivityAt": "2026-08-06T08:00:27.065Z", + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 2, + "systemEvents90d": 3, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + }, + { + "moduleKey": "payments_ap", + "totalRows": 3, + "created30d": 3, + "created90d": 3, + "activity30d": 3, + "activity90d": 3, + "latestActivityAt": "2026-08-04T06:28:26.141Z", + "humanActors30d": 1, + "humanActors90d": 1, + "humanEvents30d": 3, + "humanEvents90d": 3, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 7, + "auditEvents90d": 7, + "auditHumanActors30d": 3, + "auditHumanActors90d": 3, + "auditHumanEvents30d": 7, + "auditHumanEvents90d": 7, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "admin", + "effectiveFlagState": "on", + "queryStatus": "PASS" + }, + { + "moduleKey": "payroll", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "no_navigation", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + }, + { + "moduleKey": "people_lifecycle", + "totalRows": 2, + "created30d": 2, + "created90d": 2, + "activity30d": 2, + "activity90d": 2, + "latestActivityAt": "2026-07-27T11:59:12.669Z", + "humanActors30d": 1, + "humanActors90d": 1, + "humanEvents30d": 2, + "humanEvents90d": 2, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 51, + "auditEvents90d": 51, + "auditHumanActors30d": 1, + "auditHumanActors90d": 1, + "auditHumanEvents30d": 51, + "auditHumanEvents90d": 51, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "admin", + "effectiveFlagState": "on", + "queryStatus": "PASS" + }, + { + "moduleKey": "performance", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "no_navigation", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + }, + { + "moduleKey": "portal_notifications", + "totalRows": 21, + "created30d": 18, + "created90d": 21, + "activity30d": 18, + "activity90d": 21, + "latestActivityAt": "2026-08-04T06:28:26.161Z", + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 18, + "systemEvents90d": 21, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "on", + "queryStatus": "PASS" + }, + { + "moduleKey": "procurement", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + }, + { + "moduleKey": "projects", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "on", + "queryStatus": "PASS" + }, + { + "moduleKey": "reimbursements_invoices", + "totalRows": 13, + "created30d": 13, + "created90d": 13, + "activity30d": 13, + "activity90d": 13, + "latestActivityAt": "2026-08-05T03:07:41.581Z", + "humanActors30d": 2, + "humanActors90d": 2, + "humanEvents30d": 5, + "humanEvents90d": 5, + "smokeEvents30d": 4, + "smokeEvents90d": 4, + "systemEvents30d": 4, + "systemEvents90d": 4, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 107, + "auditEvents90d": 107, + "auditHumanActors30d": 4, + "auditHumanActors90d": 4, + "auditHumanEvents30d": 99, + "auditHumanEvents90d": 99, + "auditSmokeEvents30d": 8, + "auditSmokeEvents90d": 8, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + }, + { + "moduleKey": "system_configuration", + "totalRows": 26, + "created30d": 0, + "created90d": 26, + "activity30d": 15, + "activity90d": 26, + "latestActivityAt": "2026-08-10T13:04:19.295Z", + "humanActors30d": 1, + "humanActors90d": 1, + "humanEvents30d": 1, + "humanEvents90d": 1, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 14, + "unknownEvents90d": 25, + "auditEvents30d": 1, + "auditEvents90d": 1, + "auditHumanActors30d": 1, + "auditHumanActors90d": 1, + "auditHumanEvents30d": 1, + "auditHumanEvents90d": 1, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "admin", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + }, + { + "moduleKey": "todos", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "on", + "queryStatus": "PASS" + }, + { + "moduleKey": "travel", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "admin", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + }, + { + "moduleKey": "weekly_reports", + "totalRows": 0, + "created30d": 0, + "created90d": 0, + "activity30d": 0, + "activity90d": 0, + "latestActivityAt": null, + "humanActors30d": 0, + "humanActors90d": 0, + "humanEvents30d": 0, + "humanEvents90d": 0, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 0, + "auditEvents90d": 0, + "auditHumanActors30d": 0, + "auditHumanActors90d": 0, + "auditHumanEvents30d": 0, + "auditHumanEvents90d": 0, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "main", + "effectiveFlagState": "on", + "queryStatus": "PASS" + }, + { + "moduleKey": "workflow_approvals", + "totalRows": 2, + "created30d": 2, + "created90d": 2, + "activity30d": 2, + "activity90d": 2, + "latestActivityAt": "2026-08-01T13:47:24.242Z", + "humanActors30d": 1, + "humanActors90d": 1, + "humanEvents30d": 2, + "humanEvents90d": 2, + "smokeEvents30d": 0, + "smokeEvents90d": 0, + "systemEvents30d": 0, + "systemEvents90d": 0, + "unknownEvents30d": 0, + "unknownEvents90d": 0, + "auditEvents30d": 6, + "auditEvents90d": 6, + "auditHumanActors30d": 1, + "auditHumanActors90d": 1, + "auditHumanEvents30d": 6, + "auditHumanEvents90d": 6, + "auditSmokeEvents30d": 0, + "auditSmokeEvents90d": 0, + "auditUnknownEvents30d": 0, + "auditUnknownEvents90d": 0, + "navigationMode": "admin", + "effectiveFlagState": "not_applicable", + "queryStatus": "PASS" + } + ] +} diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/read-only-review.md b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/read-only-review.md new file mode 100644 index 00000000..d7683813 --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/read-only-review.md @@ -0,0 +1,52 @@ +# 生产使用度普查只读 review + +## 结论 + +`PASS_WITH_DECLARED_LIMITS`。生产查询、证据脱敏和四类裁决均在批准边界内;声明的两项测量限制不改变“财务闭环是唯一重点产品候选、四个主导航入口可隐藏、五类能力进入退役前调研”的结论。 + +## 1. 生产身份与执行次数 + +- identity bootstrap 先证明唯一运行环境和 origin/runtime agreement。 +- 既有 deploy wrapper 在 provenance 阶段因本地文档 HEAD 领先运行候选而 fail-closed;未进入 migration 或普查。 +- task-scoped PRE 改为精确比较运行容器 SHA 与 `origin/main`,并断言其后 `client/src`、`server/src`、Compose 产品差异为 0;没有放宽或修改正式部署工具。 +- migration pending=none,rollback window OPEN、terminatedRows=0。 +- 第一次 query runner 的 `node -` 入口没有调用 `run()`;本地复现证明 `require.main === module` 为 false,输出无 JSON、普查事务未开始。修正后用相同 stdin 入口在本地通过,再进行唯一有效生产执行。 +- 最终证据为 `preflightToolInterceptions=1`、`preQueryInterceptions=1`、`effectiveQueryExecutions=1`、`zeroRetry=true`。不存在未知状态下盲目重跑。 + +## 2. 只读与写入边界 + +- 查询器在第一批数据库语句中执行 `BEGIN TRANSACTION READ ONLY`、statement timeout、lock timeout,并断言 `transaction_read_only=on`。 +- module metrics、认证聚合、workspace setting 和 feature flag evaluator 共用同一 `pg` client/事务;adapter 只实现 `findUnique/findMany/findFirst` 三种 SELECT。 +- 静态合同拒绝 INSERT/UPDATE/DELETE/MERGE/DDL/CALL/COPY/GRANT/REVOKE 和 `FOR UPDATE`。 +- 生产未部署查询器;文件通过 stdin 进入现有 server 容器。没有登录、配置、schema、业务数据或审计写入。 +- 20/20 模块 `queryStatus=PASS`;record 分桶与 activity 总数、audit 分桶与 audit 总数逐模块/窗口自洽。 + +## 3. 隐私与归因 + +- 输出字段为固定 30 项模块聚合,另有安全认证聚合;不存在 ID、姓名、员工号、邮箱、手机号、金额、正文、附件、IP/user agent、audit extra 或 payload。 +- smoke、system 和 unknown 没有并入真人计数;生产结果清楚显示 system/unknown 活动多于真人活动,证明去污染改变了产品判断。 +- `humanEvents` 表示“由真人 actor 拥有/创建的记录证据”,不是精确点击或 mutation 次数;可变记录的系统更新可能刷新 `activityAt`。矩阵因此同时引用独立 audit 聚合,并使用“真人记录证据”措辞,没有把它写成操作次数。 +- 本次生产版本的邮箱 smoke 判据对无分隔符 `oasmoke…` 是 helper 的保守超集。它只可能降低真人计数,不会把 smoke 当真人;财务闭环仍有非 smoke 真人证据,零记录模块不受影响。该限制已写入 snapshot 和矩阵。 + +## 4. 分类抽查 + +- 真人样本:报销/票据 2 个真人 actor、5 条真人记录证据;审批和付款分别有真人记录与 audit 交叉验证,合并为一条财务闭环。 +- smoke 样本:报销/票据近 30 天 4 条 smoke 记录、8 条 smoke audit;未计入真人使用。 +- system 样本:工作台/通知近 30 天 18 条 system,真人 0;未因记录量高判为活跃页面。 +- unknown 样本:系统配置近 30 天 14 条 unknown;矩阵只将其视为配置历史,不推断管理员高频使用。 +- 零行边界:合同/采购/资产因低频高失败成本保持冻结;周报/普通待办/公告/项目因主导航暴露、零证据和便宜替代进入隐藏入口;没有“零行自动退役”。 +- flag 边界:辅助 invoice verification、流程定义和预算 precheck 没有冒充整个业务模块开关;有效主模块 exposure 已在本地解释层纠正,无需重查生产。 + +## 5. 路线图复核 + +- 重点产品候选只有一条:报销 → 审批 → 付款/AP。 +- 未来 30 天最多从下一笔真实业务的真人反馈中启动一个小任务;无反馈则不写产品代码。 +- 四入口导航减法是独立、可逆且需生产批准的后续 task;本任务没有提前执行。 +- 预算、差旅、薪资、绩效、ERP 仅为候选退役,仍需负责人、数据保留、依赖和回滚审查。 +- 低优先部署/repair 工具债不占用近期产品预算,触达时再处理。 + +## 6. Reviewer 判定 + +- AC1–AC7:证据满足。 +- AC8 的只读 review 部分:满足。 +- 稳定产品组合结论是否写入长期事实源:等待用户接受本矩阵;在此之前保持 task 证据,不抢先改 docs/spec。 diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/run-production-usage-census.sh b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/run-production-usage-census.sh new file mode 100644 index 00000000..08fb5b13 --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/run-production-usage-census.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +TASK_RESEARCH_DIR="$ROOT_DIR/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research" +CENSUS_SCRIPT="$TASK_RESEARCH_DIR/usage-census.cjs" +IDENTITY_MODULE="$ROOT_DIR/deploy/trialrun/production-identity-bootstrap.mjs" +IDENTITY_REMOTE="$ROOT_DIR/deploy/trialrun/production-identity-bootstrap-remote.sh" +PREFLIGHT_SCRIPT="$ROOT_DIR/deploy/trialrun/production-preflight.mjs" +CONTRACT_LIB="$ROOT_DIR/deploy/trialrun/deployment-contract-lib.sh" +CONTRACT_UNIT="hyb-oa-automatic-backup-daily.service" +CONFIRMATION="READ_ONLY_USAGE_CENSUS" + +# shellcheck source=../../../../../deploy/trialrun/deployment-contract-lib.sh +source "$CONTRACT_LIB" + +fail() { + printf '[usage-census-runner][FAILED] code=%s\n' "$1" >&2 + exit 1 +} + +shell_quote() { + printf "'%s'" "${1//\'/\'\\\'\'}" +} + +mode="${1:---preflight}" +case "$mode" in + --preflight) + [[ "$#" -eq 1 ]] || fail "UNSUPPORTED_ARGUMENTS" + ;; + --execute) + [[ "$#" -eq 2 && "${2:-}" == "$CONFIRMATION" ]] \ + || fail "EXPLICIT_CONFIRMATION_REQUIRED" + ;; + *) + fail "UNSUPPORTED_ARGUMENTS" + ;; +esac + +for file in "$CENSUS_SCRIPT" "$IDENTITY_MODULE" "$IDENTITY_REMOTE" "$PREFLIGHT_SCRIPT"; do + [[ -f "$file" && ! -L "$file" ]] || fail "LOCAL_CONTRACT_MISSING" +done + +cd "$ROOT_DIR" + +# Reuse the reviewed history parsing and runbook-origin parser. Values remain +# in process memory and are never printed. +identity_fields="$({ + node --input-type=module - "$IDENTITY_MODULE" <<'NODE' +import fs from "node:fs"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.argv[2]; +const mod = await import(pathToFileURL(modulePath)); +const targetSource = execFileSync("git", ["show", "34181e3:deploy/trialrun/deploy.sh"], { encoding: "utf8" }); +const projectSource = execFileSync("git", ["show", "5e2d038938265c926e06d3cc6d0841f6dcc06148:deploy/trialrun/deploy.sh"], { encoding: "utf8" }); +const identity = mod.parseHistoricalDeploymentIdentity(targetSource, projectSource); +const runbook = fs.readFileSync("docs/trialrun/OPS7运维交接Runbook.md", "utf8"); +const origin = mod.parseExpectedProductionOrigin(runbook); +process.stdout.write(`${identity.deployServer}\t${identity.composeProjectName}\t${origin}`); +NODE +} 2>/dev/null)" || fail "LOCAL_IDENTITY_PARSE_FAILED" +IFS=$'\t' read -r deploy_server expected_project expected_origin <<<"$identity_fields" +deployment_contract_safe_ssh_target "$deploy_server" || fail "DEPLOY_SERVER_INVALID" +deployment_contract_safe_project "$expected_project" || fail "COMPOSE_PROJECT_INVALID" +[[ "$expected_origin" == https://* ]] || fail "PRODUCTION_ORIGIN_INVALID" + +# Identity-only mode exits before database and migration reads. It also proves +# the unique forced-command refresh identity and origin/runtime agreement. +PRODUCTION_IDENTITY_ONLY=1 node "$IDENTITY_MODULE" \ + || fail "PRODUCTION_IDENTITY_FAILED" + +declare -A contract=() +deployment_contract_load "$deploy_server" "$CONTRACT_UNIT" contract \ + || fail "SYSTEMD_CONTRACT_UNAVAILABLE" +[[ "${contract[COMPOSE_PROJECT_NAME]}" == "$expected_project" ]] \ + || fail "COMPOSE_PROJECT_MISMATCH" + +origin_hash="$(printf '%s' "$expected_origin" | sha256sum | awk '{print $1}')" +database_output="$( + ssh "$deploy_server" \ + "IDENTITY_BOOTSTRAP_MODE='database' REMOTE_DIR=$(shell_quote "${contract[REMOTE_DIR]}") REMOTE_ENV_FILE=$(shell_quote "${contract[REMOTE_ENV_FILE]}") COMPOSE_FILE=$(shell_quote "${contract[COMPOSE_FILE]}") COMPOSE_PROJECT_NAME=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") EXPECTED_PRODUCTION_ORIGIN_SHA256='$origin_hash' bash -s" \ + < "$IDENTITY_REMOTE" +)" || fail "DATABASE_IDENTITY_FAILED" +database_hash="$(printf '%s' "$database_output" | sed -n 's/^\[production-identity-bootstrap\]\[DB_SHA256\] \([0-9a-f]\{64\}\)$/\1/p')" +[[ "$database_hash" =~ ^[0-9a-f]{64}$ ]] || fail "DATABASE_IDENTITY_PAYLOAD_INVALID" + +candidate_commit="$(git rev-parse origin/main)" +[[ "$candidate_commit" =~ ^[0-9a-f]{40,64}$ ]] || fail "DEPLOYED_COMMIT_INVALID" +[[ -z "$(git diff --name-only "$candidate_commit"..HEAD -- client/src server/src deploy/trialrun/docker-compose.yml)" ]] \ + || fail "PRODUCT_DELTA_AFTER_DEPLOYED_COMMIT" + +runtime_check_command=" +set -euo pipefail +server_ids=\"\$(docker ps --filter label=com.docker.compose.project=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") --filter label=com.docker.compose.service=server --format '{{.ID}}')\" +[[ \"\$(printf '%s\\n' \"\$server_ids\" | sed '/^\$/d' | wc -l)\" -eq 1 ]] +runtime_sha=\"\$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' \"\$server_ids\" | sed -n 's/^BUILD_SHA=//p' | tail -n 1)\" +[[ \"\$runtime_sha\" == $(shell_quote "$candidate_commit") ]] +printf '[usage-census-runner][PASS] runtime_build_exact\\n' +" +ssh "$deploy_server" "bash -c $(shell_quote "$runtime_check_command")" \ + || fail "RUNTIME_BUILD_MISMATCH" + +report_dir="$(mktemp -d)" +trap 'rm -rf -- "$report_dir"' EXIT +EXPECTED_PRODUCTION_ORIGIN="$expected_origin" \ +EXPECTED_PRODUCTION_DB_NAME_SHA256="$database_hash" \ +EXPECTED_PENDING_MIGRATIONS="" \ +EXPECTED_DEPLOY_SERVER="$deploy_server" \ +EXPECTED_REMOTE_DIR="${contract[REMOTE_DIR]}" \ +EXPECTED_COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \ +DEPLOY_SERVER="$deploy_server" \ +REMOTE_DIR="${contract[REMOTE_DIR]}" \ +REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ +COMPOSE_FILE="${contract[COMPOSE_FILE]}" \ +COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \ +POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \ +STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \ +CANDIDATE_COMMIT="$candidate_commit" \ +PRODUCTION_PREFLIGHT_REPORT_DIR="$report_dir/preflight" \ +node "$PREFLIGHT_SCRIPT" \ + || fail "PRODUCTION_PREFLIGHT_FAILED" + +printf '[usage-census-runner][PASS] preflight deployedSource=origin/main productDelta=0\n' + +if [[ "$mode" == "--preflight" ]]; then + printf '[usage-census-runner][PASS] read_only_preflight_complete queryExecuted=0\n' + exit 0 +fi + +remote_query_command=" +set -euo pipefail +server_ids=\"\$(docker ps --filter label=com.docker.compose.project=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") --filter label=com.docker.compose.service=server --format '{{.ID}}')\" +[[ \"\$(printf '%s\\n' \"\$server_ids\" | sed '/^\$/d' | wc -l)\" -eq 1 ]] +docker exec -i -w /app/server \"\$server_ids\" node - +" +query_output="$(ssh "$deploy_server" "bash -c $(shell_quote "$remote_query_command")" < "$CENSUS_SCRIPT")" \ + || fail "QUERY_EXECUTION_FAILED" + +validated_output="$(printf '%s' "$query_output" | node -e ' +const fs = require("node:fs"); +const expected = process.argv[1]; +const input = fs.readFileSync(0, "utf8"); +const value = JSON.parse(input); +if (value.status !== "PASS" || value.transactionReadOnly !== true) process.exit(1); +if (value.sourceBuildSha !== expected.slice(0, 12)) process.exit(1); +if (!Array.isArray(value.modules) || value.modules.length !== 20) process.exit(1); +if (value.modules.some((item) => item.queryStatus !== "PASS")) process.exit(1); +process.stdout.write(JSON.stringify(value, null, 2)); +' "$candidate_commit")" || fail "QUERY_OUTPUT_INVALID" + +printf '%s\n' "$validated_output" +printf '[usage-census-runner][PASS] queryExecuted=1 zeroRetry=1\n' >&2 diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/usage-census.cjs b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/usage-census.cjs new file mode 100644 index 00000000..5f17a203 --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/usage-census.cjs @@ -0,0 +1,430 @@ +"use strict"; + +const path = require("node:path"); + +const MODULE_KEYS = Object.freeze([ + "portal_notifications", + "workflow_approvals", + "reimbursements_invoices", + "payments_ap", + "procurement", + "contracts", + "projects", + "assets_inventory", + "leave_wecom_history", + "attendance_presence", + "weekly_reports", + "todos", + "announcements", + "budgets", + "travel", + "people_lifecycle", + "payroll", + "performance", + "system_configuration", + "integrations", +]); + +const SAFE_RESULT_KEYS = Object.freeze([ + "moduleKey", + "totalRows", + "created30d", + "created90d", + "activity30d", + "activity90d", + "latestActivityAt", + "humanActors30d", + "humanActors90d", + "humanEvents30d", + "humanEvents90d", + "smokeEvents30d", + "smokeEvents90d", + "systemEvents30d", + "systemEvents90d", + "unknownEvents30d", + "unknownEvents90d", + "auditEvents30d", + "auditEvents90d", + "auditHumanActors30d", + "auditHumanActors90d", + "auditHumanEvents30d", + "auditHumanEvents90d", + "auditSmokeEvents30d", + "auditSmokeEvents90d", + "auditUnknownEvents30d", + "auditUnknownEvents90d", + "navigationMode", + "effectiveFlagState", + "queryStatus", +]); + +const USAGE_SQL = String.raw` +WITH module_keys(module_key) AS ( + VALUES + ('portal_notifications'), ('workflow_approvals'), + ('reimbursements_invoices'), ('payments_ap'), ('procurement'), + ('contracts'), ('projects'), ('assets_inventory'), + ('leave_wecom_history'), ('attendance_presence'), ('weekly_reports'), + ('todos'), ('announcements'), ('budgets'), ('travel'), + ('people_lifecycle'), ('payroll'), ('performance'), + ('system_configuration'), ('integrations') +), record_events(module_key, record_id, created_at, activity_at, actor_id, forced_kind) AS ( + SELECT 'portal_notifications', id::text, created_at, created_at, recipient_employee_id, 'system' FROM notifications + UNION ALL SELECT 'workflow_approvals', id::text, created_at, updated_at, submitter_id, NULL FROM workflow_instances + UNION ALL SELECT 'reimbursements_invoices', id::text, created_at, updated_at, owner_id, NULL FROM reimbursement_forms + UNION ALL SELECT 'reimbursements_invoices', id::text, created_at, updated_at, uploaded_by_employee_id, NULL FROM invoice_pool + UNION ALL SELECT 'reimbursements_invoices', id::text, created_at, created_at, NULL::uuid, 'system' FROM ocr_results + UNION ALL SELECT 'payments_ap', id::text, created_at, updated_at, created_by, NULL FROM payment_batches + UNION ALL SELECT 'payments_ap', id::text, created_at, created_at, uploaded_by, NULL FROM payment_batch_receipt_attachments + UNION ALL SELECT 'procurement', id::text, created_at, updated_at, applicant_id, NULL FROM purchase_requests + UNION ALL SELECT 'contracts', id::text, created_at, updated_at, owner_employee_id, NULL FROM contract_registry + UNION ALL SELECT 'projects', id::text, created_at, updated_at, owner_employee_id, NULL FROM projects + UNION ALL SELECT 'assets_inventory', id::text, created_at, updated_at, created_by, NULL FROM asset_cards + UNION ALL SELECT 'assets_inventory', id::text, created_at, updated_at, created_by, NULL FROM inventory_checks + UNION ALL SELECT 'leave_wecom_history', id::text, created_at, updated_at, owner_id, NULL FROM leave_requests + UNION ALL SELECT 'leave_wecom_history', id::text, created_at, updated_at, applicant_employee_id, 'system' FROM wecom_approval_archives + UNION ALL SELECT 'attendance_presence', id::text, created_at, updated_at, owner_id, NULL FROM attendance_corrections + UNION ALL SELECT 'attendance_presence', id::text, created_at, updated_at, employee_id, 'system' FROM wecom_checkin_records + UNION ALL SELECT 'attendance_presence', id::text, created_at, updated_at, updated_by, NULL FROM employee_presence + UNION ALL SELECT 'weekly_reports', id::text, created_at, updated_at, employee_id, NULL FROM weekly_reports + UNION ALL SELECT 'todos', id::text, created_at, updated_at, created_by, NULL FROM todo_items + UNION ALL SELECT 'announcements', id::text, created_at, updated_at, created_by, NULL FROM portal_announcements + UNION ALL SELECT 'announcements', id::text, created_at, COALESCE(acknowledged_at, created_at), actor_id, NULL FROM portal_announcement_acknowledgements + UNION ALL SELECT 'budgets', id::text, created_at, updated_at, created_by, NULL FROM budget_pools + UNION ALL SELECT 'travel', id::text, created_at, updated_at, applicant_employee_id, NULL FROM travel_requests + UNION ALL SELECT 'travel', id::text, created_at, updated_at, created_by, NULL FROM travel_orders + UNION ALL SELECT 'people_lifecycle', id::text, created_at, created_at, created_by, NULL FROM employee_lifecycle_events + UNION ALL SELECT 'people_lifecycle', id::text, created_at, updated_at, created_by, NULL FROM employee_contracts + UNION ALL SELECT 'people_lifecycle', id::text, created_at, updated_at, created_by, NULL FROM employee_trainings + UNION ALL SELECT 'payroll', id::text, created_at, updated_at, created_by, NULL FROM payslips + UNION ALL SELECT 'performance', id::text, created_at, updated_at, reviewer_employee_id, NULL FROM performance_reviews + UNION ALL SELECT 'system_configuration', id::text, created_at, updated_at, updated_by, NULL FROM feature_flags + UNION ALL SELECT 'system_configuration', id::text, created_at, updated_at, updated_by, NULL FROM cutover_scopes + UNION ALL SELECT 'integrations', id::text, created_at, updated_at, created_by, 'system' FROM integration_outbox +), classified_records AS ( + SELECT + r.*, + CASE + WHEN r.forced_kind = 'system' THEN 'system' + WHEN r.actor_id IS NULL OR e.id IS NULL THEN 'unknown' + WHEN lower(split_part(e.email, '@', 1)) ~ '^(smoke[-_.]|oa[-_.]?smoke[-_.]?)' + OR e.employee_no ~* '^(SMOKE|OSMOKE|WFULL|PRELAUNCH|LAUNCHAUTO|HRWDTEST)' + OR e.name ~ '(测试|试运行)' + THEN 'smoke' + ELSE 'human' + END AS actor_kind + FROM record_events r + LEFT JOIN employees e ON e.id = r.actor_id +), audit_mapped AS ( + SELECT + CASE + WHEN action ~ '^(APPROVAL_|DELEGATION_|WORKFLOW_)' THEN 'workflow_approvals' + WHEN action ~ '^(REIMBURSEMENT_|INVOICE_)' OR action = 'EXPORT_REIMBURSEMENTS' THEN 'reimbursements_invoices' + WHEN action ~ '^PAYMENT_' OR action = 'EXPORT_AP_OPERATIONS' THEN 'payments_ap' + WHEN action ~ '^PURCHASE_' THEN 'procurement' + WHEN action ~ '^CONTRACT_' OR action = 'EXPORT_CONTRACTS_REGISTRY' THEN 'contracts' + WHEN action ~ '^PROJECT_' THEN 'projects' + WHEN action ~ '^(ASSET_|INVENTORY_)' THEN 'assets_inventory' + WHEN action ~ '^(ATTENDANCE_|PRESENCE_)' THEN 'attendance_presence' + WHEN action ~ '^PORTAL_ANNOUNCEMENT_' THEN 'announcements' + WHEN action ~ '^BUDGET_' THEN 'budgets' + WHEN action ~ '^TRAVEL_' THEN 'travel' + WHEN action ~ '^(EMPLOYEE_|SYSTEM_USER|DEPARTMENT_)' THEN 'people_lifecycle' + WHEN action ~ '^(CREATE_SALARY_|UPDATE_SALARY_|UPSERT_EMPLOYEE_SALARY_|GENERATE_PAYSLIPS|ISSUE_PAYSLIPS|UPDATE_PAYSLIP|EXPORT_PAYSLIPS)' THEN 'payroll' + WHEN action ~ '^PERFORMANCE_' THEN 'performance' + WHEN action ~ '^(FEATURE_FLAG_|WORKSPACE_)' THEN 'system_configuration' + WHEN action ~ '^ERP_' THEN 'integrations' + ELSE NULL + END AS module_key, + a.actor_id, + a.created_at, + CASE + WHEN e.id IS NULL THEN 'unknown' + WHEN lower(split_part(e.email, '@', 1)) ~ '^(smoke[-_.]|oa[-_.]?smoke[-_.]?)' + OR e.employee_no ~* '^(SMOKE|OSMOKE|WFULL|PRELAUNCH|LAUNCHAUTO|HRWDTEST)' + OR e.name ~ '(测试|试运行)' + THEN 'smoke' + ELSE 'human' + END AS actor_kind + FROM audit_logs a + LEFT JOIN employees e ON e.id = a.actor_id +), record_metrics AS ( + SELECT + module_key, + count(*)::int AS total_rows, + count(*) FILTER (WHERE created_at >= now() - interval '30 days')::int AS created_30d, + count(*) FILTER (WHERE created_at >= now() - interval '90 days')::int AS created_90d, + count(*) FILTER (WHERE activity_at >= now() - interval '30 days')::int AS activity_30d, + count(*) FILTER (WHERE activity_at >= now() - interval '90 days')::int AS activity_90d, + max(activity_at) AS latest_activity_at, + count(DISTINCT actor_id) FILTER (WHERE actor_kind = 'human' AND activity_at >= now() - interval '30 days')::int AS human_actors_30d, + count(DISTINCT actor_id) FILTER (WHERE actor_kind = 'human' AND activity_at >= now() - interval '90 days')::int AS human_actors_90d, + count(*) FILTER (WHERE actor_kind = 'human' AND activity_at >= now() - interval '30 days')::int AS human_events_30d, + count(*) FILTER (WHERE actor_kind = 'human' AND activity_at >= now() - interval '90 days')::int AS human_events_90d, + count(*) FILTER (WHERE actor_kind = 'smoke' AND activity_at >= now() - interval '30 days')::int AS smoke_events_30d, + count(*) FILTER (WHERE actor_kind = 'smoke' AND activity_at >= now() - interval '90 days')::int AS smoke_events_90d, + count(*) FILTER (WHERE actor_kind = 'system' AND activity_at >= now() - interval '30 days')::int AS system_events_30d, + count(*) FILTER (WHERE actor_kind = 'system' AND activity_at >= now() - interval '90 days')::int AS system_events_90d, + count(*) FILTER (WHERE actor_kind = 'unknown' AND activity_at >= now() - interval '30 days')::int AS unknown_events_30d, + count(*) FILTER (WHERE actor_kind = 'unknown' AND activity_at >= now() - interval '90 days')::int AS unknown_events_90d + FROM classified_records + GROUP BY module_key +), audit_metrics AS ( + SELECT + module_key, + count(*) FILTER (WHERE created_at >= now() - interval '30 days')::int AS audit_events_30d, + count(*) FILTER (WHERE created_at >= now() - interval '90 days')::int AS audit_events_90d, + count(DISTINCT actor_id) FILTER (WHERE actor_kind = 'human' AND created_at >= now() - interval '30 days')::int AS audit_human_actors_30d, + count(DISTINCT actor_id) FILTER (WHERE actor_kind = 'human' AND created_at >= now() - interval '90 days')::int AS audit_human_actors_90d, + count(*) FILTER (WHERE actor_kind = 'human' AND created_at >= now() - interval '30 days')::int AS audit_human_events_30d, + count(*) FILTER (WHERE actor_kind = 'human' AND created_at >= now() - interval '90 days')::int AS audit_human_events_90d, + count(*) FILTER (WHERE actor_kind = 'smoke' AND created_at >= now() - interval '30 days')::int AS audit_smoke_events_30d, + count(*) FILTER (WHERE actor_kind = 'smoke' AND created_at >= now() - interval '90 days')::int AS audit_smoke_events_90d, + count(*) FILTER (WHERE actor_kind = 'unknown' AND created_at >= now() - interval '30 days')::int AS audit_unknown_events_30d, + count(*) FILTER (WHERE actor_kind = 'unknown' AND created_at >= now() - interval '90 days')::int AS audit_unknown_events_90d + FROM audit_mapped + WHERE module_key IS NOT NULL + GROUP BY module_key +) +SELECT + k.module_key AS "moduleKey", + COALESCE(r.total_rows, 0) AS "totalRows", + COALESCE(r.created_30d, 0) AS "created30d", + COALESCE(r.created_90d, 0) AS "created90d", + COALESCE(r.activity_30d, 0) AS "activity30d", + COALESCE(r.activity_90d, 0) AS "activity90d", + r.latest_activity_at AS "latestActivityAt", + COALESCE(r.human_actors_30d, 0) AS "humanActors30d", + COALESCE(r.human_actors_90d, 0) AS "humanActors90d", + COALESCE(r.human_events_30d, 0) AS "humanEvents30d", + COALESCE(r.human_events_90d, 0) AS "humanEvents90d", + COALESCE(r.smoke_events_30d, 0) AS "smokeEvents30d", + COALESCE(r.smoke_events_90d, 0) AS "smokeEvents90d", + COALESCE(r.system_events_30d, 0) AS "systemEvents30d", + COALESCE(r.system_events_90d, 0) AS "systemEvents90d", + COALESCE(r.unknown_events_30d, 0) AS "unknownEvents30d", + COALESCE(r.unknown_events_90d, 0) AS "unknownEvents90d", + COALESCE(a.audit_events_30d, 0) AS "auditEvents30d", + COALESCE(a.audit_events_90d, 0) AS "auditEvents90d", + COALESCE(a.audit_human_actors_30d, 0) AS "auditHumanActors30d", + COALESCE(a.audit_human_actors_90d, 0) AS "auditHumanActors90d", + COALESCE(a.audit_human_events_30d, 0) AS "auditHumanEvents30d", + COALESCE(a.audit_human_events_90d, 0) AS "auditHumanEvents90d", + COALESCE(a.audit_smoke_events_30d, 0) AS "auditSmokeEvents30d", + COALESCE(a.audit_smoke_events_90d, 0) AS "auditSmokeEvents90d", + COALESCE(a.audit_unknown_events_30d, 0) AS "auditUnknownEvents30d", + COALESCE(a.audit_unknown_events_90d, 0) AS "auditUnknownEvents90d" +FROM module_keys k +LEFT JOIN record_metrics r ON r.module_key = k.module_key +LEFT JOIN audit_metrics a ON a.module_key = k.module_key +ORDER BY k.module_key +`; + +const AUTH_SQL = String.raw` +WITH classified AS ( + SELECT + a.actor_id, + a.created_at, + CASE + WHEN e.id IS NULL THEN 'unknown' + WHEN lower(split_part(e.email, '@', 1)) ~ '^(smoke[-_.]|oa[-_.]?smoke[-_.]?)' + OR e.employee_no ~* '^(SMOKE|OSMOKE|WFULL|PRELAUNCH|LAUNCHAUTO|HRWDTEST)' + OR e.name ~ '(测试|试运行)' + THEN 'smoke' + ELSE 'human' + END AS actor_kind + FROM audit_logs a + LEFT JOIN employees e ON e.id = a.actor_id + WHERE a.action IN ('AUTH_LOGIN_SUCCESS', 'AUTH_EMAIL_LOGIN_SUCCESS', 'AUTH_OIDC_LOGIN_SUCCESS') +) +SELECT + count(DISTINCT actor_id) FILTER (WHERE actor_kind = 'human' AND created_at >= now() - interval '30 days')::int AS "humanActors30d", + count(DISTINCT actor_id) FILTER (WHERE actor_kind = 'human' AND created_at >= now() - interval '90 days')::int AS "humanActors90d", + count(*) FILTER (WHERE actor_kind = 'human' AND created_at >= now() - interval '30 days')::int AS "humanEvents30d", + count(*) FILTER (WHERE actor_kind = 'human' AND created_at >= now() - interval '90 days')::int AS "humanEvents90d", + count(*) FILTER (WHERE actor_kind = 'smoke' AND created_at >= now() - interval '30 days')::int AS "smokeEvents30d", + count(*) FILTER (WHERE actor_kind = 'smoke' AND created_at >= now() - interval '90 days')::int AS "smokeEvents90d", + count(*) FILTER (WHERE actor_kind = 'unknown' AND created_at >= now() - interval '30 days')::int AS "unknownEvents30d", + count(*) FILTER (WHERE actor_kind = 'unknown' AND created_at >= now() - interval '90 days')::int AS "unknownEvents90d" +FROM classified +`; + +function isSmokeLikeValue(value) { + const text = String(value ?? "").trim(); + if (!text) return false; + const lower = text.toLowerCase(); + const emailLocal = lower.includes("@") ? lower.split("@")[0] : lower; + return ( + /^(smoke[-_.]|oa[-_.]?smoke[-_.]?)/u.test(emailLocal) + || /^(SMOKE|OSMOKE|WFULL|PRELAUNCH|LAUNCHAUTO|HRWDTEST)[A-Z0-9_-]*/iu.test(text) + || /测试|试运行/u.test(text) + ); +} + +function classifyFixtureActor(actor, forcedKind = null) { + if (forcedKind === "system") return "system"; + if (!actor) return "unknown"; + if ([actor.employeeNo, actor.email, actor.name].some(isSmokeLikeValue)) return "smoke"; + return "human"; +} + +function createReadOnlyAdapter(client) { + return { + featureFlag: { + async findUnique({ where }) { + const result = await client.query( + "SELECT flag_key, override_mode, rollout_type, department_ids, note, version, created_at, created_by, updated_at, updated_by FROM feature_flags WHERE flag_key = $1", + [where.flag_key], + ); + return result.rows[0] ?? null; + }, + async findMany({ where }) { + const result = await client.query( + "SELECT flag_key, override_mode, rollout_type, department_ids, note, version, created_at, created_by, updated_at, updated_by FROM feature_flags WHERE flag_key = ANY($1::text[]) ORDER BY flag_key ASC", + [where.flag_key.in], + ); + return result.rows; + }, + }, + workspaceSetting: { + async findFirst() { + const result = await client.query( + "SELECT profile, org_mode, primary_entry_mode, notification_strategy, device_priority, module_visibility_json, created_at, updated_at, updated_by FROM workspace_settings ORDER BY created_at ASC LIMIT 1", + ); + return result.rows[0] ?? null; + }, + }, + }; +} + +function effectiveFlagState(item) { + if (item.requiresDepartment && item.effectiveReason === "department_deferred") return "partial"; + return item.effectiveEnabled ? "on" : "off"; +} + +function applyExposure(metrics, workspace, flags) { + const flagState = new Map(flags.map((item) => [item.flagKey, effectiveFlagState(item)])); + const moduleExposure = { + portal_notifications: ["portal", "portal_home_v1"], + workflow_approvals: ["workflowTasks", null], + reimbursements_invoices: ["reimbursements", null], + payments_ap: ["paymentBatches", "payment_batch_v1"], + procurement: ["purchaseRequests", null], + contracts: ["contractsRegistry", "project_linking_v1"], + projects: ["projects", "project_domain_v1"], + assets_inventory: ["assets", null], + leave_wecom_history: ["leaveRequests", null], + attendance_presence: ["attendance", "attendance_unified_v1"], + weekly_reports: ["weeklyReports", "weekly_report_v1"], + todos: ["todos", "todo_board_v1"], + announcements: ["announcements", "announcements_v1"], + budgets: ["budgets", null], + travel: ["travelRequests", null], + people_lifecycle: ["people", "employee_lifecycle_v1"], + payroll: [null, null], + performance: [null, null], + system_configuration: ["featureFlags", null], + integrations: [null, "erp_sync"], + }; + + return metrics.map((metric) => { + const [navigationKey, flagKey] = moduleExposure[metric.moduleKey] ?? [null, null]; + const navigationMode = navigationKey + ? workspace.moduleVisibility[navigationKey] ?? "unknown" + : "no_navigation"; + const effectiveState = flagKey ? flagState.get(flagKey) ?? "unknown" : "not_applicable"; + return { + ...metric, + latestActivityAt: metric.latestActivityAt?.toISOString?.() ?? metric.latestActivityAt ?? null, + navigationMode, + effectiveFlagState: effectiveState, + queryStatus: "PASS", + }; + }); +} + +function assertSafeResult(metrics) { + if (!Array.isArray(metrics) || metrics.length !== MODULE_KEYS.length) { + throw new Error("result_module_count_invalid"); + } + const seen = new Set(); + for (const item of metrics) { + if (!MODULE_KEYS.includes(item.moduleKey) || seen.has(item.moduleKey)) { + throw new Error("result_module_key_invalid"); + } + seen.add(item.moduleKey); + const keys = Object.keys(item).sort(); + const expected = [...SAFE_RESULT_KEYS].sort(); + if (JSON.stringify(keys) !== JSON.stringify(expected)) { + throw new Error("result_field_contract_invalid"); + } + } +} + +async function run() { + const appRoot = path.resolve(process.env.USAGE_CENSUS_APP_ROOT || process.cwd()); + const { Client } = require(path.join(appRoot, "node_modules/pg")); + const { getConfig } = require(path.join(appRoot, "src/config.js")); + const { listFeatureFlags } = require(path.join(appRoot, "src/services/featureFlags/index.js")); + const { getWorkspaceSetting } = require(path.join(appRoot, "src/services/workspaceSettings.js")); + const config = getConfig(); + const client = new Client({ connectionString: process.env.DATABASE_URL }); + let inTransaction = false; + + try { + await client.connect(); + await client.query("BEGIN TRANSACTION READ ONLY"); + inTransaction = true; + await client.query("SET LOCAL statement_timeout = '30s'"); + await client.query("SET LOCAL lock_timeout = '2s'"); + const readOnly = await client.query("SELECT current_setting('transaction_read_only') AS value"); + if (readOnly.rows[0]?.value !== "on") throw new Error("transaction_not_read_only"); + + const adapter = createReadOnlyAdapter(client); + const [metricResult, authResult, flags, workspace] = await Promise.all([ + client.query(USAGE_SQL), + client.query(AUTH_SQL), + listFeatureFlags(adapter, config), + getWorkspaceSetting(adapter), + ]); + const metrics = applyExposure(metricResult.rows, workspace, flags); + assertSafeResult(metrics); + await client.query("COMMIT"); + inTransaction = false; + + process.stdout.write(`${JSON.stringify({ + status: "PASS", + transactionReadOnly: true, + sourceBuildSha: config.buildSha, + generatedAt: new Date().toISOString(), + authentication: authResult.rows[0], + modules: metrics, + }, null, 2)}\n`); + } catch { + if (inTransaction) { + try { await client.query("ROLLBACK"); } catch { /* fail closed */ } + } + process.stderr.write("[usage-census][FAILED] code=QUERY_FAILED\n"); + process.exitCode = 1; + } finally { + await client.end().catch(() => {}); + } +} + +module.exports = { + AUTH_SQL, + MODULE_KEYS, + SAFE_RESULT_KEYS, + USAGE_SQL, + applyExposure, + assertSafeResult, + classifyFixtureActor, + effectiveFlagState, + isSmokeLikeValue, +}; + +// `node -` names stdin as argv[1] and leaves require.main unset. Production +// streams this task-scoped file through stdin so it never lands on the host. +if (require.main === module || process.argv[1] === "-") { + run(); +} diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/usage-census.contract.test.cjs b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/usage-census.contract.test.cjs new file mode 100644 index 00000000..6fbb4173 --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/research/usage-census.contract.test.cjs @@ -0,0 +1,84 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + AUTH_SQL, + MODULE_KEYS, + SAFE_RESULT_KEYS, + USAGE_SQL, + applyExposure, + assertSafeResult, + classifyFixtureActor, + effectiveFlagState, + isSmokeLikeValue, +} = require("./usage-census.cjs"); +const fs = require("node:fs"); + +function assertSelectOnly(sql) { + assert.doesNotMatch(sql, /\b(INSERT|UPDATE|DELETE|UPSERT|MERGE|ALTER|CREATE|DROP|TRUNCATE|CALL|COPY|GRANT|REVOKE)\b/iu); + assert.doesNotMatch(sql, /\bFOR\s+UPDATE\b/iu); + assert.match(sql, /^\s*WITH\b/iu); +} + +assertSelectOnly(USAGE_SQL); +assertSelectOnly(AUTH_SQL); +assert.equal(MODULE_KEYS.length, 20); +assert.equal(new Set(MODULE_KEYS).size, MODULE_KEYS.length); +assert.equal(new Set(SAFE_RESULT_KEYS).size, SAFE_RESULT_KEYS.length); +assert.match(USAGE_SQL, /forced_kind = 'system'/u); +assert.match(USAGE_SQL, /THEN 'smoke'/u); +assert.match(USAGE_SQL, /THEN 'unknown'/u); +assert.match(USAGE_SQL, /ELSE 'human'/u); +assert.match( + fs.readFileSync(require.resolve("./usage-census.cjs"), "utf8"), + /require\.main === module \|\| process\.argv\[1\] === "-"/u, +); + +assert.equal(isSmokeLikeValue("smoke-employee@example.invalid"), true); +assert.equal(isSmokeLikeValue("OA_SMOKE_01@example.invalid"), true); +assert.equal(isSmokeLikeValue("SMOKE002"), true); +assert.equal(isSmokeLikeValue("测试员工"), true); +assert.equal(isSmokeLikeValue("ordinary@example.invalid"), false); +assert.equal(classifyFixtureActor(null), "unknown"); +assert.equal(classifyFixtureActor({ employeeNo: "E001", email: "ordinary@example.invalid", name: "普通员工" }), "human"); +assert.equal(classifyFixtureActor({ employeeNo: "SMOKE002", email: "ordinary@example.invalid", name: "普通员工" }), "smoke"); +assert.equal(classifyFixtureActor({ employeeNo: "E001", email: "ordinary@example.invalid", name: "普通员工" }, "system"), "system"); + +assert.equal(effectiveFlagState({ requiresDepartment: true, effectiveReason: "department_deferred", effectiveEnabled: true }), "partial"); +assert.equal(effectiveFlagState({ requiresDepartment: false, effectiveReason: "flag_force_on_global", effectiveEnabled: true }), "on"); +assert.equal(effectiveFlagState({ requiresDepartment: false, effectiveReason: "flag_force_off", effectiveEnabled: false }), "off"); + +const zeroMetric = Object.fromEntries(SAFE_RESULT_KEYS + .filter((key) => !["moduleKey", "latestActivityAt", "navigationMode", "effectiveFlagState", "queryStatus"].includes(key)) + .map((key) => [key, 0])); +const rawMetrics = MODULE_KEYS.map((moduleKey) => ({ + moduleKey, + ...zeroMetric, + latestActivityAt: null, + auditEvents30d: 0, + auditEvents90d: 0, + auditHumanActors30d: 0, + auditHumanActors90d: 0, + auditHumanEvents30d: 0, + auditHumanEvents90d: 0, + auditSmokeEvents30d: 0, + auditSmokeEvents90d: 0, + auditUnknownEvents30d: 0, + auditUnknownEvents90d: 0, +})); +const workspace = { moduleVisibility: {} }; +const exposed = applyExposure(rawMetrics, workspace, []); +assert.equal(exposed.length, 20); +assert.equal(exposed.find((item) => item.moduleKey === "payroll").navigationMode, "no_navigation"); +assert.equal(exposed.find((item) => item.moduleKey === "procurement").effectiveFlagState, "not_applicable"); +assert.equal(exposed.find((item) => item.moduleKey === "reimbursements_invoices").effectiveFlagState, "not_applicable"); +assert.equal(exposed.find((item) => item.moduleKey === "workflow_approvals").effectiveFlagState, "not_applicable"); +assert.equal(exposed.find((item) => item.moduleKey === "budgets").effectiveFlagState, "not_applicable"); +assertSafeResult(exposed); +assert.throws(() => assertSafeResult(exposed.slice(1)), /result_module_count_invalid/u); +assert.throws( + () => assertSafeResult(exposed.map((item, index) => index === 0 ? { ...item, leakedField: "x" } : item)), + /result_field_contract_invalid/u, +); + +process.stdout.write("usage-census contract: PASS\n"); diff --git a/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/task.json b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/task.json new file mode 100644 index 00000000..10a607ac --- /dev/null +++ b/.trellis/tasks/08-11-internal-oa-usage-census-and-simplification/task.json @@ -0,0 +1,32 @@ +{ + "id": "internal-oa-usage-census-and-simplification", + "name": "internal-oa-usage-census-and-simplification", + "title": "内部 OA 使用度普查与减法路线图", + "description": "只读测量生产业务模块真实使用度,区分人工与自动/测试活动,形成重点优化、保持冻结、隐藏入口和候选退役清单;不在本任务修改开关、代码或数据。", + "status": "in_progress", + "dev_type": "research", + "scope": "task-docs,production-readonly", + "package": "hyb-oa", + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-11", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server/prisma/schema.prisma", + "server/src/services/workspaceSettings.js", + "server/src/services/featureFlags/index.js", + ".trellis/spec/hyb-oa/project-governance.md", + ".trellis/tasks/archive/2026-08/07-30-current-issues-feature-portfolio-review/research/portfolio-review.md" + ], + "notes": "用户已接受生产只读普查与四类减法矩阵,并将结论收紧为 OA 整体采用度极低。长期事实源已固化 A(推动团队真实使用)/B(低频台账兜底)管理决策门;定位明确前冻结产品功能开发。导航减法须另建 task、另行批准,本任务未创建、未发布、未写配置/schema/业务数据。", + "meta": {} +} diff --git a/docs/产品口径.md b/docs/产品口径.md index 6d86658f..6c79b7e1 100644 --- a/docs/产品口径.md +++ b/docs/产品口径.md @@ -73,6 +73,14 @@ - OA 内的 `direct_manager_id` 是 OA 工作流路由事实,不自动回写企微直属关系。两边需要一致时,企微管理员先在企微侧修正,再由 OA 重新读取或受控同步。 - 正式运行保持 OA → 企微成员 create/update/disable 写回关闭。任何未来确需反向写企微成员的能力都必须另建 Trellis task、逐字段说明副作用并再次获得生产写入批准;不能作为 OA 用户管理接口的隐式副作用。 +### P0-10 小团队 OA 采用度与开发决策门 + +- 2026-08-11 生产只读普查覆盖 20 个业务模块:聚合总行数 70(其中系统配置 26),13 个模块为零行;近 90 天只有 13 条可归属真人的创建/拥有记录证据,认证成功审计近 30/90 天均只观察到 1 个真人 actor。该证据说明 OA 整体采用度极低,不能描述为 5–10 人团队已经稳定使用。 +- `13` 是记录归属证据,不等于 13 次页面点击;认证审计 actor 也不能排除代理提交或覆盖外使用。工作台/通知没有真人交互证据,但本次未统计通知阅读字段,不据此声称“已证明没人看”。完整证据与口径见归档 task `08-11-internal-oa-usage-census-and-simplification`。 +- 管理层必须在两种定位中明确选择:A 为要求团队把指定真实流程迁入 OA;B 为不强推日常使用,将 OA 收缩成低频台账和治理兜底。在定位明确前停止新增产品功能 task,真实线上缺陷以及认证、权限、审计、付款职责分离、备份恢复和离职撤权等底线维护除外。 +- 两种定位下都先做可逆导航减法:公告、普通待办、周报、项目不再占主导航;合同、采购、资产保留最小能力并优先收进二级入口。任何 workspace、feature flag 或生产配置变更仍须独立 Trellis task 和生产写入批准。 +- 选择 A 后,先由管理规则推动一次团队真实流程(优先下一轮报销统一从 OA 提交),再从实际阻塞中选择最多一个小型产品任务;选择 B 后默认只做减法、必要维护与安全恢复,不再追求通用 OA 功能完整度。 + --- ## 请假模块