# 杨俊报销就绪:技术设计 ## 1. 设计目标 以最小生产变更打通“杨俊企微登录 → 杨俊提交真实报销 → 马建勇审批 → 王芝玲财务处理”,并在真实员工使用前由两个既有企微测试成员完成一次端到端体验验收。本任务不新增产品能力,复用现有通讯录导入、用户/角色管理、报销工作流和异机备份能力。 ## 2. 已确认边界 - 企业微信成员与 OA 员工是两个独立事实:杨俊已在企微通讯录,但生产尚无 OA 员工记录和企微绑定。 - 单人通讯录输入可唯一筛出杨俊;手机号为空符合现有 nullable 合同,企业邮箱存在,`incomplete=0`。 - 生产已有 37 个 active 费用类型和 1 个 active 报销工作流定义。 - active 法体为 0,但当前报销创建、提交和费用控制不以法体为硬门禁,本任务不创建法体。 - 杨俊的真实直属主管由用户确认是马建勇。 - 自动备份 daily/watchdog/drill-reminder 与 WARCH3 service 的 `BUILD_SHA` 均已漂移,必须先修复。 - 通讯录中已有两个状态为已激活的专用企微测试成员,分别命名为“测试员工”和“测试主管”;生产 OA 尚未导入它们,因此不存在可直接登录的测试 OA 账号。 ## 3. 数据流与执行门禁 ```text 现场只读复核活动构建与 4 个 unit → 重装 automatic-backup 三项 timer/service 与 WARCH3 timer → 验证 unit BUILD_SHA、enabled、active → 手工触发一份真实自动异机备份 → 验证源端成功与 ARM 恢复点完整/新鲜 → 从管理员导出通讯录在内存中仅筛选杨俊 → production dry-run(create=1、conflict/skip/fail=0) → production apply(初始角色仅 employee) → 验证 OA 员工 active、企微绑定唯一、其他成员未导入 → 通过现有用户管理接口设置 direct_manager_id=马建勇 → 验证主管 active、不同人、具备 expense.approve,并核对审计 → 从同一通讯录仅筛选两个既有测试成员并 dry-run/apply → 赋予最小角色并设置“测试员工 → 测试主管”的 OA 主管关系 → 两个测试成员通过企微分别登录 → 创建一条受控测试报销并由测试主管退回 → 删除测试报销、归档测试票据、停用测试 OA 账号并撤销会话 → 杨俊本人企微登录并提交真实报销 → 马建勇审批,随后进入王芝玲财务处理链路 ``` 任一门禁失败即停止后续步骤,不用后续成功掩盖前序失败。 ## 4. 复用合同 ### 4.1 通讯录导入 - 复用 `server/src/scripts/import-wecom-directory-export.js` 的 stdin 输入,不把筛选后的 XLSX 或成员原始 payload 写入磁盘、日志、Trellis 或聊天。 - dry-run 只记录人数与结果计数。 - apply 必须同时满足脚本 `--apply` 参数和容器内 `WECOM_DIRECTORY_EXPORT_APPLY=1`。当前 Compose v1 调用必须显式使用 `docker-compose exec -T -e WECOM_DIRECTORY_EXPORT_APPLY ...`,不能只在宿主 shell export。 - 输入严格只有杨俊一人;成功创建后的初始角色集合严格为 `employee`,不导入其他通讯录成员。 - 此路径是“管理员导出企微通讯录 → OA 员工/身份绑定”的单向导入。OA 侧主管字段仅服务 OA 工作流;不调用企微成员 create/update/disable,也不把 OA 主管、姓名、部门、手机或邮箱写回企微。 - 企微成员资料如需变化,由成员本人在企微允许的入口修改,或由企微管理员在管理后台修改;OA 后续只通过重新导出/受控同步读取变化。 ### 4.2 直属主管 - 复用 `POST /api/users/batch/set-manager`,不直写数据库。 - 写入目标仅杨俊,主管仅马建勇;接口负责 active、自指等现有校验。 - 写后核对 `SYSTEM_USERS_BATCH_SET_MANAGER` 审计增量,不记录完整账号或原始请求体。 - 马建勇必须是 active、与杨俊不同且按有效角色解析出 `expense.approve`。 ### 4.3 报销业务 - 杨俊无需先存在差旅申请;`linked_travel_request_id` 可空。 - 提交仍须满足既有硬门禁:至少一条费用明细、至少一张已绑定且状态为 `bound` 的真实票据、可匹配工作流并能解析审批人。 - 第一笔真实报销必须由杨俊本人操作;Codex 不代建生产报销、发票、付款或测试业务数据。 ### 4.4 timer 与备份 - 使用现有 `install-automatic-backup-timer.sh` 和 `install-warch3-ops-timer.sh`,从活动部署现场解析并显式传入合同参数;不在任务文档中固化 Secret、宿主路径或完整账号。 - 重装后核对 4 个 service 的 `BUILD_SHA` 与活动 server 一致,并确认对应 timers 为 enabled/active。 - 随后手工触发 daily 自动备份路径,验证源端 service 成功、ARM 批次完成且 dead-man/新鲜度检查通过。 - 新鲜异机恢复点是人员数据写入的前置条件,但不会用于回滚单个人员变更。 ### 4.5 两个既有测试成员 - 不创建新的 OA-only 测试身份,也不调用企微成员创建接口;复用管理员导出表中的两个专用企微测试成员,通过通讯录导入建立 OA 员工与企微身份绑定。 - 导入输入只含“测试员工”和“测试主管”,手机号、邮箱、企微 userid 等原始字段不进入任务证据、聊天或日志。 - “测试员工”角色集合严格为 `employee`;“测试主管”角色集合严格为 `employee + manager`。后者只用于验证 `expense.approve` 和主管待办,不授予财务、薪资、银行资料或系统管理权限。 - 通过现有主管接口把测试员工的 `direct_manager_id` 指向测试主管;所有 OA 主管与角色写入均保留现有审计,不回写企微。 - 两个账号必须分别从企微入口登录,以同时验证通讯录导入后的身份绑定和实际页面权限;无需生成或传递临时密码。 ### 4.6 受控测试报销生命周期 ```text 测试员工创建报销 → 使用 0.01 元最小测试值和“系统测试、非真实业务、禁止付款”描述 → 上传不含真实票据/个人资料的安全 PDF → OCR 成功则继续;OCR 失败则在票据池人工填入唯一测试字段并确认可用 → 绑定测试票据并提交 → 测试主管确认待办可见并执行“退回” → 报销恢复 draft → 测试员工删除 draft(票据释放为 available) → 测试员工归档已释放票据 → 管理员停用两个测试 OA 账号并核对会话撤销 ``` - 测试主管不得点击“通过”;这保证测试单不会进入真实财务列表或付款批次。 - 删除仅允许发生在退回后的 `draft`;`POST /api/workflow/tasks/:taskId/return` 会在同一事务中把报销明确改回 `draft` 并终止 workflow instance,因此无需再执行申请人撤回。现有草稿删除接口会把已绑定票据释放为 `available`。 - 票据仅在解绑且为 `available`/`ocr_failed` 时手工归档;归档后保留最小审计痕迹但不再是可用业务票据。 - 账号收口使用现有 OA 停用接口,递增 token version 并撤销活动会话;企微侧两个测试成员保持原状。 ### 4.7 受控测试发现与真实使用门禁 - 测试实际确认:生产当前使用 `OCR_PROVIDER=mock`,未配置腾讯 OCR 凭证;上传的合成 PDF 被写入明显的 Mock 发票号码、销售方、日期和金额并标记为可用。 - mock 只能作为合成测试依赖,不能让真实员工把其结果误认为真实识别。杨俊真实使用前必须二选一并再次复验:配置腾讯 OCR;或关闭 mock,明确采用人工录入/核对流程。 - 用户选择并批准关闭 mock。现场拓扑没有独立 worker,`JOBS_ENABLED=true`,因此只重建实际承载 Web 与后台任务的 server;不得为了“同时重启 worker”而凭空创建第二个作业执行器。 - `OCR_PROVIDER=off` 复验确认:新 PDF 直接进入 `available`,发票代码、号码、日期、购销方和金额字段均为空,OCR 尝试为 0、请求/完成时间为空;票据可以绑定到单明细 0.01 元测试报销并正常提交、退回和清理。 - `InvoicePool.ocr_provider` 是非空枚举且 schema 默认 `tencent`;off 路径传入 `undefined` 后数据库仍写入该默认值。该字段只是历史 schema 默认,不代表发生过 OCR 调用;当前 UI 不展示 provider。是否在后续迁移中改为 nullable 应与腾讯 OCR/人工录入产品决策一起规划,不阻断本次真实报销。 - `InvoicePool` 当前使用 `@@unique([file_sha256, ocr_provider, ocr_type])`。后续若把 provider 改成 nullable 或新增 `off` 枚举,必须同步回填历史行或重设计唯一键;只改新行 provider 会把同一文件拆进两个查重命名空间。生产只读核查显示历史 `mock` 票据共 1 张且仅由测试账号上传,非测试员工上传数为 0。 - off 模式下发票号为空,基于 `invoice_code + invoice_number` 的查重不可用,但文件 SHA-256 查重仍生效;当前小团队短期由财务查看 PDF 原件并人工核对,不在本任务增加强制补录。 - 草稿删除成功后,详情页在导航完成前仍短暂请求已删除的详情与 precheck,浏览器产生预期 404 控制台噪声,但用户已回到空草稿列表。这是非阻断前端时序 finding,不影响数据清理结果。 ## 5. 失败与回退 - timer 重装或异机备份失败:不写杨俊数据,保留本地诊断并走既有脱敏群告警。 - dry-run 不满足 `target=1 / active=1 / incomplete=0 / planned create=1 / conflicts=0 / skipped=0 / failed=0`:不 apply,重新核对输入或生产现状。 - apply 失败:停止;按脚本结果诊断,不手工拼接残缺账号。 - apply 成功但主管写入失败:保留已创建的 OA 员工和企微绑定,不删除、不恢复整库;主管仍为空时不能提交报销,可安全重试同一主管接口。 - 主管写错:通过同一接口恢复写入前的 `direct_manager_id`,不恢复整库。 - 真实报销提交失败:保留草稿和真实票据状态,按返回的业务错误定位;不得通过扩权、代批或伪造数据绕过。 - 测试导入或角色/主管设置失败:停止测试,不借用真实员工账号;按单步接口恢复两名测试成员原 OA 状态。 - 测试提交失败:保留 draft 和测试票据定位问题;不得改成“审批通过”绕过。修复后从同一测试草稿重试或删除后重建。 - 测试退回后删除失败:禁止停用账号,先保留两名测试账号以完成清理;不得直写数据库删除业务记录。 - 测试账号误被赋予超范围角色:立即通过现有角色接口恢复最小角色集合并撤销会话,不恢复整库。 - OCR 仍为 mock:保持测试账号停用,不让杨俊上传真实票据;不得用“人工记住忽略 Mock 字段”作为默认生产口径。当前已切换为 off,此回退项不再触发。 ## 6. 隐私与审计 - 终端与任务证据只保留布尔值、计数、角色 slug、审计 action 和脱敏状态。 - 不输出手机号、企业邮箱、企微 userid、完整账号、通讯录原始行、票据内容、银行资料、Secret 或完整宿主路径。 - 账号导入与主管写入分别保留现有系统审计,确保每一步可独立追踪和撤销。 ## 7. 不做的设计扩展 - 不新增导入 API、主管兜底规则、CEO 自审批能力或报销状态。 - 不启用 OA → 企微通讯录写回,不修改任何企微成员资料。 - 不创建法体、不补银行账号、不导入其余成员。 - 不把首次真实业务扩成管理员代办的生产自动化测试。 - 不创建新的企微测试成员,不给测试主管财务或管理员权限,不让测试单通过审批或进入付款链路。 ## 8. 真实首跑补充风险复核 - 生产三名真实参与人均 active 且企微绑定唯一,但尚无 `AUTH_WECOM_LOGIN_SUCCESS` 记录;企微客户端入口仍需本人首登验证。 - 当前 active 费用标准为 0、杨俊部门适用预算池为 0;真实金额不会触发费用标准或预算阻断。active 法体为 0、杨俊未绑定法体,但现有报销提交和付款批次候选不以法体为硬门禁。 - 金额审批规则不是同一条:`[0,500)` 为 `manager → finance`,`[500,5000)` 为 `manager → finance_mgr`,`[5000,+∞)` 为 `manager → ceo → finance_mgr`。 - 当前 active 角色持有人:`finance=1`、`ceo=1`、`finance_mgr=0`。因此低于 500 元可路由到马建勇后再到王芝玲;达到 500 元的单据会生成无人可处理的 `finance_mgr` 角色待办。修复应调整审批规则到既有最小角色,不给王芝玲扩为权限过宽的 `finance_mgr`。 - 用户确认当前公司人数少且由马建勇统一负责,不需要金额分级。生产规则收敛为唯一 `[0,+∞)` 规则:`department_manager → finance`;删除另外两档,不保留 CEO 重复节点。 - 后续只有出现第二名实际审批主管,或有人基于真实职责承担 `finance_mgr`,并且业务证明需要金额升级时才重开分级设计。大额关注使用风险标记或通知,不通过同一自然人重复审批实现。 - 生产变更复用 `PATCH /api/approval-rules/:id`:先把低档规则扩为 `[0,+∞)`,再停用中、高两档,避免出现无人可匹配的短暂金额空窗;每步保留 `APPROVAL_RULE_UPDATED` 审计,失败则通过同一接口补偿恢复原三档。 - 最终审批只把报销置为 `approved`,不会自动创建付款批次或付款记录;付款批次仍由王芝玲主动创建并由马建勇复核。 ## 9. 审批任务到完整报销单的可达性 - 根因不在待办列表卡片:移动待办列表已经通过 `handleTaskSelection` 进入 `/mobile/approvals/:taskId`。真实阻断发生在审批任务详情,其报销摘要只有单号、申请人和金额,没有费用明细、发票和附件,也没有进入既有报销详情的出口。 - 移动端只在审批任务详情的报销摘要区增加明确的“查看完整报销单”入口,目标复用 `/mobile/reimbursements/:reimbursementId`;导航继续使用当前移动页面通用的 `{ state: { from: currentMobileSource } }` 约定,不新增 `returnTo` 参数或另一套回跳状态。 - PC 端在 `WorkflowTaskDetailPage` 的报销摘要区提供“查看报销单”入口,目标复用 `/reimbursements/:reimbursementId`。 - 现有 `manager`、`ceo` 和 `finance` 角色已经包含 `expense.view_all`,因此本轮不调整角色、权限矩阵、后端报销详情鉴权或 workflow 数据结构。 - 不把整个摘要卡片改造成无提示的隐式链接;使用明确命名的链接按钮,避免与待办列表卡片的既有任务选择交互混淆。 - 请假、采购、差旅审批详情存在同类业务详情缺口,但本轮只修复真实报销链;其他类型另轮统一处理。 ## 10. 报销详情时区与 KISS 收敛 - 后端继续用 ISO 8601 UTC 字符串传输时间,前端不得用 `slice(0, 19)` 假装本地时间。PC 报销详情、移动报销详情和 PC 审批任务详情复用 `formatDisplayDateTime`;共享函数显式使用 `Asia/Shanghai`,使公司内部 OA 不依赖浏览器所在时区。 - 不新增页面级时间工具。合成 UTC 样例通过页面文本断言验证上海时间,覆盖附件上传时间和审批任务时间。 - PC 将“当前状态与下一步”和“业务摘要”收敛为单一首屏概况,保留总额、状态、编号、日期、报销人、说明与真正可执行入口;删除解释同一事实的二级标题和辅助句。 - PC 的明细、发票、附件各保留一个可扫描标题;附件上传限制只在可编辑时出现。关联事项仅在已有项目/差旅关联或可编辑时展示,预算/风险区域仅在存在真实提示时展示。 - 移动端头部已经包含状态,因此概况卡不重复状态;只保留金额、日期、报销人和说明。只读状态不显示编辑教学或空“操作”卡,编辑态仍保留新增、上传、删除和底部提交能力。 - 采用当前设计系统和现有组件,不引入新颜色、字体、动效、布局系统或后端字段;新版 `ui-ux-pro-max` 仅用于内容优先、密度和渐进披露判断。 ## 11. 企微 WebView 旧包刷新护栏 - 诊断依据:生产构建的移动审批详情把报销日期和“查看完整报销单”放在同一个 `selectedTask.reimbursement` 条件块;真机截图只出现日期,说明 WebView 仍运行部署前 JS。公网 `/ready` 与当前移动 chunk 均已核对为正确构建,不把问题误判为权限、后端报销关联或列表未进入详情。 - 复用现有构建合同:前端使用 `VITE_BUILD_SHA`,公网 `/ready` 返回 server `buildSha`。页面只在 `visibilitychange` 回到 `visible` 时用 `cache: no-store` 请求 `/ready`,规范化为 12 位后比较;开发/unknown、空值、请求失败和解析失败均静默。 - 构建不一致时继续复用 `hyb-oa:pwa-update-ready` 事件及 `usePwaUpdatePrompt`,在已登录 AppShell 顶部显示一条用户控制的刷新提示;不自动刷新,避免打断审批意见、表单或其他未保存操作。 - 用户点击刷新后复用 `clearPwaRuntimeCaches()` 并重载当前 URL;同一次页面生命周期只提示一次,避免页面多次前后台切换反复提示。 - 护栏只能保护已经加载过本次新代码的页面,不能反向唤醒当前仍运行 `d2841b1` 之前旧包的 WebView;首次部署本护栏时仍需主动通知马建勇手工刷新,后续部署才由版本比对接管。 - 不新增后端 API、数据库字段、定时轮询、更新中心或强制升级状态;登录/改密页面继续沿用既有用户确认刷新逻辑。 ## 9. 企业微信原生材料预览 ### 9.1 运行时分流 - 企业微信 iOS/Android WebView:使用腾讯维护的 `@wecom/jssdk` 调用 `previewFile({ url, name, size })`,由企业微信原生层预览文件。 - 普通 PC 浏览器:发票使用现有短时签名 URL,附件继续使用认证下载 Blob 后由浏览器打开;两端复用同一个“查看原件”业务动作,但不强求相同底层机制。 - 非企微移动浏览器或 JS-SDK 调用失败:给出自然中文错误并保留下载/PC 查看退路;不静默宣称已预览。 ### 9.2 JS-SDK 签名链 - 新增已认证的自建应用 JS-SDK 签名接口,输入为当前页面去掉 fragment 的完整 URL。 - 服务端只允许 `APP_ORIGIN` 或当前受信请求 origin,拒绝跨 origin、带凭据 URL、非 HTTP(S) 和 fragment;复用企微应用 access token,在服务端获取并缓存 JSAPI ticket。 - 返回仅包含 `corpId`、`timestamp`、`nonceStr`、`signature`;ticket、access token、Agent Secret 和企微原始响应不得返回或记录。 - 客户端仅在 `isWecomWebView()` 为真时按需注册 SDK,`jsApiList` 只包含 `previewFile`,避免把 JS-SDK 加到登录页或普通浏览器主路径。 ### 9.3 文件 URL 与授权 - 发票复用 `/invoice-pool/:id/signed-url`,响应补准确 `fileSize`、`originalName` 和 `mimeType`;签发沿用票据所有者/报销参与人/当前待审批人对象级授权。 - 报销附件新增同形短时签名 URL:签发前复用 `ensureOwnOrCanViewAll`,执行治理状态与下载理由门禁;token 绑定 attachment、reimbursement、actor 和用途,TTL 取现有签名 TTL 与 120 秒中的较小值。 - 原生层取件时重新校验 token、对象关系和过期时间,实际打开前写下载/预览审计。短时 URL 是企业微信原生层无法携带 OA 认证头时的窄例外,不扩展为通用 query-token 下载机制。 - 不改角色或权限矩阵。审批人获得的只是当前报销材料只读能力,不能编辑、解绑、删除或管理票据池。 ### 9.4 安全与兼容 - `name` 使用数据库原始显示名但必须确保与实际 MIME 匹配并带安全扩展名;`size` 来自持久化的准确字节数。 - 短时 URL 不写入 Trellis、聊天、前端持久化存储、错误详情或普通日志;页面关闭/刷新后重新签发。 - 生产首次验收先用无业务数据 PDF 证明 `config + previewFile`,再查看真实材料。部署、企微真机调用和真实审批继续单独获批。