解决 9 个冲突文件,并修复合并后遗留问题: - 大厅候选集:采用 FIFO 窗口 + 全部置顶单 UNION(列表与抢单/拼单复核同源) - 拼单份额:采用多次参与模型(每次参与 INSERT 新份额、requestId 幂等), 取消后可重新参与;移除 HEAD 已废弃的单行 rejoin 逻辑与对应测试 - 工单迁移与成本中心 103–108 编号撞车:合并重写为 109_work_order_share_participation / 110_work_order_hall_and_shop_indexes; 临时库全量执行与重跑幂等验证通过(10 列、唯一键已删、7 个索引) - 实时补丁:保留个性化字段、置顶重排、分页总数同步;补回 orderTimelineVisible 类型 - 打手大厅:保留 memo 组件化,移植「再拼一次」多份额按钮逻辑 - 全项目表格统一 PercentTable:WorkerOrderShareTable 改造 验证:backend typecheck/lint/check:sql/test(833 pass)、 frontend typecheck/lint/test(172 pass)/build 全绿。
468 lines
26 KiB
Markdown
468 lines
26 KiB
Markdown
# 打手工单深挖:抢单 / 拼单 / 好友赠送 / 结算(2026-10-23)
|
||
|
||
> 状态:**梳理稿(待评审,未登记索引)**
|
||
> 落笔日期:2026-10-23
|
||
> 代码基线:工作区当前 HEAD(迁移 001–110)
|
||
> 范围:`work_orders` 从「可抢」到「已验收 + 资金结算」的完整闭环,含拼单份额、好友赠送、超时/问题单/退单申请、售后与欠款
|
||
> 不在范围:寄卖提报与匹配算法、订单进单、领取页(见 `项目全流程与数据库设计梳理-2026-10-23.md`)
|
||
|
||
---
|
||
|
||
## 一、一页结论
|
||
|
||
1. **一条工单只有两个资金入口**:接单时冻结押金(`deposit_freeze`),验收时结算报酬 + 释放押金(`reward_settlement` / `deposit_release`,或延迟解冻)。
|
||
2. **押金不是「按单」而是「按接单关系的未结押金」**:`getOutstandingDepositAmountWithClient(workerId, workOrderId)` 是唯一口径,同一工单多次接取/退回也只处理当前仍冻结的部分。
|
||
3. **拼单的免押额度按「该打手在本工单的累计份额」抵扣一次**,且**已冻结金额不会因为追加份数变少而自动回退**(`Math.max(当前冻结, 新要求)`)。
|
||
4. **好友赠送是「三态叠加」**:`work_orders.status`(生命周期)+ `gift_phase`(赠送阶段)+ `gift_available_at`(冷却到点)。`gift_ready` **不落库**,由到点实时计算。
|
||
5. **确认加好友是一道资金分水岭**:`friend_added_at` 一旦写入,任何「释放押金」的处置都会被强制改成「扣除押金」,寄卖卖家的发布押金同口径一并没收。
|
||
6. **超时、问题单、退单申请三条处置路径共用一套押金策略词表**:`reopen / cancel_release / cancel_deduct`(问题单另有 `return_to_worker`,不碰押金)。
|
||
7. **验收有两条免审通道**:内部打手(`worker_type='internal'`)与 VIP(`autoAcceptWithoutEvidence`,可无图提交,但留 24 小时补证据窗口);**好友赠送单一律人工验收**。
|
||
8. **退单是唯一会掉等级/封号的常规路径**:VIP1 累计 5 次通过 → 直接禁用账号并吊销会话;其他等级每 10 次通过 → 降一级并把 `level_penalty_accepted_count` 抬高,变相提高升级门槛。
|
||
9. **资金全部落在 `worker_wallet_ledgers`**(只追加),但**余额没有「由流水重算」的校验**,也没有会计期间;它仍然是业务钱包,不是总账。
|
||
10. **锁序不统一**:抢单先锁钱包再更新工单,指派先锁工单再锁钱包,拼单先锁工单再锁钱包——`grabWorkOrder` 是唯一的「钱包优先」路径。
|
||
|
||
---
|
||
|
||
## 二、单据与资金模型
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph 单据
|
||
WO["work_orders<br/>生命周期状态"]
|
||
WS["work_order_shares<br/>拼单份额"]
|
||
EV["work_order_events<br/>只追加时间线"]
|
||
end
|
||
subgraph 资金
|
||
W["worker_wallets<br/>available / frozen_deposit<br/>pending_unfreeze / frozen_withdraw"]
|
||
L["worker_wallet_ledgers<br/>只追加流水"]
|
||
U["worker_deposit_unfreezes<br/>延迟解冻队列"]
|
||
end
|
||
WO -->|"整单接取"| D1["deposit_freeze"]
|
||
WS -->|"按份额接取"| D1
|
||
WO -->|"验收"| D2["reward_settlement / deposit_release"]
|
||
WS -->|"份额验收"| D2
|
||
D2 -->|"unfreezeDays > 0"| U
|
||
U -->|"到期任务"| D3["deposit_unfreeze"]
|
||
WO -->|"超时/问题单/退单"| D4["deposit_release 或 deposit_deduction"]
|
||
D1 --> L
|
||
D2 --> L
|
||
D3 --> L
|
||
D4 --> L
|
||
L --> W
|
||
```
|
||
|
||
**唯一的余额写入方式**(除提现外)都是「`SELECT … FOR UPDATE` 读钱包 → 计算 next → `UPDATE` → `INSERT ledger`」,没有任何一处只更新余额而不写流水。
|
||
|
||
---
|
||
|
||
## 三、抢单
|
||
|
||
### 3.1 入口与校验顺序
|
||
|
||
`POST /api/v1/worker/hall/orders/:workOrderId/grab` → `grabWorkerHallOrder`
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant W as 打手
|
||
participant S as hall-action-service
|
||
participant DB as PostgreSQL
|
||
W->>S: grab(workOrderId)
|
||
S->>S: 会话有效 → 读打手 + 等级权限 + 大厅配置
|
||
S->>S: visibleAfterIso = now - visibleDelaySeconds(等级可见延迟)
|
||
S->>DB: 读工单
|
||
alt workOrder.sharing_enabled
|
||
S->>DB: joinWorkOrderShare(takeRemaining=true)
|
||
else 整单抢单
|
||
S->>DB: countWorkerActiveOrders
|
||
S->>S: 校验 maxActiveOrders / 可用余额 ≥ 冻结额
|
||
S->>DB: grabWorkOrder(...)(单事务)
|
||
S->>DB: work_order_events grabbed + 实时事件
|
||
end
|
||
```
|
||
|
||
整单抢单在**事务内**再次做全部校验(防并发),并条件更新:
|
||
|
||
```sql
|
||
UPDATE work_orders
|
||
SET status='in_progress', assigned_worker_id=$1, last_assigned_worker_id=$1,
|
||
assigned_at=$2, deadline_at=$3,
|
||
gift_phase = CASE WHEN acceptance_mode='friend_gift' THEN 'material_required' ELSE '' END,
|
||
booster_material_json='{}', friend_added_at=NULL, gift_available_at=NULL,
|
||
<CLEAR_DELIVERY_RESIDUE_SQL>, updated_at=$2
|
||
WHERE id=$4 AND status='open' AND assigned_worker_id IS NULL
|
||
```
|
||
|
||
`CLEAR_DELIVERY_RESIDUE_SQL` 清掉上一手的 `submitted_at / accepted_at / acceptance_json / draft_acceptance_json / problem_note / worker_note`,但**不动买家资料**(`material.collect`)和寄卖留痕(`consignmentHistory`)。
|
||
|
||
### 3.2 冻结押金怎么算
|
||
|
||
```text
|
||
整单:freezeAmount = max(0, work_order.required_deposit_amount - depositFreeAmount)
|
||
拼单:nextRequired = round(required_deposit_amount × nextQuantity / totalQuantity)
|
||
requiredShareDeposit = max(0, nextRequired - depositFreeAmount)
|
||
share_deposit = max(已冻结的 share_deposit, requiredShareDeposit) // 不回退
|
||
本次冻结 = share_deposit - 已冻结
|
||
```
|
||
|
||
`depositFreeAmount = 等级 permission_json.depositFreeAmount + worker_users.personal_deposit_free_amount`(两者叠加,个人额度是等级之上的追加额度)。
|
||
|
||
> 注意:`deposit_threshold_amount`(押金门槛)目前只读写/展示,不参与冻结计算;自动生成的工单把 `required_deposit_amount` 与 `deposit_threshold_amount` 都写成 0(见风险 W1)。
|
||
|
||
### 3.3 大厅可见性
|
||
|
||
| 规则 | 实现 |
|
||
| --- | --- |
|
||
| 等级可见延迟 | `permission_json.visibleDelaySeconds`;未配置时按等级键默认:vip1 20s / vip2 15s / vip3 10s / vip4 5s / vip5 0s |
|
||
| 大厅总量 | `worker_hall.maxVisibleOrders`(默认 50,上限 500) |
|
||
| 分类上限 | `worker_hall.categoryMaxVisibleOrders`(分类 ID → 上限) |
|
||
| 拼满隐藏 | `excludeFilledSharing` |
|
||
|
||
可见延迟**不是拦截**,而是「只把发布时间早于 `now - delay` 的单放进候选集」,抢单时在事务内再校验一次容量,防止延迟期内被绕过。
|
||
|
||
---
|
||
|
||
## 四、拼单
|
||
|
||
### 4.1 数据模型
|
||
|
||
`work_order_shares`:`UNIQUE(work_order_id, worker_id)`,一份行代表一个打手在该工单的全部份额。
|
||
|
||
| 列 | 含义 |
|
||
| --- | --- |
|
||
| `quantity` | 该打手累计份数(同一工单可追加) |
|
||
| `unit_reward` / `share_reward` | 单份报酬 / 累计报酬 |
|
||
| `share_deposit` | 该打手是否该单的累计冻结额(不是本次增量) |
|
||
| `status` | `joined → submitted → accepted / cancelled` |
|
||
| `acceptance_json` / `draft_acceptance_json` | 验收资料 / 草稿 |
|
||
|
||
### 4.2 参与规则
|
||
|
||
- 普通参与:指定 `quantity`(默认 1),不得超过剩余份数 `sharing_total_quantity - Σ(非 cancelled 份额)`。
|
||
- 「立即抢单」:`takeRemaining=true`,在事务锁内**承接全部剩余份数**;如果已有自己的 `joined` 份额,则在此基础上追加,否则报「数量已满」。
|
||
- 整单接取者不能再拼单(`work_order_owner_conflict`)。
|
||
- 重复参与被唯一键挡住;同一打手多次参与只能走「追加份数」路径。
|
||
- 活跃单上限只在**新建份额**时校验(追加不重复计入)。
|
||
|
||
### 4.3 母单状态收敛
|
||
|
||
```text
|
||
份额 joined → 母单保持 open(可继续参与)
|
||
份额 submitted → 仍 open;若「份数已全部分配」且「没有 joined 份额」→ 母单转 pending_acceptance
|
||
单份额验收 → 该份额 accepted;若这是最后一个未结算份额 → 母单 accepted 并补跑寄卖钩子
|
||
全部取消 → 母单回 open
|
||
```
|
||
|
||
`submitWorkOrderShareAcceptance` 里那句 `WHERE id=$2 AND status='open'` 是关键:只有母单还在 `open` 时才允许被自动推进,避免与后台手动验收并发覆盖。
|
||
|
||
### 4.4 两种验收姿势
|
||
|
||
| 方式 | 入口 | 行为 |
|
||
| --- | --- | --- |
|
||
| 单独验收某份额 | `POST /admin/worker-platform/orders/:id/shares/:shareId/accept` | 只结算该份额:份额报酬 + 份额押金;已结算份额不会在统一验收时重复结算 |
|
||
| 统一验收母单 | `POST /admin/worker-platform/orders/:id/accept` | 先检查所有份额都已 `submitted`(有任一 `joined` 直接 409 `sharing_pending_submissions`),再逐个结算未结算份额,最后结算整单打手(如有) |
|
||
|
||
> 设计上的一个细节:统一验收时 `settledShareCount` 用「`status !== 'accepted'`」统计,已经单独验收过的份额会被跳过——这是避免重复发钱的唯一保护,没有幂等键。
|
||
|
||
### 4.5 已提交份额不能绕过
|
||
|
||
问题单的 `reopen / cancel_*` 与超时处置都会先检查 `getActiveWorkOrderShareCountWithClient`;有活跃份额就拒绝整单处置(`work_order_has_active_shares`)。这是为了防止「母单被整单带走,份额验收入口全部锁死」。
|
||
|
||
---
|
||
|
||
## 五、好友赠送(friend_gift)
|
||
|
||
### 5.1 三态叠加
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> material_required: 建单时 acceptance_mode=friend_gift
|
||
material_required --> material_submitted: 打手提交本人资料(昵称/ID/区服/系统 + 主页截图)
|
||
material_submitted --> friend_countdown: 后台确认已加好友(记 friend_added_at,算 gift_available_at)
|
||
friend_countdown --> gift_ready: 到点(不落库,实时计算)
|
||
gift_ready --> pending_acceptance: 打手提交赠送凭证
|
||
pending_acceptance --> accepted: 后台人工验收(必审)
|
||
material_submitted --> material_required: 后台重置资料
|
||
friend_countdown --> material_required: 后台重置资料
|
||
```
|
||
|
||
- `work_orders.status` 在整条链路上保持 `in_progress`(或 `problem`),**不因赠送阶段改变**;前端展示用 `resolveWorkOrderGiftPhase()`。
|
||
- 冷却时长 = `gift_cooldown_hours` 小时 + `gift_cooldown_minutes` 分钟;两者都为 0 时按默认 **72 小时** 兜底(`DEFAULT_GIFT_COOLDOWN_HOURS`),上限 30 天。
|
||
- 确认加好友时,`deadline_at` 被顺延为「冷却结束 + `timeout_minutes`」,即**冷却期不计入打手超时**。
|
||
- 提交验收时如果 `gift_phase !== friend_countdown` 或未到点,直接 409 `work_order_gift_cooldown_active`。
|
||
|
||
### 5.2 押金分水岭
|
||
|
||
```text
|
||
friend_added_at IS NULL → 取消/退回/超时:按策略正常退还
|
||
friend_added_at 有值 → 取消/退回/超时:释放类一律转为扣除(deposit_deduction)
|
||
寄卖卖家的发布押金同口径没收
|
||
```
|
||
|
||
实现集中在 `isWorkOrderGiftDepositForfeitable()`,被超时处置、问题单处置、退单审核三处共用。语义是:**好友已经加了,赠送动作由打手/卖家完成,此时脱离订单属于套利**。
|
||
|
||
### 5.3 内部现货变体(寄卖)
|
||
|
||
寄卖库存绑定工单走 `internal_stock` 时没有接单打手,流程被压缩:
|
||
|
||
```text
|
||
assigned_worker_id = null,material.consignment.fulfillmentType = internal_stock
|
||
→ parkWorkOrderWaitingFriendAdd:status=in_progress,gift_phase=material_required(语义变成「待加好友」)
|
||
→ 客服线下通知买家加好友 → 手动确认 → 进入冷却
|
||
→ 冷却结束:后台可直接验收(无需打手交图)
|
||
```
|
||
|
||
`hasInternalConsignmentFulfillment()` 是识别标志:`assigned_worker_id=0` + `material.consignment.inventoryId` + 非 `worker_deposit` + 无 `consignment.workerId`。
|
||
|
||
---
|
||
|
||
## 六、验收
|
||
|
||
### 6.1 打手提交(整单)
|
||
|
||
| 规则 | 细节 |
|
||
| --- | --- |
|
||
| 归属 | 必须是 `assigned_worker_id` 本人(寄卖卖家走 `asConsignmentSupplier` 例外) |
|
||
| 状态 | `in_progress` / `problem` |
|
||
| 超时前置 | 已过期则先执行超时处置再抛 409(接口惰性判定) |
|
||
| 图片 | 默认必须至少 1 张(`work_order_acceptance_image_required`);VIP/内部打手可不交图 |
|
||
| 赠送单 | 必须 `friend_countdown` 且已到点;**一律人工验收**,VIP/内部免审不适用 |
|
||
| 草稿 | 可先存 `draft_acceptance_json`,提交时优先取「本次上传 + 草稿里已有的图」并去重 |
|
||
|
||
免审条件:`worker_type === 'internal'` **或** 等级 `permission_json.autoAcceptWithoutEvidence === true`(且不是拼单份额参与,`canWorkerAutoAcceptWholeOrder` 明确排除拼单)。
|
||
|
||
免审且无图时,验收资料写入:
|
||
|
||
```json
|
||
{ "reviewMode": "vip_auto", "evidenceStatus": "pending", "evidenceDueAt": "<now+24h>" }
|
||
```
|
||
|
||
补证据走 `supplementWorkerAcceptedOrderEvidence`(验收后仍可补)。
|
||
|
||
### 6.2 催促
|
||
|
||
- 提交满 **10 分钟** 才能催;
|
||
- 每 **30 分钟** 一次,最多 **3 次**;
|
||
- 只支持整单(拼单份额不能催);
|
||
- 催的是 `admin_notifications` 的同一个待办(`reminder_count` 累加),不会刷屏。
|
||
|
||
### 6.3 后台验收
|
||
|
||
- 只接受 `pending_acceptance`(`capabilities.canAccept`)。
|
||
- 历史拼单母单如果还是 `open`,验收前自动推成 `pending_acceptance`。
|
||
- 内部现货好友赠送在 `gift_ready` 且 `in_progress` 时,后台可直接验收(自动补 `submitted_at`)。
|
||
- 有未提交份额 → 409 `work_order_sharing_pending_submissions`。
|
||
|
||
---
|
||
|
||
## 七、结算(核心)
|
||
|
||
### 7.1 一个事务里做完 8 件事
|
||
|
||
`acceptWorkOrderAndSettle`(整单)与 `acceptWorkOrderShareAndSettle`(份额)结构一致:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["锁工单(pending_acceptance)+ 锁全部非取消份额"] --> B{"还有 joined 份额?"}
|
||
B -->|是| X["409 sharing_pending_submissions"]
|
||
B -->|否| C["工单置 accepted / 份额置 accepted"]
|
||
C --> D["释放寄卖发布押金(仅整单,按 material.consignment.sourceSubmissionId)"]
|
||
D --> E["逐份额结算:售后欠款抵扣 → 报酬 → 押金"]
|
||
E --> F["整单打手结算:押金 → 报酬 → 欠款抵扣"]
|
||
F --> G["寄卖供货货款(internal_stock 时补给卖家)"]
|
||
G --> H["等级提升判定"]
|
||
H --> I["写 work_order_events accepted"]
|
||
```
|
||
|
||
### 7.2 资金等式(单次结算)
|
||
|
||
```text
|
||
releaseAmount = min(该工单未结押金, 钱包 frozen_deposit_amount)
|
||
debtOffset = min(售后未结欠款, 本次报酬) // FIFO,按欠款创建时间
|
||
shouldDelay = rewardUnfreezeEnabled && unfreezeDays > 0
|
||
pendingDelta = (shouldDelay ? 报酬 : 0) + (unfreezeDays > 0 ? 押金 : 0)
|
||
available' = available + (shouldDelay ? 0 : 报酬) + (unfreezeDays > 0 ? 0 : 押金) − debtOffset
|
||
frozen' = max(0, frozen − releaseAmount)
|
||
total_settled += 报酬
|
||
```
|
||
|
||
默认财务配置(`worker_finance` 配置键):
|
||
|
||
| 项 | 默认 | 范围 |
|
||
| --- | --- | --- |
|
||
| `depositUnfreezeDays` | 3 天 | 0–30 |
|
||
| `rewardUnfreezeEnabled` | 开启 | 布尔 |
|
||
| `consignmentPublishDepositFen` | 2000 分(¥20) | 0–1,000,000 |
|
||
| `consignmentWhitelistEnabled` | 开启 | 布尔 |
|
||
|
||
### 7.3 流水类型对照
|
||
|
||
| 场景 | ledger_type | 金额符号 |
|
||
| --- | --- | --- |
|
||
| 接单冻结 | `deposit_freeze` | − |
|
||
| 验收即释放押金 | `deposit_release` | + |
|
||
| 验收延迟解冻(入队) | `deposit_pending_unfreeze` | +(余额不加,进 pending_unfreeze) |
|
||
| 到期解冻 | `deposit_unfreeze` | + |
|
||
| 验收即结算报酬 | `reward_settlement` | + |
|
||
| 验收延迟解冻报酬 | `reward_pending_unfreeze` | + |
|
||
| 拼单报酬 | `sharing_reward` | + |
|
||
| 售后抵扣 | `after_sales_debt_offset` | − |
|
||
| 售后从可用余额扣 | `after_sales_balance_deduction` | − |
|
||
| 超时/退单/问题单扣押金 | `deposit_deduction` | − |
|
||
| 提现冻结 / 支付 / 解冻 | `withdraw_freeze` / `withdraw_paid` / `withdraw_release` | − / − / + |
|
||
| 后台手动加款 | `manual_credit` | + |
|
||
|
||
### 7.4 延迟解冻
|
||
|
||
- 入队:`worker_deposit_unfreezes(worker_id, work_order_id, amount, fund_type='deposit'|'reward', unfreeze_at, status='pending')`。
|
||
- 出队:定时任务 `deposit-unfreeze`(60s,scanLimit 100)→ `settleDueDepositUnfreezes`。
|
||
- **到期判定按「当前配置天数」回溯**:管理员把 `depositUnfreezeDays` 调小后,存量队列会提前视为到期(`retroactiveDays`),不会卡在旧到期时间。
|
||
- 提现前后台会先跑一次 `settleDueDepositUnfreezes({workerId})`,避免「到期了但还没解冻、余额显示不足」。
|
||
|
||
### 7.5 售后抵扣顺序
|
||
|
||
```text
|
||
验收报酬 → 先抵扣 open 欠款(FIFO)→ 抵扣部分写 after_sales_debt_offset
|
||
→ 欠款结清时 cases.debt_amount 归零、status 从 recovery_pending 变 closed
|
||
→ 报酬不足抵扣的部分继续留在 worker_after_sales_debts.outstanding
|
||
```
|
||
|
||
### 7.6 等级提升 / 降级
|
||
|
||
| 方向 | 触发 | 规则 |
|
||
| --- | --- | --- |
|
||
| 升 | 每次验收后 `maybeUpgradeWorkerLevelWithClient` | `有效验收数 = 累计已验收单数 − level_penalty_accepted_count`;取 `upgradeThreshold ≤ 有效数` 的最高等级;`is_level_locked` 时完全不动;只升不降 |
|
||
| 降 | 退单申请审核通过 | 非 VIP1:每 **10 次**通过降一级,并把 `level_penalty_accepted_count` 抬到「实际验收数 − 新等级门槛」,提高重新升级成本 |
|
||
| 封 | 退单申请审核通过 | VIP1 累计 **5 次**通过 → `status=disabled`、`session_version+1`、吊销全部会话 |
|
||
|
||
`worker_level_logs.reason` 取值:`auto` / `cancel_request_penalty` / `cancel_request_ban`。
|
||
|
||
### 7.7 寄卖钩子
|
||
|
||
| 钩子 | 时机 | 作用 |
|
||
| --- | --- | --- |
|
||
| `releaseConsignmentPublishDepositBySubmissionWithClient` | 整单验收 | 释放卖家发布押金 |
|
||
| `settleConsignmentSupplierPayoutByWorkOrderWithClient` | 验收后 | 内部现货履约时订单没有接单打手,货款补给供货卖家;接单人就是卖家本人时不重复付款 |
|
||
| `releaseConsignmentInventoryBindingForWorkOrderWithClient` | 超时/退单/问题单/取消 | 把占用的现货放回在库池,避免永久 `consumed` |
|
||
|
||
---
|
||
|
||
## 八、三条处置路径的押金矩阵
|
||
|
||
| 处置 | 入口 | 目标状态 | `reopen` | `cancel_release` | `cancel_deduct` | `return_to_worker` |
|
||
| --- | --- | --- | --- | --- | --- | --- |
|
||
| 超时 | 定时任务 / 接口惰性 | open / cancelled | 退押金 | 退押金 | 扣押金 | — |
|
||
| 问题单 | 后台 `problem/resolve` | open / cancelled / 恢复 | 退押金 | 退押金 | 扣押金 | **不动押金**,恢复到 `marked_problem` 的 `from_status` |
|
||
| 退单申请 | 后台审核通过 | open(重新上架) | — | 退押金 | — | — |
|
||
|
||
补充规则:
|
||
|
||
- 三种路径都先检查「是否还有活跃份额」;有则拒绝整单处置。
|
||
- **已确认加好友的赠送单**:`cancel_release` 会被改写成 `cancel_deduct`,事件类型也按实际资金动作记(`timeout_cancel_deduct` / `problem_cancel_deduct`)。
|
||
- 超时与问题单会释放寄卖现货绑定;`return_to_worker` 不会(单还在进行)。
|
||
- 退单申请:待审核期间订单与押金保持原状;审核通过**在同一事务**里撤单、退押金、执行等级处罚。
|
||
- 打手每天最多提 **5 次**退单申请(按 Asia/Shanghai 自然日,`pending + approved` 计数),同一工单+份额不能有两条 pending。
|
||
- 拼单份额超时只在母单 `open` 时生效(份额 `joined` 且 `created_at + timeout_minutes` 已过),否则跳过。
|
||
|
||
---
|
||
|
||
## 九、售后与欠款
|
||
|
||
### 9.1 生命周期
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> awaiting_worker_response: 后台建单(必须已验收)
|
||
awaiting_worker_response --> under_review: 打手提交说明 + 证据
|
||
awaiting_worker_response --> under_review: 后台直接处置
|
||
under_review --> closed: not_upheld(不成立)
|
||
under_review --> closed: remedied(已补救)
|
||
under_review --> recovery_pending: upheld 且追缴 > 报酬/可扣押金 → 产生欠款
|
||
under_review --> closed: upheld 且全额扣到
|
||
recovery_pending --> closed: 欠款结清(验收抵扣 / 充值抵扣 / 后台代还)
|
||
```
|
||
|
||
### 9.2 建单约束
|
||
|
||
| 约束 | 说明 |
|
||
| --- | --- |
|
||
| 工单必须 `accepted` | `work_order_not_accepted` |
|
||
| 指定份额时必须已验收 | `work_order_share_not_accepted` |
|
||
| 必须能定位责任打手 | `worker_missing`;内部现货寄卖单没有接单打手时,自动反查供货卖家(`getConsignmentSupplierByWorkOrderWithClient`)作为责任人 |
|
||
| 同一「工单 + 打手 + 份额」同时只能有 1 条进行中 case | 由部分唯一索引 `idx_worker_after_sales_cases_active_target` 保证:`UNIQUE(work_order_id, worker_id, COALESCE(work_order_share_id,0)) WHERE status IN ('awaiting_worker_response','under_review','recovery_pending')`,插入用 `ON CONFLICT DO NOTHING`,冲突即 `active_case_exists` |
|
||
| 投诉说明必填、≤ 2000 字 | |
|
||
| 问题类型默认 `fake_evidence` | |
|
||
|
||
### 9.3 追缴顺序
|
||
|
||
```text
|
||
1. 该工单尚未解冻的押金(worker_deposit_unfreezes pending, fund_type='deposit')
|
||
→ 走 deductPendingDepositUnfreeze,直接从队列扣,不进可用余额
|
||
2. 可用余额(available_amount)
|
||
3. 仍不足 → 写入 worker_after_sales_debts(status='open', outstanding_amount)
|
||
→ case.status = recovery_pending
|
||
```
|
||
|
||
后续抵扣:
|
||
|
||
- 该打手下一次验收报酬 → 自动 FIFO 抵扣(`offsetWorkerAfterSalesDebtsWithClient`);
|
||
- 充值入账 → `repayWorkerAfterSalesDebtsFromAvailableWithClient`;
|
||
- 后台手动代还 → `repayAfterSalesCaseDebt`。
|
||
|
||
每一笔抵扣都会写 `worker_after_sales_case_events`(`debt_offset` / `debt_settled`)+ 对应钱包流水,两边都有痕迹。
|
||
|
||
---
|
||
|
||
## 十、发现的隐患(按资金风险排序)
|
||
|
||
| # | 隐患 | 证据 | 后果 |
|
||
| --- | --- | --- | --- |
|
||
| W1 | 自动建单硬编码 `required_deposit_amount=0`、`deposit_threshold_amount=0` | `sync-worker-orders-service.ts` | 自动单永远无押金约束,模板里的押金配置是死配置 |
|
||
| W2 | 抢单先锁钱包、指派/拼单先锁工单 | `work-order-grab-repo.ts` vs 同文件 `assignWorkOrderToWorker` / `work-order-share-repo.ts` | 并发抢单+指派可 40P01 死锁;目前只靠客户端重试 |
|
||
| W3 | 统一验收的「已结算份额」判断只看 `status !== 'accepted'`,没有幂等键 | `acceptWorkOrderAndSettle` | 极端并发下(两次验收同时进入)可能重复发报酬;`FOR UPDATE` 锁工单能挡住大部分,但没有业务幂等 |
|
||
| W4 | 发单金额 > 用户付款只在前端提示 | `publishAdminWorkOrder` 只校验 `reward_amount > 0` | 直接调 API 可发亏损单;`source_order_amount_fen` 只是记录,不参与校验 |
|
||
| W5 | 发布工单时电子凭证核销失败只写事件,不回滚发布 | `publishAdminWorkOrder` 的 `voucherConsume.ok=false` 分支 | 券没核销但工单已在大厅;指派路径却会拦截,同一规则两套行为 |
|
||
| W6 | 钱包余额没有「由流水重算」校验,也没有月度关账 | `worker_wallets` 可变字段 | 任何一次漏写/重复写流水都不会被系统发现,只能人工对账 |
|
||
| W7 | `work_orders.task_id` 是历史遗留列(迁移 091 已注释废弃) | 仍参与部分 mapper | 新人容易误以为「工单必须挂履约任务」 |
|
||
|
||
---
|
||
|
||
## 十一、两笔完整流水示例
|
||
|
||
> 先记一条硬约束:**好友赠送与拼单互斥**。建单时 `sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode) ? false : ...`,所以不存在「好友赠送 + 拼单份额」的单子。
|
||
|
||
### 11.1 拼单单
|
||
|
||
设定:`sharing_total_quantity=3`、`unit_reward=3000 分`、`required_deposit_amount=6000 分`;打手 A 等级免押 1000 分、活跃上限 3;`depositUnfreezeDays=3`、`rewardUnfreezeEnabled=true`。
|
||
|
||
| 步骤 | 动作 | 钱包变化 | 关键字段/流水 |
|
||
| --- | --- | --- | --- |
|
||
| 1 | A 参与 2 份 | nextRequired = 6000×2/3 = 4000;requiredShare = 4000−1000 = 3000;冻结 3000 | `work_order_shares(quantity=2, share_deposit=3000)`,`deposit_freeze −3000` |
|
||
| 2 | A 追加 1 份 | nextRequired = 6000;requiredShare = 5000;本次冻结 2000 | `quantity=3, share_deposit=5000`,`deposit_freeze −2000` |
|
||
| 3 | 提交验收 | 份数已满且无 `joined` 份额 → 母单 `open → pending_acceptance` | `share.status=submitted` |
|
||
| 4 | 验收该份额 | 报酬 9000 延迟解冻;押金 5000 延迟解冻 | `pending_unfreeze +14000`,`reward_pending_unfreeze +9000`,`deposit_pending_unfreeze +5000` |
|
||
| 5 | 3 天后任务 | 到期释放 | `deposit_unfreeze +14000`,`pending_unfreeze −14000` |
|
||
|
||
### 11.2 好友赠送单
|
||
|
||
设定:`acceptance_mode=friend_gift`、`required_deposit_amount=5000`、免押 0、冷却 72h、`timeout_minutes=120`。
|
||
|
||
| 步骤 | 动作 | 结果 |
|
||
| --- | --- | --- |
|
||
| 1 | 打手抢单 | 冻结 5000(`deposit_freeze`);`gift_phase=material_required`;`deadline_at=now+2h` |
|
||
| 2 | 提交本人资料 | `gift_phase=material_submitted`;`friend_added_at=NULL` |
|
||
| 3 | 客服确认加好友 | `friend_added_at=now`;`gift_available_at=now+72h`;`deadline_at=now+72h+2h`(冷却不计超时) |
|
||
| 4 | 冷却中尝试提交 | 409 `work_order_gift_cooldown_active` |
|
||
| 5 | 到点后提交赠送凭证 | `status=pending_acceptance`;**不触发免审**(赠送单强制人工) |
|
||
| 6 | 客服验收 | 押金 5000 → 延迟解冻;报酬按 `reward_amount` 入账(同样按 `rewardUnfreezeEnabled` 决定是否延迟) |
|
||
| 7′ | 若第 3 步之后打手退单 | 押金从「应释放」改为 `deposit_deduction −5000`;寄卖发布押金同口径没收 |
|
||
|
||
---
|
||
|
||
## 十二、待确认问题(等你回答后我再补口径)
|
||
|
||
1. **押金策略的业务意图**:`required_deposit_amount` 现在是「按单金额」还是「按 SKU 单价 × 份数」?自动单为什么固定为 0——是刻意豁免,还是历史遗留?
|
||
2. **延迟解冻的初衷**:`depositUnfreezeDays=3` + `rewardUnfreezeEnabled=true` 是为了防售后,还是为了压打手现金流?这决定售后追缴是否应该优先扣「待解冻队列」而不是可用余额。
|
||
3. **VIP1 五次退单封号**这条规则现在还在执行吗?线上是否有误封申诉流程(代码里只有 `review_note`)?
|
||
4. **好友赠送冷却 72h** 是否要按品类差异化?现在只有模板上的 `gift_cooldown_hours/minutes` 两个字段。
|
||
5. **拼单是否允许「一人多份 + 全部单人完成」**?当前逻辑允许,且验收后一次性结算全部份额——如果业务上要求分散风险,这条需要改。
|