feat(fulfillment): 建立履约统一状态视图与分类队列

This commit is contained in:
yml2213
2026-11-05 20:13:34 +08:00
parent e8b76b3b73
commit ed450fac79
12 changed files with 1783 additions and 179 deletions
+1
View File
@@ -32,6 +32,7 @@
"test:industry:http": "tsx scripts/test-kuaishou-industry-http.ts",
"test:industry:gen": "tsx scripts/gen-test-cases.ts",
"test:industry:curl": "tsx scripts/curl-send-callback.ts",
"audit:fulfillment": "tsx scripts/audit-fulfillment-state.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "npm run format:check && npm run check:sql && npm run lint:check && npm run typecheck && npm test && npm run build",
"start": "node dist/index.js",
@@ -0,0 +1,140 @@
#!/usr/bin/env tsx
/**
* 履约状态只读审计。
*
* 用法:
* npm run audit:fulfillment
* npm run audit:fulfillment -- --strict
*
* --strict 会在关键异常大于 0 时返回失败,适合部署后和生产巡检。
*/
import { closeDb, query } from '../src/db/client.js'
const strict = process.argv.includes('--strict')
type CountRow = {
label: string
count: number
}
async function loadCounts(): Promise<CountRow[]> {
const result = await query<{
pending_items: number
classification: number
configuration: number
carrier: number
manual_review: number
after_sales_pending: number
state_inconsistent: number
assigned_missing_carrier: number
}>(
`
SELECT
COUNT(*) FILTER (WHERE issue_code <> '')::int AS pending_items,
COUNT(*) FILTER (WHERE issue_category = 'classification')::int
AS classification,
COUNT(*) FILTER (WHERE issue_category = 'configuration')::int
AS configuration,
COUNT(*) FILTER (WHERE issue_category = 'carrier')::int AS carrier,
COUNT(*) FILTER (WHERE issue_category = 'manual_review')::int
AS manual_review,
COUNT(*) FILTER (
WHERE issue_category = 'after_sales' AND fulfillment_status = 'pending'
)::int AS after_sales_pending,
COUNT(*) FILTER (WHERE issue_code = 'state_inconsistent')::int
AS state_inconsistent,
COUNT(*) FILTER (
WHERE fulfillment_status = 'assigned' AND carrier_status = 'missing'
)::int AS assigned_missing_carrier
FROM v_fulfillment_item_state
`,
)
const row = result.rows[0]
return [
{ label: '待处理订单项', count: Number(row?.pending_items || 0) },
{ label: '未分类', count: Number(row?.classification || 0) },
{ label: '配置待补', count: Number(row?.configuration || 0) },
{ label: '载体异常', count: Number(row?.carrier || 0) },
{ label: '人工复核', count: Number(row?.manual_review || 0) },
{ label: '售后关闭仍进入履约队列', count: Number(row?.after_sales_pending || 0) },
{ label: '状态不一致', count: Number(row?.state_inconsistent || 0) },
{ label: '已分配但无有效载体', count: Number(row?.assigned_missing_carrier || 0) },
]
}
async function loadIssueBreakdown(): Promise<CountRow[]> {
const result = await query<{ label: string; count: number }>(
`
SELECT issue_code AS label, COUNT(*)::int AS count
FROM v_fulfillment_item_state
WHERE issue_code <> ''
GROUP BY issue_code
ORDER BY count DESC, issue_code
`,
)
return result.rows
}
async function loadRecentDuplicateAttempts(): Promise<CountRow[]> {
const result = await query<{ label: string; count: number }>(
`
SELECT
'近 24 小时重复尝试订单项' AS label,
COUNT(*)::int AS count
FROM (
SELECT order_item_id, unit_index
FROM fulfillment_tasks
WHERE created_at >= NOW() - INTERVAL '24 hours'
GROUP BY order_item_id, unit_index
HAVING COUNT(*) > 1
) duplicate
`,
)
return result.rows
}
async function main(): Promise<void> {
const [counts, issues, duplicates] = await Promise.all([
loadCounts(),
loadIssueBreakdown(),
loadRecentDuplicateAttempts(),
])
console.info('[fulfillment-audit] 总览')
for (const row of counts) {
console.info(` ${row.label}: ${row.count}`)
}
console.info('[fulfillment-audit] 异常分布')
if (issues.length === 0) {
console.info(' 无')
} else {
for (const row of issues) {
console.info(` ${row.label}: ${row.count}`)
}
}
console.info('[fulfillment-audit] 尝试审计')
for (const row of duplicates) {
console.info(` ${row.label}: ${row.count}`)
}
const critical = new Map(counts.map((row) => [row.label, Number(row.count || 0)]))
const duplicateCount = Number(duplicates[0]?.count || 0)
const hasCritical =
(critical.get('状态不一致') || 0) > 0 ||
(critical.get('售后关闭仍进入履约队列') || 0) > 0 ||
(critical.get('已分配但无有效载体') || 0) > 0 ||
duplicateCount > 0
if (strict && hasCritical) {
process.exitCode = 1
console.error('[fulfillment-audit] strict 模式检测到关键异常')
}
}
try {
await main()
} finally {
await closeDb()
}
@@ -0,0 +1,358 @@
-- 137_fulfillment_item_state_view.sql
--
-- 履约统一读模型:
-- 1. 每个订单项只有一行当前履约状态;
-- 2. 渠道、载体、最新尝试、异常、锁定原因分别表达;
-- 3. 退款、取消、人工复核、配置缺失不再混写成“无履约载体”。
CREATE OR REPLACE VIEW v_fulfillment_item_state AS
WITH active_items AS (
SELECT
oi.*,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
FROM order_items oi
WHERE oi.fulfillment_status IN ('pending', 'assigned')
),
base AS (
SELECT
oi.id AS order_item_id,
oi.order_id,
o.id AS local_order_id,
o.provider,
o.platform,
o.shop_id,
o.shop_name,
o.platform_order_id,
o.pay_status,
o.order_status,
oi.fulfillment_status AS item_fulfillment_status,
oi.fulfillment_channel AS stored_channel,
oi.fulfillment_source,
oi.fulfillment_locked,
oi.fulfillment_pending_reason,
oi.fulfillment_pending_detail,
oi.sku_code,
oi.sku_name,
oi.quantity,
oi.revenue_amount_fen,
oi.created_at,
oi.updated_at,
oi.internal_sku,
channel_plan.channel AS configured_channel,
channel_plan.config_json AS configured_channel_config,
COALESCE(channel_stats.channel_count, 0) AS channel_count,
COALESCE(latest_task.task_id, 0) AS latest_task_id,
latest_task.executor_key AS latest_task_executor_key,
latest_task.task_status AS latest_task_status,
latest_task.result_code AS latest_task_result_code,
latest_task.result_message AS latest_task_result_message,
latest_task.updated_at AS latest_task_updated_at,
COALESCE(latest_work_order.work_order_id, 0) AS latest_work_order_id,
latest_work_order.status AS latest_work_order_status,
latest_work_order.result_code AS latest_work_order_result_code,
latest_work_order.updated_at AS latest_work_order_updated_at,
EXISTS (
SELECT 1
FROM fulfillment_tasks active_task
WHERE active_task.order_item_id = oi.id
AND active_task.executor_key IN ('affiliate_dash', 'manual_dispatch')
AND active_task.task_status NOT IN (
'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled'
)
) AS has_active_task,
EXISTS (
SELECT 1
FROM work_orders active_work_order
WHERE active_work_order.order_item_id = oi.id
AND active_work_order.status <> 'cancelled'
) AS has_active_work_order,
EXISTS (
SELECT 1
FROM manual_fulfillment_configs cfg
WHERE cfg.enabled = TRUE
AND canonicalize_internal_sku(cfg.sku_key) = oi.internal_sku
) AS has_manual_config
FROM active_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN LATERAL (
SELECT channel.channel, channel.config_json
FROM internal_sku_channels channel
WHERE channel.enabled = TRUE
AND canonicalize_internal_sku(channel.internal_sku) = oi.internal_sku
ORDER BY channel.is_fallback, channel.priority, channel.channel, channel.id
LIMIT 1
) channel_plan ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS channel_count
FROM internal_sku_channels channel
WHERE channel.enabled = TRUE
AND canonicalize_internal_sku(channel.internal_sku) = oi.internal_sku
) channel_stats ON TRUE
LEFT JOIN LATERAL (
SELECT
task.id AS task_id,
task.executor_key,
task.task_status,
task.result_code,
task.result_message,
task.updated_at
FROM fulfillment_tasks task
WHERE task.order_item_id = oi.id
ORDER BY task.id DESC
LIMIT 1
) latest_task ON TRUE
LEFT JOIN LATERAL (
SELECT
work_order.id AS work_order_id,
work_order.status,
''::text AS result_code,
work_order.updated_at
FROM work_orders work_order
WHERE work_order.order_item_id = oi.id
ORDER BY work_order.id DESC
LIMIT 1
) latest_work_order ON TRUE
),
classified AS (
SELECT
base.*,
COALESCE(base.stored_channel, base.configured_channel) AS intended_channel,
CASE
WHEN base.pay_status = 'refunded'
OR base.order_status IN ('refunded', 'cancelled')
OR base.latest_task_result_code = 'USER_APPLY_REFUND'
THEN TRUE
ELSE FALSE
END AS is_user_refund,
CASE
WHEN base.pay_status = 'cancelled'
OR base.order_status = 'cancelled'
OR base.latest_task_result_code = 'affiliate_dash_cancelled'
THEN TRUE
ELSE FALSE
END AS is_platform_cancelled
FROM base
),
current_attempt AS (
SELECT
classified.*,
CASE
WHEN classified.latest_task_id > 0
AND (
classified.latest_work_order_id = 0
OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at
)
THEN 'task'
WHEN classified.latest_work_order_id > 0 THEN 'work_order'
ELSE ''
END AS current_attempt_type,
CASE
WHEN classified.latest_task_id > 0
AND (
classified.latest_work_order_id = 0
OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at
)
THEN classified.latest_task_id
WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_id
ELSE 0
END AS current_attempt_id,
CASE
WHEN classified.latest_task_id > 0
AND (
classified.latest_work_order_id = 0
OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at
)
THEN classified.latest_task_status
WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_status
ELSE ''
END AS current_attempt_status,
CASE
WHEN classified.latest_task_id > 0
AND (
classified.latest_work_order_id = 0
OR classified.latest_task_updated_at >= classified.latest_work_order_updated_at
)
THEN classified.latest_task_result_code
WHEN classified.latest_work_order_id > 0 THEN classified.latest_work_order_result_code
ELSE ''
END AS current_attempt_result_code
FROM classified
),
state_classified AS (
SELECT
current_attempt.*,
CASE
WHEN current_attempt.intended_channel IN ('third_party_voucher', 'on_account')
THEN 'not_required'
WHEN current_attempt.intended_channel = 'manual'
AND current_attempt.has_manual_config
THEN 'ready'
WHEN current_attempt.has_active_task OR current_attempt.has_active_work_order
THEN 'ready'
WHEN current_attempt.latest_task_status IN (
'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled'
)
THEN 'terminal'
WHEN current_attempt.latest_work_order_status = 'cancelled'
THEN 'terminal'
WHEN current_attempt.intended_channel IS NOT NULL
THEN 'missing'
ELSE 'missing'
END AS carrier_status,
CASE
WHEN COALESCE(current_attempt.internal_sku, '') = ''
THEN 'missing_internal_sku'
WHEN current_attempt.channel_count = 0
THEN 'missing_channel'
ELSE 'configured'
END AS route_status
FROM current_attempt
),
issue_classified AS (
SELECT
state_classified.*,
CASE
WHEN state_classified.is_user_refund THEN 'user_refund'
WHEN state_classified.is_platform_cancelled THEN 'platform_cancelled'
WHEN state_classified.route_status = 'missing_internal_sku' THEN 'missing_internal_sku'
WHEN state_classified.route_status = 'missing_channel' THEN 'missing_channel'
WHEN state_classified.intended_channel = 'manual'
AND NOT state_classified.has_manual_config
THEN 'manual_config_missing'
WHEN state_classified.intended_channel = 'work_order'
AND BTRIM(COALESCE(
state_classified.configured_channel_config->>'workRuleKey',
''
)) = ''
THEN 'work_rule_missing'
WHEN state_classified.intended_channel = 'affiliate_dash'
AND BTRIM(COALESCE(
state_classified.configured_channel_config->>'affiliateSku',
''
)) = ''
THEN 'affiliate_mapping_missing'
WHEN state_classified.carrier_status IN ('ready', 'not_required') THEN ''
WHEN state_classified.latest_task_status = 'manual_review'
THEN 'carrier_manual_review'
WHEN state_classified.latest_task_status IN ('failed', 'retry_pending')
THEN 'carrier_retry_pending'
WHEN state_classified.latest_task_status = 'expired' THEN 'carrier_expired'
WHEN state_classified.latest_task_status = 'closed'
OR state_classified.latest_work_order_status = 'cancelled'
THEN 'carrier_closed'
WHEN state_classified.item_fulfillment_status = 'assigned'
THEN 'state_inconsistent'
WHEN state_classified.intended_channel IN (
'affiliate_dash', 'work_order', 'manual'
)
THEN 'carrier_missing'
ELSE 'state_inconsistent'
END AS issue_code
FROM state_classified
),
categorized AS (
SELECT
issue_classified.*,
CASE
WHEN issue_code = '' THEN ''
WHEN issue_code IN ('user_refund', 'platform_cancelled') THEN 'after_sales'
WHEN issue_code = 'carrier_manual_review' THEN 'manual_review'
WHEN issue_code IN ('missing_internal_sku', 'missing_channel') THEN 'classification'
WHEN issue_code IN (
'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing'
) THEN 'configuration'
WHEN issue_code = 'state_inconsistent' THEN 'system'
ELSE 'carrier'
END AS issue_category
FROM issue_classified
)
SELECT
order_item_id,
order_id,
local_order_id,
provider,
platform,
shop_id,
shop_name,
platform_order_id,
pay_status,
order_status,
item_fulfillment_status AS fulfillment_status,
internal_sku,
intended_channel,
stored_channel,
configured_channel,
route_status,
carrier_status,
current_attempt_type,
current_attempt_id,
current_attempt_status,
current_attempt_result_code,
latest_task_id,
latest_task_executor_key,
latest_task_status,
latest_task_result_code,
latest_work_order_id,
latest_work_order_status,
issue_code,
issue_category,
CASE
WHEN issue_code = '' THEN FALSE
WHEN issue_code IN (
'user_refund', 'platform_cancelled', 'carrier_manual_review',
'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing',
'missing_internal_sku', 'missing_channel'
) THEN FALSE
ELSE TRUE
END AS retryable,
CASE
WHEN issue_code IN ('carrier_missing', 'state_inconsistent') THEN TRUE
ELSE FALSE
END AS auto_retryable,
CASE
WHEN issue_code IN ('user_refund', 'platform_cancelled', 'carrier_manual_review')
THEN 'support'
WHEN issue_code IN (
'missing_internal_sku', 'missing_channel', 'manual_config_missing',
'work_rule_missing', 'affiliate_mapping_missing'
) THEN 'operator'
WHEN issue_code = '' THEN ''
ELSE 'system'
END AS owner_role,
fulfillment_locked,
CASE
WHEN NOT fulfillment_locked THEN ''
ELSE COALESCE(
NULLIF(fulfillment_pending_detail->>'carrierIssue', ''),
NULLIF(fulfillment_pending_detail->>'reason', ''),
'operator_assigned'
)
END AS lock_reason,
fulfillment_pending_reason,
fulfillment_pending_detail,
fulfillment_source,
sku_code,
sku_name,
quantity,
revenue_amount_fen,
created_at,
updated_at,
GREATEST(
0,
FLOOR(EXTRACT(EPOCH FROM (NOW() - created_at)) / 60)
)::bigint AS age_minutes
FROM categorized;
COMMENT ON VIEW v_fulfillment_item_state IS
'履约统一读模型:每个订单项一行,拆分渠道、载体、当前尝试、结构化异常与锁定原因';
CREATE INDEX IF NOT EXISTS idx_order_items_active_fulfillment_state
ON order_items (id)
WHERE fulfillment_status IN ('pending', 'assigned');
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_order_item_latest
ON fulfillment_tasks (order_item_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_work_orders_order_item_latest
ON work_orders (order_item_id, id DESC)
WHERE order_item_id IS NOT NULL;
@@ -139,6 +139,7 @@ router.get(
page: Number(req.query.page),
pageSize: Number(req.query.pageSize),
keyword: String(req.query.keyword || ''),
issueCategory: String(req.query.issueCategory || ''),
}),
{
successMessage: 'ok',
@@ -156,6 +157,7 @@ router.post(
deletePendingChannelGroup({
groupKey: String(bodyOf(req).groupKey || ''),
internalSku: String(bodyOf(req).internalSku || ''),
issueCategory: String(bodyOf(req).issueCategory || ''),
adminUserId: req.adminSession?.userId ?? null,
}),
{
@@ -206,6 +208,9 @@ router.post(
internalSku: String(bodyOf(req).internalSku || ''),
retryInactiveCarrier: bodyOf(req).retryInactiveCarrier === true,
includeLocked: bodyOf(req).includeLocked === true,
issueCodes: Array.isArray(bodyOf(req).issueCodes)
? bodyOf(req).issueCodes.map((value: unknown) => String(value || ''))
: [],
}),
{
successMessage: '履约载体重试完成',
@@ -230,6 +235,7 @@ router.post(
return unlockPendingChannelGroup({
groupKey: String(body.groupKey || ''),
internalSku: String(body.internalSku || ''),
issueCategory: String(body.issueCategory || ''),
})
},
{
@@ -345,6 +351,7 @@ router.post(
internalSku: String(body.internalSku || ''),
mappedInternalSku: String(body.mappedInternalSku || ''),
channel,
issueCategory: String(body.issueCategory || ''),
adminUserId,
note,
})
@@ -1016,6 +1016,24 @@ export type PendingGroupRow = {
revenue_fen: number
first_seen_at: string
last_seen_at: string
issue_breakdown: Array<{
issue_code: string
issue_category: string
item_count: number
locked_item_count: number
retryable: boolean
auto_retryable: boolean
owner_role: string
}>
retryable_item_count: number
retryable_unlocked_item_count: number
auto_retryable_item_count: number
auto_retryable_unlocked_item_count: number
config_issue_item_count: number
manual_review_item_count: number
after_sales_item_count: number
system_issue_item_count: number
oldest_age_minutes: number
}
type PendingShopNameRow = {
@@ -1042,21 +1060,64 @@ const PENDING_GROUP_KEY_SQL = `
END
`
const PENDING_STATE_GROUP_KEY_SQL = `
CASE
WHEN COALESCE(BTRIM(v.internal_sku), '') <> ''
THEN 'sku:' || encode(convert_to(v.internal_sku, 'UTF8'), 'hex')
ELSE 'source:' || encode(
convert_to(
CONCAT_WS(CHR(31), v.provider, v.shop_id, v.sku_code),
'UTF8'
),
'hex'
)
END
`
const RETRYABLE_CARRIER_ISSUES = [
'carrier_closed',
'carrier_retry_pending',
'carrier_expired',
'carrier_missing',
'state_inconsistent',
] as const
const AUTO_RETRYABLE_CARRIER_ISSUES = ['carrier_missing', 'state_inconsistent'] as const
const PENDING_ISSUE_CATEGORIES = [
'classification',
'configuration',
'carrier',
'manual_review',
'after_sales',
'system',
] as const
/** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */
export async function listPendingChannelGroups(input: {
page?: number
pageSize?: number
keyword?: string
issueCategory?: string
}): Promise<{ items: PendingGroupRow[]; total: number; page: number; pageSize: 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 issueCategoryInput = String(input.issueCategory || '').trim()
const issueCategory = (PENDING_ISSUE_CATEGORIES as readonly string[]).includes(issueCategoryInput)
? issueCategoryInput
: ''
const params: unknown[] = []
const clauses = [`oi.fulfillment_status = 'pending'`]
const clauses = [`v.fulfillment_status = 'pending'`]
if (issueCategory) {
params.push(issueCategory)
clauses.push(`v.issue_category = $${params.length}`)
}
if (keyword) {
params.push(`%${keyword}%`)
clauses.push(
`(${INTERNAL_SKU_SQL} ILIKE $${params.length} OR oi.sku_code ILIKE $${params.length} OR oi.sku_name ILIKE $${params.length} OR oi.manual_sku_key ILIKE $${params.length})`,
`(v.internal_sku ILIKE $${params.length}
OR v.sku_code ILIKE $${params.length}
OR v.sku_name ILIKE $${params.length})`,
)
}
const where = clauses.join(' AND ')
@@ -1065,10 +1126,9 @@ export async function listPendingChannelGroups(input: {
`
WITH pending_items AS (
SELECT
${INTERNAL_SKU_SQL} AS internal_sku,
${PENDING_GROUP_KEY_SQL} AS group_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
v.internal_sku,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_item_state v
WHERE ${where}
)
SELECT COUNT(DISTINCT group_key)::int AS count
@@ -1082,57 +1142,124 @@ export async function listPendingChannelGroups(input: {
`
WITH pending_items AS (
SELECT
oi.id,
oi.order_id,
oi.sku_code,
oi.sku_name,
oi.fulfillment_locked,
oi.fulfillment_pending_reason,
oi.fulfillment_pending_detail,
oi.revenue_amount_fen,
oi.created_at,
o.provider,
o.shop_id,
${INTERNAL_SKU_SQL} AS internal_sku,
${PENDING_GROUP_KEY_SQL} AS group_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
v.*,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_item_state v
WHERE ${where}
),
grouped AS (
SELECT
group_key,
internal_sku,
MIN(sku_name) AS sku_name,
array_agg(DISTINCT provider) AS providers,
array_agg(DISTINCT shop_id) AS shop_ids,
array_agg(DISTINCT sku_code) AS sku_codes,
array_remove(
array_agg(
DISTINCT CASE
WHEN issue_code = 'missing_internal_sku' THEN 'no_internal_sku'
WHEN issue_code = 'missing_channel' THEN 'no_channel_capability'
WHEN issue_code = '' THEN NULL
ELSE 'no_carrier'
END
),
NULL
) AS pending_reasons,
array_remove(array_agg(DISTINCT issue_code), '') AS pending_subreasons,
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,
MIN(created_at) AS first_seen_at,
MAX(created_at) AS last_seen_at,
COALESCE(MAX(age_minutes), 0)::bigint AS oldest_age_minutes,
COUNT(*) FILTER (WHERE retryable)::int AS retryable_item_count,
COUNT(*) FILTER (WHERE retryable AND NOT fulfillment_locked)::int
AS retryable_unlocked_item_count,
COUNT(*) FILTER (WHERE auto_retryable)::int AS auto_retryable_item_count,
COUNT(*) FILTER (WHERE auto_retryable AND NOT fulfillment_locked)::int
AS auto_retryable_unlocked_item_count,
COUNT(*) FILTER (WHERE issue_category = 'configuration')::int
AS config_issue_item_count,
COUNT(*) FILTER (WHERE issue_category = 'manual_review')::int
AS manual_review_item_count,
COUNT(*) FILTER (WHERE issue_category = 'after_sales')::int
AS after_sales_item_count,
COUNT(*) FILTER (WHERE issue_category = 'system')::int
AS system_issue_item_count
FROM pending_items
GROUP BY group_key, internal_sku
),
issue_counts AS (
SELECT
group_key,
issue_code,
issue_category,
bool_or(retryable) AS retryable,
bool_or(auto_retryable) AS auto_retryable,
MIN(owner_role) AS owner_role,
COUNT(*)::int AS item_count,
COUNT(*) FILTER (WHERE fulfillment_locked)::int AS locked_item_count
FROM pending_items
GROUP BY group_key, issue_code, issue_category
),
issue_summary AS (
SELECT
group_key,
jsonb_agg(
jsonb_build_object(
'issue_code', issue_code,
'issue_category', issue_category,
'item_count', item_count,
'locked_item_count', locked_item_count,
'retryable', retryable,
'auto_retryable', auto_retryable,
'owner_role', owner_role
)
ORDER BY item_count DESC, issue_code
) AS issue_breakdown
FROM issue_counts
GROUP BY group_key
)
SELECT
pi.group_key,
CASE WHEN pi.internal_sku <> '' THEN 'internal_sku' ELSE 'source_sku' END AS scope_type,
pi.internal_sku,
MIN(pi.sku_name) AS sku_name,
array_agg(DISTINCT pi.provider) AS providers,
array_agg(DISTINCT pi.shop_id) AS shop_ids,
array_agg(DISTINCT pi.sku_code) AS sku_codes,
array_remove(array_agg(DISTINCT pi.fulfillment_pending_reason), NULL) AS pending_reasons,
array_remove(
array_agg(
DISTINCT COALESCE(
pi.fulfillment_pending_detail->>'carrierIssue',
pi.fulfillment_pending_detail->>'reason'
)
),
NULL
) AS pending_subreasons,
grouped.group_key,
CASE
WHEN grouped.internal_sku <> '' THEN 'internal_sku'
ELSE 'source_sku'
END AS scope_type,
grouped.internal_sku,
grouped.sku_name,
grouped.providers,
grouped.shop_ids,
grouped.sku_codes,
grouped.pending_reasons,
grouped.pending_subreasons,
COALESCE(cfg.channels, ARRAY[]::text[]) AS configured_channels,
COUNT(*) FILTER (WHERE pi.fulfillment_locked)::int AS locked_item_count,
COUNT(*)::int AS item_count,
COALESCE(SUM(pi.revenue_amount_fen), 0)::bigint AS revenue_fen,
MIN(pi.created_at) AS first_seen_at,
MAX(pi.created_at) AS last_seen_at
FROM pending_items pi
grouped.locked_item_count,
grouped.item_count,
grouped.revenue_fen,
grouped.first_seen_at,
grouped.last_seen_at,
grouped.oldest_age_minutes,
grouped.retryable_item_count,
grouped.retryable_unlocked_item_count,
grouped.auto_retryable_item_count,
grouped.auto_retryable_unlocked_item_count,
grouped.config_issue_item_count,
grouped.manual_review_item_count,
grouped.after_sales_item_count,
grouped.system_issue_item_count,
COALESCE(issue_summary.issue_breakdown, '[]'::jsonb) AS issue_breakdown
FROM grouped
LEFT JOIN issue_summary ON issue_summary.group_key = grouped.group_key
LEFT JOIN LATERAL (
SELECT array_agg(DISTINCT channel.channel ORDER BY channel.channel) AS channels
FROM internal_sku_channels channel
WHERE channel.enabled = TRUE
AND canonicalize_internal_sku(channel.internal_sku) =
pi.internal_sku
grouped.internal_sku
) cfg ON TRUE
GROUP BY pi.group_key, pi.internal_sku, cfg.channels
ORDER BY item_count DESC, internal_sku
ORDER BY grouped.item_count DESC, grouped.internal_sku
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
@@ -1145,16 +1272,13 @@ export async function listPendingChannelGroups(input: {
`
WITH pending_items AS (
SELECT
oi.id,
o.provider,
o.shop_id,
o.shop_name,
oi.sku_code,
${INTERNAL_SKU_SQL} AS internal_sku,
${PENDING_GROUP_KEY_SQL} AS group_key
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
v.provider,
v.shop_id,
v.shop_name,
${PENDING_STATE_GROUP_KEY_SQL} AS group_key
FROM v_fulfillment_item_state v
WHERE v.fulfillment_status = 'pending'
AND ($2::text = '' OR v.issue_category = $2)
)
SELECT
group_key,
@@ -1179,7 +1303,7 @@ export async function listPendingChannelGroups(input: {
usage_count DESC,
shop_name
`,
[groupKeys],
[groupKeys, issueCategory],
)
const seen = new Set<string>()
@@ -1225,6 +1349,7 @@ export async function retryPendingCarrierGroup(input: {
internalSku: string
retryInactiveCarrier?: boolean
includeLocked?: boolean
issueCodes?: string[]
}): Promise<RetryPendingCarrierGroupResult> {
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku || !containsCjk(internalSku)) {
@@ -1234,13 +1359,37 @@ export async function retryPendingCarrierGroup(input: {
})
}
const requestedIssueCodes = [
...new Set(
(input.issueCodes || [])
.map((value) => String(value || '').trim())
.filter((value): value is (typeof RETRYABLE_CARRIER_ISSUES)[number] =>
(RETRYABLE_CARRIER_ISSUES as readonly string[]).includes(value),
),
),
]
if ((input.issueCodes || []).length > 0 && requestedIssueCodes.length === 0) {
throw createHttpError('所选问题不允许重试,请刷新后重试', {
statusCode: 409,
errorCode: 'fulfillment_issue_not_retryable',
})
}
const issueCodes =
requestedIssueCodes.length > 0
? requestedIssueCodes
: input.retryInactiveCarrier === true
? [...RETRYABLE_CARRIER_ISSUES]
: [...AUTO_RETRYABLE_CARRIER_ISSUES]
const pending = await query<{ id: number }>(
`SELECT oi.id
FROM order_items oi
`SELECT oi.order_item_id AS id
FROM v_fulfillment_item_state oi
WHERE oi.fulfillment_status = 'pending'
AND ${INTERNAL_SKU_SQL} = $1
ORDER BY oi.id`,
[internalSku],
AND oi.internal_sku = $1
AND oi.issue_code = ANY($2::text[])
AND ($3::boolean OR NOT oi.fulfillment_locked)
ORDER BY oi.order_item_id`,
[internalSku, issueCodes, input.includeLocked === true],
)
const ids = pending.rows.map((row) => Number(row.id))
let workOrdersCreated = 0
@@ -1313,6 +1462,7 @@ export async function retryPendingCarrierGroup(input: {
export async function unlockPendingChannelGroup(input: {
groupKey?: string
internalSku?: string
issueCategory?: string
}): Promise<{ updated: number }> {
const scope = resolvePendingGroupScopeInput(input)
if (!scope) {
@@ -1323,20 +1473,25 @@ export async function unlockPendingChannelGroup(input: {
}
const params: unknown[] = []
const where = ["oi.fulfillment_status = 'pending'", 'oi.fulfillment_locked = TRUE']
const where = ["state.fulfillment_status = 'pending'", 'state.fulfillment_locked = TRUE']
if (scope.type === 'internal_sku') {
params.push(scope.internalSku)
where.push(`${INTERNAL_SKU_SQL} = $${params.length}`)
where.push(`state.internal_sku = $${params.length}`)
} else {
params.push(scope.provider, scope.shopId, scope.skuCode)
where.push(`o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3`)
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)) {
params.push(issueCategory)
where.push(`state.issue_category = $${params.length}`)
}
const result = await query(
`UPDATE order_items oi
`UPDATE order_items item
SET fulfillment_locked = FALSE,
updated_at = NOW()
FROM orders o
WHERE o.id = oi.order_id
FROM v_fulfillment_item_state state
WHERE item.id = state.order_item_id
AND ${where.join(' AND ')}`,
params,
)
@@ -1393,6 +1548,7 @@ function resolvePendingGroupScopeInput(input: {
export async function deletePendingChannelGroup(input: {
groupKey?: string
internalSku?: string
issueCategory?: string
adminUserId: number | null
}): Promise<DeletePendingChannelGroupResult> {
const scope = resolvePendingGroupScopeInput(input)
@@ -1413,6 +1569,16 @@ export async function deletePendingChannelGroup(input: {
itemParams.push(scope.provider, scope.shopId, scope.skuCode)
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)) {
itemParams.push(issueCategory)
itemWhere.push(`EXISTS (
SELECT 1
FROM v_fulfillment_item_state state
WHERE state.order_item_id = oi.id
AND state.issue_category = $${itemParams.length}
)`)
}
const items = await client.query<{ id: number; order_id: number; basis_date: string }>(
`
SELECT
@@ -1520,6 +1686,7 @@ export async function assignPendingChannelGroup(input: {
internalSku?: string
mappedInternalSku?: string
channel: string
issueCategory?: string
adminUserId: number | null
note?: string | undefined
}): Promise<{
@@ -1544,7 +1711,19 @@ export async function assignPendingChannelGroup(input: {
errorCode: 'internal_sku_mapping_required',
})
}
const updated = await query(
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)) {
updateParams.push(issueCategory)
issueCategoryFilter = `AND EXISTS (
SELECT 1
FROM v_fulfillment_item_state state
WHERE state.order_item_id = oi.id
AND state.issue_category = $${updateParams.length}
)`
}
const updated = await query<{ id: number }>(
`
UPDATE order_items oi
SET spec_json = COALESCE(oi.spec_json, '{}'::jsonb)
@@ -1556,8 +1735,10 @@ export async function assignPendingChannelGroup(input: {
AND o.shop_id = $2
AND oi.sku_code = $3
AND oi.fulfillment_status = 'pending'
${issueCategoryFilter}
RETURNING oi.id
`,
[scope.provider, scope.shopId, scope.skuCode, mappedInternalSku],
updateParams,
)
if ((updated.rowCount || 0) === 0) {
throw createHttpError('待分类来源 SKU 已发生变化,请刷新后重试', {
@@ -1568,6 +1749,8 @@ export async function assignPendingChannelGroup(input: {
return assignChannelByInternalSku({
internalSku: mappedInternalSku,
channel: input.channel,
issueCategory: input.issueCategory || '',
orderItemIds: updated.rows.map((row) => Number(row.id)),
adminUserId: input.adminUserId,
note: input.note,
})
@@ -1575,6 +1758,7 @@ export async function assignPendingChannelGroup(input: {
return assignChannelByInternalSku({
internalSku: scope.internalSku,
channel: input.channel,
issueCategory: input.issueCategory || '',
adminUserId: input.adminUserId,
note: input.note,
})
@@ -1584,6 +1768,8 @@ export async function assignPendingChannelGroup(input: {
export async function assignChannelByInternalSku(input: {
internalSku: string
channel: string
issueCategory?: string
orderItemIds?: number[]
adminUserId: number | null
note?: string | undefined
createRule?: boolean | undefined
@@ -1612,19 +1798,32 @@ export async function assignChannelByInternalSku(input: {
})
}
const issueCategory = String(input.issueCategory || '').trim()
const requestedOrderItemIds = [
...new Set(
(input.orderItemIds || []).map(Number).filter((id) => Number.isInteger(id) && id > 0),
),
]
const idParams = [...requestedOrderItemIds]
const idsResult = await query<{ id: number }>(
`
SELECT oi.id
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = oi.order_id
AND ${INTERNAL_SKU_SQL} = $1
SELECT state.order_item_id AS id
FROM v_fulfillment_item_state state
WHERE state.internal_sku = $1
AND (
oi.fulfillment_status = 'pending'
OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $2)
state.fulfillment_status = 'pending'
OR (
state.fulfillment_status = 'assigned'
AND state.stored_channel = $2
)
)
AND ($3::text = '' OR state.issue_category = $3)
AND (
$4::boolean = FALSE
OR state.order_item_id = ANY($5::bigint[])
)
`,
[internalSku, input.channel],
[internalSku, input.channel, issueCategory, requestedOrderItemIds.length > 0, idParams],
)
const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel)
@@ -430,6 +430,24 @@ test(
{ id: crossShopB, name: '测试店铺 B' },
],
)
const pendingClassification = await listPendingChannelGroups({
keyword: crossShopSku,
issueCategory: 'classification',
page: 1,
pageSize: 10,
})
assert.equal(pendingClassification.total, 1)
assert.equal(
pendingClassification.items[0]?.issue_breakdown[0]?.issue_code,
'missing_channel',
)
const pendingAfterSales = await listPendingChannelGroups({
keyword: crossShopSku,
issueCategory: 'after_sales',
page: 1,
pageSize: 10,
})
assert.equal(pendingAfterSales.total, 0)
const deletedPending = await deletePendingChannelGroup({
internalSku: crossShopSku,
@@ -897,14 +915,18 @@ test(
WHERE id = $1`,
[retryPendingItemId],
)
const protectedRetry = await retryPendingCarrierGroup({
internalSku: retryPendingAffiliateSku,
retryInactiveCarrier: true,
})
assert.equal(protectedRetry.affiliateTasksCreated, 0)
await assert.rejects(
() =>
retryPendingCarrierGroup({
internalSku: retryPendingAffiliateSku,
retryInactiveCarrier: true,
issueCodes: ['user_refund'],
}),
/不允许重试/,
)
const protectedRetryState = await query<{
fulfillment_status: string
fulfillment_pending_detail: Record<string, unknown>
fulfillment_pending_detail: Record<string, unknown> | null
task_count: number
}>(
`SELECT
@@ -916,10 +938,7 @@ test(
[retryPendingItemId],
)
assert.equal(protectedRetryState.rows[0]?.fulfillment_status, 'pending')
assert.equal(
protectedRetryState.rows[0]?.fulfillment_pending_detail.carrierIssue,
'carrier_task_user_refund',
)
assert.equal(protectedRetryState.rows[0]?.fulfillment_pending_detail, null)
assert.equal(protectedRetryState.rows[0]?.task_count, 1)
const voucherJobPrefix = `IT-VOUCHER-LIST-${suffix}`
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const migration = readFileSync(
new URL('../../db/migrations/137_fulfillment_item_state_view.sql', import.meta.url),
'utf8',
)
const channelAssign = readFileSync(new URL('./channel-assign-service.ts', import.meta.url), 'utf8')
const auditScript = readFileSync(
new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url),
'utf8',
)
test('履约读模型按订单项拆分渠道、载体、尝试、异常和锁定原因', () => {
assert.ok(migration.includes('CREATE OR REPLACE VIEW v_fulfillment_item_state'))
assert.ok(migration.includes('current_attempt_id'))
assert.ok(migration.includes('carrier_status'))
assert.ok(migration.includes('issue_code'))
assert.ok(migration.includes("'after_sales'"))
assert.ok(migration.includes("'manual_review'"))
assert.ok(migration.includes("fulfillment_status IN ('pending', 'assigned')"))
})
test('待分类查询使用统一读模型并返回异常数量', () => {
assert.ok(channelAssign.includes('FROM v_fulfillment_item_state 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('issueCategoryInput'))
})
test('重试必须按可重试 issueCode 限定,不能按整个 SKU 处理售后和复核项', () => {
assert.ok(channelAssign.includes('RETRYABLE_CARRIER_ISSUES'))
assert.ok(channelAssign.includes('issueCodes?: string[]'))
assert.ok(channelAssign.includes('oi.issue_code = ANY($2::text[])'))
})
test('保存渠道、解除锁定、归档和重试都按当前问题分类限定作用域', () => {
assert.ok(channelAssign.includes('issueCategory?: string'))
assert.ok(channelAssign.includes('state.issue_category = $'))
assert.ok(channelAssign.includes('issueCodes?: string[]'))
assert.ok(channelAssign.includes('orderItemIds?: number[]'))
})
test('履约审计脚本覆盖状态不一致、售后队列和重复尝试', () => {
assert.ok(auditScript.includes('已分配但无有效载体'))
assert.ok(auditScript.includes('售后关闭仍进入履约队列'))
assert.ok(auditScript.includes('近 24 小时重复尝试订单项'))
assert.ok(auditScript.includes('--strict'))
})
@@ -22,6 +22,7 @@ import {
Input,
InputNumber,
Modal,
Segmented,
Select,
Space,
Switch,
@@ -99,26 +100,18 @@ const ENABLED_FILTER_OPTIONS = [
{ value: 'false', label: '停用' },
]
const PENDING_CONFIG_REASONS = new Set([
'manual_config_missing',
'work_rule_missing',
'affiliate_mapping_missing',
'carrier_config_ambiguous',
])
const PENDING_RETRYABLE_TERMINAL_REASONS = new Set([
'carrier_task_closed',
'carrier_task_retry_pending',
'carrier_task_expired',
])
const PENDING_NON_RETRYABLE_TERMINAL_REASONS = new Set([
'carrier_task_user_refund',
'carrier_task_cancelled',
])
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
const PENDING_ISSUE_CATEGORY_OPTIONS = [
{ value: 'all', label: '全部问题' },
{ value: 'classification', label: '未分类' },
{ value: 'configuration', label: '配置待补' },
{ value: 'carrier', label: '载体处理' },
{ value: 'manual_review', label: '人工复核' },
{ value: 'after_sales', label: '售后关闭' },
{ value: 'system', label: '系统异常' },
]
const INTERNAL_SKU_MAPPING_TYPES: Array<{
value: InternalSkuMappingMatchType
label: string
@@ -153,14 +146,15 @@ function PendingPanel() {
const queryClient = useQueryClient()
const navigate = useNavigate()
const [keyword, setKeyword] = useState('')
const [issueCategory, setIssueCategory] = useState('')
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, page, pageSize],
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword }),
queryKey: ['fulfillment-pending', keyword, issueCategory, page, pageSize],
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword, issueCategory }),
})
const assignMutation = useMutation({
@@ -172,6 +166,7 @@ function PendingPanel() {
? ''
: String(internalSkuDrafts[rowKey(row)] || '').trim(),
channel: selections[rowKey(row)] || '',
issueCategory,
}),
onSuccess: (result) => {
const productMessage = result.data.draftProductId ? '和标准商品' : ''
@@ -216,10 +211,15 @@ function PendingPanel() {
})
const retryCarrierMutation = useMutation({
mutationFn: (input: { row: PendingChannelGroup; retryInactiveCarrier: boolean }) =>
mutationFn: (input: {
row: PendingChannelGroup
retryInactiveCarrier: boolean
issueCodes: string[]
}) =>
retryPendingCarriers({
internalSku: input.row.internal_sku || '',
retryInactiveCarrier: input.retryInactiveCarrier,
issueCodes: input.issueCodes,
}),
onSuccess: (result) => {
const data = result.data
@@ -245,6 +245,7 @@ function PendingPanel() {
unlockPendingChannelGroup({
groupKey: row.group_key,
internalSku: row.internal_sku || '',
issueCategory,
}),
onSuccess: (result) => {
message.success(`已解除 ${result.data.updated} 个订单项的运营锁定`)
@@ -255,7 +256,7 @@ function PendingPanel() {
const deleteMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
deletePendingChannelGroup({ groupKey: row.group_key }),
deletePendingChannelGroup({ groupKey: row.group_key, issueCategory }),
onSuccess: (result) => {
const data = result.data
message.success(
@@ -372,19 +373,46 @@ function PendingPanel() {
affiliate_mapping_missing: '缺自动发货映射',
existing_carrier_conflict: '已有其它载体',
carrier_config_ambiguous: '载体配置冲突',
missing_internal_sku: '缺内部 SKU',
missing_channel: '缺渠道配置',
user_refund: '用户已申请退款',
platform_cancelled: '上游履约已取消',
carrier_manual_review: '载体人工复核',
carrier_retry_pending: '载体失败待重试',
carrier_expired: '载体已过期',
carrier_closed: '载体已关闭',
carrier_missing: '缺履约载体',
state_inconsistent: '履约状态不一致',
}
const breakdown = row.issue_breakdown || []
return (
<Space direction="vertical" size={2}>
<Typography.Text>
{value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-'}
</Typography.Text>
{row.pending_subreasons?.length ? (
<Typography.Text type="secondary">
{row.pending_subreasons
.map((reason) => subreasonLabels[reason] || reason)
.join(' / ')}
</Typography.Text>
) : null}
{breakdown.length > 0 ? (
breakdown.map((issue) => (
<Typography.Text
key={issue.issue_code}
type={issue.issue_category === 'after_sales' ? 'danger' : undefined}
>
{subreasonLabels[issue.issue_code] || issue.issue_code} {issue.item_count} 项
{issue.locked_item_count > 0 ? ` · 锁定 ${issue.locked_item_count}` : ''}
</Typography.Text>
))
) : (
<>
<Typography.Text>
{value?.length
? value.map((reason) => labels[reason] || reason).join(' / ')
: '-'}
</Typography.Text>
{row.pending_subreasons?.length ? (
<Typography.Text type="secondary">
{row.pending_subreasons
.map((reason) => subreasonLabels[reason] || reason)
.join(' / ')}
</Typography.Text>
) : null}
</>
)}
{row.locked_item_count > 0 ? (
<Typography.Text type="warning">
已人工指定渠道,等待补配置 {row.locked_item_count} 项
@@ -411,16 +439,24 @@ function PendingPanel() {
title: '指定渠道',
width: 440,
render: (_: unknown, row: PendingChannelGroup) => {
const subreasons = row.pending_subreasons || []
const hasConfigIssue = subreasons.some((reason) => PENDING_CONFIG_REASONS.has(reason))
const hasManualReview = subreasons.includes('carrier_task_manual_review')
const hasRetryableTerminal = subreasons.some((reason) =>
PENDING_RETRYABLE_TERMINAL_REASONS.has(reason),
)
const hasNonRetryableTerminal = subreasons.some((reason) =>
PENDING_NON_RETRYABLE_TERMINAL_REASONS.has(reason),
)
const hasCarrierIssue = row.pending_reasons.includes('no_carrier')
const issueBreakdown = row.issue_breakdown || []
const retryableIssueCodes = issueBreakdown
.filter((issue) => issue.retryable)
.map((issue) => issue.issue_code)
const autoRetryableIssueCodes = issueBreakdown
.filter((issue) => issue.auto_retryable)
.map((issue) => issue.issue_code)
const hasConfigIssue = Number(row.config_issue_item_count || 0) > 0
const hasManualReview = Number(row.manual_review_item_count || 0) > 0
const hasAfterSales = Number(row.after_sales_item_count || 0) > 0
const hasSystemIssue = Number(row.system_issue_item_count || 0) > 0
const hasRetryable = Number(row.retryable_unlocked_item_count || 0) > 0
const hasAutoRetryable = Number(row.auto_retryable_unlocked_item_count || 0) > 0
const hasOnlyRetryableIssues =
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 hasUnlockedItems = row.locked_item_count < row.item_count
return (
<Space size={4} wrap>
@@ -439,24 +475,25 @@ function PendingPanel() {
type="primary"
disabled={
(!row.internal_sku && !String(internalSkuDrafts[rowKey(row)] || '').trim()) ||
!selections[rowKey(row)]
!selections[rowKey(row)] ||
!canRunScopedActions
}
loading={assignMutation.isPending}
onClick={() => assignMutation.mutate(row)}
>
保存渠道
</Button>
{hasConfigIssue ? (
{canRunScopedActions && hasConfigIssue ? (
<Button size="small" onClick={() => void navigate('/admin/cost-center')}>
去补配置
去补配置({row.config_issue_item_count})
</Button>
) : null}
{hasManualReview ? (
{canRunScopedActions && hasManualReview ? (
<Button size="small" onClick={() => void navigate('/admin/tasks')}>
处理人工复核
处理人工复核({row.manual_review_item_count})
</Button>
) : null}
{hasRetryableTerminal && !hasConfigIssue && hasUnlockedItems ? (
{canRunScopedActions && hasOnlyRetryableIssues && hasRetryable && hasUnlockedItems ? (
<Button
size="small"
danger
@@ -464,8 +501,7 @@ function PendingPanel() {
onClick={() =>
Modal.confirm({
title: '确认重开履约载体',
content:
'只会处理该 SKU 下未锁定、没有活跃载体的订单项。已有失败或关闭任务会保留为历史,并重新向上游创建一次履约尝试。',
content: `只会处理该 SKU 下 ${row.retryable_unlocked_item_count} 个未锁定、允许重试的订单项。退款、取消、人工复核和缺配置项不会被处理。已有失败或关闭任务会保留为历史。`,
okText: '确认重试',
okButtonProps: { danger: true },
cancelText: '取消',
@@ -473,18 +509,19 @@ function PendingPanel() {
retryCarrierMutation.mutateAsync({
row,
retryInactiveCarrier: true,
issueCodes: retryableIssueCodes,
}),
})
}
>
确认重试
确认重试({row.retryable_unlocked_item_count})
</Button>
) : null}
{!hasConfigIssue &&
!hasManualReview &&
!hasRetryableTerminal &&
!hasNonRetryableTerminal &&
hasCarrierIssue &&
{canRunScopedActions &&
hasOnlyAutoRetryableIssues &&
hasAutoRetryable &&
!hasAfterSales &&
!hasSystemIssue &&
row.configured_channels?.length &&
hasUnlockedItems ? (
<Tooltip title="只补建当前无活跃载体的未锁定订单,不会重开失败或关闭的旧任务">
@@ -496,14 +533,15 @@ function PendingPanel() {
retryCarrierMutation.mutate({
row,
retryInactiveCarrier: false,
issueCodes: autoRetryableIssueCodes,
})
}
>
补载体
补载体({row.auto_retryable_unlocked_item_count})
</Button>
</Tooltip>
) : null}
{row.locked_item_count > 0 ? (
{canRunScopedActions && row.locked_item_count > 0 ? (
<Button
size="small"
loading={unlockMutation.isPending}
@@ -520,33 +558,36 @@ function PendingPanel() {
解除锁定
</Button>
) : null}
<Tooltip title="从待办中忽略并归档,不删除原始订单">
<Button
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
onClick={() =>
Modal.confirm({
title: '忽略并归档待分类记录',
content: (
<div>
<p>
将从待办中归档 <strong>{row.item_count}</strong> 个订单项,并清除已生成的
派生成本快照、分类异常;可重建的财务事实会按开放期间重算。
</p>
<p style={{ marginBottom: 0 }}>
原始订单和订单商品不会删除;已关账期间只跳过重算,不再阻止归档。
</p>
</div>
),
okText: '确认归档',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => deleteMutation.mutateAsync(row),
})
}
/>
</Tooltip>
{canRunScopedActions ? (
<Tooltip title="从待办中忽略并归档,不删除原始订单">
<Button
danger
icon={<DeleteOutlined />}
loading={deleteMutation.isPending}
onClick={() =>
Modal.confirm({
title: '忽略并归档待分类记录',
content: (
<div>
<p>
将从待办中归档 <strong>{row.item_count}</strong>{' '}
个订单项,并清除已生成的
派生成本快照、分类异常;可重建的财务事实会按开放期间重算。
</p>
<p style={{ marginBottom: 0 }}>
原始订单和订单商品不会删除;已关账期间只跳过重算,不再阻止归档。
</p>
</div>
),
okText: '确认归档',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => deleteMutation.mutateAsync(row),
})
}
/>
</Tooltip>
) : null}
</Space>
)
},
@@ -555,7 +596,10 @@ function PendingPanel() {
return (
<Card
title={`待分类(${query.data?.data?.total ?? 0} 个 SKU)`}
title={`${
PENDING_ISSUE_CATEGORY_OPTIONS.find((option) => option.value === (issueCategory || 'all'))
?.label || '全部问题'
}(${query.data?.data?.total ?? 0} 个 SKU)`}
extra={
<Space>
<Input.Search
@@ -593,6 +637,16 @@ function PendingPanel() {
style={{ marginBottom: 12 }}
message="指定渠道会先保存渠道和草稿标准商品;载体或成本未配置时会继续留在队列中。忽略归档只影响派生流程,不删除原始订单。"
/>
<Segmented
block
value={issueCategory || 'all'}
options={PENDING_ISSUE_CATEGORY_OPTIONS}
onChange={(value) => {
setIssueCategory(value === 'all' ? '' : String(value))
setPage(1)
}}
style={{ marginBottom: 12 }}
/>
<PercentTable
rowKey={(row) => rowKey(row)}
size="small"
@@ -18,6 +18,24 @@ export type PendingChannelGroup = {
revenue_fen: number
first_seen_at: string
last_seen_at: string
issue_breakdown: Array<{
issue_code: string
issue_category: string
item_count: number
locked_item_count: number
retryable: boolean
auto_retryable: boolean
owner_role: string
}>
retryable_item_count: number
retryable_unlocked_item_count: number
auto_retryable_item_count: number
auto_retryable_unlocked_item_count: number
config_issue_item_count: number
manual_review_item_count: number
after_sales_item_count: number
system_issue_item_count: number
oldest_age_minutes: number
}
export type PendingChannelGroupListResult = {
@@ -208,6 +226,7 @@ export function fetchPendingChannelGroups(
page?: number
pageSize?: number
keyword?: string
issueCategory?: string
} = {},
) {
return apiGet<PendingChannelGroupListResult>('/api/v1/admin/fulfillment/pending', params)
@@ -268,6 +287,7 @@ export function retryPendingCarriers(payload: {
internalSku: string
retryInactiveCarrier?: boolean
includeLocked?: boolean
issueCodes?: string[]
}) {
return apiPost<PendingCarrierRetryResult>(
'/api/v1/admin/fulfillment/pending/retry-carriers',
@@ -275,11 +295,15 @@ export function retryPendingCarriers(payload: {
)
}
export function unlockPendingChannelGroup(payload: { groupKey?: string; internalSku?: string }) {
export function unlockPendingChannelGroup(payload: {
groupKey?: string
internalSku?: string
issueCategory?: string
}) {
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/pending/unlock', payload)
}
export function deletePendingChannelGroup(payload: { groupKey: string }) {
export function deletePendingChannelGroup(payload: { groupKey: string; issueCategory?: string }) {
return apiPost<DeletePendingChannelGroupResult>(
'/api/v1/admin/fulfillment/pending/delete',
payload,
@@ -291,6 +315,7 @@ export function assignChannelByInternalSku(payload: {
internalSku: string
mappedInternalSku?: string
channel: string
issueCategory?: string
note?: string
}) {
return apiPost<{
@@ -15,6 +15,9 @@ 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("label: '配置待补'"))
assert.ok(panel.includes("label: '售后关闭'"))
})
test('重开历史载体必须二次确认且不会处理锁定项', () => {
@@ -22,4 +25,8 @@ test('重开历史载体必须二次确认且不会处理锁定项', () => {
assert.ok(panel.includes('retryInactiveCarrier: true'))
assert.ok(panel.includes('不会重开失败或关闭的旧任务'))
assert.ok(panel.includes('unlockPendingChannelGroup'))
assert.ok(panel.includes('hasOnlyRetryableIssues'))
assert.ok(panel.includes('issueCodes: retryableIssueCodes'))
assert.ok(panel.includes('issueCodes: autoRetryableIssueCodes'))
assert.ok(!panel.includes('PENDING_RETRYABLE_TERMINAL_REASONS'))
})