feat(consignment): 新增待抢单手枪一键查询匹配看板

- 寄售仓库刷新前新增「一键查询待匹配订单」入口
- 待抢单非拼单工单 × 在库待匹配现货,按系统/区服/系列/钻级/璀璨细粒度评分
- 支持关键字搜索、冲突项开关、刷新与分页
- 只读查询,不自动履约、不改工单
This commit is contained in:
2026-11-02 14:59:46 +08:00
parent 13869d345f
commit 53fe841460
9 changed files with 914 additions and 1 deletions
@@ -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'