import { App, Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography } from 'antd' import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import PercentTable from '@/components/common/PercentTable' import { fetchAdminConsignmentMatchCandidates, matchAdminConsignmentWorkOrder, } from '@/services/admin/consignment' import type { ConsignmentMatchCandidate } from '@/types/consignment' import { computeConsignmentMatchScore, describeConsignmentMatch, resolveMatchCoverage, } from '@/utils/consignment-match-coverage' import { resolveConsignmentPriorityTagColor } from '@/utils/consignment-match-priority' function money(fen: number) { return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}` } const EMPTY_INVENTORY_IDS: number[] = [] /** 搜索框命中范围:单号 / 物品 / 规格 / 打手 / 匹配说明。 */ function buildCandidateHaystack(item: ConsignmentMatchCandidate): string { return [ item.consignmentNo || '', item.inventoryNo, item.itemName, item.specText || '', Object.values(item.specValues || {}).join(' '), item.workerName || '', (item.matchReasons || []).join(' '), describeConsignmentMatch(item), ] .join(' ') .toLowerCase() } function sameIdList(left: number[], right: number[]): boolean { if (left.length !== right.length) return false return left.every((value, index) => value === right[index]) } function resolveScoreTag(item: ConsignmentMatchCandidate) { const type = item.matchType || 'exact' const score = computeConsignmentMatchScore(item) if (type === 'conflict') return { color: 'default', label: '不匹配' } if (type === 'exact') return { color: 'green', label: '精确' } if (score >= 90) return { color: 'blue', label: '非精确' } if (score >= 70) return { color: 'orange', label: '非精确' } return { color: 'gold', label: '需核对' } } export default function ConsignmentWorkOrderMatchModal({ open, workOrderId, initialInventoryIds, onClose, onMatched, }: { open: boolean workOrderId: number | null /** 外部带入的预选库存(例如待匹配看板点「选择」),允许是非精确项 */ initialInventoryIds?: number[] onClose: () => void onMatched?: () => void }) { const { message } = App.useApp() const [selectedIds, setSelectedIds] = useState([]) const [keyword, setKeyword] = useState('') const [submitting, setSubmitting] = useState(false) const [nonExactReason, setNonExactReason] = useState('') const presetInventoryKey = (initialInventoryIds || EMPTY_INVENTORY_IDS).join(',') const candidatesQuery = useQuery({ queryKey: ['admin-consignment-match-candidates', workOrderId], queryFn: () => fetchAdminConsignmentMatchCandidates(Number(workOrderId)), enabled: open && Boolean(workOrderId), // 409 等业务拒绝对话框要直接把原因展示出来,不能重试后再被误判成“没有库存”。 retry: false, refetchOnWindowFocus: false, }) const data = candidatesQuery.data?.data const options = useMemo(() => data?.options || [], [data?.options]) const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1)) const activeQuantity = Math.max(0, Number(data?.activeQuantity || 0)) const sharingEnabled = Boolean(data?.sharingEnabled) // 状态不允许时接口只读返回候选:仍可搜索/核对库存,但禁止提交。 const matchable = data?.matchable !== false const blockedReason = data?.blockedReason || '当前工单状态不允许匹配寄卖库存' const remainingNeeded = sharingEnabled ? Math.max(0, totalQuantity - activeQuantity) : totalQuantity const normalizedKeyword = keyword.trim().toLowerCase() // 后端已按「枪型 → 钻石 → 璀璨」排好序;这里只做关键词过滤,必须保持原顺序。 const visibleOptions = useMemo(() => { return normalizedKeyword ? options.filter((item) => buildCandidateHaystack(item).includes(normalizedKeyword)) : options }, [normalizedKeyword, options]) const selectedOptions = useMemo( () => options.filter((item) => selectedIds.includes(item.inventoryId)), [options, selectedIds], ) const coverage = useMemo( () => resolveMatchCoverage({ sharingEnabled, totalQuantity, activeQuantity, selected: selectedOptions.map((item) => ({ inventoryId: item.inventoryId, remainingQuantity: item.remainingQuantity, })), }), [activeQuantity, selectedOptions, sharingEnabled, totalQuantity], ) const counts = useMemo(() => { let exact = 0 let partial = 0 let conflict = 0 for (const item of options) { const type = item.matchType || 'exact' if (type === 'exact') exact += 1 else if (type === 'conflict') conflict += 1 else partial += 1 } return { exact, partial, conflict } }, [options]) useEffect(() => { if (!open) { setSelectedIds((prev) => (prev.length ? [] : prev)) setKeyword('') setNonExactReason('') return } if (options.length === 0 || remainingNeeded <= 0) { setSelectedIds((prev) => (prev.length ? [] : prev)) return } // 外部预选优先:待匹配看板点「选择」时直接勾中对应库存(可能是非精确项)。 if (presetInventoryKey) { const presetIds = presetInventoryKey .split(',') .map((value) => Number(value)) .filter((id) => Number.isInteger(id) && id > 0) setSelectedIds((prev) => (sameIdList(prev, presetIds) ? prev : presetIds)) return } // 默认只选精确库存;整单必须一条满足总量,拼单按剩余份数累加到一次拼满。 const exactOptions = options.filter( (item) => (item.matchType || 'exact') === 'exact' && Number(item.remainingQuantity || 0) > 0, ) if (!sharingEnabled) { const chosen = exactOptions.find( (item) => Number(item.remainingQuantity || 0) >= totalQuantity, ) const nextIds = chosen ? [chosen.inventoryId] : [] setSelectedIds((prev) => (sameIdList(prev, nextIds) ? prev : nextIds)) return } let covered = 0 const ids: number[] = [] for (const item of exactOptions) { if (covered >= remainingNeeded) break ids.push(item.inventoryId) covered += Math.max(0, Number(item.remainingQuantity || 0)) } setSelectedIds((prev) => (sameIdList(prev, ids) ? prev : ids)) }, [open, options, presetInventoryKey, remainingNeeded, sharingEnabled, totalQuantity]) const nonExactSelected = useMemo( () => selectedOptions.some((item) => { const type = item.matchType || 'exact' return type !== 'exact' }), [selectedOptions], ) const canSubmit = matchable && selectedIds.length > 0 && coverage.sufficient && (!nonExactSelected || nonExactReason.trim().length > 0) const infoMessage = remainingNeeded <= 0 ? '该工单已匹配满,无需再次匹配。' : sharingEnabled ? `该工单拼单 ${totalQuantity} 份,已匹配 ${activeQuantity} 份,本次还需 ${remainingNeeded} 份;必须一次选满才能提交。` : `该工单为整单匹配,请选择 1 条可一次满足 ${totalQuantity} 件的库存。` return ( } > {candidatesQuery.isLoading ? : null} {candidatesQuery.isError ? ( void candidatesQuery.refetch()}> 重试 } /> ) : null} {!candidatesQuery.isLoading && !candidatesQuery.isError ? ( setKeyword(event.target.value)} /> 显示 {visibleOptions.length} / 候选 {options.length} 条 候选范围:本工单分类及其上级分类下的全部在库寄卖单(跨枪型会标为不匹配,需填原因才能强制匹配)。 ) : null} {!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0 ? ( ) : null} {!candidatesQuery.isLoading && !candidatesQuery.isError && options.length > 0 && visibleOptions.length === 0 ? ( ) : null} {!candidatesQuery.isError && visibleOptions.length > 0 ? ( {!matchable ? ( ) : null} {data?.matchingMode === 'off' ? ( ) : null} {sharingEnabled ? `待匹配 ${remainingNeeded} 份` : `整单 ${totalQuantity} 件`} {sharingEnabled ? 已匹配 {activeQuantity} 份 : null} 精确 {counts.exact} 非精确 {counts.partial} 冲突 {counts.conflict} rowKey="inventoryId" size="small" loading={candidatesQuery.isFetching} pagination={{ pageSize: 10, showSizeChanger: false }} scroll={{ x: 1210, y: 420 }} dataSource={visibleOptions} rowSelection={{ // 整单只能选一条;拼单可多选,必须覆盖剩余份数才能提交。 type: sharingEnabled ? 'checkbox' : 'radio', selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))), }} columns={[ { title: '匹配打分', key: 'score', width: 100, render: (_, item) => { const score = computeConsignmentMatchScore(item) const tag = resolveScoreTag(item) return ( {score} {tag.label} ) }, }, { title: '匹配层级', key: 'priority', width: 105, render: (_, item) => item.priorityLabel ? ( {item.priorityLabel} ) : ( - ), }, { title: '寄卖单号', key: 'consignmentNo', width: 170, render: (_, item) => ( {item.consignmentNo || item.inventoryNo || '-'} ), }, { title: '寄卖库存', key: 'inventory', width: 210, render: (_, item) => ( {item.itemName} {item.inventoryNo} · {item.workerName || `打手#${item.workerId || '-'}`} ), }, { title: '规格', key: 'spec', width: 210, render: (_, item) => item.specText || `${item.system}/${item.serverZone}/${item.series}/${item.diamondLevel}钻/${item.brillianceCount}璀`, }, { title: '库存', key: 'stock', width: 90, render: (_, item) => ( 1 ? 'blue' : 'default'}> 剩 {item.remainingQuantity}/{item.quantity} ), }, { title: '售价', key: 'price', width: 100, render: (_, item) => money(item.salePriceFen), }, { title: '匹配说明', key: 'reason', width: 280, render: (_, item) => ( {describeConsignmentMatch(item)} {(item.matchDetails || []) .filter((detail) => detail.status !== 'match' && detail.status !== 'ignored') .slice(0, 3) .map((detail) => ( {detail.label}:订单「{detail.orderValue || '-'}」/ 库存「 {detail.inventoryValue || '-'}」 ))} ), }, ]} /> {selectedIds.length > 0 && !coverage.sufficient ? ( ) : null} {nonExactSelected ? ( setNonExactReason(event.target.value)} placeholder="例如:订单规格未写全,客服已与买家确认可用该库存" /> ) : null} 已选 {selectedIds.length} 条,覆盖 {coverage.selectedQuantity}{' '} {sharingEnabled ? '份' : '件'} ) : null} ) }