优化履约待办分桶与补载体扫描
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 履约渠道页:待分类队列 + 渠道规则 + 问题队列。
|
||||
* 履约渠道页:履约待办队列 + 渠道规则 + 问题队列。
|
||||
*
|
||||
* 设计口径(2026-10-31 v2):
|
||||
* · 渠道由 L1 规则决定,不再由「有没有任务」反推;
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
saveChannelRule,
|
||||
saveInternalSkuMapping,
|
||||
type ChannelRule,
|
||||
type FulfillmentQueueBucket,
|
||||
type PendingChannelGroup,
|
||||
} from '@/services/admin'
|
||||
import { formatRedeemAmount } from '@/utils/redeem'
|
||||
@@ -102,14 +103,14 @@ const ENABLED_FILTER_OPTIONS = [
|
||||
|
||||
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
|
||||
|
||||
const PENDING_ISSUE_CATEGORY_OPTIONS = [
|
||||
{ value: 'all', label: '全部问题' },
|
||||
{ value: 'classification', label: '未分类' },
|
||||
const PENDING_QUEUE_BUCKET_OPTIONS: Array<{
|
||||
value: FulfillmentQueueBucket
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'actionable', label: '自动处理中' },
|
||||
{ value: 'configuration', label: '配置待补' },
|
||||
{ value: 'carrier', label: '载体处理' },
|
||||
{ value: 'manual_review', label: '人工复核' },
|
||||
{ value: 'after_sales', label: '售后关闭' },
|
||||
{ value: 'system', label: '系统异常' },
|
||||
{ value: 'operator_review', label: '人工处理' },
|
||||
{ value: 'terminal', label: '售后/终止' },
|
||||
]
|
||||
|
||||
const INTERNAL_SKU_MAPPING_TYPES: Array<{
|
||||
@@ -130,7 +131,7 @@ export default function AdminFulfillmentChannelPage() {
|
||||
<Tabs
|
||||
defaultActiveKey="pending"
|
||||
items={[
|
||||
{ key: 'pending', label: '待分类队列', children: <PendingPanel /> },
|
||||
{ key: 'pending', label: '履约待办', children: <PendingPanel /> },
|
||||
{ key: 'mappings', label: 'SKU 映射', children: <InternalSkuMappingsPanel /> },
|
||||
{ key: 'rules', label: '渠道规则', children: <RulesPanel /> },
|
||||
{ key: 'exceptions', label: '问题队列', children: <ExceptionsPanel /> },
|
||||
@@ -146,15 +147,15 @@ function PendingPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [issueCategory, setIssueCategory] = useState('')
|
||||
const [queueBucket, setQueueBucket] = useState<FulfillmentQueueBucket>('actionable')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [selections, setSelections] = useState<Record<string, string>>({})
|
||||
const [internalSkuDrafts, setInternalSkuDrafts] = useState<Record<string, string>>({})
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['fulfillment-pending', keyword, issueCategory, page, pageSize],
|
||||
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword, issueCategory }),
|
||||
queryKey: ['fulfillment-pending', keyword, queueBucket, page, pageSize],
|
||||
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword, queueBucket }),
|
||||
})
|
||||
|
||||
const assignMutation = useMutation({
|
||||
@@ -166,7 +167,7 @@ function PendingPanel() {
|
||||
? ''
|
||||
: String(internalSkuDrafts[rowKey(row)] || '').trim(),
|
||||
channel: selections[rowKey(row)] || '',
|
||||
issueCategory,
|
||||
queueBucket,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const productMessage = result.data.draftProductId ? '和标准商品' : ''
|
||||
@@ -245,7 +246,7 @@ function PendingPanel() {
|
||||
unlockPendingChannelGroup({
|
||||
groupKey: row.group_key,
|
||||
internalSku: row.internal_sku || '',
|
||||
issueCategory,
|
||||
queueBucket,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
message.success(`已解除 ${result.data.updated} 个订单项的运营锁定`)
|
||||
@@ -256,7 +257,7 @@ function PendingPanel() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (row: PendingChannelGroup) =>
|
||||
deletePendingChannelGroup({ groupKey: row.group_key, issueCategory }),
|
||||
deletePendingChannelGroup({ groupKey: row.group_key, queueBucket }),
|
||||
onSuccess: (result) => {
|
||||
const data = result.data
|
||||
message.success(
|
||||
@@ -456,7 +457,8 @@ function PendingPanel() {
|
||||
issueBreakdown.length > 0 && issueBreakdown.every((issue) => issue.retryable)
|
||||
const hasOnlyAutoRetryableIssues =
|
||||
issueBreakdown.length > 0 && issueBreakdown.every((issue) => issue.auto_retryable)
|
||||
const canRunScopedActions = Boolean(issueCategory) || issueBreakdown.length <= 1
|
||||
const canRunScopedActions = issueBreakdown.length <= 1
|
||||
const canChangeChannel = queueBucket !== 'terminal'
|
||||
const hasUnlockedItems = row.locked_item_count < row.item_count
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
@@ -476,7 +478,8 @@ function PendingPanel() {
|
||||
disabled={
|
||||
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
|
||||
!selections[rowKey(row)] ||
|
||||
!canRunScopedActions
|
||||
!canRunScopedActions ||
|
||||
!canChangeChannel
|
||||
}
|
||||
loading={assignMutation.isPending}
|
||||
onClick={() => assignMutation.mutate(row)}
|
||||
@@ -607,8 +610,8 @@ function PendingPanel() {
|
||||
return (
|
||||
<Card
|
||||
title={`${
|
||||
PENDING_ISSUE_CATEGORY_OPTIONS.find((option) => option.value === (issueCategory || 'all'))
|
||||
?.label || '全部问题'
|
||||
PENDING_QUEUE_BUCKET_OPTIONS.find((option) => option.value === queueBucket)?.label ||
|
||||
'履约待办'
|
||||
}(${query.data?.data?.total ?? 0} 个 SKU)`}
|
||||
extra={
|
||||
<Space>
|
||||
@@ -645,15 +648,20 @@ function PendingPanel() {
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="指定渠道会先保存渠道和草稿标准商品;载体或成本未配置时会继续留在队列中。忽略归档只影响派生流程,不删除原始订单。"
|
||||
message="补载体自动扫描只处理“自动处理中”。缺配置、失败重试、人工复核、售后和已终止订单分别留在对应队列,不再占用自动扫描。指定渠道会先保存渠道和草稿标准商品;忽略归档只影响派生流程,不删除原始订单。"
|
||||
/>
|
||||
<Segmented
|
||||
block
|
||||
value={issueCategory || 'all'}
|
||||
options={PENDING_ISSUE_CATEGORY_OPTIONS}
|
||||
value={queueBucket}
|
||||
options={PENDING_QUEUE_BUCKET_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: `${option.label} ${query.data?.data?.bucketCounts?.[option.value] ?? 0}`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setIssueCategory(value === 'all' ? '' : String(value))
|
||||
setQueueBucket(String(value) as FulfillmentQueueBucket)
|
||||
setPage(1)
|
||||
setSelections({})
|
||||
setInternalSkuDrafts({})
|
||||
}}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { apiDelete, apiGet, apiPost } from '@/lib/http'
|
||||
|
||||
export type FulfillmentQueueBucket = 'actionable' | 'configuration' | 'operator_review' | 'terminal'
|
||||
|
||||
/** 待分类队列(按内部 SKU 聚合)。 */
|
||||
export type PendingChannelGroup = {
|
||||
group_key: string
|
||||
@@ -12,6 +14,7 @@ export type PendingChannelGroup = {
|
||||
sku_codes: string[]
|
||||
pending_reasons: string[]
|
||||
pending_subreasons: string[]
|
||||
queue_buckets: FulfillmentQueueBucket[]
|
||||
configured_channels: string[]
|
||||
locked_item_count: number
|
||||
item_count: number
|
||||
@@ -43,6 +46,7 @@ export type PendingChannelGroupListResult = {
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
bucketCounts: Record<FulfillmentQueueBucket, number>
|
||||
}
|
||||
|
||||
/** 渠道规则(L1)。 */
|
||||
@@ -230,6 +234,7 @@ export function fetchPendingChannelGroups(
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
issueCategory?: string
|
||||
queueBucket?: FulfillmentQueueBucket
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<PendingChannelGroupListResult>('/api/v1/admin/fulfillment/pending', params)
|
||||
@@ -302,11 +307,16 @@ export function unlockPendingChannelGroup(payload: {
|
||||
groupKey?: string
|
||||
internalSku?: string
|
||||
issueCategory?: string
|
||||
queueBucket?: FulfillmentQueueBucket
|
||||
}) {
|
||||
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/pending/unlock', payload)
|
||||
}
|
||||
|
||||
export function deletePendingChannelGroup(payload: { groupKey: string; issueCategory?: string }) {
|
||||
export function deletePendingChannelGroup(payload: {
|
||||
groupKey: string
|
||||
issueCategory?: string
|
||||
queueBucket?: FulfillmentQueueBucket
|
||||
}) {
|
||||
return apiPost<DeletePendingChannelGroupResult>(
|
||||
'/api/v1/admin/fulfillment/pending/delete',
|
||||
payload,
|
||||
@@ -319,6 +329,7 @@ export function assignChannelByInternalSku(payload: {
|
||||
mappedInternalSku?: string
|
||||
channel: string
|
||||
issueCategory?: string
|
||||
queueBucket?: FulfillmentQueueBucket
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{
|
||||
|
||||
@@ -7,7 +7,7 @@ const panel = readFileSync(
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('待分类队列按问题类型拆分动作', () => {
|
||||
test('履约待办按四个工作桶拆分动作', () => {
|
||||
assert.ok(panel.includes('去补配置'))
|
||||
assert.ok(panel.includes('处理人工复核'))
|
||||
assert.ok(panel.includes('确认重试'))
|
||||
@@ -15,9 +15,12 @@ test('待分类队列按问题类型拆分动作', () => {
|
||||
assert.ok(panel.includes('等待补配置'))
|
||||
assert.ok(panel.includes('用户已申请退款'))
|
||||
assert.ok(panel.includes('上游履约已取消'))
|
||||
assert.ok(panel.includes('PENDING_ISSUE_CATEGORY_OPTIONS'))
|
||||
assert.ok(panel.includes('PENDING_QUEUE_BUCKET_OPTIONS'))
|
||||
assert.ok(panel.includes("label: '自动处理中'"))
|
||||
assert.ok(panel.includes("label: '配置待补'"))
|
||||
assert.ok(panel.includes("label: '售后关闭'"))
|
||||
assert.ok(panel.includes("label: '人工处理'"))
|
||||
assert.ok(panel.includes("label: '售后/终止'"))
|
||||
assert.ok(panel.includes("queueBucket !== 'terminal'"))
|
||||
})
|
||||
|
||||
test('重开历史载体必须二次确认且不会处理锁定项', () => {
|
||||
|
||||
+9
-3
@@ -23,8 +23,13 @@ updated_at: 2026-11-08
|
||||
> `160_consignment_auto_match_setting.sql`、`161_semi_auto_fulfillment.sql`。
|
||||
> 在发布负责人确认目标提交并完成隔离恢复演练前,只作为开发基线,
|
||||
> 不得按旧的 `147–150` 一条路径直接上线。
|
||||
>
|
||||
> 2026-11-14 增量待发布基线:在 `161` 之上新增
|
||||
> `162_fulfillment_queue_buckets.sql`,建立履约待办四桶只读投影、
|
||||
> 补载体扫描专用索引和新单优先扫描口径。该迁移不改写原始订单、履约尝试或售后事实;
|
||||
> 仍须完成停写、最终备份、隔离恢复验证、迁移、健康检查和履约审计后才可恢复营业。
|
||||
|
||||
### 1.1 迁移 `151 → 161` 增量门禁
|
||||
### 1.1 迁移 `151 → 162` 增量门禁
|
||||
|
||||
- 升级前必须确认生产最高迁移仍为 `151`;分支合并后重新核对迁移号,禁止回退或改写 `151` 及以前文件。
|
||||
- 半自动迁移已收敛为单个 `161_semi_auto_fulfillment.sql`,不得在已应用 main `158–160`
|
||||
@@ -33,8 +38,9 @@ updated_at: 2026-11-08
|
||||
最终 Connector 来源约束和兼容保留的 `semi_auto.credential.export` 权限记录;
|
||||
该权限不再参与 CK 导出判定,导出由后台登录、禁止缓存和审计控制;
|
||||
不迁移或清理订单、退款、资金流水等 A 级事实。
|
||||
- 迁移完成后至少验证 `semi_auto_credentials` 表、任务外键和索引存在;
|
||||
用测试库完成 `001 → 161` 空库迁移,并在隔离库验证 `151 → 161`。
|
||||
- 迁移完成后至少验证 `semi_auto_credentials` 表、任务外键和索引存在,以及
|
||||
`v_fulfillment_queue`、补载体部分索引存在;
|
||||
用测试库完成 `001 → 162` 空库迁移,并在隔离库验证 `151 → 162`。
|
||||
- 该增量仍包含迁移,普通停机发布不能使用 `deploy/ubuntu-deploy.sh` 在旧后端运行时直接换库;
|
||||
必须沿用本手册的停写、最终备份、隔离恢复验证、迁移、新后端健康检查和验收流程。
|
||||
|
||||
|
||||
+29
-5
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
|
||||
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
|
||||
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
|
||||
status: authoritative
|
||||
version: 2.17
|
||||
version: 2.18
|
||||
updated_at: 2026-11-14
|
||||
---
|
||||
|
||||
@@ -12,7 +12,7 @@ updated_at: 2026-11-14
|
||||
> 状态:**唯一权威文档**。
|
||||
> 最后校准:2026-11-14。
|
||||
> 生产基线:迁移 `151`。
|
||||
> 开发基线:迁移 `161`(仅本地,尚未部署生产)。
|
||||
> 开发基线:迁移 `162`(仅本地,尚未部署生产)。
|
||||
>
|
||||
> 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现,
|
||||
> 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。
|
||||
@@ -269,7 +269,30 @@ fulfillment_issues
|
||||
|
||||
同一订单项只能有一个当前尝试。历史尝试只追加,不覆盖。
|
||||
|
||||
### 3.4.1 人工渠道半自动履约
|
||||
### 3.4.1 履约待办分桶与自动扫描
|
||||
|
||||
`v_fulfillment_queue` 是履约待办的唯一只读投影,在正式履约状态和结构化异常之上派生四个
|
||||
工作桶,不新增可写业务状态:
|
||||
|
||||
```text
|
||||
actionable 正常等待补载体,允许自动扫描
|
||||
configuration 缺内部 SKU、渠道、模板或人工配置
|
||||
operator_review 失败重试、人工复核、载体已关闭/取消或系统不一致
|
||||
terminal 已退款、退款申请、平台取消或商业订单已关闭
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 自动补载体扫描只允许读取 `v_fulfillment_queue.actionable_for_carrier = TRUE`。
|
||||
- `configuration`、`operator_review`、`terminal` 不得进入自动补载体 cursor,也不得阻塞新订单。
|
||||
- 后台履约待办必须读取同一 `v_fulfillment_queue`,四个页签分别展示对应桶。
|
||||
- 售后/终止桶不得重新指定履约渠道;配置桶只补配置,人工处理桶只由运营确认重试或复核。
|
||||
- 补载体每日扫描采用“最近窗口新单优先 + 历史 actionable cursor”两段式;新单不能排在
|
||||
配置缺失、退款、关闭工单等非可执行记录之后。
|
||||
- 分桶是读模型,不是新的核心状态;判断依据仍为 `orders`、`fulfillments`、
|
||||
`fulfillment_attempts`、`fulfillment_issues` 和 `sku_channel_routes`。
|
||||
|
||||
### 3.4.2 人工渠道半自动履约
|
||||
|
||||
半自动履约不是新的履约渠道,而是 `manual` 渠道下的辅助执行域:
|
||||
|
||||
@@ -980,10 +1003,11 @@ docs/archive/
|
||||
|
||||
## 十三、当前下一步
|
||||
|
||||
截至 2026-11-13,生产基线为 `151`,集成分支开发基线为 `161`。迁移 `152` 保护订单来源快照,
|
||||
截至 2026-11-14,生产基线为 `151`,集成分支开发基线为 `162`。迁移 `152` 保护订单来源快照,
|
||||
迁移 `158–160` 收口寄卖登记人同步、寄卖平台与自动匹配设置;
|
||||
迁移 `161` 一次建立人工渠道半自动任务、内置 Connector 默认值、账号级 Cookie JSON 凭证、
|
||||
任务凭证引用和最终约束。迁移中保留的 `semi_auto.credential.export` 权限仅作兼容记录,
|
||||
任务凭证引用和最终约束;迁移 `162` 建立履约待办四桶只读投影、补载体专用索引和新单优先扫描口径。
|
||||
迁移中保留的 `semi_auto.credential.export` 权限仅作兼容记录,
|
||||
不再作为 CK 导出门禁。
|
||||
|
||||
1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。
|
||||
|
||||
Reference in New Issue
Block a user