diff --git a/apps/backend/src/db/migrate.ts b/apps/backend/src/db/migrate.ts index 34b0f48a..80d972a1 100644 --- a/apps/backend/src/db/migrate.ts +++ b/apps/backend/src/db/migrate.ts @@ -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) => { diff --git a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts index 6e178353..3efc2686 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-allocation-repo.ts @@ -281,6 +281,20 @@ export async function markConsignmentAllocationsConsumedWithClient( ) } +/** 单个拼单份额验收:只收口这一份占用的库存,避免整单尚在履约时提前标记。 */ +export async function markConsignmentAllocationConsumedForShareWithClient( + client: PoolClient, + shareId: number, + now: string, +): Promise { + 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。 diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 42ca33d0..c72813e7 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -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) diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index e0379b83..c1fe3db0 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts b/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts index 7e3c2ef0..21020ab6 100644 --- a/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts @@ -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([ diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index cefa61a6..28c1e589 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -143,16 +143,55 @@ export function inventoryMatchesProfile( /** * 超时「重新匹配」排除的原卖家/原库存: * 同一单重新进入匹配池后,不允许原卖家或原库存立刻再次匹配,避免死循环。 + * + * 拼单工单会同时占用多个卖家/多条库存,单列字段只能记住一个; + * 因此 `startConsignmentRematch` 会把完整集合写进 + * `material_json.consignment.rematchExcludedInventoryIds / rematchExcludedWorkerIds`, + * 这里两处都读,保证「看板、候选列表、自动匹配、手动落库」口径一致。 */ +function resolveRematchExclusions( + workOrder: Pick & { + material_json?: unknown + }, +): { inventoryIds: Set; workerIds: Set } { + const inventoryIds = new Set() + const workerIds = new Set() + + 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) => { + 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, + workOrder: Pick & { + material_json?: unknown + }, inventory: Pick, ): 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['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 { 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, }, }), diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index 46c9b152..56272b56 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -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>( diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts index e7a156c6..1a1da37a 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts @@ -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, '非精确但数量可行要计入非精确') +}) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts index 7f5a3e93..76460633 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -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([ + 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 + /** 模板驱动配置的订单侧规格(由调用方异步解析;缺省时按 unknown 处理,不参与自动匹配) */ + orderSpecOverrideByOrderId?: Map | 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() - 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 { 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() + 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, diff --git a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts index 5e6de0f5..a0015859 100644 --- a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts +++ b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts @@ -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).consignment - if (!consignment || typeof consignment !== 'object' || Array.isArray(consignment)) return null - const inventoryId = Number((consignment as Record).inventoryId || 0) - return inventoryId > 0 ? inventoryId : null + if (!source || typeof source !== 'object' || Array.isArray(source)) { + return { inventoryIds: [], workerIds: [] } + } + const record = source as Record + 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() + const workerIds = new Set() + for (const block of [record.consignment, record.consignmentSharing]) { + if (!block || typeof block !== 'object' || Array.isArray(block)) continue + const item = block as Record + 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 { 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(), ) diff --git a/apps/frontend/src/components/admin/ConsignmentInventoryMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentInventoryMatchModal.tsx new file mode 100644 index 00000000..e3d3a605 --- /dev/null +++ b/apps/frontend/src/components/admin/ConsignmentInventoryMatchModal.tsx @@ -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(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 = [ + { + title: '选择', + key: 'select', + width: 64, + render: (_, record) => { + const disabled = + record.quantitySufficient === false || (record.matchType === 'conflict' && !allowNonExact) + return ( + + ) + }, + }, + { + title: '工单', + key: 'order', + width: 220, + render: (_, record) => ( + + + {record.workOrderNo || '-'} + + + {record.platformOrderId || '-'} · {record.workOrderStatus || '-'} + + + 录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'} + + + ), + }, + { + title: '商品名', + key: 'productName', + width: 200, + render: (_, record) => ( + + {record.productName || '-'} + + ), + }, + { + title: '规格判定', + key: 'match', + width: 120, + render: (_, record) => { + if (record.quantitySufficient === false) return 数量不足 + if (record.matchType === 'exact') return 精确 + if (record.matchType === 'conflict') return 不匹配 + return 非精确 + }, + }, + { + title: '数量', + key: 'quantity', + width: 130, + render: (_, record) => ( + + {record.sharingEnabled + ? `拼单还需 ${record.requiredQuantity ?? 1} 份 / 该库存剩 ${record.availableQuantity ?? '-'} 件` + : `需求 ${record.requiredQuantity ?? 1} 件 / 该库存剩 ${record.availableQuantity ?? '-'} 件`} + + ), + }, + { + title: '匹配说明', + key: 'reason', + render: (_, record) => ( + + {record.reason || '-'} + {record.conflicts.length > 0 ? ( + + {record.conflicts.join(';')} + + ) : null} + + ), + }, + ] + + 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 ( + void handleSubmit()} + destroyOnHidden + > + + + + setAllowNonExact(event.target.checked)} + > + 允许非精确/冲突强制匹配 + + setReason(event.target.value)} + disabled={!allowNonExact && selected?.matchType === 'exact'} + /> + + + {query.isError ? ( + + ) : null} + + rowKey={(record) => `${record.workOrderId}-${record.inventoryId}`} + size="small" + loading={query.isFetching} + dataSource={pairs} + columns={columns} + pagination={{ pageSize: 10, showSizeChanger: false }} + locale={{ + emptyText: , + }} + /> + + + ) +} diff --git a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx deleted file mode 100644 index cd326d4f..00000000 --- a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx +++ /dev/null @@ -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([]) - const [orders, setOrders] = useState([]) - 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(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 ( - void handleSubmit()} - onCancel={() => { - resetState() - onClose() - }} - afterOpenChange={(visible) => { - if (visible) { - void load() - } else { - resetState() - } - }} - > - - - - } - value={keyword} - onChange={(e) => setKeyword(e.target.value)} - allowClear - /> - - -
- {mode === 'order' ? ( - filteredOptions.length === 0 ? ( - - ) : ( - filteredOptions.map((option) => { - const meta = describeManualMatchType(option.matchType) - const isSelected = - selected?.inventoryId != null && - String(selected.inventoryId) === String(option.inventory.id) - return ( -
- - - - - {option.inventory.sourceSubmissionNo || option.inventory.inventoryNo} - - {option.inventory.itemName} - {meta.label} - - - 寄卖单号{' '} - {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} - - {option.conflicts.length > 0 ? ( - - 差异:{option.conflicts.join(';')} - - ) : ( - - {option.reason || '规格完全一致'} - - )} - - - -
- ) - }) - ) - ) : filteredOrders.length === 0 ? ( - - ) : ( - filteredOrders.map((order) => { - const meta = describeManualMatchType(order.matchType) - const isSelected = - selected?.workOrderId != null && - String(selected.workOrderId) === String(order.workOrderId) - return ( -
- - - - {order.workOrderNo || `#${order.workOrderId}`} - {order.productName} - {meta.label} - {order.status === 'open' ? ( - 大厅待抢 - ) : order.status === 'unassigned' ? ( - 待指派 - ) : order.status === 'pending_material' ? ( - 待补资料 - ) : null} - - - 要求:{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} - - {order.conflicts.length > 0 ? ( - - 差异:{order.conflicts.join(';')} - - ) : ( - - {order.reason || '规格相符'} - - )} - - - -
- ) - }) - )} -
-
- - {selected ? ( -
- - - - {selected.workOrderLabel || '—'} - {selected.workOrderCreatedAt ? ( - - (创建于 {formatDateTime(selected.workOrderCreatedAt)}) - - ) : null} - - - - - {selected.inventoryLabel || '—'} - {selected.inventoryInboundAt ? ( - - (审核入库于 {formatDateTime(selected.inventoryInboundAt)}) - - ) : null} - - - - {selected.workerId ? ( - - } color="blue"> - {selected.workerName || `供货打手 #${selected.workerId}`} - - - (验收后将自动为该打手发放货款) - - - ) : ( - 内部库存现货(无打手关联) - )} - - - {formatConsignmentContactSupporter(selected).text} - - - - {describeManualMatchType(selected.matchType).label} - - {selected.conflicts.length > 0 ? ( - - {selected.conflicts.join(';')} - - ) : null} - - - - - {requirement.needsPartialConfirm ? ( - setConfirmedPartial(e.target.checked)} - > - 允许非精确匹配(订单规格未写全或高配充低配) - - ) : null} - - {requirement.needsMismatchConfirm ? ( - <> - - setConfirmedMismatch(e.target.checked)} - > - 确认规格不一致,仍要手动匹配 - - - ) : null} - - {requirement.needsNote ? ( - setMatchNote(e.target.value)} - /> - ) : null} - - - 确认后现货立即被占用并进入「待加好友」,双方加好买家好友后点确认加好友进入赠送冷却期。 - 如需退回请使用「撤销寄售履约」。 - - -
- ) : null} -
-
- ) -} diff --git a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx index e8f44a26..d9cccc14 100644 --- a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx @@ -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({ 录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'} - {Number(record.totalQuantity || 1) > 1 ? ( - - 拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份 + + + {resolveOrderStatusTag(record.workOrderStatus).label} + + {Number(record.totalQuantity || 1) > 1 ? ( + + 拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份 + + ) : null} + + {Number(record.requiredQuantity || 0) > 0 ? ( + + {record.sharingEnabled + ? `拼单还需 ${record.requiredQuantity} 份 / 该库存剩 ${record.availableQuantity ?? '-'} 件` + : `需求 ${record.requiredQuantity} 件 / 该库存剩 ${record.availableQuantity ?? '-'} 件`} + {record.quantitySufficient === false ? '(整单汇总不足)' : ''} ) : null} @@ -198,7 +229,11 @@ export default function ConsignmentPendingMatchModal({ return ( {categoryName ? 分类:{categoryName} : null} - 待抢单 {data?.pendingOrderCount ?? 0} 单 + 待匹配 {data?.pendingOrderCount ?? 0} 单 在库待匹配 {data?.inStockInventoryCount ?? 0} 件 匹配组合 {data?.pairCount ?? 0} 条 精确 {data?.exactCount ?? 0} 非精确 {data?.partialCount ?? 0} + 数量不足 {data?.quantityShortCount ?? 0} + 可自动 {data?.autoMatchableCount ?? 0} {includeConflicts ? 不匹配 {data?.conflictCount ?? 0} : null} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index fdf73f37..36bbc238 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -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() { - {/* 手动匹配订单:从现货出发挑订单(与订单侧共用同一弹窗) */} - setManualMatchItem(null)} - inventory={ - manualMatchItem - ? { - inventoryId: manualMatchItem.id, - label: manualMatchItem.itemName, - inboundAt: manualMatchItem.inboundAt || manualMatchItem.createdAt, - } - : null - } onMatched={() => void inventoriesQuery.refetch()} /> diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 0fa2ce35..f6df0171 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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(null) const [consignmentMatchOrder, setConsignmentMatchOrder] = useState(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() { ) : null} {/* - 手动指定现货必须**常驻**:只在"检测到候选"的提醒弹窗里给入口, - 订单没有任何系统候选时客服反而进不去——而那正是最需要人工指定的时候。 + 寄卖匹配只保留新引擎入口:旧的 /fulfill 手动匹配不支持数量拆分, + 还会绕过 profile/重新匹配排除,已从 UI 收敛掉。 */} - {canFulfillConsignment && canManuallyMatchConsignmentOrder(row) ? ( - - ) : null} {canFulfillConsignment && ['internal_matching', 'unassigned', 'open'].includes(String(row.status || '')) ? ( ) : null,