Files
order_site/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx
T
yml2213 f07e4087ad feat(consignment): 优化寄售仓库与接单工单双向手动匹配,支持工单号与提报单号检索与强制匹配
1. 寄售仓库手动匹配订单扩充支持 open 状态工单,增强 WO 工单号与纯数字的模糊/前缀搜索,修复工单搜不到的问题并在卡片高亮展示内部工单号与状态;
2. 接单工单操作列全面支持【手动匹配寄售】,允许从接单工单主动调起寄售匹配弹窗;
3. 寄售现货筛选支持通过 SUB 提报单号、INV 仓库号、供货打手等快速搜索;
4. 完善规格冲突下的强制匹配确认与必填原因提交机制,确保履约全流程留痕合规。
2026-09-19 17:59:05 +08:00

529 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Checkbox,
Descriptions,
Empty,
Input,
Modal,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd'
import { SearchOutlined, TeamOutlined } from '@ant-design/icons'
import {
assignWorkOrderWithConsignmentWorker,
fetchInventoryMatchableOrders,
fetchWorkOrderConsignmentMatchOptions,
} from '@/services/admin/consignment'
import type { ConsignmentMatchOptionView, ConsignmentMatchableOrder } from '@/types/consignment'
import {
canSubmitManualMatch,
describeManualMatchType,
filterManualMatchOptions,
resolveManualMatchRequirement,
type ManualMatchType,
} from '@/utils/consignment-manual-match'
import { notifyVoucherConsume } from '@/utils/voucher-consume'
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 [inventoryWorker, setInventoryWorker] = useState<{
workerId: number | null
workerName: string
} | null>(null)
const [selected, setSelected] = useState<Selected | null>(null)
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)
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}
const load = async () => {
setLoading(true)
try {
if (inventory) {
// 关键字与分页透传后端:库存侧默认只拉 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 || [])
}
} catch (err: any) {
message.error(err.message || '读取可匹配数据失败')
} finally {
setLoading(false)
}
}
// 库存侧:关键字防抖透传后端搜索(本地过滤只作用在已拉取的 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) => [
item.inventory.itemName,
item.inventory.sourceSubmissionNo,
item.inventory.inventoryNo,
item.inventory.system,
item.inventory.serverZone,
item.inventory.series,
item.inventory.workerName,
item.inventory.buyerName,
item.inventory.sellerGameId,
item.inventory.id,
]),
[options, keyword],
)
const filteredOrders = useMemo(
() =>
filterManualMatchOptions(orders, keyword, (item) => [
item.workOrderNo,
item.workOrderId,
item.productName,
item.requiredSpec.system,
item.requiredSpec.serverZone,
item.requiredSpec.series,
item.status,
]),
[orders, keyword],
)
const requirement = resolveManualMatchRequirement(selected?.matchType || 'exact')
const canSubmit =
Boolean(selected) &&
Boolean(selected?.workerId) &&
canSubmitManualMatch({
matchType: selected?.matchType || 'exact',
confirmedPartial,
confirmedMismatch,
matchNote,
})
async function handleSubmit() {
if (!selected) return
if (!selected.workerId) {
message.error('该现货未关联供货打手,无法走平台指派履约;请先关联打手')
return
}
setSubmitting(true)
try {
const res = await assignWorkOrderWithConsignmentWorker({
workOrderId: selected.workOrderId,
inventoryId: selected.inventoryId,
workerId: selected.workerId,
allowPartial: selected.matchType !== 'exact' || confirmedPartial,
allowSpecMismatch: selected.matchType === 'conflict' || confirmedMismatch,
matchNote: matchNote.trim(),
})
// 核销失败时共享工具会弹警告(状态已变更但凭证没消耗),此时不能再弹"成功"掩盖
const voucher = (res.data as any)?.voucherConsume
notifyVoucherConsume(voucher)
if (!voucher || voucher.ok) {
message.success('已手动匹配并指派供货打手履约,履约押金已冻结')
}
onMatched?.()
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)
}
}
return (
<Modal
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()
} else {
resetState()
}
}}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="手动匹配将直接占用现货并指派供货打手履约(冻结履约押金并纳入交付监控)。"
description={
mode === 'order'
? '下面是该订单的全部在库现货(含规格不一致的),请核对差异后再决定。'
: `当前现货:${inventoryLabel || inventory?.label || ''}。下面是可匹配的订单列表,请核对规格后指派。`
}
/>
<Input
placeholder={
mode === 'order'
? '搜索物品名 / 寄卖单号 (SUB...) / 仓库号 (INV...) / 规格 / 供货打手'
: '搜索工单号 (WO...) / 平台单号 / 标题 / 规格'
}
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
/>
<Spin spinning={loading}>
<div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 4 }}>
{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>
<Tag color="cyan">
{option.inventory.sourceSubmissionNo || option.inventory.inventoryNo}
</Tag>
<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.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' }}>
差异:{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>
<Tag color="purple">{order.workOrderNo || `#${order.workOrderId}`}</Tag>
<Text strong>{order.productName}</Text>
<Tag color={meta.color}>{meta.label}</Tag>
{order.status === 'open' ? (
<Tag color="blue">大厅待抢</Tag>
) : order.status === 'unassigned' ? (
<Tag color="orange">待指派</Tag>
) : order.status === 'pending_material' ? (
<Tag color="gold">待补资料</Tag>
) : null}
</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.workOrderNo || `#${order.workOrderId}`} · ${order.productName}`,
inventoryId: inventory?.inventoryId || 0,
inventoryLabel,
matchType: order.matchType,
conflicts: order.conflicts,
workerId: inventoryWorker?.workerId ?? null,
workerName: inventoryWorker?.workerName || '',
})
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="指派打手">
{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}
</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%' }}>
{!selected.workerId ? (
<Alert
type="error"
showIcon
message="无法指派打手"
description="该现货未绑定任何供货打手,无法在接单平台中执行指派与押金冻结流程。请先在寄卖管理中关联打手。"
/>
) : null}
{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>
)
}