fix(consignment): 待匹配看板同分按系列/钻石/璀璨优先排序

- 同分时按 系列 → 钻石 → 璀璨 的字段命中状态分层,避免枪型冲突的组合压过枪型相同的高配组合
- 前端不再按 score 二次重排,保留后端的优先级顺序
- 补充同分排序单元测试,分数口径保持不变
This commit is contained in:
2026-11-15 13:50:09 +08:00
parent 2f03b96634
commit a3462605c7
4 changed files with 161 additions and 6 deletions
@@ -241,6 +241,126 @@ test('匹配看板:按分数降序,同分新工单优先', () => {
)
})
const BUILT_IN_SPECS = [
['system', '操作系统', 10],
['serverZone', '游戏区服', 20],
['series', '系列 / 枪系', 30],
['diamondLevel', '钻石等级', 40],
['brillianceCount', '璀璨数', 50],
].map(([key, label, sortOrder]) => ({
key: String(key),
label: String(label),
type: 'radio' as const,
required: true,
options: [],
placeholder: '',
helpText: '',
matchMode: 'exact' as const,
// 区服「互通」在生产配置里是通配值,这里必须保持一致,否则会多算一项冲突。
wildcardValues: key === 'serverZone' ? ['互通'] : [],
showInItemName: true,
sortOrder: Number(sortOrder),
}))
test('看板新口径:同分时按 系列 → 钻石 → 璀璨 优先,枪型相同的必须排前面', () => {
const profile = buildProfile({
config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: BUILT_IN_SPECS },
})
const order = buildOrder({
id: 301,
product_name: '安卓血宴3钻带1璀璨',
material_json: JSON.stringify({ collect: { fields: { serverZone: '安卓' } } }),
})
// 枪型相同、仅钻级更高(库存 5 钻 vs 订单 3 钻):系列/璀璨命中。
const seriesMatched = buildInventory({
id: 401,
category_id: profile.categoryId,
item_name: '安卓血宴5钻1璀璨 苏尔南',
system: '安卓',
server_zone: '互通',
series: '血宴',
diamond_level: 5,
brilliance_count: 1,
inbound_at: '2026-11-05T09:00:00.000Z',
})
// 枪型不同,但钻级/璀璨都命中:与上一条同分。
const seriesConflicted = buildInventory({
id: 402,
category_id: profile.categoryId,
item_name: '安卓怒嚎3钻1璀璨 苏尔南',
system: '安卓',
server_zone: '互通',
series: '怒嚎',
diamond_level: 3,
brilliance_count: 1,
inbound_at: '2026-11-01T09:00:00.000Z',
})
const result = buildPendingConsignmentMatchPairs({
orders: [order],
// 故意把「枪型冲突」的库存放在前面,验证排序而不是输入顺序。
inventories: [seriesConflicted, seriesMatched],
profileByOrderId: new Map([[301, profile]]),
includeConflicts: true,
})
assert.deepEqual(
result.pairs.map((pair) => [pair.inventoryId, pair.score]),
[
[401, 80],
[402, 80],
],
)
})
test('看板新口径:系列都相同时,钻石相同优先于仅璀璨冲突', () => {
const profile = buildProfile({
config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: BUILT_IN_SPECS },
})
const order = buildOrder({
id: 302,
product_name: '安卓血宴3钻带1璀璨',
material_json: JSON.stringify({ collect: { fields: { serverZone: '安卓' } } }),
})
const diamondMatched = buildInventory({
id: 403,
category_id: profile.categoryId,
item_name: '安卓血宴3钻3璀璨 苏尔南',
system: '安卓',
server_zone: '互通',
series: '血宴',
diamond_level: 3,
brilliance_count: 3,
inbound_at: '2026-11-05T09:00:00.000Z',
})
const diamondConflicted = buildInventory({
id: 404,
category_id: profile.categoryId,
item_name: '安卓血宴5钻1璀璨 苏尔南',
system: '安卓',
server_zone: '互通',
series: '血宴',
diamond_level: 5,
brilliance_count: 1,
inbound_at: '2026-11-01T09:00:00.000Z',
})
const result = buildPendingConsignmentMatchPairs({
orders: [order],
inventories: [diamondConflicted, diamondMatched],
profileByOrderId: new Map([[302, profile]]),
includeConflicts: true,
})
assert.deepEqual(
result.pairs.map((pair) => [pair.inventoryId, pair.score]),
[
[403, 80],
[404, 80],
],
)
})
test('匹配看板:关键字同时搜索工单号与寄售单号', () => {
const orders = [buildOrder({ id: 1, work_order_no: 'WK001' })]
const inventories = [
@@ -250,6 +250,31 @@ function scoreFromDetails(details: PendingConsignmentMatchDetail[]): number {
return Math.round((matched / effective.length) * 100)
}
/**
* 同分排序的规格优先级:系列(枪型)> 钻石等级 > 璀璨数。
*
* 等权评分下「系列冲突」和「璀璨冲突」都是 80 分,若不在这里分层,
* 同分就只能按工单 ID 排序,枪型不匹配的组合会压过枪型相同的高配组合。
*/
const SPEC_PRIORITY_KEYS = ['series', 'diamondLevel', 'brillianceCount'] as const
/** 字段状态的优先级:命中 > 高配(partial)> 订单未写 > 冲突。 */
const SPEC_STATUS_RANK: Record<string, number> = {
match: 4,
partial: 3,
unknown: 2,
conflict: 1,
ignored: 0,
}
/** 生成可逐项比较的优先级键;缺少明细(旧评分兜底)时全为 0。 */
function buildSpecPriorityKey(details: PendingConsignmentMatchDetail[]): number[] {
return SPEC_PRIORITY_KEYS.map((key) => {
const detail = details.find((item) => item.key === key)
return detail ? (SPEC_STATUS_RANK[detail.status] ?? 0) : 0
})
}
function buildEvaluationReason(evaluation: ReturnType<typeof evaluateInventoryMatch>): string {
if (evaluation.exact) return '规格完全一致'
const parts = [...evaluation.conflicts, ...evaluation.unknowns.map((item) => `订单缺少:${item}`)]
@@ -540,6 +565,15 @@ export function buildPendingConsignmentMatchPairs(input: {
pairs.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score
// 同分时先比关键规格:系列(枪型)> 钻石等级 > 璀璨数。
// 等权评分下「系列冲突」与「璀璨冲突」都是 80 分,不分层就会按工单 ID 排序,
// 导致枪型不匹配的组合压过「枪型相同、仅璀璨/钻级有差」的高配组合。
const leftPriority = buildSpecPriorityKey(left.matchDetails)
const rightPriority = buildSpecPriorityKey(right.matchDetails)
for (let index = 0; index < leftPriority.length; index += 1) {
const diff = Number(rightPriority[index] || 0) - Number(leftPriority[index] || 0)
if (diff !== 0) return diff
}
if (right.workOrderId !== left.workOrderId) {
return right.workOrderId - left.workOrderId
}
@@ -72,11 +72,9 @@ export default function ConsignmentPendingMatchModal({
retry: false,
})
const data = query.data?.data
// 后端已按分数降序;这里再按打分排一次,保证调整排序口径时界面始终一致。
const pairs = useMemo(
() => [...(data?.pairs || [])].sort((left, right) => right.score - left.score),
[data?.pairs],
)
// 后端已按「分数 + 系列/钻石/璀璨优先级」排好序;这里必须保持原顺序,
// 否则同分项会被重排回默认序,枪型匹配的候选反而沉底。
const pairs = useMemo(() => data?.pairs || [], [data?.pairs])
const columns: TableColumnsType<PendingPistolMatchPair> = [
{
@@ -47,7 +47,10 @@ test('一键查询待匹配订单:默认展示全部配对并按打分降序',
assert.ok(source.includes('includeConflicts: true'), '默认必须包含冲突(0 分)配对')
assert.ok(!source.includes('显示 0 分不匹配项'), '旧的勾选框必须删除')
assert.ok(source.includes('right.score - left.score'), '配对必须按打分从高到低排列')
assert.ok(
!source.includes('right.score - left.score'),
'不得在前端只按分数重排,否则会覆盖后端的「系列/钻石/璀璨」优先级',
)
assert.ok(source.includes('不匹配 {data?.conflictCount ?? 0}'), '必须展示不匹配数量')
assert.ok(source.includes('默认展示全部配对(含不匹配项)'), '界面必须说明默认口径')
})