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,
|
evaluateConsignmentSpecMatch,
|
||||||
isBuiltInConsignmentSpecField,
|
isBuiltInConsignmentSpecField,
|
||||||
parseConsignmentSpecValues,
|
parseConsignmentSpecValues,
|
||||||
|
resolveScopedConsignmentSpecs,
|
||||||
type ConsignmentSpecField,
|
type ConsignmentSpecField,
|
||||||
type ConsignmentSpecValues,
|
type ConsignmentSpecValues,
|
||||||
} from './consignment-spec-service.js'
|
} from './consignment-spec-service.js'
|
||||||
import {
|
import {
|
||||||
classifyConsignmentMatch,
|
classifyConsignmentMatch,
|
||||||
|
detectItemCategory,
|
||||||
extractPeacekeeperSpecs,
|
extractPeacekeeperSpecs,
|
||||||
} from './consignment-matching-service.js'
|
} from './consignment-matching-service.js'
|
||||||
import { resolveOrderTemplateSpecValues } from './consignment-template-spec-service.js'
|
import { resolveOrderTemplateSpecValues } from './consignment-template-spec-service.js'
|
||||||
@@ -68,6 +70,21 @@ const MANUAL_CONSIGNMENT_MATCHABLE_STATUSES: string[] = [
|
|||||||
WORK_ORDER_STATUS.OPEN,
|
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> {
|
function parseMaterial(value: unknown): Record<string, unknown> {
|
||||||
const parsed = safeParseJson(value)
|
const parsed = safeParseJson(value)
|
||||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
@@ -293,40 +310,29 @@ export function evaluateInventoryMatch(
|
|||||||
workOrder: WorkOrderRow,
|
workOrder: WorkOrderRow,
|
||||||
inventory: ConsignmentInventoryRow,
|
inventory: ConsignmentInventoryRow,
|
||||||
profile: ConsignmentProfileSnapshot | null,
|
profile: ConsignmentProfileSnapshot | null,
|
||||||
options: { orderSpecOverride?: ConsignmentSpecValues | null } = {},
|
options: {
|
||||||
|
orderSpecOverride?: ConsignmentSpecValues | null
|
||||||
|
/** 自动匹配用 matchMode;手动匹配用 manualMatchMode(缺省精确相等)。 */
|
||||||
|
matchScope?: 'auto' | 'manual'
|
||||||
|
} = {},
|
||||||
): InventoryMatchEvaluation {
|
): InventoryMatchEvaluation {
|
||||||
// 模板驱动配置的规格定义是「分类级事实」:一律用 profile 的 specs,
|
// 模板驱动配置的规格定义是「分类级事实」:一律用 profile 的 specs,
|
||||||
// 不随库存的 profile_snapshot_json 回退到旧字段,否则旧快照库存会绕过模板匹配。
|
// 不随库存的 profile_snapshot_json 回退到旧字段,否则旧快照库存会绕过模板匹配。
|
||||||
|
// 手动匹配同样必须跟随「当前」配置:运营改完手动口径后,旧库存不能还按入库快照判定。
|
||||||
const isTemplateDriven = profile?.config.specSource === 'template'
|
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 =
|
const orderValues =
|
||||||
options.orderSpecOverride !== undefined && options.orderSpecOverride !== null
|
options.orderSpecOverride !== undefined && options.orderSpecOverride !== null
|
||||||
? { ...options.orderSpecOverride }
|
? { ...options.orderSpecOverride }
|
||||||
: resolveOrderSpecValues(workOrder)
|
: resolveOrderSpecValues(workOrder)
|
||||||
const values = resolveInventorySpecValues(inventory)
|
const values = resolveInventorySpecValues(inventory)
|
||||||
const hasCustomSpecs = specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
|
|
||||||
|
|
||||||
if (isTemplateDriven) {
|
if (scopedSpecs.length === 0) {
|
||||||
// 模板驱动规格:订单侧规格来自 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 规格沿用原有苏尔南匹配器(通配、低配不能充高配、品类门禁);
|
|
||||||
// 只有出现自定义规格时才走通用匹配,避免存量行为回归。
|
|
||||||
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
|
const orderSpecs = extractPeacekeeperSpecs(workOrder.product_name, {
|
||||||
system: String(orderValues.system || ''),
|
system: String(orderValues.system || ''),
|
||||||
serverZone: String(orderValues.serverZone || ''),
|
serverZone: String(orderValues.serverZone || ''),
|
||||||
@@ -334,7 +340,7 @@ export function evaluateInventoryMatch(
|
|||||||
})
|
})
|
||||||
const strict = classifyConsignmentMatch(orderSpecs, inventory)
|
const strict = classifyConsignmentMatch(orderSpecs, inventory)
|
||||||
return {
|
return {
|
||||||
specs,
|
specs: scopedSpecs,
|
||||||
values,
|
values,
|
||||||
orderValues,
|
orderValues,
|
||||||
exact: strict.matchType === 'exact',
|
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 {
|
return {
|
||||||
specs,
|
specs: scopedSpecs,
|
||||||
values,
|
values,
|
||||||
orderValues,
|
orderValues,
|
||||||
exact: evaluation.exact,
|
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(
|
export async function resolveProfileForWorkOrder(
|
||||||
workOrder: WorkOrderRow,
|
workOrder: WorkOrderRow,
|
||||||
): Promise<ConsignmentProfileSnapshot | null> {
|
): Promise<ConsignmentProfileSnapshot | null> {
|
||||||
@@ -772,6 +838,7 @@ function pickCandidateInventories(
|
|||||||
inventory,
|
inventory,
|
||||||
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
|
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
|
||||||
orderSpecOverride: options.orderSpecOverride ?? null,
|
orderSpecOverride: options.orderSpecOverride ?? null,
|
||||||
|
matchScope: 'auto',
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
.filter(({ evaluation }) => {
|
.filter(({ evaluation }) => {
|
||||||
@@ -1398,12 +1465,9 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
|||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
||||||
}
|
}
|
||||||
if (!MANUAL_CONSIGNMENT_MATCHABLE_STATUSES.includes(workOrder.status)) {
|
// 状态不满足时不再直接报错:只读返回全部候选,让运营仍能搜索库存;
|
||||||
throw createHttpError('当前工单状态不允许匹配寄卖库存', {
|
// 「确认匹配」由前端禁用、提交接口再次拦截。
|
||||||
statusCode: 409,
|
const availability = resolveManualMatchAvailability(workOrder.status)
|
||||||
errorCode: 'work_order_not_matchable',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||||
const orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile)
|
const orderSpecOverride = await resolveOrderSpecOverride(workOrder, profile)
|
||||||
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
|
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
|
||||||
@@ -1419,6 +1483,7 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
|||||||
inventory,
|
inventory,
|
||||||
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
|
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
|
||||||
orderSpecOverride,
|
orderSpecOverride,
|
||||||
|
matchScope: 'manual',
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
@@ -1428,6 +1493,8 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
|||||||
const options = evaluated.map(({ inventory, evaluation }) => ({
|
const options = evaluated.map(({ inventory, evaluation }) => ({
|
||||||
inventoryId: Number(inventory.id),
|
inventoryId: Number(inventory.id),
|
||||||
inventoryNo: inventory.inventory_no,
|
inventoryNo: inventory.inventory_no,
|
||||||
|
/** 寄卖单号口径:提报单号优先,客服手工入库的现货回退库存号 */
|
||||||
|
consignmentNo: inventory.source_submission_no || inventory.inventory_no,
|
||||||
itemName: inventory.item_name,
|
itemName: inventory.item_name,
|
||||||
system: inventory.system,
|
system: inventory.system,
|
||||||
serverZone: inventory.server_zone,
|
serverZone: inventory.server_zone,
|
||||||
@@ -1470,6 +1537,9 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
|||||||
sharingEnabled: Boolean(workOrder.sharing_enabled),
|
sharingEnabled: Boolean(workOrder.sharing_enabled),
|
||||||
totalQuantity: Math.max(1, Number(workOrder.sharing_total_quantity || 1)),
|
totalQuantity: Math.max(1, Number(workOrder.sharing_total_quantity || 1)),
|
||||||
activeQuantity: Number(active.rows[0]?.active || 0),
|
activeQuantity: Number(active.rows[0]?.active || 0),
|
||||||
|
matchable: availability.matchable,
|
||||||
|
blockedReason: availability.blockedReason,
|
||||||
|
workOrderStatus: String(workOrder.status || ''),
|
||||||
options,
|
options,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1572,6 +1642,7 @@ async function runManualConsignmentMatch(
|
|||||||
inventory,
|
inventory,
|
||||||
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
|
evaluation: evaluateInventoryMatch(workOrder, inventory, profile, {
|
||||||
orderSpecOverride,
|
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 (profile) {
|
||||||
if (!inventoryMatchesProfile(inventory, profile)) continue
|
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)
|
positiveSpecSignal = hasPositiveSpecSignal(order, evaluation)
|
||||||
if (evaluation.details.length === 0) {
|
if (evaluation.details.length === 0) {
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export type ConsignmentSpecField = {
|
|||||||
placeholder: string
|
placeholder: string
|
||||||
helpText: string
|
helpText: string
|
||||||
matchMode: ConsignmentSpecMatchMode
|
matchMode: ConsignmentSpecMatchMode
|
||||||
|
/**
|
||||||
|
* 手动匹配口径(后台「内部库存匹配」/ 一键查询待匹配订单)。
|
||||||
|
* 缺省按「精确相等」处理:自动匹配可以放宽,但人工指定必须逐字段确认。
|
||||||
|
*/
|
||||||
|
manualMatchMode?: ConsignmentSpecMatchMode
|
||||||
wildcardValues: string[]
|
wildcardValues: string[]
|
||||||
showInItemName: boolean
|
showInItemName: boolean
|
||||||
/**
|
/**
|
||||||
@@ -355,6 +360,14 @@ function normalizeOptions(value: unknown, key: string): ConsignmentSpecOption[]
|
|||||||
return [{ label: key, value: key, enabled: true }]
|
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 {
|
function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField | null {
|
||||||
const source = record(value)
|
const source = record(value)
|
||||||
const key = text(source.key, 40)
|
const key = text(source.key, 40)
|
||||||
@@ -362,15 +375,9 @@ function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField
|
|||||||
|
|
||||||
const type = resolveSpecType(source.type)
|
const type = resolveSpecType(source.type)
|
||||||
const options = type === 'select' || type === 'radio' ? normalizeOptions(source.options, key) : []
|
const options = type === 'select' || type === 'radio' ? normalizeOptions(source.options, key) : []
|
||||||
const rawMatchMode = text(source.matchMode ?? source.match_mode, 20)
|
const matchMode = resolveSpecMatchMode(source.matchMode ?? source.match_mode)
|
||||||
const matchMode: ConsignmentSpecMatchMode =
|
// 历史配置没有手动口径:默认「精确相等」,手动匹配比自动更严格。
|
||||||
rawMatchMode === 'minimum'
|
const manualMatchMode = resolveSpecMatchMode(source.manualMatchMode ?? source.manual_match_mode)
|
||||||
? 'minimum'
|
|
||||||
: rawMatchMode === 'wildcard'
|
|
||||||
? 'wildcard'
|
|
||||||
: rawMatchMode === 'ignore'
|
|
||||||
? 'ignore'
|
|
||||||
: 'exact'
|
|
||||||
const wildcardValues = stringArray(source.wildcardValues ?? source.wildcard_values)
|
const wildcardValues = stringArray(source.wildcardValues ?? source.wildcard_values)
|
||||||
const fallbackWildcards =
|
const fallbackWildcards =
|
||||||
wildcardValues.length > 0
|
wildcardValues.length > 0
|
||||||
@@ -390,6 +397,7 @@ function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField
|
|||||||
placeholder: text(source.placeholder, 120),
|
placeholder: text(source.placeholder, 120),
|
||||||
helpText: text(source.helpText ?? source.help_text, 300),
|
helpText: text(source.helpText ?? source.help_text, 300),
|
||||||
matchMode,
|
matchMode,
|
||||||
|
manualMatchMode,
|
||||||
wildcardValues: fallbackWildcards,
|
wildcardValues: fallbackWildcards,
|
||||||
showInItemName: bool(source.showInItemName ?? source.show_in_item_name, true),
|
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 是否属于历史五字段。 */
|
/** 仅用于旧匹配器兼容判断:字段 key 是否属于历史五字段。 */
|
||||||
export function isBuiltInConsignmentSpecField(field: ConsignmentSpecField): boolean {
|
export function isBuiltInConsignmentSpecField(field: ConsignmentSpecField): boolean {
|
||||||
return isBuiltInSpecKey(field.key)
|
return isBuiltInSpecKey(field.key)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ReloadOutlined } from '@ant-design/icons'
|
import { ReloadOutlined } from '@ant-design/icons'
|
||||||
import { Alert, Button, Checkbox, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
|
import { Alert, Button, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
|
||||||
import type { TableColumnsType } from 'antd'
|
import type { TableColumnsType } from 'antd'
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
import PercentTable from '@/components/common/PercentTable'
|
import PercentTable from '@/components/common/PercentTable'
|
||||||
import { fetchPendingPistolMatches } from '@/services/admin/consignment'
|
import { fetchPendingPistolMatches } from '@/services/admin/consignment'
|
||||||
import type { PendingPistolMatchPair } from '@/types/consignment'
|
import type { PendingPistolMatchPair } from '@/types/consignment'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
|
|
||||||
|
import ConsignmentWorkOrderMatchModal from './ConsignmentWorkOrderMatchModal'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
function resolveScoreTag(pair: PendingPistolMatchPair) {
|
function resolveScoreTag(pair: PendingPistolMatchPair) {
|
||||||
@@ -36,23 +38,33 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
onClose,
|
onClose,
|
||||||
categoryId,
|
categoryId,
|
||||||
categoryName,
|
categoryName,
|
||||||
|
canMatch = false,
|
||||||
|
onMatched,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
/** 只统计该寄卖分类;不传 = 全部 */
|
/** 只统计该寄卖分类;不传 = 全部 */
|
||||||
categoryId?: number | null
|
categoryId?: number | null
|
||||||
categoryName?: string
|
categoryName?: string
|
||||||
|
/** 有 consignment.fulfill 权限时才显示「选择」列 */
|
||||||
|
canMatch?: boolean
|
||||||
|
/** 快速匹配成功后通知外层刷新库存 */
|
||||||
|
onMatched?: () => void
|
||||||
}) {
|
}) {
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [includeConflicts, setIncludeConflicts] = useState(false)
|
const [selectTarget, setSelectTarget] = useState<{
|
||||||
|
workOrderId: number
|
||||||
|
inventoryId: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['admin-pending-pistol-matches', categoryId ?? null, keyword, includeConflicts],
|
queryKey: ['admin-pending-pistol-matches', categoryId ?? null, keyword],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchPendingPistolMatches({
|
fetchPendingPistolMatches({
|
||||||
categoryId: categoryId ?? undefined,
|
categoryId: categoryId ?? undefined,
|
||||||
keyword,
|
keyword,
|
||||||
includeConflicts,
|
// 默认展示全部配对:冲突项(0 分)也要能看到,便于人工核对。
|
||||||
|
includeConflicts: true,
|
||||||
limit: 300,
|
limit: 300,
|
||||||
}),
|
}),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
@@ -60,7 +72,11 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
const data = query.data?.data
|
const data = query.data?.data
|
||||||
const pairs = data?.pairs || []
|
// 后端已按分数降序;这里再按打分排一次,保证调整排序口径时界面始终一致。
|
||||||
|
const pairs = useMemo(
|
||||||
|
() => [...(data?.pairs || [])].sort((left, right) => right.score - left.score),
|
||||||
|
[data?.pairs],
|
||||||
|
)
|
||||||
|
|
||||||
const columns: TableColumnsType<PendingPistolMatchPair> = [
|
const columns: TableColumnsType<PendingPistolMatchPair> = [
|
||||||
{
|
{
|
||||||
@@ -225,6 +241,30 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
...(canMatch
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, record: PendingPistolMatchPair) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
onClick={() =>
|
||||||
|
setSelectTarget({
|
||||||
|
workOrderId: record.workOrderId,
|
||||||
|
inventoryId: record.inventoryId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
选择
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -246,10 +286,14 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
showIcon
|
showIcon
|
||||||
message={
|
message={
|
||||||
categoryName
|
categoryName
|
||||||
? `只统计「${categoryName}」分类下的待匹配订单 × 在库待匹配现货,不直接改单。`
|
? `只统计「${categoryName}」分类下的待匹配订单 × 在库待匹配现货。`
|
||||||
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货,不直接改单。'
|
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货。'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
canMatch
|
||||||
|
? '默认展示全部配对(含不匹配项),按打分从高到低排列;点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
|
||||||
|
: '默认展示全部配对(含不匹配项),按打分从高到低排列;当前账号没有履约权限,只能查看。'
|
||||||
}
|
}
|
||||||
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);内部匹配中、待分配、待抢单的工单都会纳入。数量不足或没有正向规格信号的非精确项会单独标记,自动匹配不会选中。"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Space wrap size={12}>
|
<Space wrap size={12}>
|
||||||
@@ -261,7 +305,7 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
<Tag color="orange">非精确 {data?.partialCount ?? 0}</Tag>
|
<Tag color="orange">非精确 {data?.partialCount ?? 0}</Tag>
|
||||||
<Tag color="purple">数量不足 {data?.quantityShortCount ?? 0}</Tag>
|
<Tag color="purple">数量不足 {data?.quantityShortCount ?? 0}</Tag>
|
||||||
<Tag color="cyan">可自动 {data?.autoMatchableCount ?? 0}</Tag>
|
<Tag color="cyan">可自动 {data?.autoMatchableCount ?? 0}</Tag>
|
||||||
{includeConflicts ? <Tag>不匹配 {data?.conflictCount ?? 0}</Tag> : null}
|
<Tag>不匹配 {data?.conflictCount ?? 0}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Space wrap size={12}>
|
<Space wrap size={12}>
|
||||||
@@ -271,12 +315,6 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
style={{ width: 420 }}
|
style={{ width: 420 }}
|
||||||
onSearch={(value) => setKeyword(value.trim())}
|
onSearch={(value) => setKeyword(value.trim())}
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
|
||||||
checked={includeConflicts}
|
|
||||||
onChange={(event) => setIncludeConflicts(event.target.checked)}
|
|
||||||
>
|
|
||||||
显示 0 分不匹配项
|
|
||||||
</Checkbox>
|
|
||||||
<Button
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
loading={query.isFetching}
|
loading={query.isFetching}
|
||||||
@@ -316,18 +354,23 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
showTotal: (total) => `共 ${total} 条`,
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
}}
|
}}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: (
|
emptyText: <Empty description="当前分类下没有可匹配组合" />,
|
||||||
<Empty
|
|
||||||
description={
|
|
||||||
includeConflicts
|
|
||||||
? '当前分类下没有可展示的配对'
|
|
||||||
: '当前分类下没有可匹配组合;可勾选「显示 0 分不匹配项」查看冲突'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
<ConsignmentWorkOrderMatchModal
|
||||||
|
key={
|
||||||
|
selectTarget ? `${selectTarget.workOrderId}-${selectTarget.inventoryId}` : 'pending-match'
|
||||||
|
}
|
||||||
|
open={Boolean(selectTarget)}
|
||||||
|
workOrderId={selectTarget?.workOrderId ?? null}
|
||||||
|
initialInventoryIds={selectTarget ? [selectTarget.inventoryId] : undefined}
|
||||||
|
onClose={() => setSelectTarget(null)}
|
||||||
|
onMatched={() => {
|
||||||
|
void query.refetch()
|
||||||
|
onMatched?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,4 @@
|
|||||||
import {
|
import { App, Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
|
||||||
App,
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
Empty,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
Space,
|
|
||||||
Tag,
|
|
||||||
Typography,
|
|
||||||
} from 'antd'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
@@ -30,6 +18,29 @@ function money(fen: number) {
|
|||||||
return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}`
|
return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_INVENTORY_IDS: number[] = []
|
||||||
|
|
||||||
|
/** 搜索框命中范围:单号 / 物品 / 规格 / 打手 / 匹配说明。 */
|
||||||
|
function buildCandidateHaystack(item: ConsignmentMatchCandidate): string {
|
||||||
|
return [
|
||||||
|
item.consignmentNo || '',
|
||||||
|
item.inventoryNo,
|
||||||
|
item.itemName,
|
||||||
|
item.specText || '',
|
||||||
|
Object.values(item.specValues || {}).join(' '),
|
||||||
|
item.workerName || '',
|
||||||
|
(item.matchReasons || []).join(' '),
|
||||||
|
describeConsignmentMatch(item),
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdList(left: number[], right: number[]): boolean {
|
||||||
|
if (left.length !== right.length) return false
|
||||||
|
return left.every((value, index) => value === right[index])
|
||||||
|
}
|
||||||
|
|
||||||
function resolveScoreTag(item: ConsignmentMatchCandidate) {
|
function resolveScoreTag(item: ConsignmentMatchCandidate) {
|
||||||
const type = item.matchType || 'exact'
|
const type = item.matchType || 'exact'
|
||||||
const score = computeConsignmentMatchScore(item)
|
const score = computeConsignmentMatchScore(item)
|
||||||
@@ -43,19 +54,23 @@ function resolveScoreTag(item: ConsignmentMatchCandidate) {
|
|||||||
export default function ConsignmentWorkOrderMatchModal({
|
export default function ConsignmentWorkOrderMatchModal({
|
||||||
open,
|
open,
|
||||||
workOrderId,
|
workOrderId,
|
||||||
|
initialInventoryIds,
|
||||||
onClose,
|
onClose,
|
||||||
onMatched,
|
onMatched,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
workOrderId: number | null
|
workOrderId: number | null
|
||||||
|
/** 外部带入的预选库存(例如待匹配看板点「选择」),允许是非精确项 */
|
||||||
|
initialInventoryIds?: number[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onMatched?: () => void
|
onMatched?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||||
const [includeConflicts, setIncludeConflicts] = useState(false)
|
const [keyword, setKeyword] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [nonExactReason, setNonExactReason] = useState('')
|
const [nonExactReason, setNonExactReason] = useState('')
|
||||||
|
const presetInventoryKey = (initialInventoryIds || EMPTY_INVENTORY_IDS).join(',')
|
||||||
|
|
||||||
const candidatesQuery = useQuery({
|
const candidatesQuery = useQuery({
|
||||||
queryKey: ['admin-consignment-match-candidates', workOrderId],
|
queryKey: ['admin-consignment-match-candidates', workOrderId],
|
||||||
@@ -70,17 +85,25 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1))
|
const totalQuantity = Math.max(1, Number(data?.totalQuantity || 1))
|
||||||
const activeQuantity = Math.max(0, Number(data?.activeQuantity || 0))
|
const activeQuantity = Math.max(0, Number(data?.activeQuantity || 0))
|
||||||
const sharingEnabled = Boolean(data?.sharingEnabled)
|
const sharingEnabled = Boolean(data?.sharingEnabled)
|
||||||
|
// 状态不允许时接口只读返回候选:仍可搜索/核对库存,但禁止提交。
|
||||||
|
const matchable = data?.matchable !== false
|
||||||
|
const blockedReason = data?.blockedReason || '当前工单状态不允许匹配寄卖库存'
|
||||||
const remainingNeeded = sharingEnabled
|
const remainingNeeded = sharingEnabled
|
||||||
? Math.max(0, totalQuantity - activeQuantity)
|
? Math.max(0, totalQuantity - activeQuantity)
|
||||||
: totalQuantity
|
: totalQuantity
|
||||||
|
|
||||||
const visibleOptions = useMemo(
|
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||||
() =>
|
// 默认展示全部候选,按匹配打分降序;关键词只做过滤,不改变排序口径。
|
||||||
includeConflicts
|
const visibleOptions = useMemo(() => {
|
||||||
? options
|
const filtered = normalizedKeyword
|
||||||
: options.filter((item) => (item.matchType || 'exact') !== 'conflict'),
|
? options.filter((item) => buildCandidateHaystack(item).includes(normalizedKeyword))
|
||||||
[includeConflicts, options],
|
: 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(
|
const selectedOptions = useMemo(
|
||||||
() => options.filter((item) => selectedIds.includes(item.inventoryId)),
|
() => options.filter((item) => selectedIds.includes(item.inventoryId)),
|
||||||
@@ -117,7 +140,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedIds((prev) => (prev.length ? [] : prev))
|
setSelectedIds((prev) => (prev.length ? [] : prev))
|
||||||
setIncludeConflicts(false)
|
setKeyword('')
|
||||||
setNonExactReason('')
|
setNonExactReason('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -126,6 +149,16 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 外部预选优先:待匹配看板点「选择」时直接勾中对应库存(可能是非精确项)。
|
||||||
|
if (presetInventoryKey) {
|
||||||
|
const presetIds = presetInventoryKey
|
||||||
|
.split(',')
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((id) => Number.isInteger(id) && id > 0)
|
||||||
|
setSelectedIds((prev) => (sameIdList(prev, presetIds) ? prev : presetIds))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 默认只选精确库存;整单必须一条满足总量,拼单按剩余份数累加到一次拼满。
|
// 默认只选精确库存;整单必须一条满足总量,拼单按剩余份数累加到一次拼满。
|
||||||
const exactOptions = options.filter(
|
const exactOptions = options.filter(
|
||||||
(item) => (item.matchType || 'exact') === 'exact' && Number(item.remainingQuantity || 0) > 0,
|
(item) => (item.matchType || 'exact') === 'exact' && Number(item.remainingQuantity || 0) > 0,
|
||||||
@@ -134,7 +167,8 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
const chosen = exactOptions.find(
|
const chosen = exactOptions.find(
|
||||||
(item) => Number(item.remainingQuantity || 0) >= totalQuantity,
|
(item) => Number(item.remainingQuantity || 0) >= totalQuantity,
|
||||||
)
|
)
|
||||||
setSelectedIds(chosen ? [chosen.inventoryId] : [])
|
const nextIds = chosen ? [chosen.inventoryId] : []
|
||||||
|
setSelectedIds((prev) => (sameIdList(prev, nextIds) ? prev : nextIds))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,20 +179,8 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
ids.push(item.inventoryId)
|
ids.push(item.inventoryId)
|
||||||
covered += Math.max(0, Number(item.remainingQuantity || 0))
|
covered += Math.max(0, Number(item.remainingQuantity || 0))
|
||||||
}
|
}
|
||||||
setSelectedIds(ids)
|
setSelectedIds((prev) => (sameIdList(prev, ids) ? prev : ids))
|
||||||
}, [open, options, remainingNeeded, sharingEnabled, totalQuantity])
|
}, [open, options, presetInventoryKey, remainingNeeded, sharingEnabled, totalQuantity])
|
||||||
|
|
||||||
// 隐藏不匹配项时,顺手清掉已选中的冲突库存,避免提交到不可见的行。
|
|
||||||
useEffect(() => {
|
|
||||||
if (includeConflicts) return
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = prev.filter((id) => {
|
|
||||||
const item = options.find((option) => option.inventoryId === id)
|
|
||||||
return !item || (item.matchType || 'exact') !== 'conflict'
|
|
||||||
})
|
|
||||||
return next.length === prev.length ? prev : next
|
|
||||||
})
|
|
||||||
}, [includeConflicts, options])
|
|
||||||
|
|
||||||
const nonExactSelected = useMemo(
|
const nonExactSelected = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -170,6 +192,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
|
matchable &&
|
||||||
selectedIds.length > 0 &&
|
selectedIds.length > 0 &&
|
||||||
coverage.sufficient &&
|
coverage.sufficient &&
|
||||||
(!nonExactSelected || nonExactReason.trim().length > 0)
|
(!nonExactSelected || nonExactReason.trim().length > 0)
|
||||||
@@ -240,11 +263,45 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!candidatesQuery.isLoading && !candidatesQuery.isError ? (
|
||||||
|
<Space wrap size={8} style={{ marginBottom: 12 }}>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手"
|
||||||
|
style={{ width: 320 }}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
显示 {visibleOptions.length} / 候选 {options.length} 条
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
{!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0 ? (
|
{!candidatesQuery.isLoading && !candidatesQuery.isError && options.length === 0 ? (
|
||||||
<Empty description="没有同配置的在库寄卖库存" />
|
<Empty description="没有同配置的在库寄卖库存" />
|
||||||
) : null}
|
) : null}
|
||||||
{!candidatesQuery.isError && options.length > 0 ? (
|
{!candidatesQuery.isLoading &&
|
||||||
|
!candidatesQuery.isError &&
|
||||||
|
options.length > 0 &&
|
||||||
|
visibleOptions.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
keyword.trim()
|
||||||
|
? '没有匹配该关键词的库存,换个寄卖单号/库存号/物品/打手试试'
|
||||||
|
: '没有可展示的库存'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!candidatesQuery.isError && visibleOptions.length > 0 ? (
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
{!matchable ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={blockedReason}
|
||||||
|
description="当前仍可搜索和核对库存;补充资料/重新发布到待分配或待抢单后才能提交匹配。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{data?.matchingMode === 'off' ? (
|
{data?.matchingMode === 'off' ? (
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -257,30 +314,23 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
type={remainingNeeded <= 0 ? 'warning' : 'info'}
|
type={remainingNeeded <= 0 ? 'warning' : 'info'}
|
||||||
showIcon
|
showIcon
|
||||||
message={infoMessage}
|
message={infoMessage}
|
||||||
description="待分配工单也可直接人工匹配;候选仍按寄卖配置打分,非精确/冲突库存必须人工勾选并填写原因。拼单必须一次拼满,失败不会占用任何库存。"
|
description="默认按匹配打分从高到低展示全部候选;非精确/冲突必须填写人工原因;拼单必须一次拼满,失败不会占用任何库存。"
|
||||||
/>
|
/>
|
||||||
<Space wrap size={8}>
|
<Space wrap size={8}>
|
||||||
<Tag color="blue">
|
<Tag color="blue">
|
||||||
{sharingEnabled ? `待匹配 ${remainingNeeded} 份` : `整单 ${totalQuantity} 件`}
|
{sharingEnabled ? `待匹配 ${remainingNeeded} 份` : `整单 ${totalQuantity} 件`}
|
||||||
</Tag>
|
</Tag>
|
||||||
{sharingEnabled ? <Tag>已匹配 {activeQuantity} 份</Tag> : null}
|
{sharingEnabled ? <Tag>已匹配 {activeQuantity} 份</Tag> : null}
|
||||||
<Tag>候选 {visibleOptions.length} 条</Tag>
|
|
||||||
<Tag color="green">精确 {counts.exact}</Tag>
|
<Tag color="green">精确 {counts.exact}</Tag>
|
||||||
<Tag color="orange">非精确 {counts.partial}</Tag>
|
<Tag color="orange">非精确 {counts.partial}</Tag>
|
||||||
<Tag>冲突 {counts.conflict}</Tag>
|
<Tag>冲突 {counts.conflict}</Tag>
|
||||||
<Checkbox
|
|
||||||
checked={includeConflicts}
|
|
||||||
onChange={(event) => setIncludeConflicts(event.target.checked)}
|
|
||||||
>
|
|
||||||
显示不匹配项
|
|
||||||
</Checkbox>
|
|
||||||
</Space>
|
</Space>
|
||||||
<PercentTable<ConsignmentMatchCandidate>
|
<PercentTable<ConsignmentMatchCandidate>
|
||||||
rowKey="inventoryId"
|
rowKey="inventoryId"
|
||||||
size="small"
|
size="small"
|
||||||
loading={candidatesQuery.isFetching}
|
loading={candidatesQuery.isFetching}
|
||||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||||
scroll={{ x: 1050, y: 420 }}
|
scroll={{ x: 1210, y: 420 }}
|
||||||
dataSource={visibleOptions}
|
dataSource={visibleOptions}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
// 整单只能选一条;拼单可多选,必须覆盖剩余份数才能提交。
|
// 整单只能选一条;拼单可多选,必须覆盖剩余份数才能提交。
|
||||||
@@ -306,6 +356,19 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '寄卖单号',
|
||||||
|
key: 'consignmentNo',
|
||||||
|
width: 170,
|
||||||
|
render: (_, item) => (
|
||||||
|
<Typography.Text
|
||||||
|
copyable={{ text: item.consignmentNo || item.inventoryNo }}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{item.consignmentNo || item.inventoryNo || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '寄卖库存',
|
title: '寄卖库存',
|
||||||
key: 'inventory',
|
key: 'inventory',
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ function nextCustomSpecKey(specs: ConsignmentSpecField[]): string {
|
|||||||
return `custom_${index}`
|
return `custom_${index}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SPEC_MATCH_MODE_OPTIONS = [
|
||||||
|
{ value: 'exact', label: '精确相等' },
|
||||||
|
{ value: 'minimum', label: '库存不低于订单' },
|
||||||
|
{ value: 'wildcard', label: '通配' },
|
||||||
|
{ value: 'ignore', label: '仅展示,不参与匹配' },
|
||||||
|
]
|
||||||
|
|
||||||
function newCustomSpec(specs: ConsignmentSpecField[]): ConsignmentSpecField {
|
function newCustomSpec(specs: ConsignmentSpecField[]): ConsignmentSpecField {
|
||||||
return {
|
return {
|
||||||
key: nextCustomSpecKey(specs),
|
key: nextCustomSpecKey(specs),
|
||||||
@@ -168,6 +175,7 @@ function newCustomSpec(specs: ConsignmentSpecField[]): ConsignmentSpecField {
|
|||||||
placeholder: '',
|
placeholder: '',
|
||||||
helpText: '',
|
helpText: '',
|
||||||
matchMode: 'exact',
|
matchMode: 'exact',
|
||||||
|
manualMatchMode: 'exact',
|
||||||
wildcardValues: [],
|
wildcardValues: [],
|
||||||
showInItemName: true,
|
showInItemName: true,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@@ -722,6 +730,7 @@ export default function ConsignmentConfigPanel() {
|
|||||||
placeholder: '',
|
placeholder: '',
|
||||||
helpText: '',
|
helpText: '',
|
||||||
matchMode: 'exact' as const,
|
matchMode: 'exact' as const,
|
||||||
|
manualMatchMode: 'exact' as const,
|
||||||
wildcardValues: [],
|
wildcardValues: [],
|
||||||
showInItemName: proposal.mode !== 'product',
|
showInItemName: proposal.mode !== 'product',
|
||||||
filterable: true,
|
filterable: true,
|
||||||
@@ -1429,20 +1438,25 @@ export default function ConsignmentConfigPanel() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="order-service-field-extra is-four">
|
<div className="order-service-field-extra is-match-modes">
|
||||||
<div style={fieldCellStyle}>
|
<div style={fieldCellStyle}>
|
||||||
<Text type="secondary">匹配方式</Text>
|
<Text type="secondary">自动匹配方式</Text>
|
||||||
<Select
|
<Select
|
||||||
value={spec.matchMode}
|
value={spec.matchMode}
|
||||||
options={[
|
options={SPEC_MATCH_MODE_OPTIONS}
|
||||||
{ value: 'exact', label: '精确相等' },
|
|
||||||
{ value: 'minimum', label: '库存不低于订单' },
|
|
||||||
{ value: 'wildcard', label: '通配' },
|
|
||||||
{ value: 'ignore', label: '仅展示,不参与匹配' },
|
|
||||||
]}
|
|
||||||
onChange={(value) => updateSpec(index, { matchMode: value })}
|
onChange={(value) => updateSpec(index, { matchMode: value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={fieldCellStyle}>
|
||||||
|
<Text type="secondary">手动匹配方式</Text>
|
||||||
|
<Select
|
||||||
|
value={spec.manualMatchMode ?? 'exact'}
|
||||||
|
options={SPEC_MATCH_MODE_OPTIONS}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateSpec(index, { manualMatchMode: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div style={fieldCellStyle}>
|
<div style={fieldCellStyle}>
|
||||||
<Text type="secondary">通配值</Text>
|
<Text type="secondary">通配值</Text>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1640,7 +1640,9 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
open={pendingMatchOpen}
|
open={pendingMatchOpen}
|
||||||
categoryId={categoryId}
|
categoryId={categoryId}
|
||||||
categoryName={activeFilterCategory?.name}
|
categoryName={activeFilterCategory?.name}
|
||||||
|
canMatch={canFulfillConsignment}
|
||||||
onClose={() => setPendingMatchOpen(false)}
|
onClose={() => setPendingMatchOpen(false)}
|
||||||
|
onMatched={() => void inventoriesQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConsignmentMatchLogsModal open={matchLogsOpen} onClose={() => setMatchLogsOpen(false)} />
|
<ConsignmentMatchLogsModal open={matchLogsOpen} onClose={() => setMatchLogsOpen(false)} />
|
||||||
|
|||||||
@@ -83,6 +83,17 @@ const DEPOSIT_TIP_KEY = 'worker-consignment-deposit-tip-seen'
|
|||||||
const ACTIVE_TAB_STORAGE_KEY = 'worker_consignment_active_tab'
|
const ACTIVE_TAB_STORAGE_KEY = 'worker_consignment_active_tab'
|
||||||
const RECORD_FILTER_STORAGE_KEY = 'worker_consignment_record_filter'
|
const RECORD_FILTER_STORAGE_KEY = 'worker_consignment_record_filter'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 稳定空数组:`data?.data.profiles || []` 每次渲染都新建引用,
|
||||||
|
* 会让下游 useMemo / useEffect 每轮都失效,最终触发 React #185 无限渲染。
|
||||||
|
*/
|
||||||
|
const EMPTY_CONSIGNMENT_PROFILES: WorkerConsignmentProfile[] = []
|
||||||
|
|
||||||
|
function isSameKeyList(left: Array<string | number>, right: Array<string | number>): boolean {
|
||||||
|
if (left.length !== right.length) return false
|
||||||
|
return left.every((value, index) => value === right[index])
|
||||||
|
}
|
||||||
|
|
||||||
function getInitialActiveTab(): 'publish' | 'records' {
|
function getInitialActiveTab(): 'publish' | 'records' {
|
||||||
if (typeof window === 'undefined') return 'publish'
|
if (typeof window === 'undefined') return 'publish'
|
||||||
try {
|
try {
|
||||||
@@ -252,7 +263,8 @@ export default function WorkerConsignmentPage() {
|
|||||||
queryKey: ['worker-consignment-profiles'],
|
queryKey: ['worker-consignment-profiles'],
|
||||||
queryFn: () => fetchWorkerConsignmentProfiles(),
|
queryFn: () => fetchWorkerConsignmentProfiles(),
|
||||||
})
|
})
|
||||||
const consignmentProfiles = consignmentProfilesQuery.data?.data.profiles || []
|
const consignmentProfiles =
|
||||||
|
consignmentProfilesQuery.data?.data.profiles ?? EMPTY_CONSIGNMENT_PROFILES
|
||||||
const orderedConsignmentProfiles = useMemo(
|
const orderedConsignmentProfiles = useMemo(
|
||||||
() => orderWorkerProfiles(consignmentProfiles),
|
() => orderWorkerProfiles(consignmentProfiles),
|
||||||
[consignmentProfiles],
|
[consignmentProfiles],
|
||||||
@@ -323,7 +335,10 @@ export default function WorkerConsignmentPage() {
|
|||||||
[profileTreeData],
|
[profileTreeData],
|
||||||
)
|
)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProfileExpandedKeys(profileGroupKeys)
|
// 内容相同就不 setState,避免 profileGroupKeys 重新计算时再次触发渲染。
|
||||||
|
setProfileExpandedKeys((prev) =>
|
||||||
|
isSameKeyList(prev, profileGroupKeys) ? prev : profileGroupKeys,
|
||||||
|
)
|
||||||
}, [profileGroupKeys])
|
}, [profileGroupKeys])
|
||||||
const selectedProfile: WorkerConsignmentProfile | null =
|
const selectedProfile: WorkerConsignmentProfile | null =
|
||||||
orderedConsignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
|
orderedConsignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
|
||||||
|
|||||||
@@ -2236,17 +2236,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 规格字段第 3 行(匹配方式 / 通配值 / 进物品名 / 支持筛选)共 4 格。
|
* 规格字段第 3 行:自动匹配方式 / 手动匹配方式 / 通配值 / 进物品名 / 支持筛选,共 5 格同行。
|
||||||
* 之前用 inline gridTemplateColumns 写死 3 列,把「支持筛选」挤到了下一行;
|
* 手动匹配方式刻意比自动窄(0.72fr),避免两个同等宽下拉把通配值挤没;
|
||||||
* inline 样式还会盖掉 960px 媒体查询,窄屏下这一行无法折行。
|
* 窄屏(960px 以下)按 2 列折行。
|
||||||
* 改成 CSS 类后:4 格同行,窄屏按 2×2 折行。
|
|
||||||
*/
|
*/
|
||||||
.order-service-field-extra.is-four {
|
.order-service-field-extra.is-match-modes {
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
minmax(110px, 0.8fr)
|
minmax(110px, 1fr)
|
||||||
minmax(200px, 1.5fr)
|
minmax(90px, 0.72fr)
|
||||||
minmax(90px, 0.7fr)
|
minmax(180px, 1.45fr)
|
||||||
minmax(90px, 0.7fr);
|
minmax(90px, 0.55fr)
|
||||||
|
minmax(90px, 0.55fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-service-field-extra-tip {
|
.order-service-field-extra-tip {
|
||||||
@@ -2386,7 +2386,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-service-field-extra,
|
.order-service-field-extra,
|
||||||
.order-service-field-extra.is-four,
|
.order-service-field-extra.is-match-modes,
|
||||||
.order-service-switch-grid,
|
.order-service-switch-grid,
|
||||||
.order-service-switch-grid.is-auto-publish,
|
.order-service-switch-grid.is-auto-publish,
|
||||||
.order-service-status-step-grid {
|
.order-service-status-step-grid {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export type ConsignmentSpecField = {
|
|||||||
placeholder: string
|
placeholder: string
|
||||||
helpText: string
|
helpText: string
|
||||||
matchMode: 'exact' | 'minimum' | 'wildcard' | 'ignore'
|
matchMode: 'exact' | 'minimum' | 'wildcard' | 'ignore'
|
||||||
|
/** 手动匹配口径(内部库存匹配 / 一键查询待匹配订单),缺省按精确相等 */
|
||||||
|
manualMatchMode?: 'exact' | 'minimum' | 'wildcard' | 'ignore'
|
||||||
wildcardValues: string[]
|
wildcardValues: string[]
|
||||||
showInItemName: boolean
|
showInItemName: boolean
|
||||||
/** 是否在后台「寄售仓库」作为筛选项(选项类字段默认开启) */
|
/** 是否在后台「寄售仓库」作为筛选项(选项类字段默认开启) */
|
||||||
@@ -546,6 +548,8 @@ export type WorkerConsignmentProfile = {
|
|||||||
export type ConsignmentMatchCandidate = {
|
export type ConsignmentMatchCandidate = {
|
||||||
inventoryId: number
|
inventoryId: number
|
||||||
inventoryNo: string
|
inventoryNo: string
|
||||||
|
/** 寄卖单号:提报单号(SUB…)优先,无提报单时回退库存号 */
|
||||||
|
consignmentNo?: string
|
||||||
itemName: string
|
itemName: string
|
||||||
system: string
|
system: string
|
||||||
serverZone: string
|
serverZone: string
|
||||||
@@ -582,5 +586,10 @@ export type ConsignmentMatchCandidatesResponse = {
|
|||||||
sharingEnabled: boolean
|
sharingEnabled: boolean
|
||||||
totalQuantity: number
|
totalQuantity: number
|
||||||
activeQuantity: number
|
activeQuantity: number
|
||||||
|
/** 当前工单是否允许真正落库匹配;false 时只能搜索/查看库存 */
|
||||||
|
matchable: boolean
|
||||||
|
/** matchable=false 时的原因文案 */
|
||||||
|
blockedReason: string
|
||||||
|
workOrderStatus: string
|
||||||
options: ConsignmentMatchCandidate[]
|
options: ConsignmentMatchCandidate[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,30 +58,32 @@ test('寄卖配置:规格字段提供「支持筛选」开关并随配置保
|
|||||||
assert.ok(configSource.includes('filterable: false,'), '新建字段默认不参与筛选')
|
assert.ok(configSource.includes('filterable: false,'), '新建字段默认不参与筛选')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('寄卖配置:「支持筛选」与「匹配方式」同行(第3行必须是4列栅格)', () => {
|
test('寄卖配置:「支持筛选」与两套匹配方式同行(第3行必须是5列栅格)', () => {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
configSource.includes('order-service-field-extra is-four'),
|
configSource.includes('order-service-field-extra is-match-modes'),
|
||||||
'规格字段第3行必须使用四列栅格类,支持筛选才会留在同一行',
|
'规格字段第3行必须使用 5 列栅格类,支持筛选才会留在同一行',
|
||||||
)
|
)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!configSource.includes("gridTemplateColumns: 'repeat(3, minmax(0, 1fr))'"),
|
!configSource.includes("gridTemplateColumns: 'repeat(3, minmax(0, 1fr))'"),
|
||||||
'不能再用3列 inline 栅格,否则支持筛选会被挤到下一行',
|
'不能再用3列 inline 栅格,否则支持筛选会被挤到下一行',
|
||||||
)
|
)
|
||||||
// 第3行的四格顺序固定:匹配方式 → 通配值 → 进物品名 → 支持筛选
|
// 第3行的五格顺序固定:自动 → 手动 → 通配值 → 进物品名 → 支持筛选
|
||||||
const rowStart = configSource.indexOf('order-service-field-extra is-four')
|
const rowStart = configSource.indexOf('order-service-field-extra is-match-modes')
|
||||||
const rowEnd = configSource.indexOf('</div>', configSource.indexOf('支持筛选', rowStart))
|
const rowEnd = configSource.indexOf('</div>', configSource.indexOf('支持筛选', rowStart))
|
||||||
const row = configSource.slice(rowStart, rowEnd)
|
const row = configSource.slice(rowStart, rowEnd)
|
||||||
const order = ['匹配方式', '通配值', '进物品名', '支持筛选'].map((label) => row.indexOf(label))
|
const order = ['自动匹配方式', '手动匹配方式', '通配值', '进物品名', '支持筛选'].map((label) =>
|
||||||
|
row.indexOf(label),
|
||||||
|
)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
order.every((index) => index >= 0) &&
|
order.every((index) => index >= 0) &&
|
||||||
order.every((index, i) => i === 0 || index > order[i - 1]!),
|
order.every((index, i) => i === 0 || index > order[i - 1]!),
|
||||||
`第3行四格顺序必须是 匹配方式→通配值→进物品名→支持筛选,实际 ${JSON.stringify(order)}`,
|
`第3行五格顺序必须是 自动→手动→通配值→进物品名→支持筛选,实际 ${JSON.stringify(order)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const cssSource = readSource('../src/styles/admin.css')
|
const cssSource = readSource('../src/styles/admin.css')
|
||||||
assert.ok(
|
assert.ok(
|
||||||
cssSource.includes('.order-service-field-extra.is-four'),
|
cssSource.includes('.order-service-field-extra.is-match-modes'),
|
||||||
'必须提供 is-four 四列栅格样式',
|
'必须提供 is-match-modes 五列栅格样式',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('寄卖配置:每个规格字段同时提供自动/手动两套匹配方式', () => {
|
||||||
|
const source = readSource('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
|
||||||
|
|
||||||
|
assert.ok(source.includes('自动匹配方式'), '必须保留自动匹配方式下拉')
|
||||||
|
assert.ok(source.includes('手动匹配方式'), '必须新增手动匹配方式下拉')
|
||||||
|
assert.ok(source.includes("manualMatchMode: 'exact'"), '新增字段与模板生成的字段默认手动精确相等')
|
||||||
|
assert.ok(
|
||||||
|
source.includes("value={spec.manualMatchMode ?? 'exact'}"),
|
||||||
|
'历史配置没有手动口径时按精确相等展示',
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeSource = readSource('../src/types/consignment.ts')
|
||||||
|
assert.ok(typeSource.includes('manualMatchMode?'), '前端类型必须包含 manualMatchMode')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('寄卖配置:两套匹配方式与通配值/进物品名/支持筛选必须在同一行', () => {
|
||||||
|
const source = readSource('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
|
||||||
|
const css = readSource('../src/styles/admin.css')
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
source.includes('order-service-field-extra is-match-modes'),
|
||||||
|
'字段行必须使用 5 列栅格类',
|
||||||
|
)
|
||||||
|
assert.ok(css.includes('.order-service-field-extra.is-match-modes'))
|
||||||
|
assert.ok(
|
||||||
|
css.includes('minmax(90px, 0.72fr)'),
|
||||||
|
'手动匹配方式必须比自动匹配方式窄,避免把通配值挤没',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
css.includes('.order-service-field-extra.is-match-modes,'),
|
||||||
|
'窄屏媒体查询必须包含该 5 列布局,保证折行',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('后端:规格字段归一化补 manualMatchMode,历史配置缺省精确相等', () => {
|
||||||
|
const source = readSource(
|
||||||
|
'../../backend/src/services/worker-platform/consignment-spec-service.ts',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.ok(source.includes('manualMatchMode?: ConsignmentSpecMatchMode'))
|
||||||
|
assert.ok(source.includes('source.manualMatchMode ?? source.manual_match_mode'))
|
||||||
|
assert.ok(
|
||||||
|
source.includes(
|
||||||
|
"return specs.map((spec) => ({ ...spec, matchMode: spec.manualMatchMode ?? 'exact' }))",
|
||||||
|
),
|
||||||
|
'手动口径必须把 matchMode 替换成 manualMatchMode(缺省 exact)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('后端:手动候选、手动复检、待匹配看板都使用手动口径', () => {
|
||||||
|
const engine = readSource(
|
||||||
|
'../../backend/src/services/worker-platform/consignment-match-engine-service.ts',
|
||||||
|
)
|
||||||
|
const pending = readSource(
|
||||||
|
'../../backend/src/services/worker-platform/consignment-pending-match-service.ts',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
(engine.match(/matchScope: 'manual'/g) || []).length,
|
||||||
|
2,
|
||||||
|
'手动候选与手动复检必须传 manual 口径',
|
||||||
|
)
|
||||||
|
assert.ok(engine.includes("matchScope: 'auto'"), '自动匹配候选必须显式使用 auto 口径')
|
||||||
|
assert.ok(pending.includes("matchScope: 'manual'"), '一键查询待匹配订单必须按手动口径')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('内部库存匹配弹窗:默认展示全部候选,按打分降序,并显示寄卖单号', () => {
|
||||||
|
const source = readSource('../src/components/admin/ConsignmentWorkOrderMatchModal.tsx')
|
||||||
|
|
||||||
|
assert.ok(source.includes('默认按匹配打分从高到低展示全部候选'), '默认展示口径必须在界面上说明')
|
||||||
|
assert.ok(
|
||||||
|
!source.includes("if (!searchable) return (item.matchType || 'exact') === 'exact'"),
|
||||||
|
'不能再默认只过滤精确匹配',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
source.includes('computeConsignmentMatchScore(right) - computeConsignmentMatchScore(left)'),
|
||||||
|
'列表必须按匹配打分降序排列',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
source.includes('buildCandidateHaystack(item).includes(normalizedKeyword)'),
|
||||||
|
'关键词必须覆盖全部匹配状态',
|
||||||
|
)
|
||||||
|
assert.ok(source.includes("title: '寄卖单号'"), '必须新增寄卖单号列')
|
||||||
|
assert.ok(
|
||||||
|
source.includes('item.consignmentNo || item.inventoryNo'),
|
||||||
|
'寄卖单号优先展示提报单号,无提报号回退库存号',
|
||||||
|
)
|
||||||
|
assert.ok(source.includes("item.consignmentNo || ''"), '搜索必须能命中寄卖单号')
|
||||||
|
assert.ok(!source.includes('显示不匹配项'), '「显示不匹配项」勾选框必须删除')
|
||||||
|
assert.ok(!source.includes('includeConflicts'), '不再保留 includeConflicts 状态')
|
||||||
|
assert.ok(source.includes('initialInventoryIds'), '必须支持外部预选库存')
|
||||||
|
assert.ok(source.includes('if (presetInventoryKey)'), '预选库存必须优先于默认自动勾选')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('手动匹配弹窗:状态不允许时仍能搜索,但禁止提交', () => {
|
||||||
|
const source = readSource('../src/components/admin/ConsignmentWorkOrderMatchModal.tsx')
|
||||||
|
|
||||||
|
assert.ok(source.includes('const matchable = data?.matchable !== false'))
|
||||||
|
assert.ok(source.includes('当前仍可搜索和核对库存'), '只读状态必须给出明确提示')
|
||||||
|
assert.ok(source.includes('disabled={!canSubmit}'))
|
||||||
|
assert.ok(
|
||||||
|
/matchable &&\s+selectedIds\.length > 0/.test(source),
|
||||||
|
'canSubmit 必须受 matchable 约束',
|
||||||
|
)
|
||||||
|
|
||||||
|
const searchIndex = source.indexOf('搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手')
|
||||||
|
const optionsBranchIndex = source.indexOf('visibleOptions.length > 0 ? (')
|
||||||
|
assert.ok(searchIndex > 0)
|
||||||
|
assert.ok(
|
||||||
|
searchIndex < optionsBranchIndex,
|
||||||
|
'搜索框必须常驻在候选分支之外,空列表 / 只读状态也能使用',
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeSource = readSource('../src/types/consignment.ts')
|
||||||
|
assert.ok(typeSource.includes('matchable: boolean'), '候选响应类型必须带 matchable')
|
||||||
|
assert.ok(typeSource.includes('blockedReason: string'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('一键查询待匹配订单:每行提供「选择」,打开手动匹配并预选该库存', () => {
|
||||||
|
const source = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
|
||||||
|
|
||||||
|
assert.ok(source.includes("title: '操作'"), '必须新增操作列')
|
||||||
|
assert.ok(source.includes('选择'), '操作列按钮文案必须是「选择」')
|
||||||
|
assert.ok(source.includes('setSelectTarget({'), '点击选择必须记录订单与库存')
|
||||||
|
assert.ok(source.includes('<ConsignmentWorkOrderMatchModal'), '必须挂载手动匹配弹窗')
|
||||||
|
assert.ok(source.includes('initialInventoryIds'), '必须把选中的库存传给手动匹配弹窗')
|
||||||
|
assert.ok(source.includes('workOrderId={selectTarget?.workOrderId ?? null}'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('一键查询待匹配订单:快速匹配按履约权限门控,成功后刷新看板与库存', () => {
|
||||||
|
const modal = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
|
||||||
|
const panel = readSource('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx')
|
||||||
|
|
||||||
|
assert.ok(modal.includes('canMatch = false'), '默认无权限时不给操作入口')
|
||||||
|
assert.ok(modal.includes('void query.refetch()'), '匹配成功后必须刷新看板')
|
||||||
|
assert.ok(modal.includes('onMatched?.()'), '匹配成功后必须通知外层刷新库存')
|
||||||
|
assert.ok(
|
||||||
|
panel.includes('canMatch={canFulfillConsignment}'),
|
||||||
|
'仓库面板必须按 consignment.fulfill 权限传入 canMatch',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
panel.includes('onMatched={() => void inventoriesQuery.refetch()}'),
|
||||||
|
'仓库面板必须刷新库存列表',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('一键查询待匹配订单:说明文案不再声称「不直接改单」', () => {
|
||||||
|
const source = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
|
||||||
|
|
||||||
|
assert.ok(!source.includes('不直接改单'), '新增选择匹配后必须去掉只读文案')
|
||||||
|
assert.ok(source.includes('「手动匹配方式」') || source.includes('手动匹配'))
|
||||||
|
})
|
||||||
|
|
||||||
|
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('默认展示全部配对(含不匹配项)'), '界面必须说明默认口径')
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
test('打手寄卖页:空规格集合必须用稳定引用,避免 React #185 无限渲染', () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url)),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
source.includes('const EMPTY_CONSIGNMENT_PROFILES: WorkerConsignmentProfile[] = []'),
|
||||||
|
'必须定义模块级稳定空数组',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
source.includes('consignmentProfilesQuery.data?.data.profiles ?? EMPTY_CONSIGNMENT_PROFILES'),
|
||||||
|
'接口未返回时不能每次渲染新建 []',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
!source.includes('consignmentProfilesQuery.data?.data.profiles || []'),
|
||||||
|
'禁止使用每次渲染都新建的空数组回退',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('打手寄卖页:展开节点同步必须先比较内容,相同就不 setState', () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url)),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.ok(source.includes('function isSameKeyList('), '必须有 key 列表内容比较')
|
||||||
|
assert.ok(
|
||||||
|
source.includes('isSameKeyList(prev, profileGroupKeys) ? prev : profileGroupKeys'),
|
||||||
|
'内容相同必须返回原引用,避免无限更新',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
!/setProfileExpandedKeys\(profileGroupKeys\)\s*\r?\n\s*\}, \[profileGroupKeys\]/.test(source),
|
||||||
|
'不能无条件写入新数组',
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user