feat(consignment): 苏尔南手枪按枪型/钻石/璀璨分层匹配并过滤跨枪型

排序:完美匹配 > 同钻高璀 > 高钻同璀 > 高钻高璀 > 低配;跨枪型不展示不参与;高配仍需填人工原因。
This commit is contained in:
2026-11-15 16:58:42 +08:00
parent 68fa2a575f
commit e2c27bc7a9
12 changed files with 877 additions and 127 deletions
@@ -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))
@@ -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: '规格待核对' }
}
@@ -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) => (
<Space direction="vertical" size={2}>
{record.priorityLabel ? (
<Tag
color={resolveConsignmentPriorityTagColor(record.priorityLabel)}
style={{ marginInlineEnd: 0, fontSize: 11 }}
>
{record.priorityLabel}
</Tag>
) : null}
<Text style={{ fontSize: 12 }}>{record.reason || '-'}</Text>
{record.conflicts.length > 0 ? (
<Text type="danger" style={{ fontSize: 11 }}>
@@ -205,7 +214,7 @@ export default function ConsignmentInventoryMatchModal({
type="info"
showIcon
message="按寄卖配置与数量判定候选工单;多数量库存会按剩余量扣减,不会被整批消耗。"
description="精确匹配可直接提交;非精确或规格冲突必须勾选强制匹配并填写原因。拼单工单必须一次拼满,单条库存不够时请到工单侧匹配。"
description="候选按 枪型 → 钻石 → 璀璨 排序,完美匹配在前;跨枪型不展示。精确匹配可直接提交;非精确或规格冲突必须勾选强制匹配并填写原因。拼单工单必须一次拼满,单条库存不够时请到工单侧匹配。"
/>
<Space wrap size={12}>
<Checkbox
@@ -8,6 +8,7 @@ import PercentTable from '@/components/common/PercentTable'
import { fetchPendingPistolMatches } from '@/services/admin/consignment'
import type { PendingPistolMatchPair } from '@/types/consignment'
import { formatDateTime } from '@/utils/date-time'
import { resolveConsignmentPriorityTagColor } from '@/utils/consignment-match-priority'
import ConsignmentWorkOrderMatchModal from './ConsignmentWorkOrderMatchModal'
@@ -221,6 +222,14 @@ export default function ConsignmentPendingMatchModal({
width: 300,
render: (_, record) => (
<Space direction="vertical" size={2}>
{record.priorityLabel ? (
<Tag
color={resolveConsignmentPriorityTagColor(record.priorityLabel)}
style={{ marginInlineEnd: 0, fontSize: 11 }}
>
{record.priorityLabel}
</Tag>
) : null}
<Text style={{ fontSize: 12 }}>{record.reason || '-'}</Text>
{record.conflicts.length > 0 ? (
<Text type="danger" style={{ fontSize: 11 }}>
@@ -289,8 +298,8 @@ export default function ConsignmentPendingMatchModal({
}
description={
canMatch
? '默认展示全部配对(含不匹配项),按打分从高到低排列;点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
: '默认展示全部配对(含不匹配项),按打分从高到低排列;当前账号没有履约权限,只能查看。'
? '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之;跨枪型不展示。点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
: '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序;当前账号没有履约权限,只能查看。'
}
/>
@@ -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="默认按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之,低配靠后;非精确/冲突必须填写人工原因;拼单必须一次拼满,失败不会占用任何库存。"
/>
<Space wrap size={8}>
<Tag color="blue">
@@ -356,6 +352,22 @@ export default function ConsignmentWorkOrderMatchModal({
)
},
},
{
title: '匹配层级',
key: 'priority',
width: 105,
render: (_, item) =>
item.priorityLabel ? (
<Tag
color={resolveConsignmentPriorityTagColor(item.priorityLabel)}
style={{ marginInlineEnd: 0 }}
>
{item.priorityLabel}
</Tag>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
},
{
title: '寄卖单号',
key: 'consignmentNo',
+12
View File
@@ -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
@@ -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'
}
@@ -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)'),
@@ -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('默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序'), '界面必须说明默认口径')
})