@@ -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 < number > {
) : Promise < ConsignmentSharingApplyResult > {
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 < ApplyResult > ( async ( client : PoolClient ) = > {
const locked = await client . query < WorkOrderRow > (
'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 < ConsignmentSharingApplyResult > ( async ( client : PoolClient ) = > {
const locked = await client . query < WorkOrderRow > (
'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 ) {