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:
@@ -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'), '库存侧关键字要透传后端')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user