1. 修复文件归一化器 normalizeUploadedFiles 遇到纯字符串 URL 数组被误过滤为空的问题,自动提取 objectKey 并构造规范 UploadedFile 结构; 2. 优化工单展示映射器 resolveWorkOrderEffectiveBoosterMaterial:当工单资料中截图为空时,自动回退并加载寄售卖家上架/主页截图; 3. 前端管理后台详情抽屉与打手端详情抽屉支持读取 detailOrder.gift.boosterMaterial.screenshots 与 consignmentInfo.screenshots 降级兜底渲染; 4. 手动匹配弹窗与发布拦截弹窗全面收敛为单一标准的【内部现货直接履约】,废除旧版指派打手带来的重复扣押金与二次填资料问题; 5. 新增 consignment-booster-material 单元测试,更新寄卖库存优化现状与进度追踪文档,前后端全套测试通过。
513 lines
20 KiB
TypeScript
513 lines
20 KiB
TypeScript
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 {
|
||
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'
|
||
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) &&
|
||
canSubmitManualMatch({
|
||
matchType: selected?.matchType || 'exact',
|
||
confirmedPartial,
|
||
confirmedMismatch,
|
||
matchNote,
|
||
})
|
||
|
||
async function handleSubmit() {
|
||
if (!selected) return
|
||
setSubmitting(true)
|
||
try {
|
||
const res = await fulfillWorkOrderWithConsignment({
|
||
workOrderId: selected.workOrderId,
|
||
inventoryId: selected.inventoryId,
|
||
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="info"
|
||
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>
|
||
) : (
|
||
<Tag color="default">内部库存现货(无打手关联)</Tag>
|
||
)}
|
||
</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%' }}>
|
||
{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>
|
||
)
|
||
}
|