feat(consignment): 客服手动指定现货匹配的界面入口

后端批次已提供判定/差异/强制留痕与双向读取接口,这里补界面,让客服真的能用上。

- 新增共享弹窗 ConsignmentManualMatchModal:订单侧(匹配弹窗「手动指定现货匹配」)与
  库存侧(寄售仓库行内「手动匹配订单」)共用同一套交互与落地逻辑,避免两套 UI 漂移
- 交互:搜索 → 选中(精确/非精确/规格冲突三档标签 + 字段级差异说明)→ 选交付方式
  (内部现货履约 / 指派供货打手)→ 非精确需勾选确认、冲突需勾选「确认规格不一致」并必填原因
  → 落地;并在落地前提示"现货立刻被占用、点错需撤销寄售履约"
- 判定规则收敛成纯函数 consignment-manual-match.ts(需不需要确认/填原因、能否提交、
  关键词过滤、标签文案)并加 4 例单测
- 服务与类型补齐:fetchWorkOrderConsignmentMatchOptions / fetchInventoryMatchableOrders,
  fulfill/assign 入参增加 allowSpecMismatch 与 matchNote
- 凭证核销失败的提示沿用既有规则(核销失败不能报成功)

验证:前端 typecheck/lint/format/44 例测试/build 全过;两个新接口未登录访问均 401
(路由已挂载、权限位生效)。
This commit is contained in:
yml2213
2026-09-17 12:23:30 +08:00
parent 94328ac643
commit a7a55d6c16
8 changed files with 771 additions and 0 deletions
@@ -0,0 +1,478 @@
import { useMemo, useState } from 'react'
import {
Alert,
Button,
Checkbox,
Descriptions,
Empty,
Input,
Modal,
Radio,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd'
import { SearchOutlined } from '@ant-design/icons'
import {
assignWorkOrderWithConsignmentWorker,
fetchInventoryMatchableOrders,
fetchWorkOrderConsignmentMatchOptions,
fulfillWorkOrderWithConsignment,
} from '@/services/admin/consignment'
import type { ConsignmentMatchOptionView, ConsignmentMatchableOrder } from '@/types/consignment'
import {
canSubmitManualMatch,
describeManualMatchType,
filterManualMatchOptions,
resolveManualMatchRequirement,
type ManualMatchType,
} from '@/utils/consignment-manual-match'
const { Text, Paragraph } = Typography
type Selected = {
workOrderId: number | string
workOrderLabel: string
inventoryId: number | string
inventoryLabel: string
matchType: ManualMatchType
conflicts: string[]
/** 指派打手线要用的供货卖家(现货的供货打手);内部现货履约不需要 */
workerId?: number | null
workerName?: string
}
export type ConsignmentManualMatchModalProps = {
open: boolean
onClose: () => void
/** 订单侧入口:从订单出发选现货 */
workOrder?: { workOrderId: number | string; label: string } | null
/** 库存侧入口:从现货出发选订单 */
inventory?: { inventoryId: number | string; label: string } | null
onMatched?: () => void
}
/**
* 「手动指定现货」弹窗:订单侧与库存侧共用一套交互与落地逻辑。
*
* 为什么单独做:系统候选只覆盖"规格完全对得上"的现货,客服经常遇到
* "订单规格没写全/订单录错/需要高配充低配"的情况,必须能人工指定;
* 同时要把"哪里不一致"讲清楚,并把原因留痕,避免变成无痕操作。
*/
export default function ConsignmentManualMatchModal({
open,
onClose,
workOrder,
inventory,
onMatched,
}: ConsignmentManualMatchModalProps) {
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<ConsignmentMatchOptionView[]>([])
const [orders, setOrders] = useState<ConsignmentMatchableOrder[]>([])
const [inventoryLabel, setInventoryLabel] = useState('')
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('')
const [submitting, setSubmitting] = useState(false)
const mode: 'order' | 'inventory' = inventory ? 'inventory' : 'order'
const resetState = () => {
setKeyword('')
setSelected(null)
setDeliveryKind('internal_stock')
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}
const load = async () => {
setLoading(true)
try {
if (inventory) {
const res = await fetchInventoryMatchableOrders(inventory.inventoryId)
setOrders(res.data?.orders || [])
setInventoryLabel(
res.data?.inventory
? `${res.data.inventory.itemName}(${res.data.inventory.sourceSubmissionNo || res.data.inventory.inventoryNo})`
: inventory.label,
)
} else if (workOrder) {
const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId)
setOptions(res.data?.options || [])
}
} catch (err: any) {
message.error(err.message || '读取可匹配数据失败')
} finally {
setLoading(false)
}
}
const filteredOptions = useMemo(
() =>
filterManualMatchOptions(options, keyword, (item) => [
item.inventory.itemName,
item.inventory.sourceSubmissionNo,
item.inventory.inventoryNo,
item.inventory.system,
item.inventory.serverZone,
item.inventory.series,
]),
[options, keyword],
)
const filteredOrders = useMemo(
() =>
filterManualMatchOptions(orders, keyword, (item) => [
item.workOrderNo,
item.productName,
item.requiredSpec.system,
item.requiredSpec.serverZone,
item.requiredSpec.series,
]),
[orders, keyword],
)
const requirement = resolveManualMatchRequirement(selected?.matchType || 'exact')
const canSubmit =
Boolean(selected) &&
canSubmitManualMatch({
matchType: selected?.matchType || 'exact',
confirmedPartial,
confirmedMismatch,
matchNote,
})
async function handleSubmit() {
if (!selected) return
setSubmitting(true)
try {
const payload = {
workOrderId: selected.workOrderId,
inventoryId: selected.inventoryId,
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
if (voucher && voucher.ok === false) {
message.warning({
content: `状态已变更,但电子凭证核销失败:${
voucher.errorMessage || '未知错误'
}。请在快手后台人工核销`,
duration: 8,
})
} else {
message.success(
deliveryKind === 'assign_worker'
? '已手动匹配:指派供货打手履约,押金已冻结'
: '已手动匹配:使用内部现货履约,工单进入待加好友',
)
}
onMatched?.()
resetState()
onClose()
} catch (err: any) {
message.error(err.message || '手动匹配失败')
} finally {
setSubmitting(false)
}
}
return (
<Modal
title={mode === 'order' ? `手动指定现货 · ${workOrder?.label || ''}` : '手动匹配订单'}
open={open}
width={860}
onCancel={() => {
resetState()
onClose()
}}
afterOpenChange={(visible) => {
if (visible) void load()
}}
footer={
<Space>
<Button
onClick={() => {
resetState()
onClose()
}}
>
取消
</Button>
<Button type="primary" loading={submitting} disabled={!canSubmit} onClick={handleSubmit}>
确认匹配
</Button>
</Space>
}
destroyOnClose
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="手动匹配会立刻占用现货、冻结相关押金,点错需要用「撤销寄售履约」退回"
description={
mode === 'inventory'
? `当前现货:${inventoryLabel}。选中订单后选择交付方式即可。`
: '下面是该订单的全部在库现货(含规格不一致的),请核对差异后再决定。'
}
/>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder={
mode === 'order'
? '搜索物品名 / 寄卖单号 / 仓库号 / 规格'
: '搜索订单号 / 商品名 / 规格'
}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Spin spinning={loading}>
<div style={{ maxHeight: 320, overflowY: 'auto' }}>
{mode === 'order' ? (
filteredOptions.length === 0 ? (
<Empty description="没有可用的在库现货" />
) : (
filteredOptions.map((option) => {
const meta = describeManualMatchType(option.matchType)
const isSelected =
selected?.inventoryId != null &&
String(selected.inventoryId) === String(option.inventory.id)
return (
<div
key={option.inventory.id}
style={{
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
borderRadius: 8,
padding: 10,
marginBottom: 8,
background: isSelected ? '#fff7e6' : '#fff',
}}
>
<Space
style={{ width: '100%', justifyContent: 'space-between' }}
align="start"
>
<Space direction="vertical" size={2}>
<Space size={6} wrap>
<Text strong>{option.inventory.itemName}</Text>
<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)}
</Text>
{option.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
差异:{option.conflicts.join(';')}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{option.reason || '规格相符'}
</Text>
)}
</Space>
<Button
size="small"
type={isSelected ? 'primary' : 'default'}
onClick={() => {
setSelected({
workOrderId: workOrder?.workOrderId || 0,
workOrderLabel: workOrder?.label || '',
inventoryId: option.inventory.id,
inventoryLabel: option.inventory.itemName,
matchType: option.matchType,
conflicts: option.conflicts,
workerId: option.inventory.workerId,
workerName: option.inventory.workerName,
})
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}}
>
{isSelected ? '已选' : '选它'}
</Button>
</Space>
</div>
)
})
)
) : filteredOrders.length === 0 ? (
<Empty description="没有待匹配的订单" />
) : (
filteredOrders.map((order) => {
const meta = describeManualMatchType(order.matchType)
const isSelected =
selected?.workOrderId != null &&
String(selected.workOrderId) === String(order.workOrderId)
return (
<div
key={order.workOrderId}
style={{
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
borderRadius: 8,
padding: 10,
marginBottom: 8,
background: isSelected ? '#fff7e6' : '#fff',
}}
>
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<Space direction="vertical" size={2}>
<Space size={6} wrap>
<Text strong>#{order.workOrderId}</Text>
<Text>{order.productName}</Text>
<Tag color={meta.color}>{meta.label}</Tag>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
要求:{order.requiredSpec.system || '未指定'}/
{order.requiredSpec.serverZone || '未指定'}/
{order.requiredSpec.series || '未指定'}{' '}
{order.requiredSpec.diamondLevel
? `${order.requiredSpec.diamondLevel}钻`
: ''}{' '}
· 报酬 ¥{(order.rewardAmountFen / 100).toFixed(2)}
</Text>
{order.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
差异:{order.conflicts.join(';')}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{order.reason || '规格相符'}
</Text>
)}
</Space>
<Button
size="small"
type={isSelected ? 'primary' : 'default'}
onClick={() => {
setSelected({
workOrderId: order.workOrderId,
workOrderLabel: `#${order.workOrderId} ${order.productName}`,
inventoryId: inventory?.inventoryId || 0,
inventoryLabel,
matchType: order.matchType,
conflicts: order.conflicts,
})
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}}
>
{isSelected ? '已选' : '选它'}
</Button>
</Space>
</div>
)
})
)}
</div>
</Spin>
{selected ? (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 12 }}>
<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="匹配判定">
<Tag color={describeManualMatchType(selected.matchType).color}>
{describeManualMatchType(selected.matchType).label}
</Tag>
{selected.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
{selected.conflicts.join(';')}
</Text>
) : null}
</Descriptions.Item>
</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',
label: `指派供货打手履约(冻结押金、工单进行中)${
selected.workerName ? ` · 打手:${selected.workerName}` : ''
}`,
},
]}
/>
{requirement.needsPartialConfirm ? (
<Checkbox
checked={confirmedPartial}
onChange={(e) => setConfirmedPartial(e.target.checked)}
>
允许非精确匹配(订单规格未写全或高配充低配)
</Checkbox>
) : null}
{requirement.needsMismatchConfirm ? (
<>
<Alert
type="error"
showIcon
message="该现货与订单规格不一致,确认要强制匹配?"
description="系统/区服/系列/钻级不符通常是发错货;确实要指定请勾选下方确认并填写原因,操作会留痕。"
/>
<Checkbox
checked={confirmedMismatch}
onChange={(e) => setConfirmedMismatch(e.target.checked)}
>
确认规格不一致,仍要手动匹配
</Checkbox>
</>
) : null}
{requirement.needsNote ? (
<Input.TextArea
rows={2}
maxLength={200}
showCount
placeholder="手动匹配原因(必填,会写入工单留痕,便于事后核对)"
value={matchNote}
onChange={(e) => setMatchNote(e.target.value)}
/>
) : null}
<Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
确认后现货立即被占用:内部现货履约进入「待加好友」,指派打手会冻结履约押金。
如需退回请使用「撤销寄售履约」。
</Paragraph>
</Space>
</div>
) : null}
</Space>
</Modal>
)
}