feat(fulfillment): 建立履约统一状态视图与分类队列
This commit is contained in:
@@ -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'))
|
||||
})
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
# order_site 文档索引
|
||||
|
||||
> 最后补充:2026-11-03,新增《订单全流程与内部 SKU 架构》,代码基线 `main` / `6e4758a3`。
|
||||
> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);本地已校准到 105(103 财务基础、104 财务控制/L1、105 拼单/寄卖客服),生产按收敛后的 102 → 105 路径执行。历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。
|
||||
> 最后补充:2026-11-05,新增《履约系统架构复盘与优化方案》,代码基线 `e8b76b3b`,生产数据库基线迁移 `136`。
|
||||
> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。
|
||||
> 本文是 `docs/` 的唯一入口。**整体财务流程看 [财务流程总览-2026-11-01.md](财务流程总览-2026-11-01.md)**;数据模型看 [成本中心与履约模型.md](成本中心与履约模型.md),接口看 [成本中心API契约.md](成本中心API契约.md)。
|
||||
> 新增文档必须在下表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
| [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL |
|
||||
| [财务流程总览-2026-11-01.md](财务流程总览-2026-11-01.md) | 现行 | 断代后财务全流程:六条管道、成本快照、事实、总账、退款、异常、运维与测试 |
|
||||
| [订单全流程与内部SKU架构.md](订单全流程与内部SKU架构.md) | 现行 | 平台入单、中文内部 SKU、渠道判定、四类履约载体、商品归一与财务落账的完整链路 |
|
||||
| [履约系统架构复盘与优化方案-2026-11-05.md](履约系统架构复盘与优化方案-2026-11-05.md) | 方案 | 履约状态、载体、尝试、异常队列与后台信息架构的分阶段重构方案 |
|
||||
| [订单服务收集与状态页设计-2026-11-03.md](订单服务收集与状态页设计-2026-11-03.md) | 现行 | 分类级资料配置、发布版本与工单快照、`/o` 客户状态页、自动发布核销和通知预留 |
|
||||
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`105`;履约回填;迁移基线与本地库同步;索引与性能 |
|
||||
| [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 4 个固定 Tab) |
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
---
|
||||
type: 架构优化方案
|
||||
description: 全面复盘订单履约链路、后台队列、任务载体和财务边界,并给出分阶段优化方案。
|
||||
tags: [订单, 履约, 状态机, 后台队列, 架构优化]
|
||||
status: draft
|
||||
---
|
||||
|
||||
# 履约系统架构复盘与优化方案
|
||||
|
||||
> 状态:**方案**,尚未实施。
|
||||
> 最后校准:2026-11-05。
|
||||
> 代码基线:`e8b76b3b`。
|
||||
> 数据库基线:迁移 `136`。
|
||||
> 生产核对方式:只读 SQL、后台页面、现行文档与源码交叉检查。
|
||||
|
||||
## 实施进度
|
||||
|
||||
2026-11-05 已在 `dev_11.5_2` 开始阶段 0 和阶段 1:
|
||||
|
||||
- 已完成:迁移 `137` 新增 `v_fulfillment_item_state` 统一读模型。
|
||||
- 已完成:履约问题按 `classification / configuration / carrier / manual_review / after_sales / system` 分类。
|
||||
- 已完成:待分类查询返回每类问题的订单项数量,而不是只返回原因集合。
|
||||
- 已完成:保存渠道、补载体、确认重试、解除锁定和归档按当前问题分类限定作用域。
|
||||
- 已完成:新增 `npm run audit:fulfillment` 只读审计脚本。
|
||||
- 待继续:正式 `fulfillments / attempts / issues / events` 表、统一写入口、独立售后队列和生产存量修复。
|
||||
|
||||
## 一、结论
|
||||
|
||||
这套系统并不需要推倒重写,但不能继续靠新增字段、JSON 原因和页面按钮修补。
|
||||
|
||||
当前最根本的问题是:业务已经从“单渠道自动发货”演变为“多渠道履约 + 众包工单 + 人工客服 + 三方卡券 + 电子凭证 + 售后 + 财务”,底层却仍用一套为早期单渠道设计的状态表达所有问题。
|
||||
|
||||
优化方向必须是:
|
||||
|
||||
```text
|
||||
订单商业状态
|
||||
≠ 履约路由状态
|
||||
≠ 载体状态
|
||||
≠ 执行任务状态
|
||||
≠ 异常状态
|
||||
≠ 操作锁定
|
||||
≠ 财务状态
|
||||
```
|
||||
|
||||
下一阶段的核心目标不是增加功能,而是建立六个不变式:
|
||||
|
||||
1. 每笔订单项都有清晰、可计算的履约状态。
|
||||
2. `assigned` 必须意味着载体已具备,不能只意味着渠道已指定。
|
||||
3. 每个订单项单元最多一个当前履约尝试,历史尝试只追加。
|
||||
4. 所有待办必须有结构化异常代码、数量、责任人和允许动作。
|
||||
5. 退款、取消、人工复核、配置缺失不能继续共用一个“无载体”状态。
|
||||
6. 所有后台动作的作用域必须与页面展示的作用域完全一致。
|
||||
|
||||
## 二、项目规模与现状证据
|
||||
|
||||
本次盘点基线:
|
||||
|
||||
| 项目 | 数量 |
|
||||
| --- | ---: |
|
||||
| 后端 TypeScript 文件 | 620 |
|
||||
| 前端 TSX 文件 | 116 |
|
||||
| 数据库迁移 | 136 |
|
||||
| 文档 | 149 |
|
||||
| 本地业务表 | 97 |
|
||||
| 本地视图 | 3 |
|
||||
| 索引 | 436 |
|
||||
| 约束 | 421 |
|
||||
| 后端测试文件 | 161 |
|
||||
| 真 PostgreSQL 集成测试文件 | 5 |
|
||||
|
||||
生产履约数据的关键事实:
|
||||
|
||||
- 待分类页面显示 15 个 SKU,实际对应 40 个订单项。
|
||||
- 5 个 SKU 组混合了两种及以上互斥问题。
|
||||
- 2 个组同时包含“用户退款/上游取消”和“可重试关闭任务”。
|
||||
- 1 个组同时包含“人工复核”和“可重试关闭任务”。
|
||||
- 1 个组中部分订单锁定、部分未锁定。
|
||||
- 84 个订单项为 `assigned=接单平台`,但没有任何未取消工单。
|
||||
- 81 个订单项为 `assigned=自动发货`,但没有有效自动发货任务。
|
||||
- 当前仍存在 5 个订单项、10 条重复任务尝试。
|
||||
|
||||
这说明页面问题不是单纯的 UI 排版问题,而是底层状态与动作语义没有收敛。
|
||||
|
||||
## 三、系统领域地图
|
||||
|
||||
当前系统实际包含以下业务域:
|
||||
|
||||
```text
|
||||
订单接入
|
||||
├─ 91 / 快手
|
||||
├─ PDD
|
||||
└─ 抖音码速达
|
||||
|
||||
订单归一
|
||||
├─ 中文内部 SKU
|
||||
├─ 标准商品
|
||||
└─ 平台 SKU 映射
|
||||
|
||||
履约
|
||||
├─ 渠道规则
|
||||
├─ 自动发货任务
|
||||
├─ 接单平台工单
|
||||
├─ 人工客服
|
||||
├─ 三方卡券
|
||||
├─ 统一结算
|
||||
└─ 电子凭证核销
|
||||
|
||||
打手众包
|
||||
├─ 模板匹配
|
||||
├─ 发布、抢单、指派
|
||||
├─ 资料、验收、售后
|
||||
└─ 钱包与奖励
|
||||
|
||||
寄卖仓库
|
||||
├─ 提报、审核、库存
|
||||
├─ 内部现货匹配
|
||||
└─ 押金、结算、撤销
|
||||
|
||||
财务
|
||||
├─ 成本中心
|
||||
├─ 成本快照
|
||||
├─ 销售事实
|
||||
├─ 退款
|
||||
├─ 平台对账
|
||||
└─ 总账与关账
|
||||
|
||||
后台
|
||||
├─ 待办与异常
|
||||
├─ 用户与权限
|
||||
├─ 平台配置
|
||||
└─ 审计与实时事件
|
||||
```
|
||||
|
||||
履约不是一条链,而是订单、执行、售后和财务之间的交叉层。它必须拥有自己的领域模型,不能继续寄生在订单项和通用任务表上。
|
||||
|
||||
## 四、当前主要问题
|
||||
|
||||
### P0-1:`assigned` 语义失真
|
||||
|
||||
当前 `order_items.fulfillment_status` 只有:
|
||||
|
||||
```text
|
||||
pending / assigned / archived
|
||||
```
|
||||
|
||||
文档期望 `assigned` 表示“渠道确定且履约条件具备”,生产实际存在大量 `assigned` 但没有有效载体的数据。
|
||||
|
||||
后果:
|
||||
|
||||
- 待分类页看不到 `assigned` 的异常。
|
||||
- 后台统计把订单算作已分配。
|
||||
- 自动修复任务需要额外扫描“已分配但缺载体”。
|
||||
- 用户无法仅凭订单状态判断真实履约阶段。
|
||||
|
||||
### P0-2:异常状态存在自由 JSON 中
|
||||
|
||||
`fulfillment_pending_detail` 混合保存:
|
||||
|
||||
- 缺内部 SKU。
|
||||
- 缺渠道。
|
||||
- 缺人工配置。
|
||||
- 缺接单模板。
|
||||
- 缺自动发货映射。
|
||||
- 任务关闭。
|
||||
- 任务失败。
|
||||
- 人工复核。
|
||||
- 用户退款。
|
||||
- 上游取消。
|
||||
|
||||
后果:
|
||||
|
||||
- 无法可靠统计。
|
||||
- 历史原因容易过期。
|
||||
- 同一 SKU 的不同订单项原因被 `DISTINCT` 压平。
|
||||
- UI 只能显示“发生过什么”,无法显示“每个问题多少单”。
|
||||
|
||||
### P0-3:待分类按 SKU 聚合,动作却需要按订单项执行
|
||||
|
||||
页面一行代表一个内部 SKU,但行内可能同时包含:
|
||||
|
||||
- 退款订单。
|
||||
- 可重试关闭订单。
|
||||
- 人工复核订单。
|
||||
- 缺配置订单。
|
||||
- 锁定订单。
|
||||
|
||||
后果:
|
||||
|
||||
- 一个按钮无法准确表达动作范围。
|
||||
- 用户不知道会处理哪些订单。
|
||||
- 前端只能根据“是否存在某种原因”决定是否显示按钮。
|
||||
- 后端即使按规则跳过部分订单,页面结果也会显得不可预测。
|
||||
|
||||
### P0-4:任务同时承担当前载体和历史尝试
|
||||
|
||||
`fulfillment_tasks` 既是当前执行对象,又是履约尝试历史。
|
||||
|
||||
后果:
|
||||
|
||||
- 同一订单项出现两条任务时,页面看起来像两个订单。
|
||||
- 自动重建任务时容易绕过“运营确认”。
|
||||
- 当前任务只能通过最大 ID 推断。
|
||||
- 任务状态关闭后,订单项仍可能保持 `assigned`。
|
||||
|
||||
### P0-5:退款和履约异常没有彻底分开
|
||||
|
||||
生产存在:
|
||||
|
||||
- `USER_APPLY_REFUND` 任务已关闭,但订单项仍在履约队列。
|
||||
- `affiliate_dash_cancelled` 任务已关闭,但订单仍显示自动发货。
|
||||
- 退款订单与可重试任务混合在同一个 SKU 组。
|
||||
|
||||
退款、取消应该进入售后或商业订单终态,而不是继续留在“无履约载体”。
|
||||
|
||||
### P1-1:多个服务直接写履约字段
|
||||
|
||||
`order_items` 的履约字段至少被以下链路修改:
|
||||
|
||||
- 入单服务。
|
||||
- 渠道判定。
|
||||
- 载体补建。
|
||||
- 自动发货回退。
|
||||
- 人工配置。
|
||||
- 任务同步。
|
||||
- 财务退款。
|
||||
- 后台人工操作。
|
||||
|
||||
缺少唯一 `FulfillmentStateService` 作为写入口,状态一致性的证明成本很高。
|
||||
|
||||
### P1-2:状态、锁定和原因过载
|
||||
|
||||
- `fulfillment_status` 同时承担分类、分配和归档。
|
||||
- `fulfillment_locked` 同时表示运营指定、自动暂停、配置未完成。
|
||||
- `fulfillment_pending_reason` 同时表示路由、配置、载体和售后。
|
||||
|
||||
### P1-3:后台信息架构不匹配业务
|
||||
|
||||
当前“履约渠道”放在财务菜单下,但实际使用者主要是运营、客服和支持。
|
||||
|
||||
用户需要在以下页面之间来回跳:
|
||||
|
||||
- 商品与成本。
|
||||
- 履约渠道。
|
||||
- 任务。
|
||||
- 接单平台。
|
||||
- 订单。
|
||||
- 电子凭证。
|
||||
- 财务。
|
||||
|
||||
没有一个统一的“履约中心”告诉用户:
|
||||
|
||||
- 当前有多少订单未分类。
|
||||
- 有多少缺配置。
|
||||
- 有多少缺载体。
|
||||
- 有多少人工复核。
|
||||
- 有多少退款待处理。
|
||||
- 哪些自动恢复失败。
|
||||
|
||||
### P1-4:文档已经开始漂移
|
||||
|
||||
现有多篇文档的状态描述不完全一致:
|
||||
|
||||
- `docs/README.md` 仍写旧迁移基线。
|
||||
- `订单全流程与内部SKU架构.md` 假定 `assigned` 代表载体已具备。
|
||||
- `成本中心与履约模型.md` 含历史商品数量。
|
||||
- 多份阶段方案仍被登记为现行,但代码已继续演进。
|
||||
|
||||
文档不是无效,而是缺少“单一口径 + 历史归档”的强约束。
|
||||
|
||||
### P2-1:超大文件和直接 SQL 过多
|
||||
|
||||
当前存在多个超大文件:
|
||||
|
||||
- `WorkOrdersPanel.tsx` 4,667 行。
|
||||
- `channel-assign-service.ts` 2,217 行。
|
||||
- `product-catalog-service.ts` 2,120 行。
|
||||
- `AdminTaskDetailPage.tsx` 2,017 行。
|
||||
|
||||
这些文件本身不是错误,但它们通常意味着:
|
||||
|
||||
- 多个用例共享一个大服务。
|
||||
- 查询、状态转换、展示逻辑耦合。
|
||||
- 修改一个动作时容易影响另一个动作。
|
||||
|
||||
### P2-2:集成测试比例过低
|
||||
|
||||
161 个后端测试文件中只有 5 个真实 PostgreSQL 集成测试。
|
||||
|
||||
大量测试是纯函数或源码断言,这能防止低级回归,但不能证明:
|
||||
|
||||
- 多个服务并发写状态正确。
|
||||
- SQL 与约束一致。
|
||||
- 调度任务重复执行安全。
|
||||
- 页面动作和后端作用域一致。
|
||||
|
||||
## 五、目标领域模型
|
||||
|
||||
### 5.1 五层状态
|
||||
|
||||
建议分成五个正交维度:
|
||||
|
||||
| 层 | 含义 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 商业订单状态 | 是否支付、退款、取消 | `paid / refunding / refunded / cancelled` |
|
||||
| 履约路由状态 | 是否已决定渠道 | `unrouted / configured` |
|
||||
| 载体状态 | 是否有实际执行对象 | `not_required / missing / creating / ready / failed / terminal` |
|
||||
| 执行状态 | 当前尝试进行到哪里 | `created / submitted / executing / succeeded / failed / cancelled` |
|
||||
| 异常状态 | 当前阻塞是什么 | `open / acknowledged / waiting / resolved` |
|
||||
|
||||
不要继续增加一个无限扩张的 `fulfillment_status` 枚举。
|
||||
|
||||
### 5.2 核心对象
|
||||
|
||||
推荐长期模型:
|
||||
|
||||
```text
|
||||
orders
|
||||
└─ order_items
|
||||
└─ fulfillments
|
||||
├─ internal_sku
|
||||
├─ channel
|
||||
├─ route_status
|
||||
├─ carrier_status
|
||||
├─ current_attempt_id
|
||||
├─ lock_reason
|
||||
└─ issues
|
||||
└─ attempts
|
||||
└─ events
|
||||
```
|
||||
|
||||
#### Fulfillment
|
||||
|
||||
一个订单项对应一个履约单。若未来支持拆包裹、拆批次,可扩展为一对多。
|
||||
|
||||
建议字段:
|
||||
|
||||
```text
|
||||
id
|
||||
order_item_id
|
||||
internal_sku
|
||||
channel
|
||||
route_status
|
||||
carrier_status
|
||||
current_attempt_id
|
||||
lock_reason
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
#### Attempt
|
||||
|
||||
每次真实执行尝试单独一行,只追加,不覆盖:
|
||||
|
||||
```text
|
||||
id
|
||||
fulfillment_id
|
||||
attempt_no
|
||||
attempt_type
|
||||
external_ref
|
||||
status
|
||||
result_code
|
||||
result_message
|
||||
started_at
|
||||
finished_at
|
||||
superseded_by_attempt_id
|
||||
context_json
|
||||
```
|
||||
|
||||
任务表、工单表可继续作为不同执行器的专用表,但必须有统一映射到 Attempt 的方式。
|
||||
|
||||
#### Issue
|
||||
|
||||
异常独立于履约主状态:
|
||||
|
||||
```text
|
||||
id
|
||||
fulfillment_id
|
||||
attempt_id
|
||||
issue_code
|
||||
severity
|
||||
source
|
||||
retryable
|
||||
owner_role
|
||||
status
|
||||
first_seen_at
|
||||
last_seen_at
|
||||
resolved_at
|
||||
detail_json
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
所有关键状态变化写追加式事件:
|
||||
|
||||
```text
|
||||
fulfillment_id
|
||||
attempt_id
|
||||
event_type
|
||||
from_state
|
||||
to_state
|
||||
actor_type
|
||||
actor_id
|
||||
payload_json
|
||||
created_at
|
||||
```
|
||||
|
||||
### 5.3 标准 IssueCode
|
||||
|
||||
建议先固定以下异常代码:
|
||||
|
||||
```text
|
||||
missing_internal_sku
|
||||
missing_channel
|
||||
manual_config_missing
|
||||
work_rule_missing
|
||||
affiliate_mapping_missing
|
||||
carrier_missing
|
||||
carrier_create_failed
|
||||
carrier_closed
|
||||
carrier_manual_review
|
||||
carrier_retry_pending
|
||||
user_refund
|
||||
platform_cancelled
|
||||
routing_conflict
|
||||
state_inconsistent
|
||||
```
|
||||
|
||||
每个 `IssueCode` 必须定义:
|
||||
|
||||
- 是否可自动处理。
|
||||
- 是否允许重试。
|
||||
- 由哪个角色处理。
|
||||
- 是否阻断财务成本。
|
||||
- 是否阻断关账。
|
||||
- 允许的 UI 动作。
|
||||
|
||||
## 六、目标后台信息架构
|
||||
|
||||
建议从“履约渠道”升级为统一的“履约中心”:
|
||||
|
||||
```text
|
||||
履约中心
|
||||
├─ 总览
|
||||
├─ 未分类
|
||||
├─ 配置待补
|
||||
├─ 载体待处理
|
||||
├─ 人工复核
|
||||
├─ 售后/退款
|
||||
├─ 系统异常
|
||||
├─ 渠道规则
|
||||
└─ SKU 映射
|
||||
```
|
||||
|
||||
### 总览
|
||||
|
||||
展示:
|
||||
|
||||
- 待分类订单项数。
|
||||
- 缺配置数。
|
||||
- 缺载体数。
|
||||
- 人工复核数。
|
||||
- 退款/取消数。
|
||||
- 系统状态不一致数。
|
||||
- 最老未处理时长。
|
||||
- 自动恢复成功率。
|
||||
|
||||
### 行级信息
|
||||
|
||||
每个订单项至少展示:
|
||||
|
||||
```text
|
||||
内部 SKU
|
||||
目标渠道
|
||||
当前载体
|
||||
当前尝试
|
||||
异常代码
|
||||
异常持续时间
|
||||
是否锁定
|
||||
责任人
|
||||
允许动作
|
||||
```
|
||||
|
||||
### 动作原则
|
||||
|
||||
1. 混合组不提供一键执行动作,只提供“进入明细”。
|
||||
2. 单组只有一种异常且作用域完全一致时,才允许批量动作。
|
||||
3. 所有批量动作先展示影响数量和订单项范围。
|
||||
4. 退款、取消、人工复核永远不能和普通“补载体”共用按钮。
|
||||
5. 运营锁定、解除锁定必须记录原因和操作者。
|
||||
|
||||
## 七、优化路线
|
||||
|
||||
### 阶段 0:止血与口径统一
|
||||
|
||||
周期:1 至 2 天。
|
||||
|
||||
目标:
|
||||
|
||||
- 不再让错误动作出现在混合组。
|
||||
- 阻止退款/取消订单被重新发货。
|
||||
- 建立生产只读审计 SQL。
|
||||
- 统一文档现行口径。
|
||||
|
||||
工作项:
|
||||
|
||||
1. 混合组存在不可重试异常时,隐藏整组“确认重试”。
|
||||
2. 每个异常显示准确订单项数量,而不是只显示原因集合。
|
||||
3. 退款、取消进入独立售后分类。
|
||||
4. 文档索引标注旧文档状态,停止同时引用冲突口径。
|
||||
5. 增加生产审计脚本:
|
||||
- `assigned` 但无载体。
|
||||
- pending 但无结构化异常。
|
||||
- 同一订单项多个当前尝试。
|
||||
- 退款订单仍在履约队列。
|
||||
|
||||
验收:
|
||||
|
||||
- 混合组不再出现作用域不确定的批量动作。
|
||||
- 退款、取消订单自动重试次数为 0。
|
||||
- 所有现行文档基线与迁移号一致。
|
||||
|
||||
### 阶段 1:统一读模型
|
||||
|
||||
周期:3 至 5 天。
|
||||
|
||||
目标:先不改核心表,建立一个每个订单项一行的权威读模型。
|
||||
|
||||
新增视图:
|
||||
|
||||
```text
|
||||
v_fulfillment_item_state
|
||||
```
|
||||
|
||||
建议字段:
|
||||
|
||||
```text
|
||||
order_item_id
|
||||
order_id
|
||||
platform_order_id
|
||||
internal_sku
|
||||
channel
|
||||
route_status
|
||||
carrier_status
|
||||
current_attempt_id
|
||||
current_attempt_type
|
||||
current_attempt_status
|
||||
issue_code
|
||||
issue_count
|
||||
retryable
|
||||
locked
|
||||
lock_reason
|
||||
owner_role
|
||||
first_issue_at
|
||||
last_issue_at
|
||||
age_minutes
|
||||
```
|
||||
|
||||
后台所有履约列表、异常统计、自动修复扫描都先改为读取该视图。
|
||||
|
||||
验收:
|
||||
|
||||
- 待分类页不再直接拼接 `pending_detail`。
|
||||
- 同一订单项只出现一次。
|
||||
- 每个页面行都有唯一 `issue_code`。
|
||||
- 生产异常口径与视图统计一致。
|
||||
|
||||
### 阶段 2:收敛写入口
|
||||
|
||||
周期:1 至 2 周。
|
||||
|
||||
目标:建立唯一履约状态写入口。
|
||||
|
||||
新增服务:
|
||||
|
||||
```text
|
||||
FulfillmentStateService
|
||||
```
|
||||
|
||||
负责:
|
||||
|
||||
- 设置渠道。
|
||||
- 创建当前尝试。
|
||||
- 更新载体状态。
|
||||
- 打开和关闭异常。
|
||||
- 加锁和解锁。
|
||||
- 退款/取消终态转换。
|
||||
- 写履约事件。
|
||||
|
||||
其他服务只能调用该服务,不再直接更新履约字段。
|
||||
|
||||
建议先覆盖:
|
||||
|
||||
1. `channel-assign-service`
|
||||
2. `carrier-creation-service`
|
||||
3. `carrier-reconciliation-service`
|
||||
4. `manual-fulfillment-config-service`
|
||||
5. 退款服务
|
||||
6. 后台任务动作
|
||||
|
||||
验收:
|
||||
|
||||
- 生产代码中只有 `FulfillmentStateService` 可以更新履约核心字段。
|
||||
- 所有状态变化都有事件。
|
||||
- 自动任务重复执行不会产生第二个活跃尝试。
|
||||
|
||||
### 阶段 3:拆分队列与动作
|
||||
|
||||
周期:1 周,可与阶段 2 并行。
|
||||
|
||||
工作项:
|
||||
|
||||
- 待分类只保留“缺内部 SKU / 缺渠道”。
|
||||
- 缺配置进入配置待补。
|
||||
- 缺任务/工单进入载体待处理。
|
||||
- 人工复核进入人工队列。
|
||||
- 退款、取消进入售后队列。
|
||||
- `assigned` 但无载体进入系统异常。
|
||||
|
||||
验收:
|
||||
|
||||
- 每类队列只包含一种主问题。
|
||||
- 每类队列有明确责任角色。
|
||||
- 页面按钮与后端动作作用域一致。
|
||||
- 待分类数量不再包含售后和配置异常。
|
||||
|
||||
### 阶段 4:数据模型升级
|
||||
|
||||
周期:2 至 4 周。
|
||||
|
||||
目标:将读模型和写入口稳定后,再引入正式表。
|
||||
|
||||
工作项:
|
||||
|
||||
1. 新增 `fulfillments`。
|
||||
2. 新增 `fulfillment_attempts`。
|
||||
3. 新增 `fulfillment_issues`。
|
||||
4. 新增 `fulfillment_events`。
|
||||
5. 从现有订单项、任务、工单回填。
|
||||
6. 建立双写和差异核对。
|
||||
7. 读路径切换。
|
||||
8. 停止旧字段写入。
|
||||
9. 最后归档旧字段和兼容代码。
|
||||
|
||||
禁止一次性切换后立即删除旧字段。必须经过:
|
||||
|
||||
```text
|
||||
回填
|
||||
→ 双写
|
||||
→ 读对比
|
||||
→ 以新模型为准
|
||||
→ 停止旧写
|
||||
→ 观察
|
||||
→ 删除旧逻辑
|
||||
```
|
||||
|
||||
### 阶段 5:工程治理
|
||||
|
||||
周期:持续。
|
||||
|
||||
工作项:
|
||||
|
||||
- 拆分超过 1,500 行的页面和核心服务。
|
||||
- 所有状态转换使用领域状态机。
|
||||
- 关键跨表流程增加 PostgreSQL 集成测试。
|
||||
- 调度任务统一采用幂等键和租约。
|
||||
- 文档按“现行 / 方案 / 历史”强制归档。
|
||||
- 建立生产数据质量日报。
|
||||
|
||||
## 八、优先级
|
||||
|
||||
| 优先级 | 事项 | 目标 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 混合组动作收敛 | 防止错误重试和跨范围操作 |
|
||||
| P0 | 退款/取消隔离 | 商业终态不再进入履约队列 |
|
||||
| P0 | 统一读模型 | 让页面和统计使用同一事实 |
|
||||
| P1 | 履约写入口 | 消除多服务直接改状态 |
|
||||
| P1 | 队列拆分 | 让不同角色处理不同类型问题 |
|
||||
| P1 | 当前尝试指针 | 历史尝试不再看起来像当前任务 |
|
||||
| P2 | 正式履约表 | 建立长期可扩展模型 |
|
||||
| P2 | 文档与测试治理 | 防止再次漂移 |
|
||||
|
||||
## 九、验收指标
|
||||
|
||||
上线后应持续满足:
|
||||
|
||||
```text
|
||||
assigned 且无有效载体,超过 15 分钟:0
|
||||
退款/取消订单出现在履约待办:0
|
||||
同一订单项单元多个活跃尝试:0
|
||||
pending 订单无结构化 issue_code:0
|
||||
后台动作作用域与页面展示不一致:0
|
||||
未记录事件的履约状态变化:0
|
||||
混合异常组出现整组重试按钮:0
|
||||
```
|
||||
|
||||
运营指标:
|
||||
|
||||
```text
|
||||
待分类平均处理时长
|
||||
配置待补平均处理时长
|
||||
人工复核平均处理时长
|
||||
售后关闭平均处理时长
|
||||
自动恢复成功率
|
||||
人工干预率
|
||||
```
|
||||
|
||||
## 十、明确不做
|
||||
|
||||
1. 不做一次性的“大重写”。
|
||||
2. 不继续增加通用 `pending_reason` 字符串。
|
||||
3. 不把所有状态合并成一个超长枚举。
|
||||
4. 不允许按 SKU 对混合异常整组执行危险动作。
|
||||
5. 不自动重开关闭、退款、取消或人工复核任务。
|
||||
6. 不在没有读模型和双写验证的情况下直接删除旧字段。
|
||||
7. 不只修 UI 文案而不修状态来源。
|
||||
8. 不把历史任务直接删除,必须保留审计链。
|
||||
|
||||
## 十一、建议的第一批任务
|
||||
|
||||
第一批只做低风险、高收益工作:
|
||||
|
||||
1. 增加 `v_fulfillment_item_state`。
|
||||
2. 待分类页改为读该视图。
|
||||
3. 混合组隐藏批量重试。
|
||||
4. 每个异常显示订单项数量。
|
||||
5. 退款、取消进入独立队列。
|
||||
6. 增加 4 个生产审计 SQL 和日报。
|
||||
7. 修正文档索引和现行基线。
|
||||
|
||||
这批完成后再决定是否进入 `fulfillments/attempts/issues` 正式表改造。
|
||||
|
||||
## 十二、待拍板问题
|
||||
|
||||
1. 一个订单项是否永远只有一个履约单,还是未来允许拆包裹、拆批次。
|
||||
2. 接单平台、自动发货和人工是否都统一映射为 Attempt。
|
||||
3. 三方卡券和统一结算是否需要“零载体尝试”记录。
|
||||
4. 退款、取消由平台状态自动驱动,还是允许运营手工确认。
|
||||
5. 哪类 Issue 可以自动重试,哪类必须人工确认。
|
||||
6. 各异常的责任角色是运营、客服、财务还是系统自动。
|
||||
7. 财务关账是否必须等待所有履约异常关闭。
|
||||
|
||||
Reference in New Issue
Block a user