修复审批详情报销入口(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -168,6 +168,65 @@ reference, not a copy of Feishu or Ant Design branding.
|
||||
| `请假记录` / `考勤` | 业务台账列表 or record view | Enterprise WeChat-owned records, mirror status, exceptions and audit | Ordinary users do not see OA daily leave/punch approval creation; OA emergency entry only for authorized emergency/admin scope. |
|
||||
| Mobile routes | Mobile expression of the same template | Top status/focus, card list or sectioned detail | One bottom safe-area primary action; secondary actions in `更多` or confirmation. |
|
||||
|
||||
## Scenario: Approval Task Business Detail Exit
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: an approval task detail shows only a business summary while the approver must inspect line items, invoices, attachments or other evidence before acting.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Mobile reimbursement target: `/mobile/reimbursements/:reimbursementId`.
|
||||
- PC reimbursement target: `/reimbursements/:reimbursementId`.
|
||||
- Mobile source state: `<Link state={{ from: currentMobileSource }}>`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- The pending-task list card continues to open `/mobile/approvals/:taskId`; do not confuse task selection with the separate business-detail exit inside the task detail.
|
||||
- When `WorkflowTaskDetail.reimbursement` exists, the task detail exposes an explicitly named `查看完整报销单` or `查看报销单` link near the reimbursement summary.
|
||||
- Reuse the existing business detail route and its permissions. Do not duplicate reimbursement items, invoice or attachment viewers inside the approval page merely to add reachability.
|
||||
- Mobile navigation passes the current approval task URL through the existing `state.from` convention so the business detail back button returns to that task. This is a one-level source contract, not a new nested return stack.
|
||||
- Approval actions remain on the approval task page. Opening the business detail does not approve, reject, return or mutate the task.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Task has a reimbursement -> show the reimbursement-detail link.
|
||||
- Task is another entity type -> do not show a reimbursement link; add that entity's route only in a scoped task.
|
||||
- Reimbursement summary is absent -> render no broken or guessed target.
|
||||
- Business detail permission fails -> preserve the existing permission-denied behavior; do not expand roles from the frontend.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: approver opens the task, follows `查看完整报销单`, inspects evidence, returns to the same task and then chooses an approval action.
|
||||
- Base: a completed reimbursement task still provides the read-only business-detail exit when its existing detail permission allows it.
|
||||
- Bad: make a static-looking summary card secretly clickable, change the pending-list click handler, or copy a partial invoice/attachment viewer into the task page.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Mobile E2E: task detail -> reimbursement detail -> items/evidence visible -> back to the same task -> approval actions still visible.
|
||||
- PC E2E: task detail link -> existing reimbursement detail route and overview visible.
|
||||
- Assert non-reimbursement task details do not receive a reimbursement target.
|
||||
- Run `cd client && npm run typecheck && npm run build`.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```tsx
|
||||
<div onClick={() => handleTaskSelection(task.id)}>报销摘要</div>
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```tsx
|
||||
<Link
|
||||
to={`/mobile/reimbursements/${encodeURIComponent(reimbursement.id)}`}
|
||||
state={{ from: currentMobileSource }}
|
||||
>
|
||||
查看完整报销单
|
||||
</Link>
|
||||
```
|
||||
|
||||
**Implementation order for design refactors**:
|
||||
|
||||
1. A-class frequent pages: workbench, pending tasks, workflow task detail,
|
||||
|
||||
@@ -147,3 +147,12 @@
|
||||
- 后续只有出现第二名实际审批主管,或有人基于真实职责承担 `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 数据结构。
|
||||
- 不把整个摘要卡片改造成无提示的隐式链接;使用明确命名的链接按钮,避免与待办列表卡片的既有任务选择交互混淆。
|
||||
- 请假、采购、差旅审批详情存在同类业务详情缺口,但本轮只修复真实报销链;其他类型另轮统一处理。
|
||||
|
||||
@@ -67,11 +67,19 @@
|
||||
- [x] 在杨俊提交前通过现有审批规则管理接口把三档规则收敛为唯一 `[0,+∞)` 规则,审批链固定为 `department_manager → finance`;不扩角色、不保留 CEO 重复节点。
|
||||
- [x] 规则写入前后只读核对 499.99 / 500 / 5000 三个金额,确认解析结果完全相同且 active `finance` 有唯一实际处理人。
|
||||
- [ ] 请杨俊本人从企业微信进入 OA,确认身份解析成功并能看到报销入口。
|
||||
- [ ] 杨俊使用真实业务数据创建报销,至少添加一条费用明细并上传/绑定状态有效的真实票据后提交。
|
||||
- [x] 杨俊使用真实业务数据创建报销,至少添加一条费用明细并上传/绑定状态有效的真实票据后提交。
|
||||
- [ ] 确认马建勇能看到该真实审批待办并按业务事实处理。
|
||||
- [ ] 审批通过后确认单据进入王芝玲负责的财务处理链路;后续付款批次继续遵守既有 maker-checker。
|
||||
- [ ] 用户验收期间不在聊天、截图或任务证据中粘贴票据、金额、银行资料或完整账号。
|
||||
|
||||
## 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`。
|
||||
|
||||
## 7. 完成条件
|
||||
|
||||
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
|
||||
@@ -98,3 +106,10 @@
|
||||
- 审批规则 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。
|
||||
|
||||
@@ -36,10 +36,11 @@
|
||||
- 最近部署后必须重装自动备份三项 service/timer 和日常巡检 timer,使 unit BUILD_SHA 与活动 server 一致;重装后手工触发一次真实自动异机备份,确认源端成功且 ARM 恢复点新鲜,再允许写入杨俊账号或主管关系。
|
||||
- 第一笔真实业务由员工本人正常发起,目标链路为“员工提交 → 真实主管审批 → 财务处理”;付款批次仍遵守既有 maker-checker。
|
||||
- 当前小团队取消报销金额分层,所有金额统一按“直属主管 → finance”审批;不得保留无人持有的 `finance_mgr` 节点,也不得因马建勇同时是主管和 CEO 而让同一人重复审批。
|
||||
- 真实首笔报销暴露审批可读性阻断:待办列表卡片能够进入审批任务详情,但任务详情只显示单号、申请人和金额摘要,没有通往费用明细、发票和附件的业务详情入口。移动端必须在审批任务详情区提供明确的“查看完整报销单”入口,复用既有 `/mobile/reimbursements/:id` 路由和 `state.from` 回跳约定;PC 审批任务详情同步提供报销单入口。
|
||||
|
||||
## Scope
|
||||
|
||||
本轮范围只包括:修复生产 automatic-backup/WARCH3 timer 的构建合同并取得新鲜异机恢复点;从通讯录导入杨俊及两个既有企微测试成员;设置各自真实或测试主管关系;用两个测试成员完成一条可清理的生产报销体验验收;清理测试业务数据并停用测试 OA 账号;最后由杨俊本人使用真实数据发起报销。所有真实报销内容由杨俊本人提供和提交,所有真实审批与财务处理由对应真实人员完成。
|
||||
本轮范围只包括:修复生产 automatic-backup/WARCH3 timer 的构建合同并取得新鲜异机恢复点;从通讯录导入杨俊及两个既有企微测试成员;设置各自真实或测试主管关系;用两个测试成员完成一条可清理的生产报销体验验收;清理测试业务数据并停用测试 OA 账号;最后由杨俊本人使用真实数据发起报销;修复真实审批任务详情到完整报销单的移动端和 PC 入口。所有真实报销内容由杨俊本人提供和提交,所有真实审批与财务处理由对应真实人员完成。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
@@ -60,6 +61,8 @@
|
||||
- [x] 生产已显式关闭 mock OCR;`off` 路径已用测试账号复验,空发票元数据仍可绑定、提交、主管退回和清理,真实使用暂由财务查看 PDF 原件并人工核对。
|
||||
- [x] 本轮没有调用企微成员写接口;企微成员资料未被 OA 反向修改。
|
||||
- [x] 生产审批规则已收敛为唯一全金额规则,499.99、500、5000 三个边界均解析为 `department_manager → finance`,且没有 CEO 重复节点或 `finance_mgr` 空节点。
|
||||
- [ ] 移动审批任务详情中的报销摘要区提供明确的“查看完整报销单”入口,进入现有移动报销详情并能通过既有 `state.from` 返回原审批任务。
|
||||
- [ ] PC 审批任务详情提供对应的“查看报销单”入口;两端均不修改待办列表卡片的既有点击行为,不修改审批状态机、权限矩阵或后端数据合同。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
@@ -70,6 +73,7 @@
|
||||
- 不在未获单独批准时执行通讯录 apply、主管/法体写入、生产报销或付款操作。
|
||||
- 不使用真实发票、真实金额、真实银行资料或真实员工个人资料作为测试材料;不让测试单进入“通过/待财务/付款”状态。
|
||||
- 不把手机号缺失视为导入阻断项;该员工使用企微或企业邮箱登录,后续按需补手机号。
|
||||
- 不在本轮顺带修复请假、采购、差旅等其他审批类型的同类“业务单据详情入口”缺口;将其记录为后续统一审批中心体验任务候选。
|
||||
|
||||
## Test Plan
|
||||
|
||||
@@ -77,3 +81,4 @@
|
||||
- 通讯录聚焦 dry-run 仅输出人数、计划、冲突、跳过和失败计数。
|
||||
- 生产只读 SQL/Prisma 只返回布尔值和计数,不输出手机号、邮箱、企微 userid、完整账号或原始 payload。
|
||||
- 使用既有报销 workflow/权限聚焦回归验证主管解析和 `expense.approve`,不写生产业务单据。
|
||||
- 前端运行 `cd client && npm run typecheck && npm run build`,并用聚焦 Playwright 覆盖:移动审批任务详情进入完整报销单、返回原任务后审批动作仍可见,以及 PC 审批任务详情进入报销单。
|
||||
|
||||
@@ -1177,8 +1177,19 @@ export function MobileApprovalsPage() {
|
||||
) : null}
|
||||
</div>
|
||||
{selectedTask.reimbursement ? (
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
|
||||
</div>
|
||||
<Button asChild className="w-full" variant="secondary">
|
||||
<Link
|
||||
data-testid="mobile-approval-reimbursement-detail-link"
|
||||
state={{ from: currentMobileSource }}
|
||||
to={`/mobile/reimbursements/${encodeURIComponent(selectedTask.reimbursement.id)}`}
|
||||
>
|
||||
查看完整报销单
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.leaveRequest ? (
|
||||
|
||||
@@ -398,6 +398,17 @@ export function WorkflowTaskDetailPage() {
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{detail.reimbursement ? (
|
||||
<Button asChild variant="secondary">
|
||||
<Link
|
||||
data-testid="workflow-task-reimbursement-detail-link"
|
||||
to={`/reimbursements/${encodeURIComponent(detail.reimbursement.id)}`}
|
||||
>
|
||||
查看报销单
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
|
||||
@@ -242,6 +242,7 @@ function buildNotifications() {
|
||||
|
||||
function workflowTaskSummary(status: "pending" | "completed" = "pending") {
|
||||
const isCompleted = status === "completed";
|
||||
const completedAt = isCompleted ? new Date().toISOString() : null;
|
||||
return {
|
||||
id: isCompleted ? "task-dg7-completed" : "task-dg7",
|
||||
instanceId: "instance-dg7",
|
||||
@@ -252,7 +253,7 @@ function workflowTaskSummary(status: "pending" | "completed" = "pending") {
|
||||
status,
|
||||
outcome: isCompleted ? "approved" : null,
|
||||
createdAt: "2026-05-12T08:00:00.000Z",
|
||||
completedAt: isCompleted ? "2026-05-12T09:00:00.000Z" : null,
|
||||
completedAt,
|
||||
workflowInstance: {
|
||||
entityType: "reimbursement",
|
||||
entityId: "reimbursement-dg7",
|
||||
@@ -272,6 +273,7 @@ function workflowTaskSummary(status: "pending" | "completed" = "pending") {
|
||||
|
||||
function workflowTaskDetail(status: "pending" | "completed" = "pending") {
|
||||
const isCompleted = status === "completed";
|
||||
const completedAt = isCompleted ? new Date().toISOString() : null;
|
||||
const history = [
|
||||
{
|
||||
id: "history-workflow-submit-dg7",
|
||||
@@ -309,7 +311,7 @@ function workflowTaskDetail(status: "pending" | "completed" = "pending") {
|
||||
outcome: isCompleted ? "approved" : null,
|
||||
comment: null,
|
||||
createdAt: "2026-05-12T08:00:00.000Z",
|
||||
completedAt: isCompleted ? "2026-05-12T09:00:00.000Z" : null,
|
||||
completedAt,
|
||||
},
|
||||
workflowInstance: {
|
||||
id: "instance-dg7",
|
||||
@@ -989,6 +991,16 @@ test.describe("MIF7 移动信息架构整改", () => {
|
||||
|
||||
await mobile.page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile$/);
|
||||
|
||||
await mobile.page.getByTestId("mobile-pending-summary").getByRole("link").first().click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
|
||||
await mobile.page.getByTestId("mobile-approval-reimbursement-detail-link").click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/reimbursements\/reimbursement-dg7$/);
|
||||
await expect(mobile.page.getByTestId("mobile-reimbursement-items")).toBeVisible();
|
||||
await mobile.page.getByTestId("mobile-reimbursement-detail-header").getByRole("button", { name: "返回" }).click();
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
|
||||
await expectApprovalActionsStable(mobile.page);
|
||||
|
||||
await mobile.page.goto("/mobile/approvals");
|
||||
await expect(mobile.page.getByRole("tab", { name: "待我处理" })).toBeVisible();
|
||||
await expect(mobile.page.getByRole("tab", { name: "已处理" })).toBeVisible();
|
||||
|
||||
@@ -1097,6 +1097,12 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
await expect(page.getByText("审批动作")).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "处理", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
||||
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
|
||||
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
|
||||
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/workflow\/tasks\/task-wave3$/);
|
||||
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("02-pc-workflow-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/notifications");
|
||||
|
||||
在新工单中引用
屏蔽一个用户