SOP-607:外采帐户直退审批

标准 SOP 测试文档;每个任务(Task ID)代表一个独立的测试步骤,便于人工/自动化测试按序号完成并记录进度。

文档元数据

  • 文档类型:SOP / Checklist
  • 适用场景:外采广告帐户"直退"——财务直接对外采广告帐户做单边扣减(差错冲正 / 外部退回款落账),不动端口余额、不走媒介审批,SOP-606 直充的逆向;与 SOP-605 端口退款在资金路径上互不重叠(SOP-605 冲端口余额、SOP-607 冲帐户余额)
  • 入口位置:CRM → 广告账户 → 「外采」Tab → 选行打开抽屉 → 顶部「退款」按钮(外采账户上下文中已替换为"直退"实现,非外采账户仍走原 Refund)
  • 配置文件
  • migrations/20260429_create_external_direct_refund_review_template.sql(SOP-607 审批模板)
  • migrations/20260429_rollback_external_direct_refund_review_template.sql(回滚脚本)
  • 关联 spec.kiro/specs/external-account-direct/
  • 创建者:财务 / 运营 / QA
  • 最近更新时间:2026-04-29
  • 相关接口定义:参考 crm.swagger.jsonCashServiceReviewService 模块(特别是 POST /api/v1/cash/refundPOST /api/v1/cash/trans_ordersGET /api/v1/cash/trans_orders/{id}POST /api/v1/cash/porter_balanceGET /api/v1/reviews/{flow_id}POST /api/v1/reviews/{flow_id}/actions

工作流概述

流程图

graph LR
    A[抽屉点退款发起申请] --> B[财务审批]
    B -->|通过| C[settle_external_direct_refund 原子结算]
    C --> C0{可选 origin 校验}
    C0 -->|提供| C1[SELECT FOR UPDATE 原直充 + 累计 SUM 校验 ≤ 原直充金额]
    C0 -->|不提供| C2[跳过累计校验]
    C1 --> D
    C2 --> D
    D[乐观锁扣减 accounts.balance >= amount] --> E1[写 transactions EXPEND / 外采帐户直退]
    E1 --> E2[订单状态 SUCCESS]
    B -->|驳回| F[update_status FAILED 不动账]

审批角色

步骤 审批人 模式 说明
财务审批 往来会计 / 总账会计 / 税务会计 / 财务主管 / 财务总 any(单步审批) 与 SOP-605 / SOP-606 一致

与既有外采流程的差异速查

维度 SOP-605 端口退款 SOP-607 外采帐户直退
入口 RPC cash.PorterRefund cash.Refund(mode=REFUND_MODE_DIRECT)
媒介审批 无(财务单步) 无(财务单步)
动 ext_corps.balance 是(扣端口余额,乐观锁) 否(端口余额只读)
动 accounts.balance 是(乐观锁扣帐户)
transactions.type EXPEND EXPEND
transactions.sub_act_type 端口退款 外采帐户直退
OrderType ORDER_TYPE_PORTER_REFUND ORDER_TYPE_EXTERNAL_DIRECT_REFUND
审批事件 transOrder.porter.refund transOrder.external.directRefund
origin 关联 可选(限端口充值 SUCCESS 单) 可选(限直充 SUCCESS 单 + 累计上限)

前置条件

条件 检查方式
已使用有权限用户登录(拥有 CashService.RefundDirectCashService.TransferFull Casbin policy.csv 含 p,$角色,*,CashService.RefundDirect
目标广告帐户存在且 provider='EXTERNAL'、关联端口存在 同 SOP-606
客户的外采帐户行已存在且 accounts.balance >= 直退金额 SELECT balance FROM accounts WHERE customer_id=? AND account_type='外采' AND porter_id=?
已配置 SOP-607 审批模板 SELECT id FROM review_templates WHERE sop_number='SOP-607' AND is_active=1
审批模板 on_approved_actions[0].configaccount_id=payload.AccountID / amount=payload.Amount 否则结算 action 会报字段缺失
抽屉里"退款"按钮可点击 main.tsx 的 canOperate 守门(绑定 customer + 有效政策标签)
(可选)携带 origin 时:原直充订单状态=SUCCESS、OrderType=ORDER_TYPE_EXTERNAL_DIRECT_DEPOSIT、客户 + 端口一致 SELECT order_type, status, customer_id, porter_id FROM trans_orders WHERE id=ORIGIN_ID

任务矩阵

任务 T1:进入外采广告帐户抽屉

入口:CRM 菜单 → 广告账户

Action Steps Input / Payload 预期结果 验证方式 证据 状态 下游任务
步骤1:打开 /crm/adaccount → 切「外采」Tab 列表只剩 EXTERNAL 广告帐户 列字段全 EXTERNAL 截图 [ ] 步骤 2
步骤2:点击行打开抽屉 单击行 抽屉顶部三按钮显示「充值 / 退款 / 广告户转账」 抽屉渲染 截图 [ ] 任务 T2

任务 T2:填写并提交直退表单

入口:抽屉 → 顶部「退款」按钮(外采账户上下文已替换为直退)

Action Steps Input / Payload 预期结果 验证方式 证据 状态 下游任务
步骤1:点击「退款」 Dialog 打开,标题「外采帐户直退」 标题正确 截图 [ ] 步骤 2
步骤2:核对只读卡 客户名 + 外采广告账户(name - accountId 与抽屉一致 截图 [ ] 步骤 3
步骤3:核对端口资金卡 端口名 / 端口余额 / 累计充值 / 累计提现 全部展示;无端口绑定时显示「该广告账户未关联端口」 porter_balance 接口一致 截图 [ ] 步骤 4
步骤4:填金额 amount=50 输入框显示 50 字段值正确 截图 [ ] 步骤 5
步骤5:(可选)填原直充订单 ID origin_trans_order_id=902(一笔 status=SUCCESS 的直充单) 字段值正确 后端会校验该单存在/类型/状态/客户/端口一致 截图 [ ] 步骤 6
步骤6:填直退事由 refund_reason="差错冲正 e2e 测试" textarea 显示输入内容 字段值正确 截图 [ ] 步骤 7
步骤7:(可选)填备注 memo=e2e 备注显示 UI 显示 截图 [ ] 步骤 8
步骤8:提交 点击「提交审批」 trans_orders 新增一行 order_type=ORDER_TYPE_EXTERNAL_DIRECT_REFUND, status=REVIEWING,触发审批 SELECT order_type, status FROM trans_orders ORDER BY id DESC LIMIT 1 截图 [ ] 任务 T3
步骤9:边界——空事由 留空 refund_reason 提交 HTTP 400,消息 refund_reason 不能为空 Toast 截图 [ ]
步骤10:边界——金额 ≤ 0 amount=0 HTTP 400,消息 直退金额必须大于0 Toast 截图 [ ]
步骤11:边界——非外采账户(绕过 UI) curl mode=DIRECT from_account_id=GDT账户 HTTP 400,消息 广告帐户 X 不是外采帐户(provider=GDT) curl + 400 截图 [ ]
步骤12:边界——拒绝对端字段 body 加 to_account_id / use_credit / use_pending HTTP 400,消息 直退分支不接受 to_account_id curl + 400 截图 [ ]
步骤13:边界——origin 不存在 origin_trans_order_id=99999999 HTTP 400,消息 原直充订单 X 不存在 Toast 截图 [ ]
步骤14:边界——origin 类型不符 填一笔非直充的订单 ID(如 SOP-604 外采付款) HTTP 400,消息 原订单 X 不是外采帐户直充(OrderType=...) Toast 截图 [ ]
步骤15:边界——origin 状态非 SUCCESS 填一笔 REVIEWING 的直充单 HTTP 400,消息 原直充订单 X 状态不是 SUCCESS Toast 截图 [ ]
步骤16:边界——origin 客户/端口不一致 填别的客户/端口的直充单 ID HTTP 400,消息 原直充订单 X 的客户与本次直退不一致... 端口与本次直退不一致 Toast 截图 [ ]

任务 T3:查看审批详情

入口:审批中心 → 我发起的 / 工作流详情

Action Steps Input / Payload 预期结果 验证方式 证据 状态 下游任务
步骤1:打开审批详情 点击刚创建的审批 payload 含 businessDetails.businessReasonorderId(origin 直充 ID 时) UI 渲染 截图 [ ] 步骤 2
步骤2:核对扁平字段 customerId / porterId / amount 与提交一致;isExternal=true 详情面板字段 截图 [ ] 任务 T4

任务 T4:财务审批(唯一步骤)

入口:审批中心 → 待我审批(财务 position 登录)

Action Steps Input / Payload 预期结果 验证方式 证据 状态 下游任务
步骤1:财务登录 position{往来会计, 总账会计, 税务会计, 财务主管, 财务总} 登录成功 页面加载 截图 [ ] 步骤 2
步骤2:查看待审批 筛选 SOP-607 列表出现该单 列表含 "外采帐户直退审批" 截图 [ ] 步骤 3
步骤3:通过审批 点击「通过」 review_instances.status=APPROVED,触发 settle_external_direct_refund DB + log 截图 [ ] 任务 T5
步骤4:(独立场景)驳回 点击「驳回」 trans_orders.status=FAILEDaccounts.balance 不变;transactions 无新行 DB 查询 截图 [ ]

任务 T5:验证资金落账

入口:MySQL 直查

Action Steps Input / Payload 预期结果 验证方式 证据 状态 下游任务
步骤1:accounts.balance 扣减(乐观锁) SELECT balance, total_outcome, cash_outcome, cash_balance FROM accounts WHERE customer_id=? AND account_type='外采' AND porter_id=? balance / cash_balance -=amount,total_outcome / cash_outcome +=amount(WHERE balance>=amount 乐观锁,RowsAffected=0 则订单 FAILED) 数值比对 截图 [ ] 步骤 2
步骤2:transactions 新增 SELECT type, sub_act_type, amount, status, account_type, porter_id FROM transactions WHERE trans_order_id=TO_ID EXPEND / 外采帐户直退 / amount / SUCCESS / 外采 / porter_id 全字段核对 截图 [ ] 步骤 3
步骤3:ext_corps.balance 不变 SELECT balance, total_balance FROM ext_corps WHERE id=PORTER_ID(前后对比) 两个值与提交前一致 数值比对 截图 [ ] 步骤 4
步骤4:trans_orders 最终态 SELECT status, err_msg FROM trans_orders WHERE id=TO_ID SUCCESS + err_msg 空 字段核对 截图 [ ] 步骤 5
步骤5:审批失败路径——帐户余额不足 accounts.balance < amount 后审批通过 trans_orders.status=FAILED + err_msg帐户余额不足accounts 无副作用 字段核对 截图 [ ] 步骤 6
步骤6:审批失败路径——origin 累计超额 同一原直充单连续两笔直退,累计金额 > 原直充金额 第二笔 trans_orders.status=FAILED + err_msg超过原直充金额上限accounts 不扣 字段核对 + 并发场景 截图 [ ]

任务 T6:列表与详情字段验证

入口:CRM → 审批中心 / 后台直查

Action Steps Input / Payload 预期结果 验证方式 证据 状态 下游任务
步骤1:通用列表过滤 filters: {field:"orderType", op:"EQUAL", value:"ORDER_TYPE_EXTERNAL_DIRECT_REFUND"} 仅显示直退单 接口返回行数与 DB 一致 截图 [ ] 步骤 2
步骤2:列表透出 business_details businessDetails.businessReason 字段非空 与提交值一致 接口响应 截图 [ ] 步骤 3
步骤3:单条详情透出 business_details + origin orderId GET /api/v1/cash/trans_orders/{id} businessDetails.businessReasonorderId(如有 origin) 接口响应 截图 [ ] 步骤 4
步骤4:金额展示方向 列表 / 详情 金额按 NEGATIVE_ORDER_TYPES 显示为负方向(红色 / 出账) UI 渲染 截图 [ ]

任务 T7:对账兼容性

入口:worker 日志 + DB

Action Steps Input / Payload 预期结果 验证方式 证据 状态 下游任务
步骤1:触发 reconcile_porter_balance 等待 worker 周期或手动触发 该端口 drift 在阈值内(直退流水被 sub_act_type='外采帐户直退' 排除规则识别,不计入端口余额聚合) worker 日志 截图 [ ] 步骤 2
步骤2:ListPorters / PorterBalance 端口余额 POST /api/v1/advertiser/portersPOST /api/v1/cash/porter_balance 直退前后端口余额值不变(两接口口径已统一到真源) 接口响应 截图 [ ]

任务 T8:SOP-606 直充回归(共享审批模板与子表)

入口:CRM → 广告账户 → 外采 Tab → 抽屉 → 充值

Action Steps Input / Payload 预期结果 验证方式 证据 状态 下游任务
步骤1:发起一笔直充 走 SOP-606 完整链路 审批通过、settle_external_direct_deposit 落地 DB 查询 截图 [ ] 步骤 2
步骤2:业务详情字段 SELECT business_reason FROM trans_order_business_details WHERE trans_order_id=TO_ID 与提交事由一致(验证表共享未破坏既有路径) DB 查询 截图 [ ]

API 参考

相关接口

接口 方法 描述
/api/v1/cash/refund POST 直退入口(mode=REFUND_MODE_DIRECT
/api/v1/cash/trans_orders POST 列订单(filters 含 order_type=ORDER_TYPE_EXTERNAL_DIRECT_REFUND
/api/v1/cash/trans_orders/{id} GET 单条详情
/api/v1/cash/porter_balance POST Dialog 顶部「端口资金」展示
/api/v1/customers/{id}/advertisers GET 客户名下广告账户(前端按 provider=EXTERNAL 筛)
/api/v1/reviews/{flow_id} GET 审批详情
/api/v1/reviews/{flow_id}/actions POST approve / reject

直退不提供专属列表 / 详情 RPC——直接复用通用 ListTransOrders / GetTransOrder

测试数据示例

成功案例

请求参数:

{
  "customer_id": 1024,
  "from_account_id": "1100000007",
  "amount": 50,
  "mode": "REFUND_MODE_DIRECT",
  "refund_reason": "差错冲正 e2e 测试",
  "origin_trans_order_id": 902,
  "memo": "e2e"
}

origin_trans_order_id 可选;不填即为独立直退;填了则做存在 + 类型 + SUCCESS + 客户/端口一致四条件校验,并在结算瞬间断言累计直退金额 ≤ 原直充金额。

预期响应:

{
  "transOrder": {
    "id": 903,
    "status": "REVIEWING",
    "orderType": "ORDER_TYPE_EXTERNAL_DIRECT_REFUND",
    "amount": 50,
    "isExternal": true,
    "porterId": 10000,
    "orderId": 902,
    "businessDetails": { "businessReason": "差错冲正 e2e 测试" }
  },
  "review": { "id": 934, "flowId": "...", "status": "REVIEWING" }
}

失败案例

场景 错误提示 HTTP 状态码
未传 from_account_id from_account_id 不能为空 400
广告帐户不存在 广告帐户 X 不存在 404
广告帐户 provider 非 EXTERNAL 广告帐户 X 不是外采帐户(provider=...) 400
广告帐户缺端口归属 外采帐户 X 缺少端口归属 400
客户外采帐户行不存在 客户 N 的外采帐户行不存在(请先通过 SOP-601 建立外采帐户) 400
金额 ≤ 0 直退金额必须大于0 400
空 refund_reason refund_reason 不能为空 400
设了对端字段 to_account_id 直退分支不接受 to_account_id 400
设了对端字段 use_credit 直退分支不接受 use_credit 400
设了对端字段 use_pending 直退分支不接受 use_pending 400
origin 不存在 原直充订单 X 不存在 400
origin 类型不符 原订单 X 不是外采帐户直充(OrderType=...) 400
origin 状态非 SUCCESS 原直充订单 X 状态不是 SUCCESS(当前=...) 400
origin 客户不一致 原直充订单 X 的客户与本次直退不一致 400
origin 端口不一致 原直充订单 X 的端口与本次直退不一致 400
角色未授权 角色 X 无权执行外采帐户直退(需 CashService.RefundDirect 权限) 403
帐户余额不足(结算瞬间,乐观锁 RowsAffected=0) trans_orders.status=FAILED + err_msg='帐户余额不足'(同步审批返回成功,但订单 FAILED) 审批返回 200
origin 累计直退超额 trans_orders.status=FAILED + err_msg超过原直充金额上限 审批返回 200
模板未配置 config.account_id / config.amount 异步路径置 trans_orders.status=FAILED 审批返回 200

异常处理

异常场景 预期行为 处理方法
抽屉「退款」按钮 disabled 当前账户未绑定 customer 或没有有效政策标签 main.tsx 的 canOperate 守门;先走标签流程
端口资金卡显示「端口余额为 0 或暂无流水」 porter_balance 接口对 total_balance > 0 做了过滤 不影响直退流程(直退冲帐户余额,与端口余额无关)
审批详情卡片缺字段 仅显示 business_reason 直退不写 business_type / business_date / payee_*,属正常
详情 / 列表 businessDetails 为 null service 未 Preload 同 SOP-606
结算静默失败 trans_orders 停在 REVIEWING err_msg;正常路径 completion actions 失败会写回 err_msg + status=FAILED
审批通过但 accounts.balance 未变 排查 executeSettleExternalDirectRefund 是否走了乐观锁 RowsAffected=0 分支 看 trans_orders.err_msg + zap 日志 method=SettleExternalDirectRefund
调用接口 401 / 403 Casbin 拒绝 gateway/config/policy.csv 是否含 p,$角色,*,CashService.RefundDirect(或 TransferFull);StartAutoLoadPolicy 默认 3 分钟刷新
端口余额漂移告警 reconcile_porter_balance worker 误识别直退流水 worker 与 ListPorters 已排除 sub_act_type='外采帐户直退',若仍漂移检查 SQL 是否回滚到旧版

进度采集模板

任务ID 任务名称 执行人 开始时间 结束时间 状态 备注
T1 进入外采广告帐户抽屉
T2 填写并提交直退表单
T3 查看审批详情
T4 财务审批
T5 验证资金落账
T6 列表与详情字段验证
T7 对账兼容性
T8 SOP-606 直充回归

附录

相关配置文件

  • migrations/20260429_create_external_direct_refund_review_template.sql — SOP-607 审批模板(事件 transOrder.external.directRefund + settle_external_direct_refund 动作 + 财务岗审批人)
  • migrations/20260429_rollback_external_direct_refund_review_template.sql — SOP-607 回滚脚本

高危规则

  • review_templates.on_approved_actions[*].type='settle_external_direct_refund' 是高危字段:手工修改 type 会切换执行器:
  • 改为 update_status → 仅置 SUCCESS,不动账、不写流水(直接资损 / 退款流于形式)
  • 改为 settle_porter_refund → 错误地走端口余额扣减(应扣帐户却扣端口)
  • description 字段与 type 不联动;改前必须对照 internal/review/executor/action_executor.go::Execute switch case 一一确认。
  • payload 字段映射不能改:与 SOP-606 同;config.account_id=payload.AccountID / config.amount=payload.Amount 与 settle 函数强耦合,错改会让乐观锁 step 直接 RowsAffected=0。
  • origin 累计校验是事务边界内的SELECT FOR UPDATE 锁定原直充单后再 SUM 历史直退累计,避免并发突破上限;任何把它移到事务外的改动都属于资损级 hotfix,必须回归 SOP-605 的 origin 累计场景同款用例。

相关文档

  • docs/SOP/sop-606-external-direct-deposit.md — 外采帐户直充(SOP-607 的逆向)
  • docs/SOP/sop-605-porter-refund.md — 端口退款(与直退在资金路径上互不重叠)
  • docs/SOP/sop-402-ad-account-refund.md — 普通广告帐户退款
  • docs/SOP/sop-601-external-recharge.md — 外采帐户充值审批

e2e 脚本

  • e2e/external_direct_refund/lib.sh + e2e/external_direct_refund/run_s1_happy_path.sh(core 仓库):创建 → 审批 → 资金落账 → ext_corps.balance 不变验证 全链路

文档版本:v1.0.0 最后更新:2026-04-29 维护者:财务团队 / 运营团队 / QA