From e97932dcdf8dc1f7f9012ad6516e28f18cd2f7f3 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sat, 14 Nov 2026 18:26:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E8=87=AA=E5=8A=A8/=E6=89=8B=E5=8A=A8=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=B9=B6=E4=BC=98=E5=8C=96=E5=86=85=E9=83=A8?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...signment-manual-match-availability.test.ts | 35 +++ .../consignment-match-engine-service.ts | 137 +++++++--- .../consignment-match-mode-scope.test.ts | 236 ++++++++++++++++++ .../consignment-pending-match-service.ts | 5 +- .../consignment-spec-service.ts | 38 ++- .../admin/ConsignmentPendingMatchModal.tsx | 93 +++++-- .../admin/ConsignmentWorkOrderMatchModal.tsx | 157 ++++++++---- .../admin/panels/ConsignmentConfigPanel.tsx | 30 ++- .../panels/ConsignmentInventoryPanel.tsx | 2 + .../pages/worker/WorkerConsignmentPage.tsx | 19 +- apps/frontend/src/styles/admin.css | 20 +- apps/frontend/src/types/consignment.ts | 9 + ...signment-inventory-category-filter.test.ts | 20 +- ...consignment-match-config-and-scope.test.ts | 126 ++++++++++ .../consignment-pending-match-select.test.ts | 53 ++++ ...orker-consignment-render-stability.test.ts | 41 +++ 16 files changed, 877 insertions(+), 144 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/consignment-manual-match-availability.test.ts create mode 100644 apps/backend/src/services/worker-platform/consignment-match-mode-scope.test.ts create mode 100644 apps/frontend/tests/consignment-match-config-and-scope.test.ts create mode 100644 apps/frontend/tests/consignment-pending-match-select.test.ts create mode 100644 apps/frontend/tests/worker-consignment-render-stability.test.ts diff --git a/apps/backend/src/services/worker-platform/consignment-manual-match-availability.test.ts b/apps/backend/src/services/worker-platform/consignment-manual-match-availability.test.ts new file mode 100644 index 00000000..ade5ce0d --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-manual-match-availability.test.ts @@ -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') +}) diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 7f798722..780338d1 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -31,11 +31,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 { 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 { @@ -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', }), })) diff --git a/apps/backend/src/services/worker-platform/consignment-match-mode-scope.test.ts b/apps/backend/src/services/worker-platform/consignment-match-mode-scope.test.ts new file mode 100644 index 00000000..8b7fe4fc --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-match-mode-scope.test.ts @@ -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 & { 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 { + 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, '跨品类必须留下冲突原因') +}) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts index ca4de31f..2878d12c 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -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) { diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.ts index 371fad00..a13f2767 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.ts @@ -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) diff --git a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx index d9cccc14..a13e25a2 100644 --- a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx @@ -1,14 +1,16 @@ import { useQuery } from '@tanstack/react-query' 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 { useState } from 'react' +import { useMemo, useState } from 'react' import PercentTable from '@/components/common/PercentTable' import { fetchPendingPistolMatches } from '@/services/admin/consignment' import type { PendingPistolMatchPair } from '@/types/consignment' import { formatDateTime } from '@/utils/date-time' +import ConsignmentWorkOrderMatchModal from './ConsignmentWorkOrderMatchModal' + const { Text } = Typography function resolveScoreTag(pair: PendingPistolMatchPair) { @@ -36,23 +38,33 @@ export default function ConsignmentPendingMatchModal({ onClose, categoryId, categoryName, + canMatch = false, + onMatched, }: { open: boolean onClose: () => void /** 只统计该寄卖分类;不传 = 全部 */ categoryId?: number | null categoryName?: string + /** 有 consignment.fulfill 权限时才显示「选择」列 */ + canMatch?: boolean + /** 快速匹配成功后通知外层刷新库存 */ + onMatched?: () => void }) { const [keyword, setKeyword] = useState('') - const [includeConflicts, setIncludeConflicts] = useState(false) + const [selectTarget, setSelectTarget] = useState<{ + workOrderId: number + inventoryId: number + } | null>(null) const query = useQuery({ - queryKey: ['admin-pending-pistol-matches', categoryId ?? null, keyword, includeConflicts], + queryKey: ['admin-pending-pistol-matches', categoryId ?? null, keyword], queryFn: () => fetchPendingPistolMatches({ categoryId: categoryId ?? undefined, keyword, - includeConflicts, + // 默认展示全部配对:冲突项(0 分)也要能看到,便于人工核对。 + includeConflicts: true, limit: 300, }), enabled: open, @@ -60,7 +72,11 @@ export default function ConsignmentPendingMatchModal({ retry: false, }) 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 = [ { @@ -225,6 +241,30 @@ export default function ConsignmentPendingMatchModal({ ), }, + ...(canMatch + ? [ + { + title: '操作', + key: 'action', + width: 100, + render: (_: unknown, record: PendingPistolMatchPair) => ( + + ), + }, + ] + : []), ] return ( @@ -246,10 +286,14 @@ export default function ConsignmentPendingMatchModal({ showIcon message={ categoryName - ? `只统计「${categoryName}」分类下的待匹配订单 × 在库待匹配现货,不直接改单。` - : '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货,不直接改单。' + ? `只统计「${categoryName}」分类下的待匹配订单 × 在库待匹配现货。` + : '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货。' + } + description={ + canMatch + ? '默认展示全部配对(含不匹配项),按打分从高到低排列;点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。' + : '默认展示全部配对(含不匹配项),按打分从高到低排列;当前账号没有履约权限,只能查看。' } - description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);内部匹配中、待分配、待抢单的工单都会纳入。数量不足或没有正向规格信号的非精确项会单独标记,自动匹配不会选中。" /> @@ -261,7 +305,7 @@ export default function ConsignmentPendingMatchModal({ 非精确 {data?.partialCount ?? 0} 数量不足 {data?.quantityShortCount ?? 0} 可自动 {data?.autoMatchableCount ?? 0} - {includeConflicts ? 不匹配 {data?.conflictCount ?? 0} : null} + 不匹配 {data?.conflictCount ?? 0} @@ -271,12 +315,6 @@ export default function ConsignmentPendingMatchModal({ style={{ width: 420 }} onSearch={(value) => setKeyword(value.trim())} /> - setIncludeConflicts(event.target.checked)} - > - 显示 0 分不匹配项 -