From ed450fac79ed72d3ba9a19186a15d9b51d73f7fe Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 5 Nov 2026 20:13:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(fulfillment):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E5=B1=A5=E7=BA=A6=E7=BB=9F=E4=B8=80=E7=8A=B6=E6=80=81=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=E5=88=86=E7=B1=BB=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/package.json | 1 + .../scripts/audit-fulfillment-state.ts | 140 ++++ .../137_fulfillment_item_state_view.sql | 358 +++++++++ .../src/routes/admin/fulfillment-channel.ts | 7 + .../fulfillment/channel-assign-service.ts | 357 +++++++-- .../fulfillment-routing.integration.test.ts | 39 +- .../fulfillment-state-view.test.ts | 52 ++ .../admin/AdminFulfillmentChannelPage.tsx | 226 ++++-- .../src/services/admin/fulfillment-channel.ts | 29 +- .../tests/fulfillment-pending-actions.test.ts | 7 + docs/README.md | 5 +- docs/履约系统架构复盘与优化方案-2026-11-05.md | 741 ++++++++++++++++++ 12 files changed, 1783 insertions(+), 179 deletions(-) create mode 100644 apps/backend/scripts/audit-fulfillment-state.ts create mode 100644 apps/backend/src/db/migrations/137_fulfillment_item_state_view.sql create mode 100644 apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts create mode 100644 docs/履约系统架构复盘与优化方案-2026-11-05.md diff --git a/apps/backend/package.json b/apps/backend/package.json index b3ec2500..c3232050 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/scripts/audit-fulfillment-state.ts b/apps/backend/scripts/audit-fulfillment-state.ts new file mode 100644 index 00000000..2b25056b --- /dev/null +++ b/apps/backend/scripts/audit-fulfillment-state.ts @@ -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 { + 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 { + 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 { + 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 { + 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() +} diff --git a/apps/backend/src/db/migrations/137_fulfillment_item_state_view.sql b/apps/backend/src/db/migrations/137_fulfillment_item_state_view.sql new file mode 100644 index 00000000..e67aa542 --- /dev/null +++ b/apps/backend/src/db/migrations/137_fulfillment_item_state_view.sql @@ -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; diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 1808aa46..8384488f 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -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, }) diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index e781feed..a731ae07 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -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() @@ -1225,6 +1349,7 @@ export async function retryPendingCarrierGroup(input: { internalSku: string retryInactiveCarrier?: boolean includeLocked?: boolean + issueCodes?: string[] }): Promise { 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 { 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) diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index f36e9765..c5249f68 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -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 + fulfillment_pending_detail: Record | 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}` diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts b/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts new file mode 100644 index 00000000..deee603c --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts @@ -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')) +}) diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index af002bbe..7aea826c 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -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>({}) const [internalSkuDrafts, setInternalSkuDrafts] = useState>({}) 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 ( - - {value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-'} - - {row.pending_subreasons?.length ? ( - - {row.pending_subreasons - .map((reason) => subreasonLabels[reason] || reason) - .join(' / ')} - - ) : null} + {breakdown.length > 0 ? ( + breakdown.map((issue) => ( + + {subreasonLabels[issue.issue_code] || issue.issue_code} {issue.item_count} 项 + {issue.locked_item_count > 0 ? ` · 锁定 ${issue.locked_item_count}` : ''} + + )) + ) : ( + <> + + {value?.length + ? value.map((reason) => labels[reason] || reason).join(' / ') + : '-'} + + {row.pending_subreasons?.length ? ( + + {row.pending_subreasons + .map((reason) => subreasonLabels[reason] || reason) + .join(' / ')} + + ) : null} + + )} {row.locked_item_count > 0 ? ( 已人工指定渠道,等待补配置 {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 ( @@ -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)} > 保存渠道 - {hasConfigIssue ? ( + {canRunScopedActions && hasConfigIssue ? ( ) : null} - {hasManualReview ? ( + {canRunScopedActions && hasManualReview ? ( ) : null} - {hasRetryableTerminal && !hasConfigIssue && hasUnlockedItems ? ( + {canRunScopedActions && hasOnlyRetryableIssues && hasRetryable && hasUnlockedItems ? ( ) : null} - {!hasConfigIssue && - !hasManualReview && - !hasRetryableTerminal && - !hasNonRetryableTerminal && - hasCarrierIssue && + {canRunScopedActions && + hasOnlyAutoRetryableIssues && + hasAutoRetryable && + !hasAfterSales && + !hasSystemIssue && row.configured_channels?.length && hasUnlockedItems ? ( @@ -496,14 +533,15 @@ function PendingPanel() { retryCarrierMutation.mutate({ row, retryInactiveCarrier: false, + issueCodes: autoRetryableIssueCodes, }) } > - 补载体 + 补载体({row.auto_retryable_unlocked_item_count}) ) : null} - {row.locked_item_count > 0 ? ( + {canRunScopedActions && row.locked_item_count > 0 ? (