优化履约待办分桶与补载体扫描

This commit is contained in:
yml
2026-11-14 19:49:54 +08:00
parent e97932dcdf
commit b03599eca7
17 changed files with 629 additions and 81 deletions
@@ -0,0 +1,109 @@
-- 162_fulfillment_queue_buckets.sql
--
-- 把履约待办从单一 pending 队列拆成只读派生桶:
-- actionable 正常等待补载体,允许自动扫描
-- configuration 缺内部 SKU、渠道或渠道配置
-- operator_review 失败重试、人工复核、已关闭/取消、系统不一致
-- terminal 已退款、退款申请、平台取消或商业订单已关闭
--
-- 本迁移不新增可写状态字段,也不改 fulfillments 的权威状态。
-- 分类只由订单商业状态、正式履约字段和结构化 issue 派生,供扫描器与后台共用。
CREATE INDEX IF NOT EXISTS idx_fulfillments_pending_unlocked_item
ON fulfillments (order_item_id)
WHERE status = 'pending' AND locked = FALSE;
COMMENT ON INDEX idx_fulfillments_pending_unlocked_item IS
'履约队列扫描:仅覆盖待处理且未锁定的正式履约记录';
CREATE OR REPLACE VIEW v_fulfillment_queue AS
WITH classified AS (
SELECT
state.*,
CASE
WHEN state.pay_status = 'refunded'
OR state.order_status IN ('refunded', 'cancelled', 'closed')
OR state.issue_code IN ('user_refund', 'platform_cancelled')
THEN 'terminal'
WHEN state.issue_category IN ('configuration', 'classification')
OR state.issue_code IN (
'missing_internal_sku',
'missing_channel',
'manual_config_missing',
'work_rule_missing',
'affiliate_mapping_missing'
)
OR COALESCE(state.fulfillment_pending_detail ->> 'carrierIssue', '') IN (
'manual_config_missing',
'work_rule_missing',
'affiliate_mapping_missing',
'missing_internal_sku',
'missing_channel'
)
THEN 'configuration'
WHEN state.issue_category = 'manual_review'
OR state.issue_code IN (
'carrier_manual_review',
'carrier_retry_pending',
'carrier_expired',
'carrier_closed'
)
OR COALESCE(state.fulfillment_pending_detail ->> 'carrierIssue', '') IN (
'carrier_task_closed',
'carrier_task_user_refund',
'carrier_task_retry_pending',
'carrier_task_cancelled',
'carrier_task_expired',
'carrier_task_manual_review'
)
THEN 'operator_review'
WHEN state.issue_code = 'state_inconsistent'
AND state.issue_category = 'system'
AND COALESCE(state.fulfillment_pending_detail ->> 'carrierIssue', '')
IN ('', 'carrier_not_attempted')
THEN 'actionable'
WHEN state.issue_category = 'system'
THEN 'operator_review'
ELSE 'actionable'
END AS queue_bucket
FROM v_fulfillment_item_state state
),
eligible AS (
SELECT
classified.*,
(
classified.fulfillment_status = 'pending'
AND classified.pay_status = 'paid'
AND classified.order_status NOT IN ('refunded', 'cancelled', 'closed')
AND NOT EXISTS (
SELECT 1
FROM fulfillment_tasks task
WHERE task.order_item_id = classified.order_item_id
AND task.executor_key = 'affiliate_dash'
AND task.task_status NOT IN (
'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled'
)
)
AND NOT EXISTS (
SELECT 1
FROM work_orders work_order
WHERE work_order.order_item_id = classified.order_item_id
AND work_order.status <> 'cancelled'
)
) AS carrier_queue_candidate
FROM classified
)
SELECT
eligible.order_item_id AS id,
eligible.*,
(
eligible.carrier_queue_candidate
AND eligible.queue_bucket = 'actionable'
AND eligible.fulfillment_locked = FALSE
) AS actionable_for_carrier,
item.manual_sku_key
FROM eligible
JOIN order_items item ON item.id = eligible.order_item_id;
COMMENT ON VIEW v_fulfillment_queue IS
'履约待办只读投影:actionable/configuration/operator_review/terminal;actionable_for_carrier 为补载体自动扫描唯一入口';
@@ -140,6 +140,7 @@ router.get(
pageSize: Number(req.query.pageSize),
keyword: String(req.query.keyword || ''),
issueCategory: String(req.query.issueCategory || ''),
queueBucket: String(req.query.queueBucket || ''),
}),
{
successMessage: 'ok',
@@ -158,6 +159,7 @@ router.post(
groupKey: String(bodyOf(req).groupKey || ''),
internalSku: String(bodyOf(req).internalSku || ''),
issueCategory: String(bodyOf(req).issueCategory || ''),
queueBucket: String(bodyOf(req).queueBucket || ''),
adminUserId: req.adminSession?.userId ?? null,
}),
{
@@ -236,6 +238,7 @@ router.post(
groupKey: String(body.groupKey || ''),
internalSku: String(body.internalSku || ''),
issueCategory: String(body.issueCategory || ''),
queueBucket: String(body.queueBucket || ''),
})
},
{
@@ -352,6 +355,7 @@ router.post(
mappedInternalSku: String(body.mappedInternalSku || ''),
channel,
issueCategory: String(body.issueCategory || ''),
queueBucket: String(body.queueBucket || ''),
adminUserId,
note,
})
@@ -659,6 +659,7 @@ async function retryPendingManualItems(skuKey: string): Promise<number> {
orderItemIds: ids,
limit: ids.length,
allowFallback: true,
includeNonActionable: true,
})
return result.fallbacksSelected
}
@@ -55,6 +55,10 @@ async function loadCandidateItems(input: {
orderItemIds?: number[]
assignedMissingCarrier?: boolean
afterId?: number
createdAfter?: string
createdBefore?: string
orderDirection?: 'asc' | 'desc'
includeNonActionable?: boolean
includeLocked?: boolean
cutoffCreatedAt?: string
}): Promise<CarrierCandidate[]> {
@@ -76,9 +80,22 @@ async function loadCandidateItems(input: {
params.push(cutoffCreatedAt)
cutoffFilter = `AND oi.created_at >= $${params.length}::timestamptz`
}
const createdAfter = String(input.createdAfter || '').trim()
if (createdAfter && Number.isFinite(Date.parse(createdAfter))) {
params.push(createdAfter)
cutoffFilter += ` AND oi.created_at >= $${params.length}::timestamptz`
}
const createdBefore = String(input.createdBefore || '').trim()
if (createdBefore && Number.isFinite(Date.parse(createdBefore))) {
params.push(createdBefore)
cutoffFilter += ` AND oi.created_at < $${params.length}::timestamptz`
}
const statusFilter = input.assignedMissingCarrier
? `oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
AND oi.stored_channel IN ('affiliate_dash', 'work_order')
AND oi.queue_bucket = 'actionable'
AND oi.pay_status = 'paid'
AND oi.order_status NOT IN ('refunded', 'cancelled', 'closed')
AND NOT EXISTS (
SELECT 1 FROM work_orders wo
WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'
@@ -96,7 +113,14 @@ async function loadCandidateItems(input: {
LIMIT 1
), '') NOT IN ('USER_APPLY_REFUND', 'affiliate_dash_cancelled')`
: `oi.fulfillment_status = 'pending'
AND oi.carrier_queue_candidate = TRUE
${
input.includeNonActionable
? `AND oi.queue_bucket IN ('actionable', 'configuration', 'operator_review')`
: `AND oi.queue_bucket = 'actionable'`
}
${input.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}`
const orderDirection = input.orderDirection === 'desc' ? 'DESC' : 'ASC'
const result = await query<CarrierCandidate>(
`
@@ -105,7 +129,7 @@ async function loadCandidateItems(input: {
oi.order_id,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
capability.channels
FROM v_order_item_current oi
FROM v_fulfillment_queue oi
JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
@@ -136,7 +160,7 @@ async function loadCandidateItems(input: {
WHERE ${statusFilter}
${idFilter}
${cutoffFilter}
ORDER BY oi.id
ORDER BY oi.id ${orderDirection}
LIMIT $1
`,
params,
@@ -259,6 +283,10 @@ export async function reconcilePendingCarriers(
includeAssignedMissingCarrier?: boolean
allowFallback?: boolean
afterId?: number
createdAfter?: string
createdBefore?: string
orderDirection?: 'asc' | 'desc'
includeNonActionable?: boolean
includeLocked?: boolean
retryInactiveCarrier?: boolean
cutoffCreatedAt?: string
@@ -279,6 +307,10 @@ export async function reconcilePendingCarriers(
...(input.afterId != null ? { afterId: input.afterId } : {}),
...(input.includeLocked ? { includeLocked: true } : {}),
...(input.cutoffCreatedAt ? { cutoffCreatedAt: input.cutoffCreatedAt } : {}),
...(input.createdAfter ? { createdAfter: input.createdAfter } : {}),
...(input.createdBefore ? { createdBefore: input.createdBefore } : {}),
...(input.orderDirection ? { orderDirection: input.orderDirection } : {}),
...(input.includeNonActionable ? { includeNonActionable: true } : {}),
...(input.orderItemIds ? { orderItemIds: input.orderItemIds } : {}),
})
@@ -425,18 +457,8 @@ export async function reconcilePendingCarriers(
const remainingResult = await query<{ count: number }>(
`
SELECT count(*)::int AS count
FROM v_order_item_current oi
WHERE oi.fulfillment_status IN ('pending', 'assigned')
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
AND NOT EXISTS (
SELECT 1 FROM work_orders wo
WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'
)
AND NOT EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')})
)
FROM v_fulfillment_queue oi
WHERE oi.carrier_queue_candidate = TRUE
`,
)
@@ -447,10 +469,11 @@ export async function reconcilePendingCarriers(
fallbacksSelected,
failed,
remaining: Number(remainingResult.rows[0]?.count || 0),
nextCursor: input.orderItemIds?.length
? 0
: rows.length < limit
nextCursor:
input.orderDirection === 'desc' || input.orderItemIds?.length
? 0
: Number(rows[rows.length - 1]?.id || 0),
: rows.length < limit
? 0
: Number(rows[rows.length - 1]?.id || 0),
}
}
@@ -1060,6 +1060,7 @@ export type PendingGroupRow = {
sku_codes: string[]
pending_reasons: string[]
pending_subreasons: string[]
queue_buckets: FulfillmentQueueBucket[]
configured_channels: string[]
locked_item_count: number
item_count: number
@@ -1141,6 +1142,20 @@ const PENDING_ISSUE_CATEGORIES = [
'after_sales',
'system',
] as const
export const FULFILLMENT_QUEUE_BUCKETS = [
'actionable',
'configuration',
'operator_review',
'terminal',
] as const
export type FulfillmentQueueBucket = (typeof FULFILLMENT_QUEUE_BUCKETS)[number]
function normalizeQueueBucket(value: unknown): FulfillmentQueueBucket | '' {
const bucket = String(value || '').trim()
return (FULFILLMENT_QUEUE_BUCKETS as readonly string[]).includes(bucket)
? (bucket as FulfillmentQueueBucket)
: ''
}
/** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */
export async function listPendingChannelGroups(input: {
@@ -1148,17 +1163,28 @@ export async function listPendingChannelGroups(input: {
pageSize?: number
keyword?: string
issueCategory?: string
}): Promise<{ items: PendingGroupRow[]; total: number; page: number; pageSize: number }> {
queueBucket?: string
}): Promise<{
items: PendingGroupRow[]
total: number
page: number
pageSize: number
bucketCounts: Record<FulfillmentQueueBucket, number>
}> {
const page = Math.max(1, Math.trunc(Number(input.page) || 1))
const pageSize = Math.min(200, Math.max(1, Math.trunc(Number(input.pageSize) || 30)))
const keyword = String(input.keyword || '').trim()
const queueBucket = normalizeQueueBucket(input.queueBucket)
const issueCategoryInput = String(input.issueCategory || '').trim()
const issueCategory = (PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategoryInput)
? issueCategoryInput
: ''
const params: unknown[] = []
const clauses = [`v.fulfillment_status = 'pending'`]
if (issueCategory) {
if (queueBucket) {
params.push(queueBucket)
clauses.push(`v.queue_bucket = $${params.length}`)
} else if (issueCategory) {
params.push(issueCategory)
clauses.push(`v.issue_category = $${params.length}`)
}
@@ -1172,13 +1198,49 @@ export async function listPendingChannelGroups(input: {
}
const where = clauses.join(' AND ')
const bucketCountParams: unknown[] = []
let bucketCountKeyword = ''
if (keyword) {
bucketCountParams.push(`%${keyword}%`)
bucketCountKeyword = `AND (
v.internal_sku ILIKE $${bucketCountParams.length}
OR v.sku_code ILIKE $${bucketCountParams.length}
OR v.sku_name ILIKE $${bucketCountParams.length}
)`
}
const bucketCountRows = await query<{ queue_bucket: FulfillmentQueueBucket; count: number }>(
`
WITH pending_items AS (
SELECT
v.queue_bucket,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_queue v
WHERE v.fulfillment_status = 'pending'
${bucketCountKeyword}
)
SELECT queue_bucket, COUNT(DISTINCT group_key)::int AS count
FROM pending_items
GROUP BY queue_bucket
`,
bucketCountParams,
)
const bucketCounts: Record<FulfillmentQueueBucket, number> = {
actionable: 0,
configuration: 0,
operator_review: 0,
terminal: 0,
}
for (const row of bucketCountRows.rows) {
bucketCounts[row.queue_bucket] = Number(row.count || 0)
}
const total = await query<{ count: number }>(
`
WITH pending_items AS (
SELECT
v.internal_sku,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_item_state v
FROM v_fulfillment_queue v
WHERE ${where}
)
SELECT COUNT(DISTINCT group_key)::int AS count
@@ -1194,7 +1256,7 @@ export async function listPendingChannelGroups(input: {
SELECT
v.*,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_item_state v
FROM v_fulfillment_queue v
WHERE ${where}
),
grouped AS (
@@ -1217,6 +1279,7 @@ export async function listPendingChannelGroups(input: {
NULL
) AS pending_reasons,
array_remove(array_agg(DISTINCT issue_code), '') AS pending_subreasons,
array_agg(DISTINCT queue_bucket) AS queue_buckets,
COUNT(*) FILTER (WHERE fulfillment_locked)::int AS locked_item_count,
COUNT(*)::int AS item_count,
COALESCE(SUM(revenue_amount_fen), 0)::bigint AS revenue_fen,
@@ -1284,6 +1347,7 @@ export async function listPendingChannelGroups(input: {
grouped.sku_codes,
grouped.pending_reasons,
grouped.pending_subreasons,
grouped.queue_buckets,
COALESCE(cfg.channels, ARRAY[]::text[]) AS configured_channels,
grouped.locked_item_count,
grouped.item_count,
@@ -1326,9 +1390,10 @@ export async function listPendingChannelGroups(input: {
v.shop_id,
v.shop_name,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_item_state v
FROM v_fulfillment_queue v
WHERE v.fulfillment_status = 'pending'
AND ($2::text = '' OR v.issue_category = $2)
AND ($3::text = '' OR v.queue_bucket = $3)
)
SELECT
group_key,
@@ -1353,7 +1418,7 @@ export async function listPendingChannelGroups(input: {
usage_count DESC,
shop_name
`,
[groupKeys, issueCategory],
[groupKeys, issueCategory, queueBucket],
)
const seen = new Set<string>()
@@ -1377,6 +1442,7 @@ export async function listPendingChannelGroups(input: {
total: total.rows[0]?.count || 0,
page,
pageSize,
bucketCounts,
}
}
@@ -1453,6 +1519,7 @@ export async function retryPendingCarrierGroup(input: {
orderItemIds: batchIds,
limit: batchIds.length,
includeLocked: input.includeLocked === true,
includeNonActionable: true,
retryInactiveCarrier: input.retryInactiveCarrier === true,
})
workOrdersCreated += carrier.workOrdersCreated
@@ -1532,6 +1599,7 @@ export async function unlockPendingChannelGroup(input: {
groupKey?: string
internalSku?: string
issueCategory?: string
queueBucket?: string
}): Promise<{ updated: number }> {
const scope = resolvePendingGroupScopeInput(input)
if (!scope) {
@@ -1551,13 +1619,17 @@ export async function unlockPendingChannelGroup(input: {
where.push(`state.provider = $1 AND state.shop_id = $2 AND state.sku_code = $3`)
}
const issueCategory = String(input.issueCategory || '').trim()
if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) {
const queueBucket = normalizeQueueBucket(input.queueBucket)
if (queueBucket) {
params.push(queueBucket)
where.push(`state.queue_bucket = $${params.length}`)
} else if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) {
params.push(issueCategory)
where.push(`state.issue_category = $${params.length}`)
}
const matched = await query<{ order_item_id: number }>(
`SELECT state.order_item_id
FROM v_fulfillment_item_state state
FROM v_fulfillment_queue state
WHERE ${where.join(' AND ')}
ORDER BY state.order_item_id`,
params,
@@ -1623,6 +1695,7 @@ export async function deletePendingChannelGroup(input: {
groupKey?: string
internalSku?: string
issueCategory?: string
queueBucket?: string
adminUserId: number | null
}): Promise<DeletePendingChannelGroupResult> {
const scope = resolvePendingGroupScopeInput(input)
@@ -1644,7 +1717,16 @@ export async function deletePendingChannelGroup(input: {
itemWhere.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`)
}
const issueCategory = String(input.issueCategory || '').trim()
if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) {
const queueBucket = normalizeQueueBucket(input.queueBucket)
if (queueBucket) {
itemParams.push(queueBucket)
itemWhere.push(`EXISTS (
SELECT 1
FROM v_fulfillment_queue state
WHERE state.order_item_id = oi.id
AND state.queue_bucket = $${itemParams.length}
)`)
} else if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) {
itemParams.push(issueCategory)
itemWhere.push(`EXISTS (
SELECT 1
@@ -1761,6 +1843,7 @@ export async function assignPendingChannelGroup(input: {
mappedInternalSku?: string
channel: string
issueCategory?: string
queueBucket?: string
adminUserId: number | null
note?: string | undefined
}): Promise<{
@@ -1777,6 +1860,13 @@ export async function assignPendingChannelGroup(input: {
errorCode: 'pending_group_key_required',
})
}
const queueBucket = normalizeQueueBucket(input.queueBucket)
if (queueBucket === 'terminal') {
throw createHttpError('售后或已终止订单不能重新指定履约渠道', {
statusCode: 409,
errorCode: 'terminal_fulfillment_bucket_not_assignable',
})
}
if (scope.type !== 'internal_sku') {
const mappedInternalSku = canonicalizeInternalSku(input.mappedInternalSku)
if (!mappedInternalSku || !containsCjk(mappedInternalSku)) {
@@ -1788,7 +1878,15 @@ export async function assignPendingChannelGroup(input: {
const updateParams: unknown[] = [scope.provider, scope.shopId, scope.skuCode, mappedInternalSku]
const issueCategory = String(input.issueCategory || '').trim()
let issueCategoryFilter = ''
if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) {
if (queueBucket) {
updateParams.push(queueBucket)
issueCategoryFilter = `AND EXISTS (
SELECT 1
FROM v_fulfillment_queue state
WHERE state.order_item_id = oi.id
AND state.queue_bucket = $${updateParams.length}
)`
} else if ((PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategory)) {
updateParams.push(issueCategory)
issueCategoryFilter = `AND EXISTS (
SELECT 1
@@ -1829,6 +1927,7 @@ export async function assignPendingChannelGroup(input: {
internalSku: mappedInternalSku,
channel: input.channel,
issueCategory: input.issueCategory || '',
queueBucket,
orderItemIds: updated.rows.map((row) => Number(row.id)),
adminUserId: input.adminUserId,
note: input.note,
@@ -1838,6 +1937,7 @@ export async function assignPendingChannelGroup(input: {
internalSku: scope.internalSku,
channel: input.channel,
issueCategory: input.issueCategory || '',
queueBucket,
adminUserId: input.adminUserId,
note: input.note,
})
@@ -1848,6 +1948,7 @@ export async function assignChannelByInternalSku(input: {
internalSku: string
channel: string
issueCategory?: string
queueBucket?: string
orderItemIds?: number[]
adminUserId: number | null
note?: string | undefined
@@ -1878,6 +1979,13 @@ export async function assignChannelByInternalSku(input: {
}
const issueCategory = String(input.issueCategory || '').trim()
const queueBucket = normalizeQueueBucket(input.queueBucket)
if (queueBucket === 'terminal') {
throw createHttpError('售后或已终止订单不能重新指定履约渠道', {
statusCode: 409,
errorCode: 'terminal_fulfillment_bucket_not_assignable',
})
}
const requestedOrderItemIds = [
...new Set(
(input.orderItemIds || []).map(Number).filter((id) => Number.isInteger(id) && id > 0),
@@ -1887,7 +1995,7 @@ export async function assignChannelByInternalSku(input: {
const idsResult = await query<{ id: number }>(
`
SELECT state.order_item_id AS id
FROM v_fulfillment_item_state state
FROM v_fulfillment_queue state
WHERE state.internal_sku = $1
AND (
state.fulfillment_status = 'pending'
@@ -1897,12 +2005,20 @@ export async function assignChannelByInternalSku(input: {
)
)
AND ($3::text = '' OR state.issue_category = $3)
AND ($6::text = '' OR state.queue_bucket = $6)
AND (
$4::boolean = FALSE
OR state.order_item_id = ANY($5::bigint[])
)
`,
[internalSku, input.channel, issueCategory, requestedOrderItemIds.length > 0, idParams],
[
internalSku,
input.channel,
issueCategory,
requestedOrderItemIds.length > 0,
idParams,
queueBucket,
],
)
const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel)
@@ -0,0 +1,188 @@
/**
* 履约待办分桶集成测试(需要真实 PostgreSQL)。
*
* 运行:
* FULFILLMENT_ROUTING_TEST_DATABASE_URL="$DATABASE_URL" \
* npx tsx --test src/services/fulfillment/fulfillment-queue.integration.test.ts
*
* 未设置环境变量时跳过,普通 npm test 不依赖数据库。
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(process.env.FULFILLMENT_ROUTING_TEST_DATABASE_URL || '').trim()
test(
'补载体扫描只处理 actionable,配置/人工处理/售后不占用扫描队列',
{ skip: !TEST_DATABASE_URL ? '未设置 FULFILLMENT_ROUTING_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const { listPendingChannelGroups } = await import('./channel-assign-service.js')
const { reconcilePendingCarriers } = await import('./carrier-reconciliation-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderIds: number[] = []
const internalSkuIds: number[] = []
async function createPendingItem(input: {
sku: string
channel: 'affiliate_dash' | 'manual' | 'third_party_voucher'
carrierIssue: string
orderStatus?: string
payStatus?: string
configJson?: Record<string, unknown>
}) {
const order = await query<{ id: number }>(
`INSERT INTO orders (
provider, platform, shop_id, shop_name, platform_order_id,
order_status, pay_status, total_amount, paid_at, created_at, updated_at
) VALUES (
'91kaquan', 'kuaishou', $1, '履约队列集成测试', $2,
$3, $4, 1000, NOW(), NOW(), NOW()
) RETURNING id`,
[
`it-queue-shop-${suffix}`,
`IT-QUEUE-${suffix}-${orderIds.length + 1}`,
input.orderStatus || 'paid',
input.payStatus || 'paid',
],
)
const orderId = Number(order.rows[0]!.id)
orderIds.push(orderId)
const item = await query<{ id: number }>(
`INSERT INTO order_items (
order_id, sku_code, sku_name, quantity, spec_json,
revenue_amount_fen, revenue_basis, created_at, updated_at
) VALUES ($1, $2, $2, 1, $3::jsonb, 1000, 'order_total', NOW(), NOW())
RETURNING id`,
[orderId, input.sku, JSON.stringify({ skuNick: input.sku })],
)
const orderItemId = Number(item.rows[0]!.id)
const sku = await query<{ id: string }>('SELECT ensure_canonical_internal_sku($1) AS id', [
input.sku,
])
const internalSkuId = Number(sku.rows[0]!.id)
internalSkuIds.push(internalSkuId)
await query(
`INSERT INTO sku_channel_routes (
internal_sku_id, channel, route_role, priority, enabled,
carrier_config_json, source
) VALUES ($1, $2, 'primary', 10, TRUE, $3::jsonb, 'integration_test')
ON CONFLICT (internal_sku_id, channel) DO UPDATE SET
route_role = 'primary',
priority = 10,
enabled = TRUE,
carrier_config_json = EXCLUDED.carrier_config_json,
source = 'integration_test',
updated_at = NOW()`,
[internalSkuId, input.channel, JSON.stringify(input.configJson || {})],
)
await query(
`UPDATE fulfillments
SET status = 'pending',
channel = NULL,
source = 'auto_mapping',
locked = FALSE,
pending_reason = 'no_carrier',
pending_detail = $2::jsonb,
updated_at = NOW()
WHERE order_item_id = $1`,
[
orderItemId,
JSON.stringify({
internalSku: input.sku,
channel: input.channel,
carrierIssue: input.carrierIssue,
}),
],
)
return orderItemId
}
try {
const actionableId = await createPendingItem({
sku: `集成测试队列自动-${suffix}`,
channel: 'third_party_voucher',
carrierIssue: 'carrier_not_attempted',
})
const configurationId = await createPendingItem({
sku: `集成测试队列配置-${suffix}`,
channel: 'manual',
carrierIssue: 'manual_config_missing',
})
const operatorReviewId = await createPendingItem({
sku: `集成测试队列人工-${suffix}`,
channel: 'affiliate_dash',
carrierIssue: 'carrier_task_retry_pending',
configJson: { affiliateSku: `it-queue-${suffix}` },
})
const terminalId = await createPendingItem({
sku: `集成测试队列售后-${suffix}`,
channel: 'affiliate_dash',
carrierIssue: 'carrier_not_attempted',
orderStatus: 'refunded',
payStatus: 'refunded',
})
const ids = [actionableId, configurationId, operatorReviewId, terminalId]
const states = await query<{
id: number
queue_bucket: string
actionable_for_carrier: boolean
}>(
`SELECT id, queue_bucket, actionable_for_carrier
FROM v_fulfillment_queue
WHERE id = ANY($1::bigint[])
ORDER BY id`,
[ids],
)
assert.deepEqual(
states.rows.map((row) => row.queue_bucket),
['actionable', 'configuration', 'operator_review', 'terminal'],
)
assert.deepEqual(
states.rows.map((row) => row.actionable_for_carrier),
[true, false, false, false],
)
const actionableGroups = await listPendingChannelGroups({
keyword: `集成测试队列自动-${suffix}`,
queueBucket: 'actionable',
page: 1,
pageSize: 10,
})
assert.equal(actionableGroups.total, 1)
assert.equal(actionableGroups.items[0]?.queue_buckets[0], 'actionable')
const configurationGroups = await listPendingChannelGroups({
keyword: `集成测试队列配置-${suffix}`,
queueBucket: 'configuration',
page: 1,
pageSize: 10,
})
assert.equal(configurationGroups.total, 1)
assert.deepEqual(configurationGroups.bucketCounts, {
actionable: 0,
configuration: 1,
operator_review: 0,
terminal: 0,
})
const reconciled = await reconcilePendingCarriers({
orderItemIds: ids,
limit: ids.length,
})
assert.equal(reconciled.scanned, 1)
} finally {
if (orderIds.length > 0) {
await query('DELETE FROM orders WHERE id = ANY($1::bigint[])', [orderIds])
}
if (internalSkuIds.length > 0) {
await query('DELETE FROM internal_skus WHERE id = ANY($1::bigint[])', [internalSkuIds])
}
await closeDb()
}
},
)
@@ -20,6 +20,10 @@ const currentLatestMigration = readFileSync(
new URL('../../db/migrations/145_fulfillment_current_attempt_latest.sql', import.meta.url),
'utf8',
)
const queueBucketMigration = readFileSync(
new URL('../../db/migrations/162_fulfillment_queue_buckets.sql', import.meta.url),
'utf8',
)
test('自动发货历史非活跃任务默认阻止后台自动重开', () => {
assert.ok(carrierCreation.includes('INACTIVE_CARRIER_TASK_STATUSES.includes'))
@@ -37,6 +41,19 @@ test('已分配但载体失效的订单会退回 pending/no_carrier', () => {
assert.ok(carrierReconciliation.includes("'USER_APPLY_REFUND', 'affiliate_dash_cancelled'"))
})
test('补载体自动扫描只读取 actionable 队列', () => {
assert.ok(queueBucketMigration.includes('v_fulfillment_queue'))
assert.ok(queueBucketMigration.includes("'actionable'"))
assert.ok(queueBucketMigration.includes("'configuration'"))
assert.ok(queueBucketMigration.includes("'operator_review'"))
assert.ok(queueBucketMigration.includes("'terminal'"))
assert.ok(queueBucketMigration.includes('actionable_for_carrier'))
assert.ok(carrierReconciliation.includes("oi.queue_bucket = 'actionable'"))
assert.ok(carrierReconciliation.includes('oi.carrier_queue_candidate = TRUE'))
assert.ok(carrierReconciliation.includes('createdBefore'))
assert.ok(carrierReconciliation.includes("orderDirection === 'desc'"))
})
test('整组补载体默认不包含锁定项,也不强制重开历史任务', () => {
assert.ok(channelAssign.includes('retryInactiveCarrier?: boolean'))
assert.ok(channelAssign.includes('includeLocked?: boolean'))
@@ -973,6 +973,7 @@ test(
includeAssignedMissingCarrier: true,
limit: 1,
})
assert.equal(blockedRetry.scanned, 0)
assert.equal(blockedRetry.affiliateTasksCreated, 0)
const retryPendingState = await query<{
fulfillment_status: string
@@ -991,17 +992,27 @@ test(
WHERE oi.id = $1`,
[retryPendingItemId],
)
assert.equal(retryPendingState.rows[0]?.fulfillment_status, 'pending')
assert.equal(retryPendingState.rows[0]?.fulfillment_channel, null)
assert.equal(retryPendingState.rows[0]?.fulfillment_pending_reason, 'no_carrier')
assert.equal(
retryPendingState.rows[0]?.fulfillment_pending_detail.carrierIssue,
'carrier_task_retry_pending',
)
assert.equal(retryPendingState.rows[0]?.fulfillment_status, 'assigned')
assert.equal(retryPendingState.rows[0]?.fulfillment_channel, 'affiliate_dash')
assert.equal(retryPendingState.rows[0]?.fulfillment_pending_reason, null)
assert.equal(retryPendingState.rows[0]?.task_count, 1)
await query(
`UPDATE fulfillments SET locked = TRUE, updated_at = NOW() WHERE order_item_id = $1`,
[retryPendingItemId],
`UPDATE fulfillments
SET status = 'pending',
channel = NULL,
pending_reason = 'no_carrier',
pending_detail = $2::jsonb,
locked = TRUE,
updated_at = NOW()
WHERE order_item_id = $1`,
[
retryPendingItemId,
JSON.stringify({
internalSku: retryPendingAffiliateSku,
channel: 'affiliate_dash',
carrierIssue: 'carrier_task_retry_pending',
}),
],
)
const unlockedPendingGroup = await unlockPendingChannelGroup({
internalSku: retryPendingAffiliateSku,
@@ -35,11 +35,13 @@ test('履约读模型按订单项拆分渠道、载体、尝试、异常和锁
})
test('待分类查询使用统一读模型并返回异常数量', () => {
assert.ok(channelAssign.includes('FROM v_fulfillment_item_state v'))
assert.ok(channelAssign.includes('FROM v_fulfillment_queue v'))
assert.ok(channelAssign.includes('issue_breakdown'))
assert.ok(channelAssign.includes('retryable_unlocked_item_count'))
assert.ok(channelAssign.includes('auto_retryable_unlocked_item_count'))
assert.ok(channelAssign.includes('PENDING_ISSUE_CATEGORIES'))
assert.ok(channelAssign.includes('FULFILLMENT_QUEUE_BUCKETS'))
assert.ok(channelAssign.includes('queueBucket'))
assert.ok(channelAssign.includes('issueCategoryInput'))
})
@@ -120,6 +120,8 @@ test('normalizeScheduledJobsConfig 收录 v2 六个管道作业并保持各自
const byType = new Map(config.jobs.map((item) => [item.type, item]))
assert.equal(byType.get('channel_assign')?.config.batchSize, 800)
assert.equal(byType.get('carrier_dispatch')?.enabled, false)
assert.equal(byType.get('carrier_dispatch')?.config.freshWindowMinutes, 15)
assert.equal(byType.get('carrier_dispatch')?.config.freshLimit, 200)
assert.equal(byType.get('sales_facts')?.config.days, 7)
assert.equal(byType.get('ledger_post')?.config.days, 7)
assert.ok(byType.has('product_normalize'))
@@ -271,6 +271,8 @@ function normalizeCarrierDispatchJob(rawValue: JsonObject) {
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
config: {
limit: normalizeRangeInteger(config.limit, 200, 10, 2000),
freshWindowMinutes: normalizeRangeInteger(config.freshWindowMinutes, 15, 1, 120),
freshLimit: normalizeRangeInteger(config.freshLimit, 200, 10, 2000),
},
}
}
@@ -371,7 +373,7 @@ function createDefaultCarrierDispatchJob() {
type: 'carrier_dispatch',
enabled: true,
intervalSeconds: 60,
config: { limit: 200 },
config: { limit: 200, freshWindowMinutes: 15, freshLimit: 200 },
}
}
@@ -95,16 +95,35 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
const config = jobConfig(job)
const cutoffCreatedAt = await getFulfillmentAutomationCutoff()
const limit = clampInt(config.limit, 200, 1, 2000)
const freshWindowMinutes = clampInt(config.freshWindowMinutes, 15, 1, 120)
const freshLimit = clampInt(config.freshLimit, 200, 1, 2000)
const freshCutoff = new Date(Date.now() - freshWindowMinutes * 60_000).toISOString()
const reconcileCursor =
config.carrierReconcileAfterId != null
? Math.max(0, Math.trunc(Number(config.carrierReconcileAfterId)))
: await readWatermarkItemId('carrier_reconcile')
const reconciled = await reconcilePendingCarriers({
limit: Math.min(limit, 200),
afterId: reconcileCursor,
const freshReconciled = await reconcilePendingCarriers({
limit: Math.min(freshLimit, 200),
createdAfter: freshCutoff,
orderDirection: 'desc',
...(cutoffCreatedAt ? { cutoffCreatedAt } : {}),
})
await touchWatermark('carrier_reconcile', { lastItemId: reconciled.nextCursor })
const historicalReconciled = await reconcilePendingCarriers({
limit: Math.min(limit, 200),
afterId: reconcileCursor,
createdBefore: freshCutoff,
...(cutoffCreatedAt ? { cutoffCreatedAt } : {}),
})
const reconciled = {
...historicalReconciled,
scanned: freshReconciled.scanned + historicalReconciled.scanned,
workOrdersCreated: freshReconciled.workOrdersCreated + historicalReconciled.workOrdersCreated,
affiliateTasksCreated:
freshReconciled.affiliateTasksCreated + historicalReconciled.affiliateTasksCreated,
fallbacksSelected: freshReconciled.fallbacksSelected + historicalReconciled.fallbacksSelected,
failed: freshReconciled.failed + historicalReconciled.failed,
}
await touchWatermark('carrier_reconcile', { lastItemId: historicalReconciled.nextCursor })
const repairedAssigned = await reconcilePendingCarriers({
limit: Math.min(limit, 200),
includeAssignedMissingCarrier: true,
@@ -386,6 +405,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
`人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` +
`缺履约载体 ${missingCarrier.rows.length},多载体冲突 ${conflictingCarriers.rows.length},异常收口 ${resolved},` +
`自动转人工 ${fallback.switched},本轮补载体扫描 ${reconciled.scanned}、创建 ${reconciled.workOrdersCreated + reconciled.affiliateTasksCreated}、失败 ${reconciled.failed},` +
`新单优先 ${freshReconciled.scanned},` +
`已分配补载体 ${repairedAssigned.workOrdersCreated + repairedAssigned.affiliateTasksCreated},` +
`兜底 ${reconciled.fallbacksSelected + repairedAssigned.fallbacksSelected}`,
attached,
@@ -395,6 +415,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
resolved,
affiliateFallback: fallback.switched,
carrierReconciliation: reconciled,
freshCarrierReconciliation: freshReconciled,
assignedCarrierRepair: repairedAssigned,
}
}