feat(worker-platform): 彻底去除非平台内部现货履约,寄卖匹配全面收敛为接单平台指派打手
1. 手动指定现货弹窗移除 deliveryKind 冗余单选框,统一收敛为一键指派供货打手履约; 2. 匹配确认后自动冻结打手履约押金、启动交付倒计时与履约时效监控,消除脱管盲区; 3. 订单发布前置拦截弹窗移除内部代交付入口,统一为走接单平台指派; 4. 未关联供货打手的现货进行明确阻断与告警提示,避免无监管指派; 5. 同步更新前端静态单测并保证全量测试与构建通过。
This commit is contained in:
@@ -7,20 +7,18 @@ import {
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { SearchOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
assignWorkOrderWithConsignmentWorker,
|
||||
fetchInventoryMatchableOrders,
|
||||
fetchWorkOrderConsignmentMatchOptions,
|
||||
fulfillWorkOrderWithConsignment,
|
||||
} from '@/services/admin/consignment'
|
||||
import type { ConsignmentMatchOptionView, ConsignmentMatchableOrder } from '@/types/consignment'
|
||||
import {
|
||||
@@ -41,7 +39,7 @@ type Selected = {
|
||||
inventoryLabel: string
|
||||
matchType: ManualMatchType
|
||||
conflicts: string[]
|
||||
/** 指派打手线要用的供货卖家(现货的供货打手);内部现货履约不需要 */
|
||||
/** 供货打手(接单平台指派履约对象) */
|
||||
workerId?: number | null
|
||||
workerName?: string
|
||||
}
|
||||
@@ -59,9 +57,7 @@ export type ConsignmentManualMatchModalProps = {
|
||||
/**
|
||||
* 「手动指定现货」弹窗:订单侧与库存侧共用一套交互与落地逻辑。
|
||||
*
|
||||
* 为什么单独做:系统候选只覆盖"规格完全对得上"的现货,客服经常遇到
|
||||
* "订单规格没写全/订单录错/需要高配充低配"的情况,必须能人工指定;
|
||||
* 同时要把"哪里不一致"讲清楚,并把原因留痕,避免变成无痕操作。
|
||||
* 统一走接单平台指派供货打手履约(自动冻结押金、开启交付倒计时与履约监控)。
|
||||
*/
|
||||
export default function ConsignmentManualMatchModal({
|
||||
open,
|
||||
@@ -80,9 +76,6 @@ export default function ConsignmentManualMatchModal({
|
||||
workerName: string
|
||||
} | null>(null)
|
||||
const [selected, setSelected] = useState<Selected | null>(null)
|
||||
const [deliveryKind, setDeliveryKind] = useState<'internal_stock' | 'assign_worker'>(
|
||||
'internal_stock',
|
||||
)
|
||||
const [confirmedPartial, setConfirmedPartial] = useState(false)
|
||||
const [confirmedMismatch, setConfirmedMismatch] = useState(false)
|
||||
const [matchNote, setMatchNote] = useState('')
|
||||
@@ -93,7 +86,6 @@ export default function ConsignmentManualMatchModal({
|
||||
const resetState = () => {
|
||||
setKeyword('')
|
||||
setSelected(null)
|
||||
setDeliveryKind('internal_stock')
|
||||
setConfirmedPartial(false)
|
||||
setConfirmedMismatch(false)
|
||||
setMatchNote('')
|
||||
@@ -167,6 +159,7 @@ export default function ConsignmentManualMatchModal({
|
||||
const requirement = resolveManualMatchRequirement(selected?.matchType || 'exact')
|
||||
const canSubmit =
|
||||
Boolean(selected) &&
|
||||
Boolean(selected?.workerId) &&
|
||||
canSubmitManualMatch({
|
||||
matchType: selected?.matchType || 'exact',
|
||||
confirmedPartial,
|
||||
@@ -176,36 +169,26 @@ export default function ConsignmentManualMatchModal({
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selected) return
|
||||
if (deliveryKind === 'assign_worker' && !selected.workerId) {
|
||||
message.error('该现货未关联供货打手,不能走指派履约;请改用内部现货履约')
|
||||
if (!selected.workerId) {
|
||||
message.error('该现货未关联供货打手,无法走平台指派履约;请先关联打手')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payload = {
|
||||
const res = await assignWorkOrderWithConsignmentWorker({
|
||||
workOrderId: selected.workOrderId,
|
||||
inventoryId: selected.inventoryId,
|
||||
workerId: selected.workerId,
|
||||
allowPartial: selected.matchType !== 'exact',
|
||||
allowSpecMismatch: selected.matchType === 'conflict',
|
||||
matchNote: matchNote.trim(),
|
||||
}
|
||||
const res =
|
||||
deliveryKind === 'assign_worker'
|
||||
? await assignWorkOrderWithConsignmentWorker({
|
||||
...payload,
|
||||
workerId: selected.workerId ?? undefined,
|
||||
})
|
||||
: await fulfillWorkOrderWithConsignment(payload)
|
||||
})
|
||||
|
||||
// 核销失败时共享工具会弹警告(状态已变更但凭证没消耗),此时不能再弹"成功"掩盖
|
||||
const voucher = (res.data as any)?.voucherConsume
|
||||
notifyVoucherConsume(voucher)
|
||||
if (!voucher || voucher.ok) {
|
||||
message.success(
|
||||
deliveryKind === 'assign_worker'
|
||||
? '已手动匹配:指派供货打手履约,押金已冻结'
|
||||
: '已手动匹配:使用内部现货履约,工单进入待加好友',
|
||||
)
|
||||
message.success('已手动匹配并指派供货打手履约,履约押金已冻结')
|
||||
}
|
||||
onMatched?.()
|
||||
resetState()
|
||||
@@ -232,7 +215,7 @@ export default function ConsignmentManualMatchModal({
|
||||
void load()
|
||||
return
|
||||
}
|
||||
message.error(err.message || '手动匹配失败')
|
||||
message.error(err.message || '手动匹配指派失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -243,59 +226,53 @@ export default function ConsignmentManualMatchModal({
|
||||
title={mode === 'order' ? `手动指定现货 · ${workOrder?.label || ''}` : '手动匹配订单'}
|
||||
open={open}
|
||||
width={860}
|
||||
okText="确认指派履约"
|
||||
okButtonProps={{
|
||||
disabled: !canSubmit,
|
||||
loading: submitting,
|
||||
}}
|
||||
onOk={() => void handleSubmit()}
|
||||
onCancel={() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}}
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) void load()
|
||||
if (visible) {
|
||||
void load()
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={submitting} disabled={!canSubmit} onClick={handleSubmit}>
|
||||
确认匹配
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="手动匹配会立刻占用现货、冻结相关押金,点错需要用「撤销寄售履约」退回"
|
||||
message="手动匹配将直接占用现货并指派供货打手履约(冻结履约押金并纳入交付监控)。"
|
||||
description={
|
||||
mode === 'inventory'
|
||||
? `当前现货:${inventoryLabel}。选中订单后选择交付方式即可。`
|
||||
: '下面是该订单的全部在库现货(含规格不一致的),请核对差异后再决定。'
|
||||
mode === 'order'
|
||||
? '下面是该订单的全部在库现货(含规格不一致的),请核对差异后再决定。'
|
||||
: `当前现货:${inventoryLabel || inventory?.label || ''}。下面是可匹配的订单列表,请核对规格后指派。`
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={
|
||||
mode === 'order'
|
||||
? '搜索物品名 / 寄卖单号 / 仓库号 / 规格'
|
||||
: '搜索订单号 / 商品名 / 规格'
|
||||
: '搜索工单号 / 平台单号 / 标题 / 规格'
|
||||
}
|
||||
prefix={<SearchOutlined />}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<div style={{ maxHeight: 320, overflowY: 'auto' }}>
|
||||
<div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{mode === 'order' ? (
|
||||
filteredOptions.length === 0 ? (
|
||||
<Empty description="没有可用的在库现货" />
|
||||
<Empty description="没有可匹配的现货" />
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const meta = describeManualMatchType(option.matchType)
|
||||
@@ -323,11 +300,13 @@ export default function ConsignmentManualMatchModal({
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
寄卖单号 {option.inventory.sourceSubmissionNo || '—'} ·{' '}
|
||||
{option.inventory.system}/{option.inventory.serverZone}/
|
||||
{option.inventory.series} {option.inventory.diamondLevel}钻 · 货源{' '}
|
||||
{option.inventory.workerName || '—'} · 结算价 ¥
|
||||
{(option.inventory.costPriceFen / 100).toFixed(2)}
|
||||
寄卖单号 {option.inventory.sourceSubmissionNo || option.inventory.inventoryNo} ·{' '}
|
||||
{option.inventory.system || '未指定'}/
|
||||
{option.inventory.serverZone || '未指定'}/
|
||||
{option.inventory.series || '未指定'}{' '}
|
||||
{option.inventory.diamondLevel ? `${option.inventory.diamondLevel}钻` : ''} · 货源{' '}
|
||||
{option.inventory.workerName || '未关联打手'} · 结算价 ¥
|
||||
{(Number(option.inventory.costPriceFen || 0) / 100).toFixed(2)}
|
||||
</Text>
|
||||
{option.conflicts.length > 0 ? (
|
||||
<Text style={{ fontSize: 12, color: '#cf1322' }}>
|
||||
@@ -335,7 +314,7 @@ export default function ConsignmentManualMatchModal({
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{option.reason || '规格相符'}
|
||||
{option.reason || '规格完全一致'}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
@@ -356,10 +335,6 @@ export default function ConsignmentManualMatchModal({
|
||||
setConfirmedPartial(false)
|
||||
setConfirmedMismatch(false)
|
||||
setMatchNote('')
|
||||
// 选了无打手的现货时指派不可用,兜底切回内部履约
|
||||
if (!option.inventory.workerId && deliveryKind === 'assign_worker') {
|
||||
setDeliveryKind('internal_stock')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSelected ? '已选' : '选它'}
|
||||
@@ -425,17 +400,12 @@ 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 ? '已选' : '选它'}
|
||||
@@ -453,6 +423,20 @@ export default function ConsignmentManualMatchModal({
|
||||
<Descriptions size="small" column={1} style={{ marginBottom: 8 }}>
|
||||
<Descriptions.Item label="订单">{selected.workOrderLabel || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="现货">{selected.inventoryLabel || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="指派打手">
|
||||
{selected.workerId ? (
|
||||
<Space size={6}>
|
||||
<Tag icon={<TeamOutlined />} color="blue">
|
||||
{selected.workerName || `打手 #${selected.workerId}`}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
(工单将进入该打手“我的接单”并冻结履约押金)
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="danger">该现货未关联供货打手,无法指派</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="匹配判定">
|
||||
<Tag color={describeManualMatchType(selected.matchType).color}>
|
||||
{describeManualMatchType(selected.matchType).label}
|
||||
@@ -466,26 +450,14 @@ export default function ConsignmentManualMatchModal({
|
||||
</Descriptions>
|
||||
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Radio.Group
|
||||
value={deliveryKind}
|
||||
onChange={(e) => setDeliveryKind(e.target.value)}
|
||||
options={[
|
||||
{
|
||||
value: 'internal_stock',
|
||||
label: '内部现货履约(客服/内部仓代交付,工单停在待加好友)',
|
||||
},
|
||||
{
|
||||
value: 'assign_worker',
|
||||
// 没关联打手的现货走指派必 400 consignment_worker_required,直接禁用并说明,别给死路
|
||||
disabled: !selected.workerId,
|
||||
label: `指派供货打手履约(冻结押金、工单进行中)${
|
||||
selected.workerName
|
||||
? ` · 打手:${selected.workerName}`
|
||||
: ' · 该现货未关联打手,不可指派'
|
||||
}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{!selected.workerId ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="无法指派打手"
|
||||
description="该现货未绑定任何供货打手,无法在接单平台中执行指派与押金冻结流程。请先在寄卖管理中关联打手。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{requirement.needsPartialConfirm ? (
|
||||
<Checkbox
|
||||
|
||||
@@ -3205,9 +3205,6 @@ export default function WorkOrdersPanel() {
|
||||
backgroundColor: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 8 }}>
|
||||
选择履约方式(都会立刻占用该现货)
|
||||
</div>
|
||||
{inv.workerId ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -3235,32 +3232,15 @@ export default function WorkOrdersPanel() {
|
||||
>
|
||||
走接单平台指派打手
|
||||
</Button>
|
||||
<div style={{ fontSize: 11, color: '#8c8c8c', margin: '4px 0 14px' }}>
|
||||
由该供货打手接单,需冻结履约押金
|
||||
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 6, textAlign: 'center' }}>
|
||||
指派供货打手履约 · 自动冻结履约押金
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
block
|
||||
type={inv.workerId ? 'default' : 'primary'}
|
||||
icon={<PushpinOutlined />}
|
||||
loading={consignmentInterceptModal.submitting}
|
||||
onClick={() =>
|
||||
confirmConsignmentMatchAction({
|
||||
kind: 'internal_stock',
|
||||
matchType: m.matchType,
|
||||
consignmentNo: inv.sourceSubmissionNo || '客服手工入库',
|
||||
onOk: () => {
|
||||
void handleFulfillWithConsignment(inv.id, !isExact)
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
内部现货履约
|
||||
</Button>
|
||||
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 4 }}>
|
||||
不外派打手,直接用库存账号赠送
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: '#cf1322', textAlign: 'center' }}>
|
||||
该现货未关联供货打手,无法走平台指派;请先关联打手
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,11 +87,11 @@ test('手动匹配弹窗:数据过期的 409 要清选择并刷新,无打手
|
||||
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'), '无打手时指派选项必须禁用')
|
||||
// 统一平台履约:没关联打手的现货禁用提交,提交前严格校验 workerId
|
||||
assert.ok(source.includes('Boolean(selected?.workerId)'), '无打手时必须禁用提交')
|
||||
assert.ok(
|
||||
source.includes("deliveryKind === 'assign_worker' && !selected.workerId"),
|
||||
'提交前要对无打手的指派再拦一道',
|
||||
source.includes("if (!selected.workerId)"),
|
||||
'提交前要对无打手的现货严格拦截',
|
||||
)
|
||||
// 凭证核销反馈与关键字搜索:用共享出口 / 透传后端,不内联复制
|
||||
assert.ok(source.includes("from '@/utils/voucher-consume'"), '核销反馈必须用共享出口')
|
||||
|
||||
Reference in New Issue
Block a user