feat(consignment): 苏尔南手枪按枪型/钻石/璀璨分层匹配并过滤跨枪型

排序:完美匹配 > 同钻高璀 > 高钻同璀 > 高钻高璀 > 低配;跨枪型不展示不参与;高配仍需填人工原因。
This commit is contained in:
2026-11-15 16:58:42 +08:00
parent 68fa2a575f
commit e2c27bc7a9
12 changed files with 877 additions and 127 deletions
@@ -10,6 +10,7 @@ import {
} from '@/services/admin/consignment'
import type { PendingPistolMatchPair } from '@/types/consignment'
import { formatDateTime } from '@/utils/date-time'
import { resolveConsignmentPriorityTagColor } from '@/utils/consignment-match-priority'
const { Text } = Typography
@@ -140,6 +141,14 @@ export default function ConsignmentInventoryMatchModal({
key: 'reason',
render: (_, record) => (
<Space direction="vertical" size={2}>
{record.priorityLabel ? (
<Tag
color={resolveConsignmentPriorityTagColor(record.priorityLabel)}
style={{ marginInlineEnd: 0, fontSize: 11 }}
>
{record.priorityLabel}
</Tag>
) : null}
<Text style={{ fontSize: 12 }}>{record.reason || '-'}</Text>
{record.conflicts.length > 0 ? (
<Text type="danger" style={{ fontSize: 11 }}>
@@ -205,7 +214,7 @@ export default function ConsignmentInventoryMatchModal({
type="info"
showIcon
message="按寄卖配置与数量判定候选工单;多数量库存会按剩余量扣减,不会被整批消耗。"
description="精确匹配可直接提交;非精确或规格冲突必须勾选强制匹配并填写原因。拼单工单必须一次拼满,单条库存不够时请到工单侧匹配。"
description="候选按 枪型 → 钻石 → 璀璨 排序,完美匹配在前;跨枪型不展示。精确匹配可直接提交;非精确或规格冲突必须勾选强制匹配并填写原因。拼单工单必须一次拼满,单条库存不够时请到工单侧匹配。"
/>
<Space wrap size={12}>
<Checkbox
@@ -8,6 +8,7 @@ import PercentTable from '@/components/common/PercentTable'
import { fetchPendingPistolMatches } from '@/services/admin/consignment'
import type { PendingPistolMatchPair } from '@/types/consignment'
import { formatDateTime } from '@/utils/date-time'
import { resolveConsignmentPriorityTagColor } from '@/utils/consignment-match-priority'
import ConsignmentWorkOrderMatchModal from './ConsignmentWorkOrderMatchModal'
@@ -221,6 +222,14 @@ export default function ConsignmentPendingMatchModal({
width: 300,
render: (_, record) => (
<Space direction="vertical" size={2}>
{record.priorityLabel ? (
<Tag
color={resolveConsignmentPriorityTagColor(record.priorityLabel)}
style={{ marginInlineEnd: 0, fontSize: 11 }}
>
{record.priorityLabel}
</Tag>
) : null}
<Text style={{ fontSize: 12 }}>{record.reason || '-'}</Text>
{record.conflicts.length > 0 ? (
<Text type="danger" style={{ fontSize: 11 }}>
@@ -289,8 +298,8 @@ export default function ConsignmentPendingMatchModal({
}
description={
canMatch
? '默认展示全部配对(含不匹配项),按打分从高到低排列;点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
: '默认展示全部配对(含不匹配项),按打分从高到低排列;当前账号没有履约权限,只能查看。'
? '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之;跨枪型不展示。点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
: '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序;当前账号没有履约权限,只能查看。'
}
/>
@@ -13,6 +13,7 @@ import {
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)}`
@@ -93,16 +94,11 @@ export default function ConsignmentWorkOrderMatchModal({
: totalQuantity
const normalizedKeyword = keyword.trim().toLowerCase()
// 默认展示全部候选,按匹配打分降序;关键词只做过滤,不改变排序口径。
// 后端已按「枪型 → 钻石 → 璀璨」排好序;这里只做关键词过滤,必须保持原顺序。
const visibleOptions = useMemo(() => {
const filtered = normalizedKeyword
return normalizedKeyword
? options.filter((item) => buildCandidateHaystack(item).includes(normalizedKeyword))
: options
return [...filtered].sort((left, right) => {
const scoreDiff = computeConsignmentMatchScore(right) - computeConsignmentMatchScore(left)
if (scoreDiff !== 0) return scoreDiff
return Number(right.remainingQuantity || 0) - Number(left.remainingQuantity || 0)
})
}, [normalizedKeyword, options])
const selectedOptions = useMemo(
@@ -314,7 +310,7 @@ export default function ConsignmentWorkOrderMatchModal({
type={remainingNeeded <= 0 ? 'warning' : 'info'}
showIcon
message={infoMessage}
description="默认按匹配打分从高到低展示全部候选;非精确/冲突必须填写人工原因;拼单必须一次拼满,失败不会占用任何库存。"
description="默认按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之,低配靠后;非精确/冲突必须填写人工原因;拼单必须一次拼满,失败不会占用任何库存。"
/>
<Space wrap size={8}>
<Tag color="blue">
@@ -356,6 +352,22 @@ export default function ConsignmentWorkOrderMatchModal({
)
},
},
{
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',
+12
View File
@@ -349,6 +349,12 @@ export type PendingPistolMatchPair = {
score: number
reason: string
conflicts: string[]
/** 枪型 → 钻石 → 璀璨 的排序层级文案 */
priorityLabel?: string
seriesMatched?: boolean
seriesSpecified?: boolean
diamondGap?: number
brillianceGap?: number
/** 自定义规格逐字段对比;内置五字段为空,沿用旧评分原因 */
matchDetails?: Array<{
key: string
@@ -560,6 +566,12 @@ export type ConsignmentMatchCandidate = {
specText?: string
matchType?: 'exact' | 'partial' | 'conflict' | 'unknown'
matchReasons?: string[]
/** 枪型 → 钻石 → 璀璨 的排序层级文案 */
priorityLabel?: string
seriesMatched?: boolean | null
seriesSpecified?: boolean
diamondGap?: number | null
brillianceGap?: number | null
matchDetails?: Array<{
key: string
label: string
@@ -0,0 +1,14 @@
/** 匹配层级标签颜色:完美绿、高配蓝系、低配橙、冲突/缺失红。 */
export function resolveConsignmentPriorityTagColor(label: string | null | undefined): string {
const value = String(label || '').trim()
if (!value) return 'default'
if (value === '完美匹配') return 'green'
if (value === '同钻高璀') return 'blue'
if (value === '高钻同璀') return 'cyan'
if (value === '高钻高璀') return 'geekblue'
if (value === '低配') return 'orange'
if (value.includes('冲突') || value.includes('缺失') || value.includes('不符')) {
return 'red'
}
return 'default'
}
@@ -73,17 +73,17 @@ test('后端:手动候选、手动复检、待匹配看板都使用手动口
assert.ok(pending.includes("matchScope: 'manual'"), '一键查询待匹配订单必须按手动口径')
})
test('内部库存匹配弹窗:默认展示全部候选,按打分降序,并显示寄卖单号', () => {
test('内部库存匹配弹窗:默认展示全部候选,按枪型/钻石/璀璨排序,并显示寄卖单号', () => {
const source = readSource('../src/components/admin/ConsignmentWorkOrderMatchModal.tsx')
assert.ok(source.includes('默认按匹配打分从高到低展示全部候选'), '默认展示口径必须在界面上说明')
assert.ok(source.includes('默认按 枪型 → 钻石 → 璀璨 排序'), '默认展示口径必须在界面上说明')
assert.ok(
!source.includes("if (!searchable) return (item.matchType || 'exact') === 'exact'"),
'不能再默认只过滤精确匹配',
)
assert.ok(
source.includes('computeConsignmentMatchScore(right) - computeConsignmentMatchScore(left)'),
'列表必须按匹配打分降序排列',
!source.includes('computeConsignmentMatchScore(right) - computeConsignmentMatchScore(left)'),
'列表必须保持后端排序,不能再按打分重排',
)
assert.ok(
source.includes('buildCandidateHaystack(item).includes(normalizedKeyword)'),
@@ -42,15 +42,15 @@ test('一键查询待匹配订单:说明文案不再声称「不直接改单
assert.ok(source.includes('「手动匹配方式」') || source.includes('手动匹配'))
})
test('一键查询待匹配订单:默认展示全部配对并按打分降序', () => {
test('一键查询待匹配订单:默认展示全部配对并按枪型/钻石/璀璨排序', () => {
const source = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
assert.ok(source.includes('includeConflicts: true'), '默认必须包含冲突(0 分)配对')
assert.ok(!source.includes('显示 0 分不匹配项'), '旧的勾选框必须删除')
assert.ok(
!source.includes('right.score - left.score'),
'不得在前端只按分数重排,否则会覆盖后端的「系列/钻石/璀璨」优先级',
'不得在前端只按分数重排,否则会覆盖后端的「枪型/钻石/璀璨」优先级',
)
assert.ok(source.includes('不匹配 {data?.conflictCount ?? 0}'), '必须展示不匹配数量')
assert.ok(source.includes('默认展示全部配对(含不匹配项)'), '界面必须说明默认口径')
assert.ok(source.includes('默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序'), '界面必须说明默认口径')
})