Files
order_site/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx
T
yml2213 c80bd0e337 fix(worker-platform): 修复寄售匹配工单详情中卖家上架截图丢失问题,并收敛手动匹配为内部现货直接履约
1. 修复文件归一化器 normalizeUploadedFiles 遇到纯字符串 URL 数组被误过滤为空的问题,自动提取 objectKey 并构造规范 UploadedFile 结构;
2. 优化工单展示映射器 resolveWorkOrderEffectiveBoosterMaterial:当工单资料中截图为空时,自动回退并加载寄售卖家上架/主页截图;
3. 前端管理后台详情抽屉与打手端详情抽屉支持读取 detailOrder.gift.boosterMaterial.screenshots 与 consignmentInfo.screenshots 降级兜底渲染;
4. 手动匹配弹窗与发布拦截弹窗全面收敛为单一标准的【内部现货直接履约】,废除旧版指派打手带来的重复扣押金与二次填资料问题;
5. 新增 consignment-booster-material 单元测试,更新寄卖库存优化现状与进度追踪文档,前后端全套测试通过。
2026-09-21 11:35:38 +08:00

513 lines
20 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 {
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>
)
}