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.json 中 CashService 与 ReviewService 模块(特别是 POST /api/v1/cash/refund、POST /api/v1/cash/trans_orders、GET /api/v1/cash/trans_orders/{id}、POST /api/v1/cash/porter_balance、GET /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.RefundDirect 或 CashService.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].config 含 account_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.businessReason、orderId(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=FAILED;accounts.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.businessReason 与 orderId(如有 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/porters 与 POST /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