fix(consignment): 售后立案回落到供货卖家,修内部现货单立不了案

补货款时排查"只认接单人"的地方,发现同类第三处:售后立案的责任人取
work_orders.assigned_worker_id,取不到就报 worker_missing(界面 409「请指定已验收的责任
打手或拼单份额」)。内部现货履约的寄卖单没有接单人,于是这类单子连售后都立不了案;
而货款修好后钱已结算给供货卖家,担责人本来就该是他。

- 抽出共享查询 getConsignmentSupplierByWorkOrderWithClient()
  (consignment-supplier-repo.ts):工单 material_json.consignment.inventoryId 或库存
  bound_work_order_id → 库存 → 提报单,货款结算与售后立案共用同一份口径
- 售后在找不到接单人时回落到供货卖家,并在验收快照里标记 consignmentSupplier
  与提报单号留痕
- 加测试钉住:售后必须先认寄卖关系再判定 worker_missing;取卖家的 SQL 只有一处实现

真实库验证(内部现货履约,接单人为空):反查供货卖家 workerId=3 正确;
售后立案成功且责任人 worker_id=3。临时数据残留 0。
This commit is contained in:
yml2213
2026-09-17 12:08:39 +08:00
parent 896194aa2b
commit f62f9b6645
5 changed files with 147 additions and 48 deletions
@@ -3,6 +3,7 @@ import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import { resolveAfterSalesRecoveryAllocation } from './after-sales-recovery.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
import { getConsignmentSupplierByWorkOrderWithClient } from './consignment-supplier-repo.js'
import type {
WorkerAfterSalesCaseEventRow,
WorkerAfterSalesCaseRow,
@@ -89,6 +90,19 @@ export async function createAfterSalesCase(input: {
shareQuantity: Number(share.quantity || 0),
}
}
if (workerId <= 0) {
// 内部现货履约的寄卖单没有接单打手,但货是供货卖家交付的、货款也结算给了他,
// 售后要担责的人就是他;不认寄卖关系的话这类单子连案都立不了。
const supplier = await getConsignmentSupplierByWorkOrderWithClient(client, input.workOrderId)
workerId = Number(supplier?.workerId || 0)
if (workerId > 0) {
acceptanceSnapshot = {
...acceptanceSnapshot,
consignmentSupplier: true,
consignmentSubmissionNo: supplier?.submissionNo || '',
}
}
}
if (workerId <= 0) {
return { case: null, failureReason: 'worker_missing' }
}
@@ -5,6 +5,7 @@ import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, toJsonString }
import { offsetWorkerAfterSalesDebtsWithClient } from './after-sales-repo.js'
import { enqueueDepositUnfreezeWithClient } from './work-order-deposit-repo.js'
import { maybeUpgradeWorkerLevelWithClient } from './worker-ranking-repo.js'
import { getConsignmentSupplierByWorkOrderWithClient } from './consignment-supplier-repo.js'
/**
* 查询寄卖提报押金尚未释放的金额(分)
@@ -448,53 +449,11 @@ export async function settleConsignmentSupplierPayoutByWorkOrderWithClient(
now: string
},
): Promise<{ workerId: number; amountFen: number } | null> {
const res = await client.query<{
inventory_id: number
inventory_worker_id: number | null
cost_price_fen: number
submission_worker_id: number | null
asking_price_fen: number
submission_no: string | null
}>(
`
SELECT
inv.id AS inventory_id,
inv.worker_id AS inventory_worker_id,
inv.cost_price_fen,
cs.worker_id AS submission_worker_id,
cs.asking_price_fen,
cs.submission_no
FROM work_orders wo
JOIN consignment_inventories inv
ON inv.id = CASE
WHEN (wo.material_json->'consignment'->>'inventoryId') ~ '^[0-9]+$'
THEN (wo.material_json->'consignment'->>'inventoryId')::int
END
LEFT JOIN consignment_submissions cs ON cs.id = inv.source_submission_id
WHERE wo.id = $1
UNION ALL
SELECT
inv.id,
inv.worker_id,
inv.cost_price_fen,
cs.worker_id,
cs.asking_price_fen,
cs.submission_no
FROM consignment_inventories inv
LEFT JOIN consignment_submissions cs ON cs.id = inv.source_submission_id
WHERE inv.bound_work_order_id = $1
LIMIT 1
`,
[input.workOrderId],
)
const target = res.rows[0]
if (!target) return null
const supplier = await getConsignmentSupplierByWorkOrderWithClient(client, input.workOrderId)
if (!supplier) return null
const supplierId =
Number(target.inventory_worker_id || 0) || Number(target.submission_worker_id || 0)
const amountFen =
Math.round(Number(target.cost_price_fen || 0)) ||
Math.round(Number(target.asking_price_fen || 0))
const supplierId = supplier.workerId
const amountFen = supplier.costPriceFen || supplier.askingPriceFen
if (supplierId <= 0 || amountFen <= 0) return null
// 接单人就是供货卖家时,货款已经随工单报酬结算过,不重复付款
if (Number(input.assignedWorkerId || 0) === supplierId) return null
@@ -556,8 +515,8 @@ export async function settleConsignmentSupplierPayoutByWorkOrderWithClient(
: `寄卖货款结算 #${input.workOrderId}`,
toJsonString({
workOrderId: Number(input.workOrderId),
inventoryId: Number(target.inventory_id),
submissionNo: target.submission_no || '',
inventoryId: supplier.inventoryId,
submissionNo: supplier.submissionNo,
costPriceFen: amountFen,
...(shouldDelayUnfreeze ? { unfreezeDays: input.unfreezeDays } : {}),
}),
@@ -0,0 +1,73 @@
import type { PoolClient } from 'pg'
/**
* 从工单反查寄卖的**供货卖家**(交付这件货、该收货款的人)。
*
* 为什么需要它:寄卖单在"内部现货履约"时是客服/内部仓代交付,工单上**没有接单打手**
* (`assigned_worker_id` 为空),于是所有"只认接单人"的逻辑都会落空——
* 货款没人收、售后找不到责任人都属于这一类。供货卖家只能从寄卖关系里查:
* 工单(`material_json.consignment.inventoryId` 或库存的 `bound_work_order_id`)
* → 库存 → 提报单。
*/
export type ConsignmentSupplier = {
workerId: number
inventoryId: number
costPriceFen: number
askingPriceFen: number
submissionNo: string
}
export async function getConsignmentSupplierByWorkOrderWithClient(
client: PoolClient,
workOrderId: number | string,
): Promise<ConsignmentSupplier | null> {
const res = await client.query<{
inventory_id: number
inventory_worker_id: number | null
cost_price_fen: string | number | null
submission_worker_id: number | null
asking_price_fen: string | number | null
submission_no: string | null
}>(
`
SELECT
inv.id AS inventory_id,
inv.worker_id AS inventory_worker_id,
inv.cost_price_fen,
cs.worker_id AS submission_worker_id,
cs.asking_price_fen,
cs.submission_no
FROM work_orders wo
JOIN consignment_inventories inv
ON inv.id = CASE
WHEN (wo.material_json->'consignment'->>'inventoryId') ~ '^[0-9]+$'
THEN (wo.material_json->'consignment'->>'inventoryId')::int
END
LEFT JOIN consignment_submissions cs ON cs.id = inv.source_submission_id
WHERE wo.id = $1
UNION ALL
SELECT
inv.id,
inv.worker_id,
inv.cost_price_fen,
cs.worker_id,
cs.asking_price_fen,
cs.submission_no
FROM consignment_inventories inv
LEFT JOIN consignment_submissions cs ON cs.id = inv.source_submission_id
WHERE inv.bound_work_order_id = $1
LIMIT 1
`,
[workOrderId],
)
const row = res.rows[0]
if (!row) return null
return {
workerId: Number(row.inventory_worker_id || 0) || Number(row.submission_worker_id || 0),
inventoryId: Number(row.inventory_id),
costPriceFen: Math.round(Number(row.cost_price_fen || 0)),
askingPriceFen: Math.round(Number(row.asking_price_fen || 0)),
submissionNo: String(row.submission_no || ''),
}
}
@@ -43,3 +43,31 @@ test('寄卖货款:接单人就是供货卖家时不重复付款,金额取
assert.ok(source.includes('offsetWorkerAfterSalesDebtsWithClient('))
assert.ok(source.includes('enqueueDepositUnfreezeWithClient('))
})
test('售后立案:内部现货履约(无接单打手)时要落到供货卖家,否则连案都立不了', async () => {
const afterSalesSource = await readFile(
new URL('../../repositories/worker-platform/after-sales-repo.ts', import.meta.url),
'utf8',
)
assert.ok(afterSalesSource.includes('getConsignmentSupplierByWorkOrderWithClient('))
assert.ok(afterSalesSource.includes('worker_missing'))
assert.ok(
afterSalesSource.indexOf('getConsignmentSupplierByWorkOrderWithClient(') <
afterSalesSource.lastIndexOf("'worker_missing'"),
'必须先尝试认寄卖关系,再判定找不到责任人',
)
})
test('"从工单找供货卖家"只有一处实现,货款与售后共用', async () => {
const supplierRepo = await readFile(
new URL('../../repositories/worker-platform/consignment-supplier-repo.ts', import.meta.url),
'utf8',
)
assert.ok(
supplierRepo.includes('export async function getConsignmentSupplierByWorkOrderWithClient'),
)
const depositSource = await readFile(DEPOSIT_REPO, 'utf8')
assert.ok(depositSource.includes('getConsignmentSupplierByWorkOrderWithClient('))
// 取卖家那段 SQL(含 UNION ALL 兜底)只应存在于共享查询里,别处不要再抄一份
assert.equal(depositSource.includes('UNION ALL'), false)
})
+25
View File
@@ -21,6 +21,7 @@
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
| `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
| `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` |
| 本批提交(批次 R,同一提交内含本文档,故不写自身 hash) | 批次 R:售后立案回落到供货卖家;抽共享"从工单找供货卖家"查询 | `consignment-supplier-repo.ts`、`after-sales-repo.ts`、`consignment-deposit-repo.ts` |
| 本批提交(批次 Q,同一提交内含本文档,故不写自身 hash) | 批次 Q:验收结算补寄卖货款给供货卖家;修指派打手线报酬 100 倍单位错 | `consignment-deposit-repo.ts`、`work-order-settlement-repo.ts`、`consignment-fulfillment-service.ts` |
| 本批提交(批次 P,同一提交内含本文档,故不写自身 hash) | 批次 P:确认加好友/上传赠送截图恢复为卖家操作,客服兜底 | `consignment-item-stage.ts`、`consignment-worker-service.ts`、`worker-acceptance-service.ts` |
| 本批提交(批次 O,同一提交内含本文档,故不写自身 hash) | 批次 O:文件链接去掉签名与有效期(修客服端凭证裂图),寄卖出参统一归一化 | `file-storage-service.ts`、`routes/files.ts`、`consignment-admin-service.ts` |
@@ -287,6 +288,30 @@
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
### 批次 R · 售后立案也认寄卖关系(本轮)
补货款时顺手排查"只认接单人"的地方,发现同类第三处:**售后立案**。
`createAfterSalesCase` 里责任人取 `work_orders.assigned_worker_id`,取不到就
`failureReason: 'worker_missing'` → 客服界面上是 409「请指定已验收的责任打手或拼单份额」。
内部现货履约的寄卖单**没有接单人**,于是这类单子**连售后都立不了案**;
而货款修好后,钱已经结算给供货卖家了,担责的人本来就该是他。
**改法**:把"从工单反查供货卖家"抽成共享查询
`getConsignmentSupplierByWorkOrderWithClient()`(`consignment-supplier-repo.ts`),
货款结算与售后立案共用同一份口径(工单 `material_json.consignment.inventoryId`
或库存 `bound_work_order_id` → 库存 → 提报单),售后在找不到接单人时回落到供货卖家,
并在验收快照里标记 `consignmentSupplier: true` 与提报单号留痕。
**真实库验证**(内部现货履约,接单人为空):
| 步骤 | 结果 |
| --- | --- |
| 从工单反查供货卖家 | `workerId=3`、结算价 12400、提报单号正确 ✅(不是无关的打手 4) |
| 售后立案 | ✅ 成功,`case_no=VERIFY-AS-…`,责任人 `worker_id=3`(供货卖家) |
临时数据已清理(残留 0)。至此"寄卖单没有接单人"这一类坑的三处(货款、售后、卖家按钮)都已收口。
### 批次 Q · 补上寄卖**货款**结算(本轮)
**问题(用户对账发现)**:工单 #100171 验收完成时,卖家钱包只有两笔——发布押金 -20 又 +20。