diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts index cb72d7bf..bed12a59 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts @@ -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 = [ diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts index 2878d12c..9a2b905b 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -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 = { + 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): 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 } diff --git a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx index a13e25a2..94eca532 100644 --- a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx @@ -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 = [ { diff --git a/apps/frontend/tests/consignment-pending-match-select.test.ts b/apps/frontend/tests/consignment-pending-match-select.test.ts index 1e601791..1dd82c31 100644 --- a/apps/frontend/tests/consignment-pending-match-select.test.ts +++ b/apps/frontend/tests/consignment-pending-match-select.test.ts @@ -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('默认展示全部配对(含不匹配项)'), '界面必须说明默认口径') })