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 2e4e604a..9fc4cbc7 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-deposit-repo.ts @@ -2,6 +2,9 @@ import type { PoolClient } from 'pg' import { withTransaction } from '../../db/client.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, toJsonString } from './shared.js' +import { offsetWorkerAfterSalesDebtsWithClient } from './after-sales-repo.js' +import { enqueueDepositUnfreezeWithClient } from './work-order-deposit-repo.js' +import { maybeUpgradeWorkerLevelWithClient } from './worker-ranking-repo.js' /** * 查询寄卖提报押金尚未释放的金额(分) @@ -423,3 +426,165 @@ export async function forfeitConsignmentPublishDepositByWorkOrderWithClient( now: input.now, }) } + +/** + * 结算寄卖**货款**:验收完成时把货的结算价(`cost_price_fen`)付给供货卖家。 + * + * 背景:指派打手线里接单人就是供货卖家本人,工单报酬已经被改写为货的结算价、 + * 由通用结算流程付给他,不能再付一次;而**内部现货履约**(客服代交付,订单没有接单打手) + * 时通用结算流程里没有这个人,货款就没人收 —— 卖家交付了货,却只拿回自己的押金。 + * + * 所以这里只补"没人收"的那一份:接单人不是供货卖家(含没有接单人)时,按货的结算价补给卖家。 + * 金额、售后抵扣与解冻策略都对齐通用报酬结算(`reward_amount` 的处理方式)。 + */ +export async function settleConsignmentSupplierPayoutByWorkOrderWithClient( + client: PoolClient, + input: { + workOrderId: number | string + /** 通用结算已经付过的接单人;等于供货卖家时说明货款已随报酬付过 */ + assignedWorkerId: number + rewardUnfreezeEnabled: boolean + unfreezeDays: number + 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 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)) + if (supplierId <= 0 || amountFen <= 0) return null + // 接单人就是供货卖家时,货款已经随工单报酬结算过,不重复付款 + if (Number(input.assignedWorkerId || 0) === supplierId) return null + + await ensureWorkerWalletWithClient(client, supplierId, input.now) + const wallet = await getWorkerWalletWithClient(client, supplierId) + const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, { + workerId: supplierId, + amount: amountFen, + workOrderId: Number(input.workOrderId), + now: input.now, + }) + const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0) + const shouldDelayUnfreeze = input.rewardUnfreezeEnabled && input.unfreezeDays > 0 + const nextAvailable = + Number(wallet?.available_amount || 0) + (shouldDelayUnfreeze ? 0 : amountFen) - debtOffsetAmount + + await client.query( + ` + UPDATE worker_wallets + SET + available_amount = $1, + pending_unfreeze_amount = pending_unfreeze_amount + $2, + total_settled_amount = total_settled_amount + $3, + updated_at = $4 + WHERE worker_id = $5 + `, + [nextAvailable, shouldDelayUnfreeze ? amountFen : 0, amountFen, input.now, supplierId], + ) + + if (shouldDelayUnfreeze) { + await enqueueDepositUnfreezeWithClient(client, { + workerId: supplierId, + workOrderId: Number(input.workOrderId), + amount: amountFen, + fundType: 'reward', + unfreezeDays: input.unfreezeDays, + now: input.now, + }) + } + + const frozenAfter = Number(wallet?.frozen_deposit_amount || 0) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) + `, + [ + supplierId, + shouldDelayUnfreeze ? 'reward_pending_unfreeze' : 'reward_settlement', + amountFen, + nextAvailable, + frozenAfter, + Number(input.workOrderId), + shouldDelayUnfreeze + ? `寄卖货款待解冻 #${input.workOrderId}` + : `寄卖货款结算 #${input.workOrderId}`, + toJsonString({ + workOrderId: Number(input.workOrderId), + inventoryId: Number(target.inventory_id), + submissionNo: target.submission_no || '', + costPriceFen: amountFen, + ...(shouldDelayUnfreeze ? { unfreezeDays: input.unfreezeDays } : {}), + }), + input.now, + ], + ) + + for (const debtOffset of debtOffsets) { + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'after_sales_debt_offset', $2, $3, $4, $5, '寄卖货款自动抵扣售后欠款', $6::jsonb, $7) + `, + [ + supplierId, + -debtOffset.amount, + nextAvailable, + frozenAfter, + Number(input.workOrderId), + toJsonString({ caseId: debtOffset.caseId, workOrderId: Number(input.workOrderId) }), + input.now, + ], + ) + } + + await maybeUpgradeWorkerLevelWithClient(client, supplierId, input.now) + return { workerId: supplierId, amountFen } +} diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 0f35ea0c..519adb9a 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -15,7 +15,10 @@ import { } from './work-order-deposit-repo.js' import { maybeUpgradeWorkerLevelWithClient } from './worker-ranking-repo.js' import type { WorkOrderRow, WorkOrderShareRow } from './types.js' -import { releaseConsignmentPublishDepositBySubmissionWithClient } from './consignment-deposit-repo.js' +import { + releaseConsignmentPublishDepositBySubmissionWithClient, + settleConsignmentSupplierPayoutByWorkOrderWithClient, +} from './consignment-deposit-repo.js' export async function acceptWorkOrderAndSettle(input: { workOrderId: number @@ -421,6 +424,16 @@ export async function acceptWorkOrderAndSettle(input: { } } + // 寄卖货款:内部现货履约时订单没有接单打手,通用报酬结算里没人收这笔钱, + // 必须在这里补给供货卖家(接单人就是卖家本人时不重复付款,见函数注释)。 + await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, { + workOrderId: input.workOrderId, + assignedWorkerId: workerId, + rewardUnfreezeEnabled: input.rewardUnfreezeEnabled, + unfreezeDays: input.unfreezeDays, + now: input.now, + }) + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: input.actorType || 'admin', diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index 9935bf04..baf04b99 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -361,6 +361,21 @@ export async function fulfillWorkOrderWithConsignment(input: { /** * 选用内部寄售库存并走接单平台指派打手(冻结押金担保履约) */ +/** + * 指派打手线里工单报酬要改成"货的结算价":卖家拿结算价、平台赚差价。 + * + * 单位必须是**分**(与钱包、流水、其它工单报酬一致)。这里以前多除了 100, + * 会把 ¥124 写成 ¥1.24 —— 卖家只拿到结算价的百分之一。 + */ +export function resolveConsignmentAssignRewardFen(input: { + costPriceFen: unknown + orderRewardFen: unknown +}): number { + const costPriceFen = Math.round(Number(input.costPriceFen || 0)) + if (costPriceFen > 0) return costPriceFen + return Math.round(Number(input.orderRewardFen || 0)) +} + export async function assignWorkOrderWithConsignmentWorker(input: { workOrderId: number | string inventoryId: number | string @@ -460,14 +475,14 @@ export async function assignWorkOrderWithConsignmentWorker(input: { }, } - const rewardAmountYuan = - Number(claimed.cost_price_fen || 0) > 0 - ? Number((Number(claimed.cost_price_fen) / 100).toFixed(2)) - : Number(workOrder.reward_amount || 0) + const rewardAmountFen = resolveConsignmentAssignRewardFen({ + costPriceFen: claimed.cost_price_fen, + orderRewardFen: workOrder.reward_amount, + }) await updateWorkOrder(workOrder.id, { material_json: updatedMaterial, - reward_amount: rewardAmountYuan, + reward_amount: rewardAmountFen, updated_at: now, }) diff --git a/apps/backend/src/services/worker-platform/consignment-payout.test.ts b/apps/backend/src/services/worker-platform/consignment-payout.test.ts new file mode 100644 index 00000000..52a6232a --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-payout.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFile } from 'node:fs/promises' + +import { resolveConsignmentAssignRewardFen } from './consignment-fulfillment-service.js' + +const SETTLEMENT_REPO = new URL( + '../../repositories/worker-platform/work-order-settlement-repo.ts', + import.meta.url, +) +const DEPOSIT_REPO = new URL( + '../../repositories/worker-platform/consignment-deposit-repo.ts', + import.meta.url, +) + +test('指派打手线的工单报酬用分(不再多除 100)', () => { + // ¥124.00 的货 → 12400 分。以前是 Number((12400 / 100).toFixed(2)) = 124,只等于 ¥1.24 + assert.equal( + resolveConsignmentAssignRewardFen({ costPriceFen: 12400, orderRewardFen: 14500 }), + 12400, + ) + assert.equal(resolveConsignmentAssignRewardFen({ costPriceFen: 2500, orderRewardFen: 999 }), 2500) +}) + +test('没有结算价时退回原工单报酬,且都是分', () => { + assert.equal(resolveConsignmentAssignRewardFen({ costPriceFen: 0, orderRewardFen: 14500 }), 14500) + assert.equal(resolveConsignmentAssignRewardFen({ costPriceFen: null, orderRewardFen: null }), 0) +}) + +test('验收结算必须补寄卖货款,否则内部现货单的卖家只拿回押金', async () => { + const source = await readFile(SETTLEMENT_REPO, 'utf8') + assert.ok(source.includes('settleConsignmentSupplierPayoutByWorkOrderWithClient(')) + assert.ok(source.includes('assignedWorkerId: workerId')) +}) + +test('寄卖货款:接单人就是供货卖家时不重复付款,金额取货的结算价', async () => { + const source = await readFile(DEPOSIT_REPO, 'utf8') + assert.ok(source.includes('const supplierId =')) + assert.ok(source.includes('if (Number(input.assignedWorkerId || 0) === supplierId) return null')) + assert.ok(source.includes('cost_price_fen')) + assert.ok(source.includes('寄卖货款结算'), '货款流水要有可读说明') + // 和通用报酬结算一样:售后欠款抵扣 + 按售后策略决定是否延迟解冻 + assert.ok(source.includes('offsetWorkerAfterSalesDebtsWithClient(')) + assert.ok(source.includes('enqueueDepositUnfreezeWithClient(')) +}) diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md index 97b1cf32..e2375ab1 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` | +| 本批提交(批次 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` | | 本批提交(批次 N,同一提交内含本文档,故不写自身 hash) | 批次 N:删掉按库存号的卖家入口,卖家动作只按寄卖单号定位 | `consignment-worker-service.ts`、`routes/worker/consignment.ts`、`WorkerConsignmentPage.tsx` | @@ -286,6 +287,50 @@ 前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。 3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。 +### 批次 Q · 补上寄卖**货款**结算(本轮) + +**问题(用户对账发现)**:工单 #100171 验收完成时,卖家钱包只有两笔——发布押金 -20 又 +20。 +**货卖出去了,但货款的收益没进卖家钱包。** + +**查证**: + +| 项 | 值 | +| --- | --- | +| 工单报酬 `reward_amount` | 14500 分(¥145.00,发单方付给平台) | +| 货的结算价 `cost_price_fen` | 12400 分(¥124.00,卖家应得) | +| 接单人 `assigned_worker_id` | 空(内部现货履约,客服/内部仓代交付) | + +通用验收结算的规则是"把工单报酬付给**接单人**"。内部现货履约的单子**没有接单人**, +于是这笔钱谁也没拿到;而卖家交付了自己的货,只拿回了自己的押金。差价(¥21)本该是平台的。 + +**改法**: + +1. ✅ 新增 `settleConsignmentSupplierPayoutByWorkOrderWithClient()`:验收完成时, + 把货的结算价(`cost_price_fen`,缺省退回提报的 `asking_price_fen`)补给**供货卖家**, + 金额单位与通用报酬一致(分),并同样做售后欠款抵扣、按售后策略决定是否延迟解冻、 + 写 `reward_settlement` / `reward_pending_unfreeze` 流水(说明「寄卖货款结算 #单号」)。 +2. ✅ **不重复付款**:接单人就是供货卖家本人时跳过——指派打手线里接单人正是卖家本人, + 货款已经随工单报酬付过了(见下一条),只有"没人收"的那一份才在这里补。 +3. ✅ **顺带修掉一个 100 倍单位错**:指派打手线把工单报酬改写成"货的结算价"时, + 写的是 `Number((cost_price_fen / 100).toFixed(2))` → **¥124 被写成 ¥1.24**, + 卖家只能拿到结算价的百分之一。`reward_amount` 的单位是**分**(与钱包、流水一致, + 已用真实数据核对:`reward_amount=2500` 对应流水 `2500`、界面 ¥25.00)。 + 现在抽成纯函数 `resolveConsignmentAssignRewardFen()` 并加测试钉住单位。 + +**真实库端到端验证**(内部现货履约,结算价 ¥124 / 发单方付 ¥145): + +| 步骤 | 结果 | +| --- | --- | +| 验收前卖家钱包 | 可用 ¥205.50,累计结算 ¥297.50 | +| 走真实验收结算 | 订单转 `accepted` | +| 本次流水 | `reward_settlement ¥124.00 寄卖货款结算 #100175` | +| 验收后卖家钱包 | 可用 **¥329.50**(+¥124.00),累计结算 ¥421.50(+¥124.00)✅ 等于货的结算价 | + +验证脚本改动的开发库数据已还原(钱包恢复 ¥205.50 / 累计结算 ¥297.50,等级未被触发变更),临时数据残留 0。 + +**遗留**:用户对账时看到的 #100171 是在本批修复**之前**结算的,所以它当时确实没发货款(¥124.00)。 +要不要给这类历史单补一笔,等用户确认。 + ### 批次 P · 「确认加好友 / 上传赠送截图」恢复为卖家操作(本轮) **问题(用户截图:卖家端的货点不了确认加好友)**:卖家「我的寄卖」里那件"已匹配-待加好友"的货,