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, '试匹配必须在进入内部匹配之前')
|
assert.ok(enterIndex > tryIndex, '试匹配必须在进入内部匹配之前')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('内部匹配超时:fallbackToHall=false 时重新匹配,最多 3 次', async () => {
|
test('内部匹配超时一律回大厅,不再支持超时重匹配', async () => {
|
||||||
const source = await readFile(ENGINE, 'utf8')
|
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')
|
const start = source.indexOf('export async function expireConsignmentInternalMatching')
|
||||||
assert.ok(start >= 0, '找不到 expireConsignmentInternalMatching')
|
assert.ok(start >= 0, '找不到 expireConsignmentInternalMatching')
|
||||||
const body = source.slice(start, start + 3000)
|
const end = source.indexOf('export async function', start + 1)
|
||||||
assert.ok(body.includes('fallbackToHall'), '必须读取超时回大厅开关')
|
const body = source.slice(start, end > start ? end : start + 2000)
|
||||||
assert.ok(body.includes('MAX_INTERNAL_MATCH_ATTEMPTS'), '必须有重试次数上限')
|
assert.ok(!body.includes('fallbackToHall'), '到期逻辑不得再读取回大厅开关')
|
||||||
assert.ok(body.includes('startConsignmentRematch'), '关闭回大厅时要重新匹配')
|
assert.ok(!body.includes('startConsignmentRematch'), '到期逻辑不得再调用重匹配')
|
||||||
assert.ok(body.includes("status = 'open'"), '超过次数或失败后仍要回大厅兜底')
|
assert.ok(body.includes("status = 'open'"), '到期必须回大厅')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ test('内部匹配等待时长:0/负数/缺失都归一为 0(直接发大厅
|
|||||||
assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: '12.6' }), 13)
|
assert.equal(resolveInternalMatchDelayMinutes({ delayMinutes: '12.6' }), 13)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('内部匹配入口:0 分钟必须直接返回,不能依赖 fallbackToHall 进入 internal_matching', async () => {
|
test('内部匹配入口:0 分钟必须直接返回,禁止进入 internal_matching', async () => {
|
||||||
const source = await readFile(
|
const source = await readFile(
|
||||||
new URL('./consignment-match-engine-service.ts', import.meta.url),
|
new URL('./consignment-match-engine-service.ts', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -35,10 +35,6 @@ test('内部匹配入口:0 分钟必须直接返回,不能依赖 fallbackToH
|
|||||||
body.includes('if (delayMinutes <= 0) return false'),
|
body.includes('if (delayMinutes <= 0) return false'),
|
||||||
'0 分钟必须直接返回,禁止进入 internal_matching',
|
'0 分钟必须直接返回,禁止进入 internal_matching',
|
||||||
)
|
)
|
||||||
assert.ok(
|
|
||||||
!body.includes('delayMinutes <= 0 && !profile.config.internalMatch.fallbackToHall'),
|
|
||||||
'不允许再用 fallbackToHall 绕过 0 分钟保护(会生成无截止时间的悬空工单)',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('匹配改写报酬:首次固化原值,所有统一释放路径都必须恢复', async () => {
|
test('匹配改写报酬:首次固化原值,所有统一释放路径都必须恢复', async () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ test('手动匹配不再在事务外改状态:数量不足/并发占库失败
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('apply 事务内部:允许起始状态、整单检查有效份额、拼单补截止时间', async () => {
|
test('apply 事务内部:允许起始状态、整单检查有效份额、拼单必须一次拼满', async () => {
|
||||||
const source = await readFile(ENGINE, 'utf8')
|
const source = await readFile(ENGINE, 'utf8')
|
||||||
const wholeStart = source.indexOf('async function applyWholeOrderMatch')
|
const wholeStart = source.indexOf('async function applyWholeOrderMatch')
|
||||||
const wholeEnd = source.indexOf('async function applySharingMatch', wholeStart)
|
const wholeEnd = source.indexOf('async function applySharingMatch', wholeStart)
|
||||||
@@ -66,9 +66,16 @@ test('apply 事务内部:允许起始状态、整单检查有效份额、拼
|
|||||||
assert.ok(sharingStart >= 0 && sharingEnd > sharingStart, '找不到 applySharingMatch')
|
assert.ok(sharingStart >= 0 && sharingEnd > sharingStart, '找不到 applySharingMatch')
|
||||||
const sharing = source.slice(sharingStart, sharingEnd)
|
const sharing = source.slice(sharingStart, sharingEnd)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
sharing.includes('fallbackDeadline') &&
|
sharing.includes('planConsignmentSharingTakes') && sharing.includes("reason: 'insufficient'"),
|
||||||
sharing.includes('internal_match_deadline_at IS NULL THEN $5'),
|
'拼单必须先规划;一次拼不满时必须整单失败',
|
||||||
'拼单部分成功时必须补内部匹配截止时间',
|
)
|
||||||
|
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(
|
assert.ok(
|
||||||
sharing.includes('const shareGrossFenPerUnit =') &&
|
sharing.includes('const shareGrossFenPerUnit =') &&
|
||||||
|
|||||||
@@ -339,9 +339,6 @@ export async function resolveProfileForWorkOrder(
|
|||||||
return resolveConsignmentProfileForCategory(workOrder.category_id)
|
return resolveConsignmentProfileForCategory(workOrder.category_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 内部匹配超时后最多重新匹配的次数(fallbackToHall=false 时生效)。 */
|
|
||||||
export const MAX_INTERNAL_MATCH_ATTEMPTS = 3
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布前即时匹配(仅 matching.mode='auto'):
|
* 发布前即时匹配(仅 matching.mode='auto'):
|
||||||
* 工单仍在 unassigned 时直接尝试占库,命中后由 apply 事务改成 in_progress,
|
* 工单仍在 unassigned 时直接尝试占库,命中后由 apply 事务改成 in_progress,
|
||||||
@@ -402,23 +399,35 @@ export async function tryMatchConsignmentWorkOrderBeforePublish(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
|
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({
|
await recordConsignmentMatchLog({
|
||||||
...logBase,
|
...logBase,
|
||||||
result: created > 0 ? 'matched' : 'failed',
|
result: sharing.createdCount > 0 ? 'matched' : 'failed',
|
||||||
reasonCode: created > 0 ? 'auto_sharing_matched_before_publish' : 'sharing_not_applied',
|
reasonCode:
|
||||||
reasonText: created > 0 ? '' : '发布前拼单匹配未创建任何份额',
|
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,
|
candidateCount: candidates.length,
|
||||||
matchedCount: created,
|
matchedCount: sharing.createdCount,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
payload: {
|
payload: {
|
||||||
phase: 'before_publish',
|
phase: 'before_publish',
|
||||||
partial: context.partial,
|
partial: context.partial,
|
||||||
specConflicts: context.specConflicts,
|
specConflicts: context.specConflicts,
|
||||||
|
requiredQuantity: sharing.requiredQuantity,
|
||||||
|
availableQuantity: sharing.availableQuantity,
|
||||||
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
|
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))
|
const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
|
||||||
@@ -598,7 +607,7 @@ export async function fallbackConsignmentWorkOrderToHall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 超时「重新匹配」:把工单重新放回内部匹配池,并排除原卖家/原库存。
|
* 代练超时「重新匹配」:把工单重新放回内部匹配池,并排除原卖家/原库存。
|
||||||
* 分类关闭匹配、等待时长为 0 或找不到配置时,退回大厅,避免工单卡在无人处理的状态。
|
* 分类关闭匹配、等待时长为 0 或找不到配置时,退回大厅,避免工单卡在无人处理的状态。
|
||||||
*/
|
*/
|
||||||
export async function startConsignmentRematch(
|
export async function startConsignmentRematch(
|
||||||
@@ -968,174 +977,227 @@ async function applyWholeOrderMatch(
|
|||||||
return result.applied
|
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(
|
async function applySharingMatch(
|
||||||
input: MatchCandidate,
|
input: MatchCandidate,
|
||||||
candidates: ConsignmentMatchInventoryCandidate[],
|
candidates: ConsignmentMatchInventoryCandidate[],
|
||||||
context: ConsignmentMatchContext = { matchType: 'auto' },
|
context: ConsignmentMatchContext = { matchType: 'auto' },
|
||||||
): Promise<number> {
|
): Promise<ConsignmentSharingApplyResult> {
|
||||||
const { workOrder, profile } = input
|
const { workOrder, profile } = input
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const totalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
|
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'
|
const manual = context.matchType === 'manual'
|
||||||
|
|
||||||
type ApplyResult = { createdCount: number; sms: ConsignmentMatchSmsPayload[] }
|
let result: ConsignmentSharingApplyResult
|
||||||
const result = await withTransaction<ApplyResult>(async (client: PoolClient) => {
|
try {
|
||||||
const locked = await client.query<WorkOrderRow>(
|
result = await withTransaction<ConsignmentSharingApplyResult>(async (client: PoolClient) => {
|
||||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
const locked = await client.query<WorkOrderRow>(
|
||||||
[workOrder.id],
|
'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,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
const shareId = Number(share.rows[0]?.id || 0)
|
const current = locked.rows[0]
|
||||||
if (shareId <= 0) continue
|
const allowedStartStatuses =
|
||||||
await client.query(
|
context.startStatuses ?? ([WORK_ORDER_STATUS.INTERNAL_MATCHING] as string[])
|
||||||
'UPDATE consignment_inventory_allocations SET work_order_share_id = $1, updated_at = $2 WHERE id = $3',
|
if (!current || !allowedStartStatuses.includes(current.status)) {
|
||||||
[shareId, now, allocation.id],
|
return {
|
||||||
)
|
createdCount: 0,
|
||||||
createdCount += 1
|
reason: 'not_applied',
|
||||||
createdQuantity += take
|
requiredQuantity: 0,
|
||||||
smsPayloads.push({
|
availableQuantity: 0,
|
||||||
workOrder: current,
|
sms: [],
|
||||||
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
|
|
||||||
}
|
}
|
||||||
remaining -= take
|
|
||||||
}
|
|
||||||
|
|
||||||
if (createdCount > 0) {
|
const activeResult = await client.query<{ active: number | string }>(
|
||||||
active += createdQuantity
|
`SELECT COALESCE(SUM(quantity), 0) AS active FROM work_order_shares WHERE work_order_id = $1 AND status IN ('joined', 'submitted', 'accepted')`,
|
||||||
const nextStatus =
|
[current.id],
|
||||||
active >= totalQuantity
|
)
|
||||||
? WORK_ORDER_STATUS.IN_PROGRESS
|
const active = Number(activeResult.rows[0]?.active || 0)
|
||||||
: WORK_ORDER_STATUS.INTERNAL_MATCHING
|
const remaining = Math.max(0, totalQuantity - active)
|
||||||
// 从大厅/待分配手动拼单、只匹配到部分份额时,必须补上新的内部匹配截止时间,
|
if (remaining <= 0) {
|
||||||
// 不能让工单停在「internal_matching 且 deadline 为空」的永久悬空状态。
|
return {
|
||||||
const fallbackDeadline =
|
createdCount: 0,
|
||||||
nextStatus === WORK_ORDER_STATUS.INTERNAL_MATCHING
|
reason: 'not_applied',
|
||||||
? addMinutesIso(Math.max(1, resolveInternalMatchDelayMinutes(config?.internalMatch)), now)
|
requiredQuantity: 0,
|
||||||
: null
|
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(
|
await client.query(
|
||||||
`
|
`
|
||||||
UPDATE work_orders
|
UPDATE work_orders
|
||||||
SET status = $1,
|
SET status = $1,
|
||||||
material_json = $2::jsonb,
|
material_json = $2::jsonb,
|
||||||
assigned_at = CASE WHEN $1 = 'in_progress' THEN $3 ELSE assigned_at END,
|
assigned_at = $3,
|
||||||
internal_match_deadline_at = CASE
|
internal_match_deadline_at = NULL,
|
||||||
WHEN $1 = 'in_progress' THEN NULL
|
hall_visible_at = 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,
|
|
||||||
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
|
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_worker_id = NULL,
|
||||||
rematch_excluded_inventory_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_inventory_id END,
|
rematch_excluded_inventory_id = NULL,
|
||||||
updated_at = $3
|
updated_at = $3
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
nextStatus,
|
WORK_ORDER_STATUS.IN_PROGRESS,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...material,
|
...material,
|
||||||
...(firstInventoryId
|
...(firstInventoryId
|
||||||
@@ -1164,13 +1226,12 @@ async function applySharingMatch(
|
|||||||
consignmentSharing: {
|
consignmentSharing: {
|
||||||
profileId: profile?.profileId ?? null,
|
profileId: profile?.profileId ?? null,
|
||||||
totalQuantity,
|
totalQuantity,
|
||||||
matchedQuantity: active,
|
matchedQuantity,
|
||||||
matchedAt: now,
|
matchedAt: now,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
now,
|
now,
|
||||||
current.id,
|
current.id,
|
||||||
fallbackDeadline,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
await createWorkOrderEventWithClient(client, {
|
await createWorkOrderEventWithClient(client, {
|
||||||
@@ -1179,11 +1240,11 @@ async function applySharingMatch(
|
|||||||
actorId: manual ? context.actorName || 'admin' : 'consignment_match',
|
actorId: manual ? context.actorName || 'admin' : 'consignment_match',
|
||||||
eventType: manual ? 'consignment_manual_sharing_matched' : 'consignment_sharing_matched',
|
eventType: manual ? 'consignment_manual_sharing_matched' : 'consignment_sharing_matched',
|
||||||
fromStatus: current.status,
|
fromStatus: current.status,
|
||||||
toStatus: nextStatus,
|
toStatus: WORK_ORDER_STATUS.IN_PROGRESS,
|
||||||
payloadJson: JSON.stringify({
|
payloadJson: JSON.stringify({
|
||||||
createdCount,
|
createdCount,
|
||||||
createdQuantity,
|
createdQuantity,
|
||||||
activeQuantity: active,
|
activeQuantity: matchedQuantity,
|
||||||
totalQuantity,
|
totalQuantity,
|
||||||
shareRewardFenPerUnit: firstShareNetFenPerUnit,
|
shareRewardFenPerUnit: firstShareNetFenPerUnit,
|
||||||
serviceFeeFenPerUnit: firstShareFeeFenPerUnit,
|
serviceFeeFenPerUnit: firstShareFeeFenPerUnit,
|
||||||
@@ -1201,13 +1262,30 @@ async function applySharingMatch(
|
|||||||
}),
|
}),
|
||||||
now,
|
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) {
|
for (const sms of result.sms) {
|
||||||
await sendConsignmentMatchSuccessSms(sms)
|
await sendConsignmentMatchSuccessSms(sms)
|
||||||
}
|
}
|
||||||
return result.createdCount
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自动匹配一批内部匹配工单:低价订单优先,精确规格,支持整单/拼单。 */
|
/** 自动匹配一批内部匹配工单:低价订单优先,精确规格,支持整单/拼单。 */
|
||||||
@@ -1284,19 +1362,21 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
|
|||||||
...evaluation.unknowns,
|
...evaluation.unknowns,
|
||||||
]),
|
]),
|
||||||
}
|
}
|
||||||
const created = await applySharingMatch({ workOrder, profile }, candidates, context)
|
const sharing = await applySharingMatch({ workOrder, profile }, candidates, context)
|
||||||
if (created > 0) {
|
if (sharing.createdCount > 0) {
|
||||||
matched += 1
|
matched += 1
|
||||||
await recordConsignmentMatchLog({
|
await recordConsignmentMatchLog({
|
||||||
...logBase,
|
...logBase,
|
||||||
result: 'matched',
|
result: 'matched',
|
||||||
reasonCode: 'auto_sharing_matched',
|
reasonCode: 'auto_sharing_matched',
|
||||||
candidateCount: candidates.length,
|
candidateCount: candidates.length,
|
||||||
matchedCount: created,
|
matchedCount: sharing.createdCount,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
payload: {
|
payload: {
|
||||||
partial: context.partial,
|
partial: context.partial,
|
||||||
specConflicts: context.specConflicts,
|
specConflicts: context.specConflicts,
|
||||||
|
requiredQuantity: sharing.requiredQuantity,
|
||||||
|
availableQuantity: sharing.availableQuantity,
|
||||||
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
|
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1306,10 +1386,20 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
|
|||||||
await recordConsignmentMatchLog({
|
await recordConsignmentMatchLog({
|
||||||
...logBase,
|
...logBase,
|
||||||
result: 'failed',
|
result: 'failed',
|
||||||
reasonCode: 'sharing_not_applied',
|
reasonCode:
|
||||||
reasonText: '拼单匹配未创建任何份额(库存可能已被其它任务占用)',
|
sharing.reason === 'insufficient'
|
||||||
|
? 'sharing_quantity_insufficient'
|
||||||
|
: 'sharing_not_applied',
|
||||||
|
reasonText:
|
||||||
|
sharing.reason === 'insufficient'
|
||||||
|
? `拼单必须一次拼满:还差 ${sharing.requiredQuantity} 份,可用 ${sharing.availableQuantity} 份`
|
||||||
|
: '拼单匹配未生效(库存或工单状态已变化)',
|
||||||
candidateCount: candidates.length,
|
candidateCount: candidates.length,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
|
payload: {
|
||||||
|
requiredQuantity: sharing.requiredQuantity,
|
||||||
|
availableQuantity: sharing.availableQuantity,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} 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 } = {}) {
|
export async function expireConsignmentInternalMatching(options: { now?: string } = {}) {
|
||||||
const now = options.now || nowIso()
|
const now = options.now || nowIso()
|
||||||
@@ -1401,22 +1489,7 @@ export async function expireConsignmentInternalMatching(options: { now?: string
|
|||||||
[now],
|
[now],
|
||||||
)
|
)
|
||||||
let expiredCount = 0
|
let expiredCount = 0
|
||||||
let rematched = 0
|
|
||||||
for (const workOrder of expired.rows) {
|
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 }>(
|
const updated = await query<{ id: number }>(
|
||||||
`
|
`
|
||||||
UPDATE work_orders
|
UPDATE work_orders
|
||||||
@@ -1439,14 +1512,10 @@ export async function expireConsignmentInternalMatching(options: { now?: string
|
|||||||
payload_json, created_at
|
payload_json, created_at
|
||||||
) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_expired', 'internal_matching', 'open', $2::jsonb, $3)
|
) VALUES ($1, 'system', 'consignment_match', 'consignment_internal_matching_expired', 'internal_matching', 'open', $2::jsonb, $3)
|
||||||
`,
|
`,
|
||||||
[
|
[workOrder.id, JSON.stringify({ expiredAt: now }), now],
|
||||||
workOrder.id,
|
|
||||||
JSON.stringify({ expiredAt: now, attempts, fallbackToHall, rematched: false }),
|
|
||||||
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) {
|
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
|
||||||
const created = await applySharingMatch({ workOrder, profile }, selected, context)
|
const sharing = await applySharingMatch({ workOrder, profile }, selected, context)
|
||||||
return { matched: created > 0, matchedCount: created }
|
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) {
|
if (selected.length > 1) {
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ const profileConfig = {
|
|||||||
exactOnly: true,
|
exactOnly: true,
|
||||||
preferLowReward: true,
|
preferLowReward: true,
|
||||||
},
|
},
|
||||||
internalMatch: { delayMinutes: 30, fallbackToHall: true },
|
internalMatch: { delayMinutes: 30 },
|
||||||
timeout: { minutes: 60, policy: 'reopen' },
|
timeout: { minutes: 60, policy: 'reopen' },
|
||||||
priceDrop: { enabled: true, hours: 72, mode: 'percent', value: 10 },
|
priceDrop: { enabled: true, hours: 72, mode: 'percent', value: 10 },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export type ConsignmentMatchingConfig = {
|
|||||||
export type ConsignmentInternalMatchConfig = {
|
export type ConsignmentInternalMatchConfig = {
|
||||||
/** 资料提交后等待内部库存匹配的分钟数;0 = 不等待,直接发大厅 */
|
/** 资料提交后等待内部库存匹配的分钟数;0 = 不等待,直接发大厅 */
|
||||||
delayMinutes: number
|
delayMinutes: number
|
||||||
fallbackToHall: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConsignmentTimeoutConfig = {
|
export type ConsignmentTimeoutConfig = {
|
||||||
@@ -175,7 +174,6 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
|
|||||||
},
|
},
|
||||||
internalMatch: {
|
internalMatch: {
|
||||||
delayMinutes: 30,
|
delayMinutes: 30,
|
||||||
fallbackToHall: true,
|
|
||||||
},
|
},
|
||||||
timeout: {
|
timeout: {
|
||||||
minutes: 30,
|
minutes: 30,
|
||||||
@@ -310,7 +308,6 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
|
|||||||
},
|
},
|
||||||
internalMatch: {
|
internalMatch: {
|
||||||
delayMinutes: integer(internalMatch.delayMinutes, 30, 0, 7 * 24 * 60),
|
delayMinutes: integer(internalMatch.delayMinutes, 30, 0, 7 * 24 * 60),
|
||||||
fallbackToHall: bool(internalMatch.fallbackToHall, true),
|
|
||||||
},
|
},
|
||||||
timeout: {
|
timeout: {
|
||||||
minutes: integer(timeout.minutes, 30, 0, 7 * 24 * 60),
|
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
|
exactOnly: boolean
|
||||||
preferLowReward: boolean
|
preferLowReward: boolean
|
||||||
}
|
}
|
||||||
internalMatch: { delayMinutes: number; fallbackToHall: boolean }
|
internalMatch: { delayMinutes: number }
|
||||||
timeout: {
|
timeout: {
|
||||||
hours: number
|
hours: number
|
||||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
||||||
@@ -201,7 +201,15 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
|
|||||||
exactOnly: config.matching.exactOnly,
|
exactOnly: config.matching.exactOnly,
|
||||||
preferLowReward: config.matching.preferLowReward,
|
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: {
|
timeout: {
|
||||||
hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60),
|
hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60),
|
||||||
policy: config.timeout.policy,
|
policy: config.timeout.policy,
|
||||||
@@ -234,7 +242,7 @@ function emptyFormValues(): ConfigFormValues {
|
|||||||
exactOnly: true,
|
exactOnly: true,
|
||||||
preferLowReward: true,
|
preferLowReward: true,
|
||||||
},
|
},
|
||||||
internalMatch: { delayMinutes: 30, fallbackToHall: true },
|
internalMatch: { delayMinutes: 30 },
|
||||||
timeout: { hours: 0.5, policy: 'reopen' },
|
timeout: { hours: 0.5, policy: 'reopen' },
|
||||||
priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 },
|
priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 },
|
||||||
}
|
}
|
||||||
@@ -303,11 +311,9 @@ function buildPayload(
|
|||||||
preferLowReward: values.matching.preferLowReward,
|
preferLowReward: values.matching.preferLowReward,
|
||||||
},
|
},
|
||||||
internalMatch: {
|
internalMatch: {
|
||||||
// 关闭匹配时不再等待内部库存,直接进大厅。
|
// 内部等待只属于手动匹配:关闭匹配不再等待;自动匹配不使用等待窗口。
|
||||||
delayMinutes:
|
delayMinutes:
|
||||||
values.matching.mode === 'off' ? 0 : Number(values.internalMatch.delayMinutes || 0),
|
values.matching.mode === 'off' ? 0 : Number(values.internalMatch.delayMinutes || 0),
|
||||||
fallbackToHall:
|
|
||||||
values.matching.mode === 'off' ? false : values.internalMatch.fallbackToHall,
|
|
||||||
},
|
},
|
||||||
timeout: {
|
timeout: {
|
||||||
minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)),
|
minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)),
|
||||||
@@ -1720,7 +1726,7 @@ export default function ConsignmentConfigPanel() {
|
|||||||
<ConsignmentSectionTitle
|
<ConsignmentSectionTitle
|
||||||
index="08"
|
index="08"
|
||||||
title="匹配设置"
|
title="匹配设置"
|
||||||
description="匹配模式决定新单是否进入内部库存匹配队列"
|
description="匹配模式决定新单如何匹配库存;内部等待仅手动匹配使用"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={gridStyle}>
|
<div style={gridStyle}>
|
||||||
@@ -1755,41 +1761,21 @@ export default function ConsignmentConfigPanel() {
|
|||||||
>
|
>
|
||||||
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
|
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
{matchingMode === 'manual' ? (
|
||||||
label="手动匹配等待(分钟)"
|
<Form.Item
|
||||||
name={['internalMatch', 'delayMinutes']}
|
label="手动匹配等待(分钟)"
|
||||||
extra={
|
name={['internalMatch', 'delayMinutes']}
|
||||||
matchingMode === 'auto'
|
extra="仅手动匹配生效:工单在内部池等待运营挑库存"
|
||||||
? '自动匹配在发布时立即匹配,未命中直接进大厅,不使用等待窗口'
|
style={{ marginBottom: 0 }}
|
||||||
: '仅手动匹配生效:工单在内部池等待运营挑库存'
|
>
|
||||||
}
|
<InputNumber
|
||||||
style={{ marginBottom: 0 }}
|
min={0}
|
||||||
>
|
precision={0}
|
||||||
<InputNumber
|
style={{ width: '100%' }}
|
||||||
min={0}
|
addonAfter="分钟"
|
||||||
precision={0}
|
/>
|
||||||
style={{ width: '100%' }}
|
</Form.Item>
|
||||||
addonAfter="分钟"
|
) : null}
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1801,6 +1787,14 @@ export default function ConsignmentConfigPanel() {
|
|||||||
message="关闭匹配后:新单直接进入接单大厅,不等待内部库存,也不会自动匹配。"
|
message="关闭匹配后:新单直接进入接单大厅,不等待内部库存,也不会自动匹配。"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{matchingMode === 'auto' ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
message="自动匹配:发布/补资料时立即匹配,未命中直接进大厅,不使用内部等待窗口。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="order-service-section">
|
<section className="order-service-section">
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ export type ConsignmentProfileConfig = {
|
|||||||
exactOnly: boolean
|
exactOnly: boolean
|
||||||
preferLowReward: boolean
|
preferLowReward: boolean
|
||||||
}
|
}
|
||||||
internalMatch: { delayMinutes: number; fallbackToHall: boolean }
|
internalMatch: { delayMinutes: number }
|
||||||
timeout: {
|
timeout: {
|
||||||
minutes: number
|
minutes: number
|
||||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
||||||
|
|||||||
@@ -10,21 +10,15 @@ function readPanelSource() {
|
|||||||
).replace(/\r\n/g, '\n')
|
).replace(/\r\n/g, '\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
test('自动匹配:手动等待与超时策略在 auto 下禁用并给出说明', () => {
|
test('手动匹配:只保留等待时长,不再提供「超时后回大厅」开关', () => {
|
||||||
const source = readPanelSource()
|
const source = readPanelSource()
|
||||||
assert.ok(source.includes('label="手动匹配等待(分钟)"'), '等待时长文案必须明确只作用于手动匹配')
|
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(
|
assert.ok(
|
||||||
source.includes('自动匹配在发布时立即匹配,未命中直接进大厅,不使用等待窗口'),
|
source.includes('自动匹配:发布/补资料时立即匹配,未命中直接进大厅,不使用内部等待窗口。'),
|
||||||
'auto 模式必须说明不使用等待窗口',
|
'auto 模式必须说明不使用等待窗口',
|
||||||
)
|
)
|
||||||
assert.ok(
|
|
||||||
source.includes('关闭后超时重新匹配(最多 3 次),仍未命中回大厅'),
|
|
||||||
'关闭回大厅时必须说明重试上限',
|
|
||||||
)
|
|
||||||
assert.ok(
|
|
||||||
(source.match(/disabled=\{matchingMode === 'auto'\}/g) || []).length >= 2,
|
|
||||||
'等待时长与超时策略在 auto 下都必须禁用',
|
|
||||||
)
|
|
||||||
assert.ok(!source.includes('label="内部匹配等待(分钟)"'), '旧文案必须移除')
|
assert.ok(!source.includes('label="内部匹配等待(分钟)"'), '旧文案必须移除')
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user