fix(consignment): 待匹配看板同分按系列/钻石/璀璨优先排序
- 同分时按 系列 → 钻石 → 璀璨 的字段命中状态分层,避免枪型冲突的组合压过枪型相同的高配组合 - 前端不再按 score 二次重排,保留后端的优先级顺序 - 补充同分排序单元测试,分数口径保持不变
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user