diff --git a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts index 74aa0a72..c103cbdc 100644 --- a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts +++ b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts @@ -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' } } diff --git a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts index 9fc4cbc7..d218f19a 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts @@ -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 } : {}), }), diff --git a/apps/backend/src/repositories/worker-platform/consignment-supplier-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-supplier-repo.ts new file mode 100644 index 00000000..93ad6b07 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/consignment-supplier-repo.ts @@ -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 { + 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 || ''), + } +} diff --git a/apps/backend/src/services/worker-platform/consignment-payout.test.ts b/apps/backend/src/services/worker-platform/consignment-payout.test.ts index 52a6232a..07158cf9 100644 --- a/apps/backend/src/services/worker-platform/consignment-payout.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-payout.test.ts @@ -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) +}) diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index e2375ab1..636e21c7 100644 --- a/docs/寄卖系统优化方案.md +++ b/docs/寄卖系统优化方案.md @@ -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。