diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 49c676ab..7af36cd3 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -31,11 +31,20 @@ import { assertWorkOrderHallPriceReadyWithClient } from './work-order-hall-prici import { evaluateConsignmentSpecMatch, isBuiltInConsignmentSpecField, - parseConsignmentSpecValues, resolveScopedConsignmentSpecs, type ConsignmentSpecField, type ConsignmentSpecValues, } from './consignment-spec-service.js' +import { + compareConsignmentMatchPriority, + isCrossSeriesInventory, + isSuernanPistolPriorityApplicable, + resolveConsignmentInventorySpecValues, + resolveConsignmentMatchPriorityFromSpecs, + resolveOrderSeriesInfo, + type ConsignmentMatchPriority, + type ConsignmentOrderSeriesInfo, +} from './consignment-sort-priority.js' import { classifyConsignmentMatch, detectItemCategory, @@ -206,10 +215,22 @@ export function isRematchExcludedInventory( } function compareInventoryCandidates( - a: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation }, - b: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation }, + a: { + inventory: ConsignmentInventoryRow + evaluation: InventoryMatchEvaluation + priority?: ConsignmentMatchPriority | null + }, + b: { + inventory: ConsignmentInventoryRow + evaluation: InventoryMatchEvaluation + priority?: ConsignmentMatchPriority | null + }, preferLowReward: boolean, ): number { + if (a.priority && b.priority) { + const priorityDiff = compareConsignmentMatchPriority(a.priority, b.priority) + if (priorityDiff !== 0) return priorityDiff + } if (a.evaluation.exact !== b.evaluation.exact) return a.evaluation.exact ? -1 : 1 const listedA = Date.parse(String(a.inventory.listed_at || a.inventory.inbound_at || '')) || 0 const listedB = Date.parse(String(b.inventory.listed_at || b.inventory.inbound_at || '')) || 0 @@ -250,6 +271,7 @@ export function resolveMatchType( type ConsignmentMatchInventoryCandidate = { inventory: ConsignmentInventoryRow evaluation: InventoryMatchEvaluation + priority?: ConsignmentMatchPriority | null } type ConsignmentMatchContext = { @@ -276,18 +298,6 @@ function resolveInventorySpecs( return config?.specs ?? [] } -function resolveInventorySpecValues(inventory: ConsignmentInventoryRow): ConsignmentSpecValues { - const parsed = parseConsignmentSpecValues(inventory.spec_values_json) - if (Object.keys(parsed).length > 0) return parsed - return { - system: inventory.system, - serverZone: inventory.server_zone, - series: inventory.series, - diamondLevel: Number(inventory.diamond_level || 1), - brillianceCount: Number(inventory.brilliance_count || 0), - } -} - function resolveOrderSpecValues(workOrder: WorkOrderRow): ConsignmentSpecValues { const fields = resolveCollectSubmittedFieldValues(workOrder) const specs = extractPeacekeeperSpecs(workOrder.product_name, { @@ -304,6 +314,14 @@ function resolveOrderSpecValues(workOrder: WorkOrderRow): ConsignmentSpecValues if (specs.series) values.series = specs.series if (Number(specs.diamondLevel || 0) > 0) values.diamondLevel = Number(specs.diamondLevel) values.brillianceCount = Number(specs.brillianceCount || 0) + // 枪型以二级分类优先;标题同时出现多个枪型时视为「枪型不限」, + // 不能沿用旧解析器固定取第一个命中的「暗潮」。 + const seriesInfo = resolveOrderSeriesInfo({ + categoryId: workOrder.category_id, + productName: workOrder.product_name, + parsedSeries: String(values.series || ''), + }) + values.series = seriesInfo.series return values } @@ -330,7 +348,7 @@ export function evaluateInventoryMatch( options.orderSpecOverride !== undefined && options.orderSpecOverride !== null ? { ...options.orderSpecOverride } : resolveOrderSpecValues(workOrder) - const values = resolveInventorySpecValues(inventory) + const values = resolveConsignmentInventorySpecValues(inventory) if (scopedSpecs.length === 0) { // 没有任何配置字段:沿用旧苏尔南匹配器兜底,避免无条件放行全部库存。 @@ -388,6 +406,43 @@ export function evaluateInventoryMatch( } } +/** + * 候选排序优先级:只对苏尔南手枪(配置含枪型/钻石/璀璨)或旧手枪兜底生效; + * 其它品类返回 null,沿用 exact → 低价/入库的旧排序。 + */ +function resolveCandidatePriority( + workOrder: WorkOrderRow, + inventory: ConsignmentInventoryRow, + evaluation: InventoryMatchEvaluation, + orderSeriesInfo: ConsignmentOrderSeriesInfo, + profile: ConsignmentProfileSnapshot | null, +): ConsignmentMatchPriority | null { + const applicable = + isSuernanPistolPriorityApplicable(evaluation.specs) || + (profile === null && isLegacyPistolPair(workOrder, inventory)) + if (!applicable) return null + return resolveConsignmentMatchPriorityFromSpecs({ + orderValues: evaluation.orderValues, + inventoryValues: evaluation.values, + orderSeriesInfo, + details: evaluation.details, + exact: evaluation.exact, + }) +} + +function isLegacyPistolPair(workOrder: WorkOrderRow, inventory: ConsignmentInventoryRow): boolean { + const orderText = String(workOrder.product_name || '') + const inventoryText = [ + inventory.item_name, + inventory.series || '', + inventory.category_name || '', + ].join(' ') + return ( + detectItemCategory(orderText) === 'pistol_suernan' && + detectItemCategory(inventoryText) === 'pistol_suernan' + ) +} + /** * 与旧手枪匹配器一致的品类隔离:订单与库存都能识别品类且不一致时拒绝匹配。 * 配置字段只能放宽规格,不能跨越商品品类。 @@ -845,17 +900,41 @@ function pickCandidateInventories( ): ConsignmentMatchInventoryCandidate[] { const allowPartial = Boolean(options.allowPartial) const preferLowReward = options.preferLowReward !== false + const orderSpecOverride = options.orderSpecOverride ?? null + const orderValues = orderSpecOverride ?? resolveOrderSpecValues(workOrder) + const orderSeriesInfo = resolveOrderSeriesInfo({ + categoryId: workOrder.category_id, + productName: workOrder.product_name, + parsedSeries: String(orderValues.series || ''), + }) return inventories .filter((inventory) => inventoryMatchesProfile(inventory, profile)) .filter((inventory) => Number(inventory.remaining_quantity ?? 1) > 0) .filter((inventory) => !isRematchExcludedInventory(workOrder, inventory)) - .map((inventory) => ({ - inventory, - evaluation: evaluateInventoryMatch(workOrder, inventory, profile, { - orderSpecOverride: options.orderSpecOverride ?? null, + .filter( + (inventory) => + !isCrossSeriesInventory( + orderSeriesInfo, + String(resolveConsignmentInventorySpecValues(inventory).series || ''), + ), + ) + .map((inventory) => { + const evaluation = evaluateInventoryMatch(workOrder, inventory, profile, { + orderSpecOverride, matchScope: 'auto', - }), - })) + }) + return { + inventory, + evaluation, + priority: resolveCandidatePriority( + workOrder, + inventory, + evaluation, + orderSeriesInfo, + profile, + ), + } + }) .filter(({ evaluation }) => { if (evaluation.exact) return true if (!allowPartial || !evaluation.partial || evaluation.conflicts.length > 0) return false @@ -1485,27 +1564,52 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu const availability = resolveManualMatchAvailability(workOrder.status) const profile = await resolveProfileForWorkOrder(workOrder) const orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile) - const inventories = (await listActiveConsignmentInventoriesForMatching()).filter( - (inventory) => - inventoryMatchesProfile(inventory, profile) && - Number(inventory.remaining_quantity ?? 1) > 0 && - inventory.status === 'in_stock' && - inventory.allow_matching && - !isRematchExcludedInventory(workOrder, inventory), - ) + const orderValues = orderSpecOverride ?? resolveOrderSpecValues(workOrder) + const orderSeriesInfo = resolveOrderSeriesInfo({ + categoryId: workOrder.category_id, + productName: workOrder.product_name, + parsedSeries: String(orderValues.series || ''), + }) + const inventories = (await listActiveConsignmentInventoriesForMatching()) + .filter( + (inventory) => + inventoryMatchesProfile(inventory, profile) && + Number(inventory.remaining_quantity ?? 1) > 0 && + inventory.status === 'in_stock' && + inventory.allow_matching && + !isRematchExcludedInventory(workOrder, inventory), + ) + // 跨枪型不展示、不参与匹配:暗潮单看不到怒嚎/血宴库存,反之亦然。 + .filter( + (inventory) => + !isCrossSeriesInventory( + orderSeriesInfo, + String(resolveConsignmentInventorySpecValues(inventory).series || ''), + ), + ) const evaluated = inventories - .map((inventory) => ({ - inventory, - evaluation: evaluateInventoryMatch(workOrder, inventory, profile, { + .map((inventory) => { + const evaluation = evaluateInventoryMatch(workOrder, inventory, profile, { orderSpecOverride, matchScope: 'manual', - }), - })) + }) + return { + inventory, + evaluation, + priority: resolveCandidatePriority( + workOrder, + inventory, + evaluation, + orderSeriesInfo, + profile, + ), + } + }) .sort((a, b) => compareInventoryCandidates(a, b, profile?.config.matching.preferLowReward !== false), ) - const options = evaluated.map(({ inventory, evaluation }) => ({ + const options = evaluated.map(({ inventory, evaluation, priority }) => ({ inventoryId: Number(inventory.id), inventoryNo: inventory.inventory_no, /** 寄卖单号口径:提报单号优先,客服手工入库的现货回退库存号 */ @@ -1529,6 +1633,11 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu : 'unknown', matchReasons: [...evaluation.conflicts, ...evaluation.unknowns], matchDetails: evaluation.details, + priorityLabel: priority?.priorityLabel ?? '', + seriesMatched: priority?.seriesMatched ?? null, + seriesSpecified: orderSeriesInfo.specified, + diamondGap: priority?.diamondGap ?? null, + brillianceGap: priority?.brillianceGap ?? null, quantity: Number(inventory.quantity || 1), remainingQuantity: Number(inventory.remaining_quantity ?? 1), salePriceFen: inventorySalePriceFen(inventory), @@ -1643,12 +1752,27 @@ async function runManualConsignmentMatch( const profile = resolvedProfile === undefined ? await resolveProfileForWorkOrder(workOrder) : resolvedProfile const orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile) - const inventories = (await listActiveConsignmentInventoriesForMatching()).filter( - (inventory) => - inventoryMatchesProfile(inventory, profile) && - Number(inventory.remaining_quantity ?? 1) > 0 && - !isRematchExcludedInventory(workOrder, inventory), - ) + const orderValues = orderSpecOverride ?? resolveOrderSpecValues(workOrder) + const orderSeriesInfo = resolveOrderSeriesInfo({ + categoryId: workOrder.category_id, + productName: workOrder.product_name, + parsedSeries: String(orderValues.series || ''), + }) + const inventories = (await listActiveConsignmentInventoriesForMatching()) + .filter( + (inventory) => + inventoryMatchesProfile(inventory, profile) && + Number(inventory.remaining_quantity ?? 1) > 0 && + !isRematchExcludedInventory(workOrder, inventory), + ) + // 接口兜底:即使前端绕过候选列表直接传库存 id,也不允许跨枪型落库。 + .filter( + (inventory) => + !isCrossSeriesInventory( + orderSeriesInfo, + String(resolveConsignmentInventorySpecValues(inventory).series || ''), + ), + ) const inventoryIds = [...new Set(input.inventoryIds.map((id) => Number(id)))] const selected = inventoryIds .map((id) => inventories.find((inventory) => Number(inventory.id) === id)) 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 0cee92b5..d1a096be 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 @@ -262,7 +262,7 @@ const BUILT_IN_SPECS = [ sortOrder: Number(sortOrder), })) -test('看板新口径:同分且璀璨相同时,按 系列 → 钻石 收敛', () => { +test('看板新口径:跨枪型不展示,只保留同枪型高配', () => { const profile = buildProfile({ config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: BUILT_IN_SPECS }, }) @@ -283,7 +283,7 @@ test('看板新口径:同分且璀璨相同时,按 系列 → 钻石 收敛' brilliance_count: 1, inbound_at: '2026-11-05T09:00:00.000Z', }) - // 枪型不同,但钻级/璀璨都命中:与上一条同分。 + // 枪型不同,即使钻级/璀璨都命中,也不展示。 const seriesConflicted = buildInventory({ id: 402, category_id: profile.categoryId, @@ -298,22 +298,18 @@ test('看板新口径:同分且璀璨相同时,按 系列 → 钻石 收敛' 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], - ], + result.pairs.map((pair) => [pair.inventoryId, pair.priorityLabel]), + [[401, '高钻同璀']], ) }) -test('看板新口径:同分时璀璨不同优先于璀璨相同(即使钻石冲突)', () => { +test('看板新口径:同钻高璀优先于高钻同璀', () => { const profile = buildProfile({ config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: BUILT_IN_SPECS }, }) @@ -361,7 +357,7 @@ test('看板新口径:同分时璀璨不同优先于璀璨相同(即使钻 ) }) -test('看板新口径:同分时璀璨不同的组合必须排在璀璨相同的组合之前', () => { +test('看板新口径:同钻高璀排在低配之前', () => { const profile = buildProfile({ config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: BUILT_IN_SPECS }, }) @@ -370,8 +366,8 @@ test('看板新口径:同分时璀璨不同的组合必须排在璀璨相同 product_name: '安卓血宴3钻带1璀璨', material_json: JSON.stringify({ collect: { fields: { serverZone: '安卓' } } }), }) - // 璀璨不同(订单 1 / 库存 2),但枪型、钻级都相同。 - const brillianceDiff = buildInventory({ + // 同钻高璀:订单 1 璀,库存 2 璀。 + const brillianceHigher = buildInventory({ id: 405, category_id: profile.categoryId, item_name: '安卓血宴3钻2璀璨 苏尔南', @@ -382,32 +378,32 @@ test('看板新口径:同分时璀璨不同的组合必须排在璀璨相同 brilliance_count: 2, inbound_at: '2026-11-05T09:00:00.000Z', }) - // 璀璨相同,但枪型不同:与上一条同为 80 分。 - const brillianceSame = buildInventory({ + // 同枪型但钻石更低:低配,必须排在高配之后。 + const lowConfig = buildInventory({ id: 406, category_id: profile.categoryId, - item_name: '安卓怒嚎3钻1璀璨 苏尔南', + item_name: '安卓血宴2钻1璀璨 苏尔南', system: '安卓', server_zone: '互通', - series: '怒嚎', - diamond_level: 3, + series: '血宴', + diamond_level: 2, brilliance_count: 1, inbound_at: '2026-11-01T09:00:00.000Z', }) const result = buildPendingConsignmentMatchPairs({ orders: [order], - // 故意把「璀璨相同」的库存放前面,验证排序而不是输入顺序。 - inventories: [brillianceSame, brillianceDiff], + // 故意把「低配」的库存放前面,验证排序而不是输入顺序。 + inventories: [lowConfig, brillianceHigher], profileByOrderId: new Map([[303, profile]]), includeConflicts: true, }) assert.deepEqual( - result.pairs.map((pair) => [pair.inventoryId, pair.score]), + result.pairs.map((pair) => [pair.inventoryId, pair.priorityLabel]), [ - [405, 80], - [406, 80], + [405, '同钻高璀'], + [406, '低配'], ], ) }) 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 6bd6c087..e97f23b2 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 @@ -23,6 +23,16 @@ import { } from './consignment-match-engine-service.js' import type { ConsignmentProfileSnapshot } from './consignment-profile-service.js' import type { ConsignmentSpecValues } from './consignment-spec-service.js' +import { + compareConsignmentMatchPriority, + isCrossSeriesInventory, + isSuernanPistolPriorityApplicable, + resolveConsignmentInventorySpecValues, + resolveConsignmentMatchPriorityFromSpecs, + resolveOrderSeriesInfo, + type ConsignmentMatchPriority, + type ConsignmentOrderSeriesInfo, +} from './consignment-sort-priority.js' import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js' export type PendingPistolMatchPair = { @@ -40,6 +50,12 @@ export type PendingPistolMatchPair = { score: number reason: string conflicts: string[] + /** 枪型 → 钻石 → 璀璨 的排序层级文案 */ + priorityLabel: string + seriesMatched: boolean + seriesSpecified: boolean + diamondGap: number + brillianceGap: number } export type PendingPistolMatchResult = { @@ -131,9 +147,29 @@ export function buildPendingPistolMatchPairs(input: { const pairs: PendingPistolMatchPair[] = [] for (const { order, specs } of preparedOrders) { + const orderSeriesInfo = resolveOrderSeriesInfo({ + categoryId: order.category_id, + productName: order.product_name, + parsedSeries: String(specs.series || ''), + }) for (const inventory of preparedInventories) { + const inventorySpecValues = resolveConsignmentInventorySpecValues(inventory) + if (isCrossSeriesInventory(orderSeriesInfo, String(inventorySpecValues.series || ''))) { + continue + } const scored = scoreConsignmentMatchDetails(specs, inventory) if (scored.matchType === 'conflict' && !includeConflicts) continue + const priority = resolveConsignmentMatchPriorityFromSpecs({ + orderValues: { + series: orderSeriesInfo.series, + diamondLevel: specs.diamondLevel, + brillianceCount: specs.brillianceCount, + }, + inventoryValues: inventorySpecValues, + orderSeriesInfo, + exact: scored.matchType === 'exact', + fallbackConflict: scored.matchType === 'conflict', + }) const pair: PendingPistolMatchPair = { workOrderId: Number(order.id), @@ -154,6 +190,11 @@ export function buildPendingPistolMatchPairs(input: { score: scored.score, reason: scored.reason, conflicts: scored.conflicts, + priorityLabel: priority.priorityLabel, + seriesMatched: priority.seriesMatched, + seriesSpecified: priority.seriesSpecified, + diamondGap: priority.diamondGap, + brillianceGap: priority.brillianceGap, } if (keyword && !buildPairHaystack(pair).includes(keyword)) continue @@ -250,42 +291,6 @@ function scoreFromDetails(details: PendingConsignmentMatchDetail[]): number { return Math.round((matched / effective.length) * 100) } -/** - * 同分排序的规格优先级:璀璨数 > 系列(枪型)> 钻石等级。 - * - * 硬性口径:同分时「璀璨不同」的组合必须排在「璀璨相同」之前, - * 让运营优先看到需要核对/补璀璨的配对;璀璨一致时再按枪型、钻级收敛。 - */ -const SPEC_PRIORITY_KEYS = ['brillianceCount', 'series', 'diamondLevel'] as const - -/** 普通字段:命中优先(相同 > 高配 > 未写 > 冲突)。 */ -const SPEC_STATUS_RANK: Record = { - match: 4, - partial: 3, - unknown: 2, - conflict: 1, - ignored: 0, -} - -/** 璀璨数:不同优先(差异 > 高配 > 未写 > 相同)。 */ -const BRILLIANCE_STATUS_RANK: Record = { - conflict: 4, - partial: 3, - unknown: 2, - match: 1, - ignored: 0, -} - -/** 生成可逐项比较的优先级键;缺少明细(旧评分兜底)时全为 0。 */ -function buildSpecPriorityKey(details: PendingConsignmentMatchDetail[]): number[] { - return SPEC_PRIORITY_KEYS.map((key) => { - const detail = details.find((item) => item.key === key) - if (!detail) return 0 - const rank = key === 'brillianceCount' ? BRILLIANCE_STATUS_RANK : SPEC_STATUS_RANK - return rank[detail.status] ?? 0 - }) -} - function buildEvaluationReason(evaluation: ReturnType): string { if (evaluation.exact) return '规格完全一致' const parts = [...evaluation.conflicts, ...evaluation.unknowns.map((item) => `订单缺少:${item}`)] @@ -309,6 +314,34 @@ function scoreLegacy( return scoreConsignmentMatchDetails(specs, inventory) } +/** 看板排序用的订单侧规格:与引擎同源(模板覆盖优先,其次标题/收集资料)。 */ +function resolvePendingOrderSpecs( + order: WorkOrderRow, + orderSpecOverride: ConsignmentSpecValues | null, +): { orderValues: ConsignmentSpecValues; seriesInfo: ConsignmentOrderSeriesInfo } { + const fields = resolveCollectSubmittedFieldValues(order) + const specs = extractPeacekeeperSpecs(order.product_name, { + system: String(fields.system || ''), + serverZone: String(fields.serverZone || ''), + series: String(fields.series || ''), + }) + const seriesInfo = resolveOrderSeriesInfo({ + categoryId: order.category_id, + productName: order.product_name, + parsedSeries: String(orderSpecOverride?.series ?? specs.series ?? ''), + }) + return { + orderValues: { + system: String(orderSpecOverride?.system ?? specs.system ?? ''), + serverZone: String(orderSpecOverride?.serverZone ?? specs.serverZone ?? ''), + series: seriesInfo.series, + diamondLevel: Number(orderSpecOverride?.diamondLevel ?? specs.diamondLevel ?? 0), + brillianceCount: Number(orderSpecOverride?.brillianceCount ?? specs.brillianceCount ?? 0), + }, + seriesInfo, + } +} + /** * 新口径的待抢单看板:与内部匹配引擎完全一致。 * @@ -354,6 +387,9 @@ export function buildPendingConsignmentMatchPairs(input: { requiredQuantity: number friendGift: boolean sharingOrder: boolean + orderSpecOverride: ConsignmentSpecValues | null + orderValues: ConsignmentSpecValues + orderSeriesInfo: ConsignmentOrderSeriesInfo }> = [] for (const order of input.orders) { if (!PENDING_BOARD_ORDER_STATUSES.has(String(order.status || ''))) continue @@ -380,6 +416,11 @@ export function buildPendingConsignmentMatchPairs(input: { const orderCategoryId = profile ? profile.categoryId : Number(order.category_id || 0) if (orderCategoryId !== categoryId) continue } + const orderSpecOverride = input.orderSpecOverrideByOrderId?.get(Number(order.id)) ?? null + const { orderValues, seriesInfo: orderSeriesInfo } = resolvePendingOrderSpecs( + order, + orderSpecOverride, + ) preparedOrders.push({ order, profile, @@ -389,6 +430,9 @@ export function buildPendingConsignmentMatchPairs(input: { requiredQuantity: sharingOrder ? remainingQuantity : totalQuantity, friendGift: isFriendGiftAcceptanceMode(order.acceptance_mode), sharingOrder, + orderSpecOverride, + orderValues, + orderSeriesInfo, }) } @@ -402,6 +446,7 @@ export function buildPendingConsignmentMatchPairs(input: { }) const pairs: PendingConsignmentMatchPair[] = [] + const priorityByPairKey = new Map() for (const { order, profile, @@ -410,8 +455,10 @@ export function buildPendingConsignmentMatchPairs(input: { requiredQuantity, friendGift, sharingOrder, + orderSpecOverride, + orderValues, + orderSeriesInfo, } of preparedOrders) { - const orderSpecOverride = input.orderSpecOverrideByOrderId?.get(Number(order.id)) ?? null const allowPartial = profile ? profile.config.matching.autoMatchEnabled && profile.config.matching.exactOnly === false : false @@ -424,11 +471,17 @@ export function buildPendingConsignmentMatchPairs(input: { conflicts: string[] matchDetails: PendingConsignmentMatchDetail[] positiveSpecSignal: boolean + priority: ConsignmentMatchPriority | null } const evaluated: EvaluatedInventory[] = [] for (const inventory of inventories) { // 超时重新匹配的排除项:看板同样不能把它算成可匹配。 if (isRematchExcludedInventory(order, inventory)) continue + const inventorySpecValues = resolveConsignmentInventorySpecValues(inventory) + // 跨枪型不展示、不参与匹配;订单未指定枪型时不过滤。 + if (isCrossSeriesInventory(orderSeriesInfo, String(inventorySpecValues.series || ''))) { + continue + } let matchType: 'exact' | 'partial' | 'conflict' let score: number let reason: string @@ -475,6 +528,22 @@ export function buildPendingConsignmentMatchPairs(input: { matchDetails = [] } + const priorityApplicable = profile + ? isSuernanPistolPriorityApplicable(profile.config.specs) + : // 无配置的旧手枪分支在候选阶段已限制为手枪 × 手枪。 + true + const priority = priorityApplicable + ? resolveConsignmentMatchPriorityFromSpecs({ + orderValues, + inventoryValues: inventorySpecValues, + orderSeriesInfo, + details: matchDetails, + exact: matchType === 'exact', + fallbackConflict: matchType === 'conflict', + }) + : // 非苏尔南配置(密钥/地铁等)保持原排序,不套用枪型/钻石/璀璨口径。 + null + evaluated.push({ inventory, matchType, @@ -483,6 +552,7 @@ export function buildPendingConsignmentMatchPairs(input: { conflicts, matchDetails, positiveSpecSignal, + priority, }) } @@ -543,6 +613,11 @@ export function buildPendingConsignmentMatchPairs(input: { score: item.score, reason: item.reason, conflicts: item.conflicts, + priorityLabel: item.priority?.priorityLabel ?? '', + seriesMatched: item.priority?.seriesMatched ?? false, + seriesSpecified: item.priority?.seriesSpecified ?? false, + diamondGap: item.priority?.diamondGap ?? 0, + brillianceGap: item.priority?.brillianceGap ?? 0, matchDetails: item.matchDetails, remainingQuantity, totalQuantity, @@ -558,6 +633,9 @@ export function buildPendingConsignmentMatchPairs(input: { } if (keyword && !buildPairHaystack(pair).includes(keyword)) continue + if (item.priority) { + priorityByPairKey.set(`${pair.workOrderId}-${pair.inventoryId}`, item.priority) + } pairs.push(pair) } } @@ -575,15 +653,14 @@ export function buildPendingConsignmentMatchPairs(input: { const autoMatchableCount = pairs.filter((pair) => pair.autoMatchable).length 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 + // 统一口径:枪型 → 钻石 → 璀璨;完美匹配优先,跨枪型已在候选阶段过滤。 + const leftPriority = priorityByPairKey.get(`${left.workOrderId}-${left.inventoryId}`) + const rightPriority = priorityByPairKey.get(`${right.workOrderId}-${right.inventoryId}`) + if (leftPriority && rightPriority) { + const priorityDiff = compareConsignmentMatchPriority(leftPriority, rightPriority) + if (priorityDiff !== 0) return priorityDiff } + if (right.score !== left.score) return right.score - left.score if (right.workOrderId !== left.workOrderId) { return right.workOrderId - left.workOrderId } diff --git a/apps/backend/src/services/worker-platform/consignment-sort-priority.test.ts b/apps/backend/src/services/worker-platform/consignment-sort-priority.test.ts new file mode 100644 index 00000000..b22284d0 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-sort-priority.test.ts @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + compareConsignmentMatchPriority, + isCrossSeriesInventory, + isSuernanPistolPriorityApplicable, + resolveConsignmentMatchPriority, + resolveConsignmentMatchPriorityFromSpecs, + resolveOrderSeriesInfo, +} from './consignment-sort-priority.js' + +function priority(input: { + orderSeries?: string + orderSeriesSpecified?: boolean + orderDiamond?: number + orderBrilliance?: number + inventorySeries?: string + inventoryDiamond?: number + inventoryBrilliance?: number + otherConflict?: boolean + otherUnknown?: boolean + exact?: boolean +}) { + return resolveConsignmentMatchPriority({ + orderSeries: input.orderSeries ?? '暗潮', + orderSeriesSpecified: input.orderSeriesSpecified !== false, + orderDiamondLevel: input.orderDiamond ?? 5, + orderBrillianceCount: input.orderBrilliance ?? 2, + inventorySeries: input.inventorySeries ?? '暗潮', + inventoryDiamondLevel: input.inventoryDiamond ?? 5, + inventoryBrillianceCount: input.inventoryBrilliance ?? 2, + otherConflict: input.otherConflict, + otherUnknown: input.otherUnknown, + exact: input.exact, + }) +} + +test('排序:完美匹配 > 同钻高璀(越接近越前)', () => { + const perfect = priority({ inventoryDiamond: 5, inventoryBrilliance: 2, exact: true }) + const three = priority({ inventoryDiamond: 5, inventoryBrilliance: 3 }) + const five = priority({ inventoryDiamond: 5, inventoryBrilliance: 5 }) + + assert.equal(perfect.priorityLabel, '完美匹配') + assert.equal(three.priorityLabel, '同钻高璀') + assert.ok(compareConsignmentMatchPriority(perfect, three) < 0) + assert.ok(compareConsignmentMatchPriority(three, five) < 0) +}) + +test('排序:同钻高璀优先于高钻同璀(钻石 > 璀璨)', () => { + const sameDiamondHigherBrilliance = priority({ + inventoryDiamond: 5, + inventoryBrilliance: 3, + }) + const higherDiamondSameBrilliance = priority({ + inventoryDiamond: 6, + inventoryBrilliance: 2, + }) + + assert.equal(sameDiamondHigherBrilliance.priorityLabel, '同钻高璀') + assert.equal(higherDiamondSameBrilliance.priorityLabel, '高钻同璀') + assert.ok( + compareConsignmentMatchPriority(sameDiamondHigherBrilliance, higherDiamondSameBrilliance) < 0, + ) +}) + +test('排序:高钻内部按钻级差升序,6钻3璀优先于7钻2璀', () => { + const sixWithThree = priority({ inventoryDiamond: 6, inventoryBrilliance: 3 }) + const sevenWithTwo = priority({ inventoryDiamond: 7, inventoryBrilliance: 2 }) + + assert.equal(sixWithThree.priorityLabel, '高钻高璀') + assert.equal(sevenWithTwo.priorityLabel, '高钻同璀') + // 钻石差是主键:6 钻(差 1)比 7 钻(差 2)更接近订单。 + assert.ok(compareConsignmentMatchPriority(sixWithThree, sevenWithTwo) < 0) +}) + +test('排序:高配全部排在低配之前,低配内部越接近越前', () => { + const higherDiamond = priority({ inventoryDiamond: 7, inventoryBrilliance: 2 }) + const lowerDiamond = priority({ inventoryDiamond: 4, inventoryBrilliance: 2 }) + const muchLowerDiamond = priority({ inventoryDiamond: 3, inventoryBrilliance: 2 }) + + assert.equal(lowerDiamond.priorityLabel, '低配') + assert.equal(muchLowerDiamond.priorityLabel, '低配') + assert.ok(compareConsignmentMatchPriority(higherDiamond, lowerDiamond) < 0) + assert.ok(compareConsignmentMatchPriority(lowerDiamond, muchLowerDiamond) < 0) +}) + +test('排序:低璀璨也是低配,且排在更高钻石之后', () => { + const higherDiamond = priority({ inventoryDiamond: 6, inventoryBrilliance: 2 }) + const lowerBrilliance = priority({ inventoryDiamond: 5, inventoryBrilliance: 1 }) + + assert.equal(lowerBrilliance.priorityLabel, '低配') + assert.ok(compareConsignmentMatchPriority(higherDiamond, lowerBrilliance) < 0) +}) + +test('排序:跨枪型永远最后,即使钻级/璀璨更接近', () => { + const crossSeries = priority({ + inventorySeries: '怒嚎', + inventoryDiamond: 5, + inventoryBrilliance: 2, + }) + const lowConfig = priority({ inventoryDiamond: 4, inventoryBrilliance: 1 }) + + assert.equal(crossSeries.priorityLabel, '跨枪型') + assert.ok(compareConsignmentMatchPriority(lowConfig, crossSeries) < 0) +}) + +test('排序:订单未指定枪型时不判跨枪型,只看钻石/璀璨', () => { + const unspecified = priority({ orderSeries: '', orderSeriesSpecified: false }) + assert.equal(unspecified.priorityLabel, '枪型未指定') + assert.equal(unspecified.seriesMatched, false) + assert.equal( + isCrossSeriesInventory({ series: '', specified: false, source: 'multi' }, '怒嚎'), + false, + ) +}) + +test('排序:系统/区服等硬冲突排在有效高配之后', () => { + const validHighConfig = priority({ inventoryDiamond: 6, inventoryBrilliance: 2 }) + const systemConflict = priority({ + inventoryDiamond: 5, + inventoryBrilliance: 2, + otherConflict: true, + }) + + assert.equal(systemConflict.priorityLabel, '其他规格冲突') + assert.ok(compareConsignmentMatchPriority(validHighConfig, systemConflict) < 0) +}) + +test('排序:订单没写钻石等级时标记待核对,不误判完美', () => { + const unknownDiamond = priority({ orderDiamond: 0, inventoryDiamond: 5 }) + assert.equal(unknownDiamond.diamondState, 'unknown') + assert.equal(unknownDiamond.priorityLabel, '钻石等级未写') + assert.notEqual(unknownDiamond.priorityLabel, '完美匹配') +}) + +test('枪型解析:分类优先于标题', () => { + const info = resolveOrderSeriesInfo({ + categoryId: 66, + productName: '安卓暗潮5钻2璀璨', + }) + assert.equal(info.series, '怒嚎') + assert.equal(info.source, 'category') +}) + +test('枪型解析:单枪型标题命中,多枪型标题视为不限', () => { + assert.equal( + resolveOrderSeriesInfo({ categoryId: 4, productName: '安卓血宴5钻2璀璨' }).series, + '血宴', + ) + const multi = resolveOrderSeriesInfo({ + categoryId: 4, + productName: '安卓【血宴/暗潮/怒嚎1-3钻】', + }) + assert.equal(multi.specified, false) + assert.equal(multi.source, 'multi') +}) + +test('枪型过滤:订单/库存都明确且不同才过滤', () => { + const specified = resolveOrderSeriesInfo({ categoryId: 65, productName: '' }) + assert.equal(isCrossSeriesInventory(specified, '怒嚎'), true) + assert.equal(isCrossSeriesInventory(specified, '暗潮'), false) + assert.equal(isCrossSeriesInventory(specified, ''), false) +}) + +test('排序器只对苏尔南五字段配置生效', () => { + assert.equal( + isSuernanPistolPriorityApplicable([ + { key: 'system' }, + { key: 'serverZone' }, + { key: 'series' }, + { key: 'diamondLevel' }, + { key: 'brillianceCount' }, + ]), + true, + ) + assert.equal(isSuernanPistolPriorityApplicable([{ key: 'product' }]), false) +}) + +test('从评估明细生成优先级:系统冲突不会被误判完美', () => { + const priorityValue = resolveConsignmentMatchPriorityFromSpecs({ + orderValues: { series: '暗潮', diamondLevel: 5, brillianceCount: 2, system: '苹果' }, + inventoryValues: { series: '暗潮', diamondLevel: 5, brillianceCount: 2, system: '安卓' }, + orderSeriesInfo: { series: '暗潮', specified: true, source: 'category' }, + details: [ + { key: 'system', status: 'conflict' }, + { key: 'series', status: 'match' }, + { key: 'diamondLevel', status: 'match' }, + { key: 'brillianceCount', status: 'match' }, + ], + exact: false, + }) + assert.equal(priorityValue.priorityLabel, '其他规格冲突') + assert.equal(priorityValue.exact, false) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-sort-priority.ts b/apps/backend/src/services/worker-platform/consignment-sort-priority.ts new file mode 100644 index 00000000..b1b3c91f --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-sort-priority.ts @@ -0,0 +1,302 @@ +import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js' +import { + parseConsignmentSpecValues, + type ConsignmentSpecValues, +} from './consignment-spec-service.js' + +/** + * 苏尔南手枪匹配排序(单一事实来源)。 + * + * 业务口径:枪型(暗潮/怒嚎/血宴)> 钻石等级 > 璀璨数量。 + * - 同枪型:完美匹配 > 同钻高璀 > 高钻同璀 > 高钻高璀 > 低配; + * - 高配内部按差距升序:6 钻优先于 7 钻,3 璀优先于 5 璀; + * - 跨枪型不参与匹配,由调用方在候选阶段过滤; + * - 非苏尔南配置不套用该排序器,避免影响密钥/地铁等其他品类。 + */ +export const SUERNAN_PISTOL_SERIES = ['暗潮', '怒嚎', '血宴'] as const +export type SuernanPistolSeries = (typeof SUERNAN_PISTOL_SERIES)[number] + +/** 二级分类 → 枪型:这些分类的工单即使标题没写枪型,也能按分类兜底。 */ +export const SUERNAN_SERIES_BY_CATEGORY: Record = { + 65: '暗潮', + 66: '怒嚎', + 67: '血宴', +} + +const PRIORITY_SPEC_KEYS = new Set(['series', 'diamondLevel', 'brillianceCount']) + +export type ConsignmentMatchState = 'exact' | 'higher' | 'lower' | 'unknown' + +export type ConsignmentOrderSeriesInfo = { + series: string + specified: boolean + source: 'category' | 'title' | 'field' | 'multi' | 'none' +} + +export type ConsignmentMatchPriority = { + /** 0=同枪型,1=订单未指定枪型,2=枪型不符/缺失 */ + seriesRank: number + seriesMatched: boolean + seriesSpecified: boolean + diamondState: ConsignmentMatchState + diamondGap: number + brillianceState: ConsignmentMatchState + brillianceGap: number + /** 系统/区服等非 枪型/钻石/璀璨 字段存在冲突 */ + otherConflict: boolean + /** 订单侧其他规格缺失,无法确认为完美匹配 */ + otherUnknown: boolean + exact: boolean + /** 0 完美、1 同钻高璀、2 高钻同璀、3 高钻高璀、4 待核对、5 低配、6 其他冲突、7 跨枪型 */ + tier: number + priorityLabel: string +} + +/** + * 解析工单枪型:分类优先,其次商品名,最后收集资料里的系列字段。 + * 商品名同时出现两种以上枪型(如「【血宴/暗潮/怒嚎1-3钻】」)视为「枪型不限」, + * 不能像旧解析器那样固定取第一个命中的「暗潮」。 + */ +export function resolveOrderSeriesInfo(input: { + categoryId?: number | null + productName?: string | null + parsedSeries?: string | null +}): ConsignmentOrderSeriesInfo { + const categorySeries = SUERNAN_SERIES_BY_CATEGORY[Number(input.categoryId || 0)] + if (categorySeries) { + return { series: categorySeries, specified: true, source: 'category' } + } + + const text = String(input.productName || '') + const titleMatches = SUERNAN_PISTOL_SERIES.filter((series) => text.includes(series)) + if (titleMatches.length === 1) { + return { series: titleMatches[0]!, specified: true, source: 'title' } + } + if (titleMatches.length > 1) { + return { series: '', specified: false, source: 'multi' } + } + + const parsed = String(input.parsedSeries || '').trim() + if (parsed) { + return { series: parsed, specified: true, source: 'field' } + } + return { series: '', specified: false, source: 'none' } +} + +/** 配置里同时存在枪型/钻石/璀璨三个字段时,才启用苏尔南排序口径。 */ +export function isSuernanPistolPriorityApplicable(specs: Array<{ key: string }>): boolean { + const keys = new Set(specs.map((spec) => spec.key)) + return keys.has('series') && keys.has('diamondLevel') && keys.has('brillianceCount') +} + +/** 库存侧规格值:优先 spec_values_json,旧库存回退五个旧列。 */ +export function resolveConsignmentInventorySpecValues( + inventory: ConsignmentInventoryRow, +): ConsignmentSpecValues { + const parsed = parseConsignmentSpecValues(inventory.spec_values_json) + if (Object.keys(parsed).length > 0) return parsed + return { + system: inventory.system, + serverZone: inventory.server_zone, + series: inventory.series, + diamondLevel: Number(inventory.diamond_level || 1), + brillianceCount: Number(inventory.brilliance_count || 0), + } +} + +/** + * 跨枪型判定:订单枪型明确、库存枪型也明确且不同 → true。 + * 订单未指定枪型(多枪型标题 / 未填)时不拦截,交给「枪型未指定」层级处理。 + */ +export function isCrossSeriesInventory( + orderSeriesInfo: ConsignmentOrderSeriesInfo, + inventorySeries: string, +): boolean { + if (!orderSeriesInfo.specified || !orderSeriesInfo.series) return false + const inventory = String(inventorySeries || '').trim() + if (!inventory) return false + return inventory !== orderSeriesInfo.series +} + +/** 从评估结果(规格值 + 逐字段明细)生成排序优先级。 */ +export function resolveConsignmentMatchPriorityFromSpecs(input: { + orderValues: ConsignmentSpecValues + inventoryValues: ConsignmentSpecValues + orderSeriesInfo: ConsignmentOrderSeriesInfo + details?: Array<{ key: string; status: string }> + exact?: boolean + fallbackConflict?: boolean + fallbackUnknown?: boolean +}): ConsignmentMatchPriority { + const details = input.details ?? [] + const hasDetails = details.length > 0 + const otherConflict = hasDetails + ? details.some((detail) => detail.status === 'conflict' && !PRIORITY_SPEC_KEYS.has(detail.key)) + : Boolean(input.fallbackConflict) + const otherUnknown = hasDetails + ? details.some((detail) => detail.status === 'unknown' && !PRIORITY_SPEC_KEYS.has(detail.key)) + : Boolean(input.fallbackUnknown) + + return resolveConsignmentMatchPriority({ + orderSeries: input.orderSeriesInfo.series, + orderSeriesSpecified: input.orderSeriesInfo.specified, + orderDiamondLevel: Number(input.orderValues.diamondLevel || 0), + orderBrillianceCount: Number(input.orderValues.brillianceCount || 0), + inventorySeries: String(input.inventoryValues.series || ''), + inventoryDiamondLevel: Number(input.inventoryValues.diamondLevel || 1), + inventoryBrillianceCount: Number(input.inventoryValues.brillianceCount || 0), + otherConflict, + otherUnknown, + exact: Boolean(input.exact), + }) +} + +export function resolveConsignmentMatchPriority(input: { + orderSeries: string + orderSeriesSpecified: boolean + orderDiamondLevel: number + orderBrillianceCount: number + inventorySeries: string + inventoryDiamondLevel: number + inventoryBrillianceCount: number + otherConflict?: boolean + otherUnknown?: boolean + exact?: boolean +}): ConsignmentMatchPriority { + const orderSeries = String(input.orderSeries || '').trim() + const inventorySeries = String(input.inventorySeries || '').trim() + let seriesRank = 0 + let seriesMatched = true + if (!input.orderSeriesSpecified || !orderSeries) { + seriesRank = 1 + seriesMatched = false + } else if (!inventorySeries || inventorySeries !== orderSeries) { + seriesRank = 2 + seriesMatched = false + } + + const diamond = resolveNumericState(input.orderDiamondLevel, input.inventoryDiamondLevel, true) + const brilliance = resolveNumericState( + input.orderBrillianceCount, + input.inventoryBrillianceCount, + false, + ) + const otherConflict = Boolean(input.otherConflict) + const otherUnknown = Boolean(input.otherUnknown) + const exact = Boolean(input.exact) + const label = resolvePriorityLabel({ + seriesRank, + inventorySeriesMissing: Boolean(input.orderSeriesSpecified) && !inventorySeries, + diamondState: diamond.state, + brillianceState: brilliance.state, + otherConflict, + otherUnknown, + exact, + }) + + return { + seriesRank, + seriesMatched, + seriesSpecified: Boolean(input.orderSeriesSpecified), + diamondState: diamond.state, + diamondGap: diamond.gap, + brillianceState: brilliance.state, + brillianceGap: brilliance.gap, + otherConflict, + otherUnknown, + exact, + tier: label.tier, + priorityLabel: label.label, + } +} + +/** 排序键:枪型 → 其他硬冲突 → 钻石 → 钻石差 → 璀璨 → 璀璨差 → 其他缺失 → 精确。 */ +export function compareConsignmentMatchPriority( + left: ConsignmentMatchPriority, + right: ConsignmentMatchPriority, +): number { + if (left.seriesRank !== right.seriesRank) return left.seriesRank - right.seriesRank + if (left.otherConflict !== right.otherConflict) return left.otherConflict ? 1 : -1 + + // 低配(钻级或璀璨低于订单)整体沉底:同钻 1 璀不能压过 6 钻 2 璀。 + const leftLowConfig = left.diamondState === 'lower' || left.brillianceState === 'lower' + const rightLowConfig = right.diamondState === 'lower' || right.brillianceState === 'lower' + if (leftLowConfig !== rightLowConfig) return leftLowConfig ? 1 : -1 + + const diamondRankDiff = + DIAMOND_STATE_RANK[left.diamondState] - DIAMOND_STATE_RANK[right.diamondState] + if (diamondRankDiff !== 0) return diamondRankDiff + if (left.diamondGap !== right.diamondGap) return left.diamondGap - right.diamondGap + + const brillianceRankDiff = + BRILLIANCE_STATE_RANK[left.brillianceState] - BRILLIANCE_STATE_RANK[right.brillianceState] + if (brillianceRankDiff !== 0) return brillianceRankDiff + if (left.brillianceGap !== right.brillianceGap) return left.brillianceGap - right.brillianceGap + + if (left.otherUnknown !== right.otherUnknown) return left.otherUnknown ? 1 : -1 + if (left.exact !== right.exact) return left.exact ? -1 : 1 + return 0 +} + +const DIAMOND_STATE_RANK: Record = { + exact: 0, + higher: 1, + unknown: 2, + lower: 3, +} + +const BRILLIANCE_STATE_RANK: Record = { + exact: 0, + higher: 1, + unknown: 2, + lower: 2, +} + +function resolveNumericState( + orderValue: number, + inventoryValue: number, + zeroIsUnknown: boolean, +): { state: ConsignmentMatchState; gap: number } { + const order = Math.max(0, Math.round(Number(orderValue || 0))) + const inventory = Math.max(0, Math.round(Number(inventoryValue || 0))) + // 钻石等级 0 = 订单没写;璀璨数 0 = 无璀璨,是有效值。 + if (zeroIsUnknown && order <= 0) return { state: 'unknown', gap: 0 } + if (zeroIsUnknown && inventory <= 0) return { state: 'lower', gap: order } + if (order === inventory) return { state: 'exact', gap: 0 } + if (inventory > order) return { state: 'higher', gap: inventory - order } + return { state: 'lower', gap: order - inventory } +} + +function resolvePriorityLabel(input: { + seriesRank: number + inventorySeriesMissing: boolean + diamondState: ConsignmentMatchState + brillianceState: ConsignmentMatchState + otherConflict: boolean + otherUnknown: boolean + exact: boolean +}): { tier: number; label: string } { + if (input.seriesRank >= 2) { + return { tier: 7, label: input.inventorySeriesMissing ? '枪型缺失' : '跨枪型' } + } + if (input.otherConflict) return { tier: 6, label: '其他规格冲突' } + if (input.diamondState === 'lower' || input.brillianceState === 'lower') { + return { tier: 5, label: '低配' } + } + if (input.seriesRank === 1) return { tier: 4, label: '枪型未指定' } + if (input.diamondState === 'unknown') return { tier: 4, label: '钻石等级未写' } + if (input.otherUnknown) return { tier: 4, label: '规格待核对' } + if (input.diamondState === 'exact' && input.brillianceState === 'exact') { + return input.exact ? { tier: 0, label: '完美匹配' } : { tier: 4, label: '规格待核对' } + } + if (input.diamondState === 'exact' && input.brillianceState === 'higher') { + return { tier: 1, label: '同钻高璀' } + } + if (input.diamondState === 'higher' && input.brillianceState === 'exact') { + return { tier: 2, label: '高钻同璀' } + } + if (input.diamondState === 'higher' && input.brillianceState === 'higher') { + return { tier: 3, label: '高钻高璀' } + } + return { tier: 4, label: '规格待核对' } +} diff --git a/apps/frontend/src/components/admin/ConsignmentInventoryMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentInventoryMatchModal.tsx index e3d3a605..1726a06b 100644 --- a/apps/frontend/src/components/admin/ConsignmentInventoryMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentInventoryMatchModal.tsx @@ -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) => ( + {record.priorityLabel ? ( + + {record.priorityLabel} + + ) : null} {record.reason || '-'} {record.conflicts.length > 0 ? ( @@ -205,7 +214,7 @@ export default function ConsignmentInventoryMatchModal({ type="info" showIcon message="按寄卖配置与数量判定候选工单;多数量库存会按剩余量扣减,不会被整批消耗。" - description="精确匹配可直接提交;非精确或规格冲突必须勾选强制匹配并填写原因。拼单工单必须一次拼满,单条库存不够时请到工单侧匹配。" + description="候选按 枪型 → 钻石 → 璀璨 排序,完美匹配在前;跨枪型不展示。精确匹配可直接提交;非精确或规格冲突必须勾选强制匹配并填写原因。拼单工单必须一次拼满,单条库存不够时请到工单侧匹配。" /> ( + {record.priorityLabel ? ( + + {record.priorityLabel} + + ) : null} {record.reason || '-'} {record.conflicts.length > 0 ? ( @@ -289,8 +298,8 @@ export default function ConsignmentPendingMatchModal({ } description={ canMatch - ? '默认展示全部配对(含不匹配项),按打分从高到低排列;点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。' - : '默认展示全部配对(含不匹配项),按打分从高到低排列;当前账号没有履约权限,只能查看。' + ? '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之;跨枪型不展示。点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。' + : '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序;当前账号没有履约权限,只能查看。' } /> diff --git a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx index 74c63d16..f5b77c93 100644 --- a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx @@ -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="默认按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之,低配靠后;非精确/冲突必须填写人工原因;拼单必须一次拼满,失败不会占用任何库存。" /> @@ -356,6 +352,22 @@ export default function ConsignmentWorkOrderMatchModal({ ) }, }, + { + title: '匹配层级', + key: 'priority', + width: 105, + render: (_, item) => + item.priorityLabel ? ( + + {item.priorityLabel} + + ) : ( + - + ), + }, { title: '寄卖单号', key: 'consignmentNo', diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 69b3ce4e..f2b23913 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -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 diff --git a/apps/frontend/src/utils/consignment-match-priority.ts b/apps/frontend/src/utils/consignment-match-priority.ts new file mode 100644 index 00000000..e9f3f8a0 --- /dev/null +++ b/apps/frontend/src/utils/consignment-match-priority.ts @@ -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' +} diff --git a/apps/frontend/tests/consignment-match-config-and-scope.test.ts b/apps/frontend/tests/consignment-match-config-and-scope.test.ts index a991a11d..78f82190 100644 --- a/apps/frontend/tests/consignment-match-config-and-scope.test.ts +++ b/apps/frontend/tests/consignment-match-config-and-scope.test.ts @@ -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)'), diff --git a/apps/frontend/tests/consignment-pending-match-select.test.ts b/apps/frontend/tests/consignment-pending-match-select.test.ts index 1dd82c31..f57aa380 100644 --- a/apps/frontend/tests/consignment-pending-match-select.test.ts +++ b/apps/frontend/tests/consignment-pending-match-select.test.ts @@ -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('默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序'), '界面必须说明默认口径') })