fix(consignment): 拼单份额验收收官补寄卖结算钩子 + 手动匹配弹窗三处复核遗留

- acceptWorkOrderShareAndSettle 最后一个份额把母单转 accepted 的分支,
  此前不释放发布押金也不结算供货货款(两钩子只在统一验收里):
  现在同口径补上;供货货款传 assignedWorkerId=0(拼单母单无单一接单人,
  份额打手已各自按 share_reward 结算,不会重复付款)
- 手动匹配弹窗:race_condition/unavailable/spec_mismatch/partial_requires_confirm
  四个错误码清空已选并自动重拉列表,数据过期不再死循环撞错
- 手动匹配弹窗库存侧:keyword+pageSize100 透传后端(原来 keyword 是死参数、
  只拉 50 条),300ms 防抖后端搜索;无供货打手时禁用指派选项并在提交前
  再拦一道(原来必 400 死路);选中订单回显 workerId/workerName
- 凭证核销反馈抽共享 utils/voucher-consume,WorkOrdersPanel 与弹窗共用,
  口径不再漂移(弹窗顺带获得"已提前核销 N 张"提示)
- docs/寄卖系统优化方案.md 记录批次 V

后端 477(+4)/ 前端 50(+1)测试全绿,typecheck / lint / format / check:sql 通过
This commit is contained in:
yml2213
2026-09-17 12:45:51 +08:00
parent 63837e473b
commit b666a9c951
8 changed files with 183 additions and 31 deletions
@@ -647,6 +647,42 @@ export async function acceptWorkOrderShareAndSettle(input: {
`UPDATE work_orders SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
[input.now, input.workOrderId],
)
// 最后一个份额把母单转成已验收时,必须与统一验收(acceptWorkOrderAndSettle)同口径补寄卖钩子,
// 否则这条路径下发布押金永久挂死、供货卖家拿不到货款。
const shareMaterial =
typeof workOrder.material_json === 'string'
? (() => {
try {
return JSON.parse(workOrder.material_json) as Record<string, unknown>
} catch {
return {}
}
})()
: workOrder.material_json && typeof workOrder.material_json === 'object'
? (workOrder.material_json as Record<string, unknown>)
: {}
const shareConsignment =
shareMaterial.consignment && typeof shareMaterial.consignment === 'object'
? (shareMaterial.consignment as Record<string, unknown>)
: null
const shareSourceSubmissionId = shareConsignment?.sourceSubmissionId
if (shareSourceSubmissionId) {
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
submissionId: String(shareSourceSubmissionId),
note: `寄卖拼单份额全部验收完成释放发布押金 #${input.workOrderId}`,
now: input.now,
})
}
// 拼单母单没有单一接单人(份额打手已在上面各自按 share_reward 结算),
// 传 0 让供货货款必走本渠道,不会与份额报酬重复。
await settleConsignmentSupplierPayoutByWorkOrderWithClient(client, {
workOrderId: input.workOrderId,
assignedWorkerId: 0,
rewardUnfreezeEnabled: input.rewardUnfreezeEnabled,
unfreezeDays: input.unfreezeDays,
now: input.now,
})
}
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
@@ -107,3 +107,24 @@ test('指派链路必须按“指派对象===供货卖家”判定报酬渠道
'指派报酬必须传 assigneeIsSupplier,且与供货卖家(inventory.worker_id)比对',
)
})
test('拼单份额全部验收(母单转已验收)也必须补寄卖钩子,否则货款漏付、押金挂死', async () => {
const source = await readFile(SETTLEMENT_REPO, 'utf8')
const start = source.indexOf('export async function acceptWorkOrderShareAndSettle')
assert.ok(start > 0, '未找到份额单独验收函数')
const body = source.slice(start)
// 只看"最后一个份额把母单转 accepted"的分支,别被统一验收函数里的同名调用误判
const acceptedBranch = body.slice(
body.indexOf("nextOrderStatus === 'accepted'"),
body.indexOf('sharing_share_accepted'),
)
assert.ok(acceptedBranch.length > 0, '未找到母单转已验收的分支')
assert.ok(
acceptedBranch.includes('releaseConsignmentPublishDepositBySubmissionWithClient('),
'份额验收收官时必须释放卖家发布押金',
)
assert.ok(
acceptedBranch.includes('settleConsignmentSupplierPayoutByWorkOrderWithClient('),
'份额验收收官时必须结算供货货款(拼单母单无接单人,传 0 走供货渠道)',
)
})
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
@@ -30,6 +30,7 @@ import {
resolveManualMatchRequirement,
type ManualMatchType,
} from '@/utils/consignment-manual-match'
import { notifyVoucherConsume } from '@/utils/voucher-consume'
const { Text, Paragraph } = Typography
@@ -74,6 +75,10 @@ export default function ConsignmentManualMatchModal({
const [options, setOptions] = useState<ConsignmentMatchOptionView[]>([])
const [orders, setOrders] = useState<ConsignmentMatchableOrder[]>([])
const [inventoryLabel, setInventoryLabel] = useState('')
const [inventoryWorker, setInventoryWorker] = useState<{
workerId: number | null
workerName: string
} | null>(null)
const [selected, setSelected] = useState<Selected | null>(null)
const [deliveryKind, setDeliveryKind] = useState<'internal_stock' | 'assign_worker'>(
'internal_stock',
@@ -98,13 +103,22 @@ export default function ConsignmentManualMatchModal({
setLoading(true)
try {
if (inventory) {
const res = await fetchInventoryMatchableOrders(inventory.inventoryId)
// 关键字与分页透传后端:库存侧默认只拉 50 条时,第 51 单以后永远搜不到
const res = await fetchInventoryMatchableOrders(inventory.inventoryId, {
keyword: keyword.trim() || undefined,
pageSize: 100,
})
setOrders(res.data?.orders || [])
setInventoryLabel(
res.data?.inventory
? `${res.data.inventory.itemName}(${res.data.inventory.sourceSubmissionNo || res.data.inventory.inventoryNo})`
: inventory.label,
)
// 指派线需要现货的供货打手:库存侧入口从响应里记下来,选中订单时带上
setInventoryWorker({
workerId: res.data?.inventory?.workerId ?? null,
workerName: res.data?.inventory?.workerName || '',
})
} else if (workOrder) {
const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId)
setOptions(res.data?.options || [])
@@ -116,6 +130,16 @@ export default function ConsignmentManualMatchModal({
}
}
// 库存侧:关键字防抖透传后端搜索(本地过滤只作用在已拉取的 100 条内)
useEffect(() => {
if (!open || !inventory) return
const timer = setTimeout(() => {
void load()
}, 300)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword])
const filteredOptions = useMemo(
() =>
filterManualMatchOptions(options, keyword, (item) => [
@@ -152,6 +176,10 @@ export default function ConsignmentManualMatchModal({
async function handleSubmit() {
if (!selected) return
if (deliveryKind === 'assign_worker' && !selected.workerId) {
message.error('该现货未关联供货打手,不能走指派履约;请改用内部现货履约')
return
}
setSubmitting(true)
try {
const payload = {
@@ -169,15 +197,10 @@ export default function ConsignmentManualMatchModal({
})
: await fulfillWorkOrderWithConsignment(payload)
// 核销失败时共享工具会弹警告(状态已变更但凭证没消耗),此时不能再弹"成功"掩盖
const voucher = (res.data as any)?.voucherConsume
if (voucher && voucher.ok === false) {
message.warning({
content: `状态已变更,但电子凭证核销失败:${
voucher.errorMessage || '未知错误'
}。请在快手后台人工核销`,
duration: 8,
})
} else {
notifyVoucherConsume(voucher)
if (!voucher || voucher.ok) {
message.success(
deliveryKind === 'assign_worker'
? '已手动匹配:指派供货打手履约,押金已冻结'
@@ -188,6 +211,27 @@ export default function ConsignmentManualMatchModal({
resetState()
onClose()
} catch (err: any) {
// 列表数据过期(并发占用 / 打开弹窗后订单规格被改):刷新列表按最新判定重来,
// 否则运营会对着已失效的候选反复重试、循环撞同一个错
const code = String(err?.errorCode || '')
if (
code === 'consignment_inventory_race_condition' ||
code === 'consignment_inventory_unavailable'
) {
message.error('该现货刚被其他操作占用或已下架,已刷新列表,请重新选择')
setSelected(null)
void load()
return
}
if (
code === 'consignment_spec_mismatch' ||
code === 'consignment_spec_partial_requires_confirm'
) {
message.error('订单或现货的规格已变化(列表数据过期),已刷新,请按最新判定重新确认')
setSelected(null)
void load()
return
}
message.error(err.message || '手动匹配失败')
} finally {
setSubmitting(false)
@@ -312,6 +356,10 @@ export default function ConsignmentManualMatchModal({
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
// 选了无打手的现货时指派不可用,兜底切回内部履约
if (!option.inventory.workerId && deliveryKind === 'assign_worker') {
setDeliveryKind('internal_stock')
}
}}
>
{isSelected ? '已选' : '选它'}
@@ -377,10 +425,17 @@ export default function ConsignmentManualMatchModal({
inventoryLabel,
matchType: order.matchType,
conflicts: order.conflicts,
// 指派线要用现货的供货打手:库存侧入口从接口响应的 inventory 里带过来
workerId: inventoryWorker?.workerId ?? null,
workerName: inventoryWorker?.workerName || '',
})
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
// 选了无打手的现货时指派不可用,兜底切回内部履约
if (!inventoryWorker?.workerId && deliveryKind === 'assign_worker') {
setDeliveryKind('internal_stock')
}
}}
>
{isSelected ? '已选' : '选它'}
@@ -421,8 +476,12 @@ export default function ConsignmentManualMatchModal({
},
{
value: 'assign_worker',
// 没关联打手的现货走指派必 400 consignment_worker_required,直接禁用并说明,别给死路
disabled: !selected.workerId,
label: `指派供货打手履约(冻结押金、工单进行中)${
selected.workerName ? ` · 打手:${selected.workerName}` : ''
selected.workerName
? ` · 打手:${selected.workerName}`
: ' · 该现货未关联打手,不可指派'
}`,
},
]}
@@ -79,8 +79,8 @@ import {
checkWorkOrderConsignmentMatches,
fulfillWorkOrderWithConsignment,
revokeWorkOrderConsignmentFulfillment,
type WorkOrderVoucherConsumeResult,
} from '@/services/admin/consignment'
import { notifyVoucherConsume } from '@/utils/voucher-consume'
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { isManuallyMatchableOrderStatus } from '@/utils/consignment-manual-match'
@@ -764,24 +764,7 @@ export default function WorkOrdersPanel() {
confirmPublishWorkOrder(row)
}
/**
* 电子凭证核销结果必须如实反馈:核销失败时后端已经改完状态,但凭证没消耗,
* 之前这里无条件弹"成功",运营会以为凭证已核销而漏掉人工处理。
*/
function notifyVoucherConsume(voucherConsume?: WorkOrderVoucherConsumeResult) {
if (!voucherConsume || voucherConsume.ok) {
if (voucherConsume?.alreadyConsumedCount) {
message.info(`电子凭证已提前核销 ${voucherConsume.alreadyConsumedCount} 张,本次未重复核销`)
}
return
}
message.warning({
content: `状态已变更,但电子凭证核销失败:${
voucherConsume.errorMessage || '未知错误'
}。请在快手后台人工核销后再发货`,
duration: 8,
})
}
// 电子凭证核销结果的如实反馈抽到了 utils/voucher-consume(与手动匹配弹窗共用,口径不再漂移)
/** 非精确匹配(订单规格信息不全 / 高配充低配)必须人工确认,后端也会二次拦截 */
/**
@@ -112,7 +112,7 @@ export function fetchWorkOrderConsignmentMatchOptions(workOrderId: number | stri
/** 手动匹配:从现货出发,列出可以匹配的待指派/待补资料订单(逐单带规格判定) */
export function fetchInventoryMatchableOrders(
inventoryId: number | string,
params?: { keyword?: string },
params?: { keyword?: string; pageSize?: number },
) {
return apiGet<InventoryMatchableOrders>(
`/api/v1/admin/worker-platform/consignment/inventories/${inventoryId}/match-orders`,
@@ -0,0 +1,23 @@
import { message } from 'antd'
import type { WorkOrderVoucherConsumeResult } from '@/services/admin/consignment'
/**
* 电子凭证核销结果必须如实反馈:核销失败时后端已经改完状态,但凭证没消耗,
* 之前有调用方无条件弹"成功",运营会以为凭证已核销而漏掉人工处理。
* 统一出口:所有履约/指派/手动匹配的调用方都用这一个,避免文案与口径各自漂移。
*/
export function notifyVoucherConsume(voucherConsume?: WorkOrderVoucherConsumeResult) {
if (!voucherConsume || voucherConsume.ok) {
if (voucherConsume?.alreadyConsumedCount) {
message.info(`电子凭证已提前核销 ${voucherConsume.alreadyConsumedCount} 张,本次未重复核销`)
}
return
}
message.warning({
content: `状态已变更,但电子凭证核销失败:${
voucherConsume.errorMessage || '未知错误'
}。请在快手后台人工核销后再发货`,
duration: 8,
})
}
@@ -77,3 +77,23 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
'拦截弹窗 footer 的「手动指定现货匹配」必须按 canFulfillConsignment 门控',
)
})
test('手动匹配弹窗:数据过期的 409 要清选择并刷新,无打手的现货禁止走指派', () => {
const source = readFileSync(
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url).pathname,
'utf8',
)
// 并发占用/规格在打开弹窗后被改 → 清空选择并重新拉列表,不能让运营循环撞同一个错
assert.ok(source.includes('consignment_inventory_race_condition'), '要处理并发占用错误码')
assert.ok(source.includes('consignment_spec_mismatch'), '要处理规格变化错误码')
assert.ok(source.includes('setSelected(null)'), '数据过期时要清空已选')
// 没关联打手的现货走指派必 400 consignment_worker_required:选项禁用 + 提交前再拦一次
assert.ok(source.includes('disabled: !selected.workerId'), '无打手时指派选项必须禁用')
assert.ok(
source.includes("deliveryKind === 'assign_worker' && !selected.workerId"),
'提交前要对无打手的指派再拦一道',
)
// 凭证核销反馈与关键字搜索:用共享出口 / 透传后端,不内联复制
assert.ok(source.includes("from '@/utils/voucher-consume'"), '核销反馈必须用共享出口')
assert.ok(source.includes('keyword: keyword.trim() || undefined'), '库存侧关键字要透传后端')
})
+10
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` |
| 本批提交(批次 V,同一提交内含本文档,故不写自身 hash) | 批次 V:复核遗留——拼单份额验收收官补寄卖钩子(押金释放+供货货款)、手动匹配弹窗 409 刷新/关键字透传/无打手禁指派、核销反馈抽共享出口 | `work-order-settlement-repo.ts`、`ConsignmentManualMatchModal.tsx`、`utils/voucher-consume.ts` |
| 本批提交(批次 U,同一提交内含本文档,故不写自身 hash) | 批次 U:复核修复——占库 allocation_status 撞 080 CHECK(真实库必 500)、指派非供货卖家时货款双付、手动匹配入口按 fulfill 权限门控 | `consignment-repo.ts`、`consignment-fulfillment-service.ts`、两个面板、三个测试文件 |
| 本批提交(批次 S,同一提交内含本文档,故不写自身 hash) | 批次 S:手动指定现货匹配(判定/差异/强制留痕 + 双向入口 + 共享弹窗) | `consignment-matching-service.ts`、`consignment-fulfillment-service.ts`、`ConsignmentManualMatchModal.tsx` |
| 本批提交(批次 R,同一提交内含本文档,故不写自身 hash) | 批次 R:售后立案回落到供货卖家;抽共享"从工单找供货卖家"查询 | `consignment-supplier-repo.ts`、`after-sales-repo.ts`、`consignment-deposit-repo.ts` |
@@ -289,6 +290,15 @@
规格冲突 → 409 `consignment_spec_mismatch`;非 exact 且未显式传 `allowPartial` → 409 `consignment_spec_partial_requires_confirm`。
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
### 批次 V · 复核遗留:份额验收钩子 + 手动匹配弹窗三处(本轮)
1. ✅ **拼单份额全部验收(母单转已验收)补寄卖钩子**:`acceptWorkOrderShareAndSettle` 里最后一个份额把母单转 `accepted` 的分支,此前既不释放发布押金也不结算供货货款(两个钩子只在统一验收里)。现在与 `acceptWorkOrderAndSettle` 同口径:有 `material.consignment.sourceSubmissionId` 就释放发布押金;供货货款固定传 `assignedWorkerId: 0`(拼单母单没有单一接单人,份额打手已各自按 share_reward 结算,不会重复)。
2. ✅ **手动匹配弹窗 · 数据过期的 409 不再死循环**:`consignment_inventory_race_condition / consignment_inventory_unavailable / consignment_spec_mismatch / consignment_spec_partial_requires_confirm` 四个错误码清空已选并自动重拉列表,按最新判定重来,不再对着失效候选反复撞错。
3. ✅ **手动匹配弹窗 · 库存侧搜索与指派死路**:关键字 + `pageSize:100` 透传后端(原来只拉默认 50 条、keyword 是从未传出去的死参数,第 51 单以后搜不到);关键字 300ms 防抖触发后端搜索;无供货打手的现货禁用"指派供货打手履约"选项(原来选了必 400 `consignment_worker_required` 且弹窗里没有打手选择器,是死路),提交前再拦一道;库存侧选中订单时从响应 inventory 带出 `workerId/workerName` 回显。
4. ✅ **凭证核销反馈抽共享出口**:`utils/voucher-consume.ts` 统一 `notifyVoucherConsume`(含"已提前核销 N 张"提示),`WorkOrdersPanel` 本地实现删除、手动匹配弹窗弃用内联子集版,口径不再漂移。
**验证**:后端 477 测试(+4)全绿、前端 50(+1)全绿;两侧 `typecheck` / `lint:check` / `format:check` / `check:sql` 通过。
### 批次 U · 复核修复:占库 500 / 指派双付 / 手动匹配权限门控(本轮)
对 `94328ac6..0476339e` 做了一轮对抗复核(两个并行审查 + 人工验证),修三处: