feat(consignment): 统一寄卖匹配看板口径并收口占用与重匹配
- 看板与引擎统一数量、模板规格、正向规格信号判定,纳入 internal_matching/unassigned - 库存侧改走新引擎匹配,移除旧 /fulfill 手动入口与旧弹窗 - 验收时将 reserved allocation 收口为 consumed,补份额级收口 - 拼单重新匹配通过 material 排除全部原库存/原卖家 - 开发/测试环境放宽迁移顺序校验,生产保持严格;修正集成测试份额报酬口径
This commit is contained in:
@@ -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>>(
|
||||
|
||||
+203
-1
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user