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'), '库存侧关键字要透传后端')
|
||||
})
|
||||
|
||||
@@ -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` 做了一轮对抗复核(两个并行审查 + 人工验证),修三处:
|
||||
|
||||
Reference in New Issue
Block a user