diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index c5d8d58d..d5bcac43 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -36,6 +36,7 @@ import { listInventoryMatchableOrders, listWorkOrderInventoryMatchOptions, } from '../../../services/worker-platform/consignment-fulfillment-service.js' +import { listPendingPistolMatches } from '../../../services/worker-platform/consignment-pending-match-service.js' const router = Router() @@ -266,6 +267,25 @@ router.post( // ---------------- 匹配与履约 ---------------- +// 全局待抢单匹配看板:只看非拼单的待抢单工单 × 在库待匹配现货,只读。 +router.get( + '/worker-platform/consignment/pending-pistol-matches', + requireAdminPermission('consignment.view'), + createJsonHandler( + (req) => + listPendingPistolMatches({ + keyword: String(req.query.keyword || ''), + includeConflicts: String(req.query.includeConflicts || '') === 'true', + limit: Number(req.query.limit || 0) || undefined, + }), + { + successMessage: 'ok', + errorMessage: '读取待匹配手枪订单失败', + scope: '[admin/consignment/pending-pistol-matches]', + }, + ), +) + router.get( '/worker-platform/consignment/matches/:workOrderId', requireAdminPermission('consignment.view'), diff --git a/apps/backend/src/services/worker-platform/consignment-matching-service.ts b/apps/backend/src/services/worker-platform/consignment-matching-service.ts index 6a4b6af9..c7e82541 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching-service.ts @@ -566,6 +566,122 @@ export function listConsignmentMatchOptions( })) } +/** 全局待抢单匹配看板使用的细粒度评分结果。 */ +export type ConsignmentDetailedMatchScore = { + matchType: 'exact' | 'partial' | 'conflict' + score: number + reason: string + conflicts: string[] +} + +/** + * 细粒度匹配打分(0-100),仅用于「待抢单手枪 × 在库现货」看板排序。 + * + * 权重:系统 20 / 区服 20 / 系列 20 / 钻级 25 / 璀璨 15。 + * 订单未写或无法确认的字段只给一半分;现货配置更高时钻级给 20、璀璨给 12。 + * 品类、系统/区服/系列冲突、钻级/璀璨低于订单要求属于硬性不匹配,直接 0 分。 + */ +export function scoreConsignmentMatchDetails( + order: ParsedOrderSpecs, + inventory: ConsignmentInventoryRow, +): ConsignmentDetailedMatchScore { + const conflicts = describeConsignmentSpecDifferences(order, inventory) + const conflict = (fallback: string): ConsignmentDetailedMatchScore => ({ + matchType: 'conflict', + score: 0, + reason: conflicts.join(';') || fallback, + conflicts, + }) + + if (!inventory.allow_matching || inventory.status !== 'in_stock') { + return conflict('现货不在库或已暂停匹配') + } + + const orderCat = detectItemCategory(order.rawText) + const invCat = detectItemCategory( + `${inventory.item_name} ${inventory.series} ${inventory.category_name || ''}`, + ) + if (orderCat !== 'other' && invCat !== 'other' && orderCat !== invCat) { + return conflict('品类不匹配') + } + if (invCat === 'pistol_suernan') { + const orderHasPistolSignal = + Boolean(order.series) || + order.rawText.includes('苏尔南') || + order.rawText.includes('手枪') || + order.rawText.includes('枪皮') + if (!orderHasPistolSignal) { + return conflict('订单商品不是苏尔南手枪') + } + } + + const invSystem = normalizePeacekeeperSystem(inventory.system) + const invZone = normalizePeacekeeperServerZone(inventory.server_zone) + const invSeries = normalizePeacekeeperSeries(inventory.series) + + const verdictSystem = compareSpec(order.system, invSystem) + const verdictZone = compareSpec(order.serverZone, invZone, ZONE_WILDCARDS) + const verdictSeries = compareSpec(order.series, invSeries) + if (verdictSystem === 'mismatch' || verdictZone === 'mismatch' || verdictSeries === 'mismatch') { + return conflict('系统/区服/系列规格冲突') + } + + const invDiamond = Number(inventory.diamond_level || 1) + const invBrilliance = Number(inventory.brilliance_count || 0) + const orderDiamond = Number(order.diamondLevel || 0) + const orderBrilliance = Number(order.brillianceCount || 0) + + if (orderDiamond > 0 && invDiamond < orderDiamond) { + return conflict('现货钻级低于订单要求') + } + if (invBrilliance < orderBrilliance) { + return conflict('现货璀璨低于订单要求') + } + + const unknownFields: string[] = [] + let score = 0 + + score += verdictSystem === 'match' ? 20 : 10 + if (verdictSystem === 'unknown') unknownFields.push('系统') + score += verdictZone === 'match' ? 20 : 10 + if (verdictZone === 'unknown') unknownFields.push('区服') + score += verdictSeries === 'match' ? 20 : 10 + if (verdictSeries === 'unknown') unknownFields.push('系列') + + if (orderDiamond <= 0) { + score += 12.5 + unknownFields.push('钻级') + } else if (invDiamond === orderDiamond) { + score += 25 + } else { + score += 20 + } + + score += invBrilliance === orderBrilliance ? 15 : 12 + + const reasons: string[] = [] + if (unknownFields.length > 0) { + reasons.push(`订单规格信息不全(未写: ${unknownFields.join('、')}),需人工确认`) + } + if (orderDiamond > 0 && invDiamond > orderDiamond) { + reasons.push(`钻级更高(${invDiamond}钻充${orderDiamond}钻)`) + } + if (invBrilliance > orderBrilliance) { + reasons.push(`多出【${invBrilliance - orderBrilliance}璀璨】`) + } + + const roundedScore = Math.round(score) + if (roundedScore >= 100) { + return { matchType: 'exact', score: 100, reason: '规格完全一致', conflicts: [] } + } + return { + matchType: 'partial', + score: roundedScore, + reason: reasons.length > 0 ? `需人工确认:${reasons.join(',')}` : '部分规格匹配,需人工确认', + conflicts, + } +} + export function findMatchingPeacekeeperInventories( rawRequirementText: string, inventories: ConsignmentInventoryRow[], diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts new file mode 100644 index 00000000..51bb2504 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts @@ -0,0 +1,247 @@ +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 { + extractPeacekeeperSpecs, + scoreConsignmentMatchDetails, +} from './consignment-matching-service.js' +import { + buildPendingPistolMatchPairs, + type PendingPistolMatchPair, +} from './consignment-pending-match-service.js' + +function buildInventory(overrides: Partial = {}): ConsignmentInventoryRow { + return { + id: 1, + inventory_no: 'INV001', + game_name: '和平精英', + category_id: 1, + item_name: '苹果微信区 暗潮 1钻 苏尔南', + system: '苹果', + server_zone: '微信区', + series: '暗潮', + diamond_level: 1, + brilliance_count: 0, + cost_price_fen: 10500, + buyer_name: '周杰雄', + inbound_batch: '8.30-1', + seller_contact: 'wx_123', + seller_game_id: 'UID8888', + seller_payment_info: '王大锤商行 331682...', + seller_phone: '13800000000', + credentials_json: {}, + screenshots_json: [], + remark: '', + allow_matching: true, + status: 'in_stock', + allocation_status: '待安排', + bound_order_id: null, + bound_work_order_id: null, + bound_platform_order_id: '', + shop_name: '', + source_type: 'manual', + source_submission_id: null, + inbound_at: '2026-11-01T09:00:00.000Z', + consumed_at: null, + created_at: '2026-11-01T08:00:00.000Z', + updated_at: '2026-11-01T09:00:00.000Z', + ...overrides, + } +} + +function buildOrder(overrides: Partial = {}): WorkOrderRow { + return { + id: 1, + work_order_no: 'WK001', + platform_order_id: 'PLAT001', + product_name: '苹果微信区 苏尔南手枪 暗潮 1钻', + status: 'open', + active_share_quantity: 0, + material_json: JSON.stringify({ + collect: { fields: { system: '苹果', serverZone: '微信区', series: '暗潮' } }, + }), + created_at: '2026-11-01T10:00:00.000Z', + ...overrides, + } as unknown as WorkOrderRow +} + +function pairByInventory(result: { pairs: PendingPistolMatchPair[] }, inventoryId: number) { + return result.pairs.find((pair) => pair.inventoryId === inventoryId) +} + +test('细粒度评分:全部规格一致为 100 分', () => { + const order = extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 1钻') + const scored = scoreConsignmentMatchDetails(order, buildInventory()) + assert.deepEqual(scored, { + matchType: 'exact', + score: 100, + reason: '规格完全一致', + conflicts: [], + }) +}) + +test('细粒度评分:钻级更高 95 分,璀璨更多 97 分', () => { + const order = extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 1钻') + const higherDiamond = scoreConsignmentMatchDetails(order, buildInventory({ diamond_level: 2 })) + assert.equal(higherDiamond.matchType, 'partial') + assert.equal(higherDiamond.score, 95) + assert.match(higherDiamond.reason, /钻级更高/) + + const higherBrilliance = scoreConsignmentMatchDetails( + order, + buildInventory({ brilliance_count: 2 }), + ) + assert.equal(higherBrilliance.matchType, 'partial') + assert.equal(higherBrilliance.score, 97) + assert.match(higherBrilliance.reason, /多出【2璀璨】/) +}) + +test('细粒度评分:订单未写系统时扣一半分', () => { + const order = extractPeacekeeperSpecs('微信区 苏尔南手枪 暗潮 1钻') + const scored = scoreConsignmentMatchDetails(order, buildInventory()) + assert.equal(scored.matchType, 'partial') + assert.equal(scored.score, 90) + assert.match(scored.reason, /未写: 系统/) +}) + +test('细粒度评分:系统/区服/系列冲突或现货配置更低为 0 分', () => { + const order = extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 3钻') + assert.equal(scoreConsignmentMatchDetails(order, buildInventory({ system: '安卓' })).score, 0) + assert.equal( + scoreConsignmentMatchDetails(order, buildInventory({ server_zone: 'QQ区' })).score, + 0, + ) + assert.equal(scoreConsignmentMatchDetails(order, buildInventory({ series: '怒嚎' })).score, 0) + assert.equal(scoreConsignmentMatchDetails(order, buildInventory({ diamond_level: 1 })).score, 0) + assert.equal( + scoreConsignmentMatchDetails(order, buildInventory({ diamond_level: 3, brilliance_count: 0 })) + .matchType, + 'exact', + ) + assert.equal( + scoreConsignmentMatchDetails( + extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 1钻 2璀璨'), + buildInventory({ brilliance_count: 0 }), + ).score, + 0, + ) +}) + +test('匹配看板:只保留非拼单待抢单 × 在库待匹配手枪', () => { + const result = buildPendingPistolMatchPairs({ + orders: [ + buildOrder({ id: 1 }), + buildOrder({ id: 2, active_share_quantity: 1 }), + buildOrder({ + id: 3, + product_name: '指挥官密钥10个', + material_json: JSON.stringify({}), + }), + buildOrder({ id: 4, status: 'unassigned' }), + ], + inventories: [ + buildInventory({ id: 10 }), + buildInventory({ id: 11, allow_matching: false }), + buildInventory({ id: 12, status: 'consumed' }), + buildInventory({ id: 13, item_name: '指挥官密钥10个', series: '' }), + ], + }) + + assert.equal(result.pendingOrderCount, 1) + assert.equal(result.inStockInventoryCount, 1) + assert.equal(result.pairCount, 1) + assert.equal(result.pairs[0]?.workOrderId, 1) + assert.equal(result.pairs[0]?.inventoryId, 10) +}) + +test('匹配看板:不按时间过滤,寄售入库晚于工单创建仍参与匹配', () => { + const result = buildPendingPistolMatchPairs({ + orders: [buildOrder({ created_at: '2026-11-01T10:00:00.000Z' })], + inventories: [buildInventory({ inbound_at: '2026-11-02T10:00:00.000Z' })], + }) + assert.equal(result.pairCount, 1) + assert.equal(result.pairs[0]?.inventoryInboundAt, '2026-11-02T10:00:00.000Z') +}) + +test('匹配看板:按分数降序,同分新工单优先', () => { + const result = buildPendingPistolMatchPairs({ + orders: [buildOrder({ id: 1 }), buildOrder({ id: 2, work_order_no: 'WK002' })], + inventories: [ + buildInventory({ id: 10 }), + buildInventory({ id: 11, diamond_level: 2, inbound_at: '2026-11-01T11:00:00.000Z' }), + ], + }) + + assert.deepEqual( + result.pairs.map((pair) => [pair.workOrderId, pair.inventoryId, pair.score]), + [ + [2, 10, 100], + [1, 10, 100], + [2, 11, 95], + [1, 11, 95], + ], + ) +}) + +test('匹配看板:关键字同时搜索工单号与寄售单号', () => { + const orders = [buildOrder({ id: 1, work_order_no: 'WK001' })] + const inventories = [ + buildInventory({ id: 10, source_submission_no: 'SUB-AAA' }), + buildInventory({ id: 11, source_submission_no: 'SUB-BBB' }), + ] + const byOrder = buildPendingPistolMatchPairs({ orders, inventories, keyword: 'wk001' }) + assert.equal(byOrder.pairCount, 2) + + const byConsignment = buildPendingPistolMatchPairs({ + orders, + inventories, + keyword: 'sub-bbb', + }) + assert.equal(byConsignment.pairCount, 1) + assert.equal(byConsignment.pairs[0]?.inventoryId, 11) +}) + +test('匹配看板:默认隐藏 0 分冲突项,可显式包含', () => { + const orders = [buildOrder()] + const inventories = [buildInventory({ id: 10, system: '安卓' })] + const hidden = buildPendingPistolMatchPairs({ orders, inventories }) + assert.equal(hidden.pairCount, 0) + assert.equal(hidden.conflictCount, 0) + + const included = buildPendingPistolMatchPairs({ + orders, + inventories, + includeConflicts: true, + }) + assert.equal(included.pairCount, 1) + assert.equal(included.conflictCount, 1) + assert.equal(included.pairs[0]?.score, 0) +}) + +test('匹配看板:结果条数受 limit 限制并标记 truncated', () => { + const result = buildPendingPistolMatchPairs({ + orders: [buildOrder({ id: 1 }), buildOrder({ id: 2, work_order_no: 'WK002' })], + inventories: [buildInventory({ id: 10 }), buildInventory({ id: 11 })], + limit: 2, + }) + assert.equal(result.pairCount, 4) + assert.equal(result.pairs.length, 2) + assert.equal(result.truncated, true) +}) + +test('匹配看板:配对明细包含平台号/工单号/商品名/寄售单号/寄售物品/分数', () => { + const result = buildPendingPistolMatchPairs({ + orders: [buildOrder()], + inventories: [buildInventory({ id: 10, source_submission_no: 'SUB-001' })], + }) + const pair = pairByInventory(result, 10) + assert.ok(pair) + assert.equal(pair.platformOrderId, 'PLAT001') + assert.equal(pair.workOrderNo, 'WK001') + assert.equal(pair.productName, '苹果微信区 苏尔南手枪 暗潮 1钻') + assert.equal(pair.consignmentNo, 'SUB-001') + assert.equal(pair.itemName, '苹果微信区 暗潮 1钻 苏尔南') + assert.equal(pair.score, 100) +}) 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 new file mode 100644 index 00000000..af3a0730 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -0,0 +1,199 @@ +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + listActiveConsignmentInventoriesForMatching, + type ConsignmentInventoryRow, +} from '../../repositories/worker-platform/consignment-repo.js' +import { listWorkOrders } from '../../repositories/worker-platform/work-order-query-repo.js' +import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' +import { + detectItemCategory, + extractPeacekeeperSpecs, + scoreConsignmentMatchDetails, +} from './consignment-matching-service.js' +import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js' + +export type PendingPistolMatchPair = { + workOrderId: number + workOrderNo: string + platformOrderId: string + productName: string + workOrderCreatedAt: string | null + inventoryId: number + consignmentNo: string + inventoryNo: string + itemName: string + inventoryInboundAt: string | null + matchType: 'exact' | 'partial' | 'conflict' + score: number + reason: string + conflicts: string[] +} + +export type PendingPistolMatchResult = { + /** 参与匹配的待抢单手枪单数量 */ + pendingOrderCount: number + /** 参与匹配的在库待匹配现货数量 */ + inStockInventoryCount: number + /** 命中关键字后的配对总数(未截断前) */ + pairCount: number + exactCount: number + partialCount: number + conflictCount: number + /** 结果过多被截断,或待抢单工单本身超过单次拉取上限 */ + truncated: boolean + pairs: PendingPistolMatchPair[] +} + +const OPEN_ORDER_FETCH_LIMIT = 500 +const DEFAULT_RESULT_LIMIT = 300 +const MAX_RESULT_LIMIT = 1000 + +function buildPairHaystack(pair: PendingPistolMatchPair): string { + return [ + pair.workOrderNo, + pair.platformOrderId, + pair.productName, + pair.consignmentNo, + pair.inventoryNo, + pair.itemName, + ] + .join(' ') + .toLowerCase() +} + +/** 纯函数:把待抢单工单与在库现货做笛卡尔匹配并排序,便于单测。 */ +export function buildPendingPistolMatchPairs(input: { + orders: WorkOrderRow[] + inventories: ConsignmentInventoryRow[] + keyword?: string | undefined + includeConflicts?: boolean | undefined + limit?: number | undefined + ordersTruncated?: boolean | undefined +}): PendingPistolMatchResult { + const preparedOrders = input.orders + .filter((order) => String(order.status || '') === WORK_ORDER_STATUS.OPEN) + // 寄售只做整单:已有有效拼单份额的待抢单不参与匹配。 + .filter((order) => Number(order.active_share_quantity || 0) <= 0) + .map((order) => { + const fields = resolveCollectSubmittedFieldValues(order) + const specs = extractPeacekeeperSpecs(order.product_name, { + system: fields.system, + serverZone: fields.serverZone, + series: fields.series, + }) + return { order, specs } + }) + .filter(({ order, specs }) => { + const text = [ + String(order.product_name || ''), + specs.system, + specs.serverZone, + specs.series, + ].join(' ') + return detectItemCategory(text) === 'pistol_suernan' + }) + + const preparedInventories = input.inventories.filter((inventory) => { + if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false + return ( + detectItemCategory( + `${inventory.item_name} ${inventory.series || ''} ${inventory.category_name || ''}`, + ) === 'pistol_suernan' + ) + }) + + const keyword = String(input.keyword || '') + .trim() + .toLowerCase() + const includeConflicts = input.includeConflicts === true + const limit = Math.min( + Math.max(Math.floor(Number(input.limit) || DEFAULT_RESULT_LIMIT), 1), + MAX_RESULT_LIMIT, + ) + + const pairs: PendingPistolMatchPair[] = [] + for (const { order, specs } of preparedOrders) { + for (const inventory of preparedInventories) { + const scored = scoreConsignmentMatchDetails(specs, inventory) + if (scored.matchType === 'conflict' && !includeConflicts) continue + + const pair: PendingPistolMatchPair = { + workOrderId: Number(order.id), + workOrderNo: String(order.work_order_no || ''), + platformOrderId: String(order.platform_order_id || ''), + productName: String(order.product_name || ''), + workOrderCreatedAt: order.created_at ? String(order.created_at) : null, + inventoryId: Number(inventory.id), + consignmentNo: String(inventory.source_submission_no || inventory.inventory_no || ''), + inventoryNo: String(inventory.inventory_no || ''), + itemName: String(inventory.item_name || ''), + inventoryInboundAt: inventory.inbound_at + ? String(inventory.inbound_at) + : inventory.created_at + ? String(inventory.created_at) + : null, + matchType: scored.matchType, + score: scored.score, + reason: scored.reason, + conflicts: scored.conflicts, + } + + if (keyword && !buildPairHaystack(pair).includes(keyword)) continue + pairs.push(pair) + } + } + + const exactCount = pairs.filter((pair) => pair.matchType === 'exact').length + const partialCount = pairs.filter((pair) => pair.matchType === 'partial').length + const conflictCount = pairs.filter((pair) => pair.matchType === 'conflict').length + + // 分数优先;同分时新工单优先,再按寄售入库时间早的优先。 + pairs.sort((left, right) => { + if (right.score !== left.score) return right.score - left.score + if (right.workOrderId !== left.workOrderId) { + return right.workOrderId - left.workOrderId + } + const leftTime = Date.parse(left.inventoryInboundAt || '') || left.inventoryId + const rightTime = Date.parse(right.inventoryInboundAt || '') || right.inventoryId + if (leftTime !== rightTime) return leftTime - rightTime + return left.inventoryId - right.inventoryId + }) + + return { + pendingOrderCount: preparedOrders.length, + inStockInventoryCount: preparedInventories.length, + pairCount: pairs.length, + exactCount, + partialCount, + conflictCount, + truncated: pairs.length > limit || input.ordersTruncated === true, + pairs: pairs.slice(0, limit), + } +} + +/** 后台「寄售仓库 → 匹配」:待抢单手枪单 × 在库待匹配现货。 */ +export async function listPendingPistolMatches( + input: { + keyword?: string | undefined + includeConflicts?: boolean | undefined + limit?: number | undefined + } = {}, +): Promise { + const { items, total } = await listWorkOrders({ + statuses: [WORK_ORDER_STATUS.OPEN], + page: 1, + pageSize: OPEN_ORDER_FETCH_LIMIT, + includeTotal: true, + sort: 'id_desc', + }) + const inventories = await listActiveConsignmentInventoriesForMatching() + + return buildPendingPistolMatchPairs({ + orders: items, + inventories, + keyword: input.keyword, + includeConflicts: input.includeConflicts, + limit: input.limit, + ordersTruncated: total > items.length, + }) +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 60b9a547..29aaf7b4 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -65,4 +65,5 @@ export * from './worker-product-name-suffix-service.js' export * from './worker-acceptance-config-service.js' export * from './sync-worker-orders-service.js' export * from './consignment-contact-service.js' +export * from './consignment-pending-match-service.js' export * from './work-order-material-link-service.js' diff --git a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx new file mode 100644 index 00000000..14d71e83 --- /dev/null +++ b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx @@ -0,0 +1,266 @@ +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 type { TableColumnsType } from 'antd' +import { 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' + +const { Text } = Typography + +function resolveScoreTag(pair: PendingPistolMatchPair) { + if (pair.matchType === 'exact') return { color: 'green', label: '精确' } + if (pair.matchType === 'conflict') return { color: 'default', label: '不匹配' } + if (pair.score >= 90) return { color: 'blue', label: '非精确' } + if (pair.score >= 70) return { color: 'orange', label: '非精确' } + return { color: 'gold', label: '需核对' } +} + +export default function ConsignmentPendingMatchModal({ + open, + onClose, +}: { + open: boolean + onClose: () => void +}) { + const [keyword, setKeyword] = useState('') + const [includeConflicts, setIncludeConflicts] = useState(false) + + const query = useQuery({ + queryKey: ['admin-pending-pistol-matches', keyword, includeConflicts], + queryFn: () => fetchPendingPistolMatches({ keyword, includeConflicts, limit: 300 }), + enabled: open, + refetchOnWindowFocus: false, + retry: false, + }) + const data = query.data?.data + const pairs = data?.pairs || [] + + const columns: TableColumnsType = [ + { + title: '匹配打分', + key: 'score', + width: 90, + render: (_, record) => { + const tag = resolveScoreTag(record) + return ( + + {record.score} + + {tag.label} + + + ) + }, + }, + { + title: '订单信息', + key: 'order', + width: 280, + render: (_, record) => ( + + + + 工单 + + {record.workOrderNo ? ( + + {record.workOrderNo} + + ) : ( + - + )} + + + + 平台 + + {record.platformOrderId ? ( + + {record.platformOrderId} + + ) : ( + - + )} + + + 录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'} + + + ), + }, + { + title: '商品名', + key: 'productName', + width: 220, + render: (_, record) => ( + + {record.productName || '-'} + + ), + }, + { + title: '寄售信息', + key: 'consignment', + width: 280, + render: (_, record) => ( + + + + 寄售 + + + {record.consignmentNo || record.inventoryNo || '-'} + + + {record.inventoryNo && record.inventoryNo !== record.consignmentNo ? ( + + + 仓库 + + + {record.inventoryNo} + + + ) : null} + + 入库 {record.inventoryInboundAt ? formatDateTime(record.inventoryInboundAt) : '-'} + + + ), + }, + { + title: '寄售物品', + key: 'itemName', + width: 220, + render: (_, record) => ( + + {record.itemName || '-'} + + ), + }, + { + title: '匹配说明', + key: 'reason', + width: 300, + render: (_, record) => ( + + {record.reason || '-'} + {record.conflicts.length > 0 ? ( + + {record.conflicts.join(';')} + + ) : null} + + ), + }, + ] + + return ( + + + + + + 待抢单 {data?.pendingOrderCount ?? 0} 单 + 在库待匹配 {data?.inStockInventoryCount ?? 0} 件 + 匹配组合 {data?.pairCount ?? 0} 条 + 精确 {data?.exactCount ?? 0} + 非精确 {data?.partialCount ?? 0} + {includeConflicts ? 不匹配 {data?.conflictCount ?? 0} : null} + + + + setKeyword(value.trim())} + /> + setIncludeConflicts(event.target.checked)} + > + 显示 0 分不匹配项 + + + + + {query.isError ? ( + + ) : null} + + {data?.truncated ? ( + + ) : null} + + + rowKey={(record) => `${record.workOrderId}-${record.inventoryId}`} + size="small" + loading={query.isFetching} + dataSource={pairs} + columns={columns} + scroll={{ y: 460 }} + pagination={{ + pageSize: 20, + showSizeChanger: false, + showTotal: (total) => `共 ${total} 条`, + }} + locale={{ + emptyText: ( + + ), + }} + /> + + + ) +} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 0ba696af..42cf0567 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -1,4 +1,10 @@ -import { EyeOutlined, ReloadOutlined, RollbackOutlined, StopOutlined } from '@ant-design/icons' +import { + EyeOutlined, + ReloadOutlined, + RollbackOutlined, + StopOutlined, + ThunderboltOutlined, +} from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' import { Alert, @@ -27,6 +33,7 @@ import type { TableColumnsType } from 'antd' import { useState } from 'react' import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery' +import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal' import { archiveAdminConsignmentInventory, fetchAdminConsignmentContacts, @@ -149,6 +156,7 @@ export default function ConsignmentInventoryPanel() { const [viewingItem, setViewingItem] = useState(null) const [returnItem, setReturnItem] = useState(null) const [manualMatchItem, setManualMatchItem] = useState(null) + const [pendingMatchOpen, setPendingMatchOpen] = useState(false) const [returnReason, setReturnReason] = useState('') const [editForm] = Form.useForm() @@ -836,6 +844,10 @@ export default function ConsignmentInventoryPanel() { }} /> + + @@ -1305,6 +1317,12 @@ export default function ConsignmentInventoryPanel() { } onMatched={() => void inventoriesQuery.refetch()} /> + + {/* 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货 */} + setPendingMatchOpen(false)} + /> ) } diff --git a/apps/frontend/src/services/admin/consignment.ts b/apps/frontend/src/services/admin/consignment.ts index c6399dfa..cb676d25 100644 --- a/apps/frontend/src/services/admin/consignment.ts +++ b/apps/frontend/src/services/admin/consignment.ts @@ -5,6 +5,7 @@ import type { ConsignmentStatsSummary, ConsignmentSubmission, InventoryMatchableOrders, + PendingPistolMatchResult, WorkOrderInventoryMatchCheck, WorkOrderInventoryMatchOptions, } from '@/types/consignment' @@ -158,6 +159,22 @@ export function fetchInventoryMatchableOrders( ) } +/** 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货,按细粒度评分排序。 */ +export function fetchPendingPistolMatches(params?: { + keyword?: string + includeConflicts?: boolean + limit?: number +}) { + return apiGet( + '/api/v1/admin/worker-platform/consignment/pending-pistol-matches', + { + keyword: params?.keyword?.trim() || undefined, + includeConflicts: params?.includeConflicts ? 'true' : undefined, + limit: params?.limit, + }, + ) +} + /** 履约/指派返回的电子凭证核销结果:ok=false 时必须提示运营人工核销,不能报成功 */ export type WorkOrderVoucherConsumeResult = { ok: boolean diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index f1669404..19c04b2d 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -240,3 +240,32 @@ export type WorkOrderInventoryMatchCheck = { partialCount: number matches: ConsignmentMatchResult[] } + +/** 后台「寄售仓库 → 匹配」:待抢单手枪 × 在库待匹配现货的配对明细 */ +export type PendingPistolMatchPair = { + workOrderId: number + workOrderNo: string + platformOrderId: string + productName: string + workOrderCreatedAt: string | null + inventoryId: number + consignmentNo: string + inventoryNo: string + itemName: string + inventoryInboundAt: string | null + matchType: 'exact' | 'partial' | 'conflict' + score: number + reason: string + conflicts: string[] +} + +export type PendingPistolMatchResult = { + pendingOrderCount: number + inStockInventoryCount: number + pairCount: number + exactCount: number + partialCount: number + conflictCount: number + truncated: boolean + pairs: PendingPistolMatchPair[] +}