feat(consignment): 拆分自动/手动匹配口径并优化内部匹配列表
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveManualMatchAvailability } from './consignment-match-engine-service.js'
|
||||
|
||||
test('手动匹配可用性:待分配 / 待抢单允许提交', () => {
|
||||
for (const status of ['unassigned', 'open']) {
|
||||
const result = resolveManualMatchAvailability(status)
|
||||
assert.equal(result.matchable, true, `${status} 应该允许匹配`)
|
||||
assert.equal(result.blockedReason, '')
|
||||
}
|
||||
})
|
||||
|
||||
test('手动匹配可用性:其它状态只读查看,并给出原因', () => {
|
||||
for (const status of ['pending_material', 'internal_matching', 'in_progress', 'accepted']) {
|
||||
const result = resolveManualMatchAvailability(status)
|
||||
assert.equal(result.matchable, false, `${status} 不应允许落库匹配`)
|
||||
assert.ok(result.blockedReason.includes('仅可查看库存'), `${status} 必须说明只能查看`)
|
||||
}
|
||||
})
|
||||
|
||||
test('候选接口:状态不允许时不再抛错,改为返回只读标记并保留候选', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./consignment-match-engine-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const start = source.indexOf('export async function listConsignmentMatchCandidatesForWorkOrder')
|
||||
const end = source.indexOf('function extractConsignmentErrorCode', start)
|
||||
const body = source.slice(start, end)
|
||||
|
||||
assert.ok(body.includes('resolveManualMatchAvailability(workOrder.status)'))
|
||||
assert.ok(body.includes('matchable: availability.matchable'))
|
||||
assert.ok(!body.includes('work_order_not_matchable'), '候选查询不能再因状态直接 409')
|
||||
})
|
||||
@@ -31,11 +31,13 @@ import {
|
||||
evaluateConsignmentSpecMatch,
|
||||
isBuiltInConsignmentSpecField,
|
||||
parseConsignmentSpecValues,
|
||||
resolveScopedConsignmentSpecs,
|
||||
type ConsignmentSpecField,
|
||||
type ConsignmentSpecValues,
|
||||
} from './consignment-spec-service.js'
|
||||
import {
|
||||
classifyConsignmentMatch,
|
||||
detectItemCategory,
|
||||
extractPeacekeeperSpecs,
|
||||
} from './consignment-matching-service.js'
|
||||
import { resolveOrderTemplateSpecValues } from './consignment-template-spec-service.js'
|
||||
@@ -68,6 +70,21 @@ const MANUAL_CONSIGNMENT_MATCHABLE_STATUSES: string[] = [
|
||||
WORK_ORDER_STATUS.OPEN,
|
||||
]
|
||||
|
||||
/**
|
||||
* 手动匹配可用性:状态不满足时只读展示库存,不在查询阶段抛错。
|
||||
* 运营仍需要搜索/核对仓库里的现货,真正提交时由落库事务再次校验状态。
|
||||
*/
|
||||
export function resolveManualMatchAvailability(status: string): {
|
||||
matchable: boolean
|
||||
blockedReason: string
|
||||
} {
|
||||
const matchable = MANUAL_CONSIGNMENT_MATCHABLE_STATUSES.includes(String(status || ''))
|
||||
return {
|
||||
matchable,
|
||||
blockedReason: matchable ? '' : '当前工单状态不允许匹配寄卖库存,仅可查看库存',
|
||||
}
|
||||
}
|
||||
|
||||
function parseMaterial(value: unknown): Record<string, unknown> {
|
||||
const parsed = safeParseJson(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
@@ -293,40 +310,29 @@ export function evaluateInventoryMatch(
|
||||
workOrder: WorkOrderRow,
|
||||
inventory: ConsignmentInventoryRow,
|
||||
profile: ConsignmentProfileSnapshot | null,
|
||||
options: { orderSpecOverride?: ConsignmentSpecValues | null } = {},
|
||||
options: {
|
||||
orderSpecOverride?: ConsignmentSpecValues | null
|
||||
/** 自动匹配用 matchMode;手动匹配用 manualMatchMode(缺省精确相等)。 */
|
||||
matchScope?: 'auto' | 'manual'
|
||||
} = {},
|
||||
): InventoryMatchEvaluation {
|
||||
// 模板驱动配置的规格定义是「分类级事实」:一律用 profile 的 specs,
|
||||
// 不随库存的 profile_snapshot_json 回退到旧字段,否则旧快照库存会绕过模板匹配。
|
||||
// 手动匹配同样必须跟随「当前」配置:运营改完手动口径后,旧库存不能还按入库快照判定。
|
||||
const isTemplateDriven = profile?.config.specSource === 'template'
|
||||
const specs = isTemplateDriven ? profile!.config.specs : resolveInventorySpecs(inventory, profile)
|
||||
const useCurrentProfileSpecs = isTemplateDriven || options.matchScope === 'manual'
|
||||
const specs = useCurrentProfileSpecs
|
||||
? (profile?.config.specs ?? [])
|
||||
: resolveInventorySpecs(inventory, profile)
|
||||
const scopedSpecs = resolveScopedConsignmentSpecs(specs, options.matchScope ?? 'auto')
|
||||
const orderValues =
|
||||
options.orderSpecOverride !== undefined && options.orderSpecOverride !== null
|
||||
? { ...options.orderSpecOverride }
|
||||
: resolveOrderSpecValues(workOrder)
|
||||
const values = resolveInventorySpecValues(inventory)
|
||||
const hasCustomSpecs = specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
|
||||
|
||||
if (isTemplateDriven) {
|
||||
// 模板驱动规格:订单侧规格来自 product_rule_id → 模板商品名 → 同一套因子解析器
|
||||
//(由调用方预先解析并经 orderSpecOverride 传入);解析失败(历史订单/模板已删)
|
||||
// 一律按 unknown 处理,不参与自动匹配,避免把任意库存判成匹配。
|
||||
const evaluation = evaluateConsignmentSpecMatch({ specs, orderValues, inventoryValues: values })
|
||||
return {
|
||||
specs,
|
||||
values,
|
||||
orderValues,
|
||||
exact: evaluation.exact,
|
||||
partial: evaluation.partial,
|
||||
unknown: evaluation.unknown || Object.keys(orderValues).length === 0,
|
||||
conflicts: evaluation.conflicts,
|
||||
unknowns: evaluation.unknowns,
|
||||
details: evaluation.details,
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCustomSpecs) {
|
||||
// 内置 5 规格沿用原有苏尔南匹配器(通配、低配不能充高配、品类门禁);
|
||||
// 只有出现自定义规格时才走通用匹配,避免存量行为回归。
|
||||
if (scopedSpecs.length === 0) {
|
||||
// 没有任何配置字段:沿用旧苏尔南匹配器兜底,避免无条件放行全部库存。
|
||||
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
|
||||
system: String(orderValues.system || ''),
|
||||
serverZone: String(orderValues.serverZone || ''),
|
||||
@@ -334,7 +340,7 @@ export function evaluateInventoryMatch(
|
||||
})
|
||||
const strict = classifyConsignmentMatch(orderSpecs, inventory)
|
||||
return {
|
||||
specs,
|
||||
specs: scopedSpecs,
|
||||
values,
|
||||
orderValues,
|
||||
exact: strict.matchType === 'exact',
|
||||
@@ -346,9 +352,30 @@ export function evaluateInventoryMatch(
|
||||
}
|
||||
}
|
||||
|
||||
const evaluation = evaluateConsignmentSpecMatch({ specs, orderValues, inventoryValues: values })
|
||||
// 品类门禁:配置再宽也不能让手枪库存匹配密钥/机甲等其他品类。
|
||||
const categoryConflict = detectConsignmentCategoryConflict(workOrder, inventory, orderValues)
|
||||
if (categoryConflict) {
|
||||
return {
|
||||
specs: scopedSpecs,
|
||||
values,
|
||||
orderValues,
|
||||
exact: false,
|
||||
partial: false,
|
||||
unknown: false,
|
||||
conflicts: [categoryConflict],
|
||||
unknowns: [],
|
||||
details: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 内置五字段与自定义字段统一走同一套 matchMode 判定,配置才真正生效。
|
||||
const evaluation = evaluateConsignmentSpecMatch({
|
||||
specs: scopedSpecs,
|
||||
orderValues,
|
||||
inventoryValues: values,
|
||||
})
|
||||
return {
|
||||
specs,
|
||||
specs: scopedSpecs,
|
||||
values,
|
||||
orderValues,
|
||||
exact: evaluation.exact,
|
||||
@@ -360,6 +387,45 @@ export function evaluateInventoryMatch(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 与旧手枪匹配器一致的品类隔离:订单与库存都能识别品类且不一致时拒绝匹配。
|
||||
* 配置字段只能放宽规格,不能跨越商品品类。
|
||||
*/
|
||||
function detectConsignmentCategoryConflict(
|
||||
workOrder: WorkOrderRow,
|
||||
inventory: ConsignmentInventoryRow,
|
||||
orderValues: ConsignmentSpecValues,
|
||||
): string {
|
||||
const orderText = [
|
||||
workOrder.product_name,
|
||||
orderValues.system,
|
||||
orderValues.serverZone,
|
||||
orderValues.series,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
const orderCategory = detectItemCategory(orderText)
|
||||
const inventoryCategory = detectItemCategory(
|
||||
`${inventory.item_name} ${inventory.series || ''} ${inventory.category_name || ''}`,
|
||||
)
|
||||
if (
|
||||
orderCategory !== 'other' &&
|
||||
inventoryCategory !== 'other' &&
|
||||
orderCategory !== inventoryCategory
|
||||
) {
|
||||
return '商品品类与库存不一致'
|
||||
}
|
||||
if (inventoryCategory === 'pistol_suernan') {
|
||||
const hasPistolSignal =
|
||||
Boolean(orderValues.series) ||
|
||||
orderText.includes('苏尔南') ||
|
||||
orderText.includes('手枪') ||
|
||||
orderText.includes('枪皮')
|
||||
if (!hasPistolSignal) return '订单未识别为苏尔南手枪,不能匹配手枪库存'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export async function resolveProfileForWorkOrder(
|
||||
workOrder: WorkOrderRow,
|
||||
): Promise<ConsignmentProfileSnapshot | null> {
|
||||
@@ -772,6 +838,7 @@ function pickCandidateInventories(
|
||||
inventory,
|
||||
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
|
||||
orderSpecOverride: options.orderSpecOverride ?? null,
|
||||
matchScope: 'auto',
|
||||
}),
|
||||
}))
|
||||
.filter(({ evaluation }) => {
|
||||
@@ -1398,12 +1465,9 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
||||
if (!workOrder) {
|
||||
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
||||
}
|
||||
if (!MANUAL_CONSIGNMENT_MATCHABLE_STATUSES.includes(workOrder.status)) {
|
||||
throw createHttpError('当前工单状态不允许匹配寄卖库存', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_not_matchable',
|
||||
})
|
||||
}
|
||||
// 状态不满足时不再直接报错:只读返回全部候选,让运营仍能搜索库存;
|
||||
// 「确认匹配」由前端禁用、提交接口再次拦截。
|
||||
const availability = resolveManualMatchAvailability(workOrder.status)
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
const orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile)
|
||||
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
|
||||
@@ -1419,6 +1483,7 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
||||
inventory,
|
||||
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
|
||||
orderSpecOverride,
|
||||
matchScope: 'manual',
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
@@ -1428,6 +1493,8 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
||||
const options = evaluated.map(({ inventory, evaluation }) => ({
|
||||
inventoryId: Number(inventory.id),
|
||||
inventoryNo: inventory.inventory_no,
|
||||
/** 寄卖单号口径:提报单号优先,客服手工入库的现货回退库存号 */
|
||||
consignmentNo: inventory.source_submission_no || inventory.inventory_no,
|
||||
itemName: inventory.item_name,
|
||||
system: inventory.system,
|
||||
serverZone: inventory.server_zone,
|
||||
@@ -1470,6 +1537,9 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
||||
sharingEnabled: Boolean(workOrder.sharing_enabled),
|
||||
totalQuantity: Math.max(1, Number(workOrder.sharing_total_quantity || 1)),
|
||||
activeQuantity: Number(active.rows[0]?.active || 0),
|
||||
matchable: availability.matchable,
|
||||
blockedReason: availability.blockedReason,
|
||||
workOrderStatus: String(workOrder.status || ''),
|
||||
options,
|
||||
}
|
||||
}
|
||||
@@ -1572,6 +1642,7 @@ async function runManualConsignmentMatch(
|
||||
inventory,
|
||||
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
|
||||
orderSpecOverride,
|
||||
matchScope: 'manual',
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||
import { evaluateInventoryMatch } from './consignment-match-engine-service.js'
|
||||
import type { ConsignmentProfileSnapshot } from './consignment-profile-service.js'
|
||||
import { normalizeConsignmentSpecs, type ConsignmentSpecField } from './consignment-spec-service.js'
|
||||
|
||||
function buildSpec(
|
||||
overrides: Partial<ConsignmentSpecField> & { key: string },
|
||||
): ConsignmentSpecField {
|
||||
return {
|
||||
label: overrides.key,
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [],
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
matchMode: 'exact',
|
||||
manualMatchMode: 'exact',
|
||||
wildcardValues: [],
|
||||
showInItemName: true,
|
||||
filterable: false,
|
||||
sortOrder: 10,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildProfile(specs: ConsignmentSpecField[]): ConsignmentProfileSnapshot {
|
||||
return {
|
||||
profileId: 5,
|
||||
versionId: 1,
|
||||
categoryId: 11,
|
||||
config: {
|
||||
specs,
|
||||
specSource: 'legacy',
|
||||
matching: {
|
||||
autoMatchEnabled: true,
|
||||
exactOnly: false,
|
||||
preferLowReward: true,
|
||||
orderEffectiveFrom: '',
|
||||
},
|
||||
},
|
||||
} as unknown as ConsignmentProfileSnapshot
|
||||
}
|
||||
|
||||
function buildInventory(overrides: Partial<ConsignmentInventoryRow> = {}): ConsignmentInventoryRow {
|
||||
return {
|
||||
id: 1,
|
||||
inventory_no: 'INV1',
|
||||
item_name: '苏尔南手枪 暗潮 3钻 1璀璨',
|
||||
category_id: 11,
|
||||
profile_id: 5,
|
||||
profile_snapshot_json: null,
|
||||
system: '苹果',
|
||||
server_zone: '互通',
|
||||
series: '暗潮',
|
||||
diamond_level: 3,
|
||||
brilliance_count: 1,
|
||||
quantity: 1,
|
||||
remaining_quantity: 1,
|
||||
status: 'in_stock',
|
||||
allow_matching: true,
|
||||
spec_values_json: null,
|
||||
...overrides,
|
||||
} as unknown as ConsignmentInventoryRow
|
||||
}
|
||||
|
||||
function buildWorkOrder(): WorkOrderRow {
|
||||
return {
|
||||
id: 10,
|
||||
work_order_no: 'WO1',
|
||||
product_name: '苹果 互通 暗潮 2钻 1璀璨 苏尔南',
|
||||
category_id: 11,
|
||||
consignment_profile_id: 5,
|
||||
status: 'open',
|
||||
} as unknown as WorkOrderRow
|
||||
}
|
||||
|
||||
const ORDER_VALUES = {
|
||||
system: '苹果',
|
||||
serverZone: '互通',
|
||||
series: '暗潮',
|
||||
diamondLevel: 2,
|
||||
brillianceCount: 1,
|
||||
}
|
||||
|
||||
const BASE_SPECS = [
|
||||
buildSpec({ key: 'system', label: '操作系统', matchMode: 'exact', manualMatchMode: 'exact' }),
|
||||
buildSpec({
|
||||
key: 'serverZone',
|
||||
label: '游戏区服',
|
||||
matchMode: 'wildcard',
|
||||
manualMatchMode: 'wildcard',
|
||||
wildcardValues: ['互通', '通用', '全区', '不限'],
|
||||
}),
|
||||
buildSpec({ key: 'series', label: '系列', matchMode: 'exact', manualMatchMode: 'exact' }),
|
||||
buildSpec({ key: 'diamondLevel', label: '钻石等级', type: 'number', matchMode: 'minimum' }),
|
||||
buildSpec({ key: 'brillianceCount', label: '璀璨数', type: 'number', matchMode: 'minimum' }),
|
||||
]
|
||||
|
||||
test('匹配口径:自动按 minimum 放过钻级高配,手动默认精确相等则判冲突', () => {
|
||||
const profile = buildProfile(BASE_SPECS)
|
||||
const inventory = buildInventory({ diamond_level: 3 })
|
||||
const workOrder = buildWorkOrder()
|
||||
|
||||
const auto = evaluateInventoryMatch(workOrder, inventory, profile, {
|
||||
orderSpecOverride: ORDER_VALUES,
|
||||
matchScope: 'auto',
|
||||
})
|
||||
assert.equal(auto.exact, false, '钻级更高在自动口径下不是精确')
|
||||
assert.equal(auto.partial, true, '钻级更高在自动口径下是非精确')
|
||||
assert.deepEqual(auto.conflicts, [])
|
||||
|
||||
const manual = evaluateInventoryMatch(workOrder, inventory, profile, {
|
||||
orderSpecOverride: ORDER_VALUES,
|
||||
matchScope: 'manual',
|
||||
})
|
||||
assert.equal(manual.exact, false, '手动口径默认要求钻级精确相等')
|
||||
assert.equal(manual.partial, false)
|
||||
assert.ok(
|
||||
manual.conflicts.some((item) => item.includes('钻石等级')),
|
||||
'手动口径必须把钻级差异标成冲突',
|
||||
)
|
||||
})
|
||||
|
||||
test('匹配口径:手动设为「仅展示,不参与匹配」后,钻级差异不再影响精确', () => {
|
||||
const profile = buildProfile(
|
||||
BASE_SPECS.map((spec) =>
|
||||
spec.key === 'diamondLevel' ? { ...spec, manualMatchMode: 'ignore' as const } : spec,
|
||||
),
|
||||
)
|
||||
|
||||
const manual = evaluateInventoryMatch(
|
||||
buildWorkOrder(),
|
||||
buildInventory({ diamond_level: 3 }),
|
||||
profile,
|
||||
{ orderSpecOverride: ORDER_VALUES, matchScope: 'manual' },
|
||||
)
|
||||
|
||||
assert.equal(manual.exact, true, '忽略钻级后应回到精确匹配')
|
||||
assert.deepEqual(manual.conflicts, [])
|
||||
})
|
||||
|
||||
test('匹配口径:区服通配在手动口径下仍然生效', () => {
|
||||
const profile = buildProfile(BASE_SPECS)
|
||||
const manual = evaluateInventoryMatch(
|
||||
buildWorkOrder(),
|
||||
buildInventory({ diamond_level: 2, server_zone: '微信区' }),
|
||||
profile,
|
||||
{ orderSpecOverride: ORDER_VALUES, matchScope: 'manual' },
|
||||
)
|
||||
|
||||
assert.equal(manual.exact, true, '互通通配值应让微信区库存算精确')
|
||||
assert.deepEqual(manual.conflicts, [])
|
||||
})
|
||||
|
||||
test('匹配口径:硬冲突(系列不同)在任何口径下都判冲突', () => {
|
||||
const profile = buildProfile(BASE_SPECS)
|
||||
const inventory = buildInventory({ diamond_level: 2, series: '怒嚎' })
|
||||
|
||||
const manual = evaluateInventoryMatch(buildWorkOrder(), inventory, profile, {
|
||||
orderSpecOverride: ORDER_VALUES,
|
||||
matchScope: 'manual',
|
||||
})
|
||||
|
||||
assert.equal(manual.exact, false)
|
||||
assert.ok(manual.conflicts.some((item) => item.includes('系列')))
|
||||
})
|
||||
|
||||
test('手动口径必须跟随当前配置,不能被库存入库快照覆盖', () => {
|
||||
const profile = buildProfile(
|
||||
BASE_SPECS.map((spec) =>
|
||||
spec.key === 'diamondLevel'
|
||||
? { ...spec, matchMode: 'minimum' as const, manualMatchMode: 'ignore' as const }
|
||||
: spec,
|
||||
),
|
||||
)
|
||||
// 入库时快照把钻石设成必须相等;手动口径必须按当前配置的 ignore 判定。
|
||||
const inventory = buildInventory({
|
||||
diamond_level: 3,
|
||||
profile_snapshot_json: {
|
||||
specs: BASE_SPECS.map((spec) =>
|
||||
spec.key === 'diamondLevel' ? { ...spec, manualMatchMode: 'exact' as const } : spec,
|
||||
),
|
||||
specSource: 'legacy',
|
||||
},
|
||||
})
|
||||
|
||||
const manual = evaluateInventoryMatch(buildWorkOrder(), inventory, profile, {
|
||||
orderSpecOverride: ORDER_VALUES,
|
||||
matchScope: 'manual',
|
||||
})
|
||||
|
||||
assert.equal(manual.exact, true, '当前配置忽略钻级后,旧快照不能把它判成冲突')
|
||||
})
|
||||
|
||||
test('历史配置归一化:没有 manualMatchMode 时默认精确相等,自动口径保持原值', () => {
|
||||
const specs = normalizeConsignmentSpecs([
|
||||
{ key: 'diamondLevel', label: '钻石等级', type: 'radio', matchMode: 'minimum' },
|
||||
{
|
||||
key: 'brillianceCount',
|
||||
label: '璀璨数',
|
||||
type: 'radio',
|
||||
matchMode: 'minimum',
|
||||
manualMatchMode: 'ignore',
|
||||
},
|
||||
])
|
||||
|
||||
assert.equal(specs[0]?.matchMode, 'minimum')
|
||||
assert.equal(specs[0]?.manualMatchMode, 'exact', '缺省手动口径必须是精确相等')
|
||||
assert.equal(specs[1]?.manualMatchMode, 'ignore', '显式配置的手动口径必须保留')
|
||||
})
|
||||
|
||||
test('品类门禁:配置再宽也不让手枪库存匹配其它品类订单', () => {
|
||||
const profile = buildProfile(
|
||||
BASE_SPECS.map((spec) => ({
|
||||
...spec,
|
||||
matchMode: 'ignore' as const,
|
||||
manualMatchMode: 'ignore' as const,
|
||||
})),
|
||||
)
|
||||
const workOrder = {
|
||||
...buildWorkOrder(),
|
||||
product_name: '和平精英 特种兵密钥 通行证',
|
||||
} as unknown as WorkOrderRow
|
||||
|
||||
const evaluation = evaluateInventoryMatch(workOrder, buildInventory(), profile, {
|
||||
orderSpecOverride: {},
|
||||
matchScope: 'manual',
|
||||
})
|
||||
|
||||
assert.equal(evaluation.exact, false)
|
||||
assert.ok(evaluation.conflicts.length > 0, '跨品类必须留下冲突原因')
|
||||
})
|
||||
@@ -402,7 +402,10 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
|
||||
if (profile) {
|
||||
if (!inventoryMatchesProfile(inventory, profile)) continue
|
||||
const evaluation = evaluateInventoryMatch(order, inventory, profile, { orderSpecOverride })
|
||||
const evaluation = evaluateInventoryMatch(order, inventory, profile, {
|
||||
orderSpecOverride,
|
||||
matchScope: 'manual',
|
||||
})
|
||||
// 与引擎同口径:只有正向规格信号存在时,非精确才可能被自动匹配选中。
|
||||
positiveSpecSignal = hasPositiveSpecSignal(order, evaluation)
|
||||
if (evaluation.details.length === 0) {
|
||||
|
||||
@@ -43,6 +43,11 @@ export type ConsignmentSpecField = {
|
||||
placeholder: string
|
||||
helpText: string
|
||||
matchMode: ConsignmentSpecMatchMode
|
||||
/**
|
||||
* 手动匹配口径(后台「内部库存匹配」/ 一键查询待匹配订单)。
|
||||
* 缺省按「精确相等」处理:自动匹配可以放宽,但人工指定必须逐字段确认。
|
||||
*/
|
||||
manualMatchMode?: ConsignmentSpecMatchMode
|
||||
wildcardValues: string[]
|
||||
showInItemName: boolean
|
||||
/**
|
||||
@@ -355,6 +360,14 @@ function normalizeOptions(value: unknown, key: string): ConsignmentSpecOption[]
|
||||
return [{ label: key, value: key, enabled: true }]
|
||||
}
|
||||
|
||||
function resolveSpecMatchMode(value: unknown): ConsignmentSpecMatchMode {
|
||||
const raw = text(value, 20)
|
||||
if (raw === 'minimum') return 'minimum'
|
||||
if (raw === 'wildcard') return 'wildcard'
|
||||
if (raw === 'ignore') return 'ignore'
|
||||
return 'exact'
|
||||
}
|
||||
|
||||
function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField | null {
|
||||
const source = record(value)
|
||||
const key = text(source.key, 40)
|
||||
@@ -362,15 +375,9 @@ function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField
|
||||
|
||||
const type = resolveSpecType(source.type)
|
||||
const options = type === 'select' || type === 'radio' ? normalizeOptions(source.options, key) : []
|
||||
const rawMatchMode = text(source.matchMode ?? source.match_mode, 20)
|
||||
const matchMode: ConsignmentSpecMatchMode =
|
||||
rawMatchMode === 'minimum'
|
||||
? 'minimum'
|
||||
: rawMatchMode === 'wildcard'
|
||||
? 'wildcard'
|
||||
: rawMatchMode === 'ignore'
|
||||
? 'ignore'
|
||||
: 'exact'
|
||||
const matchMode = resolveSpecMatchMode(source.matchMode ?? source.match_mode)
|
||||
// 历史配置没有手动口径:默认「精确相等」,手动匹配比自动更严格。
|
||||
const manualMatchMode = resolveSpecMatchMode(source.manualMatchMode ?? source.manual_match_mode)
|
||||
const wildcardValues = stringArray(source.wildcardValues ?? source.wildcard_values)
|
||||
const fallbackWildcards =
|
||||
wildcardValues.length > 0
|
||||
@@ -390,6 +397,7 @@ function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField
|
||||
placeholder: text(source.placeholder, 120),
|
||||
helpText: text(source.helpText ?? source.help_text, 300),
|
||||
matchMode,
|
||||
manualMatchMode,
|
||||
wildcardValues: fallbackWildcards,
|
||||
showInItemName: bool(source.showInItemName ?? source.show_in_item_name, true),
|
||||
// 缺省时选项类字段可直接筛选(苏尔南等存量配置无需重新发布即可用);
|
||||
@@ -1032,6 +1040,18 @@ export function resolveLegacyConsignmentColumns(values: ConsignmentSpecValues):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按匹配口径取字段定义:自动匹配用 matchMode,手动匹配用 manualMatchMode。
|
||||
* 手动口径缺省为「精确相等」,避免历史配置在人工匹配时被宽松放过。
|
||||
*/
|
||||
export function resolveScopedConsignmentSpecs(
|
||||
specs: ConsignmentSpecField[],
|
||||
scope: 'auto' | 'manual',
|
||||
): ConsignmentSpecField[] {
|
||||
if (scope !== 'manual') return specs
|
||||
return specs.map((spec) => ({ ...spec, matchMode: spec.manualMatchMode ?? 'exact' }))
|
||||
}
|
||||
|
||||
/** 仅用于旧匹配器兼容判断:字段 key 是否属于历史五字段。 */
|
||||
export function isBuiltInConsignmentSpecField(field: ConsignmentSpecField): boolean {
|
||||
return isBuiltInSpecKey(field.key)
|
||||
|
||||
Reference in New Issue
Block a user