feat(consignment): 拼单一次拼满,内部匹配到期统一回大厅
- 拼单(自动/手动)先规划再一次落库,凑不齐整单失败并回滚,不再进入 internal_matching - 删除「超时后回大厅」开关与 fallbackToHall 配置,到期统一回大厅(迁移 152 清理历史字段)
This commit is contained in:
@@ -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;
|
||||
@@ -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'"), '到期必须回大厅')
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 =') &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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() {
|
||||
<ConsignmentSectionTitle
|
||||
index="08"
|
||||
title="匹配设置"
|
||||
description="匹配模式决定新单是否进入内部库存匹配队列"
|
||||
description="匹配模式决定新单如何匹配库存;内部等待仅手动匹配使用"
|
||||
/>
|
||||
</div>
|
||||
<div style={gridStyle}>
|
||||
@@ -1755,41 +1761,21 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="手动匹配等待(分钟)"
|
||||
name={['internalMatch', 'delayMinutes']}
|
||||
extra={
|
||||
matchingMode === 'auto'
|
||||
? '自动匹配在发布时立即匹配,未命中直接进大厅,不使用等待窗口'
|
||||
: '仅手动匹配生效:工单在内部池等待运营挑库存'
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="分钟"
|
||||
disabled={matchingMode === 'auto'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="超时后回大厅"
|
||||
name={['internalMatch', 'fallbackToHall']}
|
||||
valuePropName="checked"
|
||||
extra={
|
||||
matchingMode === 'auto'
|
||||
? '自动匹配未命中直接进大厅,不涉及此策略'
|
||||
: '关闭后超时重新匹配(最多 3 次),仍未命中回大厅'
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch
|
||||
checkedChildren="回大厅"
|
||||
unCheckedChildren="重新匹配"
|
||||
disabled={matchingMode === 'auto'}
|
||||
/>
|
||||
</Form.Item>
|
||||
{matchingMode === 'manual' ? (
|
||||
<Form.Item
|
||||
label="手动匹配等待(分钟)"
|
||||
name={['internalMatch', 'delayMinutes']}
|
||||
extra="仅手动匹配生效:工单在内部池等待运营挑库存"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="分钟"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1801,6 +1787,14 @@ export default function ConsignmentConfigPanel() {
|
||||
message="关闭匹配后:新单直接进入接单大厅,不等待内部库存,也不会自动匹配。"
|
||||
/>
|
||||
) : null}
|
||||
{matchingMode === 'auto' ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="自动匹配:发布/补资料时立即匹配,未命中直接进大厅,不使用内部等待窗口。"
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="order-service-section">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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="内部匹配等待(分钟)"'), '旧文案必须移除')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user