feat(consignment): 新增待抢单手枪一键查询匹配看板
- 寄售仓库刷新前新增「一键查询待匹配订单」入口 - 待抢单非拼单工单 × 在库待匹配现货,按系统/区服/系列/钻级/璀璨细粒度评分 - 支持关键字搜索、冲突项开关、刷新与分页 - 只读查询,不自动履约、不改工单
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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> = {}): 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> = {}): 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)
|
||||
})
|
||||
@@ -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<PendingPistolMatchResult> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<PendingPistolMatchPair> = [
|
||||
{
|
||||
title: '匹配打分',
|
||||
key: 'score',
|
||||
width: 90,
|
||||
render: (_, record) => {
|
||||
const tag = resolveScoreTag(record)
|
||||
return (
|
||||
<Space direction="vertical" size={3} style={{ width: '100%' }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, lineHeight: 1 }}>{record.score}</span>
|
||||
<Tag color={tag.color} style={{ marginInlineEnd: 0 }}>
|
||||
{tag.label}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '订单信息',
|
||||
key: 'order',
|
||||
width: 280,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
工单
|
||||
</Text>
|
||||
{record.workOrderNo ? (
|
||||
<Text
|
||||
copyable={{ text: record.workOrderNo }}
|
||||
style={{ fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{record.workOrderNo}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
平台
|
||||
</Text>
|
||||
{record.platformOrderId ? (
|
||||
<Text
|
||||
copyable={{ text: record.platformOrderId }}
|
||||
style={{ fontSize: 11, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{record.platformOrderId}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品名',
|
||||
key: 'productName',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Text ellipsis={{ tooltip: record.productName }} style={{ maxWidth: '100%' }}>
|
||||
{record.productName || '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '寄售信息',
|
||||
key: 'consignment',
|
||||
width: 280,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
寄售
|
||||
</Text>
|
||||
<Text
|
||||
copyable={{ text: record.consignmentNo || record.inventoryNo }}
|
||||
style={{ fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{record.consignmentNo || record.inventoryNo || '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
{record.inventoryNo && record.inventoryNo !== record.consignmentNo ? (
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
仓库
|
||||
</Text>
|
||||
<Text
|
||||
copyable={{ text: record.inventoryNo }}
|
||||
style={{ fontSize: 11, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{record.inventoryNo}
|
||||
</Text>
|
||||
</Space>
|
||||
) : null}
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
入库 {record.inventoryInboundAt ? formatDateTime(record.inventoryInboundAt) : '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '寄售物品',
|
||||
key: 'itemName',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Text ellipsis={{ tooltip: record.itemName }} style={{ maxWidth: '100%' }}>
|
||||
{record.itemName || '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '匹配说明',
|
||||
key: 'reason',
|
||||
width: 300,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text style={{ fontSize: 12 }}>{record.reason || '-'}</Text>
|
||||
{record.conflicts.length > 0 ? (
|
||||
<Text type="danger" style={{ fontSize: 11 }}>
|
||||
{record.conflicts.join(';')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="匹配 · 待抢单手枪 × 在库现货"
|
||||
open={open}
|
||||
width="min(1320px, 94vw)"
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="只查询非拼单的待抢单手枪单 × 在库待匹配现货,不直接改单。"
|
||||
description="分数 0-100:系统 20 / 区服 20 / 系列 20 / 钻级 25 / 璀璨 15。非精确项仍需按现有规则确认;不按录单、入库时间先后过滤。"
|
||||
/>
|
||||
|
||||
<Space wrap size={12}>
|
||||
<Tag>待抢单 {data?.pendingOrderCount ?? 0} 单</Tag>
|
||||
<Tag>在库待匹配 {data?.inStockInventoryCount ?? 0} 件</Tag>
|
||||
<Tag color="blue">匹配组合 {data?.pairCount ?? 0} 条</Tag>
|
||||
<Tag color="green">精确 {data?.exactCount ?? 0}</Tag>
|
||||
<Tag color="orange">非精确 {data?.partialCount ?? 0}</Tag>
|
||||
{includeConflicts ? <Tag>不匹配 {data?.conflictCount ?? 0}</Tag> : null}
|
||||
</Space>
|
||||
|
||||
<Space wrap size={12}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索平台号 / 工单号 / 商品名 / 寄售单号 / 寄售物品"
|
||||
style={{ width: 420 }}
|
||||
onSearch={(value) => setKeyword(value.trim())}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={includeConflicts}
|
||||
onChange={(event) => setIncludeConflicts(event.target.checked)}
|
||||
>
|
||||
显示 0 分不匹配项
|
||||
</Checkbox>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={query.isFetching}
|
||||
onClick={() => void query.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{query.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="读取匹配结果失败"
|
||||
description={query.error instanceof Error ? query.error.message : '请稍后重试'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data?.truncated ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`结果较多,仅展示前 ${pairs.length} 条,请用搜索缩小范围`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PercentTable<PendingPistolMatchPair>
|
||||
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: (
|
||||
<Empty
|
||||
description={
|
||||
includeConflicts
|
||||
? '没有可展示的配对'
|
||||
: '没有有分匹配;可勾选「显示 0 分不匹配项」查看冲突'
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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<ConsignmentInventory | null>(null)
|
||||
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [manualMatchItem, setManualMatchItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [pendingMatchOpen, setPendingMatchOpen] = useState(false)
|
||||
const [returnReason, setReturnReason] = useState<string>('')
|
||||
|
||||
const [editForm] = Form.useForm()
|
||||
@@ -836,6 +844,10 @@ export default function ConsignmentInventoryPanel() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button icon={<ThunderboltOutlined />} onClick={() => setPendingMatchOpen(true)}>
|
||||
一键查询待匹配订单
|
||||
</Button>
|
||||
|
||||
<Button icon={<ReloadOutlined />} onClick={refresh}>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -1305,6 +1317,12 @@ export default function ConsignmentInventoryPanel() {
|
||||
}
|
||||
onMatched={() => void inventoriesQuery.refetch()}
|
||||
/>
|
||||
|
||||
{/* 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货 */}
|
||||
<ConsignmentPendingMatchModal
|
||||
open={pendingMatchOpen}
|
||||
onClose={() => setPendingMatchOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<PendingPistolMatchResult>(
|
||||
'/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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user