diff --git a/apps/backend/src/db/migrations/152_consignment_internal_match_cleanup.sql b/apps/backend/src/db/migrations/152_consignment_internal_match_cleanup.sql new file mode 100644 index 00000000..b981556d --- /dev/null +++ b/apps/backend/src/db/migrations/152_consignment_internal_match_cleanup.sql @@ -0,0 +1,12 @@ +-- 152_consignment_internal_match_cleanup.sql +-- 「超时后回大厅」改为手动匹配的固定行为:清理配置里的 internalMatch.fallbackToHall。 +-- 147–151 已应用不可回改,这里用数据迁移清理历史草稿与已发布快照。 +-- 幂等:仅删除存在该 key 的记录。 + +UPDATE consignment_profiles +SET config_json = config_json #- '{internalMatch,fallbackToHall}' +WHERE config_json #> '{internalMatch,fallbackToHall}' IS NOT NULL; + +UPDATE consignment_profile_versions +SET config_json = config_json #- '{internalMatch,fallbackToHall}' +WHERE config_json #> '{internalMatch,fallbackToHall}' IS NOT NULL; diff --git a/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts b/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts index 56d5e7e2..100c43cb 100644 --- a/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-before-publish-match.test.ts @@ -50,14 +50,14 @@ test('补资料链路先试匹配,未命中才进入 manual 内部匹配', asy assert.ok(enterIndex > tryIndex, '试匹配必须在进入内部匹配之前') }) -test('内部匹配超时:fallbackToHall=false 时重新匹配,最多 3 次', async () => { +test('内部匹配超时一律回大厅,不再支持超时重匹配', async () => { const source = await readFile(ENGINE, 'utf8') - assert.ok(source.includes('export const MAX_INTERNAL_MATCH_ATTEMPTS = 3')) + assert.ok(!source.includes('MAX_INTERNAL_MATCH_ATTEMPTS'), '重匹配次数上限必须删除') const start = source.indexOf('export async function expireConsignmentInternalMatching') assert.ok(start >= 0, '找不到 expireConsignmentInternalMatching') - const body = source.slice(start, start + 3000) - assert.ok(body.includes('fallbackToHall'), '必须读取超时回大厅开关') - assert.ok(body.includes('MAX_INTERNAL_MATCH_ATTEMPTS'), '必须有重试次数上限') - assert.ok(body.includes('startConsignmentRematch'), '关闭回大厅时要重新匹配') - assert.ok(body.includes("status = 'open'"), '超过次数或失败后仍要回大厅兜底') + const end = source.indexOf('export async function', start + 1) + const body = source.slice(start, end > start ? end : start + 2000) + assert.ok(!body.includes('fallbackToHall'), '到期逻辑不得再读取回大厅开关') + assert.ok(!body.includes('startConsignmentRematch'), '到期逻辑不得再调用重匹配') + assert.ok(body.includes("status = 'open'"), '到期必须回大厅') }) diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts index 70c60e32..98aa958d 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-config.test.ts @@ -15,7 +15,7 @@ test('内部匹配等待时长:0/负数/缺失都归一为 0(直接发大厅 assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: '12.6' }), 13) }) -test('内部匹配入口:0 分钟必须直接返回,不能依赖 fallbackToHall 进入 internal_matching', async () => { +test('内部匹配入口:0 分钟必须直接返回,禁止进入 internal_matching', async () => { const source = await readFile( new URL('./consignment-match-engine-service.ts', import.meta.url), 'utf8', @@ -35,10 +35,6 @@ test('内部匹配入口:0 分钟必须直接返回,不能依赖 fallbackToH body.includes('if (delayMinutes <= 0) return false'), '0 分钟必须直接返回,禁止进入 internal_matching', ) - assert.ok( - !body.includes('delayMinutes <= 0 && !profile.config.internalMatch.fallbackToHall'), - '不允许再用 fallbackToHall 绕过 0 分钟保护(会生成无截止时间的悬空工单)', - ) }) test('匹配改写报酬:首次固化原值,所有统一释放路径都必须恢复', async () => { diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts index b3bae116..ea451f12 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-guards.test.ts @@ -41,7 +41,7 @@ test('手动匹配不再在事务外改状态:数量不足/并发占库失败 ) }) -test('apply 事务内部:允许起始状态、整单检查有效份额、拼单补截止时间', async () => { +test('apply 事务内部:允许起始状态、整单检查有效份额、拼单必须一次拼满', async () => { const source = await readFile(ENGINE, 'utf8') const wholeStart = source.indexOf('async function applyWholeOrderMatch') const wholeEnd = source.indexOf('async function applySharingMatch', wholeStart) @@ -66,9 +66,16 @@ test('apply 事务内部:允许起始状态、整单检查有效份额、拼 assert.ok(sharingStart >= 0 && sharingEnd > sharingStart, '找不到 applySharingMatch') const sharing = source.slice(sharingStart, sharingEnd) assert.ok( - sharing.includes('fallbackDeadline') && - sharing.includes('internal_match_deadline_at IS NULL THEN $5'), - '拼单部分成功时必须补内部匹配截止时间', + sharing.includes('planConsignmentSharingTakes') && sharing.includes("reason: 'insufficient'"), + '拼单必须先规划;一次拼不满时必须整单失败', + ) + assert.ok( + !sharing.includes('fallbackDeadline') && !sharing.includes('const nextStatus'), + '拼单不允许部分成功,不能再进入 internal_matching 等剩余份额', + ) + assert.ok( + sharing.includes('toStatus: WORK_ORDER_STATUS.IN_PROGRESS'), + '拼单成功必须直接进入 in_progress', ) assert.ok( sharing.includes('const shareGrossFenPerUnit =') && 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 58ac5269..08b76488 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 @@ -339,9 +339,6 @@ export async function resolveProfileForWorkOrder( return resolveConsignmentProfileForCategory(workOrder.category_id) } -/** 内部匹配超时后最多重新匹配的次数(fallbackToHall=false 时生效)。 */ -export const MAX_INTERNAL_MATCH_ATTEMPTS = 3 - /** * 发布前即时匹配(仅 matching.mode='auto'): * 工单仍在 unassigned 时直接尝试占库,命中后由 apply 事务改成 in_progress, @@ -402,23 +399,35 @@ export async function tryMatchConsignmentWorkOrderBeforePublish( } if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) { - const created = await applySharingMatch({ workOrder, profile }, candidates, context) + const sharing = await applySharingMatch({ workOrder, profile }, candidates, context) await recordConsignmentMatchLog({ ...logBase, - result: created > 0 ? 'matched' : 'failed', - reasonCode: created > 0 ? 'auto_sharing_matched_before_publish' : 'sharing_not_applied', - reasonText: created > 0 ? '' : '发布前拼单匹配未创建任何份额', + result: sharing.createdCount > 0 ? 'matched' : 'failed', + reasonCode: + sharing.createdCount > 0 + ? 'auto_sharing_matched_before_publish' + : sharing.reason === 'insufficient' + ? 'sharing_quantity_insufficient' + : 'sharing_not_applied', + reasonText: + sharing.createdCount > 0 + ? '' + : sharing.reason === 'insufficient' + ? `拼单必须一次拼满:还差 ${sharing.requiredQuantity} 份,可用 ${sharing.availableQuantity} 份` + : '发布前拼单匹配未生效(库存或工单状态已变化)', candidateCount: candidates.length, - matchedCount: created, + matchedCount: sharing.createdCount, durationMs: Date.now() - startedAt, payload: { phase: 'before_publish', partial: context.partial, specConflicts: context.specConflicts, + requiredQuantity: sharing.requiredQuantity, + availableQuantity: sharing.availableQuantity, inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)), }, }) - return created > 0 + return sharing.createdCount > 0 } const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) @@ -598,7 +607,7 @@ export async function fallbackConsignmentWorkOrderToHall( } /** - * 超时「重新匹配」:把工单重新放回内部匹配池,并排除原卖家/原库存。 + * 代练超时「重新匹配」:把工单重新放回内部匹配池,并排除原卖家/原库存。 * 分类关闭匹配、等待时长为 0 或找不到配置时,退回大厅,避免工单卡在无人处理的状态。 */ export async function startConsignmentRematch( @@ -968,174 +977,227 @@ async function applyWholeOrderMatch( return result.applied } +export type ConsignmentSharingPlanItem = { + inventory: ConsignmentInventoryRow + take: number +} + +/** + * 拼单份额规划:按候选库存顺序计算一次能拿走的份额,不写库。 + * outstanding > 0 表示当前候选无法一次拼满,调用方必须整单失败。 + */ +export function planConsignmentSharingTakes( + candidates: Array<{ inventory: ConsignmentInventoryRow }>, + remainingQuantity: number, + friendGift: boolean, +): { plan: ConsignmentSharingPlanItem[]; outstanding: number } { + let remaining = Math.max(0, Math.round(Number(remainingQuantity || 0))) + const plan: ConsignmentSharingPlanItem[] = [] + for (const { inventory } of candidates) { + if (remaining <= 0) break + if (!inventory.worker_id) continue + const available = Math.max(0, Math.round(Number(inventory.remaining_quantity ?? 1))) + if (friendGift && available > remaining) continue + const take = friendGift ? available : Math.min(available, remaining) + if (take <= 0) continue + plan.push({ inventory, take }) + remaining -= take + } + return { plan, outstanding: remaining } +} + +type ConsignmentSharingApplyResult = { + createdCount: number + reason: 'matched' | 'insufficient' | 'not_applied' + requiredQuantity: number + availableQuantity: number + sms: ConsignmentMatchSmsPayload[] +} + +class ConsignmentSharingRollbackError extends Error {} + async function applySharingMatch( input: MatchCandidate, candidates: ConsignmentMatchInventoryCandidate[], context: ConsignmentMatchContext = { matchType: 'auto' }, -): Promise { +): Promise { const { workOrder, profile } = input const now = nowIso() const totalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) - if (!workOrder.id || totalQuantity <= 1) return 0 + if (!workOrder.id || totalQuantity <= 1) { + return { + createdCount: 0, + reason: 'not_applied', + requiredQuantity: 0, + availableQuantity: 0, + sms: [], + } + } const manual = context.matchType === 'manual' - type ApplyResult = { createdCount: number; sms: ConsignmentMatchSmsPayload[] } - const result = await withTransaction(async (client: PoolClient) => { - const locked = await client.query( - 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', - [workOrder.id], - ) - const current = locked.rows[0] - const allowedStartStatuses = - context.startStatuses ?? ([WORK_ORDER_STATUS.INTERNAL_MATCHING] as string[]) - if (!current || !allowedStartStatuses.includes(current.status)) { - return { createdCount: 0, sms: [] } - } - - const activeResult = await client.query<{ active: number | string }>( - `SELECT COALESCE(SUM(quantity), 0) AS active FROM work_order_shares WHERE work_order_id = $1 AND status IN ('joined', 'submitted', 'accepted')`, - [current.id], - ) - let active = Number(activeResult.rows[0]?.active || 0) - let remaining = Math.max(0, totalQuantity - active) - if (remaining <= 0) return { createdCount: 0, sms: [] } - - const config = profile?.config - const timeoutMinutes = config?.timeout.minutes ?? 30 - const timeoutPolicy = config?.timeout.policy ?? 'reopen' - const deadline = addMinutesIso(timeoutMinutes, now) - // 库存没有售价时退回订单单价,避免份额报酬为 0;有售价时始终以库存净售价为准。 - const fallbackOrderUnitRewardFen = - Math.max(0, Number(current.sharing_unit_reward || 0)) || - Math.max(0, Number(current.reward_amount || 0)) / totalQuantity - const material = parseMaterial(current.material_json) - const source = parseMaterial(material.source) - const shopName = String(source.shopName || source.shopTitle || '').trim() - - let createdCount = 0 - let createdQuantity = 0 - let firstInventoryId: number | null = null - let firstInventoryQuantity = 0 - let firstShareGrossFenPerUnit = 0 - let firstShareFeeFenPerUnit = 0 - let firstShareNetFenPerUnit = 0 - const smsPayloads: ConsignmentMatchSmsPayload[] = [] - for (const { inventory } of candidates) { - if (remaining <= 0) break - if (!inventory.worker_id) continue - // 一条库存可以拆给多张订单:本次最多吃掉“订单还缺的份数”, - // 剩下的留在 remaining_quantity 里等下一单。好友赠送单必须整份匹配,避免同一份库存跨单赠礼。 - const available = Math.max(0, Math.round(Number(inventory.remaining_quantity ?? 1))) - if (isFriendGiftAcceptanceMode(current.acceptance_mode) && available > remaining) continue - const take = isFriendGiftAcceptanceMode(current.acceptance_mode) - ? available - : Math.min(available, remaining) - if (take <= 0) continue - // 拼单寄卖的份额报酬按「库存售价」而不是订单单价结算: - // 卖家交付的是寄卖货,整单与拼单必须同口径(净售价 × 数量), - // 否则拼单会同时拿到订单报酬和寄卖货款,造成双付/多卖家漏付。 - const shareGrossFenPerUnit = - inventorySalePriceFen(inventory) || Math.max(0, Math.round(fallbackOrderUnitRewardFen)) - const shareReward = buildConsignmentShareReward(config, shareGrossFenPerUnit, take) - - const allocation = await reserveConsignmentInventoryWithClient(client, { - inventoryId: Number(inventory.id), - workOrderId: Number(current.id), - quantity: take, - unitPriceFen: shareReward.grossPerUnitFen, - now, - boundOrderId: current.order_id ? Number(current.order_id) : null, - boundPlatformOrderId: current.platform_order_id || '', - shopName, - }) - if (!allocation) continue - - const share = await client.query<{ id: number }>( - ` - INSERT INTO work_order_shares ( - work_order_id, worker_id, quantity, unit_reward, share_reward, share_deposit, - status, acceptance_json, draft_acceptance_json, deadline_at, timeout_minutes, - timeout_policy, request_id, consignment_inventory_id, created_at, updated_at - ) VALUES ( - $1, $2, $10, $3, $4, 0, - 'joined', '{}'::jsonb, '{}'::jsonb, $5, $6, - $7, '', $8, $9, $9 - ) - RETURNING id - `, - [ - current.id, - Number(inventory.worker_id), - shareReward.netPerUnitFen, - shareReward.netFen, - deadline, - timeoutMinutes, - timeoutPolicy, - Number(inventory.id), - now, - take, - ], + let result: ConsignmentSharingApplyResult + try { + result = await withTransaction(async (client: PoolClient) => { + const locked = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [workOrder.id], ) - const shareId = Number(share.rows[0]?.id || 0) - if (shareId <= 0) continue - await client.query( - 'UPDATE consignment_inventory_allocations SET work_order_share_id = $1, updated_at = $2 WHERE id = $3', - [shareId, now, allocation.id], - ) - createdCount += 1 - createdQuantity += take - smsPayloads.push({ - workOrder: current, - inventory, - quantity: take, - priceFen: inventorySalePriceFen(inventory), - matchType: manual ? 'manual' : 'auto', - matchedAt: now, - }) - if (!firstInventoryId) { - firstInventoryId = Number(inventory.id) - firstInventoryQuantity = take - firstShareGrossFenPerUnit = shareReward.grossPerUnitFen - firstShareFeeFenPerUnit = Math.round(shareReward.feeFen / take) - firstShareNetFenPerUnit = shareReward.netPerUnitFen + const current = locked.rows[0] + const allowedStartStatuses = + context.startStatuses ?? ([WORK_ORDER_STATUS.INTERNAL_MATCHING] as string[]) + if (!current || !allowedStartStatuses.includes(current.status)) { + return { + createdCount: 0, + reason: 'not_applied', + requiredQuantity: 0, + availableQuantity: 0, + sms: [], + } } - remaining -= take - } - if (createdCount > 0) { - active += createdQuantity - const nextStatus = - active >= totalQuantity - ? WORK_ORDER_STATUS.IN_PROGRESS - : WORK_ORDER_STATUS.INTERNAL_MATCHING - // 从大厅/待分配手动拼单、只匹配到部分份额时,必须补上新的内部匹配截止时间, - // 不能让工单停在「internal_matching 且 deadline 为空」的永久悬空状态。 - const fallbackDeadline = - nextStatus === WORK_ORDER_STATUS.INTERNAL_MATCHING - ? addMinutesIso(Math.max(1, resolveInternalMatchDelayMinutes(config?.internalMatch)), now) - : null + const activeResult = await client.query<{ active: number | string }>( + `SELECT COALESCE(SUM(quantity), 0) AS active FROM work_order_shares WHERE work_order_id = $1 AND status IN ('joined', 'submitted', 'accepted')`, + [current.id], + ) + const active = Number(activeResult.rows[0]?.active || 0) + const remaining = Math.max(0, totalQuantity - active) + if (remaining <= 0) { + return { + createdCount: 0, + reason: 'not_applied', + requiredQuantity: 0, + availableQuantity: 0, + sms: [], + } + } + + const config = profile?.config + const timeoutMinutes = config?.timeout.minutes ?? 30 + const timeoutPolicy = config?.timeout.policy ?? 'reopen' + const deadline = addMinutesIso(timeoutMinutes, now) + // 库存没有售价时退回订单单价,避免份额报酬为 0;有售价时始终以库存净售价为准。 + const fallbackOrderUnitRewardFen = + Math.max(0, Number(current.sharing_unit_reward || 0)) || + Math.max(0, Number(current.reward_amount || 0)) / totalQuantity + const material = parseMaterial(current.material_json) + const source = parseMaterial(material.source) + const shopName = String(source.shopName || source.shopTitle || '').trim() + const friendGift = isFriendGiftAcceptanceMode(current.acceptance_mode) + + // 先规划再落库:拼单必须一次拼满,规划不足时一件库存都不占用。 + const { plan, outstanding } = planConsignmentSharingTakes(candidates, remaining, friendGift) + if (outstanding > 0) { + return { + createdCount: 0, + reason: 'insufficient', + requiredQuantity: remaining, + availableQuantity: remaining - outstanding, + sms: [], + } + } + + let createdCount = 0 + let createdQuantity = 0 + let firstInventoryId: number | null = null + let firstInventoryQuantity = 0 + let firstShareGrossFenPerUnit = 0 + let firstShareFeeFenPerUnit = 0 + let firstShareNetFenPerUnit = 0 + const smsPayloads: ConsignmentMatchSmsPayload[] = [] + for (const { inventory, take } of plan) { + // 拼单寄卖的份额报酬按「库存售价」而不是订单单价结算: + // 卖家交付的是寄卖货,整单与拼单必须同口径(净售价 × 数量), + // 否则拼单会同时拿到订单报酬和寄卖货款,造成双付/多卖家漏付。 + const shareGrossFenPerUnit = + inventorySalePriceFen(inventory) || Math.max(0, Math.round(fallbackOrderUnitRewardFen)) + const shareReward = buildConsignmentShareReward(config, shareGrossFenPerUnit, take) + + const allocation = await reserveConsignmentInventoryWithClient(client, { + inventoryId: Number(inventory.id), + workOrderId: Number(current.id), + quantity: take, + unitPriceFen: shareReward.grossPerUnitFen, + now, + boundOrderId: current.order_id ? Number(current.order_id) : null, + boundPlatformOrderId: current.platform_order_id || '', + shopName, + }) + // 规划后库存被并发占用:整单回滚,绝不允许部分扣减/部分份额。 + if (!allocation) throw new ConsignmentSharingRollbackError('inventory_unavailable') + + const share = await client.query<{ id: number }>( + ` + INSERT INTO work_order_shares ( + work_order_id, worker_id, quantity, unit_reward, share_reward, share_deposit, + status, acceptance_json, draft_acceptance_json, deadline_at, timeout_minutes, + timeout_policy, request_id, consignment_inventory_id, created_at, updated_at + ) VALUES ( + $1, $2, $10, $3, $4, 0, + 'joined', '{}'::jsonb, '{}'::jsonb, $5, $6, + $7, '', $8, $9, $9 + ) + RETURNING id + `, + [ + current.id, + Number(inventory.worker_id), + shareReward.netPerUnitFen, + shareReward.netFen, + deadline, + timeoutMinutes, + timeoutPolicy, + Number(inventory.id), + now, + take, + ], + ) + const shareId = Number(share.rows[0]?.id || 0) + // 份额没落库时库存已经扣减,必须回滚,避免悬空 allocation。 + if (shareId <= 0) throw new ConsignmentSharingRollbackError('share_not_created') + await client.query( + 'UPDATE consignment_inventory_allocations SET work_order_share_id = $1, updated_at = $2 WHERE id = $3', + [shareId, now, allocation.id], + ) + createdCount += 1 + createdQuantity += take + smsPayloads.push({ + workOrder: current, + inventory, + quantity: take, + priceFen: inventorySalePriceFen(inventory), + matchType: manual ? 'manual' : 'auto', + matchedAt: now, + }) + if (!firstInventoryId) { + firstInventoryId = Number(inventory.id) + firstInventoryQuantity = take + firstShareGrossFenPerUnit = shareReward.grossPerUnitFen + firstShareFeeFenPerUnit = Math.round(shareReward.feeFen / take) + firstShareNetFenPerUnit = shareReward.netPerUnitFen + } + } + + if (createdQuantity !== remaining) { + throw new ConsignmentSharingRollbackError('quantity_not_fulfilled') + } + const matchedQuantity = active + createdQuantity + await client.query( ` UPDATE work_orders SET status = $1, material_json = $2::jsonb, - assigned_at = CASE WHEN $1 = 'in_progress' THEN $3 ELSE assigned_at END, - internal_match_deadline_at = CASE - WHEN $1 = 'in_progress' THEN NULL - WHEN internal_match_deadline_at IS NULL THEN $5 - ELSE internal_match_deadline_at END, - hall_visible_at = CASE - WHEN $1 = 'in_progress' THEN NULL - WHEN hall_visible_at IS NULL THEN $5 - ELSE hall_visible_at END, + assigned_at = $3, + internal_match_deadline_at = NULL, + hall_visible_at = NULL, internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1, - rematch_excluded_worker_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_worker_id END, - rematch_excluded_inventory_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_inventory_id END, + rematch_excluded_worker_id = NULL, + rematch_excluded_inventory_id = NULL, updated_at = $3 WHERE id = $4 `, [ - nextStatus, + WORK_ORDER_STATUS.IN_PROGRESS, JSON.stringify({ ...material, ...(firstInventoryId @@ -1164,13 +1226,12 @@ async function applySharingMatch( consignmentSharing: { profileId: profile?.profileId ?? null, totalQuantity, - matchedQuantity: active, + matchedQuantity, matchedAt: now, }, }), now, current.id, - fallbackDeadline, ], ) await createWorkOrderEventWithClient(client, { @@ -1179,11 +1240,11 @@ async function applySharingMatch( actorId: manual ? context.actorName || 'admin' : 'consignment_match', eventType: manual ? 'consignment_manual_sharing_matched' : 'consignment_sharing_matched', fromStatus: current.status, - toStatus: nextStatus, + toStatus: WORK_ORDER_STATUS.IN_PROGRESS, payloadJson: JSON.stringify({ createdCount, createdQuantity, - activeQuantity: active, + activeQuantity: matchedQuantity, totalQuantity, shareRewardFenPerUnit: firstShareNetFenPerUnit, serviceFeeFenPerUnit: firstShareFeeFenPerUnit, @@ -1201,13 +1262,30 @@ async function applySharingMatch( }), now, }) + return { + createdCount, + reason: 'matched', + requiredQuantity: remaining, + availableQuantity: remaining, + sms: smsPayloads, + } + }) + } catch (error) { + if (error instanceof ConsignmentSharingRollbackError) { + return { + createdCount: 0, + reason: 'not_applied', + requiredQuantity: 0, + availableQuantity: 0, + sms: [], + } } - return { createdCount, sms: smsPayloads } - }) + throw error + } for (const sms of result.sms) { await sendConsignmentMatchSuccessSms(sms) } - return result.createdCount + return result } /** 自动匹配一批内部匹配工单:低价订单优先,精确规格,支持整单/拼单。 */ @@ -1284,19 +1362,21 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number } ...evaluation.unknowns, ]), } - const created = await applySharingMatch({ workOrder, profile }, candidates, context) - if (created > 0) { + const sharing = await applySharingMatch({ workOrder, profile }, candidates, context) + if (sharing.createdCount > 0) { matched += 1 await recordConsignmentMatchLog({ ...logBase, result: 'matched', reasonCode: 'auto_sharing_matched', candidateCount: candidates.length, - matchedCount: created, + matchedCount: sharing.createdCount, durationMs: Date.now() - startedAt, payload: { partial: context.partial, specConflicts: context.specConflicts, + requiredQuantity: sharing.requiredQuantity, + availableQuantity: sharing.availableQuantity, inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)), }, }) @@ -1306,10 +1386,20 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number } await recordConsignmentMatchLog({ ...logBase, result: 'failed', - reasonCode: 'sharing_not_applied', - reasonText: '拼单匹配未创建任何份额(库存可能已被其它任务占用)', + reasonCode: + sharing.reason === 'insufficient' + ? 'sharing_quantity_insufficient' + : 'sharing_not_applied', + reasonText: + sharing.reason === 'insufficient' + ? `拼单必须一次拼满:还差 ${sharing.requiredQuantity} 份,可用 ${sharing.availableQuantity} 份` + : '拼单匹配未生效(库存或工单状态已变化)', candidateCount: candidates.length, durationMs: Date.now() - startedAt, + payload: { + requiredQuantity: sharing.requiredQuantity, + availableQuantity: sharing.availableQuantity, + }, }) } } else { @@ -1384,11 +1474,9 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number } } /** - * 内部匹配超时: - * - fallbackToHall=true(默认):发回大厅; - * - fallbackToHall=false:重新匹配(排除原卖家/库存),最多 MAX_INTERNAL_MATCH_ATTEMPTS 次; - * 超过次数或重新匹配失败仍回大厅,避免工单永久悬空。 - * 拼单已占库存的份额不受影响,剩余份数继续抢单。 + * 内部匹配超时:工单统一发回大厅。 + * 「超时后回大厅」是手动匹配的固定行为,不再支持按配置重新匹配。 + * 拼单已占库存的份额不受影响,剩余份数继续在大厅抢单。 */ export async function expireConsignmentInternalMatching(options: { now?: string } = {}) { const now = options.now || nowIso() @@ -1401,22 +1489,7 @@ export async function expireConsignmentInternalMatching(options: { now?: string [now], ) let expiredCount = 0 - let rematched = 0 for (const workOrder of expired.rows) { - const profile = await resolveProfileForWorkOrder(workOrder) - const fallbackToHall = profile?.config.internalMatch.fallbackToHall !== false - const attempts = Number(workOrder.internal_match_attempts || 0) - if (!fallbackToHall && attempts < MAX_INTERNAL_MATCH_ATTEMPTS) { - const started = await startConsignmentRematch(Number(workOrder.id), {}, now).catch( - () => false, - ) - if (started) { - rematched += 1 - continue - } - // 重新匹配失败时 startConsignmentRematch 内部已把工单放回大厅, - // 下面的 UPDATE 带 status='internal_matching' 条件,不会重复命中。 - } const updated = await query<{ id: number }>( ` UPDATE work_orders @@ -1439,14 +1512,10 @@ export async function expireConsignmentInternalMatching(options: { now?: string payload_json, created_at ) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_expired', 'internal_matching', 'open', $2::jsonb, $3) `, - [ - workOrder.id, - JSON.stringify({ expiredAt: now, attempts, fallbackToHall, rematched: false }), - now, - ], + [workOrder.id, JSON.stringify({ expiredAt: now }), now], ) } - return { expired: expiredCount, rematched } + return { expired: expiredCount } } /** 后台手动匹配弹窗:列出同配置在库库存,并标注精确/差异/冲突。 */ @@ -1664,8 +1733,20 @@ async function runManualConsignmentMatch(input: { } if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) { - const created = await applySharingMatch({ workOrder, profile }, selected, context) - return { matched: created > 0, matchedCount: created } + const sharing = await applySharingMatch({ workOrder, profile }, selected, context) + if (sharing.createdCount > 0) { + return { matched: true, matchedCount: sharing.createdCount } + } + if (sharing.reason === 'insufficient') { + throw createHttpError( + `拼单必须一次拼满:订单还缺 ${sharing.requiredQuantity} 份,所选库存只能提供 ${sharing.availableQuantity} 份`, + { statusCode: 409, errorCode: 'consignment_sharing_quantity_insufficient' }, + ) + } + throw createHttpError('工单状态或库存已变化,请刷新后重试', { + statusCode: 409, + errorCode: 'consignment_sharing_not_applied', + }) } if (selected.length > 1) { 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 ada5c158..a49f19e4 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 @@ -215,7 +215,7 @@ const profileConfig = { exactOnly: true, preferLowReward: true, }, - internalMatch: { delayMinutes: 30, fallbackToHall: true }, + internalMatch: { delayMinutes: 30 }, timeout: { minutes: 60, policy: 'reopen' }, priceDrop: { enabled: true, hours: 72, mode: 'percent', value: 10 }, } diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index dd60e1e9..3e5446af 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -52,7 +52,6 @@ export type ConsignmentMatchingConfig = { export type ConsignmentInternalMatchConfig = { /** 资料提交后等待内部库存匹配的分钟数;0 = 不等待,直接发大厅 */ delayMinutes: number - fallbackToHall: boolean } export type ConsignmentTimeoutConfig = { @@ -175,7 +174,6 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = { }, internalMatch: { delayMinutes: 30, - fallbackToHall: true, }, timeout: { minutes: 30, @@ -310,7 +308,6 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr }, internalMatch: { delayMinutes: integer(internalMatch.delayMinutes, 30, 0, 7 * 24 * 60), - fallbackToHall: bool(internalMatch.fallbackToHall, true), }, timeout: { minutes: integer(timeout.minutes, 30, 0, 7 * 24 * 60), diff --git a/apps/backend/src/services/worker-platform/consignment-sharing-plan.test.ts b/apps/backend/src/services/worker-platform/consignment-sharing-plan.test.ts new file mode 100644 index 00000000..4c675ecb --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-sharing-plan.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js' +import { planConsignmentSharingTakes } from './consignment-match-engine-service.js' + +function candidate(id: number, remaining: number, workerId = 1) { + return { + inventory: { + id, + worker_id: workerId, + remaining_quantity: remaining, + } as ConsignmentInventoryRow, + } +} + +test('拼单规划:1+1 不足以一次拼满 3 份', () => { + const { plan, outstanding } = planConsignmentSharingTakes( + [candidate(1, 1), candidate(2, 1)], + 3, + false, + ) + assert.equal(plan.length, 2) + assert.equal( + plan.reduce((sum, item) => sum + item.take, 0), + 2, + ) + assert.equal(outstanding, 1) +}) + +test('拼单规划:2+1 一次拼满 3 份,多库存依次扣减', () => { + const { plan, outstanding } = planConsignmentSharingTakes( + [candidate(1, 2), candidate(2, 1), candidate(3, 5)], + 3, + false, + ) + assert.equal(outstanding, 0) + assert.deepEqual( + plan.map((item) => [item.inventory.id, item.take]), + [ + [1, 2], + [2, 1], + ], + ) +}) + +test('拼单规划:4+4 满足 5 份时第二条只取 1 份', () => { + const { plan, outstanding } = planConsignmentSharingTakes( + [candidate(1, 4), candidate(2, 4)], + 5, + false, + ) + assert.equal(outstanding, 0) + assert.deepEqual( + plan.map((item) => [item.inventory.id, item.take]), + [ + [1, 4], + [2, 1], + ], + ) +}) + +test('好友赠送:只能整份取,2+2 满足 4 份', () => { + const { plan, outstanding } = planConsignmentSharingTakes( + [candidate(1, 2), candidate(2, 2)], + 4, + true, + ) + assert.equal(outstanding, 0) + assert.deepEqual( + plan.map((item) => [item.inventory.id, item.take]), + [ + [1, 2], + [2, 2], + ], + ) +}) + +test('好友赠送:2+2 不能拼出 3 份,规划必须报缺口', () => { + const { plan, outstanding } = planConsignmentSharingTakes( + [candidate(1, 2), candidate(2, 2)], + 3, + true, + ) + assert.equal(plan.length, 1) + assert.equal(outstanding, 1) +}) diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index badb5549..858fdc85 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -90,7 +90,7 @@ type ConfigFormValues = { exactOnly: boolean preferLowReward: boolean } - internalMatch: { delayMinutes: number; fallbackToHall: boolean } + internalMatch: { delayMinutes: number } timeout: { hours: number policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct' @@ -201,7 +201,15 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues { exactOnly: config.matching.exactOnly, preferLowReward: config.matching.preferLowReward, }, - internalMatch: { ...config.internalMatch }, + internalMatch: { + // 内部等待只属于手动匹配;非手动模式切回手动时给默认 30 分钟。 + delayMinutes: + config.matching.mode === 'manual' + ? Number(config.internalMatch.delayMinutes || 0) + : Number(config.internalMatch.delayMinutes) > 0 + ? Number(config.internalMatch.delayMinutes) + : 30, + }, timeout: { hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60), policy: config.timeout.policy, @@ -234,7 +242,7 @@ function emptyFormValues(): ConfigFormValues { exactOnly: true, preferLowReward: true, }, - internalMatch: { delayMinutes: 30, fallbackToHall: true }, + internalMatch: { delayMinutes: 30 }, timeout: { hours: 0.5, policy: 'reopen' }, priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 }, } @@ -303,11 +311,9 @@ function buildPayload( preferLowReward: values.matching.preferLowReward, }, internalMatch: { - // 关闭匹配时不再等待内部库存,直接进大厅。 + // 内部等待只属于手动匹配:关闭匹配不再等待;自动匹配不使用等待窗口。 delayMinutes: values.matching.mode === 'off' ? 0 : Number(values.internalMatch.delayMinutes || 0), - fallbackToHall: - values.matching.mode === 'off' ? false : values.internalMatch.fallbackToHall, }, timeout: { minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)), @@ -1720,7 +1726,7 @@ export default function ConsignmentConfigPanel() {
@@ -1755,41 +1761,21 @@ export default function ConsignmentConfigPanel() { > - - - - - - + {matchingMode === 'manual' ? ( + + + + ) : null} ) : null}
@@ -1801,6 +1787,14 @@ export default function ConsignmentConfigPanel() { message="关闭匹配后:新单直接进入接单大厅,不等待内部库存,也不会自动匹配。" /> ) : null} + {matchingMode === 'auto' ? ( + + ) : null}
diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 146c57af..605531c1 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -418,7 +418,7 @@ export type ConsignmentProfileConfig = { exactOnly: boolean preferLowReward: boolean } - internalMatch: { delayMinutes: number; fallbackToHall: boolean } + internalMatch: { delayMinutes: number } timeout: { minutes: number policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct' diff --git a/apps/frontend/tests/consignment-auto-match-publish.test.ts b/apps/frontend/tests/consignment-auto-match-publish.test.ts index 873daf83..f299614f 100644 --- a/apps/frontend/tests/consignment-auto-match-publish.test.ts +++ b/apps/frontend/tests/consignment-auto-match-publish.test.ts @@ -10,21 +10,15 @@ function readPanelSource() { ).replace(/\r\n/g, '\n') } -test('自动匹配:手动等待与超时策略在 auto 下禁用并给出说明', () => { +test('手动匹配:只保留等待时长,不再提供「超时后回大厅」开关', () => { const source = readPanelSource() assert.ok(source.includes('label="手动匹配等待(分钟)"'), '等待时长文案必须明确只作用于手动匹配') - assert.ok(source.includes('label="超时后回大厅"'), '必须提供超时回大厅开关') + assert.ok(source.includes("matchingMode === 'manual' ? ("), '等待时长必须只在手动匹配下展示') + assert.ok(!source.includes('超时后回大厅'), '回大厅是固定行为,不应再有开关') + assert.ok(!source.includes('fallbackToHall'), '配置与表单必须移除 fallbackToHall') assert.ok( - source.includes('自动匹配在发布时立即匹配,未命中直接进大厅,不使用等待窗口'), + source.includes('自动匹配:发布/补资料时立即匹配,未命中直接进大厅,不使用内部等待窗口。'), 'auto 模式必须说明不使用等待窗口', ) - assert.ok( - source.includes('关闭后超时重新匹配(最多 3 次),仍未命中回大厅'), - '关闭回大厅时必须说明重试上限', - ) - assert.ok( - (source.match(/disabled=\{matchingMode === 'auto'\}/g) || []).length >= 2, - '等待时长与超时策略在 auto 下都必须禁用', - ) assert.ok(!source.includes('label="内部匹配等待(分钟)"'), '旧文案必须移除') })