归档(07-27-yang-jun-reimbursement-readiness): 完成杨俊报销闭环收口
这个提交包含在:
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||
@@ -0,0 +1,360 @@
|
||||
# 杨俊报销就绪:技术设计
|
||||
|
||||
## 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`,再查看真实材料。部署、企微真机调用和真实审批继续单独获批。
|
||||
|
||||
## 12. 报销明细与材料的 KISS 对齐
|
||||
|
||||
- 交互采用“一个入口、一次通览、语义保留”:费用明细只展示费用事实,不再渲染关联发票;`linkedItemId`、票据/附件类型、对象权限、审计和生命周期均不改变。付款回单仍属于付款阶段材料,不提前混入本次提交材料。
|
||||
- 报销材料首屏显示“发票 N · 补充附件 M”和一个明确的“查看材料合并预览”按钮,下方始终显示有序原件列表。每份材料只使用一个语义化整行按钮打开原件,不再叠加“查看”按钮;草稿/驳回编辑态在同一行外保留发票移除和附件删除动作,只读审批人不显示数据变更动作。
|
||||
- 服务端新增报销级合并 PDF grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 原页全部复制,JPG/PNG/WebP 经 Sharp 规范化后各嵌入一页。企微调用一次 `previewFile({ url, name, size })`,PC/普通浏览器打开同一短时 PDF URL;不引入 PDF.js,不在客户端渲染 PDF。
|
||||
- 预览顺序固定为发票 `created_at, id`、补充附件 `created_at, id`、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。有序材料集合哈希覆盖材料类型、ID、文件哈希/大小/MIME,缓存键由报销单 ID、集合哈希和合并器版本组成。
|
||||
- 合并在 Node `worker_threads` 中执行,不启用 Compose worker profile。worker 限制材料数、源文件总字节、PDF 总页数、图片像素、输出字节和执行时间;`pdf-lib` 不使用 `ignoreEncryption`,加密、损坏或不可复制 PDF 导致整包失败。任一材料失败都不能跳过,客户端展开现有逐份原件路径。
|
||||
- 合并 PDF 固定 CreationDate、ModificationDate、Creator、Producer 和保存选项;相同有序输入必须生成完全相同字节。合并 Buffer 生成一次后缓存并原样返回,签发 `size` 取该 Buffer 长度。缓存缺失时原签发 URL 返回拒绝,不现场生成另一份字节;用户重新操作会重新签发。
|
||||
- 合并件不进入业务表、不形成原件,只在有界短时缓存中保存;页面明确“报销材料合并预览,仅供审批核对;原始文件另行保留”。grant 绑定 purpose、reimbursement、actor、bundle 与过期时间,实际 GET 验签并写整包预览审计。
|
||||
- 后端从已脱敏持久化 OCR `result_json` 只映射 `documentCategory` / `documentSubtype` 到报销详情,不下发原始腾讯响应。前端共享格式化函数按 `vat_invoice`、`full_vat_invoice`、`electronic_train_ticket` 选择票种名称和适用字段;未知票种使用中性“发票 + 号码/文件名”,不显示“无代码”或对不适用字段宣称“未识别”。
|
||||
- “查看材料合并预览”和降级行均使用语义化 button、可见焦点、至少 44px 触控目标;超过 300ms 时按钮禁用并显示“正在准备材料预览…”。材料区提示“打开后上下滚动,连续查看全部材料”;不再提示左右滑动。
|
||||
|
||||
## 13. 生产验收修正:材料可读性与轻量差旅概要
|
||||
|
||||
### 13.1 按钮层级
|
||||
|
||||
- PC 现有入口已在“报销材料” `SectionPanel`,保持不变。
|
||||
- 移动端将入口从 `mobile-reimbursement-items` 移到现有“报销材料” card 标题/计数之后;明细区只保留费用明细及其编辑动作。
|
||||
|
||||
### 13.2 合并 PDF 与确定性合同
|
||||
|
||||
- 生产脱敏计时基线保留为旧图片包证据;新主路径不再使用 Poppler 或图片数组。
|
||||
- `createBundle` 先按显式排序取得材料并计算集合哈希,再把源字节交给受限 `worker_threads`。worker 复制全部 PDF 页、把图片嵌为页面,并返回单个 Buffer、页数和精确大小。
|
||||
- 固定 PDF 元数据和序列化选项;单测对相同输入分别启动两次合并,断言 Buffer 全等。签发响应的 `fileSize` 必须等于随后 GET 的 `content-length` 和响应字节数。
|
||||
- 合并缓存按报销单 ID、集合哈希、合并器版本复用;actor grant 仍独立。缓存淘汰或进程重启后旧 URL 失败,不以重新生成结果冒充旧签发字节。
|
||||
- 移除旧图片 bundle 页路由与 `previewImage` 主路径;若 Poppler 无其他用途,从镜像移除 `poppler-utils`。保留单份发票/附件原件预览作为失败降级与电子签名事实源。
|
||||
|
||||
### 13.3 轻量差旅概要数据流
|
||||
|
||||
```text
|
||||
草稿/驳回报销的差旅明细
|
||||
-> 编辑 travelPurpose / travelLocation / travelStartDate / travelEndDate
|
||||
-> PATCH 入口集中验证长度与日期顺序
|
||||
-> reimbursement_forms 可空列持久化
|
||||
-> 详情 API 返回轻量 travelSummary
|
||||
-> PC/移动首部同一信息层级显示
|
||||
-> 存在 travel category 明细时,submit precheck 拒绝缺失字段
|
||||
```
|
||||
|
||||
- Prisma 在 `ReimbursementForm` 增加四个 nullable 列:事由、地点、开始日期、结束日期。迁移只加可空列,不回填、不改历史单、不修改审批状态;回滚时代码先回滚,列可保留至受控后续清理。
|
||||
- 差旅单判定使用“任一明细的 `expenseType.category === travel`”或存在关联差旅申请,不新建另一套报销类型字典。
|
||||
- 关联差旅申请时,概要可从已批准申请的 purpose、segments 城市、start/end date 读取;普通差旅报销使用自身四字段。历史数据只读降级为“description 作事由、明细最小/最大日期作起止日”,地点缺失如实显示,禁止从文件名、OCR 金额或自由文本猜地点。
|
||||
- 四字段只允许申请人/受权代办在 `draft/rejected` 更改。审批中单不原地改业务事实;当前真实单如需完整地点,必须由主管退回后申请人补齐并重新提交。
|
||||
- 详情页把差旅概要并入金额/报销人所在的单头概况;只有差旅单显示差旅行,不让普通费用报销承担空字段或独立空卡。
|
||||
- 共享 `ReimbursementTravelSummary` 以“可编辑且概要不完整”作为初始填写态条件,直接呈现四个可见标签输入框;保存成功后切回摘要态,保存失败停留在表单并显示现有错误反馈。完整概要仍由用户点击“编辑”进入表单,不引入新的步骤、弹窗或报销类型。
|
||||
|
||||
### 13.4 四块式 KISS 交互边界
|
||||
|
||||
- 材料列表与合并预览复用现有单份签名 URL、企微 `previewFile`、PC 打开方式、下载理由、权限和审计;本轮只移除只读态的列表可见性条件,不增加后端接口。
|
||||
- 列表顺序沿用详情 API 的发票、补充附件顺序;不按金额、文件名或关联明细重新排序。稳定 ID 作为 React key,整行按钮保留可见焦点和至少 44px 触控高度。
|
||||
- PC/移动沿用现有详情路由和返回路径,只重排当前内容为四块纯滚动;不做 Tab、折叠、抽屉、普通/差旅二选一、费用日期交叉校验、补助自动计算或审批页重构。
|
||||
- 真实风险以内联提示贴近单头/明细,关联项目或差旅申请以单头一行链接呈现;草稿/驳回动作留在页面底部命令区,均不计入稳定内容块。
|
||||
|
||||
### 13.5 五阶段处理进度 DTO
|
||||
|
||||
`GET /api/reimbursements/:id` 新增只读 `processingProgress`:
|
||||
|
||||
```text
|
||||
processingProgress.stages[] = {
|
||||
key,
|
||||
label,
|
||||
state, // completed | current | pending | exception
|
||||
actorName?,
|
||||
occurredAt?,
|
||||
result?,
|
||||
comment?,
|
||||
detail?,
|
||||
targetHref?
|
||||
}
|
||||
```
|
||||
|
||||
- 服务端固定生成“申请提交”以及“付款批次复核、付款与回单”首尾阶段;中间审批段先按查询的 `created_at,id` 顺序逐项生成真实 task,每个 task 拥有独立 stage key,不再用职责 Map 合并;再从提交时 `workflow_steps_snapshot` 的当前节点之后补齐尚未创建 task 的未来 pending 节点。当前两步审批规则从刚提交起就返回五个阶段,新增或重复角色节点会自然增加时间轴节点,不得丢失、覆盖或因延迟建任务而暂时消失。
|
||||
- 审批标题优先使用非空且不属于“步骤/节点/step + 序号”的 `step_name`;否则把 `manager` 映射为“主管审批”、`finance|finance_mgr` 映射为“财务复核”、`ceo` 映射为“负责人复核”,其余统一为“审批”。标题和详情均不暴露原始 step ID、角色码或序号。
|
||||
- workflow 实例已创建但 `workflow_tasks` 暂为空时,中间审批段按提交快照生成无姓名 pending 节点;workflow 整体不存在时才回退为“主管审批、财务复核”默认占位,避免申请刚提交时出现空白进度。
|
||||
- workflow task 提供实际 assignee/completer/outcome,history 提供动作、意见和时间。已完成取实际 completer/actor,当前取真实 pending assignee;未来 task 尚未创建时 `actorName=null`,严禁按当前角色持有人猜姓名。
|
||||
- 付款汇总读取与当前报销关联的最新 `PaymentBatchItem` 及其 batch,只选择状态、制单/复核/执行人员与时间和未归档回单计数,不返回批次其他报销、金额或材料。只有具备现有付款批次读取权限时才返回可跳转的 batch href。
|
||||
- 没有批次时付款复核 detail 为“待财务创建付款批次”;draft 为“所在付款批次待非制单人复核”;reviewed 为“所在付款批次已复核,待执行付款”;failed/cancelled 如实表达失败或待重新创建。不得写成“您的报销待复核”。
|
||||
- `paid` 后最后阶段完成;回单计数为 0 显示“付款已完成,回单待留存”,大于 0 显示“付款与回单已留存”。回单是最后阶段次级状态,不增加第六块。
|
||||
- workflow、batch、receipt 三个来源分别 try/normalize;合法缺失不是错误,缺失来源只让对应及下游阶段保持 pending。数据库或映射异常仍按现有 5xx 处理并记录服务端错误,不用伪造“已完成”。
|
||||
- PC 与移动复用一个时间轴组件。已完成无意见节点压成单行;当前、exception、returned/rejected 或有 comment 的节点展开。语义状态同时使用图标/文字/线条,不只依赖颜色。
|
||||
|
||||
### 13.6 验证与回退
|
||||
|
||||
- 后端覆盖迁移、PATCH/GET/submit 字段合同、日期倒置、非差旅报销不误拦截、关联差旅申请摘要与旧单降级。
|
||||
- 后端补充进度 DTO 的 pending/approved/paid、退回/驳回、batch 五种状态、回单有无、三个来源独立缺失、未来节点无姓名和付款链接权限测试;另覆盖第三个未知职责节点、两个同角色节点互不覆盖、未来快照节点尚无 task、实例存在但 tasks 为空四条边界。
|
||||
- 前端覆盖 PC/移动四块顺序、单头差旅填写/只读、材料列表与合并入口、变长审批节点紧凑时间轴、加载反馈、企微原件预览和 375px/横屏;组件只按数组 `map` 渲染,不按固定长度或位置解释职责。
|
||||
- 若 JPEG/并发修复未通过目标镜像与合成火车票验收,回滚应用代码到现有 PNG 串行路径,保留原件降级;不删生产数据或回滚整库。
|
||||
|
||||
### 13.7 报销 UX 最小收口
|
||||
|
||||
- 报销列表把当前 `pathname + search` 作为 `location.state.from` 传给报销详情或审批任务;审批任务进入报销详情时把当前任务路径作为来源。PC 详情仅接受 `/reimbursements` 列表和 `/workflow/tasks/:id` 两类内部来源,拒绝绝对 URL、协议相对 URL、hash、当前页和其他模块路径。
|
||||
- 报销详情在 `EntityOverviewHero` 之前渲染一个文字明确的左侧返回控件;返回采用 replace,避免“返回后浏览器后退又回详情”的循环。无可信来源时降级 `/reimbursements`。
|
||||
- `displayFormat.ts` 新增可接受 `string | number | null | undefined` 的人民币格式化函数;PC/移动详情删除各自金额 formatter,统一复用。列表页本轮不做横向重构,避免扩大已批准范围。
|
||||
- 移动材料区继续承接发票添加;固定底栏删除同名次级按钮,只渲染提交主动作。固定栏的 safe-area、loading、禁用和提交校验保持不变。
|
||||
- 回退只需还原前端提交;不涉及 API、数据库、权限、workflow、生产配置或真实数据。
|
||||
|
||||
## 14. 生产部署防重复事故合同
|
||||
|
||||
### 14.1 根因与边界
|
||||
|
||||
- 正确发布提交 `5dac336` 前,首次尝试把 Compose `working_dir` 误当仓库根目录。该标签实际指向 `deploy/trialrun` 配置目录;现有 `deploy.sh` 又到 rsync 之后才检查远端 `.env`,因此错误目标先被同步,随后才因重复的 env 路径停止。旧容器未重建,但远端 `.env` 需从仍运行容器的项目允许键原子恢复。
|
||||
- 发布慢的主因不是 Vite;现场 Vite 生产构建约 11 秒,而 server `npm ci` 约 8 分钟。Dockerfile 在依赖层之前注入每次变化的 build args,使 `apk add` 与两套依赖层随提交 SHA 一起失效。
|
||||
- 当前加固只改部署脚本、Dockerfile、脱敏输出、测试和既有 runbook/spec;不修改数据库、业务状态、生产 `.env` 值或真实报销数据。加固完成后需另行批准才再次部署。
|
||||
|
||||
### 14.2 同步前只读 preflight
|
||||
|
||||
- `DEPLOY_DATA_MODE=existing` 时,`deploy.sh` 在创建目录或 rsync 前通过 SSH 验证:`REMOTE_DIR` 已存在且同时含 `server/package.json` 与 `deploy/trialrun/docker-compose.yml`;PGDATA 含 `PG_VERSION`;附件目录存在。
|
||||
- `SYNC_ENV=0` 时,同一 preflight 必须确认 `REMOTE_ENV_FILE` 是普通文件;若位于 `REMOTE_DIR` 内,继续使用当前精确相对路径保护。任何验证失败均在 rsync 前退出。
|
||||
- 已有生产部署的参数只从受控 automatic-backup systemd unit 的 allowlist 环境读取,并交叉核对活动 Compose project 与挂载;禁止从 `com.docker.compose.project.working_dir` 猜仓库根目录。首次部署继续使用人工审核的显式参数。
|
||||
|
||||
### 14.3 Docker 缓存与构建身份
|
||||
|
||||
- `base` 只安装稳定系统依赖;client/server dependency stages 只由各自 lockfile(以及 Prisma generate 所需 schema)决定。
|
||||
- `VITE_BUILD_SHA` 只在 `client_build` 执行 Vite 前注入;`BUILD_SHA` 只在 `server_build`/最终运行层注入。改变 SHA 必须重建应用产物,但不得重跑基础包和 `npm ci`。
|
||||
- 静态合同测试断言 Dockerfile 中 `apk add`、client `npm ci`、server `npm ci` 均早于对应 build arg;既有 deploy 来源、`/ready` 和公网短 SHA 检查继续作为运行时证明。
|
||||
|
||||
### 14.4 日志与回退
|
||||
|
||||
- seed/smoke 只输出 readiness、计数和布尔状态,不输出完整邮箱、电话、企业微信 corp/agent 标识、redirect URI 或一次性密码。需要首次凭据时必须通过受控配置渠道提供,不从部署日志交付。
|
||||
- preflight 失败不需要回滚,因为尚未同步。镜像构建或 smoke 失败时沿用旧容器/既有发布回退合同,不通过重建 `.env` 作为常规路径。
|
||||
- 回归至少覆盖错误仓库根、缺失远端 env、无效 existing 数据目录都不会调用 rsync;正确合同可进入 rsync;Docker 缓存顺序与日志禁敏片段均有静态断言。
|
||||
|
||||
## 15. 五人推广前的采用扩展就绪检查
|
||||
|
||||
### 15.1 目标与证据层级
|
||||
|
||||
- 本阶段回答“当前生产是否具备让第二位及更多真实员工开始走报销的前置条件”,不再回答“mock 自动化能否走通界面”。代码测试证明合同实现,生产只读事实证明账号、权限、配置和历史业务当前状态,两类证据不得互相替代。
|
||||
- 既有任务证据已确认至少一笔真实报销完成提交、主管审批、财务复核与付款批次复核;采用扩展查询须现场判断它是否继续完成到付款、回单,以及其他记录的实际停点,不能沿用旧快照假定完整闭环。
|
||||
- `people_lifecycle` 仅是采用度模块口径;人员分母从 `employees` 及其认证/绑定关系读取。法体配置只作为现状字段输出,不进入 go/no-go 硬门禁。
|
||||
|
||||
### 15.2 只读数据合同
|
||||
|
||||
只读结果按六组输出,所有分组使用各自实体独立计数:
|
||||
|
||||
1. **人员与登录**:active 员工数、具备至少一种可登录标识的 active 员工数、无可登录标识数;只输出计数与布尔,不输出标识本身。
|
||||
2. **关系与权限**:普通员工主管关系覆盖数;`expense.submit`、`invoice.upload`、`expense.approve` 分别按适用人群统计具备/缺失数量,不用角色名称代替有效权限计算。
|
||||
3. **主数据与门禁**:active 费用类型数、active 报销 workflow 数、相关 feature flag/effective state、提交前检查所需配置;active 法体数量单列为观察项。
|
||||
4. **票据能力**:上传路径是否启用、OCR provider/mode 是否为受支持现场值、必要 worker/队列状态是否可观察;只输出枚举、布尔和计数,不输出 Secret、文件或 OCR payload。
|
||||
5. **生命周期漏斗**:报销单按 `draft/reviewing/approved/paid/rejected/cancelled`,票据池按 `pending_ocr/ocr_failed/available/bound/archived`,workflow instance/task 按各自枚举,付款批次按 `draft/reviewed/executed/failed/cancelled`,回单按存在/缺失独立聚合。
|
||||
6. **闭环判定**:以关联关系证明是否存在至少一条“已提交 → workflow 完成 → 付款批次已执行 → 回单存在”的完整链;同时把其他记录归入明确停点。不得通过总量相等或时间接近推定关联。
|
||||
|
||||
本地汇总器必须 fail closed:预期枚举、模块或查询结果缺失,字段名不匹配,或一个预期有分类输入的过滤器产出空集合时,报告失败并保留原因;禁止将解析失败序列化为全零。
|
||||
|
||||
### 15.3 执行边界与停止条件
|
||||
|
||||
- 先完成本地静态合同复核,再单独申请生产只读 PRE。PRE 仅核对受控身份、运行 SHA、migration、回滚窗口及查询能力,不读取业务正文。
|
||||
- 正式 readiness 查询严格只读,输出经本地脱敏后只保留计数、布尔和状态分布;姓名、邮箱、手机号、员工号、金额、单号、UUID、票据/附件内容和原始 payload 均不得进入任务文档、日志或对话。
|
||||
- 查询失败、数据模型与预期不一致、闭环无法可靠关联或脱敏断言失败时停止;允许如实标记 supplemental read,但不得把补读包装成首次严格执行。
|
||||
- 发现账号、权限、主数据、OCR 或历史状态缺口时仅形成 remediation 清单。任何修复、登录或业务 mutation 都需要新的明确批准,不因“只差一项”顺手执行。
|
||||
|
||||
### 15.4 真人采用验证
|
||||
|
||||
- readiness 通过后也不自动制造测试数据。等待第二位真实员工出现真实报销需求,由其本人使用真实账号和真实票据完成正常流程;观察者只记录,不代替操作。
|
||||
- 若现实条件迫使熟练管理员代走,结果必须标记为“真人摩擦下限”,不能作为五人自然采用已验证的证据。
|
||||
- 摩擦点全部记录,包含发生阶段、可复现现象和是否阻断;本轮最多选择三个进入后续处理。认证、授权或数据安全问题无论排序如何都立即停止并另批。
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||
@@ -0,0 +1,420 @@
|
||||
# 杨俊报销就绪:执行计划
|
||||
|
||||
## 0.1 报销 UX 最小收口(本轮)
|
||||
|
||||
- [x] PC 报销列表和审批任务向报销详情传递受控来源;详情标题左上方提供明确返回并删除业务动作区重复列表入口。
|
||||
- [x] `displayFormat.ts` 新增共享人民币金额格式,PC/移动报销详情统一复用。
|
||||
- [x] 移动草稿底部固定栏删除重复“添加发票”,材料区入口和提交主动作保持可用。
|
||||
- [x] 聚焦 Playwright 覆盖列表筛选返回、审批任务返回、直接打开降级、金额格式与移动单一入口;再运行 typecheck、build 和治理门禁。
|
||||
- [x] 本轮只做本地实现与验证;未获部署批准前不发布、不修改生产配置或真实业务数据。
|
||||
|
||||
## 0.2 R5 移动契约与门禁收口(本轮)
|
||||
|
||||
- [x] 修正 R5 首页指标采集:从 `mobile-action-grid` 的直接入口动态取得 key 与布局,不再维护已经失效的旧 key 列表。
|
||||
- [x] 对经理测试角色明确断言有序入口集合为 `new-reimbursement / capture / leave / purchase`,保留 44px、单行、SVG、边界和无重叠检查。
|
||||
- [x] 将 R5 登记到 `route-manifest.json` 的 `acceptanceSpecs`,使 `run-local-candidate.sh` 的 list/focused/full 都能解析并执行该用例;不重复加入另一个门禁造成同轮双跑。
|
||||
- [x] 运行 R5 双设备、flow coverage/本地候选资产解析、前端 typecheck/build 与 Trellis 治理门禁;记录约 18 个其他孤儿 spec 为后续治理,不在本轮扩范围。
|
||||
- [x] 修正上一轮证据中的错误表述:页面实际仍有四个入口,只有旧采集器因 key 漂移看见两个;本轮未推送、未部署前不宣称线上已修复。
|
||||
|
||||
## 0.3 生产部署流程加固(本轮部署后)
|
||||
|
||||
- [x] 如实记录 `5dac336` 发布的首次错误根目录尝试、`.env` 原子恢复、第二次成功发布、独立构建身份和备份链验证;真实业务数据与审批状态不变。
|
||||
- [x] 把远端仓库根、`SYNC_ENV=0` env、existing PGDATA/storage 验证前移到 rsync 之前;错误合同必须在任何远端同步前停止。
|
||||
- [x] 重排 Dockerfile,使基础包与两套 lockfile 依赖层不受 build SHA 变化影响,同时保留前端短 SHA和后端完整 SHA。
|
||||
- [x] 收敛 deploy seed/external/runtime smoke 的账号、联系方式和外部配置标识输出,只保留 readiness、计数和状态。
|
||||
- [x] 增加部署顺序、错误停止点、Docker 缓存顺序和日志最小化回归;更新 deployment spec 与 runbook。
|
||||
- [x] 运行聚焦单测、环境/来源合同、shell 语法、敏感扫描、Trellis/process/docs/diff 门禁;单独提交本地加固,不在未获再次批准时部署。
|
||||
|
||||
## 1. 生产写入前门禁
|
||||
|
||||
- [x] 只读复核活动 server 构建标识、Compose 项目、4 个 systemd service 当前 `BUILD_SHA`、timer enabled/active、最近源端备份与 ARM 新鲜度;输出仅限状态和计数。
|
||||
- [x] 使用活动部署的显式合同参数重装 automatic backup 的 daily/watchdog/drill-reminder units。
|
||||
- [x] 使用活动部署的显式合同参数重装 WARCH3 unit。
|
||||
- [x] 验证以下 4 个 service 的 `BUILD_SHA` 全部等于活动 server,相关 timers 为 enabled/active:
|
||||
- `hyb-oa-automatic-backup-daily.service`
|
||||
- `hyb-oa-automatic-backup-watchdog.service`
|
||||
- `hyb-oa-automatic-backup-drill-reminder.service`
|
||||
- `hyb-oa-warch3-ops.service`
|
||||
- [x] 手工启动一次 automatic backup daily service;确认源端 `Result=success`、`ExecMainStatus=0`。
|
||||
- [x] 在 ARM 只读验证新批次完成、manifest/文件完整性与新鲜度检查通过;任何失败均停止人员写入。
|
||||
|
||||
## 2. 杨俊单人导入
|
||||
|
||||
- [x] 从管理员导出的通讯录以管道/内存方式筛选杨俊一人,不生成持久化子集文件,不输出个人字段。
|
||||
- [x] 在生产容器运行 dry-run,验收计数:输入 1、active 1、incomplete 0、planned create 1、conflict 0、skip 0、failure 0。
|
||||
- [x] dry-run 符合预期,无需进入偏差处理分支。
|
||||
- [x] 经本任务最终实施批准后,使用 `docker-compose exec -T -e WECOM_DIRECTORY_EXPORT_APPLY ... --input=- --apply` 写入唯一目标。
|
||||
- [x] 只读验证杨俊 OA 员工为 active、企微绑定唯一、初始角色严格为 `employee`,且其余通讯录成员未进入本轮输入或写入。
|
||||
- [x] 确认生产企微通讯录写回门禁关闭;本轮同步结果为 skipped,未调用企微成员写接口。
|
||||
|
||||
## 3. 设置真实直属主管
|
||||
|
||||
- [x] 写入前记录杨俊原 `direct_manager_id` 的脱敏状态,并确认马建勇为 active、不同员工且具有 `expense.approve`。
|
||||
- [x] 通过现有 `POST /api/users/batch/set-manager` 仅将杨俊的主管设置为马建勇;不直写数据库、不调整任何人的角色。
|
||||
- [x] 写后验证主管解析成功,并核对 `SYSTEM_USERS_BATCH_SET_MANAGER` 审计增量;该接口合同不递增目标员工 token version 或撤销会话。
|
||||
- [x] 主管写入成功,未进入失败或误配回退分支。
|
||||
|
||||
## 4. 聚焦验证
|
||||
|
||||
- [x] 运行通讯录导入相关测试,覆盖手机号为空、单目标和 apply 双门禁。
|
||||
- [x] 运行 users set-manager 路由测试,覆盖 active、自指、审计和权限边界。
|
||||
- [x] 运行 workflow/reimbursement 聚焦测试,覆盖主管解析、`expense.approve`、无差旅申请和绑定票据提交门禁。
|
||||
- [x] 运行 `cd server && npm run prisma:validate`;代码未变化时仍将结果作为当前合同基线记录。
|
||||
- [x] 运行 Trellis task/governance、流程/文档、敏感扫描和 `git diff --check`。
|
||||
- [x] 按项目规则对权限、workflow 和生产写入证据做一次只读 review;Codex inline 主代理复核通过,未发现角色扩权、企微反向写入、审批自审或生产测试业务数据。
|
||||
|
||||
## 5. 两个既有企微测试成员的受控验收
|
||||
|
||||
- [x] 从管理员通讯录以内存方式仅筛选“测试员工”和“测试主管”,验证二者唯一、active、资料满足导入合同;不输出或持久化原始字段。
|
||||
- [x] 生产 dry-run 验收输入 2、计划创建 2、冲突/跳过/失败 0;偏差即停止。
|
||||
- [x] 单独取得生产测试账号/业务写入批准后 apply,并确认两人 active、企微绑定各自唯一、没有导入其他成员。
|
||||
- [x] 通过现有角色接口将角色收敛为:测试员工=`employee`,测试主管=`employee + manager`;核对 token version、会话撤销及角色审计。
|
||||
- [x] 通过现有主管接口设置“测试员工 → 测试主管”,确认测试主管 active 且具有 `expense.approve`。
|
||||
- [x] 两个测试成员均完成账号密码登录与首次改密,申请人可见报销入口、主管可见审批入口;企微 SSO 因不能冒充企微身份仍待人工确认。
|
||||
- [x] 测试员工创建唯一一条描述含“系统测试、非真实业务、禁止付款”、总额 0.01 元的测试报销,并上传不含真实资料的安全 PDF。
|
||||
- [x] 合成 PDF 经生产 mock OCR 自动生成测试字段并标记可用,绑定后成功提交;该结果同时暴露真实使用前必须移除 mock OCR 的门禁。
|
||||
- [x] 测试主管确认待办可见并执行“退回”,未执行“通过”;报销回到 `draft` 且 workflow instance 终止,无需追加“撤回”。
|
||||
- [x] 测试员工删除 draft 报销;票据解绑回到 `available` 后已手工归档。
|
||||
- [x] 管理员停用两个测试 OA 账号并确认会话撤销、企微写回为 skipped;企微侧两个测试成员保持不变。
|
||||
- [x] 只读收口确认:测试报销 0、pending 待办 0、测试票据唯一且为 manual archived、两个测试 OA 账号为 archived、活动会话 0、企微绑定保留 2;未产生测试付款。
|
||||
|
||||
## 5.1 真实发票路径门禁
|
||||
|
||||
- [x] 只读确认生产当前为 `OCR_PROVIDER=mock`,腾讯 OCR 凭证未配置。
|
||||
- [x] 用户选定“关闭 mock 并采用财务查看 PDF 原件人工核对”;本轮不改代码、不部署新构建。
|
||||
- [x] 生产 `.env` 已切换为 `OCR_PROVIDER=off`;现场无独立 worker 且 `JOBS_ENABLED=true`,因此仅强制重建 server。公网 `/ready` 正常,构建仍为 `4886f9b081dd`。
|
||||
- [x] 重新启用两个测试 OA 账号并完成受控 API 闭环:新 PDF 直接 `available`,八个发票业务字段为空、OCR 尝试为 0,绑定、提交、主管退回均成功。
|
||||
- [x] 复验后删除测试报销、归档测试票据、停用测试 OA 账号;最终活动会话 0、测试报销 0、pending 待办 0、未归档测试票据 0,企微同步均为 skipped。
|
||||
- [x] 记录非阻断 schema finding:off 路径未显式写 provider,但 `ocr_provider` 因非空默认值仍存 `tencent`;请求/完成时间和 OCR 结果为空,当前 UI 不展示该字段。
|
||||
|
||||
## 6. 用户真实验收
|
||||
|
||||
- [x] 在杨俊提交前通过现有审批规则管理接口把三档规则收敛为唯一 `[0,+∞)` 规则,审批链固定为 `department_manager → finance`;不扩角色、不保留 CEO 重复节点。
|
||||
- [x] 规则写入前后只读核对 499.99 / 500 / 5000 三个金额,确认解析结果完全相同且 active `finance` 有唯一实际处理人。
|
||||
- [x] 杨俊本人从企业微信进入 OA,身份解析成功并能看到报销入口;用户后续确认该差旅报销已经走完整个业务链路。
|
||||
- [x] 杨俊使用真实业务数据创建报销,至少添加一条费用明细并上传/绑定状态有效的真实票据后提交。
|
||||
- [x] 马建勇已看到真实审批待办并按业务事实处理;完成状态由用户确认,并由后续生产只读闭环查询交叉验证。
|
||||
- [x] 单据已完成王芝玲负责的财务处理链路、付款批次 maker-checker、真实付款和回单留存;只读查询确认存在关联的 executed 批次与已批准回单。
|
||||
- [x] 用户验收记录与任务证据只保留脱敏状态、计数和结论,未写入票据、金额、银行资料或完整账号。
|
||||
|
||||
## 6.1 审批详情可达性修复
|
||||
|
||||
- [x] 保留移动待办列表卡片现有 `handleTaskSelection` 行为,不修改任务列表到审批任务详情的路由。
|
||||
- [x] 在 `MobileApprovalsPage` 的审批任务详情报销摘要区增加“查看完整报销单”,复用 `/mobile/reimbursements/:id` 和现有 `state.from` 来源回跳约定。
|
||||
- [x] 在 `WorkflowTaskDetailPage` 的报销摘要区增加“查看报销单”,复用 PC 报销详情路由。
|
||||
- [x] 不修改后端、审批状态机、权限矩阵或生产数据;请假、采购、差旅同类入口缺口仅记录,不在本轮扩展。
|
||||
- [x] 增加聚焦前端回归,验证移动进入/返回及 PC 入口;运行前端 typecheck、build、Trellis task/governance 和 `git diff --check`。
|
||||
|
||||
## 6.2 审批详情入口生产发布
|
||||
|
||||
- [x] 将工作提交 `d2841b1` 推送后,以 `SYNC_ENV=0` 按标准发布脚本部署;未覆盖生产 `.env`,未修改审批状态、真实业务数据或付款数据。
|
||||
- [x] 第一次发布尝试在同步前因附件挂载目标解析不匹配停止,生产未变化;按活动容器的实际挂载目标修正输入后重新执行。
|
||||
- [x] 发布来源校验、环境合同、远端 Compose 校验、Prisma migration status、`/ready`、external deps smoke、runtime smoke 与公网资源扫描均通过,活动构建为 `d2841b166dc0`。
|
||||
- [x] 使用既有 unit 中的受控合同参数重装 automatic backup 三项 service/timer 与 WARCH3 service/timer;第一次读取活动容器时因未向 Compose 显式传入构建变量而在 unit 写入前失败,补齐目标构建参数后成功。
|
||||
- [x] 4 个 service 的 `BUILD_SHA` 均与活动构建一致;4 个 timer 均为 enabled/active。
|
||||
- [x] 手工触发 automatic backup daily,源端 `Result=success`、`ExecMainStatus=0`;随后源端 watchdog 同为 `success/0`。daily 只有在 ARM receiver 完成三文件、manifest 与 SHA-256 校验并原子 finalize 后才会成功。
|
||||
- [x] ARM dead-man timer 保持 enabled/active,最近一次自动运行是 `success/0`;本次尝试立即手工复跑被已收口的最小权限策略拒绝,未恢复临时 sudo、未改 ARM。不得把该证据写成“独立 dead-man 已对新批次即时复跑”。
|
||||
- [x] 公网 `/ready` 构建标识匹配,已发布的移动端和 PC 资源分别包含“查看完整报销单”和“查看报销单”;未读取、截图或输出真实报销内容。
|
||||
|
||||
## 6.3 报销详情时区与 KISS 收敛
|
||||
|
||||
- [x] 让共享日期时间格式化显式使用 `Asia/Shanghai`,PC/移动报销详情与 PC 审批任务详情移除本地字符串截取函数并复用共享实现。
|
||||
- [x] PC 合并重复概况,收敛明细、发票、附件标题和解释;无关联或无预算/风险内容时隐藏对应辅助区块。
|
||||
- [x] 移动概况删除重复状态、计数教学和冗余下一步文案;只读无动作时不渲染空操作卡,编辑能力保持不变。
|
||||
- [x] 用合成数据补附件 UTC→上海时间和 KISS 信息层级聚焦回归;运行前端 typecheck、build 与相关 Playwright。
|
||||
- [x] 运行 Trellis task/governance、流程合同、文档审计、敏感扫描和 `git diff --check`;生产部署另行批准。
|
||||
|
||||
## 6.4 企微 WebView 旧包刷新护栏
|
||||
|
||||
- [x] 在页面从后台恢复为可见时请求 `/ready`,比较规范化后的前端与 server 构建标识;一致、未知或请求失败时静默。
|
||||
- [x] 构建不一致时复用现有 PWA 更新事件,在已登录 PC/移动壳显示“发现新版本,点击刷新”;不自动重载页面。
|
||||
- [x] 点击刷新后清理旧 PWA runtime/precache 并重载;同一页面生命周期只提示一次。
|
||||
- [x] 用合成 `/ready` 响应覆盖 mismatch、match 和 failure,运行 typecheck、build、聚焦 Playwright 与 Trellis 门禁。
|
||||
- [x] 只提交并推送;生产部署和经理二次刷新另行执行,不在本步骤自动部署。
|
||||
|
||||
## 6.5 企业微信原生发票与附件预览
|
||||
|
||||
- [x] 增加企微 JSAPI ticket 获取/缓存和本站 URL 签名接口;覆盖缺配置、上游失败、跨 origin、fragment/凭据 URL 拒绝与脱敏返回。
|
||||
- [x] 发票短时 URL 响应补准确文件元数据;移动企微用 `previewFile`,PC 用浏览器预览。
|
||||
- [x] 为报销附件增加严格对象授权、理由门禁和实际取件审计的短时预览 URL;不扩大角色或通用下载权限。
|
||||
- [x] 抽取 PC/移动共用材料预览调用,企微路径不使用异步 `window.open(blob:)`;只读审批人看得到查看动作但看不到编辑、解绑、删除动作。
|
||||
- [x] 用合成文件与 mock JS bridge 运行后端聚焦测试、前端 typecheck/build 和 PC/移动 Playwright 一致性回归。
|
||||
- [x] 运行 Trellis 治理、流程/文档、敏感扫描、`git diff --check`,并对新增短时授权边界做只读 review;不在本步骤部署生产。
|
||||
|
||||
## 6.6 原生预览生产发布与运维对齐
|
||||
|
||||
- [x] 将提交 `410d8cd` 推送后以 `SYNC_ENV=0` 发布;未覆盖生产 `.env`,62 个 migration 无待执行项,公网 `/ready` 的 12 位构建标识匹配。
|
||||
- [x] 如实记录两次发布尝试:第一次使用 runbook 示例发布目录和普通运维入口,来源/环境合同通过,但 rsync 因目录权限在同步前失败,生产未变化;随后从活动 Compose 标签解析实际发布根目录,复用既有受控 root 运维入口成功发布,未新增 sudo 或修改目录权限。
|
||||
- [x] external deps、runtime smoke 与公网 bundle 扫描通过;公网包包含 `previewFile`、同源 JS-SDK 签名接口和附件短时预览入口,未登录请求签名接口返回 401。
|
||||
- [x] 发布后从既有 unit 内部复用受控合同参数和运行时间,仅把 automatic backup 三项与 WARCH3 的构建标识刷新为 `410d8cd`;4 个 service 均匹配活动构建,4 个 timer 均为 enabled/active。
|
||||
- [x] 手工触发 automatic backup daily 和源端 watchdog,二者均为 `success/0`;daily 成功以异机接收端完成文件、manifest、哈希校验和原子 finalize 为既有合同。
|
||||
- [x] 记录非阻断依赖 finding:本次镜像构建审计报告 client 18 个风险(10 high),server 32 个风险(20 high、2 critical);未在发布窗口执行自动升级,留待独立依赖升级和回归任务。
|
||||
- [x] 记录证据最小化 finding:既有 external/runtime smoke 输出仍包含不必要的配置标识和测试管理员联系方式;本任务不复述这些值,后续应独立收敛为状态与计数输出。
|
||||
- [x] 用户已确认真实报销材料能够在企微查看;该报销后续已完成主管审批及完整业务闭环。
|
||||
|
||||
## 6.7 移动报销查看范围对齐
|
||||
|
||||
- [x] 在 `MobileReimbursementsPage` 读取 `expense.view_all`,仅对有权用户显示“我的 / 全部”范围切换;复用现有 Tabs/按钮样式,触控高度为 48px。
|
||||
- [x] 范围默认和无权限回退均为 `own`;切换 `all` 时写入 URL 查询参数并纳入 React Query key,调用现有 `fetchReimbursements({ scope })`,不改后端或扩大权限。
|
||||
- [x] 列表到详情的 `state.from` 保留当前 status 和 scope;普通员工不渲染“全部”入口,“全部”范围不显示仅限本人草稿的删除动作。
|
||||
- [x] 增加聚焦 Playwright:管理员默认本人、切换全部后看到他人单据并进入详情;普通员工即使手工带 `scope=all` 也不显示范围切换且请求保持 `scope=own`。
|
||||
- [x] 运行前端 typecheck/build、聚焦 Playwright、task/governance、流程/文档、敏感扫描和 `git diff --check`;部署另行批准。
|
||||
|
||||
## 6.8 移动范围生产发布
|
||||
|
||||
- [x] 将 `20b20b1` 和 `8b376f4` 推送后,以 `SYNC_ENV=0` 部署 `8b376f4`;未覆盖生产 `.env`,无待执行 migration,`/ready`、external/runtime smoke 与公网扫描通过。
|
||||
- [x] 如实记录三次本地发布入口尝试:第一次因临时日志清理命令被本地安全护栏在进程启动前拒绝;第二次在来源校验阶段因未跟踪 portfolio review 导致 dirty worktree 停止;两次均未同步或改变生产。仅临时 stash 该目录后第三次发布成功,随后按 stash 标识原样恢复且未纳入提交。
|
||||
- [x] 公网 `/ready` 为 `8b376f413585`,活动 server 中唯一移动报销 chunk 包含 `mobile-reimbursements-scope-all` 标识。
|
||||
- [x] 复用既有 unit 合同参数与运行时间刷新 automatic backup 三项和 WARCH3;4 个 service 均匹配 `8b376f4`,4 个 timer 均 enabled/active。
|
||||
- [x] 手工触发 automatic backup daily 与源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次或付款写入。
|
||||
- [x] 用户已用自己的企微管理员账号进入“报销 → 审批中 → 全部”,打开当前真实报销并确认能够查看材料;本项只记录查看验收,不代表主管审批或财务链路已完成。
|
||||
|
||||
## 6.9 报销明细与材料 KISS 对齐
|
||||
|
||||
- [x] PC 与移动端按 `linkedItemId` 在每条费用明细内展示关联发票摘要,并复用当前安全预览动作;未关联发票不归入明细。
|
||||
- [x] 将原发票区和附件区收敛为单一“报销材料”区,显示分类计数,全部材料信息行可点击预览且不再重复显示“查看原件”按钮。
|
||||
- [x] 保留草稿编辑态的添加/移除发票与上传/删除附件,不改后端合同、对象授权、审计、签名 URL、企微 JS-SDK 或审批状态机。
|
||||
- [x] 更新聚焦 Playwright,覆盖关联/未关联发票、统一计数、PC/企微预览、编辑态动作、44px 触控目标和移动横屏无溢出;前端 typecheck/build 与 Trellis 质量门禁通过。
|
||||
- [x] 用户明确批准后推送并部署生产;真人业务验收仍由用户在企微刷新后执行。
|
||||
|
||||
## 6.10 明细/材料 KISS 生产发布
|
||||
|
||||
- [x] 推送 `665dcab` 与 `0e44aa7`,从干净的已发布提交快照以 `SYNC_ENV=0` 部署 `0e44aa7`;不触碰未跟踪 portfolio review,不同步生产 `.env`。
|
||||
- [x] 发布脚本的来源、环境合同、init、migration、`/ready`、external/runtime smoke 和公网资源扫描通过;公网构建标识为 `0e44aa72f3f5`,活动资源包含 PC/移动统一材料区标识。
|
||||
- [x] 复用现有 unit 参数和运行时间刷新 automatic backup 三项与 WARCH3;4 个 service 构建身份一致,4 个 timer 均 enabled/active。
|
||||
- [x] 手工触发 automatic backup daily 和源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次或付款写入。
|
||||
- [x] 用户验收后修正方向:不需要在费用明细内查看关联发票;现有逐份材料行仍然繁琐,需要一次进入连续查看全部材料。该反馈覆盖 6.9 的内联发票展示合同,已部署事实保留但不再作为目标体验。
|
||||
|
||||
## 6.11 票种摘要与整单材料预览
|
||||
|
||||
- [x] 只读核查生产真实报销:绑定票据 3 张,均为 `bound`、腾讯 OCR 已实际执行且无存储错误;号码/日期/金额完整。票种为 2 张电子火车票和 1 张数电发票,传统代码均不适用,火车票销售方字段不适用;未输出号码、金额、主体、文件名、UUID 或原始 OCR 结果。
|
||||
- [x] 核查企微 SDK 合同:`previewFile` 仅接受单个 `url/name/size`;只有图片接口 `previewImage` 接受 `urls[]`。当前真实材料为 3 份 PDF 发票和 3 份 PNG 补充附件,总源文件小于 1 MiB,适合逐份 PDF 栅格化后组成有序图片包验证连续预览。
|
||||
- [x] 用户批准采用服务端 PDF 栅格化 + 企微 `previewImage`,其余按统一材料入口、票种摘要和原件降级方案实施;不采用合并 PDF 或 PDF.js。
|
||||
- [x] 删除 PC/移动费用明细中的关联发票 UI 和对应旧断言,保留后端 `linkedItemId`。
|
||||
- [x] 报销详情 API 增加安全票种分类映射;共享票据格式化按普通增值税、数电发票和电子火车票显示适用字段,不重跑或改写当前真实 OCR 数据。
|
||||
- [x] 在运行镜像加入固定 Poppler 工具,结合既有 `sharp` 实现有界、短时、对象授权的报销级图片预览 grant;企微一次 `previewImage` 原生滑动,PC/普通浏览器连续浏览,失败展开逐份原件预览降级。
|
||||
- [x] 更新后端路由/授权/审计/超限/缓存失效测试与 PC/企微 Playwright;运行 Prisma validate、server 聚焦测试与全量基线、client typecheck/build、Trellis/governance/process/docs/sensitive/diff 门禁。
|
||||
- [x] 稳定合同验收后再反向更新 `reimbursement-invoice-ocr.md` 与 `wecom-boundary.md`,覆盖当前“明细内发票、无聚合接口”的旧规范;提交、推送、生产部署和真实材料查看分别遵守既有批准门禁。
|
||||
|
||||
## 6.12 整单材料预览生产发布
|
||||
|
||||
- [x] 提交并推送 `8a9279e`,以 `SYNC_ENV=0` 发布;未覆盖生产 `.env`,未修改权限、审批状态机或任何真实业务数据。
|
||||
- [x] 如实记录发布过程:首次从历史 Compose working-dir 标签解析路径,在同步前因普通用户权限停止,生产未变;一次临时 clone 长时间无进展后主动中止,未进入 deploy;一次后台日志方案在进程启动前被本地安全护栏拒绝,未改变生产。
|
||||
- [x] 一次本地长构建连接被中断,但远程构建继续完成;当时仅源码已同步、活动容器未切换。最终复跑复用已完成的构建缓存并全流程成功。
|
||||
- [x] 发布来源、环境合同、Compose/init、62 个 migration、external/runtime smoke 与公网资源扫描通过;公网 `/ready` 与活动容器均为 `8a9279e`,容器内 `pdftoppm` 可用,未认证材料包接口返回 401。
|
||||
- [x] 发布后发现 automatic backup 三项与 WARCH3 的 unit 仍为旧构建身份;只读复用现有 unit 参数与运行时间重装后,4 个 service 全部匹配 `8a9279e`,4 个 timer 均 enabled/active。
|
||||
- [x] 手工触发 automatic backup daily 与源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次、银行回单或付款写入。
|
||||
- [x] 构建耗时主要来自首次加入 Poppler 系统包后的基础层失效:系统依赖安装及 client/server/init 重新构建;migration 无待执行项,不是本次耗时主因。
|
||||
|
||||
## 6.13 生产真机验收缺口修正
|
||||
|
||||
- [x] 将移动端“查看全部材料”从报销明细区移到报销材料区;PC 保持现有正确位置。
|
||||
- [x] 用 `pdf-lib` 实现确定性合并 PDF:显式排序、全部 PDF 页、图片各一页、固定元数据/保存选项、集合哈希缓存;不使用 `ignoreEncryption`。
|
||||
- [x] 用 Node `worker_threads` 受限执行合并并设置材料数、源字节、页数、图片像素、输出大小和超时;不启用 Compose worker profile。
|
||||
- [x] 将材料 bundle 签发改为单一 PDF URL + 准确 `name/size`,企微一次 `previewFile`、PC 浏览器原生预览;缓存缺失、加密/损坏/超限时整包失败并展开逐份原件。
|
||||
- [x] 删除旧图片页 GET、共享图片浏览页和 `previewImage` 主路径;确认无其他用途后移除 Poppler 镜像依赖。
|
||||
- [x] 新增报销差旅事由/地点/开始/结束四个 nullable 列和加法迁移,更新 PATCH/GET/submit 合同,不回填或改写审批中真实单。
|
||||
- [x] PC/移动差旅单在首部概况与明细之间显示轻量差旅概要;草稿/驳回可编辑,审批中只读,历史单只用说明/明细日期降级且不猜地点。
|
||||
- [x] 后端聚焦测试覆盖字节确定性、显式顺序、页数、准确 size、缓存缺失、加密/损坏回退、全部上限、差旅字段验证与非差旅不误拦截;前端 Playwright 覆盖按钮位置、加载文案、企微 `previewFile`、PC 打开和概要。
|
||||
- [x] 运行 Prisma validate、新建空库 migration、server 聚焦/全量、client typecheck/build、无业务 25 页合并探针、Trellis/governance/process/docs/sensitive/diff 门禁。
|
||||
- [x] 只读 review 迁移、提交门禁、短时合并件权限与审计。
|
||||
- [x] 实现提交、推送和生产部署均已分别取得批准并完成。
|
||||
- [ ] 企微真机用当前真实报销验证合并 PDF 可打开、全部页可连续查看且返回详情正常;该人工结果不由自动化或发布 smoke 代替。
|
||||
|
||||
## 6.14 合并 PDF 与差旅概要生产发布
|
||||
|
||||
- [x] 实现提交 `da35591` 已推送到 `origin/main`;从该提交的干净临时 clone 执行标准 `deploy/trialrun/deploy.sh`,固定 `SYNC_ENV=0`、`DEPLOY_DATA_MODE=existing`,未触碰未跟踪的 portfolio review,也未同步生产 `.env`。
|
||||
- [x] 如实记录第一次本地发布入口尝试:命令末尾包含临时 clone 清理动作,被本地安全护栏在进程启动前拒绝;未连接生产、未同步文件、生产没有变化。去掉清理动作后第二次发布全流程成功,完成验证后再校验临时目录前缀和提交身份并删除该临时 clone。
|
||||
- [x] 发布来源校验确认 `da35591` 已进入远端;环境合同、Compose 预检、init 和 migration deploy 通过。63 个 migration 中新增 `20260801120000_reimbursement_travel_summary` 成功应用,schema 随后为 up to date;迁移只增加四个 nullable 列。
|
||||
- [x] `/ready`、external deps smoke、runtime smoke 和公网前端资源扫描通过;公网 12 位构建标识与容器完整 SHA 分别匹配 `da35591`。构建仍报告既有 npm 风险基线:client 18 个风险(10 high),server 23 个风险(10 high、2 critical);本轮未执行自动升级。
|
||||
- [x] 发布后只读验证目标 server 可加载 `pdf-lib`,且 `pdftoppm` 已从运行镜像移除;未认证材料合并签发和带无效 token 的取件请求均保持 401/403 边界。
|
||||
- [x] 发布后首轮独立 `/ready` 复核误把公网 12 位前端标识按完整 40 位后端 SHA 断言,检查在只读响应处停止;未执行后续动作或改变生产。按既有“公网 12 位 + 容器完整 SHA”合同修正后复核通过。
|
||||
- [x] 复用既有 unit 内的受控合同参数重装 automatic backup 三项与 WARCH3;4 个 service 构建身份均匹配 `da35591`,4 个 timer 均 enabled/active。手工触发 automatic backup daily 和源端 watchdog,结果均为 `success/0`。
|
||||
- [x] 本次发布未修改真实报销状态、审批任务、权限、付款批次、银行回单或业务材料;企微真机合并 PDF 体验和真实报销后半链继续由对应真人验收。
|
||||
|
||||
## 6.15 首笔历史差旅概要受控补录
|
||||
|
||||
- [x] 写入前只读锁定唯一目标:当前真实报销仍为审批中、当前审批任务尚未处理、四个差旅概要字段均为空;5 条 travel 明细中只有 1 条出发火车票日期早于申请人确认的实际行程,且正确日期已由业务方明确确认。
|
||||
- [x] 以单一 Serializable 数据库事务补录用户确认的事由、地点和起止日期,并只把上述唯一错误火车票明细日期改为确认后的实际乘车日期;未改金额、费用类型、说明、票据、附件、审批状态、工作流实例或待办。
|
||||
- [x] 同事务新增 1 条管理员代录审计,只记录“字段于提交后上线、原提交未采集、业务方确认后代录”的原因、被修正字段名和受控 task 标识;未把真实事由、地点、日期、票据或原始请求写入 task 证据或审计 extra。
|
||||
- [x] 写后只读断言通过:差旅概要四项完整、5 条 travel 明细日期全部位于确认区间、金额与 3 张发票和 3 个附件保持不变、工作流与当前待办仍为原 pending 状态、目标审计恰好新增 1 条;事务结果为 `applied`,未触发回滚。
|
||||
- [x] 用户刷新当前真实报销后确认页面日期显示正确;该结果只证明日期展示,不代替差旅概要其他字段、材料列表或真实审批结果。
|
||||
- [ ] 补录不代表审批通过;马建勇仍须按业务事实完成主管审批,后续财务、付款批次、银行回单和最终复核继续遵守既有流程。
|
||||
|
||||
## 6.16 四块式详情与五阶段处理进度
|
||||
|
||||
- [x] PC 与移动端移除只读态材料列表的权限/失败条件,所有有权查看当前报销的人始终看到发票与补充附件列表;合并预览保留在列表上方。
|
||||
- [x] 单份材料继续使用现有整行按钮和单份预览路径;只读审批人没有移除、删除或管理动作,编辑态既有动作保持不变。
|
||||
- [x] PC/移动详情重排为“单头概况 → 费用明细 → 报销材料 → 处理进度”四块;差旅概要并入单头,关联/风险/付款/操作按数据与权限就近显示,不增加 Tab 或折叠。
|
||||
- [x] 共享差旅概要组件在“草稿/驳回可编辑且字段不完整”时直接进入四字段填写态;完整概要保持单头内摘要 + 编辑,不新增报销类型或字段合同。
|
||||
- [x] 后端详情新增五阶段 `processingProgress`,从 latest workflow snapshot/tasks/history、关联 PaymentBatchItem/batch、报销付款字段和回单计数生成;不改 schema、workflow 或 PaymentBatch 状态机。
|
||||
- [x] 固化未来节点无姓名、共享批次措辞、maker-checker 各状态、回单次级状态、付款链接权限和三源独立缺失降级;返回中不包含批次其他单据、金额或材料。
|
||||
- [x] PC/移动复用紧凑纵向时间轴:已完成无意见节点单行,当前/异常/有意见节点展开;不显示“第 N 步”、内部 step ID、角色码或权限码。
|
||||
- [x] 更新后端聚焦测试与 PC/移动 Playwright,覆盖五阶段、退回/驳回、batch 状态、回单、来源缺失、未来无姓名、材料单份/合并、编辑权限、四块顺序、44px 触控和横屏无溢出。
|
||||
- [x] 运行 Prisma validate、touched-file `node --check`、server 聚焦/必要全量、client typecheck/build、聚焦 Playwright、task/Trellis/process/docs/sensitive/diff 门禁;只读 review 跨 workflow/付款状态映射与权限,不让 reviewer 改代码。
|
||||
- [x] 实现完成后分别取得提交、推送和生产部署批准;部署与公网检查已通过,未执行真实审批、付款或回单写入。
|
||||
|
||||
## 6.17 审批节点数据驱动修正
|
||||
|
||||
- [x] 移除 `classifyApprovalTask` + 阶段键 Map 覆盖路径;固定首尾业务阶段,中间按 `workflow_tasks` 的真实稳定顺序逐任务生成唯一节点,并按提交快照补齐尚未创建 task 的未来审批节点。
|
||||
- [x] 节点标题按“可读 step name → assignee role 职责映射 → 审批”降级,过滤“步骤/节点/step + 序号”,不显示 step ID、角色码或序号。
|
||||
- [x] workflow 整体不存在时保留主管/财务 pending 占位,实例 tasks 暂为空时按快照展示;前端 stage key 改为可扩展字符串并继续纯 `map` 渲染,不增加固定位置判断。
|
||||
- [x] 新增第三个未知职责节点、两个同角色节点、未来快照尚无 task、空 task 实例单元测试,并复跑 `reimbursement-workflow-progress.spec.ts` 及既有四块/企微聚焦回归。
|
||||
- [x] 重新运行后端聚焦与全量、Prisma、前端 typecheck/build、Trellis/process/docs/sensitive/diff 门禁;更新稳定审批投影规约后申请提交,随后按本轮授权推送、部署和公网检查。
|
||||
|
||||
验证记录:
|
||||
|
||||
- 服务端聚焦 16 项通过;最终全量 171 个文件、1129 项通过,Prisma validate 与 touched route `node --check` 通过。
|
||||
- 前端 typecheck/生产 build 通过;Wave3 + R111 聚焦 2/2 通过,Wave3 同时覆盖 PC/移动 6 节点变长时间轴。
|
||||
- `reimbursement-workflow-progress.spec.ts` 首轮在临时库首次改密页停止;隔离处理种子账号后,依次暴露手工服务未启 jobs、共享 Page Object 仍等待已不展示的文件名、盲取差旅费用类型却未填概要、旧断言硬编码主管/财务五阶段四个既有测试漂移。逐项收紧测试编排与断言后,最终真实临时 PostgreSQL + server + Mock OCR 路径 1/1 通过;应用认证、OCR、差旅和 workflow 生产合同未为测试放宽。
|
||||
- workflow 实际只创建当前 task 的核查促使 DTO 继续用提交快照补齐未来 pending 审批节点;单元测试覆盖第三节点、同角色双节点、未来 snapshot-only 节点和空 tasks 实例。
|
||||
- 本轮唯一明确命名的临时测试库已关闭服务并删除;未读取或写入生产业务数据,未执行真实审批、付款或回单。
|
||||
|
||||
## 6.18 四块式详情与动态审批节点生产发布
|
||||
|
||||
- [x] 将 `de2edd6` 至 `7bd80ef` 共 6 个已验证提交推送到 `origin/main`,从远端 `main` 的干净 clone 发布 `7bd80ef4f464`;当前工作区未跟踪的 portfolio review task 未进入发布来源。
|
||||
- [x] 以活动 systemd unit 和容器只读解析既有部署、Compose 与数据目录合同,固定 `SYNC_ENV=0`、`DEPLOY_DATA_MODE=existing`;未同步生产 `.env`,未变更数据挂载、Compose 项目、worker/minio profile 或生产业务数据。
|
||||
- [x] 如实记录发布前尝试:第一次包装命令因含临时目录删除而被本地安全护栏在进程启动前拒绝;第二次从 Compose working-dir 标签推断 env 文件失败;第三次干净 clone 被切成 detached HEAD,来源门禁报 `upstream_missing`。三次均在 rsync 前停止,生产未变化;第四次保持 `main` upstream 后全流程成功,未使用未发布 override。
|
||||
- [x] 环境合同、远端 Compose 预检、init、migration deploy 与 schema status 通过;现有 63 个 migration 无待执行项。`/ready`、external deps、runtime smoke 和公网 93 个前端资源扫描通过,公网 12 位标识与活动容器完整 SHA 均匹配目标提交。
|
||||
- [x] 独立复核按层校正断言:第一次误从 `/ready` 顶层读取构建标识,第二次误在前端 bundle 查找由服务端 DTO 生成的批次文案,均为只读检查脚本漂移且未改变生产。修正后公网 bundle 命中“处理进度/报销材料”,活动 server 源码命中共享批次职责文案,未认证材料合并签发保持 401。
|
||||
- [x] 运行镜像确认旧 PDF 栅格化依赖已移除:`pdftoppm` 不存在,未安装 `pdfjs-dist`;合并主路径所需 `pdf-lib` 与项目既有 `sharp` 均可加载。构建耗时主要来自 client/server 的 `npm ci` 依赖层重装,前端 Vite 构建本身约十余秒;依赖升级和缓存优化不在本次发布窗口顺手处理。
|
||||
- [x] 复用既有 unit 的受控参数和运行时间刷新 automatic backup 三项与 WARCH3;4 个 service 构建身份均匹配 `7bd80ef`,4 个 timer 均 enabled/active。手工触发 automatic backup daily 和源端 watchdog,结果均为 `success/0`。
|
||||
- [x] 用户确认马建勇在企微真机刷新后已能正常使用本轮页面;该结果只关闭四块式详情、材料查看与动态处理进度的真机可用性验收,不代表主管审批已经完成。真实主管审批、财务复核、付款批次、银行回单和最终复核仍未完成,task 保持 `in_progress`。
|
||||
|
||||
## 7. 完成条件
|
||||
|
||||
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
|
||||
- [ ] 本任务未导入杨俊和两个既有测试成员之外的其他成员、未创建法体、未补银行资料;唯一测试业务数据已按受控生命周期清理/归档。
|
||||
- [ ] 若只完成系统就绪而真实报销尚未提交,任务保持未完成并明确剩余用户操作,不用合成测试替代。
|
||||
- [ ] 完成后归档本任务;后续按“财务与低频台账后台 + 核心流程真实采用”定位处理真实报销摩擦,不再返回已失效的 R63–R66 扩建排期,也不从本任务继续长出新的运维加固范围。
|
||||
|
||||
## 8. 已完成的脱敏证据
|
||||
|
||||
- timer:4 个 service 构建合同一致,4 个 timer 均 enabled/active。
|
||||
- 备份:源端真实批次 `success/0`,ARM 独立 dead-man `success/0`。
|
||||
- 通讯录 dry-run:输入 1、active 1、缺手机号 1、incomplete 0、计划创建 1、冲突/跳过/失败 0。
|
||||
- 通讯录 apply:创建 1、成功 1、冲突/跳过/失败 0;角色严格为 `employee`,企微绑定唯一。
|
||||
- 主管接口:状态 200、更新 1、`SYSTEM_USERS_BATCH_SET_MANAGER` 审计新增 1;企微外部写入 0。
|
||||
- 主数据:active 费用类型 37,active 报销工作流定义 1。
|
||||
- 本地回归:5 个测试文件、61 项测试通过;Prisma schema 校验通过。
|
||||
- 公网检查:`/ready` 为 ok、构建标识匹配,首页 HTTP 200。
|
||||
- 只读 review:本轮只写 OA 员工、身份绑定和 OA 主管关系;未修改角色矩阵、workflow 代码、状态机、数据库 schema 或企微成员资料。
|
||||
- 测试账号受控闭环:dry-run/apply 2/2、最小角色和主管关系通过;普通浏览器双账号登录、首次改密、0.01 元测试报销、合成 PDF 上传、提交、主管退回、草稿删除、票据归档均通过;最终 OA 测试账号 archived 2、活动会话 0、企微同步 skipped 2、测试报销与 pending 待办均为 0。
|
||||
- finding:生产 OCR 为 mock 且真实腾讯 OCR 未配置,合成 PDF 自动生成 Mock 字段;草稿删除后页面导航前产生已删除详情/precheck 的 404 控制台噪声。前者阻断杨俊真实票据上传,后者不阻断业务。
|
||||
- OCR off 复验:配置切换后未改构建;三次有业务写入的复验/诊断均按“退回 → 删除 → 归档 → 停用”收口。累计 4 张合成测试票据(含第一轮)均已归档,未归档 0;测试报销与 pending 待办均为 0。
|
||||
- OCR off 结论:空发票业务字段可以绑定并提交,主管退回成功;真实使用暂由财务查看 PDF 原件并人工核对。文件 SHA-256 查重仍有效,发票号查重暂不可用。
|
||||
- 真实首跑复核:三名参与人 active 且企微绑定唯一,但企微首登均未发生;费用标准 0、适用预算池 0、active 法体 0,法体不阻断当前报销。历史 mock 票据仅 1 张且为测试账号上传,非测试员工为 0。
|
||||
- 审批规则 finding:`[0,500)` 可由马建勇和唯一 finance 王芝玲处理;`[500,5000)` 与 `[5000,+∞)` 均以 `finance_mgr` 收尾,但 active `finance_mgr=0`,达到 500 元会形成无人处理待办。未修正规则前不得笼统通知杨俊可提交。
|
||||
- 审批规则收口:写入前报销 reviewing/工作流 pending/待办 pending 均为 0;备份 watchdog 与 ARM dead-man 均为 success。正式接口更新 3 次、审计新增 3,生产仅剩 1 条 `[0,+∞)` active 规则。
|
||||
- 金额边界验证:实际工作流构造函数对 499.99、500、5000 均输出 `department_manager → finance`;直属主管解析到马建勇,active finance 唯一且由王芝玲承担。写后仍无报销、实例或待办新增,公网 ready 正常、构建标识未变化。
|
||||
- 真实报销生产只读核验:杨俊最新真实报销为 `reviewing`,工作流为 `pending`,当前唯一待办为主管审批且已指派主管;付款批次 0。未输出金额、单号、UUID、附件或原始 payload,未执行生产写入。
|
||||
- 真实验收 finding:主管能进入审批任务详情,但该页只有报销摘要,没有费用明细、发票、附件或完整报销单入口;因此尚未按业务事实审批。
|
||||
- 审批详情入口本地实现:移动任务详情新增“查看完整报销单”,复用移动报销详情和 `state.from`;PC 任务详情新增“查看报销单”。未修改后端、权限矩阵、审批状态机或列表卡片点击行为。
|
||||
- 首次聚焦 Playwright 运行因本地前端服务未启动,9 个用例均在 `/login` 前以 `ERR_CONNECTION_REFUSED` 失败,未执行到功能断言;启动 Vite 后改为只跑本次相关用例。
|
||||
- 移动聚焦用例首次进入/返回链路通过,随后因固定在 2026-05 的“已处理”测试数据超过页面最近 30 天窗口而失败;已把完成时间改为运行时近期时间,避免日历漂移。
|
||||
- 最终本地验证:`npm run typecheck` 通过,`npm run build` 通过;移动审批入口聚焦 Playwright 1/1 通过,PC 审批入口聚焦 Playwright 1/1 通过。
|
||||
- 交付门禁:当前 task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 均通过;文档注册审计为 0 error、1 条既有未注册文档汇总 warning。
|
||||
- 生产发布:`d2841b166dc0` 已部署,迁移无待执行项,发布脚本的 `/ready`、外部依赖、运行时和公网资源扫描通过;生产 `.env` 未同步。
|
||||
- 发布后运维身份:automatic backup 三项与 WARCH3 共 4 个 service 均匹配活动构建,4 个 timer 均 enabled/active;新 automatic backup daily 和源端 watchdog 均为 `success/0`。
|
||||
- ARM 证据边界:新批次已由 receiver 校验并 finalize;ARM dead-man timer enabled/active、最近自动结果 `success/0`,但本次没有扩大 sudo 权限来即时手工复跑独立 dead-man。
|
||||
- 公网入口:生产移动资源存在“查看完整报销单”,PC 资源存在“查看报销单”;只验证静态发布资源和构建身份,不读取真实报销数据。
|
||||
- 报销详情时区:共享日期时间格式化固定 `Asia/Shanghai`;聚焦 Playwright 将浏览器强制设为洛杉矶后,合成 UTC 附件与审批时间仍分别显示为上海 `17:10`、`17:05`,1/1 通过。
|
||||
- KISS 视觉复核:PC 与移动合成截图确认重复概况、状态、教学和空辅助区已收敛;未读取生产真实报销。Wave3 移动整段 1/1 通过;PC 大用例先通过本轮报销/审批断言,随后在未改动的合同页因找不到“筛选”按钮超时,因此不把该大用例记录为全绿。
|
||||
- 编辑态回归:R111 移动发票/附件能力 1/1 通过;PC 首轮因旧断言仍要求空“预算与审核提示”显示而失败,按新合同更新断言后,PC 发票与附件流程 1/1 通过。
|
||||
- 本地质量门禁:前端 typecheck/build 通过;task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。
|
||||
- 企微旧包诊断:真机截图中的报销日期与已部署按钮属于同一个无额外条件的 JSX 块;公网 `/ready` 为部署构建且当前移动 chunk 包含入口文案,因此排除权限、报销关联缺失和未进入详情,结论为存活 WebView 旧包。
|
||||
- 旧包刷新护栏:页面回到前台时用 `/ready` 与前端构建标识做一次 no-store 比对;不一致只提示用户刷新,点击后清理 PWA 缓存并重载,同一生命周期不重复提示;一致、unknown/development、异常响应和网络失败静默。
|
||||
- 刷新护栏回归:mismatch/match/failure 聚焦 Playwright 3/3 通过,完整 login/portal UX 15/15 通过,typecheck/build 通过;375px 竖屏与 667×375 横屏无水平溢出,刷新按钮高度 44px。手机专项首次因测试在 React 壳监听器挂载前人工触发事件而失败,等待移动工作台渲染后复跑通过;未改生产或真实业务数据。
|
||||
- 原生预览调研:官方 `previewFile` JSAPI 与腾讯维护的 `@wecom/jssdk` 均确认支持企微 iOS/Android;普通 PDF 链接“只下载”不等于原生 JSAPI 不支持预览。原生调用必须提供可直接取件的短时 URL、带扩展名文件名和准确字节数。
|
||||
- 原生预览实现:服务端新增同源 JS-SDK 签名、发票元数据和附件 120 秒以内对象授权;客户端只在企微 WebView 动态加载 SDK。PC 发票使用短时 URL,PC 附件保留认证 Blob;移动发票/附件统一显示“查看原件”。未修改角色矩阵、workflow、数据库 schema 或真实业务数据。
|
||||
- 短时授权只读 review:grant 绑定用途、报销、附件、actor 与过期时间;签发前检查当前对象权限和下载理由,实际取件审计失败时不发送文件。review 发现附件 GET 初版会在普通未认证请求前查询对象,已改为无 grant 先认证、有 grant 先验签再查对象,并用回归固定 401/403 前置拒绝。
|
||||
- 原生预览回归:最终后端聚焦 3 个文件、46 项通过;服务端全量基线 169 个文件、1101 项通过;Prisma validate、touched server `node --check`、前端 typecheck/build 通过。聚焦 Playwright PC/企微 2/2 通过,mock bridge 收到准确 URL/name/size,企微路径未调用 `window.open`。
|
||||
- 原生预览治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计 0 error、1 个既有注册汇总 warning。稳定例外已写入 `wecom-boundary`、`security-privacy` 与 `reimbursement-invoice-ocr` spec。本步骤未部署生产,真机和真实材料验收仍待单独批准。
|
||||
- 原生预览生产发布:`410d8cd71b1f` 已部署,`SYNC_ENV=0`,62 个 migration 无待执行项;`/ready`、external deps、runtime smoke 与公网 bundle 扫描通过,未登录 JS-SDK 签名请求为 401。第一次尝试在同步前因示例目录权限失败且生产未变化,第二次通过活动 Compose 标签解析实际发布根目录并复用既有受控 root 运维入口成功。
|
||||
- 本次发布后运维身份:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `410d8cd`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。
|
||||
- 新增 finding:镜像构建审计为 client 18 个风险(10 high)、server 32 个风险(20 high、2 critical),未执行自动修复;既有部署 smoke 还会输出不必要的配置标识和测试管理员联系方式,后续需分别建立依赖升级回归和证据最小化收敛项。
|
||||
- 外部验收边界:生产已部署但企微真机中的无业务 PDF 原生预览、马建勇对真实发票与附件的可见性仍待人工验收;在材料真实可见前不得完成主管审批。
|
||||
- 移动范围对齐:管理员移动报销页新增“我的 / 全部”,默认 `own`,选择“全部”后请求现有 `scope=all` 并把 status/scope 带入详情回跳来源;普通员工即使 URL 带 `scope=all` 仍请求 `own` 且看不到切换。首轮 Playwright 因未显式指定本地 Vite 地址而在登录前连接失败;指定 `E2E_BASE_URL` 后功能执行,首次严格 44px 断言测得浏览器子像素值略低于 44,控件提升为 48px 后最终聚焦 2/2、同文件完整回归 5/5 通过。375px 竖屏和 667×375 横屏无横向溢出,typecheck/build 通过。
|
||||
- 移动范围生产发布:`20b20b1` 与 `8b376f4` 已推送,`8b376f413585` 以 `SYNC_ENV=0` 部署;无待执行 migration,公网 ready、external/runtime smoke、公网扫描和活动移动 chunk 标识通过。第一次本地命令在进程启动前被安全护栏拒绝,第二次因 portfolio review 令来源校验在同步前停止;临时 stash 后第三次成功,目录已原样恢复。4 个 service 构建身份匹配、4 个 timer enabled/active,新 daily 与 watchdog 均为 `success/0`。企微管理员真机的“全部”列表及发票/附件原生预览仍待用户验收。
|
||||
- 真实材料查看验收:用户随后确认已能从自己的企微管理员账号找到当前真实报销并查看材料;该确认只关闭查看阻断,不代表主管审批、财务审核、付款批次或最终复核已经完成。
|
||||
- 明细/材料 KISS 实现:PC 与移动端按 `linkedItemId` 在费用明细内显示可预览发票,未关联发票只留在统一“报销材料”区;材料区显示类型计数并以整行作为预览入口,后台类型、权限、审计、短时 URL 和企微 `previewFile` 均未改变。
|
||||
- 聚焦 Playwright 首轮因本地前端服务未启动,PC/移动 2 项均在 `/login` 前 `ERR_CONNECTION_REFUSED`,未进入功能断言。启动 Vite 并指定 `E2E_BASE_URL` 后,两端首次都因旧测试仍假设销售方文本唯一而触发 strict-mode failure;这是新界面同时在关联明细和材料区展示同一发票摘要后的断言漂移,改为按区域断言。
|
||||
- 下一轮 PC 通过,移动在“添加附件”后因测试仍从旧附件子区查找已移到统一材料区的隐藏 file input,等待至用例超时;页面快照确认实现已渲染且未发生业务错误。测试改为从材料区定位上传控件后,移动单项通过。
|
||||
- 最终本地验证:生产 build(含 typecheck)通过;R111 PC/企微双端与 Wave3 只读时区/KISS 聚焦回归 3/3 通过,企微 `previewFile` 参数、PC 打开 URL、关联/未关联发票、材料计数、无逐文件“查看原件”按钮、44px 触控目标和 667×375 横屏无溢出均有合成断言。
|
||||
- 最终治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计为 0 error、1 条既有汇总 warning(22 个历史未登记文件)。稳定展示合同已写入 `reimbursement-invoice-ocr` spec;未部署、未读取或写入真实业务数据。
|
||||
- 明细/材料生产发布:`665dcab` 与 `0e44aa7` 已推送,`0e44aa72f3f5` 以 `SYNC_ENV=0` 部署;无待执行 migration,schema、`/ready`、external/runtime smoke 与公网资源扫描通过,公网递归扫描 91 个 JS 资源并在 2 个活动资源中命中统一材料区标识。
|
||||
- 本次发布第一次本地命令因包含临时目录递归清理,被安全护栏在进程启动前拒绝,未生成快照、未连接或改变生产。第二次移除清理动作后,从干净 clone 发布成功;发布后按提交标识和受限临时目录模式删除本轮唯一快照,未保留构建日志。
|
||||
- 发布后运维对齐:只读检查先发现 automatic backup 三项与 WARCH3 均仍为旧构建身份;复用现有 unit 参数和运行时间刷新后,4 个 service 匹配 `0e44aa7`、4 个 timer enabled/active。新 automatic backup daily 与源端 watchdog 均为 `success/0`。
|
||||
- 本轮未同步生产 `.env`,未修改权限、审批状态机、企微认证或业务数据,未执行真实审批、财务审核、付款批次、银行回单或付款动作;真人企微 KISS 页面验收仍待用户刷新确认。
|
||||
- 整单材料实现:PC/移动费用明细不再渲染关联发票;只读态以一个“查看全部材料”入口生成有序图片包,企微调用一次 `previewImage`,PC 使用共享材料页连续显示;编辑态和失败降级仍保留逐份原件管理/查看能力。
|
||||
- 票种显示回归:合成数电发票与电子火车票分别显示正确票种;数电发票保留销售方,火车票使用购买方且不再出现“无代码 / 未识别销售方”;未重跑或改写生产 OCR 数据。
|
||||
- 后端验证:聚焦 5 个测试文件、23 项通过;服务端全量 171 个文件、1109 项通过,Prisma validate 与 touched-file `node --check` 通过。新增断言覆盖顺序、页/源/输出/缓存上限、token 篡改、缓存缺失、下载理由、访问审计失败和安全票种映射。
|
||||
- 前端验证:生产 build(含 typecheck)通过;R111 PC/企微 2/2 通过,覆盖费用明细无发票入口、票种文案、统一计数、材料页与一次有序 `previewImage.urls` 调用。
|
||||
- 目标镜像验证:`server_build` 成功安装 `poppler-utils`;Playwright 生成的无业务合成单页 PDF 经容器内生产 `reimbursementMaterialPreview` 转换为 1 页 PNG,探针结果为 `raster-probe-pass`。未合并、改写或使用真实票据。
|
||||
- 治理复核:task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。首次敏感扫描命令误指向不存在的旧路径并立即失败,改用当前 `scripts/qa/check-sensitive-content.mjs` 后 30 个变更文件、0 finding;该失败未执行生产或业务写入。
|
||||
- 本步骤只完成本地实现与验证;未推送、未部署、未修改生产配置或真实业务数据,不能据此通知真人线上测试。
|
||||
- 整单材料生产发布:`8a9279efceee` 已部署,`SYNC_ENV=0`,62 个 migration 无待执行项;`/ready`、external/runtime smoke、公网扫描与未登录 401 边界通过,目标容器内 `pdftoppm` 可用。
|
||||
- 本次长构建主要是 Poppler 首次进入生产基础层后引起系统包安装和 client/server/init 缓存失效;首轮路径权限、无进展 clone、启动前安全护栏拒绝和本地连接中断均已如实记录,未美化为一次成功。
|
||||
- 合并 PDF 本地实现:材料按发票/附件各自 `created_at,id` 显式排序,集合哈希纳入缓存键;`pdf-lib` 复制全部 PDF 页,Sharp 仅处理图片,固定 PDF 元数据与保存选项,相同输入两次生成字节完全一致。
|
||||
- Worker/边界验证:Node `worker_threads` 成功合并 25 页无业务 PDF 且页数完整;加密 PDF 和损坏 PDF 均整包失败,未使用 `ignoreEncryption`,未启用 Compose worker profile;同集合并发请求只生成一次。
|
||||
- 签发与审计验证:接口只返回一个短时 PDF URL、名称、精确字节数和页数;GET 复核 `content-length` 与响应字节一致,token 篡改、缓存缺失和访问审计失败均在发送字节前拒绝。
|
||||
- 前端回归:R111 PC/企微双端 2/2 通过;移动入口只在报销材料区,PC 只打开一个 PDF URL,企微只调用一次 `previewFile` 且 URL/name/size 精确;PC/移动均显示合成差旅概要。
|
||||
- 数据合同验证:Prisma schema 通过;63 个 migration 在临时空 PostgreSQL 16 数据库完整 apply;新迁移只增加四个 nullable 差旅概要列,未回填或改写真实审批中数据。
|
||||
- 最终质量:server 全量 171 个文件、1114 项通过;client typecheck/生产 build 通过;task、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过。文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。
|
||||
- 发布后运维对齐:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `8a9279e`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。
|
||||
- 四块式详情实现:PC/移动均收敛为“单头概况 → 报销明细 → 报销材料 → 处理进度”;差旅概要、关联事项和真实风险并入单头,材料列表对只读审批人常显,单份与合并预览沿用既有权限和审计路径。
|
||||
- 五阶段只读投影:详情 API 固定返回申请提交、主管审批、财务复核、付款批次复核、付款与回单;付款批次未创建时可明确显示“待财务创建付款批次”,未来处理人不预填,普通申请人不获得批次跳转或批次其他业务信息。
|
||||
- 五阶段验证:后端最新聚焦 12 项覆盖草稿、审批中、退回/驳回、批次 draft/reviewed/executed/failed/cancelled、回单有无、无批次历史付款和批次链接权限;服务端全量基线 171 个文件、1123 项通过。Prisma validate 与 touched route `node --check` 通过。
|
||||
- 双端验证:client typecheck/生产 build 通过;R111 PC/企微 2/2、Wave3 只读详情 1/1 及移动视觉聚焦 2/2 通过,覆盖材料常显、只读无删除、四块顺序、五阶段无“第 N 步”、44px 触控和无横向溢出。视觉复核确认 PC 主内容不再因空侧栏过窄。
|
||||
- 只读 review:按 workflow task、PaymentBatchItem/batch、回单计数三源逐项复核映射与权限;修正“workflow 已通过但尚未建批次”原本无当前节点的问题,使其明确落在“待财务创建付款批次”。确认未来 checker 不预填、普通申请人无批次链接、共享批次不暴露其他单据/金额/材料;review 未修改状态机、权限矩阵或生产数据。
|
||||
- 最终治理:task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。本步骤未部署、未推送、未修改生产配置、真实报销状态、付款批次、回单或业务数据。
|
||||
- PC/移动 UX 最小收口:PC 从带 `status/page/scope` 的报销列表进入审批任务后可返回同一查询,从审批任务进入报销详情后显示“返回审批详情”,直接打开详情显示“返回报销列表”;业务动作区不再重复列表入口。共享 `formatMoney` 统一总额、明细、发票和标准提示为人民币两位小数;移动底栏只保留“提交审批”,材料区继续保留“添加发票”。
|
||||
- UX 聚焦验证:Wave3 返回/金额/视觉相关 5 项通过,R111 PC/企微材料相关 2 项通过;typecheck 与生产 build 通过,截图确认 PC 返回位于标题上方、移动底栏为单一主按钮。旧 R5 综合移动用例 2 项在进入报销详情前因采集器仍查 `approvals/reimbursements/me` 等旧 key 而只识别出 2 个入口;页面对该经理角色实际仍渲染“新建报销、上传票据、请假记录、采购”4 项,上一轮“实际为 2 个”的表述已在本轮勘误。
|
||||
- R5 门禁收口:测试从常用动作宫格动态采集入口,但固定断言经理角色的 4 项 key/标签集合,并保留双设备 44px、单行、SVG、边界和无重叠检查;详情返回按当前统一详情壳断言为按钮。R5 单跑 2/2 通过,并登记到 `route-manifest.json` 的 `acceptanceSpecs`。
|
||||
- 候选门禁修复:`run-local-candidate.sh list` 首轮暴露隔离夹具漏建普通 `bash child-script`,修正为按命令行同时发现 `bash` 与 `bash -n` 子脚本;focused 首轮随后揭示 Wave3 合同页在 Radix 菜单仍打开时等待背景“筛选”按钮至 300 秒超时,改为先完成页面操作、最后验证“更多/导出 CSV”。最终本地候选 50 项、10 个核心 witness 全通过,0 failed、0 skipped、0 unexpected,状态为 `FOCUSED_BROWSER_PASS`。
|
||||
- 门禁治理复验:QA Node 测试 52/52 通过;flow coverage 将 acceptance spec 从 7 项增至 8 项,8/8 有效且 0 blocking gap;前端最终 `typecheck` 与生产 build 明确 `exit=0`,PWA 产物生成完成。
|
||||
- UX 治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。稳定的 PC 单层来源返回、共享人民币展示及 acceptance spec 必须接入真实门禁的合同已写入 `frontend-react-vite-design` spec。本步骤未部署、未推送、未修改生产配置或真实业务数据。
|
||||
- `5dac336` 生产发布事故与恢复:首次尝试把 Compose 配置目录误当仓库根目录,rsync 已把源码同步到错误嵌套位置,随后因重复拼接的 `.env` 路径不存在而停止;旧容器未重建且持续服务,但远端 `.env` 被错误同步删除。恢复只从仍运行的 server 容器按 `.env.example` 允许键提取,163/163 键可恢复、关键键缺失 0,原子写回后权限为 `0600`;未输出值或原始环境。第二次使用正确根目录发布成功并清理错误嵌套代码树,不能表述为一次成功。
|
||||
- `5dac336fbf851a4196ee6607e5705ee840dce92f` 生产成功证据:63 个 migration 无待执行项;`/ready`、external deps、runtime smoke 和公网资源扫描通过;公网、活动容器与 4 个运维 service 构建标识匹配,4 个 timer enabled/active,automatic backup daily 与 watchdog 均为 `success/0`。本次未改真实报销、审批或付款数据。
|
||||
- 部署加固实现:新增既有部署包装入口,只从 automatic-backup systemd unit 读取 6 个 allowlist 参数且不回显值;远端仓库根、普通非 symlink `.env`、existing PGDATA 与 storage 在 `mkdir`/rsync 前检查。Dockerfile 把 `apk add`、client/server `npm ci` 移到 build SHA 之前;seed/smoke 不再打印完整登录标识、联系方式、企业微信标识、redirect URI 或生成凭据。
|
||||
- Docker 双 SHA 实测:第一次冷构建 `server_build` 通过,基础包约 124 秒、client `npm ci` 约 66 秒、server `npm ci` 约 85 秒;仅把前后端构建 SHA 改为另一组值后,三层均明确显示 `CACHED`,第二次约 78 秒,主要剩余前端 typecheck/Vite/PWA 构建与薄的镜像导出。第二个镜像内完整 `BUILD_SHA` 与前端 12 位标识均命中预期。
|
||||
- 加固验证:部署安全聚焦测试 7/7;server 全量 172 个文件、1136 项通过,退出码 0;Prisma validate、3 个 shell 语法、4 个 JS 语法、环境合同、task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 均通过。文档审计为 0 error、1 条既有汇总 warning(22 个历史未登记文件)。本步骤只完成本地流程加固,尚未提交、推送或部署。
|
||||
|
||||
## 9. 五人推广前的采用扩展阶段(本轮新增规划)
|
||||
|
||||
1. **本地静态合同复核**
|
||||
- 核对当前报销创建/提交硬门禁、`resolveEntityPrecheck`、权限解析、Prisma 状态枚举及历史闭环关联键。
|
||||
- 确认 `people_lifecycle` 不进入人员分母,法体不进入提交硬门禁;把各实体的计数口径和允许输出字段写成查询合同。
|
||||
- 为脱敏汇总增加 fail-closed 判据:预期模块/枚举缺失、字段名不匹配或异常空摘要立即失败。
|
||||
2. **申请并执行生产只读 PRE**
|
||||
- 只读确认执行身份、运行 SHA、migration pending 状态、回滚窗口和数据库只读通道;保存脱敏基线。
|
||||
- 本步骤不登录真人账号、不调用业务 mutation、不修改生产配置或数据。PRE 结果交用户复核后,才申请正式 readiness 查询批准。
|
||||
3. **严格一次 readiness 与历史漏斗查询**
|
||||
- 分别采集 active/可登录员工、主管覆盖及三项有效权限;active 费用类型、workflow、feature flag、票据上传/OCR 和提交阻断配置。
|
||||
- 分别按报销、票据池、workflow instance/task、付款批次、回单聚合状态,并按真实关联判断完整闭环和各停点。
|
||||
- 输出只含计数、布尔、枚举分布和脱敏结论;如需补读,明确登记为 supplemental,不改写首次执行次数。
|
||||
4. **形成 go/no-go 矩阵**
|
||||
- 将缺口分为:可直接推广、推广前必须处理、只记录不阻断;法体默认属于观察项,除非现场代码合同出现新证据。
|
||||
- 任一需要账号、权限、主数据、OCR、历史状态或代码变化的处理项单独规划、单独批准。本阶段不修复。
|
||||
5. **决定真人试走是否启动**
|
||||
- 仅在 readiness 结论允许且用户另行批准后,等待第二位真实员工的真实报销需求;不创建合成业务。
|
||||
- 由本人走上传票据、建单、明细、绑定、提交及后续真实审批/财务链;熟练管理员代走时标记为“真人摩擦下限”。
|
||||
- 全部摩擦记录,本轮最多处理三个;登录、认证、权限、数据安全或需生产写入的异常立即停止,不现场绕过。
|
||||
6. **本轮规划验证与停点**
|
||||
- 运行 task validate、Trellis governance、process contract、docs registry audit、敏感内容扫描与 `git diff --check`。
|
||||
- 本轮停在规划只读复核;不得因 task 已为 `in_progress` 而自动执行生产 PRE、readiness 查询或真人试走。
|
||||
|
||||
### 9.1 首次生产 PRE 尝试(不完整,停止待补采批准)
|
||||
|
||||
- 唯一一次生产执行已通过精确生产 origin/目录身份、运行构建与 `origin/main` 候选一致、migration `pending=none`,回滚窗口为 `OPEN / terminatedRows=0 / basis=terminated_status_count`。
|
||||
- readiness 聚合器在 `BEGIN TRANSACTION READ ONLY` 内成功退出,但本地输出校验器错误地要求运行 `config.buildSha` 等于 40 位候选 SHA;当前合同实际为候选的 12 位公开前缀。校验因此返回 `QUERY_OUTPUT_INVALID`,安全摘要没有输出或留存,员工/权限/生命周期分布均不得从本次执行推断。
|
||||
- 本次没有登录真人账号、调用业务 API mutation、修改配置或执行数据库写入;没有自动重试。校验器已按既有公开构建标识合同修正为比较 `expected.slice(0, 12)`,后续只允许在用户明确批准后以 supplemental read 重新采集,并如实记为第二次只读查询。
|
||||
|
||||
### 9.2 Supplemental 生产只读核验与任务收口
|
||||
|
||||
- 用户明确批准后于 2026-08-23 执行一次 supplemental read。生产身份、活动运行构建、`migration pending=none`、只读事务和回滚窗口均通过;查询执行 1 次、重试 0 次,未登录真人账号、调用业务 mutation、修改配置或写数据库。
|
||||
- 人员与权限:6 名 active 员工均具有登录标识、密码凭据和企微绑定;6 人具备 `expense.submit` 与 `invoice.upload`,4 人具备 `expense.approve`。3 人具有有效且不同人的 active 直属主管;杨俊与马建勇的唯一性、active、审批权限和直属关系合同全部成立。
|
||||
- 主数据与能力:38 个 active 费用类型、1 个 active 报销 workflow、1 个 active 法体观察项;票据上传已配置,生产 OCR provider 为 `tencent`,付款批次开关有效。发票验真开关关闭仅作为观察项,不冒充腾讯 OCR 未启用。
|
||||
- 生命周期:报销 `paid=1`、`draft=1`,其余状态为 0;报销 workflow `approved=1`、`terminated=1`,任务 `completed=3` 且无 pending;付款批次 `executed=1`、`cancelled=1` 且无 draft/reviewed;回单 1 份且治理状态为 approved;票据均已 archived。
|
||||
- 关联闭环查询确认 `completeSubmitApprovePayReceiptLoops=1`,因此杨俊差旅报销的“提交 → 审批 → 付款 → 回单”已走完,旧待办完成勘误。现存 1 条 draft 和 1 个 cancelled 批次作为历史停点保留,本轮不读取正文、不自动修复或删除。
|
||||
- 推广矩阵结论为 `CONDITIONAL_GO`:登录、企微绑定、提交/上传权限、主数据、OCR、workflow、付款和历史闭环均为 GO;直属主管仅覆盖适用人员中的一部分,新增真人首次提交前必须逐人核对主管关系。第二位真实员工试走仍需在真实需求出现时另行建任务,不制造测试业务。
|
||||
@@ -0,0 +1,172 @@
|
||||
# 杨俊报销就绪
|
||||
|
||||
## Goal
|
||||
|
||||
让已在企业微信通讯录中的杨俊尽快具备真实报销能力,同时避免让真实员工承担首轮试错:先使用通讯录中既有的“测试员工”和“测试主管”两个企微测试成员,在生产 OA 完成一条受控、可清理的报销体验验收;验证通过并清理测试数据后,再请杨俊通过企微进入 OA、创建并提交真实报销,由其真实直属主管马建勇审批,再进入王芝玲负责的财务处理链路。
|
||||
|
||||
## Background
|
||||
|
||||
- 杨俊已存在于管理员导出的企业微信通讯录,因此具备企微成员账号;生产只读体检已确认他尚无 OA 员工记录和企微绑定。
|
||||
- 上一轮通讯录 apply 只包含两名财务职责目标,明确没有把其余四名成员送入生产输入。
|
||||
- 报销是当前急需的真实业务,不等待站内通知、入职向导、审批流简化或月度报表等后续产品项。
|
||||
- 生产写入前需同时确认自动异机备份 timer 的构建合同未因最近部署发生漂移。
|
||||
- 通讯录单人子集唯一、active、企业邮箱完整、手机号缺失但满足现有 nullable 合同。
|
||||
- 当前有 37 个 active 费用类型和 1 个 active 报销工作流定义;active 法体为 0,但代码确认报销创建/提交不读取法体,法体不是本次硬门禁。
|
||||
- 当前自动备份 daily/watchdog/drill-reminder 和日常巡检 service 的 BUILD_SHA 均与活动 server 不一致;最近一次备份成功发生在该漂移被今晚任务触发之前,不能视为今晚安全。
|
||||
- 管理员导出的通讯录中已有“测试员工”和“测试主管”两个专用企微测试成员;二者均有唯一账号、部门及联系方式且状态为已激活,但生产 OA 尚无姓名包含“测试”的员工或绑定。
|
||||
- 受控测试发现生产 `OCR_PROVIDER=mock` 且腾讯 OCR 凭证未配置;合成 PDF 被写入虚构的 Mock 发票字段并直接标记可用。该行为可用于测试,但不得作为杨俊真实发票的正式识别结果。
|
||||
- 生产已将 `OCR_PROVIDER` 切换为 `off` 并重建实际承载后台任务的 server;复验确认票据不进入 OCR 队列、业务字段为空且仍可绑定和提交。`ocr_provider` 因既有非空 schema 默认值仍记录为 `tencent`,但 OCR 尝试数和请求/完成时间均为空;该历史字段语义不作为“已调用腾讯 OCR”的证据。
|
||||
|
||||
## Requirements
|
||||
|
||||
- 生产只读体检必须一次性核对:OA 员工、企微绑定、active 状态、角色、直属主管、主管 active/审批权限、可用费用类型、报销功能开关和审批定义;法体仅记录现状,不作为报销阻断项。
|
||||
- 同时比较自动备份 systemd unit 中烤入的 BUILD_SHA 与活动 server 构建标识;只输出是否一致,不输出环境文件或宿主路径。
|
||||
- 若 OA 账号缺失,复用现有通讯录 stdin 路径只筛选目标员工,先 dry-run,另行获批后 apply;初始角色严格为 `employee`,其余通讯录成员不进入输入。
|
||||
- 通讯录导入只允许把企微管理员导出的成员资料写入 OA 并建立身份绑定;OA 不调用企微成员 create/update/disable,不反向修改企微姓名、部门、主管、手机号或邮箱。企微成员资料由本人在企微或企微管理员在管理后台维护。
|
||||
- 将杨俊的 `direct_manager_id` 设置为用户已确认的真实直属主管马建勇;写入前仍需只读确认马建勇为不同、active 且具有 `expense.approve` 权限的 OA 员工,不得为了报销绕过审批或扩宽角色。
|
||||
- 若费用类型或报销工作流缺失,只补足完成报销所需的最小主数据;不为了本次报销新建法体,不顺带整理全部组织架构或导入其余员工。
|
||||
- 杨俊写入后只做账号、绑定、主管解析、费用类型和提交流程的安全验证;杨俊本人名下不创建测试报销、测试发票或测试付款数据。
|
||||
- 允许从同一份管理员通讯录中仅筛选既有的“测试员工”和“测试主管”两个企微测试成员,先 dry-run 再 apply;不得新建企微成员,不得把其他真实成员带入输入。
|
||||
- 两名测试成员进入 OA 后,“测试员工”角色严格为 `employee`,“测试主管”角色严格为 `employee + manager`;测试员工的 OA 直属主管仅指向测试主管。不得授予 `finance`、`finance_mgr`、`administration` 或 `admin`,不得借测试查看真实薪资、银行资料或系统管理数据。
|
||||
- 每次受控验证最多创建一条明确标记“系统测试、非真实业务、禁止付款”的测试报销和一张不含真实票据、银行或个人资料的安全测试 PDF;金额使用最小测试值,测试单不得审批通过或进入财务付款链路。配置复验或固定门禁诊断需要重跑时,每批都必须独立退回、删除报销并归档票据。
|
||||
- 受控测试必须覆盖:两个测试成员分别通过企微登录、申请人创建/编辑报销、上传安全 PDF、处理 OCR 失败分支(如发生)、绑定票据、提交、主管待办可见及执行“退回”。退回后申请人删除 draft 报销,释放后的测试票据手工归档。
|
||||
- 测试结束后撤销两个测试成员的 OA 会话并停用其 OA 账号;仅停用 OA,不修改或停用企微中的两个测试成员。后续需要复测时可通过现有 OA 接口重新启用。
|
||||
- 杨俊上传真实发票前必须消除生产 mock OCR:要么配置并验证真实腾讯 OCR,要么显式关闭 mock 并采用人工录入/核对方案;不得把 Mock 字段展示为真实识别结果。
|
||||
- 真实报销提交前至少需要一条费用明细和一张已绑定、状态有效的票据;这由员工在真实业务中提供,不用合成数据代替。
|
||||
- 最近部署后必须重装自动备份三项 service/timer 和日常巡检 timer,使 unit BUILD_SHA 与活动 server 一致;重装后手工触发一次真实自动异机备份,确认源端成功且 ARM 恢复点新鲜,再允许写入杨俊账号或主管关系。
|
||||
- 第一笔真实业务由员工本人正常发起,目标链路为“员工提交 → 真实主管审批 → 财务处理”;付款批次仍遵守既有 maker-checker。
|
||||
- 当前小团队取消报销金额分层,所有金额统一按“直属主管 → finance”审批;不得保留无人持有的 `finance_mgr` 节点,也不得因马建勇同时是主管和 CEO 而让同一人重复审批。
|
||||
- 真实首笔报销暴露审批可读性阻断:待办列表卡片能够进入审批任务详情,但任务详情只显示单号、申请人和金额摘要,没有通往费用明细、发票和附件的业务详情入口。移动端必须在审批任务详情区提供明确的“查看完整报销单”入口,复用既有 `/mobile/reimbursements/:id` 路由和 `state.from` 回跳约定;PC 审批任务详情同步提供报销单入口。
|
||||
- 报销详情当前把后端 ISO UTC 时间直接截取为字符串,附件和审批记录会在中国时区显示早 8 小时;报销链路的日期时间必须复用共享格式化能力并固定按 `Asia/Shanghai` 展示,不修改数据库时间或 API ISO 合同。
|
||||
- PC 与移动报销详情应按 KISS 原则收敛为四个稳定内容块:“单头概况 → 费用明细 → 报销材料 → 处理进度”。差旅事由、地点和起止日期并入单头,不保留独立差旅卡;项目/差旅申请关联、真实风险和付款事实仅在存在时作为相邻行或节点次级信息显示。页面纯纵向滚动,不增加 Tab、折叠或抽屉,同时保留编辑态必要控件、风险提示和审计信息。
|
||||
- 生产真机验收确认企微 WebView 可继续运行部署前的旧前端包:公网资源已包含“查看完整报销单”,但存活页面仍只显示报销日期。页面从后台恢复到前台时必须用现有前后端构建标识做一次轻量比对;不一致时只显示用户可控的“发现新版本,点击刷新”,点击后清理旧 PWA 缓存并重载,不自动打断当前页面操作,不扩展为完整更新管理系统。
|
||||
- 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。
|
||||
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
|
||||
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
|
||||
- 生产可见性验收进一步确认:费用明细内再次展示并预览关联发票仍然重复,审批人希望一次进入后连续查看本单全部材料。PC 与移动端应统一采用“明细只讲费用、材料一次通览”:费用明细不再渲染发票摘要或原件入口;发票和补充附件仍保留各自后台类型、关联、权限和审计,但只在单一“报销材料”区汇总,并提供一个“查看全部材料”动作。
|
||||
- 企业微信官方 `previewFile({ url, name, size })` 一次只接收一个文件。为满足企微与 PC 一次进入、连续查看全部 PDF/图片,本任务将整单材料按稳定顺序派生为一个短时合并 PDF:PDF 原页全部复制,图片各占一页;企微调用一次 `previewFile`,PC 交给浏览器内置 PDF 阅读器。合并件仅供审批核对,不替代原件;任一材料加密、损坏、不支持、超限或合并失败时整包失败并回退逐份原件,绝不静默跳过材料或 PDF 页。
|
||||
- 生产只读核查确认当前真实报销的 3 张票均已由腾讯 OCR 成功处理:号码、日期和金额齐全,无 OCR 错误;2 张为电子火车票,1 张为数电发票。三者按票种均无传统发票代码,电子火车票也没有销售方字段。页面显示“无代码 / 未识别销售方”属于票种展示错误,不得通过重新调用 OCR、伪造代码或把其他主体字段冒充销售方来补值。报销详情 API 应暴露已持久化 OCR 结果中的安全票种分类,前端按票种显示“电子火车票”或“数电发票”、号码、日期、金额及适用主体字段。
|
||||
- 生产验收发现移动端“查看全部材料”被误放在“报销明细”区,必须归位到“报销材料”区;PC 已在正确区域,不重构其层级。
|
||||
- 生产只读计时确认当前图片包串行处理约 1.8 秒,且火车票栅格图在企微中视觉上近似空白。新主路径不得继续现场栅格化 PDF;合并由 Node `worker_threads` 执行并设置材料数、源文件字节、总页数、输出大小和超时上限,避免阻塞单一 server 的事件循环。不启用 Compose worker profile,不引入 PDF.js。
|
||||
- 合并产物必须可重复生成:材料查询使用显式排序键,缓存键包含报销单 ID、有序材料集合哈希与合并器版本;PDF 元数据和保存选项固定。同样输入独立生成两次必须字节完全相同。签发返回的 `fileSize` 与实际 GET 返回的缓存字节严格一致;缓存缺失或进程重启不能临时拼出另一份字节冒充原签发产物,只能拒绝并让客户端重新签发或降级原件。
|
||||
- 当前真实报销为“空白报销”创建且未关联差旅申请,现有数据模型没有独立的出差事由、地点和起止日期,因此详情页无法显示完整差旅概要。轻量差旅报销必须直接持久化这四项可选业务字段;存在任一差旅费用明细时,草稿/驳回态提交前必须补齐。既有审批中单据不自动伪造或回填地点;可以只读显示报销说明和明细日期的明确降级值,需完整补齐时由主管退回后申请人修改。
|
||||
- KISS 复核确认只读审批人仍需要知道本单有哪些原始材料。PC 与移动端“报销材料”区必须始终显示有序材料列表,整行点击只预览该份原件;“查看材料合并预览”保留为列表上方的一次通览动作。只读审批人不得看到移除、删除或票据池管理动作,费用明细仍不重复展示发票。
|
||||
- 草稿/驳回报销出现差旅类明细且四项差旅概要不完整时,PC 与移动端应直接显示带标签的填写表单,不先展示四个“未填写”再要求用户点击编辑;概要完整后恢复紧凑只读摘要并保留编辑入口。不新增“普通/差旅”选择,不改变现有字段、差旅判定、提交校验或差旅申请关联规则。
|
||||
- “处理进度”必须使用移动端友好的纵向时间轴。申请提交、付款批次复核、付款与回单是固定业务阶段;中间审批段按当前 workflow 实例的真实任务顺序逐项展开。当前生产规则仍自然形成“申请提交、主管审批、财务复核、付款批次复核、付款与回单”五阶段,但未来新增或重复角色审批节点时不得丢失、覆盖或错误提前显示付款。节点名使用职责而非序号;已完成节点显示实际处理人/时间/结果,当前节点显示实际待办人(可取得时),未来节点不得预填姓名。退回、驳回和有内容的意见必须展开,普通已完成节点压成一行。
|
||||
- 处理进度是跨状态机只读投影,不把付款批次塞入 workflow。服务端从 workflow 快照/任务/历史、报销付款字段、当前关联付款批次和回单计数生成安全 DTO;已发生/当前审批逐条使用真实 task,尚未创建的未来审批按提交快照补 pending 占位,workflow 实例存在但任务尚未落库时也按快照展示,不因延迟建任务而出现空白;仅在 workflow 整体缺失时使用主管/财务默认占位。任一来源缺失时仅把对应下游节点降级为“待处理”,前面已完成节点继续返回。审批节点标题按“非序号化 step name → assignee role/快照规则职责映射 → 审批”降级,永远不向界面显示原始 step ID、角色码或“步骤 N”。付款批次为共享对象,节点文案必须写“所在付款批次…”,不能暗示单张报销自身再次被审批。
|
||||
- PC 报销详情必须提供位于标题左上方、来源感知且可预测的返回入口:从带筛选的报销列表进入时返回原列表 URL,从审批任务进入时返回原审批任务;刷新、收藏或非法来源降级到报销列表。返回入口不得继续混在提交、催办、打印或“更多”等业务动作中,来源只接受受信任的 OA 内部报销列表或审批任务路径。
|
||||
- PC 与移动报销详情的人民币金额必须复用 `client/src/lib/displayFormat.ts` 中的共享格式化函数,统一显示货币符号、千分位和两位小数;缺失或非法金额显示中性占位,不把缺失值伪装成 0 元。
|
||||
- 移动草稿详情的“添加发票”只保留在“报销材料”区;底部固定动作栏只保留“提交审批”。不删除发票上传/选择能力,不改变提交前检查、材料权限或审批状态机。
|
||||
- 旧 R5 真机反馈用例必须跟随当前移动首页信息架构:从常用动作宫格动态采集实际入口,但对经理测试角色固定断言“新建报销、上传票据、请假记录、采购”四项完整集合,并继续验证触控尺寸、单行标签、SVG 图标和无重叠;不得把动态采集写成“采到什么就接受什么”的重言测试。
|
||||
- R5 不得继续作为无人执行的孤立 spec。将其登记到现有本地候选门禁的 acceptance spec 清单,使 `run-local-candidate.sh` 每次候选回归都执行;其余未被任何门禁引用的历史 E2E 仅记录为后续测试资产治理范围,本轮不批量接入、跳过或删除。
|
||||
- 本轮生产发布暴露出既有流程仍依赖人工解释 Compose `working_dir`,且远端 `.env`/数据目录检查发生在 rsync 之后;标准发布必须在任何远端同步前只读确认目标是仓库根目录、`SYNC_ENV=0` 的远端 `.env` 存在、existing 数据目录有效,任一不满足即停止且不执行 rsync。既有部署参数以受控 systemd unit 为 canonical source,不再从 Compose 标签临时拼装路径。
|
||||
- Docker 生产镜像的依赖层不得因每次变化的 `BUILD_SHA/VITE_BUILD_SHA` 失效。基础系统包与 client/server lockfile 依赖安装必须位于构建标识注入之前;构建标识仍须进入后端运行时和前端 12 位公开标识。部署流程与 smoke 日志不得输出完整登录标识、联系方式、企业微信配置标识或 Secret。
|
||||
- 在把报销从单人真实验证扩展到 5—10 人团队采用前,先做一次生产只读就绪检查;检查“人、权限、主数据、票据/OCR、提交门禁和历史生命周期”当前是否可用,不以新增自动化测试代替生产配置与真实数据事实。
|
||||
- 员工规模必须从生产 `employees` 及其认证/绑定关系独立统计;`people_lifecycle` 只代表模块采用度,不能推导员工数量。法体只记录当前配置现状,不作为报销创建或提交硬门禁;当前提交前检查以 `resolveEntityPrecheck` 实际合同为准。
|
||||
- 只读检查必须分别给出:active 员工与可登录标识覆盖、主管关系及 `expense.submit`/`invoice.upload`/`expense.approve` 权限覆盖、active 费用类型与报销 workflow、票据上传/OCR 当前运行模式,以及其他会阻断创建或提交的现场配置。
|
||||
- 历史业务必须按实体独立计数并给出状态分布,至少覆盖报销单、票据池、workflow instance/task、付款批次与回单;不得把采用度普查中的聚合行数表述为“发票数”或“报销数”。需要判断是否已有“提交 → 审批 → 付款 → 回单”的完整闭环,以及其余记录停在哪个阶段。
|
||||
- 生产只读输出只保留计数、布尔、状态分布和脱敏结论;不得输出姓名、完整邮箱/手机号/员工号、金额、单号、UUID、票据/附件内容或原始 payload。任一查询或本地脱敏汇总异常时 fail closed,不用空摘要冒充零数据。
|
||||
- 真人试走优先由第二位真实员工在真实报销需求发生时,使用本人账号和真实票据正常完成;不再制造测试业务。若只能由熟练管理员试走,结论必须标记为“真人摩擦下限”。观察到的摩擦全部记录,但本轮最多选择三个进入处理。
|
||||
- 只读检查只形成就绪矩阵和 go/no-go 建议,不自动修复账号、权限、费用类型、workflow、OCR 或历史业务状态。认证、权限、数据安全或任何需要生产写入的问题必须停止并另行批准;真人登录、上传、提交、审批、付款和回单也均不包含在本次只读批准中。
|
||||
|
||||
## Scope
|
||||
|
||||
本任务原范围包括:修复生产 automatic-backup/WARCH3 timer 的构建合同并取得新鲜异机恢复点;从通讯录导入杨俊及两个既有企微测试成员;设置各自真实或测试主管关系;用两个测试成员完成一条可清理的生产报销体验验收;清理测试业务数据并停用测试 OA 账号;最后由杨俊本人使用真实数据发起报销;修复真实审批任务详情到完整报销单的移动端和 PC 入口。采用扩展阶段仅追加一次生产只读就绪与历史生命周期检查,并据此决定是否允许第二位真实员工试走;所有真实报销内容由本人提供和提交,所有真实审批与财务处理由对应真实人员完成。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 只读体检给出脱敏、可执行的缺口清单,并确认 timer BUILD_SHA 是否与活动构建一致。
|
||||
- [x] 目标员工在 OA 中为 active,企微绑定唯一,初始业务角色不超过 `employee`。
|
||||
- [x] 至少有一个 active 费用类型可用于填单;法体缺失已确认不阻断当前报销创建/提交。
|
||||
- [x] `direct_manager_id` 能解析到真实、active 且具有 `expense.approve` 权限的不同员工。
|
||||
- [x] 报销相关功能开关和工作流定义可用;无需差旅申请也能创建报销。
|
||||
- [x] 自动备份三项 timer 与日常巡检 timer 的 BUILD_SHA 均与活动 server 一致,且 timer 保持 enabled/active。
|
||||
- [x] timer 修复后生成一份新的成功异机批次,ARM 侧完整性/新鲜度检查通过;该门禁先于任何杨俊生产数据写入。
|
||||
- [x] 目标员工完成一次真实企微登录并能看到报销入口;主管能看到相应审批入口。
|
||||
- [ ] 两个既有企微测试成员分别成功通过企微登录 OA;没有创建新的企微成员。
|
||||
- [x] 两个测试成员均通过普通浏览器账号密码登录并完成首次改密;企微 SSO 仍保留为人工确认项。
|
||||
- [x] 测试员工仅有 `employee`,测试主管仅有 `employee + manager`,测试主管能看到测试员工提交的待办且执行“退回”。
|
||||
- [x] 每次受控验证最多产生一条带明确测试标识的报销和一张安全测试 PDF;所有批次均未审批通过、未进入财务或付款链路,并已逐批清理。
|
||||
- [x] 退回后的测试报销已删除,测试票据已解绑并归档,两个测试 OA 账号已停用且会话已撤销;企微测试成员未被修改或停用。
|
||||
- [x] 不持久化通讯录筛选文件,不导入杨俊及两个测试成员之外的其他成员,不产生测试付款。
|
||||
- [x] 生产已显式关闭 mock OCR;`off` 路径已用测试账号复验,空发票元数据仍可绑定、提交、主管退回和清理,真实使用暂由财务查看 PDF 原件并人工核对。
|
||||
- [x] 本轮没有调用企微成员写接口;企微成员资料未被 OA 反向修改。
|
||||
- [x] 生产审批规则已收敛为唯一全金额规则,499.99、500、5000 三个边界均解析为 `department_manager → finance`,且没有 CEO 重复节点或 `finance_mgr` 空节点。
|
||||
- [x] 移动审批任务详情中的报销摘要区提供明确的“查看完整报销单”入口,进入现有移动报销详情并能通过既有 `state.from` 返回原审批任务。
|
||||
- [x] PC 审批任务详情提供对应的“查看报销单”入口;两端均不修改待办列表卡片的既有点击行为,不修改审批状态机、权限矩阵或后端数据合同。
|
||||
- [x] PC、移动报销详情和 PC 审批任务详情中的 ISO 时间按 `Asia/Shanghai` 正确展示,UTC 样例不再直接显示原始小时;数据库与 API 时间合同不变。
|
||||
- [x] PC 报销详情合并重复概况,附件区不再出现“附件材料/补充附件”双标题;无关联、无预算/风险内容时不显示空辅助区块。
|
||||
- [x] 移动报销详情顶部不重复状态和教学文案;无实际操作时不显示仅含“当前状态不可编辑”的操作卡,编辑态上传与提交能力保持可用。
|
||||
- [x] 已登录页面从后台恢复到前台且前后端构建不一致时显示明确刷新提示;构建一致、检查失败或开发构建时不误提示,刷新动作复用缓存清理后重载能力。
|
||||
- [x] 移动企微报销详情中的发票和 PDF/图片附件均提供“查看原件”,通过官方 JS-SDK 原生预览打开;调用参数包含准确 `size` 和带扩展名的 `name`,不依赖异步弹窗或 WebView 原生 PDF 渲染。
|
||||
- [x] PC 报销详情中的发票和附件提供对应的“查看原件”,使用短时签名 URL 或现有认证下载能力;两端审批人均只能查看当前报销绑定材料,不能编辑、解绑、删除或获得票据池管理权限。
|
||||
- [x] 移动报销列表仅对具备 `expense.view_all` 的用户显示“我的 / 全部”切换,默认“我的”;切换“全部”后能通过现有列表进入他人报销详情,普通员工始终只能查看本人列表。
|
||||
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
|
||||
- [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。
|
||||
- [x] PC 与移动端费用明细不再展示或预览关联发票;`linkedItemId` 数据关系继续保留,仅不在费用明细 UI 重复呈现。
|
||||
- [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看材料合并预览”动作;企微调用一次原生 `previewFile`,PC 用浏览器内置阅读器打开同一份合并 PDF。
|
||||
- [x] 合并预览只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自创建时间再按 ID、PDF 内原页序”生成;多页 PDF 不漏页,图片各占一页,短时授权、审计、全部上限和整包失败降级均有验证。
|
||||
- [x] 票据摘要按 OCR `documentCategory` 展示正确票种与适用字段;数电发票和电子火车票不再出现误导性的“无代码”,电子火车票不再出现“未识别销售方”。当前真实票据不重跑 OCR、不改原始识别数据。
|
||||
- [x] 移动端材料合并预览动作仅出现在“报销材料”区,不占用“报销明细”的主操作位。
|
||||
- [x] 合并在 `worker_threads` 中受限执行;同样输入生成两次字节完全相同,签发 `fileSize` 等于 GET 实际字节,缓存丢失不返回漂移产物。
|
||||
- [x] 用无业务数据的多页 PDF 与 JPG/PNG/WebP 合成样本验证页数、顺序、25 页边界和企微一次 `previewFile`;加密、损坏或不可复制 PDF 必须整包失败并回退逐份原件。
|
||||
- [x] 存在差旅费用明细时,PC/移动详情首部均显示“出差事由、出差地点、起止日期”的轻量概要;草稿/驳回态可编辑且提交校验完整,审批中历史单不伪造缺失字段。
|
||||
- [x] PC 与移动端只读报销详情始终显示有序材料列表;列表上方保留合并预览,点击单行仅预览对应原件,审批人没有编辑/解绑/删除动作。
|
||||
- [x] 草稿/驳回差旅报销的概要不完整时直接进入四字段填写态;完整后显示紧凑摘要与编辑入口,普通报销不显示差旅字段。
|
||||
- [x] PC 与移动报销详情只保留四个稳定内容块;差旅概要并入单头,关联、风险、付款和操作均按数据/权限条件呈现,不形成永久空版块,不增加 Tab 或折叠。
|
||||
- [x] “处理进度”按五阶段纵向显示,不出现“第 N 步”;当前责任、实际处理人、时间、结果及必要意见清楚,未来节点无姓名,付款批次共享语义准确。
|
||||
- [x] workflow、付款批次或回单任一来源缺失时进度 DTO 和页面均安全降级,已知节点不丢失;批次 draft/reviewed/executed/failed/cancelled 与回单有/无均有合成回归。
|
||||
- [x] workflow 中第三个未知职责节点和多个同角色节点均按真实顺序完整显示且互不覆盖;实例存在但任务为空时审批段仍显示安全占位,前端不假设固定节点数。
|
||||
- [x] PC 从报销列表或审批任务进入报销详情后,标题左上方显示明确的来源返回入口;返回可恢复列表查询参数或原审批任务,直接打开详情则安全回报销列表,业务动作区不再重复显示“报销列表”。
|
||||
- [x] PC/移动报销详情的总额、明细和发票金额统一使用共享人民币格式,覆盖整数、小数、千分位、空值和非法值。
|
||||
- [x] 移动草稿详情“报销材料”区保留“添加发票”,底部固定栏不再重复该动作且“提交审批”仍可见、可达并满足安全区/触控尺寸合同。
|
||||
- [x] R5 双设备用例按当前四项常用动作合同通过,且已进入本地候选门禁的 acceptance spec 清单;门禁资产解析能列出该 spec,不再依赖手工全目录执行才发现漂移。
|
||||
- [x] 标准部署在 rsync 前验证仓库根目录、远端 `.env` 与 existing 数据目录;错误根目录或缺失 `.env` 的合成回归与脚本顺序断言证明失败发生在 rsync 之前。
|
||||
- [x] Dockerfile 顺序合同及双 SHA 实际构建证明提交 SHA 变化不会使 `apk add`、client `npm ci` 或 server `npm ci` 层失效,同时前端短 SHA 与运行镜像完整 SHA 仍可核对;`/ready` 合同未改动。
|
||||
- [x] 部署 runbook/spec 固化既有部署参数来源、只读 preflight、失败停止点和脱敏日志边界;本地验证通过后单独提交,不把该未部署的流程加固冒充为已上线。
|
||||
- [x] 采用扩展 PRE 以现场生产身份、运行构建、migration 和回滚窗口为只读基线,未执行登录、业务 API mutation、配置写入或数据库写入。
|
||||
- [x] 就绪报告从 `employees` 独立给出 active/可登录员工与主管关系覆盖,并分别给出三项有效权限覆盖;不再使用 `people_lifecycle` 推导员工数量。
|
||||
- [x] 就绪报告给出 active 费用类型、active 报销 workflow、票据上传/OCR 运行模式和提交阻断配置;法体只记录现状且不被误列为硬门禁。
|
||||
- [x] 报销单、票据池、workflow instance/task、付款批次与回单分别按自身状态独立计数,并明确是否存在完整“提交 → 审批 → 付款 → 回单”闭环及其余记录的停点。
|
||||
- [x] 只读输出仅含计数、布尔、状态分布和脱敏结论;预期模块缺失、条目数异常或后处理结果为空时明确失败,不把静默丢数报告为零。
|
||||
- [x] 基于只读事实形成五人推广 go/no-go 矩阵;任何账号、权限、配置或数据修复均列为另批项,本阶段不顺手写入生产。
|
||||
- [ ] 若后续获批真人试走,优先由第二位真实员工在真实需求中完成;全部摩擦被记录、本轮最多处理三个,管理员代走时明确标记为“真人摩擦下限”。
|
||||
|
||||
## Lifecycle Closeout
|
||||
|
||||
- 用户于 2026-08-23 确认杨俊差旅报销已走完;同日 supplemental 生产只读查询独立确认至少存在一条“已提交 → workflow 完成 → 付款批次 executed → 已批准回单”的完整关联闭环,旧“待主管审批/财务处理/付款/回单”条目不再作为当前待办。
|
||||
- 杨俊本人企微登录、真实提交、主管审批、财务复核、maker-checker、付款与回单的原始目标已经完成;生产查询未执行登录、mutation、配置写入或数据库写入。
|
||||
- 五人推广结论为 `CONDITIONAL_GO`:账号登录、企微绑定、提交/上传权限、费用类型、报销 workflow、腾讯 OCR 和付款功能均具备;新员工首次真实报销前仍须逐人确认其直属主管关系,不以全员聚合计数替代个人门禁。
|
||||
- 两个测试成员的企微 SSO 人工确认和“第二位真实员工试走”仍未执行,保持未勾选;它们不再阻断杨俊首笔真实业务闭环,若后续确有采用验证需求应由新的 Trellis task 承接。
|
||||
- 腾讯云控制台的 OCR 免费包、后付费关闭和费用/余额告警属于现有差旅/OCR 任务的外部运维确认,不在本任务中伪造完成证据。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不修改 CEO 本人无主管报销限制,不通过授予 `finance_mgr` 或管理员代批绕过。
|
||||
- 不重构报销状态机、审批引擎、企微 OAuth 或权限模型。
|
||||
- 不从 OA 创建、更新或停用企微成员;需要修改企微成员资料时,由本人或企微管理员在企微侧处理。
|
||||
- 不提前实现 R63–R66,不扩大为全员入职或组织架构治理。
|
||||
- 不在未获单独批准时执行通讯录 apply、主管/法体写入、生产报销或付款操作。
|
||||
- 不使用真实发票、真实金额、真实银行资料或真实员工个人资料作为测试材料;不让测试单进入“通过/待财务/付款”状态。
|
||||
- 不把手机号缺失视为导入阻断项;该员工使用企微或企业邮箱登录,后续按需补手机号。
|
||||
- 不在本轮顺带修复请假、采购、差旅等其他审批类型的同类“业务单据详情入口”缺口;将其记录为后续统一审批中心体验任务候选。
|
||||
- 在采用扩展只读阶段创建测试报销/票据、登录真人账号、上传真实票据、提交/审批报销、创建/复核/执行付款批次或上传回单。
|
||||
- 因检查发现缺口而直接新增员工、修改主管或角色权限、补费用类型/workflow、切换 OCR、修复历史单据,或据此开发新功能。
|
||||
- 把现有自动化测试数量或 mock E2E 通过当作生产配置齐全和团队采用就绪的充分证据。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Trellis task/governance、敏感扫描、流程与文档门禁、`git diff --check`。
|
||||
- 通讯录聚焦 dry-run 仅输出人数、计划、冲突、跳过和失败计数。
|
||||
- 生产只读 SQL/Prisma 只返回布尔值和计数,不输出手机号、邮箱、企微 userid、完整账号或原始 payload。
|
||||
- 使用既有报销 workflow/权限聚焦回归验证主管解析和 `expense.approve`,不写生产业务单据。
|
||||
- 前端运行 `cd client && npm run typecheck && npm run build`,并用聚焦 Playwright 覆盖:移动审批任务详情进入完整报销单、返回原任务后审批动作仍可见,以及 PC 审批任务详情进入报销单。
|
||||
- 用合成 UTC 时间在聚焦 Playwright 中验证附件时间转换为上海时间,并验证 PC/移动 KISS 信息层级、编辑态关键入口和无动作只读态;不读取生产真实报销内容。
|
||||
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
|
||||
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
|
||||
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
|
||||
- 用合成普通增值税发票、数电发票、电子火车票、多页 PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;合并 PDF 包含全部材料并保持顺序;异常/加密 PDF、超限和合并失败可安全降级;编辑态管理动作不回归。
|
||||
- 用合成申请人、审批人和管理员权限覆盖 PC/移动材料区:列表始终可见且顺序稳定,语义化整行按钮可单份预览,合并预览仍只调用一次,只有可编辑参与人看到移除/删除动作。
|
||||
- 用合成普通与差旅草稿覆盖 PC/移动概要:无差旅明细时不显示;差旅概要不完整时直接显示四个有标签输入框;保存中、成功和失败有反馈;完整后回到摘要态。
|
||||
- 后端用合成 workflow task/history、付款批次 item 和回单计数覆盖五阶段只读 DTO:当前/完成/未来节点、退回/驳回意见、maker-checker、共享批次措辞、每个来源单独缺失和权限安全摘要;不写生产业务数据。
|
||||
- 前端用同一 DTO 覆盖 PC/企微纵向时间轴:已完成节点单行、当前/异常节点展开、无“第 N 步”、未来无姓名、付款批次共享措辞和四块式纯滚动布局。
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
# 国内报销详情页 KISS 调研
|
||||
|
||||
## 1. 调研问题
|
||||
|
||||
面向 10 人以下国内科技公司,报销详情页是否需要把概况、差旅概要、费用明细、材料、关联事项、风险、审批、付款和操作拆成多个常驻区域;如果不需要,最小但完整的信息结构是什么。
|
||||
|
||||
## 2. 公开资料结论
|
||||
|
||||
- 合思公开产品资料覆盖申请、消费、报销、对账、记账、归档、报表,并强调票据识别、费用可追踪和审核;这些是全链路能力域,不等于单据详情页需要常驻同等数量的版块。
|
||||
- 汇联易公开产品资料强调费用管理、合规规则、发票管理、智能审核、会计与流程能力;其复杂预算、风险和核算能力面向中大型企业,不应直接成为本项目小团队详情页的默认结构。
|
||||
- 每刻报销公开产品资料强调出差申请、费用、发票、审核、支付和归档,并明确其相对传统 OA 的优势来自预算、智能审核、ERP 和银企集成;本项目当前没有这些真实配置或集成,不能为了“像费控系统”而展示空区域。
|
||||
- 人人都是产品经理的费控设计案例把差旅移动流程概括为事项申请、发票采集、场景报销、领导审批和财务审核,并指出不同费用类型应使用动态单据模板和附件清单。可复用原则是“按业务事实动态展示”,不是固定堆叠所有能力域。
|
||||
|
||||
## 3. 项目现状
|
||||
|
||||
- 移动报销详情当前至少有:报销概况、独立差旅概要、报销明细、报销材料、审批、操作。
|
||||
- PC 详情除上述主线外,还可能出现关联事项、预算与审核提示、付款状态和更多操作;多数已按数据条件隐藏,但差旅概要仍形成独立卡片。
|
||||
- 当前公司费用标准和预算没有形成真实常用数据;风险区没有内容时本来就不显示。审批动作在审批任务页,普通业务详情不应复制审批动作。
|
||||
- 当前真实单据规模小,适合单页纵向滚动;Tab、折叠和二级抽屉只会增加企微 WebView 的状态与返回验证成本。
|
||||
|
||||
## 4. 推荐结构
|
||||
|
||||
只保留四个稳定内容块,PC 与移动端信息顺序一致:
|
||||
|
||||
1. **单头概况**:单号、状态、申请人、部门、总额、说明;差旅单把事由、地点和起止日期并入同一块,普通报销自然不显示差旅行。
|
||||
2. **费用明细**:费用类型、发生日期、金额和说明;编辑态在这里保留新增、编辑和删除。
|
||||
3. **报销材料**:计数与合并预览在上,下面始终显示可单份打开的发票/补充附件列表;只读审批人没有移除或删除动作。
|
||||
4. **审批与处理记录**:提交、当前节点和历史。付款事实仅在实际存在时作为本块内的处理结果显示,不新建永久空版块。
|
||||
|
||||
状态性内容不形成新的常驻区域:
|
||||
|
||||
- 真实风险或校验异常以内联提示放到单头或对应明细附近;无风险不显示。
|
||||
- 项目或差旅申请关联作为单头的一行链接;无关联不显示。
|
||||
- 草稿/驳回操作继续使用页面底部命令区或移动固定操作条,不算内容块。
|
||||
- 打印、返回列表等低频动作收进“更多”,不占主阅读流。
|
||||
|
||||
## 5. 明确不做
|
||||
|
||||
- 不做 Tab、折叠、底部抽屉或左右分页。
|
||||
- 不保留独立“差旅概要”卡片。
|
||||
- 不新增常驻预算、合规、风险、财务或付款版块。
|
||||
- 不把审批按钮搬进报销业务详情,不改变审批任务返回链路。
|
||||
- 不新增普通/差旅类型,不改变现有数据字段、权限、状态机或 API。
|
||||
|
||||
## 6. 来源
|
||||
|
||||
- 合思官网,合思费控与全链路产品说明:<https://www.ekuaibao.com/>(访问:2026-08-01)。
|
||||
- 汇联易官网,全面费用管理产品功能:<https://www.huilianyi.com/products/helios>(访问:2026-08-01)。
|
||||
- 每刻报销官网,企业差旅及费用管理平台:<https://www.maycur.com/mkbx>(访问:2026-08-01)。
|
||||
- 人人都是产品经理,《费控报销系统,如何设计?》:<https://www.woshipm.com/pd/5292016.html>(访问:2026-08-01)。
|
||||
|
||||
以上公开资料只用于提炼信息与流程共性,不把供应商宣传中的预算、AI、ERP、银企直连或大型集团能力视为本项目需求。
|
||||
|
||||
## 7. 审批与付款进度补充调研
|
||||
|
||||
### 7.1 国内产品共性
|
||||
|
||||
- 企业微信官方“获取审批申请详情”数据合同把审批流程表达为多个审批节点;节点含审批人、节点/审批人状态、操作时间和审批意见,备注另行记录。可复用原则是按节点展示职责、处理人、状态、时间和必要意见,而不是显示“节点 1 / 节点 2”。
|
||||
- 飞书、钉钉等国内审批产品同样把实例详情、任务/节点和操作记录分开建模。公开资料没有要求统一视觉,但移动端纵向时间轴符合用户既有审批心智,也适合本项目企业微信 WebView。
|
||||
- 国内费控产品通常把费用审批与支付、回单、归档串成端到端业务链;实现上审批 workflow 与付款对象仍是不同状态机。详情页可以给用户一条连续进度,但不能把付款批次伪造成 workflow 节点。
|
||||
|
||||
企业微信官方资料:<https://developer.work.weixin.qq.com/document/path/91983>(访问:2026-08-01)。
|
||||
|
||||
### 7.2 本项目真实状态机
|
||||
|
||||
- 报销 workflow 快照当前固定为“主管审批 → 财务复核”,最终把报销状态置为 `approved`。
|
||||
- 付款另由 `PaymentBatch` 承担:财务创建批次(maker)→ 非制单人复核批次(checker)→ 执行付款;执行后报销进入 `paid`,银行回单可随后留存。
|
||||
- 当前 PC `workflowProgress` 显示“第 N 步”,且只在 workflow pending 时返回;移动端只显示当前节点和历史。报销详情没有付款批次安全摘要,因此还不能诚实展示付款复核、执行和回单状态。
|
||||
|
||||
### 7.3 推荐的端到端纵向进度
|
||||
|
||||
第四个稳定内容块命名为“处理进度”,使用一条纵向时间轴,按职责显示五个业务阶段:
|
||||
|
||||
1. 申请提交
|
||||
2. 主管审批
|
||||
3. 财务复核
|
||||
4. 付款批次复核
|
||||
5. 付款与回单
|
||||
|
||||
显示规则:
|
||||
|
||||
- 节点名称使用业务职责,不显示“第 1 步 / 第 2 步”。
|
||||
- 已处理节点显示实际处理人、时间和结果;当前节点显示实际待办处理人(可取得时)和“处理中”;未来节点只显示职责与“待处理”,不根据当前角色持有人猜姓名。
|
||||
- 审批意见、退回/驳回原因仅在有内容时显示;退回/驳回原因不得隐藏。
|
||||
- 付款批次未创建时,付款复核节点显示“待财务创建付款批次”;批次 draft 显示“待非制单人复核”;reviewed 显示“复核通过,待执行付款”;failed/cancelled 如实显示异常或重新处理。
|
||||
- 报销进入 `paid` 后,最后节点显示付款时间/处理人;回单状态作为该节点的次级状态,不额外增加第六块内容。
|
||||
- 前端时间轴消费统一的只读进度 DTO;服务端分别读取 workflow 快照/历史、当前 task、报销付款字段和当前关联付款批次摘要,再做安全映射。不得修改 workflow 或 PaymentBatch 状态机。
|
||||
|
||||
### 7.4 KISS 边界
|
||||
|
||||
- 不画 BPMN、分支图或横向步骤条。
|
||||
- 不展示内部 step ID、角色码、权限码或状态机枚举。
|
||||
- 不在报销详情复制审批、付款或回单操作;需要处理时仍跳到当前审批任务或付款批次详情。
|
||||
- 不硬编码自然人姓名;历史用实际 actor,当前用实际 assignee,未来只写职责。
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
|
||||
const TARGET_NAME_SHA256 = "d6ad24a313331e19381654bab85b3a53de2ce2d518d7fe6a819124cca81c9cc6";
|
||||
const MANAGER_NAME_SHA256 = "5da0f6dcab6e9b1e0aa52e06e18a7cc9e181ae51c4a10b64fb3411cfd607424a";
|
||||
const REIMBURSEMENT_STATUSES = Object.freeze(["draft", "reviewing", "approved", "paid", "rejected", "cancelled"]);
|
||||
const INVOICE_STATUSES = Object.freeze(["pending_ocr", "ocr_failed", "available", "bound", "archived"]);
|
||||
const WORKFLOW_INSTANCE_STATUSES = Object.freeze(["pending", "approved", "rejected", "cancelled", "terminated"]);
|
||||
const WORKFLOW_TASK_STATUSES = Object.freeze(["pending", "completed", "rejected", "cancelled", "skipped"]);
|
||||
const PAYMENT_BATCH_STATUSES = Object.freeze(["draft", "reviewed", "executed", "failed", "cancelled"]);
|
||||
const RECEIPT_GOVERNANCE_STATUSES = Object.freeze([
|
||||
"draft", "under_approval", "pending_review", "approved", "archived", "returned", "rejected", "quarantined",
|
||||
]);
|
||||
|
||||
function sha256(value) {
|
||||
return createHash("sha256").update(String(value), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function zeroFilled(rows, allowed) {
|
||||
const result = Object.fromEntries(allowed.map((status) => [status, 0]));
|
||||
for (const row of rows) {
|
||||
const status = String(row.status ?? "");
|
||||
if (!allowed.includes(status)) throw new Error("unexpected_status");
|
||||
result[status] = Number(row.count ?? 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rolesGranting(permissions, permission) {
|
||||
return Object.entries(permissions)
|
||||
.filter(([, values]) => values.includes("*") || values.includes(permission))
|
||||
.map(([role]) => role)
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function grouped(client, sql, allowed) {
|
||||
const result = await client.query(sql);
|
||||
return zeroFilled(result.rows, allowed);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const appRoot = path.resolve(process.env.REIMBURSEMENT_READINESS_APP_ROOT || process.cwd());
|
||||
const { Client } = require(path.join(appRoot, "node_modules/pg"));
|
||||
const { getConfig } = require(path.join(appRoot, "src/config.js"));
|
||||
const { ROLE_PERMISSIONS } = require(path.join(appRoot, "src/constants/permissions.js"));
|
||||
const config = getConfig();
|
||||
const permissionRoles = {
|
||||
expenseSubmit: rolesGranting(ROLE_PERMISSIONS, "expense.submit"),
|
||||
invoiceUpload: rolesGranting(ROLE_PERMISSIONS, "invoice.upload"),
|
||||
expenseApprove: rolesGranting(ROLE_PERMISSIONS, "expense.approve"),
|
||||
};
|
||||
if (Object.values(permissionRoles).some((roles) => roles.length === 0)) {
|
||||
throw new Error("permission_role_contract_empty");
|
||||
}
|
||||
|
||||
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 employees = await client.query(`
|
||||
SELECT
|
||||
e.id,
|
||||
e.name,
|
||||
e.status::text AS status,
|
||||
e.archived_at,
|
||||
e.email,
|
||||
e.phone,
|
||||
e.password_hash,
|
||||
e.must_change_password,
|
||||
e.login_locked_until,
|
||||
e.direct_manager_id,
|
||||
EXISTS (SELECT 1 FROM wecom_identity_bindings w WHERE w.employee_id = e.id) AS has_wecom_binding,
|
||||
COALESCE(array_agg(er.role_code::text ORDER BY er.role_code::text)
|
||||
FILTER (WHERE er.role_code IS NOT NULL), ARRAY[]::text[]) AS role_codes
|
||||
FROM employees e
|
||||
LEFT JOIN employee_roles er ON er.employee_id = e.id
|
||||
GROUP BY e.id
|
||||
ORDER BY e.id
|
||||
`);
|
||||
if (!Array.isArray(employees.rows) || employees.rows.length === 0) throw new Error("employees_empty");
|
||||
const active = employees.rows.filter((item) => item.status === "active" && item.archived_at == null);
|
||||
const permissionCount = (permission) => active.filter((item) => (
|
||||
item.role_codes.some((role) => permissionRoles[permission].includes(role))
|
||||
)).length;
|
||||
const byId = new Map(employees.rows.map((item) => [item.id, item]));
|
||||
const targetMatches = employees.rows.filter((item) => sha256(item.name) === TARGET_NAME_SHA256);
|
||||
const managerMatches = employees.rows.filter((item) => sha256(item.name) === MANAGER_NAME_SHA256);
|
||||
const target = targetMatches.length === 1 ? targetMatches[0] : null;
|
||||
const manager = managerMatches.length === 1 ? managerMatches[0] : null;
|
||||
|
||||
const employeeSummary = {
|
||||
total: employees.rows.length,
|
||||
active: active.length,
|
||||
activeWithLoginIdentifier: active.filter((item) => (
|
||||
String(item.email ?? "").trim() || String(item.phone ?? "").trim()
|
||||
)).length,
|
||||
activeWithPasswordCredential: active.filter((item) => String(item.password_hash ?? "").trim()).length,
|
||||
activeCurrentlyUnlocked: active.filter((item) => !item.login_locked_until || new Date(item.login_locked_until) <= new Date()).length,
|
||||
activeWithoutInitialPasswordChange: active.filter((item) => item.must_change_password === false).length,
|
||||
activeWithWecomBinding: active.filter((item) => item.has_wecom_binding === true).length,
|
||||
activeWithDirectManager: active.filter((item) => item.direct_manager_id != null).length,
|
||||
activeWithValidActiveDistinctManager: active.filter((item) => {
|
||||
const linked = byId.get(item.direct_manager_id);
|
||||
return linked && linked.id !== item.id && linked.status === "active" && linked.archived_at == null;
|
||||
}).length,
|
||||
permissions: {
|
||||
expenseSubmit: permissionCount("expenseSubmit"),
|
||||
invoiceUpload: permissionCount("invoiceUpload"),
|
||||
expenseApprove: permissionCount("expenseApprove"),
|
||||
},
|
||||
targetManagerContract: {
|
||||
targetUnique: targetMatches.length === 1,
|
||||
managerUnique: managerMatches.length === 1,
|
||||
distinct: Boolean(target && manager && target.id !== manager.id),
|
||||
targetActive: Boolean(target && target.status === "active" && target.archived_at == null),
|
||||
managerActive: Boolean(manager && manager.status === "active" && manager.archived_at == null),
|
||||
managerCanApprove: Boolean(manager && manager.role_codes.some((role) => permissionRoles.expenseApprove.includes(role))),
|
||||
relationshipMatches: Boolean(target && manager && target.direct_manager_id === manager.id),
|
||||
},
|
||||
};
|
||||
|
||||
const expenseTypes = await client.query("SELECT count(*)::int AS count FROM expense_types WHERE enabled = true");
|
||||
const workflows = await client.query(`
|
||||
SELECT count(*)::int AS count
|
||||
FROM workflow_definitions
|
||||
WHERE entity_type = 'reimbursement' AND is_active = true AND archived_at IS NULL
|
||||
`);
|
||||
const legalEntities = await client.query("SELECT count(*)::int AS count FROM legal_entities WHERE enabled = true AND archived_at IS NULL");
|
||||
const paymentFlag = await client.query(`
|
||||
SELECT override_mode, version
|
||||
FROM feature_flags
|
||||
WHERE flag_key = 'payment_batch_v1'
|
||||
`);
|
||||
if (paymentFlag.rows.length !== 1) throw new Error("payment_flag_not_unique");
|
||||
|
||||
const reimbursements = await grouped(
|
||||
client,
|
||||
"SELECT status::text AS status, count(*)::int AS count FROM reimbursement_forms GROUP BY status ORDER BY status",
|
||||
REIMBURSEMENT_STATUSES,
|
||||
);
|
||||
const invoices = await grouped(
|
||||
client,
|
||||
"SELECT status::text AS status, count(*)::int AS count FROM invoice_pool GROUP BY status ORDER BY status",
|
||||
INVOICE_STATUSES,
|
||||
);
|
||||
const workflowInstances = await grouped(
|
||||
client,
|
||||
"SELECT current_status::text AS status, count(*)::int AS count FROM workflow_instances WHERE entity_type = 'reimbursement' GROUP BY current_status ORDER BY current_status",
|
||||
WORKFLOW_INSTANCE_STATUSES,
|
||||
);
|
||||
const workflowTasks = await grouped(
|
||||
client,
|
||||
`SELECT wt.status::text AS status, count(*)::int AS count
|
||||
FROM workflow_tasks wt
|
||||
JOIN workflow_instances wi ON wi.id = wt.instance_id
|
||||
WHERE wi.entity_type = 'reimbursement'
|
||||
GROUP BY wt.status ORDER BY wt.status`,
|
||||
WORKFLOW_TASK_STATUSES,
|
||||
);
|
||||
const paymentBatches = await grouped(
|
||||
client,
|
||||
"SELECT status::text AS status, count(*)::int AS count FROM payment_batches WHERE entity_type = 'reimbursement' GROUP BY status ORDER BY status",
|
||||
PAYMENT_BATCH_STATUSES,
|
||||
);
|
||||
const receiptGovernance = await grouped(
|
||||
client,
|
||||
`SELECT pra.governance_status AS status, count(*)::int AS count
|
||||
FROM payment_batch_receipt_attachments pra
|
||||
JOIN payment_batches pb ON pb.id = pra.payment_batch_id
|
||||
WHERE pb.entity_type = 'reimbursement'
|
||||
GROUP BY pra.governance_status ORDER BY pra.governance_status`,
|
||||
RECEIPT_GOVERNANCE_STATUSES,
|
||||
);
|
||||
const receiptCounts = await client.query(`
|
||||
SELECT
|
||||
count(*)::int AS total,
|
||||
count(DISTINCT pra.payment_batch_id)::int AS batches_with_receipt,
|
||||
count(DISTINCT pra.payment_batch_id) FILTER (WHERE pb.status = 'executed')::int AS executed_batches_with_receipt
|
||||
FROM payment_batch_receipt_attachments pra
|
||||
JOIN payment_batches pb ON pb.id = pra.payment_batch_id
|
||||
WHERE pb.entity_type = 'reimbursement'
|
||||
`);
|
||||
const closedLoop = await client.query(`
|
||||
SELECT count(DISTINCT rf.id)::int AS count
|
||||
FROM reimbursement_forms rf
|
||||
JOIN workflow_instances wi
|
||||
ON wi.reimbursement_id = rf.id
|
||||
AND wi.entity_type = 'reimbursement'
|
||||
AND wi.current_status = 'approved'
|
||||
JOIN payment_batch_items pbi ON pbi.entity_id = rf.id
|
||||
JOIN payment_batches pb
|
||||
ON pb.id = pbi.payment_batch_id
|
||||
AND pb.entity_type = 'reimbursement'
|
||||
AND pb.status = 'executed'
|
||||
WHERE rf.status = 'paid'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM payment_batch_receipt_attachments pra
|
||||
WHERE pra.payment_batch_id = pb.id
|
||||
AND pra.governance_status = 'approved'
|
||||
)
|
||||
`);
|
||||
|
||||
const ocrProvider = String(process.env.OCR_PROVIDER ?? "off").trim().toLowerCase();
|
||||
if (!["off", "mock", "tencent"].includes(ocrProvider)) throw new Error("ocr_provider_unexpected");
|
||||
const result = {
|
||||
status: "PASS",
|
||||
transactionReadOnly: true,
|
||||
sourceBuildSha: config.buildSha,
|
||||
employees: employeeSummary,
|
||||
configuration: {
|
||||
activeExpenseTypes: Number(expenseTypes.rows[0]?.count ?? -1),
|
||||
activeReimbursementWorkflows: Number(workflows.rows[0]?.count ?? -1),
|
||||
activeLegalEntitiesObservationOnly: Number(legalEntities.rows[0]?.count ?? -1),
|
||||
invoiceUploadConfigured: Number(config.maxUploadSizeBytes ?? 0) > 0,
|
||||
ocrProvider,
|
||||
invoiceVerificationEnabled: config.invoiceVerificationEnabled === true,
|
||||
paymentBatchFlag: {
|
||||
overrideMode: String(paymentFlag.rows[0].override_mode),
|
||||
version: Number(paymentFlag.rows[0].version),
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
reimbursements,
|
||||
invoices,
|
||||
workflow: { instances: workflowInstances, tasks: workflowTasks },
|
||||
paymentBatches,
|
||||
receipts: {
|
||||
total: Number(receiptCounts.rows[0]?.total ?? 0),
|
||||
batchesWithReceipt: Number(receiptCounts.rows[0]?.batches_with_receipt ?? 0),
|
||||
executedBatchesWithReceipt: Number(receiptCounts.rows[0]?.executed_batches_with_receipt ?? 0),
|
||||
byGovernanceStatus: receiptGovernance,
|
||||
},
|
||||
completeSubmitApprovePayReceiptLoops: Number(closedLoop.rows[0]?.count ?? 0),
|
||||
},
|
||||
};
|
||||
if (result.configuration.activeExpenseTypes < 0 || result.configuration.activeReimbursementWorkflows < 0) {
|
||||
throw new Error("configuration_count_invalid");
|
||||
}
|
||||
if (!result.employees.targetManagerContract.targetUnique || !result.employees.targetManagerContract.managerUnique) {
|
||||
throw new Error("target_manager_not_unique");
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
inTransaction = false;
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} catch {
|
||||
if (inTransaction) {
|
||||
try { await client.query("ROLLBACK"); } catch { /* fail closed */ }
|
||||
}
|
||||
process.stderr.write("[reimbursement-readiness-pre][FAILED] code=QUERY_FAILED\n");
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module || process.argv[1] === "-") {
|
||||
run();
|
||||
}
|
||||
|
||||
module.exports = { zeroFilled, rolesGranting };
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
QUERY_SCRIPT="$ROOT_DIR/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/research/production-reimbursement-readiness-pre.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_REIMBURSEMENT_READINESS_PRE"
|
||||
|
||||
# shellcheck source=../../../../../deploy/trialrun/deployment-contract-lib.sh
|
||||
source "$CONTRACT_LIB"
|
||||
|
||||
fail() {
|
||||
printf '[reimbursement-readiness-pre-runner][FAILED] code=%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
shell_quote() {
|
||||
printf "'%s'" "${1//\'/\'\\\'\'}"
|
||||
}
|
||||
|
||||
[[ "$#" -eq 1 && "${1:-}" == "$CONFIRMATION" ]] || fail "EXPLICIT_CONFIRMATION_REQUIRED"
|
||||
for file in "$QUERY_SCRIPT" "$IDENTITY_MODULE" "$IDENTITY_REMOTE" "$PREFLIGHT_SCRIPT"; do
|
||||
[[ -f "$file" && ! -L "$file" ]] || fail "LOCAL_CONTRACT_MISSING"
|
||||
done
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
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"
|
||||
|
||||
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 '[reimbursement-readiness-pre-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"
|
||||
|
||||
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")" < "$QUERY_SCRIPT")" \
|
||||
|| fail "QUERY_EXECUTION_FAILED"
|
||||
|
||||
validated_output="$(printf '%s' "$query_output" | node -e '
|
||||
const fs = require("node:fs");
|
||||
const expected = process.argv[1];
|
||||
const value = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
if (value.status !== "PASS" || value.transactionReadOnly !== true) process.exit(1);
|
||||
if (value.sourceBuildSha !== expected.slice(0, 12)) process.exit(1);
|
||||
if (!value.employees || !value.configuration || !value.lifecycle) process.exit(1);
|
||||
for (const key of [
|
||||
"reimbursements",
|
||||
"invoices",
|
||||
"workflow",
|
||||
"paymentBatches",
|
||||
"receipts",
|
||||
"completeSubmitApprovePayReceiptLoops",
|
||||
]) {
|
||||
if (!(key in value.lifecycle)) process.exit(1);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(value, null, 2));
|
||||
' "$candidate_commit")" || fail "QUERY_OUTPUT_INVALID"
|
||||
|
||||
printf '%s\n' "$validated_output"
|
||||
printf '[reimbursement-readiness-pre-runner][PASS] queryExecuted=1 zeroRetry=1\n' >&2
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# 企业微信自建应用原生文件预览调研
|
||||
|
||||
## 结论
|
||||
|
||||
企业微信自建应用内可以预览 PDF,不应以普通链接在 WebView 中的下载表现判断企业微信是否具备 PDF 预览能力。官方 JS-SDK 提供 `ww.previewFile` / `wx.previewFile`,兼容企业微信 iOS、Android,由企业微信原生层下载 URL 对应文件并在内置预览器中打开,官方说明支持图片、音频、视频、文档等格式。
|
||||
|
||||
本任务应优先验证并接入企业微信原生 `previewFile`,而不是先引入 PDF.js 或服务端转图片。PC 普通浏览器继续使用浏览器预览;两端保持“查看原件”业务能力一致,但底层实现按运行环境分流。
|
||||
|
||||
## 官方证据
|
||||
|
||||
- 企业微信开发者中心《预览文件》:<https://developer.work.weixin.qq.com/document/path/100623>
|
||||
- 参数为 `url`、`size`、`name`。
|
||||
- `size` 必填且必须准确,否则打开失败。
|
||||
- `name` 应包含正确文件扩展名。
|
||||
- 原生层会重新请求文件 URL;iOS WKWebView 中的 Cookie 不会自动带给原生请求。
|
||||
- 官方建议资源 cookie-free;需要鉴权时,在 URL 中携带业务侧短时 access token。
|
||||
- 腾讯企业微信维护的 JS-SDK:<https://github.com/TencentWecom/wecom-jssdk>
|
||||
- npm 包 `@wecom/jssdk` 的类型定义明确标注 `previewFile` 兼容 WeCom iOS、Android。
|
||||
- SDK 将 `previewFile` 调用传递给企业微信原生 `WeixinJSBridge`。
|
||||
- JS-SDK 总入口:<https://developer.work.weixin.qq.com/document/path/90547>
|
||||
- 自建应用需完成 JS-SDK 注册和当前页面 URL 的 `config` 签名;官方 SDK README 说明自建应用只需提供 `getConfigSignature`。
|
||||
|
||||
## 对 hyb-oa 的影响
|
||||
|
||||
1. 当前项目没有 JS-SDK ticket/signature 接口,也未引入 `@wecom/jssdk`,需要补最小自建应用签名链路。
|
||||
2. 发票已有短时签名文件 URL,可复用其 cookie-free 形态,但报销详情还需返回准确 `fileSize` 和带扩展名的 `originalName`。
|
||||
3. 报销附件当前仅支持带 OA Bearer token 的 Blob 下载;企业微信原生请求不会携带该请求头,需要增加对象级授权后的短时签名 URL,不能改为公开文件。
|
||||
4. 签名 URL 必须短时、限定文件与申请人、保留实际打开审计;不得把长期 token、Cookie、Secret 或原始企微凭据放入 URL。
|
||||
5. 移动企微内调用 `previewFile`;普通 PC 浏览器使用签名 URL 的浏览器预览。非企微移动浏览器可保留明确下载或浏览器预览 fallback。
|
||||
|
||||
## 建议验证顺序
|
||||
|
||||
1. 先用不含业务数据的安全 PDF,在生产同域受控页面完成 JS-SDK `config + previewFile` 技术探针,验证马建勇实际 Android/iOS 企业微信版本。
|
||||
2. 探针通过后再接发票短时 URL和附件短时 URL,避免拿真实票据做首轮 SDK 调试。
|
||||
3. 合成数据覆盖:签名 URL 权限、过期/篡改拒绝、准确 size/name、企微原生调用、PC fallback、审批人只读边界。
|
||||
4. 生产部署后先用安全 PDF 验收,再让经理查看真实报销材料;材料可见前不得审批。
|
||||
|
||||
## 行业实现归纳
|
||||
|
||||
企业微信 H5 的常见稳妥实现是“JS-SDK 原生预览 + cookie-free 短时签名 URL + Web fallback”。其关键不在 PDF 渲染库,而在把受保护文件临时转换成原生层可拉取、可过期、可审计的 URL。PDF.js 或服务端转图片适合作为官方原生接口在特定机型失败后的第二级 fallback,不应作为本项目首选路径。
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "yang-jun-reimbursement-readiness",
|
||||
"name": "yang-jun-reimbursement-readiness",
|
||||
"title": "杨俊报销就绪",
|
||||
"description": "修复生产 timer 构建合同并取得新鲜异机恢复点,随后最小化导入杨俊并将真实直属主管设置为马建勇,打通本人报销链路。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": "production timer contract repair, fresh off-host backup, single-member WeCom directory import, manager assignment, and reimbursement readiness verification",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-07-27",
|
||||
"completedAt": "2026-08-23",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"deploy/trialrun/install-automatic-backup-timer.sh",
|
||||
"deploy/trialrun/install-warch3-ops-timer.sh",
|
||||
"server/src/scripts/import-wecom-directory-export.js",
|
||||
"server/src/routes/users.js",
|
||||
"server/src/routes/reimbursements.js",
|
||||
"server/src/utils/workflow.js",
|
||||
".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md",
|
||||
".trellis/spec/hyb-oa/approval-workflow.md",
|
||||
".trellis/spec/hyb-oa/wecom-boundary.md",
|
||||
".trellis/spec/hyb-oa/deployment-trialrun.md"
|
||||
],
|
||||
"notes": "用户确认杨俊差旅报销已走完;2026-08-23 supplemental 生产只读核验确认存在一条提交、workflow 完成、付款批次 executed、已批准回单的完整关联闭环。五人推广为 CONDITIONAL_GO,新员工首次真实提交前仍须逐人确认直属主管;测试成员企微 SSO 与第二位真人试走未执行,不阻断本任务收口。",
|
||||
"meta": {}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户