merge: 合并 dev_match 寄卖匹配看板改造到 dev

# Conflicts:
#	apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts
This commit is contained in:
2026-11-12 15:35:01 +08:00
18 changed files with 961 additions and 758 deletions
+8 -1
View File
@@ -64,6 +64,13 @@ export async function runDatabaseMigrations() {
console.info(`[db:migrate] 迁移目录: ${MIGRATIONS_DIR}`)
// 分支联调库可能已应用过 main 的 147-158,而本分支的寄卖迁移编号是 147-152;
// node-pg-migrate 的 checkOrder 会按名称逐位比较并直接抛错,导致服务起不来。
// 生产保持严格顺序;开发/测试允许顺序放宽(迁移文件本身仍需幂等)。
const strictMigrationOrder =
String(process.env.DB_MIGRATION_STRICT_ORDER || '').trim() === '1' ||
String(process.env.NODE_ENV || '').trim() === 'production'
await runner({
databaseUrl: createMigrationDatabaseConfig(),
dir: MIGRATIONS_DIR,
@@ -72,7 +79,7 @@ export async function runDatabaseMigrations() {
migrationsSchema: 'public',
schema: 'public',
singleTransaction: true,
checkOrder: true,
checkOrder: strictMigrationOrder,
logger: {
debug: () => undefined,
info: (message) => {
@@ -281,6 +281,20 @@ export async function markConsignmentAllocationsConsumedWithClient(
)
}
/** 单个拼单份额验收:只收口这一份占用的库存,避免整单尚在履约时提前标记。 */
export async function markConsignmentAllocationConsumedForShareWithClient(
client: PoolClient,
shareId: number,
now: string,
): Promise<void> {
await client.query(
`UPDATE consignment_inventory_allocations
SET status = 'consumed', updated_at = $1
WHERE work_order_share_id = $2 AND status = 'reserved'`,
[now, shareId],
)
}
/**
* 一条库存当前的有效占用(一库存可拆给多张订单)。
* 卖家操作必须据此定位具体订单/份额,不能再只看 bound_work_order_id。
@@ -30,6 +30,8 @@ import {
settleConsignmentSupplierPayoutByWorkOrderWithClient,
} from './consignment-deposit-repo.js'
import {
markConsignmentAllocationConsumedForShareWithClient,
markConsignmentAllocationsConsumedWithClient,
releaseConsignmentAllocationForShareWithClient,
releaseConsignmentAllocationsForWorkOrderWithClient,
} from './consignment-allocation-repo.js'
@@ -240,6 +242,9 @@ export async function acceptWorkOrderAndSettle(input: {
now: input.now,
})
}
// 验收即交付完成:占用从 reserved 收口为 consumed,
// 否则卖家端会一直显示「占用中」,售后释放路径也可能把已交付的货退回可匹配池。
await markConsignmentAllocationsConsumedWithClient(client, input.workOrderId, input.now)
if (shares.length > 0) {
// 单独验收过的份额已经完成资金结算,统一验收时只处理尚未结算的份额。
@@ -787,6 +792,8 @@ export async function acceptWorkOrderShareAndSettle(input: {
`UPDATE work_order_shares SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
[input.now, input.shareId],
)
// 份额验收即这一份交付完成:只收口该份额的占用,母单剩余份额仍按 reserved 统计。
await markConsignmentAllocationConsumedForShareWithClient(client, input.shareId, input.now)
if (workerId > 0) {
// 等级升级要跑全量已验收订单计数,挪到事务提交后执行,避免拖垮验收事务。
upgradeWorkerIds.push(workerId)
@@ -413,6 +413,7 @@ router.get(
(req) =>
listPendingConsignmentMatches({
categoryId: Number(req.query.categoryId || 0) || undefined,
inventoryId: Number(req.query.inventoryId || 0) || undefined,
keyword: String(req.query.keyword || ''),
includeConflicts: String(req.query.includeConflicts || '') === 'true',
limit: Number(req.query.limit || 0) || undefined,
@@ -53,6 +53,23 @@ test('重新匹配排除:原库存与原卖家都不再参与,且匹配成
assert.equal(isRematchExcludedInventory({} as never, { id: 12, worker_id: 7 }), false)
})
test('拼单重新匹配:material 里的全部原库存/原卖家都必须排除', () => {
const excluded = {
rematch_excluded_worker_id: 7,
rematch_excluded_inventory_id: 11,
material_json: JSON.stringify({
consignment: {
rematchExcludedInventoryIds: [11, 12, 13],
rematchExcludedWorkerIds: [7, 8, 9],
},
}),
}
assert.equal(isRematchExcludedInventory(excluded, { id: 12, worker_id: 99 }), true)
assert.equal(isRematchExcludedInventory(excluded, { id: 13, worker_id: 99 }), true)
assert.equal(isRematchExcludedInventory(excluded, { id: 14, worker_id: 8 }), true)
assert.equal(isRematchExcludedInventory(excluded, { id: 14, worker_id: 10 }), false)
})
test('超时重新匹配与匹配日志:链路必须接线到调度与后台接口', async () => {
const { readFile } = await import('node:fs/promises')
const [timeoutSrc, engineSrc, routes, logSrc] = await Promise.all([
@@ -143,16 +143,55 @@ export function inventoryMatchesProfile(
/**
* 超时「重新匹配」排除的原卖家/原库存:
* 同一单重新进入匹配池后,不允许原卖家或原库存立刻再次匹配,避免死循环。
*
* 拼单工单会同时占用多个卖家/多条库存,单列字段只能记住一个;
* 因此 `startConsignmentRematch` 会把完整集合写进
* `material_json.consignment.rematchExcludedInventoryIds / rematchExcludedWorkerIds`,
* 这里两处都读,保证「看板、候选列表、自动匹配、手动落库」口径一致。
*/
function resolveRematchExclusions(
workOrder: Pick<WorkOrderRow, 'rematch_excluded_worker_id' | 'rematch_excluded_inventory_id'> & {
material_json?: unknown
},
): { inventoryIds: Set<number>; workerIds: Set<number> } {
const inventoryIds = new Set<number>()
const workerIds = new Set<number>()
const singleInventoryId = Number(workOrder.rematch_excluded_inventory_id || 0)
const singleWorkerId = Number(workOrder.rematch_excluded_worker_id || 0)
// 没有重新匹配标记时直接返回,避免看板/候选循环里对每一对都解析 material JSON。
if (singleInventoryId <= 0 && singleWorkerId <= 0) {
return { inventoryIds, workerIds }
}
if (singleInventoryId > 0) inventoryIds.add(singleInventoryId)
if (singleWorkerId > 0) workerIds.add(singleWorkerId)
const material = parseMaterial(workOrder.material_json)
const consignment = parseMaterial(material.consignment)
const collectIds = (value: unknown, target: Set<number>) => {
if (!Array.isArray(value)) return
for (const item of value) {
const id = Number(item || 0)
if (id > 0) target.add(id)
}
}
collectIds(consignment.rematchExcludedInventoryIds, inventoryIds)
collectIds(consignment.rematchExcludedWorkerIds, workerIds)
return { inventoryIds, workerIds }
}
export function isRematchExcludedInventory(
workOrder: Pick<WorkOrderRow, 'rematch_excluded_worker_id' | 'rematch_excluded_inventory_id'>,
workOrder: Pick<WorkOrderRow, 'rematch_excluded_worker_id' | 'rematch_excluded_inventory_id'> & {
material_json?: unknown
},
inventory: Pick<ConsignmentInventoryRow, 'id' | 'worker_id'>,
): boolean {
const excludedInventoryId = Number(workOrder.rematch_excluded_inventory_id || 0)
const excludedWorkerId = Number(workOrder.rematch_excluded_worker_id || 0)
if (!excludedInventoryId && !excludedWorkerId) return false
if (excludedInventoryId && Number(inventory.id) === excludedInventoryId) return true
if (excludedWorkerId && Number(inventory.worker_id || 0) === excludedWorkerId) return true
const { inventoryIds, workerIds } = resolveRematchExclusions(workOrder)
if (inventoryIds.size === 0 && workerIds.size === 0) return false
if (inventoryIds.has(Number(inventory.id))) return true
if (Number(inventory.worker_id || 0) > 0 && workerIds.has(Number(inventory.worker_id)))
return true
return false
}
@@ -189,6 +228,15 @@ type InventoryMatchEvaluation = {
details: ReturnType<typeof evaluateConsignmentSpecMatch>['details']
}
/** 把逐字段判定收敛成看板/前端共用的三态。 */
export function resolveMatchType(
evaluation: InventoryMatchEvaluation,
): 'exact' | 'partial' | 'conflict' {
if (evaluation.exact) return 'exact'
if (evaluation.conflicts.length > 0) return 'conflict'
return 'partial'
}
type ConsignmentMatchInventoryCandidate = {
inventory: ConsignmentInventoryRow
evaluation: InventoryMatchEvaluation
@@ -615,7 +663,13 @@ export async function fallbackConsignmentWorkOrderToHall(
*/
export async function startConsignmentRematch(
workOrderId: number,
input: { excludeWorkerId?: number | null; excludeInventoryId?: number | null } = {},
input: {
excludeWorkerId?: number | null
excludeInventoryId?: number | null
/** 拼单重新匹配:本次占用的全部卖家/库存都要排除,不能只记第一条 */
excludeWorkerIds?: number[]
excludeInventoryIds?: number[]
} = {},
now = nowIso(),
): Promise<boolean> {
const workOrder = await getWorkOrderById(workOrderId)
@@ -625,6 +679,20 @@ export async function startConsignmentRematch(
await fallbackConsignmentWorkOrderToHall(workOrderId, now)
return false
}
const excludedInventoryIds = [
...new Set(
[...(input.excludeInventoryIds || []), Number(input.excludeInventoryId || 0)].filter(
(id) => Number(id) > 0,
),
),
]
const excludedWorkerIds = [
...new Set(
[...(input.excludeWorkerIds || []), Number(input.excludeWorkerId || 0)].filter(
(id) => Number(id) > 0,
),
),
]
// 与首次进入内部匹配同口径:0 表示不等待、直接发大厅。
const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch)
if (delayMinutes <= 0) {
@@ -652,13 +720,33 @@ export async function startConsignmentRematch(
[
profile.profileId,
deadline,
input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
excludedWorkerIds[0] ?? null,
excludedInventoryIds[0] ?? null,
now,
workOrderId,
],
)
if (!result.rows[0]) return false
// 单列字段只够兼容旧逻辑;完整排除集合写进 material,拼单的所有原卖家/原库存都会生效。
await query(
`
UPDATE work_orders
SET material_json = jsonb_set(
COALESCE(material_json, '{}'::jsonb),
'{consignment}',
COALESCE(material_json -> 'consignment', '{}'::jsonb) || $1::jsonb,
true
)
WHERE id = $2
`,
[
JSON.stringify({
rematchExcludedInventoryIds: excludedInventoryIds,
rematchExcludedWorkerIds: excludedWorkerIds,
}),
workOrderId,
],
)
await query(
`
INSERT INTO work_order_events (
@@ -673,8 +761,10 @@ export async function startConsignmentRematch(
profileId: profile.profileId,
delayMinutes,
deadline,
excludedWorkerId: input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
excludedInventoryId: input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
excludedWorkerId: excludedWorkerIds[0] ?? null,
excludedInventoryId: excludedInventoryIds[0] ?? null,
excludedWorkerIds,
excludedInventoryIds,
}),
now,
],
@@ -694,8 +784,10 @@ export async function startConsignmentRematch(
payload: {
delayMinutes,
deadline,
excludedWorkerId: input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
excludedInventoryId: input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
excludedWorkerId: excludedWorkerIds[0] ?? null,
excludedInventoryId: excludedInventoryIds[0] ?? null,
excludedWorkerIds,
excludedInventoryIds,
},
})
return true
@@ -708,7 +800,7 @@ export async function startConsignmentRematch(
* 旧口径的宽松匹配会把缺失系统/区服/系列全部判成 unknown 并在 conflicts 里丢掉,
* 如果不加这道门槛,标题只写「苏尔南手枪」的订单会被自动匹配到任意在库库存。
*/
function hasPositiveSpecSignal(
export function hasPositiveSpecSignal(
workOrder: WorkOrderRow,
evaluation: InventoryMatchEvaluation,
): boolean {
@@ -1110,6 +1202,16 @@ async function applySharingMatch(
let firstShareFeeFenPerUnit = 0
let firstShareNetFenPerUnit = 0
const smsPayloads: ConsignmentMatchSmsPayload[] = []
// 拼单会同时占用多条库存/多个卖家;重新匹配时必须把它们全部排除,
// 单列 rematch_excluded_* 只能记住第一条,所以完整集合写进 material 供下次排除。
const matchedInventoryIds = [
...new Set(plan.map(({ inventory }) => Number(inventory.id)).filter((id) => id > 0)),
]
const matchedWorkerIds = [
...new Set(
plan.map(({ inventory }) => Number(inventory.worker_id || 0)).filter((id) => id > 0),
),
]
for (const { inventory, take } of plan) {
// 拼单寄卖的份额报酬按「库存售价」而不是订单单价结算:
// 卖家交付的是寄卖货,整单与拼单必须同口径(净售价 × 数量),
@@ -1210,6 +1312,8 @@ async function applySharingMatch(
? {
consignment: {
inventoryId: firstInventoryId,
inventoryIds: matchedInventoryIds,
workerIds: matchedWorkerIds,
quantity: firstInventoryQuantity,
profileId: profile?.profileId ?? null,
matchedAt: now,
@@ -1233,6 +1337,8 @@ async function applySharingMatch(
profileId: profile?.profileId ?? null,
totalQuantity,
matchedQuantity,
inventoryIds: matchedInventoryIds,
workerIds: matchedWorkerIds,
matchedAt: now,
},
}),
@@ -1129,7 +1129,7 @@ async function main() {
assert(
Number(shares.rows[0]!.share_reward) === 17100,
// 寄卖拼单按库存售价结算(6000×3),不是订单单价 7000;再扣 5% 服务费。
`share reward=${shares.rows[0]!.share_reward},应按库存售价 3×6000 扣 5% 服务费`,
`share reward=${shares.rows[0]!.share_reward},应按 3×6000 库存净售价扣 5% 服务费`,
)
const inv = await query<Record<string, any>>(
@@ -367,7 +367,8 @@ test('看板新口径:库存 profile 与订单配置不一致时跳过', () =>
profileByOrderId: new Map([[101, buildProfile()]]),
})
assert.equal(result.pairCount, 0)
assert.equal(result.inStockInventoryCount, 0)
// 在库待匹配按「在库+可匹配+有余量」统计,不再等于「产生了配对的库存数」
assert.equal(result.inStockInventoryCount, 1)
})
test('看板新口径:无寄卖配置的订单仍按旧手枪口径兜底', () => {
@@ -447,3 +448,204 @@ test('看板按分类筛选:不传分类时保持原有全量口径', () => {
assert.equal(result.pendingOrderCount, 1)
})
test('看板数量口径:库存余量不够整单需求时不能用 100 分冒充可匹配', () => {
const order = buildOrder({
id: 501,
sharing_enabled: false,
sharing_total_quantity: 3,
})
const result = buildPendingConsignmentMatchPairs({
orders: [order],
inventories: [buildInventory({ id: 502, remaining_quantity: 1, quantity: 3 })],
profileByOrderId: new Map([[501, null]]),
})
assert.equal(result.pairCount, 1)
const pair = result.pairs[0]!
assert.equal(pair.requiredQuantity, 3)
assert.equal(pair.quantitySufficient, false)
assert.equal(pair.autoMatchable, false, '数量不足时自动匹配不会选中,看板不能标成可自动匹配')
assert.equal(pair.remainingQuantity, 1, '非拼单不展示拼单剩余份数')
assert.equal(result.exactCount, 0, '数量不足的精确项不计入精确')
assert.equal(result.quantityShortCount, 1)
assert.equal(result.autoMatchableCount, 0)
})
test('看板数量口径:拼单按整单汇总余量判断能否一次拼满', () => {
const profile = buildProfile({
profileId: 99,
categoryId: 9,
config: {
...DEFAULT_CONSIGNMENT_PROFILE_CONFIG,
specs: [],
matching: { mode: 'manual', exactOnly: true, preferLowReward: true },
},
})
const order = buildOrder({
id: 511,
category_id: 9,
sharing_enabled: true,
sharing_total_quantity: 3,
active_share_quantity: 1,
})
const enough = buildPendingConsignmentMatchPairs({
orders: [order],
inventories: [
buildInventory({
id: 512,
category_id: 9,
remaining_quantity: 2,
quantity: 2,
worker_id: 9,
}),
buildInventory({
id: 513,
category_id: 9,
remaining_quantity: 1,
quantity: 1,
worker_id: 9,
}),
],
profileByOrderId: new Map([[511, profile]]),
})
assert.ok(
enough.pairs.every((pair) => pair.quantitySufficient),
'2+1 能拼满剩余 2 份,两条库存都应标记为数量可行',
)
const short = buildPendingConsignmentMatchPairs({
orders: [order],
inventories: [
buildInventory({ id: 514, category_id: 9, remaining_quantity: 1, quantity: 1, worker_id: 9 }),
],
profileByOrderId: new Map([[511, profile]]),
})
assert.equal(short.pairs[0]?.quantitySufficient, false)
assert.equal(short.quantityShortCount, 1)
})
test('看板状态口径:内部匹配中的工单必须出现在看板里', () => {
const result = buildPendingConsignmentMatchPairs({
orders: [
buildOrder({ id: 521, status: 'internal_matching' }),
buildOrder({ id: 522, work_order_no: 'WK522', status: 'unassigned' }),
buildOrder({ id: 523, work_order_no: 'WK523', status: 'in_progress' }),
],
inventories: [buildInventory({ id: 524 })],
profileByOrderId: new Map([
[521, null],
[522, null],
[523, null],
]),
})
assert.deepEqual(
result.pairs.map((pair) => [pair.workOrderId, pair.workOrderStatus]),
[
[522, 'unassigned'],
[521, 'internal_matching'],
],
)
})
test('看板模板口径:模板能解析出订单规格时按 exact 展示,与引擎一致', () => {
const templateSpec = {
key: 'weaponSkin',
label: '枪皮',
type: 'text' as const,
required: true,
options: [],
placeholder: '',
helpText: '',
matchMode: 'exact' as const,
wildcardValues: [],
showInItemName: true,
sortOrder: 1,
}
const templateProfile = buildProfile({
config: {
...DEFAULT_CONSIGNMENT_PROFILE_CONFIG,
specSource: 'template',
specs: [templateSpec],
},
})
const order = buildCustomOrder({ id: 531, product_rule_id: 8801, material_json: '{}' })
const inventory = buildCustomInventory({ id: 532 })
const withoutOverride = buildPendingConsignmentMatchPairs({
orders: [order],
inventories: [inventory],
profileByOrderId: new Map([[531, templateProfile]]),
})
assert.equal(withoutOverride.pairs[0]?.matchType, 'partial', '没有模板解析结果时按 unknown 处理')
const withOverride = buildPendingConsignmentMatchPairs({
orders: [order],
inventories: [inventory],
profileByOrderId: new Map([[531, templateProfile]]),
orderSpecOverrideByOrderId: new Map([[531, { weaponSkin: '龙纹' }]]),
})
assert.equal(withOverride.pairs[0]?.matchType, 'exact')
assert.equal(withOverride.pairs[0]?.score, 100)
})
test('看板自动匹配口径:非精确必须同时满足配置允许与正向规格信号', () => {
const order = buildOrder({
id: 541,
product_name: '和平精英 苏尔南手枪',
material_json: '{}',
})
const partialInventory = buildInventory({ id: 542, diamond_level: 1 })
const allowPartialProfile = buildProfile({
profileId: 9,
categoryId: 1,
config: {
...DEFAULT_CONSIGNMENT_PROFILE_CONFIG,
specs: [],
matching: { mode: 'auto', exactOnly: false, preferLowReward: true },
},
})
const result = buildPendingConsignmentMatchPairs({
orders: [order],
inventories: [partialInventory],
profileByOrderId: new Map([[541, allowPartialProfile]]),
})
const pair = result.pairs[0]!
assert.equal(pair.matchType, 'partial')
assert.equal(
pair.autoMatchable,
false,
'订单没写系统/区服/系列时没有正向规格信号,自动匹配不会选中',
)
})
test('看板自动匹配口径:订单写清系统且有高配库存时允许自动非精确', () => {
const order = buildOrder({ id: 551, product_name: '苹果微信区 苏尔南手枪 暗潮 1钻' })
const higherInventory = buildInventory({ id: 552, diamond_level: 3 })
const allowPartialProfile = buildProfile({
profileId: 9,
categoryId: 1,
config: {
...DEFAULT_CONSIGNMENT_PROFILE_CONFIG,
specs: [],
matching: { mode: 'auto', exactOnly: false, preferLowReward: true },
},
})
const result = buildPendingConsignmentMatchPairs({
orders: [order],
inventories: [higherInventory],
profileByOrderId: new Map([[551, allowPartialProfile]]),
})
const pair = result.pairs[0]!
assert.equal(pair.matchType, 'partial')
assert.equal(pair.quantitySufficient, true)
assert.equal(pair.autoMatchable, true, '高配充低配有正向规格信号,应允许自动匹配')
assert.equal(result.autoMatchableCount, 1)
assert.equal(result.partialCount, 1, '非精确但数量可行要计入非精确')
})
@@ -1,4 +1,5 @@
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-mode.js'
import {
listActiveConsignmentInventoriesForMatching,
type ConsignmentInventoryRow,
@@ -12,11 +13,16 @@ import {
} from './consignment-matching-service.js'
import {
evaluateInventoryMatch,
hasPositiveSpecSignal,
inventoryMatchesProfile,
isRematchExcludedInventory,
planConsignmentSharingTakes,
resolveMatchType,
resolveOrderSpecOverride,
resolveProfileForWorkOrder,
} from './consignment-match-engine-service.js'
import type { ConsignmentProfileSnapshot } from './consignment-profile-service.js'
import type { ConsignmentSpecValues } from './consignment-spec-service.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
export type PendingPistolMatchPair = {
@@ -55,6 +61,17 @@ const OPEN_ORDER_FETCH_LIMIT = 500
const DEFAULT_RESULT_LIMIT = 300
const MAX_RESULT_LIMIT = 1000
/**
* 看板纳入的工单状态:
* - `internal_matching`:手动匹配模式下工单会在这里等运营挑库存,看板必须能看到;
* - `open` / `unassigned`:已发大厅但还没被接走,仍可由寄卖现货内部消化。
*/
const PENDING_BOARD_ORDER_STATUSES = new Set<string>([
WORK_ORDER_STATUS.INTERNAL_MATCHING,
WORK_ORDER_STATUS.OPEN,
WORK_ORDER_STATUS.UNASSIGNED,
])
function buildPairHaystack(pair: PendingPistolMatchPair): string {
return [
pair.workOrderNo,
@@ -192,15 +209,33 @@ export type PendingConsignmentMatchPair = PendingPistolMatchPair & {
remainingQuantity: number
totalQuantity: number
profileId: number | null
/** 订单还需要多少件/份;非拼单=订单总数量,拼单=总份数-已匹配份数 */
requiredQuantity: number
/** 该条库存的可匹配余量 */
availableQuantity: number
/** 该条库存的可匹配余量是否够覆盖 requiredQuantity(拼单看整单汇总) */
quantitySufficient: boolean
/** 自动匹配是否真会选中它:精确,或配置允许非精确且存在正向规格信号 */
autoMatchable: boolean
/** 是否为拼单工单 */
sharingEnabled: boolean
/** 工单当前状态(看板同时展示 internal_matching 与 open/unassigned) */
workOrderStatus: string
}
export type PendingConsignmentMatchResult = {
pendingOrderCount: number
inStockInventoryCount: number
pairCount: number
/** 规格精确且数量可行的组合 */
exactCount: number
/** 规格非精确且数量可行的组合 */
partialCount: number
conflictCount: number
/** 规格可行但库存余量不够的组合 */
quantityShortCount: number
/** 自动匹配真会选中的组合(精确,或允许非精确且有正向信号) */
autoMatchableCount: number
truncated: boolean
pairs: PendingConsignmentMatchPair[]
}
@@ -244,21 +279,30 @@ function scoreLegacy(
* 新口径的待抢单看板:与内部匹配引擎完全一致。
*
* - 有寄卖配置的工单:按 profile 过滤库存、跑 `evaluateInventoryMatch`,支持自定义规格;
* - 模板驱动配置:套用与引擎相同的订单侧规格覆盖(`product_rule_id` → 模板商品名);
* - 拼单工单:按 `sharing_total_quantity - active_share_quantity` 展示剩余份数;
* - 没有寄卖配置的工单:保留旧手枪分类器兜底,避免配置缺失时看板直接空掉。
*
* 看板只做「预防」:是否真能落到自动/手动匹配,还要看数量与正向规格信号,
* 因此每对结果都带 `quantitySufficient / autoMatchable`,避免运营看到 100 分却匹配不上。
*/
export function buildPendingConsignmentMatchPairs(input: {
orders: WorkOrderRow[]
inventories: ConsignmentInventoryRow[]
profileByOrderId: Map<number, ConsignmentProfileSnapshot | null>
/** 模板驱动配置的订单侧规格(由调用方异步解析;缺省时按 unknown 处理,不参与自动匹配) */
orderSpecOverrideByOrderId?: Map<number, ConsignmentSpecValues | null> | undefined
/** 只统计该寄卖分类下的订单与库存;0 / 未传 = 全部 */
categoryId?: number | undefined
/** 只统计这一条库存(库存侧「匹配订单」用) */
inventoryId?: number | undefined
keyword?: string | undefined
includeConflicts?: boolean | undefined
limit?: number | undefined
ordersTruncated?: boolean | undefined
}): PendingConsignmentMatchResult {
const categoryId = Math.max(0, Math.floor(Number(input.categoryId) || 0))
const inventoryId = Math.max(0, Math.floor(Number(input.inventoryId) || 0))
const keyword = String(input.keyword || '')
.trim()
.toLowerCase()
@@ -273,12 +317,16 @@ export function buildPendingConsignmentMatchPairs(input: {
profile: ConsignmentProfileSnapshot | null
remainingQuantity: number
totalQuantity: number
requiredQuantity: number
friendGift: boolean
sharingOrder: boolean
}> = []
for (const order of input.orders) {
if (String(order.status || '') !== WORK_ORDER_STATUS.OPEN) continue
if (!PENDING_BOARD_ORDER_STATUSES.has(String(order.status || ''))) continue
const profile = input.profileByOrderId.get(Number(order.id)) ?? null
const totalQuantity = Math.max(1, Number(order.sharing_total_quantity || 1))
const activeQuantity = Math.max(0, Number(order.active_share_quantity || 0))
const sharingOrder = Boolean(profile) && Boolean(order.sharing_enabled) && totalQuantity > 1
if (!profile) {
// 兜底:没有寄卖配置的订单仍按旧手枪口径展示,且保持“已有份额不参与”的旧规则。
if (activeQuantity > 0) continue
@@ -291,28 +339,59 @@ export function buildPendingConsignmentMatchPairs(input: {
].join(' ')
if (detectItemCategory(text) !== 'pistol_suernan') continue
}
const remainingQuantity =
profile && order.sharing_enabled ? Math.max(0, totalQuantity - activeQuantity) : 1
const remainingQuantity = sharingOrder ? Math.max(0, totalQuantity - activeQuantity) : 1
if (remainingQuantity <= 0) continue
// 按寄卖分类筛选:有配置的订单看配置归属,没配置的老订单回退工单自身分类
if (categoryId > 0) {
const orderCategoryId = profile ? profile.categoryId : Number(order.category_id || 0)
if (orderCategoryId !== categoryId) continue
}
preparedOrders.push({ order, profile, remainingQuantity, totalQuantity })
preparedOrders.push({
order,
profile,
remainingQuantity,
totalQuantity,
// 整单(含多数量)要一次占用订单总数量;拼单只补剩余份数。
requiredQuantity: sharingOrder ? remainingQuantity : totalQuantity,
friendGift: isFriendGiftAcceptanceMode(order.acceptance_mode),
sharingOrder,
})
}
const inventories = input.inventories.filter((inventory) => {
if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
if (inventoryId > 0 && Number(inventory.id) !== inventoryId) return false
// 库存必须属于当前寄卖分类;没有分类的历史货无法判断归属,按分类看板时排除
if (categoryId > 0 && Number(inventory.category_id || 0) !== categoryId) return false
return true
})
const pairs: PendingConsignmentMatchPair[] = []
const matchedInventoryIds = new Set<number>()
for (const { order, profile, remainingQuantity, totalQuantity } of preparedOrders) {
for (const {
order,
profile,
remainingQuantity,
totalQuantity,
requiredQuantity,
friendGift,
sharingOrder,
} of preparedOrders) {
const orderSpecOverride = input.orderSpecOverrideByOrderId?.get(Number(order.id)) ?? null
const allowPartial = profile
? profile.config.matching.mode === 'auto' && profile.config.matching.exactOnly === false
: false
type EvaluatedInventory = {
inventory: ConsignmentInventoryRow
matchType: 'exact' | 'partial' | 'conflict'
score: number
reason: string
conflicts: string[]
matchDetails: PendingConsignmentMatchDetail[]
positiveSpecSignal: boolean
}
const evaluated: EvaluatedInventory[] = []
for (const inventory of inventories) {
// 超时重新匹配的排除项:看板同样不能把它算成可匹配。
if (isRematchExcludedInventory(order, inventory)) continue
@@ -321,10 +400,13 @@ export function buildPendingConsignmentMatchPairs(input: {
let reason: string
let conflicts: string[]
let matchDetails: PendingConsignmentMatchDetail[]
let positiveSpecSignal = true
if (profile) {
if (!inventoryMatchesProfile(inventory, profile)) continue
const evaluation = evaluateInventoryMatch(order, inventory, profile)
const evaluation = evaluateInventoryMatch(order, inventory, profile, { orderSpecOverride })
// 与引擎同口径:只有正向规格信号存在时,非精确才可能被自动匹配选中。
positiveSpecSignal = hasPositiveSpecSignal(order, evaluation)
if (evaluation.details.length === 0) {
// 内置五字段沿用旧评分,保证存量口径与 0-100 分不变。
const legacy = scoreLegacy(order, inventory)
@@ -334,11 +416,7 @@ export function buildPendingConsignmentMatchPairs(input: {
conflicts = legacy.conflicts
matchDetails = []
} else {
matchType = evaluation.exact
? 'exact'
: evaluation.conflicts.length > 0
? 'conflict'
: 'partial'
matchType = resolveMatchType(evaluation)
score = scoreFromDetails(evaluation.details)
reason = buildEvaluationReason(evaluation)
conflicts = evaluation.conflicts
@@ -360,8 +438,43 @@ export function buildPendingConsignmentMatchPairs(input: {
matchDetails = []
}
matchedInventoryIds.add(Number(inventory.id))
if (matchType === 'conflict' && !includeConflicts) continue
evaluated.push({
inventory,
matchType,
score,
reason,
conflicts,
matchDetails,
positiveSpecSignal,
})
}
// 拼单是「多条库存凑满」:数量是否足够必须按整单可用余量汇总,
// 不能只看某一条库存是否 >= 剩余份数,否则会把 2+1 可拼满的订单全标成数量不足。
let sharingCoverageSufficient = true
if (sharingOrder) {
// 数量可行性只看「规格不是冲突」:非精确可以由运营填原因强制匹配,
// 自动匹配是否能选中由下面的 autoMatchable 单独判断。
const candidates = evaluated
.filter((item) => item.matchType !== 'conflict')
.map(({ inventory }) => ({ inventory }))
const { outstanding } = planConsignmentSharingTakes(candidates, remainingQuantity, friendGift)
sharingCoverageSufficient = outstanding === 0
}
for (const item of evaluated) {
const availableQuantity = Math.max(0, Number(item.inventory.remaining_quantity ?? 1))
// 整单/好友赠送必须一条库存吃满;拼单看整单汇总后能否一次拼满。
const quantitySufficient = sharingOrder
? sharingCoverageSufficient
: friendGift
? availableQuantity === requiredQuantity
: availableQuantity >= requiredQuantity
const autoMatchable =
item.matchType !== 'conflict' &&
quantitySufficient &&
(item.matchType === 'exact' || (allowPartial && item.positiveSpecSignal))
if (item.matchType === 'conflict' && !includeConflicts) continue
const pair: PendingConsignmentMatchPair = {
workOrderId: Number(order.id),
@@ -369,23 +482,31 @@ export function buildPendingConsignmentMatchPairs(input: {
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)
inventoryId: Number(item.inventory.id),
consignmentNo: String(
item.inventory.source_submission_no || item.inventory.inventory_no || '',
),
inventoryNo: String(item.inventory.inventory_no || ''),
itemName: String(item.inventory.item_name || ''),
inventoryInboundAt: item.inventory.inbound_at
? String(item.inventory.inbound_at)
: item.inventory.created_at
? String(item.inventory.created_at)
: null,
matchType,
score,
reason,
conflicts,
matchDetails,
matchType: item.matchType,
score: item.score,
reason: item.reason,
conflicts: item.conflicts,
matchDetails: item.matchDetails,
remainingQuantity,
totalQuantity,
profileId: profile?.profileId ?? null,
requiredQuantity,
availableQuantity,
quantitySufficient,
autoMatchable,
sharingEnabled: sharingOrder,
workOrderStatus: String(order.status || ''),
}
if (keyword && !buildPairHaystack(pair).includes(keyword)) continue
@@ -393,9 +514,17 @@ export function buildPendingConsignmentMatchPairs(input: {
}
}
const exactCount = pairs.filter((pair) => pair.matchType === 'exact').length
const partialCount = pairs.filter((pair) => pair.matchType === 'partial').length
const exactCount = pairs.filter(
(pair) => pair.matchType === 'exact' && pair.quantitySufficient,
).length
const partialCount = pairs.filter(
(pair) => pair.matchType === 'partial' && pair.quantitySufficient,
).length
const conflictCount = pairs.filter((pair) => pair.matchType === 'conflict').length
const quantityShortCount = pairs.filter(
(pair) => pair.matchType !== 'conflict' && !pair.quantitySufficient,
).length
const autoMatchableCount = pairs.filter((pair) => pair.autoMatchable).length
pairs.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score
@@ -410,28 +539,36 @@ export function buildPendingConsignmentMatchPairs(input: {
return {
pendingOrderCount: preparedOrders.length,
inStockInventoryCount: matchedInventoryIds.size,
inStockInventoryCount: inventories.length,
pairCount: pairs.length,
exactCount,
partialCount,
conflictCount,
quantityShortCount,
autoMatchableCount,
truncated: pairs.length > limit || input.ordersTruncated === true,
pairs: pairs.slice(0, limit),
}
}
/** 后台「寄售仓库 → 匹配」:待抢单 × 在库待匹配现货。 */
/** 后台「寄售仓库 → 匹配」:待内部匹配 / 待抢单 × 在库待匹配现货。 */
export async function listPendingConsignmentMatches(
input: {
/** 只统计该寄卖分类;0 / 未传 = 全部 */
categoryId?: number | undefined
/** 只统计这一条库存(库存侧「匹配订单」用) */
inventoryId?: number | undefined
keyword?: string | undefined
includeConflicts?: boolean | undefined
limit?: number | undefined
} = {},
): Promise<PendingConsignmentMatchResult> {
const { items, total } = await listWorkOrders({
statuses: [WORK_ORDER_STATUS.OPEN],
statuses: [
WORK_ORDER_STATUS.INTERNAL_MATCHING,
WORK_ORDER_STATUS.OPEN,
WORK_ORDER_STATUS.UNASSIGNED,
],
page: 1,
pageSize: OPEN_ORDER_FETCH_LIMIT,
includeTotal: true,
@@ -455,11 +592,27 @@ export async function listPendingConsignmentMatches(
}),
)
// 模板驱动配置的订单侧规格必须与引擎同源解析,否则看板会把「标题没写规格、
// 但模板能解析出规格」的订单误判成 partial。
const orderSpecOverrideByOrderId = new Map<number, ConsignmentSpecValues | null>()
await Promise.all(
items.map(async (order) => {
const profile = profileByOrderId.get(Number(order.id)) ?? null
if (profile?.config.specSource !== 'template') return
orderSpecOverrideByOrderId.set(
Number(order.id),
await resolveOrderSpecOverride(order, profile).catch(() => null),
)
}),
)
return buildPendingConsignmentMatchPairs({
orders: items,
inventories,
profileByOrderId,
orderSpecOverrideByOrderId,
categoryId: input.categoryId,
inventoryId: input.inventoryId,
keyword: input.keyword,
includeConflicts: input.includeConflicts,
limit: input.limit,
@@ -20,21 +20,40 @@ import {
startConsignmentRematch,
} from './consignment-match-engine-service.js'
/** 从工单物料里取本次匹配占用的寄卖库存;重新匹配时用于排除原库存。 */
function resolveConsignmentInventoryId(material: unknown): number | null {
/** 从工单物料里取本次匹配占用的寄卖库存/卖家;拼单会占多条库存、多个卖家。 */
function resolveConsignmentExclusions(material: unknown): {
inventoryIds: number[]
workerIds: number[]
} {
let source = material
if (typeof material === 'string') {
try {
source = JSON.parse(material)
} catch {
return null
return { inventoryIds: [], workerIds: [] }
}
}
if (!source || typeof source !== 'object' || Array.isArray(source)) return null
const consignment = (source as Record<string, unknown>).consignment
if (!consignment || typeof consignment !== 'object' || Array.isArray(consignment)) return null
const inventoryId = Number((consignment as Record<string, unknown>).inventoryId || 0)
return inventoryId > 0 ? inventoryId : null
if (!source || typeof source !== 'object' || Array.isArray(source)) {
return { inventoryIds: [], workerIds: [] }
}
const record = source as Record<string, unknown>
const collect = (value: unknown): number[] => {
if (!Array.isArray(value)) return []
return value.map((item) => Number(item || 0)).filter((id) => id > 0)
}
const inventoryIds = new Set<number>()
const workerIds = new Set<number>()
for (const block of [record.consignment, record.consignmentSharing]) {
if (!block || typeof block !== 'object' || Array.isArray(block)) continue
const item = block as Record<string, unknown>
const singleInventoryId = Number(item.inventoryId || 0)
if (singleInventoryId > 0) inventoryIds.add(singleInventoryId)
const singleWorkerId = Number(item.workerId || 0)
if (singleWorkerId > 0) workerIds.add(singleWorkerId)
for (const id of collect(item.inventoryIds)) inventoryIds.add(id)
for (const id of collect(item.workerIds)) workerIds.add(id)
}
return { inventoryIds: [...inventoryIds], workerIds: [...workerIds] }
}
/**
@@ -45,7 +64,12 @@ function resolveConsignmentInventoryId(material: unknown): number | null {
*/
async function startRematchOrFallback(
workOrderId: number,
input: { excludeWorkerId?: number | null; excludeInventoryId?: number | null },
input: {
excludeWorkerId?: number | null
excludeInventoryId?: number | null
excludeWorkerIds?: number[]
excludeInventoryIds?: number[]
},
now: string,
): Promise<void> {
try {
@@ -76,11 +100,18 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
processedCount += 1
if (isRematchTimeoutPolicy(policy)) {
// 重新匹配:回内部匹配池,并排除原卖家/原库存,避免同一卖家立刻接回同一单。
const exclusions = resolveConsignmentExclusions(workOrder.material_json)
const assignedWorkerId = Number(workOrder.assigned_worker_id || 0)
await startRematchOrFallback(
Number(workOrder.id),
{
excludeWorkerId: Number(workOrder.assigned_worker_id || 0) || null,
excludeInventoryId: resolveConsignmentInventoryId(workOrder.material_json),
excludeWorkerId: assignedWorkerId || null,
excludeInventoryId: exclusions.inventoryIds[0] ?? null,
excludeWorkerIds: [
...exclusions.workerIds,
...(assignedWorkerId > 0 ? [assignedWorkerId] : []),
],
excludeInventoryIds: exclusions.inventoryIds,
},
nowIso(),
)
@@ -0,0 +1,250 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Alert, App, Button, Checkbox, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import {
matchAdminConsignmentWorkOrder,
fetchPendingPistolMatches,
} from '@/services/admin/consignment'
import type { PendingPistolMatchPair } from '@/types/consignment'
import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
/**
* 库存侧「匹配订单」:从一条在库现货出发,复用待匹配看板的统一判定
* (profile、模板规格、数量、重新匹配排除),落库走新引擎 `/consignment/match`。
*
* 旧 `/fulfill` 链路只做整单、且不认数量拆分,这里不再暴露。
*/
export default function ConsignmentInventoryMatchModal({
open,
inventoryId,
inventoryLabel,
onClose,
onMatched,
}: {
open: boolean
inventoryId: number | null
inventoryLabel?: string
onClose: () => void
onMatched?: () => void
}) {
const { message } = App.useApp()
const [selectedId, setSelectedId] = useState<number | null>(null)
const [allowNonExact, setAllowNonExact] = useState(false)
const [reason, setReason] = useState('')
const [submitting, setSubmitting] = useState(false)
const query = useQuery({
queryKey: ['admin-consignment-inventory-match-orders', inventoryId ?? null],
queryFn: () =>
fetchPendingPistolMatches({
inventoryId: Number(inventoryId),
includeConflicts: true,
limit: 1000,
}),
enabled: open && Boolean(inventoryId),
refetchOnWindowFocus: false,
retry: false,
})
const pairs = useMemo(() => query.data?.data?.pairs ?? [], [query.data])
const selected = useMemo(
() => pairs.find((pair) => pair.workOrderId === selectedId) || null,
[pairs, selectedId],
)
const selectedBlocked = selected
? selected.quantitySufficient === false || (selected.matchType === 'conflict' && !allowNonExact)
: true
const needReason = Boolean(selected && selected.matchType !== 'exact')
const columns: TableColumnsType<PendingPistolMatchPair> = [
{
title: '选择',
key: 'select',
width: 64,
render: (_, record) => {
const disabled =
record.quantitySufficient === false || (record.matchType === 'conflict' && !allowNonExact)
return (
<Button
size="small"
type={selectedId === record.workOrderId ? 'primary' : 'default'}
disabled={disabled}
onClick={() => setSelectedId(record.workOrderId)}
>
{selectedId === record.workOrderId ? '已选' : '选择'}
</Button>
)
},
},
{
title: '工单',
key: 'order',
width: 220,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Text copyable={{ text: record.workOrderNo }} style={{ fontSize: 12, fontWeight: 600 }}>
{record.workOrderNo || '-'}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{record.platformOrderId || '-'} · {record.workOrderStatus || '-'}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
</Text>
</Space>
),
},
{
title: '商品名',
key: 'productName',
width: 200,
render: (_, record) => (
<Text ellipsis={{ tooltip: record.productName }} style={{ maxWidth: '100%' }}>
{record.productName || '-'}
</Text>
),
},
{
title: '规格判定',
key: 'match',
width: 120,
render: (_, record) => {
if (record.quantitySufficient === false) return <Tag color="orange">数量不足</Tag>
if (record.matchType === 'exact') return <Tag color="green">精确</Tag>
if (record.matchType === 'conflict') return <Tag>不匹配</Tag>
return <Tag color="blue">非精确</Tag>
},
},
{
title: '数量',
key: 'quantity',
width: 130,
render: (_, record) => (
<Text
type={record.quantitySufficient === false ? 'danger' : 'secondary'}
style={{ fontSize: 12 }}
>
{record.sharingEnabled
? `拼单还需 ${record.requiredQuantity ?? 1} 份 / 该库存剩 ${record.availableQuantity ?? '-'} 件`
: `需求 ${record.requiredQuantity ?? 1} 件 / 该库存剩 ${record.availableQuantity ?? '-'} 件`}
</Text>
),
},
{
title: '匹配说明',
key: 'reason',
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>
),
},
]
function handleClose() {
setSelectedId(null)
setAllowNonExact(false)
setReason('')
onClose()
}
async function handleSubmit() {
if (!selected || !inventoryId) return
setSubmitting(true)
try {
const res = await matchAdminConsignmentWorkOrder({
workOrderId: selected.workOrderId,
inventoryIds: [Number(inventoryId)],
allowNonExact: allowNonExact || selected.matchType !== 'exact',
reason: reason.trim(),
})
if (res.data?.matched === false) {
message.warning('工单状态或库存已变化,请刷新后重试')
await query.refetch()
return
}
message.success('已用该寄卖库存匹配工单')
setSelectedId(null)
setAllowNonExact(false)
setReason('')
onMatched?.()
handleClose()
} catch (error) {
message.error(error instanceof Error ? error.message : '匹配失败,请刷新后重试')
void query.refetch()
} finally {
setSubmitting(false)
}
}
return (
<Modal
title={`匹配订单 · ${inventoryLabel || `库存 #${inventoryId ?? ''}`}`}
open={open}
width="min(1200px, 94vw)"
onCancel={handleClose}
okText="确认匹配"
cancelText="取消"
okButtonProps={{ disabled: !selected || selectedBlocked || (needReason && !reason.trim()) }}
confirmLoading={submitting}
onOk={() => void handleSubmit()}
destroyOnHidden
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="按寄卖配置与数量判定候选工单;多数量库存会按剩余量扣减,不会被整批消耗。"
description="精确匹配可直接提交;非精确或规格冲突必须勾选强制匹配并填写原因。拼单工单必须一次拼满,单条库存不够时请到工单侧匹配。"
/>
<Space wrap size={12}>
<Checkbox
checked={allowNonExact}
onChange={(event) => setAllowNonExact(event.target.checked)}
>
允许非精确/冲突强制匹配
</Checkbox>
<Input
style={{ width: 360 }}
placeholder="强制匹配原因(非精确必填)"
value={reason}
onChange={(event) => setReason(event.target.value)}
disabled={!allowNonExact && selected?.matchType === 'exact'}
/>
<Button 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}
<PercentTable<PendingPistolMatchPair>
rowKey={(record) => `${record.workOrderId}-${record.inventoryId}`}
size="small"
loading={query.isFetching}
dataSource={pairs}
columns={columns}
pagination={{ pageSize: 10, showSizeChanger: false }}
locale={{
emptyText: <Empty description="这条库存当前没有可匹配的工单" />,
}}
/>
</Space>
</Modal>
)
}
@@ -1,571 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Checkbox,
Descriptions,
Empty,
Input,
Modal,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd'
import { SearchOutlined, TeamOutlined } from '@ant-design/icons'
import {
fetchInventoryMatchableOrders,
fetchWorkOrderConsignmentMatchOptions,
fulfillWorkOrderWithConsignment,
} from '@/services/admin/consignment'
import type { ConsignmentMatchOptionView, ConsignmentMatchableOrder } from '@/types/consignment'
import {
canSubmitManualMatch,
describeManualMatchType,
filterManualMatchOptions,
resolveManualMatchRequirement,
type ManualMatchType,
} from '@/utils/consignment-manual-match'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { formatDateTime } from '@/utils/date-time'
import { notifyVoucherConsume } from '@/utils/voucher-consume'
const { Text, Paragraph } = Typography
type Selected = {
workOrderId: number | string
workOrderLabel: string
workOrderCreatedAt?: string
inventoryId: number | string
inventoryLabel: string
inventoryInboundAt?: string
matchType: ManualMatchType
conflicts: string[]
/** 供货打手(用于回显供货来源) */
workerId?: number | null
workerName?: string
/** 对接客服(用于履约前核对归属) */
contactSupporterName?: string
}
export type ConsignmentManualMatchModalProps = {
open: boolean
onClose: () => void
/** 订单侧入口:从订单出发选现货 */
workOrder?: { workOrderId: number | string; label: string; createdAt?: string } | null
/** 库存侧入口:从现货出发选订单 */
inventory?: { inventoryId: number | string; label: string; inboundAt?: string } | null
onMatched?: () => void
}
/**
* 「手动指定现货」弹窗:订单侧与库存侧共用一套交互与落地逻辑。
*
* 统一走内部现货直接履约(原子占库、免外派佣金、不冻结重复押金、跳过打手资料直接进入待加好友)。
*/
export default function ConsignmentManualMatchModal({
open,
onClose,
workOrder,
inventory,
onMatched,
}: ConsignmentManualMatchModalProps) {
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<ConsignmentMatchOptionView[]>([])
const [orders, setOrders] = useState<ConsignmentMatchableOrder[]>([])
const [inventoryLabel, setInventoryLabel] = useState('')
const [inventoryInboundAt, setInventoryInboundAt] = useState('')
const [inventoryWorker, setInventoryWorker] = useState<{
workerId: number | null
workerName: string
} | null>(null)
const [inventoryContact, setInventoryContact] = useState<{
contactSupporterName: string
} | null>(null)
const [selected, setSelected] = useState<Selected | null>(null)
const [confirmedPartial, setConfirmedPartial] = useState(false)
const [confirmedMismatch, setConfirmedMismatch] = useState(false)
const [matchNote, setMatchNote] = useState('')
const [submitting, setSubmitting] = useState(false)
const mode: 'order' | 'inventory' = inventory ? 'inventory' : 'order'
const resetState = () => {
setKeyword('')
setSelected(null)
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
setInventoryInboundAt('')
setInventoryContact(null)
}
const load = async () => {
setLoading(true)
try {
if (inventory) {
// 关键字与分页透传后端:库存侧默认只拉 50 条时,第 51 单以后永远搜不到
const res = await fetchInventoryMatchableOrders(inventory.inventoryId, {
keyword: keyword.trim() || undefined,
pageSize: 100,
})
setOrders(res.data?.orders || [])
setInventoryLabel(
res.data?.inventory
? `${res.data.inventory.itemName}(${res.data.inventory.sourceSubmissionNo || res.data.inventory.inventoryNo})`
: inventory.label,
)
setInventoryInboundAt(res.data?.inventory?.inboundAt || inventory.inboundAt || '')
setInventoryWorker({
workerId: res.data?.inventory?.workerId ?? null,
workerName: res.data?.inventory?.workerName || '',
})
setInventoryContact({
contactSupporterName: res.data?.inventory?.contactSupporterName || '',
})
} else if (workOrder) {
const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId)
setOptions(res.data?.options || [])
}
} catch (err: any) {
message.error(err.message || '读取可匹配数据失败')
} finally {
setLoading(false)
}
}
// 库存侧:关键字防抖透传后端搜索(本地过滤只作用在已拉取的 100 条内)
useEffect(() => {
if (!open || !inventory) return
const timer = setTimeout(() => {
void load()
}, 300)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword])
const filteredOptions = useMemo(
() =>
filterManualMatchOptions(options, keyword, (item) => [
item.inventory.itemName,
item.inventory.sourceSubmissionNo,
item.inventory.inventoryNo,
item.inventory.system,
item.inventory.serverZone,
item.inventory.series,
item.inventory.workerName,
item.inventory.buyerName,
item.inventory.sellerGameId,
item.inventory.id,
]),
[options, keyword],
)
const filteredOrders = useMemo(
() =>
filterManualMatchOptions(orders, keyword, (item) => [
item.workOrderNo,
item.workOrderId,
item.productName,
item.requiredSpec.system,
item.requiredSpec.serverZone,
item.requiredSpec.series,
item.status,
]),
[orders, keyword],
)
const requirement = resolveManualMatchRequirement(selected?.matchType || 'exact')
const canSubmit =
Boolean(selected) &&
canSubmitManualMatch({
matchType: selected?.matchType || 'exact',
confirmedPartial,
confirmedMismatch,
matchNote,
})
async function handleSubmit() {
if (!selected) return
setSubmitting(true)
try {
const res = await fulfillWorkOrderWithConsignment({
workOrderId: selected.workOrderId,
inventoryId: selected.inventoryId,
allowPartial: selected.matchType !== 'exact' || confirmedPartial,
allowSpecMismatch: selected.matchType === 'conflict' || confirmedMismatch,
matchNote: matchNote.trim(),
})
// 核销失败时共享工具会弹警告(状态已变更但凭证没消耗),此时不能再弹"成功"掩盖
const voucher = (res.data as any)?.voucherConsume
notifyVoucherConsume(voucher)
if (!voucher || voucher.ok) {
message.success('已使用内部寄售现货直接履约,已进入待加好友阶段')
}
onMatched?.()
resetState()
onClose()
} catch (err: any) {
// 列表数据过期(并发占用 / 打开弹窗后订单规格被改):刷新列表按最新判定重来,
// 否则运营会对着已失效的候选反复重试、循环撞同一个错
const code = String(err?.errorCode || '')
if (
code === 'consignment_inventory_race_condition' ||
code === 'consignment_inventory_unavailable'
) {
message.error('该现货刚被其他操作占用或已下架,已刷新列表,请重新选择')
setSelected(null)
void load()
return
}
if (
code === 'consignment_spec_mismatch' ||
code === 'consignment_spec_partial_requires_confirm'
) {
message.error('订单或现货的规格已变化(列表数据过期),已刷新,请按最新判定重新确认')
setSelected(null)
void load()
return
}
message.error(err.message || '内部现货履约失败')
} finally {
setSubmitting(false)
}
}
return (
<Modal
title={mode === 'order' ? `手动指定现货 · ${workOrder?.label || ''}` : '手动匹配订单'}
open={open}
width={860}
okText="确认内部现货履约"
okButtonProps={{
disabled: !canSubmit,
loading: submitting,
}}
onOk={() => void handleSubmit()}
onCancel={() => {
resetState()
onClose()
}}
afterOpenChange={(visible) => {
if (visible) {
void load()
} else {
resetState()
}
}}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="手动匹配将直接选用内部寄售现货履约(原子占库、免外派佣金、不冻结重复押金、自动跳过打手资料)。"
description={
mode === 'order'
? '下面是该订单的全部在库现货(含规格不一致的),核对差异后直接内部履约。'
: `当前现货:${inventoryLabel || inventory?.label || ''}${
inventoryInboundAt ? `(审核入库:${formatDateTime(inventoryInboundAt)})` : ''
}。下面是可匹配的订单列表,核对后直接内部履约。`
}
/>
<Input
placeholder={
mode === 'order'
? '搜索物品名 / 寄卖单号 (SUB...) / 仓库号 (INV...) / 规格 / 供货打手'
: '搜索工单号 (WK... / WO...) / 平台单号 / 标题 / 规格'
}
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
/>
<Spin spinning={loading}>
<div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 4 }}>
{mode === 'order' ? (
filteredOptions.length === 0 ? (
<Empty description="没有可匹配的现货" />
) : (
filteredOptions.map((option) => {
const meta = describeManualMatchType(option.matchType)
const isSelected =
selected?.inventoryId != null &&
String(selected.inventoryId) === String(option.inventory.id)
return (
<div
key={option.inventory.id}
style={{
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
borderRadius: 8,
padding: 10,
marginBottom: 8,
background: isSelected ? '#fff7e6' : '#fff',
}}
>
<Space
style={{ width: '100%', justifyContent: 'space-between' }}
align="start"
>
<Space direction="vertical" size={2}>
<Space size={6} wrap>
<Tag color="cyan">
{option.inventory.sourceSubmissionNo || option.inventory.inventoryNo}
</Tag>
<Text strong>{option.inventory.itemName}</Text>
<Tag color={meta.color}>{meta.label}</Tag>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
寄卖单号{' '}
{option.inventory.sourceSubmissionNo || option.inventory.inventoryNo} ·{' '}
{option.inventory.system || '未指定'}/
{option.inventory.serverZone || '未指定'}/
{option.inventory.series || '未指定'}{' '}
{option.inventory.diamondLevel
? `${option.inventory.diamondLevel}钻`
: ''}{' '}
· 货源 {option.inventory.workerName || '未关联打手'} · 结算价 ¥
{(Number(option.inventory.costPriceFen || 0) / 100).toFixed(2)} ·
对接客服 {formatConsignmentContactSupporter(option.inventory).text}
{option.inventory.inboundAt || option.inventory.createdAt ? (
<>
{' '}
· 审核入库{' '}
{formatDateTime(
option.inventory.inboundAt || option.inventory.createdAt,
)}
</>
) : null}
</Text>
{option.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
差异:{option.conflicts.join(';')}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{option.reason || '规格完全一致'}
</Text>
)}
</Space>
<Button
size="small"
type={isSelected ? 'primary' : 'default'}
onClick={() => {
setSelected({
workOrderId: workOrder?.workOrderId || 0,
workOrderLabel: workOrder?.label || '',
workOrderCreatedAt: workOrder?.createdAt,
inventoryId: option.inventory.id,
inventoryLabel: option.inventory.itemName,
inventoryInboundAt:
option.inventory.inboundAt || option.inventory.createdAt,
matchType: option.matchType,
conflicts: option.conflicts,
workerId: option.inventory.workerId,
workerName: option.inventory.workerName,
contactSupporterName: option.inventory.contactSupporterName,
})
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}}
>
{isSelected ? '已选' : '选它'}
</Button>
</Space>
</div>
)
})
)
) : filteredOrders.length === 0 ? (
<Empty description="没有待匹配的订单" />
) : (
filteredOrders.map((order) => {
const meta = describeManualMatchType(order.matchType)
const isSelected =
selected?.workOrderId != null &&
String(selected.workOrderId) === String(order.workOrderId)
return (
<div
key={order.workOrderId}
style={{
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
borderRadius: 8,
padding: 10,
marginBottom: 8,
background: isSelected ? '#fff7e6' : '#fff',
}}
>
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<Space direction="vertical" size={2}>
<Space size={6} wrap>
<Tag color="purple">{order.workOrderNo || `#${order.workOrderId}`}</Tag>
<Text strong>{order.productName}</Text>
<Tag color={meta.color}>{meta.label}</Tag>
{order.status === 'open' ? (
<Tag color="blue">大厅待抢</Tag>
) : order.status === 'unassigned' ? (
<Tag color="orange">待指派</Tag>
) : order.status === 'pending_material' ? (
<Tag color="gold">待补资料</Tag>
) : null}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
要求:{order.requiredSpec.system || '未指定'}/
{order.requiredSpec.serverZone || '未指定'}/
{order.requiredSpec.series || '未指定'}{' '}
{order.requiredSpec.diamondLevel
? `${order.requiredSpec.diamondLevel}钻`
: ''}{' '}
· 报酬 ¥{(order.rewardAmountFen / 100).toFixed(2)}
{order.createdAt ? (
<> · 创建时间 {formatDateTime(order.createdAt)}</>
) : null}
</Text>
{order.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
差异:{order.conflicts.join(';')}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{order.reason || '规格相符'}
</Text>
)}
</Space>
<Button
size="small"
type={isSelected ? 'primary' : 'default'}
onClick={() => {
setSelected({
workOrderId: order.workOrderId,
workOrderLabel: `${order.workOrderNo || `#${order.workOrderId}`} · ${order.productName}`,
workOrderCreatedAt: order.createdAt,
inventoryId: inventory?.inventoryId || 0,
inventoryLabel,
inventoryInboundAt,
matchType: order.matchType,
conflicts: order.conflicts,
workerId: inventoryWorker?.workerId ?? null,
workerName: inventoryWorker?.workerName || '',
contactSupporterName: inventoryContact?.contactSupporterName || '',
})
setConfirmedPartial(false)
setConfirmedMismatch(false)
setMatchNote('')
}}
>
{isSelected ? '已选' : '选它'}
</Button>
</Space>
</div>
)
})
)}
</div>
</Spin>
{selected ? (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 12 }}>
<Descriptions size="small" column={1} style={{ marginBottom: 8 }}>
<Descriptions.Item label="订单">
<Space size={6} wrap>
<span>{selected.workOrderLabel || '—'}</span>
{selected.workOrderCreatedAt ? (
<Text type="secondary" style={{ fontSize: 12 }}>
(创建于 {formatDateTime(selected.workOrderCreatedAt)})
</Text>
) : null}
</Space>
</Descriptions.Item>
<Descriptions.Item label="现货">
<Space size={6} wrap>
<span>{selected.inventoryLabel || '—'}</span>
{selected.inventoryInboundAt ? (
<Text type="secondary" style={{ fontSize: 12 }}>
(审核入库于 {formatDateTime(selected.inventoryInboundAt)})
</Text>
) : null}
</Space>
</Descriptions.Item>
<Descriptions.Item label="供货来源">
{selected.workerId ? (
<Space size={6}>
<Tag icon={<TeamOutlined />} color="blue">
{selected.workerName || `供货打手 #${selected.workerId}`}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
(验收后将自动为该打手发放货款)
</Text>
</Space>
) : (
<Tag color="default">内部库存现货(无打手关联)</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="对接客服">
{formatConsignmentContactSupporter(selected).text}
</Descriptions.Item>
<Descriptions.Item label="匹配判定">
<Tag color={describeManualMatchType(selected.matchType).color}>
{describeManualMatchType(selected.matchType).label}
</Tag>
{selected.conflicts.length > 0 ? (
<Text style={{ fontSize: 12, color: '#cf1322' }}>
{selected.conflicts.join(';')}
</Text>
) : null}
</Descriptions.Item>
</Descriptions>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{requirement.needsPartialConfirm ? (
<Checkbox
checked={confirmedPartial}
onChange={(e) => setConfirmedPartial(e.target.checked)}
>
允许非精确匹配(订单规格未写全或高配充低配)
</Checkbox>
) : null}
{requirement.needsMismatchConfirm ? (
<>
<Alert
type="error"
showIcon
message="该现货与订单规格不一致,确认要强制匹配?"
description="系统/区服/系列/钻级不符通常是发错货;确实要指定请勾选下方确认并填写原因,操作会留痕。"
/>
<Checkbox
checked={confirmedMismatch}
onChange={(e) => setConfirmedMismatch(e.target.checked)}
>
确认规格不一致,仍要手动匹配
</Checkbox>
</>
) : null}
{requirement.needsNote ? (
<Input.TextArea
rows={2}
maxLength={200}
showCount
placeholder="手动匹配原因(必填,会写入工单留痕,便于事后核对)"
value={matchNote}
onChange={(e) => setMatchNote(e.target.value)}
/>
) : null}
<Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
确认后现货立即被占用并进入「待加好友」,双方加好买家好友后点确认加好友进入赠送冷却期。
如需退回请使用「撤销寄售履约」。
</Paragraph>
</Space>
</div>
) : null}
</Space>
</Modal>
)
}
@@ -12,13 +12,25 @@ import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
function resolveScoreTag(pair: PendingPistolMatchPair) {
if (pair.matchType === 'exact') return { color: 'green', label: '精确' }
if (pair.quantitySufficient === false) return { color: 'orange', label: '数量不足' }
if (pair.matchType === 'exact') {
return pair.autoMatchable === false
? { color: 'gold', label: '需人工' }
: { color: 'green', label: '精确' }
}
if (pair.matchType === 'conflict') return { color: 'default', label: '不匹配' }
if (pair.autoMatchable) return { color: 'blue', label: '可自动' }
if (pair.score >= 90) return { color: 'blue', label: '非精确' }
if (pair.score >= 70) return { color: 'orange', label: '非精确' }
return { color: 'gold', label: '需核对' }
}
function resolveOrderStatusTag(status?: string) {
if (status === 'internal_matching') return { color: 'purple', label: '内部匹配中' }
if (status === 'unassigned') return { color: 'default', label: '待分配' }
return { color: 'blue', label: '待抢单' }
}
export default function ConsignmentPendingMatchModal({
open,
onClose,
@@ -106,9 +118,28 @@ export default function ConsignmentPendingMatchModal({
<Text type="secondary" style={{ fontSize: 11 }}>
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
</Text>
{Number(record.totalQuantity || 1) > 1 ? (
<Text type="secondary" style={{ fontSize: 11 }}>
拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份
<Space size={4} wrap>
<Tag
color={resolveOrderStatusTag(record.workOrderStatus).color}
style={{ marginInlineEnd: 0 }}
>
{resolveOrderStatusTag(record.workOrderStatus).label}
</Tag>
{Number(record.totalQuantity || 1) > 1 ? (
<Text type="secondary" style={{ fontSize: 11 }}>
拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份
</Text>
) : null}
</Space>
{Number(record.requiredQuantity || 0) > 0 ? (
<Text
type={record.quantitySufficient === false ? 'danger' : 'secondary'}
style={{ fontSize: 11 }}
>
{record.sharingEnabled
? `拼单还需 ${record.requiredQuantity} 份 / 该库存剩 ${record.availableQuantity ?? '-'} 件`
: `需求 ${record.requiredQuantity} 件 / 该库存剩 ${record.availableQuantity ?? '-'} 件`}
{record.quantitySufficient === false ? '(整单汇总不足)' : ''}
</Text>
) : null}
</Space>
@@ -198,7 +229,11 @@ export default function ConsignmentPendingMatchModal({
return (
<Modal
title={categoryName ? `匹配 · ${categoryName} 待抢单 × 在库现货` : '匹配 · 待抢单 × 在库现货'}
title={
categoryName
? `匹配 · ${categoryName} 待匹配订单 × 在库现货`
: '匹配 · 待匹配订单 × 在库现货'
}
open={open}
width="min(1320px, 94vw)"
footer={null}
@@ -211,19 +246,21 @@ export default function ConsignmentPendingMatchModal({
showIcon
message={
categoryName
? `只统计「${categoryName}」分类下的待抢单 × 在库待匹配现货,不直接改单。`
: '按寄卖配置匹配全部待抢单 × 在库待匹配现货,不直接改单。'
? `只统计「${categoryName}」分类下的待匹配订单 × 在库待匹配现货,不直接改单。`
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货,不直接改单。'
}
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);拼单订单按剩余份数展示。"
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);内部匹配中、待分配、待抢单的工单都会纳入。数量不足或没有正向规格信号的非精确项会单独标记,自动匹配不会选中。"
/>
<Space wrap size={12}>
{categoryName ? <Tag color="blue">分类:{categoryName}</Tag> : null}
<Tag>待抢单 {data?.pendingOrderCount ?? 0} 单</Tag>
<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>
<Tag color="purple">数量不足 {data?.quantityShortCount ?? 0}</Tag>
<Tag color="cyan">可自动 {data?.autoMatchableCount ?? 0}</Tag>
{includeConflicts ? <Tag>不匹配 {data?.conflictCount ?? 0}</Tag> : null}
</Space>
@@ -53,7 +53,7 @@ import {
updateAdminConsignmentInventory,
} from '@/services/admin/consignment'
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import ConsignmentInventoryMatchModal from '@/components/admin/ConsignmentInventoryMatchModal'
import type {
ConsignmentInventory,
ConsignmentInventoryFilterCategory,
@@ -1617,19 +1617,13 @@ export default function ConsignmentInventoryPanel() {
</Form>
</Modal>
{/* 手动匹配订单:从现货出发挑订单(与订单侧共用同一弹窗) */}
<ConsignmentManualMatchModal
{/* 匹配订单:从现货出发,走新引擎(数量/配置/重新匹配排除与看板同口径) */}
<ConsignmentInventoryMatchModal
key={manualMatchItem?.id ?? 'none'}
open={Boolean(manualMatchItem)}
inventoryId={manualMatchItem?.id ?? null}
inventoryLabel={manualMatchItem?.itemName}
onClose={() => setManualMatchItem(null)}
inventory={
manualMatchItem
? {
inventoryId: manualMatchItem.id,
label: manualMatchItem.itemName,
inboundAt: manualMatchItem.inboundAt || manualMatchItem.createdAt,
}
: null
}
onMatched={() => void inventoriesQuery.refetch()}
/>
@@ -92,20 +92,17 @@ import {
import type { CustomerRefundPreview } from '@/services/admin/worker-platform'
import {
checkWorkOrderConsignmentMatches,
fulfillWorkOrderWithConsignment,
matchAdminConsignmentWorkOrder,
revokeWorkOrderConsignmentFulfillment,
} from '@/services/admin/consignment'
import { notifyVoucherConsume } from '@/utils/voucher-consume'
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { canManuallyMatchConsignmentOrder } from '@/utils/consignment-manual-match'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import {
isConsignmentOrder,
isInternalConsignmentOrder,
resolveConsignmentScreenshotUrls,
} from '@/utils/consignment-display'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import ConsignmentWorkOrderMatchModal from '@/components/admin/ConsignmentWorkOrderMatchModal'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
@@ -433,7 +430,6 @@ export default function WorkOrdersPanel() {
}>()
// 寄售库存匹配拦截弹窗状态
const [manualMatchOrder, setManualMatchOrder] = useState<WorkOrder | null>(null)
const [consignmentMatchOrder, setConsignmentMatchOrder] = useState<WorkOrder | null>(null)
const [consignmentInterceptModal, setConsignmentInterceptModal] = useState<{
open: boolean
@@ -1207,17 +1203,23 @@ export default function WorkOrdersPanel() {
if (!consignmentInterceptModal.order) return
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: true }))
try {
const res = await fulfillWorkOrderWithConsignment({
const res = await matchAdminConsignmentWorkOrder({
workOrderId: consignmentInterceptModal.order.workOrderId,
inventoryId,
allowPartial,
inventoryIds: [inventoryId],
allowNonExact: allowPartial,
reason: allowPartial ? '内部寄售库存手动匹配(非精确)' : '',
})
message.success('已占用内部库存,跳过打手资料,已进入待加好友阶段')
notifyVoucherConsume(res?.data?.voucherConsume)
if (res.data?.matched === false) {
message.warning('工单状态或库存已变化,请刷新后重试')
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: false }))
await refreshAll()
return
}
message.success('已占用内部库存并匹配工单')
setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false })
await refreshAll()
} catch (err: any) {
message.error(err.message || '内部履约失败')
message.error(err.message || '内部库存匹配失败')
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: false }))
}
}
@@ -2066,18 +2068,9 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{/*
手动指定现货必须**常驻**:只在"检测到候选"的提醒弹窗里给入口,
订单没有任何系统候选时客服反而进不去——而那正是最需要人工指定的时候。
寄卖匹配只保留新引擎入口:旧的 /fulfill 手动匹配不支持数量拆分,
还会绕过 profile/重新匹配排除,已从 UI 收敛掉。
*/}
{canFulfillConsignment && canManuallyMatchConsignmentOrder(row) ? (
<Button
icon={<ThunderboltOutlined />}
onClick={() => setManualMatchOrder(row)}
title="从全部在库现货里人工指定一件(支持规格冲突强制匹配,需填原因)"
>
手动匹配寄售
</Button>
) : null}
{canFulfillConsignment &&
['internal_matching', 'unassigned', 'open'].includes(String(row.status || '')) ? (
<Button
@@ -4411,10 +4404,10 @@ export default function WorkOrdersPanel() {
matchData: null,
submitting: false,
})
setManualMatchOrder(currentOrder)
setConsignmentMatchOrder(currentOrder)
}}
>
手动指定现货匹配
内部库存匹配
</Button>
) : null,
<Button
@@ -4627,23 +4620,6 @@ export default function WorkOrdersPanel() {
}}
/>
<ConsignmentManualMatchModal
open={Boolean(manualMatchOrder)}
onClose={() => setManualMatchOrder(null)}
workOrder={
manualMatchOrder
? {
workOrderId: manualMatchOrder.workOrderId,
label: `#${manualMatchOrder.workOrderId} ${manualMatchOrder.productName || ''}`,
createdAt: manualMatchOrder.createdAt,
}
: null
}
onMatched={() => {
void refreshAll()
}}
/>
{/* 撤单 / 撤销寄售履约弹窗 */}
<Modal
title={`撤单 / 撤销寄售履约 · ${getDisplayOrderNo(revokeConsignmentModal.order)}`}
@@ -268,10 +268,12 @@ export function fetchInventoryMatchableOrders(
)
}
/** 全局匹配看板:待抢单 × 在库待匹配现货,按细粒度评分排序。 */
/** 全局匹配看板:待匹配订单 × 在库待匹配现货,按细粒度评分排序。 */
export function fetchPendingPistolMatches(params?: {
/** 只统计该寄卖分类;不传 = 全部 */
categoryId?: number
/** 只统计这一条库存(库存侧「匹配订单」用) */
inventoryId?: number
keyword?: string
includeConflicts?: boolean
limit?: number
@@ -280,6 +282,7 @@ export function fetchPendingPistolMatches(params?: {
'/api/v1/admin/worker-platform/consignment/pending-pistol-matches',
{
categoryId: params?.categoryId,
inventoryId: params?.inventoryId,
keyword: params?.keyword?.trim() || undefined,
includeConflicts: params?.includeConflicts ? 'true' : undefined,
limit: params?.limit,
+14
View File
@@ -359,6 +359,18 @@ export type PendingPistolMatchPair = {
totalQuantity?: number
/** 命中的寄卖配置 id;无配置时为 null(旧手枪兜底) */
profileId?: number | null
/** 本单一次需要匹配的数量(整单=总数量,拼单=剩余份数) */
requiredQuantity?: number
/** 该条库存的可匹配余量 */
availableQuantity?: number
/** 该库存余量是否够本单一次匹配;拼单按整单汇总判断 */
quantitySufficient?: boolean
/** 自动匹配是否真会选中:精确,或配置允许非精确且订单有正向规格信号 */
autoMatchable?: boolean
/** 是否为拼单工单 */
sharingEnabled?: boolean
/** 工单当前状态:internal_matching / open / unassigned */
workOrderStatus?: string
}
export type PendingPistolMatchResult = {
@@ -368,6 +380,8 @@ export type PendingPistolMatchResult = {
exactCount: number
partialCount: number
conflictCount: number
quantityShortCount: number
autoMatchableCount: number
truncated: boolean
pairs: PendingPistolMatchPair[]
}
@@ -4,9 +4,8 @@ import { fileURLToPath } from 'node:url'
import test from 'node:test'
/**
* 手动匹配弹窗曾经被插到「编辑寄售物品」弹窗**内部**,导致外层弹窗没打开时它根本没挂载:
* 用户点「手动匹配订单」什么都不会发生。这类"点了没反应"的问题在浏览器里才看得出来,
* 这里用静态检查把它钉住:组件必须挂在顶层(前面不能有未闭合的 Modal)。
* 手动匹配弹窗必须挂在页面顶层,不能嵌在别的 Modal 里:
* 外层弹窗没打开时它根本不会挂载,用户点按钮会「没反应」。
*/
function assertTopLevelMount(filePath: string, componentName: string) {
const source = readFileSync(filePath, 'utf8')
@@ -23,19 +22,19 @@ function assertTopLevelMount(filePath: string, componentName: string) {
)
}
test('库存面板:手动匹配订单弹窗挂在顶层(按钮点了必须能弹出来)', () => {
test('库存面板:匹配订单弹窗挂在顶层(按钮点了必须能弹出来)', () => {
assertTopLevelMount(
fileURLToPath(
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url),
),
'ConsignmentManualMatchModal',
'ConsignmentInventoryMatchModal',
)
})
test('订单面板:手动指定现货弹窗同样挂在顶层', () => {
test('订单面板:内部库存匹配弹窗同样挂在顶层', () => {
assertTopLevelMount(
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'ConsignmentManualMatchModal',
'ConsignmentWorkOrderMatchModal',
)
})
@@ -57,8 +56,6 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
),
'utf8',
)
// 库存面板:手动匹配订单按钮要与编辑/作废拆开 —— 前者 fulfill(提交打到
// /fulfill、/assign-worker,后端要求 consignment.fulfill),后者 manage
assert.ok(
inventoryPanel.includes("resolveAdminPermission(sessionPermissions, 'consignment.fulfill')"),
'库存面板必须解析 consignment.fulfill 权限',
@@ -74,56 +71,50 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'utf8',
)
// 拦截弹窗 footer 的入口同样要门控,无 fulfill 权限的人不该看到能直达 403 的按钮
const footerButtonIndex = ordersPanel.indexOf('手动指定现货匹配')
assert.ok(footerButtonIndex > 0, '找不到「手动指定现货匹配」按钮')
const footerButtonIndex = ordersPanel.lastIndexOf('内部库存匹配')
assert.ok(footerButtonIndex > 0, '找不到「内部库存匹配」按钮')
const beforeFooterButton = ordersPanel.slice(0, footerButtonIndex)
assert.ok(
beforeFooterButton.lastIndexOf('canFulfillConsignment ?') >
beforeFooterButton.lastIndexOf('footer={['),
'拦截弹窗 footer 的「手动指定现货匹配」必须按 canFulfillConsignment 门控',
'拦截弹窗 footer 的「内部库存匹配」必须按 canFulfillConsignment 门控',
)
})
test('手动匹配弹窗:数据过期的 409 要清选择并刷新,走内部现货直接履约', () => {
test('库存侧匹配弹窗:必须走新引擎,不再调用旧 /fulfill', () => {
const source = readFileSync(
fileURLToPath(
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url),
new URL('../src/components/admin/ConsignmentInventoryMatchModal.tsx', import.meta.url),
),
'utf8',
)
// 并发占用/规格在打开弹窗后被改 → 清空选择并重新拉列表,不能让运营循环撞同一个错
assert.ok(source.includes('consignment_inventory_race_condition'), '要处理并发占用错误码')
assert.ok(source.includes('consignment_spec_mismatch'), '要处理规格变化错误码')
assert.ok(source.includes('setSelected(null)'), '数据过期时要清空已选')
// 统一内部现货直接履约:调用 fulfillWorkOrderWithConsignment,免外派佣金与二次押金
assert.ok(source.includes('fetchPendingPistolMatches'), '库存侧必须复用待匹配看板的统一判定')
assert.ok(source.includes('inventoryId: Number(inventoryId)'), '必须按当前库存过滤候选工单')
assert.ok(
source.includes('fulfillWorkOrderWithConsignment'),
'手动匹配必须调用内部现货直接履约接口',
source.includes('matchAdminConsignmentWorkOrder'),
'库存侧落库必须调用新引擎 /consignment/match',
)
assert.ok(
!source.includes('assignWorkOrderWithConsignmentWorker'),
'手动匹配不得再调用旧的指派打手接口',
)
// 凭证核销反馈与关键字搜索:用共享出口 / 透传后端,不内联复制
assert.ok(source.includes("from '@/utils/voucher-consume'"), '核销反馈必须用共享出口')
assert.ok(source.includes('keyword: keyword.trim() || undefined'), '库存侧关键字要透传后端')
assert.ok(!source.includes('fulfillWorkOrderWithConsignment'), '库存侧不得再调用旧 /fulfill 链路')
assert.ok(source.includes('quantitySufficient'), '数量不足的工单必须禁用选择')
assert.ok(source.includes('允许非精确/冲突强制匹配'), '非精确必须显式勾选')
})
test('发布拦截弹窗:只走内部现货履约,文案停在待加好友而不是冷静期', () => {
test('订单面板:拦截弹窗与行内入口都只走新引擎', () => {
const source = readFileSync(
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
'utf8',
)
assert.ok(source.includes('fulfillWorkOrderWithConsignment'), '拦截弹窗必须调用内部现货履约')
assert.ok(source.includes('matchAdminConsignmentWorkOrder'), '拦截弹窗必须调用新引擎匹配')
assert.ok(
!source.includes('fulfillWorkOrderWithConsignment'),
'订单面板不得再调用旧 /fulfill 链路',
)
assert.ok(
!source.includes('assignWorkOrderWithConsignmentWorker'),
'拦截弹窗不得再调用旧的指派打手接口',
'订单面板不得调用旧的指派打手接口',
)
assert.ok(!source.includes('手动匹配寄售'), '旧「手动匹配寄售」入口必须移除')
assert.ok(source.includes('进入待加好友'), '拦截弹窗必须写明工单进入待加好友')
assert.ok(!source.includes('自动进冷静期'), '拦截弹窗不得再写自动进冷静期')
assert.ok(!source.includes('进入赠送冷静期'), '成功提示不得再写成进入赠送冷静期')
assert.ok(source.includes('入库时间:'), '拦截弹窗现货展示必须包含入库时间')
})
test('寄售仓库库存面板:必须包含「通过审核入库时间」列,且详情弹窗正确展示时间', () => {
@@ -139,33 +130,4 @@ test('寄售仓库库存面板:必须包含「通过审核入库时间」列
'表格必须格式化入库时间',
)
assert.ok(source.includes('label="通过审核入库时间"'), '详情弹窗必须包含「通过审核入库时间」项')
assert.ok(
source.includes('inboundAt: manualMatchItem.inboundAt || manualMatchItem.createdAt'),
'打开手动匹配弹窗时必须透传现货的入库时间',
)
})
test('手动匹配弹窗:候选订单必须展示创建时间,现货展示审核入库时间', () => {
const source = readFileSync(
fileURLToPath(
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url),
),
'utf8',
)
assert.ok(
source.includes('创建时间 {formatDateTime(order.createdAt)}'),
'候选订单卡片必须展示工单创建时间',
)
assert.ok(
source.includes('审核入库:${formatDateTime(inventoryInboundAt)}'),
'弹窗现货提示栏必须展示审核入库时间',
)
assert.ok(
source.includes('审核入库于 {formatDateTime(selected.inventoryInboundAt)}'),
'已选现货概要必须展示审核入库时间',
)
assert.ok(
source.includes('创建于 {formatDateTime(selected.workOrderCreatedAt)}'),
'已选订单概要必须展示创建时间',
)
})