feat(consignment): 统一寄卖匹配看板口径并收口占用与重匹配

- 看板与引擎统一数量、模板规格、正向规格信号判定,纳入 internal_matching/unassigned

- 库存侧改走新引擎匹配,移除旧 /fulfill 手动入口与旧弹窗

- 验收时将 reserved allocation 收口为 consumed,补份额级收口

- 拼单重新匹配通过 material 排除全部原库存/原卖家

- 开发/测试环境放宽迁移顺序校验,生产保持严格;修正集成测试份额报酬口径
This commit is contained in:
2026-11-12 15:17:09 +08:00
parent 229eef31a8
commit e39f640dac
18 changed files with 962 additions and 759 deletions
+8 -1
View File
@@ -31,6 +31,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,
@@ -39,7 +46,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
@@ -612,7 +660,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)
@@ -622,6 +676,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) {
@@ -649,13 +717,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 (
@@ -670,8 +758,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,
],
@@ -688,8 +778,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
@@ -702,7 +794,7 @@ export async function startConsignmentRematch(
* 旧口径的宽松匹配会把缺失系统/区服/系列全部判成 unknown 并在 conflicts 里丢掉,
* 如果不加这道门槛,标题只写「苏尔南手枪」的订单会被自动匹配到任意在库库存。
*/
function hasPositiveSpecSignal(
export function hasPositiveSpecSignal(
workOrder: WorkOrderRow,
evaluation: InventoryMatchEvaluation,
): boolean {
@@ -1104,6 +1196,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) {
// 拼单寄卖的份额报酬按「库存售价」而不是订单单价结算:
// 卖家交付的是寄卖货,整单与拼单必须同口径(净售价 × 数量),
@@ -1204,6 +1306,8 @@ async function applySharingMatch(
? {
consignment: {
inventoryId: firstInventoryId,
inventoryIds: matchedInventoryIds,
workerIds: matchedWorkerIds,
quantity: firstInventoryQuantity,
profileId: profile?.profileId ?? null,
matchedAt: now,
@@ -1227,6 +1331,8 @@ async function applySharingMatch(
profileId: profile?.profileId ?? null,
totalQuantity,
matchedQuantity,
inventoryIds: matchedInventoryIds,
workerIds: matchedWorkerIds,
matchedAt: now,
},
}),
@@ -1059,8 +1059,8 @@ async function main() {
assert(shares.rows.length === 1, `shares=${shares.rows.length}`)
assert(Number(shares.rows[0]!.quantity) === 3, `share quantity=${shares.rows[0]!.quantity}`)
assert(
Number(shares.rows[0]!.share_reward) === 19950,
`share reward=${shares.rows[0]!.share_reward},应按 3×7000 扣 5% 服务费`,
Number(shares.rows[0]!.share_reward) === 17100,
`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(),
)