- 新增 consignment-category-scope 作为分类范围单一事实来源(自身 + 祖先 + 子孙,二级分类对祖先库存按系列收窄) - 寄卖库存列表/统计/筛选项按分类范围取数,二级分类(血宴/暗潮/怒嚎)可正常进入下拉并看到挂在一级分类下的货 - 一键查询待匹配看板:订单侧与库存侧都改为分类范围过滤,二级分类页签不再空白 - 手动匹配候选与提交校验统一按分类家族取数(工单分类 + 其一级分类),跨枪型仍不参与自动匹配,人工强制匹配需填原因 - 仓库搜索关键字增加系列、规格文本与当前/上级分类名,可直接搜「血宴」「苏尔南手枪」 - 新增分类范围单测,并补充看板跨层级用例
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
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<number[]>([])
|
|
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 (
|
|
<Modal
|
|
title="手动匹配寄卖"
|
|
open={open}
|
|
onCancel={onClose}
|
|
width="min(1180px, 96vw)"
|
|
destroyOnHidden
|
|
footer={
|
|
<Space>
|
|
<Button onClick={onClose}>取消</Button>
|
|
<Button
|
|
type="primary"
|
|
loading={submitting}
|
|
disabled={!canSubmit}
|
|
onClick={async () => {
|
|
if (!workOrderId) return
|
|
setSubmitting(true)
|
|
try {
|
|
const result = await matchAdminConsignmentWorkOrder({
|
|
workOrderId,
|
|
inventoryIds: selectedIds,
|
|
allowNonExact: nonExactSelected,
|
|
reason: nonExactSelected ? nonExactReason.trim() : undefined,
|
|
})
|
|
message.success(
|
|
result.data?.matched
|
|
? `已匹配 ${result.data.matchedCount || selectedIds.length} 条寄卖库存`
|
|
: '未匹配到可用库存',
|
|
)
|
|
onMatched?.()
|
|
onClose()
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '匹配失败')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}}
|
|
>
|
|
确认匹配
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
{candidatesQuery.isLoading ? <Card loading /> : null}
|
|
{candidatesQuery.isError ? (
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message="读取可匹配寄卖库存失败"
|
|
description={
|
|
candidatesQuery.error instanceof Error ? candidatesQuery.error.message : '请稍后重试'
|
|
}
|
|
action={
|
|
<Button size="small" onClick={() => void candidatesQuery.refetch()}>
|
|
重试
|
|
</Button>
|
|
}
|
|
/>
|
|
) : null}
|
|
{!candidatesQuery.isLoading && !candidatesQuery.isError ? (
|
|
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 12 }}>
|
|
<Space wrap size={8}>
|
|
<Input.Search
|
|
allowClear
|
|
placeholder="搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手"
|
|
style={{ width: 320 }}
|
|
value={keyword}
|
|
onChange={(event) => setKeyword(event.target.value)}
|
|
/>
|
|
<Typography.Text type="secondary">
|
|
显示 {visibleOptions.length} / 候选 {options.length} 条
|
|
</Typography.Text>
|
|
</Space>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
候选范围:本工单分类及其上级分类下的全部在库寄卖单(跨枪型会标为不匹配,需填原因才能强制匹配)。
|
|
</Typography.Text>
|
|
</Space>
|
|
) : null}
|
|
{!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0 ? (
|
|
<Empty description="没有同配置的在库寄卖库存" />
|
|
) : null}
|
|
{!candidatesQuery.isLoading &&
|
|
!candidatesQuery.isError &&
|
|
options.length > 0 &&
|
|
visibleOptions.length === 0 ? (
|
|
<Empty
|
|
description={
|
|
keyword.trim()
|
|
? '没有匹配该关键词的库存,换个寄卖单号/库存号/物品/打手试试'
|
|
: '没有可展示的库存'
|
|
}
|
|
/>
|
|
) : null}
|
|
{!candidatesQuery.isError && visibleOptions.length > 0 ? (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
{!matchable ? (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message={blockedReason}
|
|
description="当前仍可搜索和核对库存;补充资料/重新发布到待分配或待抢单后才能提交匹配。"
|
|
/>
|
|
) : null}
|
|
{data?.matchingMode === 'off' ? (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message="该分类已关闭自动匹配"
|
|
description="关闭自动匹配不会禁止人工操作:本次手动匹配仍会直接占用寄卖现货并写入匹配留痕,请确认库存与订单规格后再提交。"
|
|
/>
|
|
) : null}
|
|
<Alert
|
|
type={remainingNeeded <= 0 ? 'warning' : 'info'}
|
|
showIcon
|
|
message={infoMessage}
|
|
description="默认按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之,低配靠后;非精确/冲突必须填写人工原因;拼单必须一次拼满,失败不会占用任何库存。"
|
|
/>
|
|
<Space wrap size={8}>
|
|
<Tag color="blue">
|
|
{sharingEnabled ? `待匹配 ${remainingNeeded} 份` : `整单 ${totalQuantity} 件`}
|
|
</Tag>
|
|
{sharingEnabled ? <Tag>已匹配 {activeQuantity} 份</Tag> : null}
|
|
<Tag color="green">精确 {counts.exact}</Tag>
|
|
<Tag color="orange">非精确 {counts.partial}</Tag>
|
|
<Tag>冲突 {counts.conflict}</Tag>
|
|
</Space>
|
|
<PercentTable<ConsignmentMatchCandidate>
|
|
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 (
|
|
<Space direction="vertical" size={3} style={{ width: '100%' }}>
|
|
<span style={{ fontSize: 18, fontWeight: 700, lineHeight: 1 }}>{score}</span>
|
|
<Tag color={tag.color} style={{ marginInlineEnd: 0 }}>
|
|
{tag.label}
|
|
</Tag>
|
|
</Space>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: '匹配层级',
|
|
key: 'priority',
|
|
width: 105,
|
|
render: (_, item) =>
|
|
item.priorityLabel ? (
|
|
<Tag
|
|
color={resolveConsignmentPriorityTagColor(item.priorityLabel)}
|
|
style={{ marginInlineEnd: 0 }}
|
|
>
|
|
{item.priorityLabel}
|
|
</Tag>
|
|
) : (
|
|
<Typography.Text type="secondary">-</Typography.Text>
|
|
),
|
|
},
|
|
{
|
|
title: '寄卖单号',
|
|
key: 'consignmentNo',
|
|
width: 170,
|
|
render: (_, item) => (
|
|
<Typography.Text
|
|
copyable={{ text: item.consignmentNo || item.inventoryNo }}
|
|
style={{ fontSize: 12 }}
|
|
>
|
|
{item.consignmentNo || item.inventoryNo || '-'}
|
|
</Typography.Text>
|
|
),
|
|
},
|
|
{
|
|
title: '寄卖库存',
|
|
key: 'inventory',
|
|
width: 210,
|
|
render: (_, item) => (
|
|
<Space direction="vertical" size={0}>
|
|
<Typography.Text strong>{item.itemName}</Typography.Text>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
{item.inventoryNo} · {item.workerName || `打手#${item.workerId || '-'}`}
|
|
</Typography.Text>
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
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) => (
|
|
<Tag color={item.remainingQuantity > 1 ? 'blue' : 'default'}>
|
|
剩 {item.remainingQuantity}/{item.quantity}
|
|
</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '售价',
|
|
key: 'price',
|
|
width: 100,
|
|
render: (_, item) => money(item.salePriceFen),
|
|
},
|
|
{
|
|
title: '匹配说明',
|
|
key: 'reason',
|
|
width: 280,
|
|
render: (_, item) => (
|
|
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
|
<Typography.Text style={{ fontSize: 12 }}>
|
|
{describeConsignmentMatch(item)}
|
|
</Typography.Text>
|
|
{(item.matchDetails || [])
|
|
.filter((detail) => detail.status !== 'match' && detail.status !== 'ignored')
|
|
.slice(0, 3)
|
|
.map((detail) => (
|
|
<Typography.Text key={detail.key} type="secondary" style={{ fontSize: 11 }}>
|
|
{detail.label}:订单「{detail.orderValue || '-'}」/ 库存「
|
|
{detail.inventoryValue || '-'}」
|
|
</Typography.Text>
|
|
))}
|
|
</Space>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
{selectedIds.length > 0 && !coverage.sufficient ? (
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message={
|
|
sharingEnabled
|
|
? `还差 ${coverage.gap} 份,拼单必须一次拼满才能提交`
|
|
: `所选库存仅剩 ${coverage.selectedQuantity} 件,整单需要 ${coverage.requiredQuantity} 件`
|
|
}
|
|
/>
|
|
) : null}
|
|
{nonExactSelected ? (
|
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
|
<Alert
|
|
type="warning"
|
|
showIcon
|
|
message="已选择非精确库存:请确认规格差异,并填写人工匹配原因。"
|
|
/>
|
|
<Input.TextArea
|
|
rows={2}
|
|
value={nonExactReason}
|
|
onChange={(event) => setNonExactReason(event.target.value)}
|
|
placeholder="例如:订单规格未写全,客服已与买家确认可用该库存"
|
|
/>
|
|
</Space>
|
|
) : null}
|
|
<Typography.Text type="secondary">
|
|
已选 {selectedIds.length} 条,覆盖 {coverage.selectedQuantity}{' '}
|
|
{sharingEnabled ? '份' : '件'}
|
|
</Typography.Text>
|
|
</Space>
|
|
) : null}
|
|
</Modal>
|
|
)
|
|
}
|