feat(consignment): 苏尔南手枪按枪型/钻石/璀璨分层匹配并过滤跨枪型
排序:完美匹配 > 同钻高璀 > 高钻同璀 > 高钻高璀 > 低配;跨枪型不展示不参与;高配仍需填人工原因。
This commit is contained in:
@@ -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))
|
||||
|
||||
+18
-22
@@ -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, '低配'],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<string, number> = {
|
||||
match: 4,
|
||||
partial: 3,
|
||||
unknown: 2,
|
||||
conflict: 1,
|
||||
ignored: 0,
|
||||
}
|
||||
|
||||
/** 璀璨数:不同优先(差异 > 高配 > 未写 > 相同)。 */
|
||||
const BRILLIANCE_STATUS_RANK: Record<string, number> = {
|
||||
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<typeof evaluateInventoryMatch>): 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<string, ConsignmentMatchPriority>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<number, SuernanPistolSeries> = {
|
||||
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<ConsignmentMatchState, number> = {
|
||||
exact: 0,
|
||||
higher: 1,
|
||||
unknown: 2,
|
||||
lower: 3,
|
||||
}
|
||||
|
||||
const BRILLIANCE_STATE_RANK: Record<ConsignmentMatchState, number> = {
|
||||
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: '规格待核对' }
|
||||
}
|
||||
Reference in New Issue
Block a user