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..f624404c --- /dev/null +++ b/apps/backend/scripts/audit-fulfillment-state.ts @@ -0,0 +1,236 @@ +#!/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 loadShadowModelDiffs(): Promise { + const result = await query<{ + missing_fulfillments: number + fulfillment_state_diffs: number + missing_attempts: number + missing_open_issues: number + unlinked_events: number + }>( + ` + SELECT + COUNT(*) FILTER (WHERE fulfillment.id IS NULL)::int AS missing_fulfillments, + COUNT(*) FILTER ( + WHERE fulfillment.id IS NOT NULL + AND ROW( + fulfillment.route_status, + fulfillment.channel, + fulfillment.carrier_status, + fulfillment.current_attempt_type, + fulfillment.current_attempt_source_id, + fulfillment.issue_code, + fulfillment.issue_category + ) IS DISTINCT FROM ROW( + CASE WHEN state.route_status = 'configured' THEN 'configured' ELSE 'unrouted' END, + state.intended_channel, + state.carrier_status, + state.current_attempt_type, + NULLIF(state.current_attempt_id, 0), + state.issue_code, + state.issue_category + ) + )::int AS fulfillment_state_diffs, + ( + SELECT COUNT(*)::int + FROM ( + SELECT task.order_item_id + FROM fulfillment_tasks task + JOIN fulfillments fulfillment ON fulfillment.order_item_id = task.order_item_id + WHERE NOT EXISTS ( + SELECT 1 + FROM fulfillment_attempts attempt + WHERE attempt.attempt_type = 'task' + AND attempt.source_id = task.id + ) + UNION ALL + SELECT work_order.order_item_id + FROM work_orders work_order + JOIN fulfillments fulfillment ON fulfillment.order_item_id = work_order.order_item_id + WHERE work_order.order_item_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM fulfillment_attempts attempt + WHERE attempt.attempt_type = 'work_order' + AND attempt.source_id = work_order.id + ) + ) missing + )::int AS missing_attempts, + COUNT(*) FILTER ( + WHERE state.issue_code <> '' + AND NOT EXISTS ( + SELECT 1 + FROM fulfillment_issues issue + WHERE issue.fulfillment_id = fulfillment.id + AND issue.issue_code = state.issue_code + AND issue.status <> 'resolved' + ) + )::int AS missing_open_issues, + ( + SELECT COUNT(*)::int + FROM fulfillment_state_events event + JOIN v_fulfillment_item_state active_state + ON active_state.order_item_id = event.order_item_id + WHERE event.fulfillment_id IS NULL + ) AS unlinked_events + FROM v_fulfillment_item_state state + LEFT JOIN fulfillments fulfillment + ON fulfillment.order_item_id = state.order_item_id + `, + ) + const row = result.rows[0] + return [ + { label: '影子履约缺失', count: Number(row?.missing_fulfillments || 0) }, + { label: '影子履约状态差异', count: Number(row?.fulfillment_state_diffs || 0) }, + { label: '影子执行尝试缺失', count: Number(row?.missing_attempts || 0) }, + { label: '影子开放异常缺失', count: Number(row?.missing_open_issues || 0) }, + { label: '履约事件未关联影子记录', count: Number(row?.unlinked_events || 0) }, + ] +} + +async function main(): Promise { + const [counts, issues, duplicates, shadowDiffs] = await Promise.all([ + loadCounts(), + loadIssueBreakdown(), + loadRecentDuplicateAttempts(), + loadShadowModelDiffs(), + ]) + + 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}`) + } + + console.info('[fulfillment-audit] 影子模型差异') + for (const row of shadowDiffs) { + 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 shadowDiffCount = shadowDiffs.reduce((sum, row) => sum + Number(row.count || 0), 0) + const hasCritical = + (critical.get('状态不一致') || 0) > 0 || + (critical.get('售后关闭仍进入履约队列') || 0) > 0 || + (critical.get('已分配但无有效载体') || 0) > 0 || + duplicateCount > 0 || + shadowDiffCount > 0 + + if (strict && hasCritical) { + process.exitCode = 1 + console.error('[fulfillment-audit] strict 模式检测到关键异常') + } +} + +try { + await main() +} finally { + await closeDb() +} diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index 706b01ae..d6a7c66d 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -17,4 +17,6 @@ export const APP_CONFIG_KEYS = { pddSource: 'pdd_source', /** 抖音码速达(抖店中转开放平台)接入配置:uid/secret/baseUrl/certLink 等 */ douyinMashida: 'douyin_mashida', + /** 履约自动化生效边界:此时间之前的历史订单不再参与自动判定和自动重试 */ + fulfillmentAutomationCutoff: 'fulfillment_automation_cutoff', } as const diff --git a/apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql b/apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql new file mode 100644 index 00000000..20214dab --- /dev/null +++ b/apps/backend/src/db/migrations/135_reconcile_manual_standard_products.sql @@ -0,0 +1,190 @@ +-- 135_reconcile_manual_standard_products.sql +-- +-- 人工标准商品归一: +-- 1. 同一内部 SKU 因大小写/全半角残留出多条 active man: 商品时,只保留最早一条; +-- 2. 订单项、人工配置、成本版本、成本快照、渠道映射和渠道能力统一指向保留商品; +-- 3. 重复商品只归档,不删除历史成本与订单数据。 + +-- 已有配置/登记但缺少 man: 标准商品的 SKU,先补标准商品。 +-- 若该 SKU 已有其他启用主渠道,则不再补人工商品,避免重新制造跨渠道重复。 +WITH raw_keys AS ( + SELECT canonicalize_internal_sku(sku_key) AS sku_key, sku_key AS display_name + FROM manual_fulfillment_configs + UNION ALL + SELECT canonicalize_internal_sku(sku_key) AS sku_key, sku_key AS display_name + FROM manual_sku_registry +), +keys AS ( + SELECT sku_key, MAX(display_name) AS display_name + FROM raw_keys + GROUP BY 1 +), +missing AS ( + SELECT keys.* + FROM keys + WHERE keys.sku_key <> '' + AND NOT EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) = keys.sku_key + ) + AND NOT EXISTS ( + SELECT 1 + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND channel.is_fallback = FALSE + AND channel.channel <> 'manual' + AND canonicalize_internal_sku(channel.internal_sku) = keys.sku_key + ) +) +INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at +) +SELECT + 'man:' || sku_key, + COALESCE(NULLIF(display_name, ''), sku_key), + '人工', + '件', + 'active', + '{"source":"manual_config","auto_created":true,"reconciled":true}'::jsonb, + NOW(), + NOW() +FROM missing +ON CONFLICT (canonical_sku) DO UPDATE SET + status = 'active', + canonical_name = CASE + WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name + ELSE product_catalog.canonical_name + END, + updated_at = EXCLUDED.updated_at; + +DROP TABLE IF EXISTS pg_temp.tmp_manual_product_merge; + +CREATE TEMP TABLE tmp_manual_product_merge AS +WITH ranked AS ( + SELECT + id, + FIRST_VALUE(id) OVER ( + PARTITION BY canonicalize_internal_sku(substring(canonical_sku from 5)) + ORDER BY id + ) AS keeper_id, + ROW_NUMBER() OVER ( + PARTITION BY canonicalize_internal_sku(substring(canonical_sku from 5)) + ORDER BY id + ) AS candidate_rank + FROM product_catalog + WHERE status = 'active' AND canonical_sku LIKE 'man:%' +) +SELECT id AS duplicate_id, keeper_id +FROM ranked +WHERE candidate_rank > 1; + +CREATE UNIQUE INDEX ON tmp_manual_product_merge (duplicate_id); + +UPDATE order_items oi +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE oi.canonical_product_id = merge.duplicate_id; + +UPDATE manual_fulfillment_configs cfg +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE cfg.canonical_product_id = merge.duplicate_id; + +UPDATE cost_snapshots snapshot +SET canonical_product_id = merge.keeper_id +FROM tmp_manual_product_merge merge +WHERE snapshot.canonical_product_id = merge.duplicate_id; + +-- 无冲突版本迁到保留商品;若生效时间等唯一范围冲突,则留在归档商品上供历史快照追溯。 +UPDATE cost_versions version +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE version.canonical_product_id = merge.duplicate_id + AND NOT EXISTS ( + SELECT 1 + FROM cost_versions keeper + WHERE keeper.canonical_product_id = merge.keeper_id + AND keeper.cost_item_id = version.cost_item_id + AND keeper.scope_type = version.scope_type + AND keeper.provider = version.provider + AND keeper.shop_id = version.shop_id + AND keeper.work_product_rule_id IS NOT DISTINCT FROM version.work_product_rule_id + AND keeper.effective_from = version.effective_from + ); + +DELETE FROM channel_product_mappings duplicate +USING tmp_manual_product_merge merge, channel_product_mappings keeper +WHERE duplicate.canonical_product_id = merge.duplicate_id + AND keeper.canonical_product_id = merge.keeper_id + AND keeper.provider = duplicate.provider + AND keeper.shop_id = duplicate.shop_id + AND keeper.platform_sku_code = duplicate.platform_sku_code + AND keeper.match_type = duplicate.match_type + AND keeper.platform_sku_name_key = duplicate.platform_sku_name_key; + +UPDATE channel_product_mappings mapping +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE mapping.canonical_product_id = merge.duplicate_id; + +DELETE FROM product_channel_capabilities duplicate +USING tmp_manual_product_merge merge, product_channel_capabilities keeper +WHERE duplicate.product_id = merge.duplicate_id + AND keeper.product_id = merge.keeper_id + AND keeper.channel = duplicate.channel; + +UPDATE product_channel_capabilities capability +SET product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE capability.product_id = merge.duplicate_id; + +UPDATE internal_sku_channels channel +SET canonical_product_id = merge.keeper_id, + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE channel.canonical_product_id = merge.duplicate_id; + +UPDATE product_catalog product +SET status = 'archived', + attributes_json = product.attributes_json || jsonb_build_object( + 'reconciledIntoProductId', merge.keeper_id, + 'reconciledAt', NOW() + ), + updated_at = NOW() +FROM tmp_manual_product_merge merge +WHERE product.id = merge.duplicate_id; + +-- 人工配置统一挂到唯一 active man: 标准商品。 +UPDATE manual_fulfillment_configs cfg +SET canonical_product_id = product.id, + updated_at = NOW() +FROM product_catalog product +WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) + = canonicalize_internal_sku(cfg.sku_key) + AND cfg.canonical_product_id IS DISTINCT FROM product.id; + +UPDATE manual_sku_registry registry +SET config_id = cfg.id, + updated_at = NOW() +FROM manual_fulfillment_configs cfg +JOIN product_catalog product ON product.id = cfg.canonical_product_id +WHERE product.status = 'active' + AND product.canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(registry.sku_key) + = canonicalize_internal_sku(substring(product.canonical_sku from 5)); + +COMMENT ON COLUMN product_catalog.attributes_json IS + '标准商品扩展信息;人工商品归档时记录 reconciledIntoProductId 便于追溯'; + +DROP TABLE IF EXISTS pg_temp.tmp_manual_product_merge; diff --git a/apps/backend/src/db/migrations/136_reconcile_channel_products_and_work_costs.sql b/apps/backend/src/db/migrations/136_reconcile_channel_products_and_work_costs.sql new file mode 100644 index 00000000..c550830f --- /dev/null +++ b/apps/backend/src/db/migrations/136_reconcile_channel_products_and_work_costs.sql @@ -0,0 +1,139 @@ +-- 136_reconcile_channel_products_and_work_costs.sql +-- +-- 渠道商品与成本模型归一: +-- 1. 已启用的第三方卡券规则必须有 active tpv: 标准商品; +-- 2. 接单报酬成本版本不得挂在 man: 人工商品上;无快照引用的历史错误版本退休; +-- 3. 保留有快照引用的历史数据,不删除订单、成本版本或成本快照。 + +-- 卡券规则已有、但标准商品缺失时补建商品。 +INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at +) +SELECT + 'tpv:' || canonicalize_internal_sku(channel.internal_sku), + BTRIM(channel.internal_sku), + '卡券', + '张', + 'active', + '{"source":"voucher_rule","auto_created":true,"reconciled":true}'::jsonb, + NOW(), + NOW() +FROM internal_sku_channels channel +WHERE channel.channel = 'third_party_voucher' + AND channel.enabled = TRUE + AND canonicalize_internal_sku(channel.internal_sku) <> '' + AND char_length('tpv:' || canonicalize_internal_sku(channel.internal_sku)) <= 64 + AND NOT EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.status = 'active' + AND product.canonical_sku LIKE 'tpv:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) + = canonicalize_internal_sku(channel.internal_sku) + ) + AND NOT EXISTS ( + SELECT 1 + FROM internal_sku_channels other + WHERE other.enabled = TRUE + AND other.is_fallback = FALSE + AND other.channel <> 'third_party_voucher' + AND canonicalize_internal_sku(other.internal_sku) + = canonicalize_internal_sku(channel.internal_sku) + ) +ON CONFLICT (canonical_sku) DO UPDATE SET + status = 'active', + canonical_name = CASE + WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name + ELSE product_catalog.canonical_name + END, + updated_at = EXCLUDED.updated_at; + +-- 卡券规则统一挂到 active 标准商品,并补齐商品渠道能力。 +UPDATE internal_sku_channels channel +SET canonical_product_id = product.id, + priority = 10, + is_fallback = FALSE, + config_json = channel.config_json || jsonb_build_object( + 'voucherSku', + canonicalize_internal_sku(channel.internal_sku) + ), + updated_at = NOW() +FROM product_catalog product +WHERE channel.channel = 'third_party_voucher' + AND channel.enabled = TRUE + AND product.status = 'active' + AND product.canonical_sku LIKE 'tpv:%' + AND canonicalize_internal_sku(substring(product.canonical_sku from 5)) + = canonicalize_internal_sku(channel.internal_sku) + AND ( + channel.canonical_product_id IS DISTINCT FROM product.id + OR channel.priority <> 10 + OR channel.is_fallback + ); + +INSERT INTO product_channel_capabilities ( + product_id, channel, priority, is_fallback, enabled, source, created_at, updated_at +) +SELECT + channel.canonical_product_id, + 'third_party_voucher', + 10, + FALSE, + TRUE, + 'voucher', + NOW(), + NOW() +FROM internal_sku_channels channel +JOIN product_catalog product + ON product.id = channel.canonical_product_id + AND product.status = 'active' +WHERE channel.channel = 'third_party_voucher' + AND channel.enabled = TRUE + AND channel.canonical_product_id IS NOT NULL +ON CONFLICT (product_id, channel) DO UPDATE SET + enabled = TRUE, + priority = 10, + is_fallback = FALSE, + updated_at = EXCLUDED.updated_at; + +-- 无法补齐标准商品的启用卡券规则必须停用,否则会继续把订单路由到无商品渠道。 +UPDATE internal_sku_channels channel +SET enabled = FALSE, + is_fallback = FALSE, + note = CASE + WHEN BTRIM(channel.note) = '' THEN '标准商品缺失,迁移 136 自动停用' + ELSE channel.note + END, + updated_at = NOW() +WHERE channel.channel = 'third_party_voucher' + AND channel.enabled = TRUE + AND NOT EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.id = channel.canonical_product_id + AND product.status = 'active' + AND product.canonical_sku LIKE 'tpv:%' + ); + +-- 早期错误地把“接单报酬”成本挂到了人工商品。无快照引用时直接退休。 +UPDATE cost_versions version +SET status = 'retired', + change_reason = CASE + WHEN version.change_reason = '' THEN '清理错误挂到人工商品的接单报酬成本版本' + ELSE version.change_reason + END, + updated_at = NOW() +FROM cost_items item, + product_catalog product +WHERE version.cost_item_id = item.id + AND version.canonical_product_id = product.id + AND version.status = 'active' + AND item.cost_model = 'work_order_reward' + AND product.canonical_sku LIKE 'man:%' + AND NOT EXISTS ( + SELECT 1 FROM cost_snapshots snapshot WHERE snapshot.cost_version_id = version.id + ); + +COMMENT ON COLUMN internal_sku_channels.canonical_product_id IS + '渠道规则关联的标准商品;启用规则必须指向 active 标准商品,历史归档仍保留关联'; 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/db/migrations/138_fulfillment_current_attempt.sql b/apps/backend/src/db/migrations/138_fulfillment_current_attempt.sql new file mode 100644 index 00000000..03cc5a64 --- /dev/null +++ b/apps/backend/src/db/migrations/138_fulfillment_current_attempt.sql @@ -0,0 +1,54 @@ +-- 138_fulfillment_current_attempt.sql +-- +-- 履约尝试指针: +-- 1. 每个订单项单元显式记录尝试序号; +-- 2. 同一订单项单元只允许一个当前尝试; +-- 3. 新尝试创建时自动把旧尝试标为非当前,历史仍完整保留。 + +ALTER TABLE fulfillment_tasks + ADD COLUMN IF NOT EXISTS attempt_no INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS is_current BOOLEAN NOT NULL DEFAULT TRUE; + +WITH ranked AS ( + SELECT + id, + order_item_id, + unit_index, + ROW_NUMBER() OVER ( + PARTITION BY order_item_id, unit_index + ORDER BY id + )::int AS attempt_no, + ROW_NUMBER() OVER ( + PARTITION BY order_item_id, unit_index + ORDER BY id DESC + ) AS current_rank + FROM fulfillment_tasks +), +duplicate_units AS ( + SELECT order_item_id, unit_index + FROM fulfillment_tasks + GROUP BY order_item_id, unit_index + HAVING COUNT(*) > 1 +) +UPDATE fulfillment_tasks task +SET attempt_no = ranked.attempt_no, + is_current = ranked.current_rank = 1 +FROM ranked +JOIN duplicate_units duplicate + ON duplicate.order_item_id = ranked.order_item_id + AND duplicate.unit_index = ranked.unit_index +WHERE task.id = ranked.id +; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_fulfillment_tasks_current_unit + ON fulfillment_tasks (order_item_id, unit_index) + WHERE is_current = TRUE; + +CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_attempt_history + ON fulfillment_tasks (order_item_id, unit_index, attempt_no DESC, id DESC); + +COMMENT ON COLUMN fulfillment_tasks.attempt_no IS + '同一订单项单元的履约尝试序号,从 1 开始递增'; + +COMMENT ON COLUMN fulfillment_tasks.is_current IS + '是否为该订单项单元当前尝试;历史尝试保留但不再标记为当前'; diff --git a/apps/backend/src/db/migrations/139_fulfillment_automation_cutoff.sql b/apps/backend/src/db/migrations/139_fulfillment_automation_cutoff.sql new file mode 100644 index 00000000..caa37899 --- /dev/null +++ b/apps/backend/src/db/migrations/139_fulfillment_automation_cutoff.sql @@ -0,0 +1,30 @@ +-- 139_fulfillment_automation_cutoff.sql +-- +-- 历史履约数据已完成人工处置,不再参与自动判定、自动补载体或自动重试。 +-- 从本迁移执行时间起,只自动处理之后新建的订单。 + +INSERT INTO app_config_entries ( + config_key, + config_json, + source_file_path, + migrated_from_file, + created_at, + updated_at +) VALUES ( + 'fulfillment_automation_cutoff', + jsonb_build_object( + 'cutoff', + to_char( + NOW() AT TIME ZONE 'Asia/Shanghai', + 'YYYY-MM-DD"T"HH24:MI:SS.MS+08:00' + ) + ), + '', + FALSE, + NOW(), + NOW() +) +ON CONFLICT (config_key) DO NOTHING; + +COMMENT ON TABLE app_config_entries IS + '运行时配置中心,保存从 JSON 文件迁移来的平台和系统配置;fulfillment_automation_cutoff 是履约自动化生效边界'; diff --git a/apps/backend/src/db/migrations/140_fulfillment_state_events.sql b/apps/backend/src/db/migrations/140_fulfillment_state_events.sql new file mode 100644 index 00000000..62c984b7 --- /dev/null +++ b/apps/backend/src/db/migrations/140_fulfillment_state_events.sql @@ -0,0 +1,212 @@ +-- 140_fulfillment_state_events.sql +-- +-- 履约状态事件: +-- 1. 订单项履约核心字段每次真实变化都追加一条事件; +-- 2. 状态写入口可写入操作者、动作和原因,遗留直写至少留下 system 事件; +-- 3. 事件表不允许应用更新;父订单删除时允许随外键级联清理。 + +CREATE TABLE IF NOT EXISTS fulfillment_state_events ( + id BIGSERIAL PRIMARY KEY, + order_item_id BIGINT NOT NULL REFERENCES order_items(id) ON DELETE CASCADE, + order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + actor_type TEXT NOT NULL DEFAULT 'system', + actor_id TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + previous_status TEXT, + next_status TEXT, + previous_channel TEXT, + next_channel TEXT, + previous_source TEXT, + next_source TEXT, + previous_locked BOOLEAN, + next_locked BOOLEAN, + previous_pending_reason TEXT, + next_pending_reason TEXT, + previous_pending_detail JSONB, + next_pending_detail JSONB, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_fulfillment_state_events_item + ON fulfillment_state_events (order_item_id, id DESC); + +CREATE INDEX IF NOT EXISTS idx_fulfillment_state_events_order + ON fulfillment_state_events (order_id, created_at DESC, id DESC); + +COMMENT ON TABLE fulfillment_state_events IS + '履约状态追加事件:记录订单项履约状态、渠道、锁定和待办原因的真实变化'; +COMMENT ON COLUMN fulfillment_state_events.payload_json IS + '本次变化字段的前后值;空对象表示仅有未知扩展字段变化'; + +CREATE OR REPLACE FUNCTION record_fulfillment_state_event() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + event_type_value TEXT; + actor_type_value TEXT; + actor_id_value TEXT; + reason_value TEXT; + changed_fields JSONB; +BEGIN + IF ROW( + OLD.fulfillment_status, + OLD.fulfillment_channel, + OLD.fulfillment_source, + OLD.fulfillment_locked, + OLD.fulfillment_pending_reason, + OLD.fulfillment_pending_detail, + OLD.fulfillment_reason, + OLD.fulfillment_decided_by, + OLD.fulfillment_decided_at, + OLD.original_channel, + OLD.channel_switch_reason, + OLD.channel_switched_at + ) IS NOT DISTINCT FROM ROW( + NEW.fulfillment_status, + NEW.fulfillment_channel, + NEW.fulfillment_source, + NEW.fulfillment_locked, + NEW.fulfillment_pending_reason, + NEW.fulfillment_pending_detail, + NEW.fulfillment_reason, + NEW.fulfillment_decided_by, + NEW.fulfillment_decided_at, + NEW.original_channel, + NEW.channel_switch_reason, + NEW.channel_switched_at + ) THEN + RETURN NEW; + END IF; + + event_type_value := COALESCE( + NULLIF(BTRIM(current_setting('app.fulfillment_event_type', TRUE)), ''), + 'fulfillment_state_changed' + ); + actor_type_value := COALESCE( + NULLIF(BTRIM(current_setting('app.fulfillment_actor_type', TRUE)), ''), + 'system' + ); + actor_id_value := COALESCE( + current_setting('app.fulfillment_actor_id', TRUE), + '' + ); + reason_value := COALESCE( + current_setting('app.fulfillment_reason', TRUE), + '' + ); + + changed_fields := jsonb_strip_nulls(jsonb_build_object( + 'fulfillmentStatus', CASE + WHEN OLD.fulfillment_status IS DISTINCT FROM NEW.fulfillment_status + THEN jsonb_build_object('from', OLD.fulfillment_status, 'to', NEW.fulfillment_status) + END, + 'fulfillmentChannel', CASE + WHEN OLD.fulfillment_channel IS DISTINCT FROM NEW.fulfillment_channel + THEN jsonb_build_object('from', OLD.fulfillment_channel, 'to', NEW.fulfillment_channel) + END, + 'fulfillmentSource', CASE + WHEN OLD.fulfillment_source IS DISTINCT FROM NEW.fulfillment_source + THEN jsonb_build_object('from', OLD.fulfillment_source, 'to', NEW.fulfillment_source) + END, + 'fulfillmentLocked', CASE + WHEN OLD.fulfillment_locked IS DISTINCT FROM NEW.fulfillment_locked + THEN jsonb_build_object('from', OLD.fulfillment_locked, 'to', NEW.fulfillment_locked) + END, + 'fulfillmentPendingReason', CASE + WHEN OLD.fulfillment_pending_reason IS DISTINCT FROM NEW.fulfillment_pending_reason + THEN jsonb_build_object( + 'from', OLD.fulfillment_pending_reason, + 'to', NEW.fulfillment_pending_reason + ) + END, + 'fulfillmentPendingDetail', CASE + WHEN OLD.fulfillment_pending_detail IS DISTINCT FROM NEW.fulfillment_pending_detail + THEN jsonb_build_object( + 'from', OLD.fulfillment_pending_detail, + 'to', NEW.fulfillment_pending_detail + ) + END + )); + + INSERT INTO fulfillment_state_events ( + order_item_id, + order_id, + event_type, + actor_type, + actor_id, + reason, + previous_status, + next_status, + previous_channel, + next_channel, + previous_source, + next_source, + previous_locked, + next_locked, + previous_pending_reason, + next_pending_reason, + previous_pending_detail, + next_pending_detail, + payload_json + ) VALUES ( + NEW.id, + NEW.order_id, + LEFT(event_type_value, 100), + LEFT(actor_type_value, 32), + LEFT(actor_id_value, 200), + LEFT(reason_value, 500), + OLD.fulfillment_status, + NEW.fulfillment_status, + OLD.fulfillment_channel, + NEW.fulfillment_channel, + OLD.fulfillment_source, + NEW.fulfillment_source, + OLD.fulfillment_locked, + NEW.fulfillment_locked, + OLD.fulfillment_pending_reason, + NEW.fulfillment_pending_reason, + OLD.fulfillment_pending_detail, + NEW.fulfillment_pending_detail, + changed_fields + ); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_order_items_fulfillment_state_event ON order_items; +CREATE TRIGGER trg_order_items_fulfillment_state_event +AFTER UPDATE OF + fulfillment_status, + fulfillment_channel, + fulfillment_source, + fulfillment_locked, + fulfillment_pending_reason, + fulfillment_pending_detail, + fulfillment_reason, + fulfillment_decided_by, + fulfillment_decided_at, + original_channel, + channel_switch_reason, + channel_switched_at +ON order_items +FOR EACH ROW +EXECUTE FUNCTION record_fulfillment_state_event(); + +CREATE OR REPLACE FUNCTION reject_fulfillment_state_event_mutation() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'fulfillment_state_events is append-only'; +END; +$$; + +DROP TRIGGER IF EXISTS trg_fulfillment_state_events_append_only ON fulfillment_state_events; +CREATE TRIGGER trg_fulfillment_state_events_append_only +BEFORE UPDATE ON fulfillment_state_events +FOR EACH ROW +EXECUTE FUNCTION reject_fulfillment_state_event_mutation(); diff --git a/apps/backend/src/db/migrations/141_fulfillment_shadow_model.sql b/apps/backend/src/db/migrations/141_fulfillment_shadow_model.sql new file mode 100644 index 00000000..a5539320 --- /dev/null +++ b/apps/backend/src/db/migrations/141_fulfillment_shadow_model.sql @@ -0,0 +1,810 @@ +-- 141_fulfillment_shadow_model.sql +-- +-- 正式履约影子模型: +-- 1. fulfillments 保存订单项当前履约事实; +-- 2. fulfillment_attempts 保存任务/工单的执行尝试; +-- 3. fulfillment_issues 保存当前结构化异常,并保留已解决历史; +-- 4. 现有 order_items / fulfillment_tasks / work_orders 继续作为真源, +-- 通过触发器双写影子表,读路径暂不切换。 + +-- 原视图把 route_status 错写成“内部 SKU 是否有配置”,导致已经明确指定渠道的 +-- 订单项仍被标记为 missing_channel。保留原视图为 raw 层,外层按“已有渠道即已路由” +-- 修正路由口径,原视图名和字段保持不变。 +ALTER VIEW v_fulfillment_item_state RENAME TO v_fulfillment_item_state_raw; + +CREATE OR REPLACE VIEW v_fulfillment_item_state AS +SELECT + raw.order_item_id, + raw.order_id, + raw.local_order_id, + raw.provider, + raw.platform, + raw.shop_id, + raw.shop_name, + raw.platform_order_id, + raw.pay_status, + raw.order_status, + raw.fulfillment_status, + raw.internal_sku, + raw.intended_channel, + raw.stored_channel, + raw.configured_channel, + CASE + WHEN COALESCE(raw.route_status, '') = 'missing_internal_sku' THEN 'missing_internal_sku' + WHEN raw.intended_channel IS NOT NULL THEN 'configured' + ELSE 'missing_channel' + END AS route_status, + raw.carrier_status, + raw.current_attempt_type, + raw.current_attempt_id, + raw.current_attempt_status, + raw.current_attempt_result_code, + raw.latest_task_id, + raw.latest_task_executor_key, + raw.latest_task_status, + raw.latest_task_result_code, + raw.latest_work_order_id, + raw.latest_work_order_status, + CASE + WHEN raw.route_status = 'missing_channel' + AND raw.intended_channel IS NOT NULL + THEN '' + ELSE raw.issue_code + END AS issue_code, + CASE + WHEN raw.route_status = 'missing_channel' + AND raw.intended_channel IS NOT NULL + THEN '' + ELSE raw.issue_category + END AS issue_category, + CASE + WHEN raw.route_status = 'missing_channel' + AND raw.intended_channel IS NOT NULL + THEN FALSE + ELSE raw.retryable + END AS retryable, + CASE + WHEN raw.route_status = 'missing_channel' + AND raw.intended_channel IS NOT NULL + THEN FALSE + ELSE raw.auto_retryable + END AS auto_retryable, + CASE + WHEN raw.route_status = 'missing_channel' + AND raw.intended_channel IS NOT NULL + THEN '' + ELSE raw.owner_role + END AS owner_role, + raw.fulfillment_locked, + raw.lock_reason, + raw.fulfillment_pending_reason, + raw.fulfillment_pending_detail, + raw.fulfillment_source, + raw.sku_code, + raw.sku_name, + raw.quantity, + raw.revenue_amount_fen, + raw.created_at, + raw.updated_at, + raw.age_minutes +FROM v_fulfillment_item_state_raw raw; + +COMMENT ON VIEW v_fulfillment_item_state IS + '履约统一读模型:每订单项一行;明确渠道即视为已路由,渠道配置缺失只作为异常明细'; + +CREATE TABLE IF NOT EXISTS fulfillments ( + id BIGSERIAL PRIMARY KEY, + order_item_id BIGINT NOT NULL UNIQUE REFERENCES order_items(id) ON DELETE CASCADE, + order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + internal_sku TEXT NOT NULL DEFAULT '', + route_status TEXT NOT NULL DEFAULT 'unrouted' + CHECK (route_status IN ('unrouted', 'configured')), + channel TEXT + CHECK ( + channel IS NULL + OR channel IN ('work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account') + ), + carrier_status TEXT NOT NULL DEFAULT 'missing' + CHECK (carrier_status IN ('not_required', 'missing', 'creating', 'ready', 'failed', 'terminal')), + current_attempt_type TEXT NOT NULL DEFAULT '' + CHECK (current_attempt_type IN ('', 'task', 'work_order')), + current_attempt_source_id BIGINT, + current_attempt_status TEXT NOT NULL DEFAULT '', + lock_reason TEXT NOT NULL DEFAULT '', + issue_code TEXT NOT NULL DEFAULT '', + issue_category TEXT NOT NULL DEFAULT '', + retryable BOOLEAN NOT NULL DEFAULT FALSE, + owner_role TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CHECK ( + (current_attempt_type = '' AND current_attempt_source_id IS NULL) + OR (current_attempt_type <> '' AND current_attempt_source_id IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_fulfillments_order + ON fulfillments (order_id, updated_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_fulfillments_issue + ON fulfillments (issue_category, carrier_status, updated_at DESC) + WHERE issue_code <> ''; +CREATE INDEX IF NOT EXISTS idx_fulfillments_channel + ON fulfillments (channel, carrier_status, updated_at DESC); + +COMMENT ON TABLE fulfillments IS + '履约主记录:每订单项一行,拆分路由、渠道、载体、当前尝试和当前异常摘要'; + +CREATE TABLE IF NOT EXISTS fulfillment_attempts ( + id BIGSERIAL PRIMARY KEY, + fulfillment_id BIGINT NOT NULL REFERENCES fulfillments(id) ON DELETE CASCADE, + unit_index INTEGER NOT NULL DEFAULT 1, + attempt_no INTEGER NOT NULL, + attempt_type TEXT NOT NULL CHECK (attempt_type IN ('task', 'work_order')), + source_id BIGINT NOT NULL, + source_attempt_no INTEGER NOT NULL DEFAULT 1, + executor_key TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + result_code TEXT NOT NULL DEFAULT '', + result_message TEXT NOT NULL DEFAULT '', + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ, + is_current BOOLEAN NOT NULL DEFAULT FALSE, + context_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (attempt_type, source_id), + UNIQUE (fulfillment_id, unit_index, attempt_no), + CHECK (attempt_no > 0), + CHECK (unit_index > 0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_fulfillment_attempts_current_unit + ON fulfillment_attempts (fulfillment_id, unit_index) + WHERE is_current = TRUE; +CREATE INDEX IF NOT EXISTS idx_fulfillment_attempts_history + ON fulfillment_attempts (fulfillment_id, unit_index, attempt_no DESC); +CREATE INDEX IF NOT EXISTS idx_fulfillment_attempts_status + ON fulfillment_attempts (attempt_type, status, updated_at DESC); + +COMMENT ON TABLE fulfillment_attempts IS + '履约执行尝试:任务和接单工单的追加式历史,同一订单项单元仅一个当前尝试'; + +CREATE TABLE IF NOT EXISTS fulfillment_issues ( + id BIGSERIAL PRIMARY KEY, + fulfillment_id BIGINT NOT NULL REFERENCES fulfillments(id) ON DELETE CASCADE, + issue_code TEXT NOT NULL, + issue_category TEXT NOT NULL DEFAULT '', + severity TEXT NOT NULL DEFAULT 'warning' + CHECK (severity IN ('info', 'warning', 'error', 'critical')), + source TEXT NOT NULL DEFAULT 'read_model', + retryable BOOLEAN NOT NULL DEFAULT FALSE, + auto_retryable BOOLEAN NOT NULL DEFAULT FALSE, + owner_role TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'acknowledged', 'waiting', 'resolved')), + detail_json JSONB NOT NULL DEFAULT '{}'::jsonb, + first_seen_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + UNIQUE (fulfillment_id, issue_code) +); + +CREATE INDEX IF NOT EXISTS idx_fulfillment_issues_queue + ON fulfillment_issues (status, issue_category, last_seen_at DESC) + WHERE status <> 'resolved'; +CREATE INDEX IF NOT EXISTS idx_fulfillment_issues_code + ON fulfillment_issues (issue_code, status, last_seen_at DESC); + +COMMENT ON TABLE fulfillment_issues IS + '履约结构化异常:当前开放异常按 issue_code 唯一,解决后保留历史'; + +ALTER TABLE fulfillment_state_events + ADD COLUMN IF NOT EXISTS fulfillment_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_fulfillment_state_events_fulfillment + ON fulfillment_state_events (fulfillment_id, id DESC) + WHERE fulfillment_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION sync_fulfillment_shadow(p_order_item_id BIGINT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + item_record RECORD; + state_record RECORD; + fulfillment_id_value BIGINT; +BEGIN + SELECT + oi.id, + oi.order_id, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku, + oi.created_at + INTO item_record + FROM order_items oi + WHERE oi.id = p_order_item_id; + + IF NOT FOUND THEN + RETURN; + END IF; + + SELECT state.* + INTO state_record + FROM v_fulfillment_item_state state + WHERE state.order_item_id = item_record.id; + + IF FOUND THEN + INSERT INTO fulfillments ( + order_item_id, + order_id, + internal_sku, + route_status, + channel, + carrier_status, + current_attempt_type, + current_attempt_source_id, + current_attempt_status, + lock_reason, + issue_code, + issue_category, + retryable, + owner_role, + created_at, + updated_at + ) VALUES ( + item_record.id, + item_record.order_id, + COALESCE(state_record.internal_sku, ''), + CASE + WHEN state_record.route_status = 'configured' THEN 'configured' + ELSE 'unrouted' + END, + state_record.intended_channel, + state_record.carrier_status, + state_record.current_attempt_type, + NULLIF(state_record.current_attempt_id, 0), + state_record.current_attempt_status, + state_record.lock_reason, + state_record.issue_code, + state_record.issue_category, + state_record.retryable, + state_record.owner_role, + item_record.created_at, + NOW() + ) + ON CONFLICT (order_item_id) DO UPDATE SET + order_id = EXCLUDED.order_id, + internal_sku = EXCLUDED.internal_sku, + route_status = EXCLUDED.route_status, + channel = EXCLUDED.channel, + carrier_status = EXCLUDED.carrier_status, + current_attempt_type = EXCLUDED.current_attempt_type, + current_attempt_source_id = EXCLUDED.current_attempt_source_id, + current_attempt_status = EXCLUDED.current_attempt_status, + lock_reason = EXCLUDED.lock_reason, + issue_code = EXCLUDED.issue_code, + issue_category = EXCLUDED.issue_category, + retryable = EXCLUDED.retryable, + owner_role = EXCLUDED.owner_role, + updated_at = NOW() + RETURNING id INTO fulfillment_id_value; + + IF state_record.issue_code <> '' THEN + INSERT INTO fulfillment_issues ( + fulfillment_id, + issue_code, + issue_category, + severity, + source, + retryable, + auto_retryable, + owner_role, + status, + detail_json, + first_seen_at, + last_seen_at, + resolved_at + ) VALUES ( + fulfillment_id_value, + state_record.issue_code, + state_record.issue_category, + CASE + WHEN state_record.issue_category IN ('after_sales', 'system') THEN 'error' + WHEN state_record.issue_category = 'carrier' THEN 'warning' + ELSE 'info' + END, + 'read_model', + state_record.retryable, + state_record.auto_retryable, + state_record.owner_role, + 'open', + COALESCE(state_record.fulfillment_pending_detail, '{}'::jsonb), + state_record.created_at, + NOW(), + NULL + ) + ON CONFLICT (fulfillment_id, issue_code) DO UPDATE SET + issue_category = EXCLUDED.issue_category, + severity = EXCLUDED.severity, + retryable = EXCLUDED.retryable, + auto_retryable = EXCLUDED.auto_retryable, + owner_role = EXCLUDED.owner_role, + status = 'open', + detail_json = EXCLUDED.detail_json, + last_seen_at = NOW(), + resolved_at = NULL; + + UPDATE fulfillment_issues + SET status = 'resolved', + resolved_at = NOW() + WHERE fulfillment_id = fulfillment_id_value + AND issue_code <> state_record.issue_code + AND status <> 'resolved'; + ELSE + UPDATE fulfillment_issues + SET status = 'resolved', + resolved_at = NOW() + WHERE fulfillment_id = fulfillment_id_value + AND status <> 'resolved'; + END IF; + ELSE + INSERT INTO fulfillments ( + order_item_id, + order_id, + internal_sku, + route_status, + channel, + carrier_status, + current_attempt_type, + current_attempt_source_id, + current_attempt_status, + lock_reason, + issue_code, + issue_category, + retryable, + owner_role, + created_at, + updated_at + ) VALUES ( + item_record.id, + item_record.order_id, + COALESCE(item_record.internal_sku, ''), + CASE WHEN COALESCE(item_record.internal_sku, '') = '' THEN 'unrouted' ELSE 'configured' END, + NULL, + 'terminal', + '', + NULL, + '', + '', + '', + '', + FALSE, + '', + item_record.created_at, + NOW() + ) + ON CONFLICT (order_item_id) DO UPDATE SET + route_status = EXCLUDED.route_status, + channel = NULL, + carrier_status = 'terminal', + current_attempt_type = '', + current_attempt_source_id = NULL, + current_attempt_status = '', + lock_reason = '', + issue_code = '', + issue_category = '', + retryable = FALSE, + owner_role = '', + updated_at = NOW() + RETURNING id INTO fulfillment_id_value; + + UPDATE fulfillment_issues + SET status = 'resolved', + resolved_at = NOW() + WHERE fulfillment_id = fulfillment_id_value + AND status <> 'resolved'; + END IF; +END; +$$; + +-- 先为当前活跃订单项建立履约主记录和结构化异常。 +SELECT sync_fulfillment_shadow(state.order_item_id) +FROM v_fulfillment_item_state state; + +WITH source_attempts AS ( + SELECT + task.order_item_id, + task.unit_index, + task.created_at AS occurred_at, + 'task'::text AS attempt_type, + task.id AS source_id, + task.attempt_no AS source_attempt_no, + task.executor_key, + task.task_status AS status, + task.result_code, + task.result_message, + task.created_at AS started_at, + CASE + WHEN task.task_status IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) THEN task.updated_at + ELSE NULL + END AS finished_at, + task.is_current, + task.context_json, + task.created_at, + task.updated_at + FROM fulfillment_tasks task + JOIN fulfillments fulfillment ON fulfillment.order_item_id = task.order_item_id + + UNION ALL + + SELECT + work_order.order_item_id, + 1, + work_order.created_at, + 'work_order'::text, + work_order.id, + 1, + 'work_order'::text, + work_order.status, + ''::text, + work_order.problem_note, + work_order.created_at, + CASE + WHEN work_order.status IN ('accepted', 'cancelled', 'closed') THEN work_order.updated_at + ELSE NULL + END, + work_order.status <> 'cancelled', + jsonb_build_object( + 'workOrderNo', work_order.work_order_no, + 'productName', work_order.product_name + ), + work_order.created_at, + work_order.updated_at + FROM work_orders work_order + JOIN fulfillments fulfillment ON fulfillment.order_item_id = work_order.order_item_id + WHERE work_order.order_item_id IS NOT NULL +), +ranked AS ( + SELECT + source_attempts.*, + ROW_NUMBER() OVER ( + PARTITION BY order_item_id, unit_index + ORDER BY occurred_at, attempt_type, source_id + )::int AS attempt_no + FROM source_attempts +) +INSERT INTO fulfillment_attempts ( + fulfillment_id, + unit_index, + attempt_no, + attempt_type, + source_id, + source_attempt_no, + executor_key, + status, + result_code, + result_message, + started_at, + finished_at, + is_current, + context_json, + created_at, + updated_at +) +SELECT + fulfillment.id, + ranked.unit_index, + ranked.attempt_no, + ranked.attempt_type, + ranked.source_id, + ranked.source_attempt_no, + ranked.executor_key, + ranked.status, + ranked.result_code, + ranked.result_message, + ranked.started_at, + ranked.finished_at, + CASE + WHEN ranked.attempt_type = fulfillment.current_attempt_type + AND ranked.source_id = fulfillment.current_attempt_source_id + THEN TRUE + ELSE FALSE + END, + ranked.context_json, + ranked.created_at, + ranked.updated_at +FROM ranked +JOIN fulfillments fulfillment ON fulfillment.order_item_id = ranked.order_item_id +ON CONFLICT (attempt_type, source_id) DO NOTHING; + +UPDATE fulfillment_state_events event +SET fulfillment_id = fulfillment.id +FROM fulfillments fulfillment +WHERE fulfillment.order_item_id = event.order_item_id + AND event.fulfillment_id IS NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_fulfillment_state_events_fulfillment' + ) THEN + ALTER TABLE fulfillment_state_events + ADD CONSTRAINT fk_fulfillment_state_events_fulfillment + FOREIGN KEY (fulfillment_id) REFERENCES fulfillments(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE OR REPLACE FUNCTION assign_fulfillment_event_shadow() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.fulfillment_id IS NULL THEN + SELECT fulfillment.id + INTO NEW.fulfillment_id + FROM fulfillments fulfillment + WHERE fulfillment.order_item_id = NEW.order_item_id; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_fulfillment_state_events_shadow ON fulfillment_state_events; +CREATE TRIGGER trg_fulfillment_state_events_shadow +BEFORE INSERT ON fulfillment_state_events +FOR EACH ROW +EXECUTE FUNCTION assign_fulfillment_event_shadow(); + +CREATE OR REPLACE FUNCTION sync_fulfillment_shadow_from_order_item() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM sync_fulfillment_shadow(NEW.id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_order_items_fulfillment_shadow ON order_items; +CREATE TRIGGER trg_order_items_fulfillment_shadow +AFTER INSERT OR UPDATE OF + fulfillment_status, + fulfillment_channel, + fulfillment_source, + fulfillment_locked, + fulfillment_pending_reason, + fulfillment_pending_detail, + fulfillment_reason, + manual_sku_key, + sku_code, + sku_name +ON order_items +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_shadow_from_order_item(); + +CREATE OR REPLACE FUNCTION sync_fulfillment_task_attempt() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + fulfillment_id_value BIGINT; + existing_attempt_id BIGINT; + next_attempt_no INTEGER; +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM fulfillment_attempts + WHERE attempt_type = 'task' AND source_id = OLD.id; + PERFORM sync_fulfillment_shadow(OLD.order_item_id); + RETURN OLD; + END IF; + + PERFORM sync_fulfillment_shadow(NEW.order_item_id); + SELECT fulfillment.id + INTO fulfillment_id_value + FROM fulfillments fulfillment + WHERE fulfillment.order_item_id = NEW.order_item_id; + IF fulfillment_id_value IS NULL THEN + RETURN NEW; + END IF; + + SELECT attempt.id + INTO existing_attempt_id + FROM fulfillment_attempts attempt + WHERE attempt.attempt_type = 'task' + AND attempt.source_id = NEW.id; + + IF existing_attempt_id IS NULL THEN + SELECT COALESCE(MAX(attempt.attempt_no), 0) + 1 + INTO next_attempt_no + FROM fulfillment_attempts attempt + WHERE attempt.fulfillment_id = fulfillment_id_value + AND attempt.unit_index = NEW.unit_index; + END IF; + + UPDATE fulfillment_attempts + SET is_current = FALSE, + updated_at = NOW() + WHERE fulfillment_id = fulfillment_id_value + AND unit_index = NEW.unit_index + AND is_current = TRUE + AND NOT (attempt_type = 'task' AND source_id = NEW.id); + + INSERT INTO fulfillment_attempts ( + fulfillment_id, + unit_index, + attempt_no, + attempt_type, + source_id, + source_attempt_no, + executor_key, + status, + result_code, + result_message, + started_at, + finished_at, + is_current, + context_json, + created_at, + updated_at + ) VALUES ( + fulfillment_id_value, + NEW.unit_index, + COALESCE(next_attempt_no, 1), + 'task', + NEW.id, + NEW.attempt_no, + NEW.executor_key, + NEW.task_status, + NEW.result_code, + NEW.result_message, + NEW.created_at, + CASE + WHEN NEW.task_status IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) THEN NEW.updated_at + ELSE NULL + END, + NEW.is_current, + NEW.context_json, + NEW.created_at, + NEW.updated_at + ) + ON CONFLICT (attempt_type, source_id) DO UPDATE SET + executor_key = EXCLUDED.executor_key, + status = EXCLUDED.status, + result_code = EXCLUDED.result_code, + result_message = EXCLUDED.result_message, + finished_at = EXCLUDED.finished_at, + is_current = EXCLUDED.is_current, + context_json = EXCLUDED.context_json, + updated_at = EXCLUDED.updated_at; + + PERFORM sync_fulfillment_shadow(NEW.order_item_id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_fulfillment_tasks_shadow ON fulfillment_tasks; +CREATE TRIGGER trg_fulfillment_tasks_shadow +AFTER INSERT OR UPDATE OR DELETE ON fulfillment_tasks +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_task_attempt(); + +CREATE OR REPLACE FUNCTION sync_fulfillment_work_order_attempt() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + fulfillment_id_value BIGINT; + existing_attempt_id BIGINT; + next_attempt_no INTEGER; +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM fulfillment_attempts + WHERE attempt_type = 'work_order' AND source_id = OLD.id; + IF OLD.order_item_id IS NOT NULL THEN + PERFORM sync_fulfillment_shadow(OLD.order_item_id); + END IF; + RETURN OLD; + END IF; + + IF NEW.order_item_id IS NULL THEN + RETURN NEW; + END IF; + + PERFORM sync_fulfillment_shadow(NEW.order_item_id); + SELECT fulfillment.id + INTO fulfillment_id_value + FROM fulfillments fulfillment + WHERE fulfillment.order_item_id = NEW.order_item_id; + IF fulfillment_id_value IS NULL THEN + RETURN NEW; + END IF; + + SELECT attempt.id + INTO existing_attempt_id + FROM fulfillment_attempts attempt + WHERE attempt.attempt_type = 'work_order' + AND attempt.source_id = NEW.id; + + IF existing_attempt_id IS NULL THEN + SELECT COALESCE(MAX(attempt.attempt_no), 0) + 1 + INTO next_attempt_no + FROM fulfillment_attempts attempt + WHERE attempt.fulfillment_id = fulfillment_id_value + AND attempt.unit_index = 1; + END IF; + + UPDATE fulfillment_attempts + SET is_current = FALSE, + updated_at = NOW() + WHERE fulfillment_id = fulfillment_id_value + AND unit_index = 1 + AND is_current = TRUE + AND NOT (attempt_type = 'work_order' AND source_id = NEW.id); + + INSERT INTO fulfillment_attempts ( + fulfillment_id, + unit_index, + attempt_no, + attempt_type, + source_id, + source_attempt_no, + executor_key, + status, + result_code, + result_message, + started_at, + finished_at, + is_current, + context_json, + created_at, + updated_at + ) VALUES ( + fulfillment_id_value, + 1, + COALESCE(next_attempt_no, 1), + 'work_order', + NEW.id, + 1, + 'work_order', + NEW.status, + '', + NEW.problem_note, + NEW.created_at, + CASE + WHEN NEW.status IN ('accepted', 'cancelled', 'closed') THEN NEW.updated_at + ELSE NULL + END, + NEW.status <> 'cancelled', + jsonb_build_object( + 'workOrderNo', NEW.work_order_no, + 'productName', NEW.product_name + ), + NEW.created_at, + NEW.updated_at + ) + ON CONFLICT (attempt_type, source_id) DO UPDATE SET + status = EXCLUDED.status, + result_message = EXCLUDED.result_message, + finished_at = EXCLUDED.finished_at, + is_current = EXCLUDED.is_current, + context_json = EXCLUDED.context_json, + updated_at = EXCLUDED.updated_at; + + PERFORM sync_fulfillment_shadow(NEW.order_item_id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_work_orders_fulfillment_shadow ON work_orders; +CREATE TRIGGER trg_work_orders_fulfillment_shadow +AFTER INSERT OR UPDATE OR DELETE ON work_orders +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_work_order_attempt(); + +COMMENT ON COLUMN fulfillment_state_events.fulfillment_id IS + '正式履约影子记录;迁移 141 后新旧事件都自动关联'; diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index 66bd067c..ba634a42 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -94,8 +94,43 @@ export async function listTasksByOrderIds(orderIds: number[]): Promise { return withTransaction(async (client) => { - const taskResult = await client.query<{ id: number }>( - ` + // 同一订单项单元的创建必须串行,否则并发调度会同时算出同一尝试号, + // 再一起绕过“仅一个当前尝试”的唯一索引。 + await client.query(`SELECT id FROM order_items WHERE id = $1 FOR UPDATE`, [input.orderItemId]) + const active = await client.query<{ id: number }>( + `SELECT id + FROM fulfillment_tasks + WHERE order_item_id = $1 + AND unit_index = $2 + AND task_status NOT IN ( + 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) + ORDER BY is_current DESC, id DESC + LIMIT 1`, + [input.orderItemId, input.unitIndex], + ) + const activeTaskId = Number(active.rows[0]?.id || 0) + if (activeTaskId > 0) { + return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, activeTaskId) + } + + const nextAttempt = await client.query<{ attempt_no: number }>( + `SELECT COALESCE(MAX(attempt_no), 0) + 1 AS attempt_no + FROM fulfillment_tasks + WHERE order_item_id = $1 AND unit_index = $2`, + [input.orderItemId, input.unitIndex], + ) + await client.query( + `UPDATE fulfillment_tasks + SET is_current = FALSE + WHERE order_item_id = $1 AND unit_index = $2 AND is_current = TRUE`, + [input.orderItemId, input.unitIndex], + ) + const nextAttemptNo = Number(nextAttempt.rows[0]?.attempt_no || 1) + let taskResult: { rows: Array<{ id: number }> } + try { + taskResult = await client.query<{ id: number }>( + ` INSERT INTO fulfillment_tasks ( order_id, order_item_id, @@ -121,42 +156,54 @@ export async function createTask(input: TaskCreateInput): Promise { + const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') + const inventoryListStart = source.indexOf('export async function listConsignmentInventories') + const inventoryListBody = source.slice( + inventoryListStart, + source.indexOf('\n}\n', inventoryListStart), + ) + + assert.ok( + inventoryListBody.includes( + "ci.inbound_at >= ($${values.length}::date::timestamp AT TIME ZONE 'Asia/Shanghai')", + ), + '开始日期必须按上海时区当天零点筛选 inbound_at', + ) + assert.ok( + inventoryListBody.includes( + "ci.inbound_at < (($${values.length}::date + INTERVAL '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')", + ), + '结束日期必须筛选到次日零点之前,不能漏掉结束日当天', + ) +}) + test('匹配候选要带出寄卖单号,并且 join 之后所有列名都必须加 ci. 前缀', async () => { const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8') const start = source.indexOf('export async function listActiveConsignmentInventoriesForMatching') diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index e4a205b7..fd609b85 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -158,6 +158,8 @@ export async function listConsignmentInventories(filters: { allowMatching?: boolean | undefined workerId?: number | undefined keyword?: string | undefined + dateFrom?: string | undefined + dateTo?: string | undefined page?: number | undefined pageSize?: number | undefined }): Promise<{ items: ConsignmentInventoryRow[]; total: number }> { @@ -231,6 +233,18 @@ export async function listConsignmentInventories(filters: { OR ccs.name ILIKE $${values.length})`, ) } + if (filters.dateFrom) { + values.push(filters.dateFrom) + conditions.push( + `ci.inbound_at >= ($${values.length}::date::timestamp AT TIME ZONE 'Asia/Shanghai')`, + ) + } + if (filters.dateTo) { + values.push(filters.dateTo) + conditions.push( + `ci.inbound_at < (($${values.length}::date + INTERVAL '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')`, + ) + } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' // 计数与列表必须使用同一组 join,否则新增的提报单 / 供货打手关键字会直接报错。 diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index 213d4cc7..a790e0e3 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -17,6 +17,7 @@ import { createAdminProduct, deleteAdminChannelMapping, deleteAdminProduct, + deleteAdminVoucherProduct, getAdminChannelMappingDiagnostics, listAdminChannelMappings, listAdminProducts, @@ -416,4 +417,30 @@ router.post( }), ) +router.delete( + '/cost-center/voucher-products/:productId', + requireAdminPermission('finance.config'), + createJsonHandler((req) => deleteAdminVoucherProduct(idOf(req, 'productId')), { + successMessage: '第三方卡券商品已处理', + errorMessage: '删除第三方卡券商品失败', + scope: '[admin/cost-center/voucher-products/:productId]', + audit: (req, data) => { + const result = data as { + action?: string + product?: { id?: number; canonicalSku?: string } + } + return { + action: + result.action === 'archived' ? 'voucher_product_archived' : 'voucher_product_deleted', + targetType: 'product_catalog', + targetId: String(result.product?.id || req.params.productId || ''), + data: { + canonicalSku: result.product?.canonicalSku || '', + action: result.action || '', + }, + } + }, + }), +) + export default router diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 55f0f25f..8384488f 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -20,6 +20,7 @@ import { retryPendingCarrierGroup, switchChannelForOrderItems, unlockChannel, + unlockPendingChannelGroup, upsertChannelRule, } from '../../services/fulfillment/channel-assign-service.js' import { @@ -86,6 +87,9 @@ router.get( createJsonHandler( (req) => listVoucherConsumeJobs({ + keyword: String(req.query.keyword || ''), + provider: String(req.query.provider || ''), + channel: String(req.query.channel || ''), status: String(req.query.status || ''), page: Number(req.query.page), pageSize: Number(req.query.pageSize), @@ -135,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', @@ -152,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, }), { @@ -200,6 +206,11 @@ router.post( (req) => retryPendingCarrierGroup({ 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: '履约载体重试完成', @@ -215,6 +226,32 @@ router.post( ), ) +router.post( + '/fulfillment/pending/unlock', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => { + const body = bodyOf(req) + return unlockPendingChannelGroup({ + groupKey: String(body.groupKey || ''), + internalSku: String(body.internalSku || ''), + issueCategory: String(body.issueCategory || ''), + }) + }, + { + successMessage: '运营锁定已解除', + errorMessage: '解除运营锁定失败', + scope: '[admin/fulfillment/pending/unlock]', + audit: (req, data) => ({ + action: 'fulfillment.unlock_pending_group', + targetType: 'pending_group', + targetId: String(bodyOf(req).groupKey || bodyOf(req).internalSku || ''), + data: data as Record, + }), + }, + ), +) + router.get( '/fulfillment/internal-sku-mappings', requireAdminPermission('finance.view'), @@ -314,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/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index cadd4e45..f5418ebe 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -348,6 +348,7 @@ export async function getAdminTasks( roleId: String(query.roleId || '').trim(), dateFrom: normalizeDateQuery(query.dateFrom), dateTo: normalizeDateQuery(query.dateTo, true), + currentOnly: String(query.currentOnly ?? 'true') !== 'false', }) const viewerContext = createAdminViewerContext(session) diff --git a/apps/backend/src/services/cost-center/affiliate-product-service.ts b/apps/backend/src/services/cost-center/affiliate-product-service.ts index 8ebe7d6f..47ffe772 100644 --- a/apps/backend/src/services/cost-center/affiliate-product-service.ts +++ b/apps/backend/src/services/cost-center/affiliate-product-service.ts @@ -7,16 +7,22 @@ import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js import { nowIso } from '../../utils/time.js' import { rebuildChannelSalesDailyFacts } from '../finance/sales-facts-channel-service.js' import { AFFILIATE_DASH_PRICES_BASELINE } from './affiliate-dash-prices-baseline.js' +import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js' /** * 清理历史上因为把商品中文名误当成 SKU 插入的非标脏商品,并将误关联的订单项重新归一到标准干净商品。 */ export async function cleanupDirtyAffiliateDashProducts() { - await query(` + const dirtyMappings = await query<{ + order_item_id: number + clean_product_id: number + expected_product_id: number + }>(` WITH dirty_map AS ( SELECT DISTINCT ON (oi.id) oi.id AS order_item_id, - clean.id AS clean_product_id + clean.id AS clean_product_id, + dirty.id AS expected_product_id FROM order_items oi JOIN product_catalog dirty ON dirty.id = oi.canonical_product_id JOIN fulfillment_tasks ft ON ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' @@ -27,11 +33,20 @@ export async function cleanupDirtyAffiliateDashProducts() { WHERE dirty.canonical_sku ~ '^aff:[^a-z0-9_]' ORDER BY oi.id, ft.id DESC ) - UPDATE order_items oi - SET canonical_product_id = dm.clean_product_id - FROM dirty_map dm - WHERE oi.id = dm.order_item_id + SELECT order_item_id, clean_product_id, expected_product_id + FROM dirty_map + ORDER BY order_item_id `) + if (dirtyMappings.rows.length > 0) { + await attachFulfillmentConfigurations({ + mappings: dirtyMappings.rows.map((row) => ({ + orderItemId: Number(row.order_item_id), + canonicalProductId: Number(row.clean_product_id), + expectedCanonicalProductId: Number(row.expected_product_id), + })), + onlyWhenCanonicalProductDifferent: true, + }) + } await query(` DELETE FROM sales_daily_facts @@ -134,8 +149,6 @@ export async function syncAdminAffiliateDashProducts() { type AffiliateSkuBackfillRow = { affiliate_item_count: number - matched_count: number - updated_count: number from_date: string | null to_date: string | null } @@ -165,8 +178,11 @@ export async function rebuildFactsForRange(fromDate: string, toDate: string) { * 不写 channel_product_mappings。多个任务共用同一订单项时取最新 SKU。 */ export async function backfillAdminAffiliateDashSkus() { - const now = nowIso() - const result = await query( + const targets = await query<{ + id: number + canonical_product_id: number + expected_product_id: number | null + }>( ` WITH affiliate_item AS ( SELECT DISTINCT ON (ft.order_item_id) @@ -184,30 +200,37 @@ export async function backfillAdminAffiliateDashSkus() { ORDER BY ft.order_item_id, ft.created_at DESC, ft.id DESC ), target AS ( - SELECT ai.id, p.id AS canonical_product_id + SELECT + ai.id, + p.id AS canonical_product_id, + oi.canonical_product_id AS expected_product_id FROM affiliate_item ai JOIN product_catalog p ON p.canonical_sku = 'aff:' || ai.sku_code - ), - updated AS ( - UPDATE order_items oi - SET canonical_product_id = target.canonical_product_id, - updated_at = $1 - FROM target - WHERE oi.id = target.id - AND oi.canonical_product_id IS DISTINCT FROM target.canonical_product_id - RETURNING oi.id + JOIN order_items oi ON oi.id = ai.id + ) + SELECT id, canonical_product_id, expected_product_id + FROM target + ORDER BY id + `, + ) + const result = await query( + ` + WITH affiliate_item AS ( + SELECT DISTINCT ON (ft.order_item_id) + ft.order_item_id AS id + FROM fulfillment_tasks ft + WHERE ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') + AND ft.created_at >= NOW() - INTERVAL '30 days' + AND btrim(COALESCE(ft.context_json->>'skuCode', '')) <> '' + ORDER BY ft.order_item_id, ft.created_at DESC, ft.id DESC ) SELECT ( - SELECT COUNT(DISTINCT ft.order_item_id)::int - FROM fulfillment_tasks ft - WHERE ft.executor_key = 'affiliate_dash' - AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') - AND ft.created_at >= NOW() - INTERVAL '30 days' + SELECT COUNT(*)::int + FROM affiliate_item ) AS affiliate_item_count, - (SELECT COUNT(*)::int FROM target) AS matched_count, - (SELECT COUNT(*)::int FROM updated) AS updated_count, ( SELECT to_char( GREATEST( @@ -230,13 +253,24 @@ export async function backfillAdminAffiliateDashSkus() { JOIN orders o ON o.id = oi.order_id ) AS to_date `, - [now], ) + const attached = + targets.rows.length > 0 + ? await attachFulfillmentConfigurations({ + mappings: targets.rows.map((row) => ({ + orderItemId: Number(row.id), + canonicalProductId: Number(row.canonical_product_id), + expectedCanonicalProductId: + row.expected_product_id === null ? null : Number(row.expected_product_id), + })), + onlyWhenCanonicalProductDifferent: true, + }) + : [] const row = result.rows[0] const affiliateItemCount = Number(row?.affiliate_item_count || 0) - const matchedCount = Number(row?.matched_count || 0) - const updatedCount = Number(row?.updated_count || 0) + const matchedCount = targets.rows.length + const updatedCount = attached.length const skippedCount = Math.max(0, matchedCount - updatedCount) const remainingResult = await query<{ total: number }>( ` diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index 7633261d..d1c35136 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -291,6 +291,25 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => { assert.ok(productSourceFilterSql(1).includes("'tpv:%'")) }) +test('第三方卡券删除:无历史引用硬删除,有历史引用自动归档并停用规则', async () => { + const { readFile } = await import('node:fs/promises') + const [serviceSource, routeSource] = await Promise.all([ + readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8'), + readFile(new URL('../../routes/admin/cost-center-catalog.ts', import.meta.url), 'utf8'), + ]) + const start = serviceSource.indexOf('export async function deleteAdminVoucherProduct') + const end = serviceSource.indexOf('/**\n * 批量归档旧人工归一商品', start) + const body = serviceSource.slice(start, end) + + assert.ok(body.includes('withTransaction'), '卡券删除必须在事务内完成') + assert.ok(body.includes("action: 'archived'"), '有历史引用时必须自动归档') + assert.ok(body.includes("status = 'archived'"), '归档必须保留商品和历史成本') + assert.ok(body.includes('UPDATE channel_product_mappings'), '归档后必须停用旧映射,避免继续命中') + assert.ok(body.includes('DELETE FROM cost_versions'), '无历史引用时应清理采购价版本') + assert.ok(body.includes("channel = 'third_party_voucher'"), '必须停用对应卡券渠道规则') + assert.ok(routeSource.includes("'/cost-center/voucher-products/:productId'")) +}) + test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => { const sql = productSourceFilterSql(1) assert.ok(sql.includes(`$1 = 'manual'`)) diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index 48acc6c8..27aa86ed 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -6,6 +6,11 @@ import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js import { nowIso } from '../../utils/time.js' import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js' import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js' +import { + attachFulfillmentConfigurations, + transitionFulfillmentStateBatch, + transitionFulfillmentStateBatchWithExecutor, +} from '../fulfillment/fulfillment-state-service.js' import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js' import { refreshFactsForDates } from '../finance/refresh-facts.js' import { @@ -13,6 +18,7 @@ import { normalizeManualCreditQuantity, parseManualChannelQuantity, } from './manual-credit-quantity.js' +import { productSourceFilterSql } from './cost-center-mappers.js' /** * 人工履约 SKU 配置服务。 @@ -223,9 +229,7 @@ export async function scanManualSkus(options: ScanOptions) { return { scanned: rows.length, registered: rows.length, fromDate, toDate } } -/** - * 列出人工 SKU 清单(登记表 + 配置状态)。 - */ +/** 列出人工标准商品清单(标准商品为唯一主键,配置与订单统计只做关联)。 */ export async function listManualSkus(input: Record = {}) { const unitPrices = await loadPlatformUnitPriceFen() const page = normalizePositiveInteger(input.page, 1, '页码') @@ -238,13 +242,16 @@ export async function listManualSkus(input: Record = {}) { const platformKey = String(input.platformKey || input.platform_key || '').trim() const provider = String(input.provider || '').trim() - const conditions: string[] = ['1 = 1'] - const params: unknown[] = [] + const conditions: string[] = [`p.status = 'active'`, `p.canonical_sku LIKE 'man:%'`] + const params: unknown[] = ['manual'] + conditions.push(productSourceFilterSql(params.length)) if (keyword) { params.push(`%${escapeLike(keyword)}%`) const placeholder = `$${params.length}` - conditions.push(`(r.sku_key ILIKE ${placeholder} OR r.display_name ILIKE ${placeholder})`) + conditions.push( + `(p.canonical_sku ILIKE ${placeholder} OR p.canonical_name ILIKE ${placeholder})`, + ) } if (status === 'configured') { conditions.push('cfg.id IS NOT NULL') @@ -266,7 +273,7 @@ export async function listManualSkus(input: Record = {}) { WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND o.provider = $${params.length} AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) - = canonicalize_internal_sku(r.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) )`) } @@ -274,9 +281,15 @@ export async function listManualSkus(input: Record = {}) { const countResult = await query<{ total: number }>( ` SELECT COUNT(*)::int AS total - FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg - ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) + FROM product_catalog p + LEFT JOIN LATERAL ( + SELECT * + FROM manual_fulfillment_configs cfg + WHERE canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ORDER BY cfg.id + LIMIT 1 + ) cfg ON TRUE WHERE ${where} `, params, @@ -284,22 +297,56 @@ export async function listManualSkus(input: Record = {}) { const listParams = [...params, pageSize, (page - 1) * pageSize] const result = await query( ` + WITH manual_stats AS ( + SELECT + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key, + COUNT(DISTINCT o.shop_id)::int AS shop_count, + COUNT(DISTINCT o.id)::int AS order_count, + COUNT(*)::int AS item_count, + COALESCE(SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)), 0)::bigint + AS revenue_fen, + MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) + AS first_seen_date, + MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) + AS last_seen_date + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel = 'manual' + GROUP BY 1 + ) SELECT - r.id, r.sku_key, r.display_name, r.shop_count, r.order_count, r.item_count, - r.revenue_fen, r.first_seen_date, r.last_seen_date, - r.scan_from_date, r.scan_to_date, + p.id, + substring(p.canonical_sku from 5) AS sku_key, + p.canonical_name AS display_name, + COALESCE(manual_stats.shop_count, 0)::int AS shop_count, + COALESCE(manual_stats.order_count, 0)::int AS order_count, + COALESCE(manual_stats.item_count, 0)::int AS item_count, + COALESCE(manual_stats.revenue_fen, 0)::bigint AS revenue_fen, + manual_stats.first_seen_date, + manual_stats.last_seen_date, + manual_stats.first_seen_date AS scan_from_date, + manual_stats.last_seen_date AS scan_to_date, cfg.id AS config_id, cfg.platform_key AS config_platform_key, cfg.credit_quantity AS config_credit_quantity, cfg.cost_mode AS config_cost_mode, - cfg.canonical_product_id AS config_canonical_product_id, + COALESCE(cfg.canonical_product_id, p.id) AS config_canonical_product_id, cfg.enabled AS config_enabled, cfg.channels_json AS config_channels_json - FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg - ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) + FROM product_catalog p + LEFT JOIN LATERAL ( + SELECT * + FROM manual_fulfillment_configs cfg + WHERE canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ORDER BY cfg.id + LIMIT 1 + ) cfg ON TRUE + LEFT JOIN manual_stats + ON manual_stats.sku_key = canonicalize_internal_sku(substring(p.canonical_sku from 5)) WHERE ${where} - ORDER BY r.item_count DESC, r.sku_key ASC + ORDER BY COALESCE(manual_stats.item_count, 0) DESC, p.canonical_name ASC, p.id ASC LIMIT $${listParams.length - 1} OFFSET $${listParams.length} `, listParams, @@ -323,20 +370,50 @@ export async function getManualSkuOverview() { last_scan_at: string | null }>( ` + WITH manual_stats AS ( + SELECT + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key, + COUNT(*)::int AS item_count, + MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date) + AS last_seen_date + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel = 'manual' + GROUP BY 1 + ), + manual_scope AS ( + SELECT p.id, p.canonical_sku, p.canonical_name, cfg.id AS config_id + FROM product_catalog p + LEFT JOIN LATERAL ( + SELECT cfg.id + FROM manual_fulfillment_configs cfg + WHERE canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(substring(p.canonical_sku from 5)) + ORDER BY cfg.id + LIMIT 1 + ) cfg ON TRUE + WHERE p.status = 'active' + AND p.canonical_sku LIKE 'man:%' + AND ${productSourceFilterSql(1)} + ) SELECT COUNT(*)::int AS total_sku, - COUNT(*) FILTER (WHERE cfg.id IS NOT NULL)::int AS configured_sku, - COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_sku, - COALESCE(SUM(r.item_count), 0)::bigint AS total_items, - COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NOT NULL), 0)::bigint + COUNT(*) FILTER (WHERE config_id IS NOT NULL)::int AS configured_sku, + COUNT(*) FILTER (WHERE config_id IS NULL)::int AS unconfigured_sku, + COALESCE(SUM(manual_stats.item_count), 0)::bigint AS total_items, + COALESCE(SUM(manual_stats.item_count) FILTER (WHERE config_id IS NOT NULL), 0)::bigint AS configured_items, - COALESCE(SUM(r.item_count) FILTER (WHERE cfg.id IS NULL), 0)::bigint + COALESCE(SUM(manual_stats.item_count) FILTER (WHERE config_id IS NULL), 0)::bigint AS unconfigured_items, - MAX(r.updated_at) AS last_scan_at - FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg - ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) + MAX(manual_stats.last_seen_date)::text AS last_scan_at + FROM manual_scope + LEFT JOIN manual_stats + ON manual_stats.sku_key = canonicalize_internal_sku( + substring(manual_scope.canonical_sku from 5) + ) `, + ['manual'], ) const row = result.rows[0] return { @@ -470,6 +547,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { }) await syncRegistryConfigId(config.sku_key, config.id) + await upsertManualRegistryConfig(config) // 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明, // 少了这一步,新配的 SKU 会一直停在待分类。 await syncManualChannelRule(config) @@ -508,12 +586,13 @@ async function retryPendingManualItems(skuKey: string): Promise { ) const ids = candidates.rows.map((row) => Number(row.id)) if (ids.length === 0) return 0 - await query( - `UPDATE order_items - SET fulfillment_locked = FALSE, updated_at = NOW() - WHERE id = ANY($1::bigint[]) AND fulfillment_status = 'pending'`, - [ids], - ) + await transitionFulfillmentStateBatch({ + orderItemIds: ids, + expected: { status: 'pending', locked: true }, + patch: { locked: false }, + eventType: 'manual_config_retry_unlocked', + actor: { type: 'system' }, + }) const result = await reconcilePendingCarriers({ orderItemIds: ids, limit: ids.length, @@ -556,6 +635,7 @@ export type DeleteManualSkuResult = { deletedConfigs: number deletedRegistry: number deletedRules: number + archivedProducts: number refreshedDays: number skippedClosedDays: number } @@ -671,31 +751,46 @@ export async function deleteManualSkuClassification( RETURNING id`, [key], ) + const products = await client.query( + ` + UPDATE product_catalog + SET status = 'archived', + attributes_json = attributes_json || jsonb_build_object( + 'deletedAt', $2::text, + 'deletedBy', 'manual_sku_config' + ), + updated_at = $2::timestamptz + WHERE status = 'active' + AND canonical_sku LIKE 'man:%' + AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1 + RETURNING id + `, + [key, nowIso()], + ) if (itemIds.length > 0) { - await client.query( - ` - UPDATE order_items - SET fulfillment_channel = NULL, - fulfillment_status = 'pending', - fulfillment_source = '', - fulfillment_locked = FALSE, - fulfillment_config_id = NULL, - fulfillment_pending_reason = 'no_channel_capability', - fulfillment_pending_detail = jsonb_build_object( - 'reason', 'manual_classification_removed', - 'removedAt', NOW() - ), - fulfillment_reason = '运营移除错误的人工分类', - fulfillment_decided_by = NULL, - fulfillment_decided_at = NULL, - updated_at = NOW() - WHERE id = ANY($1::bigint[]) - AND fulfillment_status = 'assigned' - AND fulfillment_channel = 'manual' - `, - [itemIds], - ) + await transitionFulfillmentStateBatchWithExecutor(client, { + orderItemIds: itemIds, + expected: { status: 'assigned', channel: 'manual' }, + patch: { + status: 'pending', + channel: null, + source: '', + locked: false, + configId: null, + pendingReason: 'no_channel_capability', + pendingDetail: { + reason: 'manual_classification_removed', + removedAt: nowIso(), + }, + reason: '运营移除错误的人工分类', + decidedBy: null, + decidedAt: null, + }, + eventType: 'manual_classification_removed', + actor: { type: 'operator' }, + reason: key, + }) } return { @@ -703,6 +798,7 @@ export async function deleteManualSkuClassification( deletedConfigs: configs.rowCount || 0, deletedRegistry: registry.rowCount || 0, deletedRules: rules.rowCount || 0, + archivedProducts: products.rowCount || 0, dates: [...new Set(items.rows.map((row) => row.basis_date).filter(Boolean))], } }) @@ -714,6 +810,7 @@ export async function deleteManualSkuClassification( deletedConfigs: result.deletedConfigs, deletedRegistry: result.deletedRegistry, deletedRules: result.deletedRules, + archivedProducts: result.archivedProducts, refreshedDays: refreshed.days, skippedClosedDays: refreshed.skippedClosed, } @@ -731,11 +828,14 @@ export async function rematchManualFulfillments(input: Record = const dateFrom = normalizeOptionalDate(input.dateFrom ?? input.date_from, '开始日期') const dateTo = normalizeOptionalDate(input.dateTo ?? input.date_to, '结束日期') - const now = nowIso() - const result = await query<{ id: number }>( + const candidates = await query<{ + order_item_id: number + config_id: number + canonical_product_id: number | null + }>( ` WITH target AS ( - SELECT + SELECT DISTINCT ON (oi.id) oi.id AS order_item_id, cfg.id AS config_id, cfg.canonical_product_id @@ -757,20 +857,27 @@ export async function rematchManualFulfillments(input: Record = $4::date IS NULL OR (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date <= $4::date ) + ORDER BY oi.id, cfg.id ) - UPDATE order_items oi - SET fulfillment_config_id = target.config_id, - canonical_product_id = COALESCE(target.canonical_product_id, oi.canonical_product_id), - updated_at = $5 + SELECT order_item_id, config_id, canonical_product_id FROM target - WHERE oi.id = target.order_item_id - RETURNING oi.id + ORDER BY order_item_id `, - [skuKey, onlyUnmatched, dateFrom, dateTo, now], + [skuKey, onlyUnmatched, dateFrom, dateTo], ) + const attached = await attachFulfillmentConfigurations({ + mappings: candidates.rows.map((row) => ({ + orderItemId: Number(row.order_item_id), + configId: Number(row.config_id), + ...(row.canonical_product_id ? { canonicalProductId: Number(row.canonical_product_id) } : {}), + })), + expected: { status: 'assigned', channel: 'manual' }, + onlyWhenConfigNull: onlyUnmatched, + onlyWhenCanonicalProductNull: true, + }) // 配置补上以后,「人工 SKU 未配置」异常要立即收口,不能等人工去点。 - const updatedIds = result.rows.map((row) => Number(row.id)) + const updatedIds = attached.map((row) => Number(row.id)) if (updatedIds.length > 0) { await resolveExceptionsForItemsByReason(updatedIds, 'manual_config_missing') } @@ -779,7 +886,7 @@ export async function rematchManualFulfillments(input: Record = await attachCostVersionsForManualConfigs() return { - updatedCount: result.rowCount || 0, + updatedCount: attached.length, skuKey, dateFrom, dateTo, @@ -840,6 +947,40 @@ async function syncRegistryConfigId(skuKey: string, configId: number | null) { ) } +/** 配置保存后确保登记表仍有同一 SKU 的记录,兼容旧扫描清单和运维查询。 */ +async function upsertManualRegistryConfig(config: { + id: number + sku_key: string + canonical_product_id: number | null +}) { + const key = canonicalizeInternalSku(config.sku_key) + if (!key) return + const now = nowIso() + await query( + ` + INSERT INTO manual_sku_registry ( + sku_key, display_name, config_id, created_at, updated_at + ) + SELECT + $1, + COALESCE(NULLIF(product.canonical_name, ''), $1), + $2, + $3, + $3 + FROM (SELECT 1) seed + LEFT JOIN product_catalog product ON product.id = $4 + ON CONFLICT (sku_key) DO UPDATE SET + display_name = CASE + WHEN BTRIM(manual_sku_registry.display_name) = '' THEN EXCLUDED.display_name + ELSE manual_sku_registry.display_name + END, + config_id = EXCLUDED.config_id, + updated_at = EXCLUDED.updated_at + `, + [key, config.id, now, config.canonical_product_id], + ) +} + /** 读取单个 SKU 的配置详情(含平台标签与成本折算)。 */ export async function getManualFulfillmentConfig(skuKey: string) { const unitPrices = await loadPlatformUnitPriceFen() diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index acf6257b..34aa9b80 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -19,6 +19,7 @@ import { setAdminAffiliateProductPrice } from './affiliate-product-service.js' import { resolvePriceEffectiveFrom } from './cost-center-normalizers.js' import { ensureVoucherRuleForSku } from '../fulfillment/channel-assign-service.js' import { syncCostSnapshotsForItems } from './cost-snapshot-service.js' +import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js' import { resolveExceptionsForItemsByReason, resolveOrphanCostExceptions, @@ -439,6 +440,152 @@ export async function deleteAdminProduct(productId: number | string) { return { deleted: true, product: mapProduct(current) } } +/** + * 删除第三方卡券商品。 + * + * 卡券商品通常带采购价版本,不能套用普通商品“有任何成本版本就拒绝”的规则: + * - 没有任何订单项/映射/成本快照:删除未使用成本版本、渠道能力与卡券规则后硬删除; + * - 已有历史引用:归档商品并停用后续卡券规则,成本版本与快照全部保留。 + */ +export async function deleteAdminVoucherProduct(productId: number | string) { + const id = normalizeId(productId, '商品 ID') + return withTransaction(async (client) => { + const currentResult = await client.query( + `SELECT * FROM product_catalog WHERE id = $1 FOR UPDATE`, + [id], + ) + const current = requiredRow(currentResult.rows[0], '商品不存在') + if (!current.canonical_sku.startsWith('tpv:')) { + throw createHttpError('该商品不是第三方卡券商品', { + statusCode: 400, + errorCode: 'voucher_product_type_invalid', + }) + } + + const usageResult = await client.query<{ + mapping_count: number + snapshot_count: number + order_item_count: number + }>( + ` + SELECT + (SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) + AS mapping_count, + ( + SELECT COUNT(*)::int + FROM cost_snapshots + WHERE canonical_product_id = $1 + OR cost_version_id IN ( + SELECT id FROM cost_versions WHERE canonical_product_id = $1 + ) + ) + AS snapshot_count, + (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) + AS order_item_count + `, + [id], + ) + const usage = usageResult.rows[0] + const mappingCount = toNumber(usage?.mapping_count) + const snapshotCount = toNumber(usage?.snapshot_count) + const orderItemCount = toNumber(usage?.order_item_count) + const internalSku = current.canonical_sku.slice(4) + + await client.query( + ` + UPDATE internal_sku_channels + SET enabled = FALSE, + is_fallback = FALSE, + updated_at = $3 + WHERE channel = 'third_party_voucher' + AND ( + canonical_product_id = $1 + OR canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($2) + ) + `, + [id, internalSku, nowIso()], + ) + await client.query( + ` + UPDATE product_channel_capabilities + SET enabled = FALSE, + updated_at = $2 + WHERE product_id = $1 AND channel = 'third_party_voucher' + `, + [id, nowIso()], + ) + + if (mappingCount > 0 || snapshotCount > 0 || orderItemCount > 0) { + await client.query( + ` + UPDATE channel_product_mappings + SET enabled = FALSE, + note = CASE + WHEN note = '' THEN '第三方卡券商品删除后自动停用' + ELSE note + END, + updated_at = $2 + WHERE canonical_product_id = $1 + `, + [id, nowIso()], + ) + const archived = await client.query( + ` + UPDATE product_catalog + SET status = 'archived', + attributes_json = attributes_json || jsonb_build_object( + 'deletedAt', $2::text, + 'deletedBy', 'voucher_product_panel' + ), + updated_at = $2::timestamptz + WHERE id = $1 + RETURNING * + `, + [id, nowIso()], + ) + return { + action: 'archived' as const, + deleted: false, + product: mapProduct(requiredRow(archived.rows[0], '归档卡券商品失败')), + preserved: { + mappingCount, + snapshotCount, + orderItemCount, + }, + } + } + + const deletedVersions = await client.query( + `DELETE FROM cost_versions WHERE canonical_product_id = $1`, + [id], + ) + await client.query(`DELETE FROM product_channel_capabilities WHERE product_id = $1`, [id]) + await client.query( + ` + DELETE FROM internal_sku_channels + WHERE channel = 'third_party_voucher' + AND ( + canonical_product_id = $1 + OR canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($2) + ) + `, + [id, internalSku], + ) + await client.query('DELETE FROM product_catalog WHERE id = $1', [id]) + return { + action: 'deleted' as const, + deleted: true, + product: mapProduct(current), + deletedCostVersionCount: deletedVersions.rowCount || 0, + preserved: { + mappingCount: 0, + snapshotCount: 0, + orderItemCount: 0, + }, + } + }) +} + /** * 批量归档旧人工归一商品。 * @@ -1229,19 +1376,14 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) { } if (ids.length > 0) { - const updated = await query( - ` - UPDATE order_items oi - SET canonical_product_id = input.canonical_product_id, - updated_at = NOW() - FROM unnest($1::bigint[], $2::bigint[]) - AS input(id, canonical_product_id) - WHERE oi.id = input.id - AND oi.canonical_product_id IS NULL - `, - [ids, productIds], - ) - updatedCount += updated.rowCount || 0 + const updated = await attachFulfillmentConfigurations({ + mappings: ids.map((orderItemId, index) => ({ + orderItemId, + canonicalProductId: productIds[index]!, + })), + onlyWhenCanonicalProductNull: true, + }) + updatedCount += updated.length } batches += 1 if (!all || itemsResult.rows.length < limit) break diff --git a/apps/backend/src/services/cost-center/product-normalize-service.ts b/apps/backend/src/services/cost-center/product-normalize-service.ts index 9d0fdf09..d81a6480 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -3,6 +3,7 @@ import { resolveExceptionsForItems, upsertAggregatedException } from './cost-exc import { readWatermarkItemId } from '../watermark-store.js' import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js' import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js' +import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js' /** * 管道 B:商品归一。 @@ -188,10 +189,10 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise 0) { - await query( - ` - UPDATE order_items oi - SET canonical_product_id = input.product_id, updated_at = NOW() - FROM unnest($1::bigint[], $2::bigint[]) AS input(id, product_id) - WHERE oi.id = input.id - AND oi.canonical_product_id IS NULL - `, - [matched.map((row) => row.id), matched.map((row) => row.productId)], - ) - await resolveExceptionsForItems(matched.map((row) => row.id)) + const attached = await attachFulfillmentConfigurations({ + mappings: matched.map((row) => ({ + orderItemId: row.id, + canonicalProductId: row.productId, + })), + expected: { status: 'assigned' }, + onlyWhenCanonicalProductNull: true, + }) + await resolveExceptionsForItems(attached.map((row) => Number(row.id))) } } @@ -508,17 +507,16 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ matched.push({ id: item.id, productId }) } if (matched.length > 0) { - await query( - ` - UPDATE order_items oi - SET canonical_product_id = input.product_id, updated_at = NOW() - FROM unnest($1::bigint[], $2::bigint[]) AS input(id, product_id) - WHERE oi.id = input.id - AND oi.canonical_product_id IS NULL - `, - [matched.map((row) => row.id), matched.map((row) => row.productId)], - ) - await resolveExceptionsForItems(matched.map((row) => row.id)) + const attached = await attachFulfillmentConfigurations({ + mappings: matched.map((row) => ({ + orderItemId: row.id, + canonicalProductId: row.productId, + })), + expected: { status: 'assigned' }, + onlyWhenCanonicalProductNull: true, + }) + await resolveExceptionsForItems(attached.map((row) => Number(row.id))) + return { matched: attached.length, unmatched } } - return { matched: matched.length, unmatched } + return { matched: 0, unmatched } } diff --git a/apps/backend/src/services/cost-center/template-bootstrap-service.ts b/apps/backend/src/services/cost-center/template-bootstrap-service.ts index 7f4c2a7b..5b53afdf 100644 --- a/apps/backend/src/services/cost-center/template-bootstrap-service.ts +++ b/apps/backend/src/services/cost-center/template-bootstrap-service.ts @@ -22,6 +22,7 @@ import { buildTemplateProductSku, inferTemplateUnitName, } from './work-order-template-product-service.js' +import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js' export { buildTemplateProductSku, inferTemplateUnitName } @@ -621,10 +622,10 @@ function compareMappingCandidates(left: TemplateMappingCandidate, right: Templat async function resetImportedTemplateProductBootstrap(range: { fromDate: string; toDate: string }) { let resetOrderItemCount = 0 for (let batch = 0; batch < 20; batch += 1) { - const result = await query<{ id: number }>( + const candidates = await query<{ id: number; expected_product_id: number }>( ` WITH candidates AS ( - SELECT oi.id + SELECT oi.id, oi.canonical_product_id AS expected_product_id FROM order_items oi JOIN orders o ON o.id = oi.order_id JOIN channel_product_mappings m @@ -655,17 +656,22 @@ async function resetImportedTemplateProductBootstrap(range: { fromDate: string; BETWEEN $1::date AND $2::date LIMIT 20000 ) - UPDATE order_items oi - SET canonical_product_id = NULL, - updated_at = NOW() - FROM candidates c - WHERE oi.id = c.id - RETURNING oi.id + SELECT DISTINCT id, expected_product_id + FROM candidates + ORDER BY id `, [range.fromDate, range.toDate], ) - resetOrderItemCount += result.rowCount || 0 - if ((result.rowCount || 0) < 20000) break + if (candidates.rows.length === 0) break + const reset = await attachFulfillmentConfigurations({ + mappings: candidates.rows.map((row) => ({ + orderItemId: Number(row.id), + canonicalProductId: null, + expectedCanonicalProductId: Number(row.expected_product_id), + })), + }) + resetOrderItemCount += reset.length + if (candidates.rows.length < 20000) break } const deleted = await query( ` diff --git a/apps/backend/src/services/cost-center/work-order-product-service.ts b/apps/backend/src/services/cost-center/work-order-product-service.ts index 4317bc33..5ab02b77 100644 --- a/apps/backend/src/services/cost-center/work-order-product-service.ts +++ b/apps/backend/src/services/cost-center/work-order-product-service.ts @@ -6,6 +6,7 @@ import { resolveFinanceDateRange } from '../../utils/finance-date-range.js' import { nowIso } from '../../utils/time.js' import { rebuildFactsForRange } from './affiliate-product-service.js' import { WORK_ORDER_TEMPLATE_COST_BASELINE } from './cost-center-shared.js' +import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js' type WorkOrderSkuBackfillRow = { work_order_item_count: number @@ -64,12 +65,17 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record( + const targetRows = await query<{ + id: number + canonical_product_id: number + expected_product_id: number | null + }>( ` WITH work_order_item AS ( SELECT DISTINCT ON (wo.order_item_id) wo.order_item_id AS id, - wo.product_rule_id + wo.product_rule_id, + oi.canonical_product_id AS expected_product_id FROM work_orders wo JOIN order_items oi ON oi.id = wo.order_item_id JOIN orders o ON o.id = oi.order_id @@ -81,7 +87,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record Number(row.id)), - targetRows.rows.map((row) => Number(row.canonical_product_id)), - ], - ) - ruleUpdatedCount += updated.rowCount || 0 + const updated = await attachFulfillmentConfigurations({ + mappings: targetRows.rows.map((row) => ({ + orderItemId: Number(row.id), + canonicalProductId: Number(row.canonical_product_id), + expectedCanonicalProductId: + row.expected_product_id === null ? null : Number(row.expected_product_id), + })), + onlyWhenCanonicalProductDifferent: true, + }) + ruleUpdatedCount += updated.length if (targetRows.rows.length < WORK_ORDER_BACKFILL_BATCH_SIZE) break } @@ -249,23 +248,14 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record Number(row.id)), - tailRows.rows.map((row) => Number(row.canonical_product_id)), - ], - ) - tailUpdatedCount += updated.rowCount || 0 + const updated = await attachFulfillmentConfigurations({ + mappings: tailRows.rows.map((row) => ({ + orderItemId: Number(row.id), + canonicalProductId: Number(row.canonical_product_id), + })), + onlyWhenCanonicalProductNull: true, + }) + tailUpdatedCount += updated.length if (tailRows.rows.length < WORK_ORDER_BACKFILL_BATCH_SIZE) break } diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index 85952ad6..cf58fe96 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -90,17 +90,23 @@ test('人工确认必须单事务:扣款与快照同事务,且使用 with-cl }) test('人工确认前必须自动匹配履约配置,配置保存/定时任务也要回填', async () => { - const [manualSource, configSource, jobSource, migrationSource] = await Promise.all([ - readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'), - readFile( - new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url), - 'utf8', - ), - readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'), - readFile(new URL('../../db/migrations/106_manual_sku_key.sql', import.meta.url), 'utf8'), - ]) + const [manualSource, configSource, jobSource, migrationSource, reconcileMigration] = + await Promise.all([ + readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'), + readFile( + new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url), + 'utf8', + ), + readFile(new URL('../scheduler/finance-pipeline-jobs.ts', import.meta.url), 'utf8'), + readFile(new URL('../../db/migrations/106_manual_sku_key.sql', import.meta.url), 'utf8'), + readFile( + new URL('../../db/migrations/135_reconcile_manual_standard_products.sql', import.meta.url), + 'utf8', + ), + ]) assert.ok( - manualSource.includes('matchManualFulfillmentConfigWithClient(client, orderItemId, now)'), + manualSource.includes('matchManualFulfillmentConfigWithClient(client, orderItemId)') && + manualSource.includes('attachFulfillmentConfigurationsWithExecutor(client'), '确认时必须按 SKU 自动挂渠道', ) assert.ok( @@ -137,6 +143,18 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务 migrationSource.includes('sku_name'), '生成列必须保留 sku_code / sku_name 兜底', ) + const listStart = configSource.indexOf('export async function listManualSkus') + const listEnd = configSource.indexOf('export async function getManualSkuOverview') + const listBody = configSource.slice(listStart, listEnd) + assert.ok( + listBody.includes('FROM product_catalog p') && listBody.includes('productSourceFilterSql'), + '人工成本配置必须以 man: 标准商品为唯一清单来源', + ) + assert.ok( + reconcileMigration.includes('tmp_manual_product_merge') && + reconcileMigration.includes("status = 'archived'"), + '存量重复人工 SKU 必须合并到同一标准商品并归档重复项', + ) }) test('人工成本缺失由管道按配置与快照生成并收口', async () => { diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index 59fc8b4e..315bbeaa 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -17,6 +17,10 @@ import { import { rebuildChannelSalesDailyFacts } from './sales-facts-channel-service.js' import { syncFinanceLedgerFromFacts } from './finance-ledger-service.js' import { scheduleVoucherConsumeForOrderItems } from '../fulfillment/voucher-consume-queue-service.js' +import { + attachFulfillmentConfigurationsWithExecutor, + transitionFulfillmentStateWithExecutor, +} from '../fulfillment/fulfillment-state-service.js' import { calculateManualCostFen, normalizeManualCreditQuantity, @@ -307,7 +311,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm const result = await withTransaction(async (client) => { // 配置可能晚于订单创建:确认前按 SKU 再匹配一次(与定时 rematch 同口径), // 保证「配置好之后的新单」不需要人工再点一次「重新匹配」。 - await matchManualFulfillmentConfigWithClient(client, orderItemId, now) + await matchManualFulfillmentConfigWithClient(client, orderItemId) const context = await client.query( ` @@ -621,19 +625,21 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm // 人工确认即渠道成立:写新三列,避免订单一直停在 pending。 const finalChannel = platformKey === 'on_account' ? 'on_account' : 'manual' - await client.query( - ` - UPDATE order_items - SET fulfillment_channel = $3, - fulfillment_status = 'assigned', - fulfillment_source = 'manual_confirm', - fulfillment_decided_at = $2, - updated_at = $2 - WHERE id = $1 - AND fulfillment_status <> 'archived' - `, - [orderItemId, now, finalChannel], - ) + await transitionFulfillmentStateWithExecutor(client, { + orderItemId, + patch: { + channel: finalChannel, + status: 'assigned', + source: 'manual_confirm', + decidedAt: now, + decidedBy: Number(session.userId || 0) || null, + pendingReason: null, + pendingDetail: null, + }, + eventType: 'manual_fulfillment_confirmed', + actor: { type: 'finance', id: session.userId }, + reason: platformKey, + }) // 人工缺成本异常立即收口,不等下一轮定时任务。 await client.query( @@ -788,11 +794,7 @@ async function resolveCreditOwnerAdminUserId( * 与 `rematchManualFulfillments` 使用同一套键:规范中文内部 SKU。 * 这样即使配置晚于订单创建,新单在确认时也能自动挂上渠道。 */ -async function matchManualFulfillmentConfigWithClient( - client: PoolClient, - orderItemId: number, - now: string, -) { +async function matchManualFulfillmentConfigWithClient(client: PoolClient, orderItemId: number) { const matched = await client.query<{ id: number canonical_product_id: number | null @@ -823,17 +825,19 @@ async function matchManualFulfillmentConfigWithClient( ) const match = matched.rows[0] if (!match) return false - await client.query( - ` - UPDATE order_items - SET fulfillment_config_id = $2, - canonical_product_id = COALESCE($3, canonical_product_id), - updated_at = $4 - WHERE id = $1 - AND fulfillment_config_id IS NULL - `, - [orderItemId, match.id, match.canonical_product_id, now], - ) + await attachFulfillmentConfigurationsWithExecutor(client, { + mappings: [ + { + orderItemId, + configId: Number(match.id), + ...(match.canonical_product_id + ? { canonicalProductId: Number(match.canonical_product_id) } + : {}), + }, + ], + onlyWhenConfigNull: true, + onlyWhenCanonicalProductNull: true, + }) return true } diff --git a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts index f68c6cfc..a9928ac9 100644 --- a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts +++ b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts @@ -2,6 +2,7 @@ import { query, withTransaction } from '../../db/client.js' import { syncCostSnapshotsForItems } from '../cost-center/cost-snapshot-service.js' import { refreshFactsForDates } from '../finance/refresh-facts.js' import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js' +import { transitionFulfillmentStateWithExecutor } from './fulfillment-state-service.js' /** * 自动发货失败 → 人工兜底。 @@ -22,9 +23,16 @@ export const AFFILIATE_FAILURE_STATUSES = [ const AFFILIATE_SUCCESS_STATUSES = ['redeemed', 'paid'] as const export async function runAffiliateFallback( - options: { limit?: number } = {}, + options: { limit?: number; cutoffCreatedAt?: string } = {}, ): Promise<{ scanned: number; switched: number }> { const limit = Math.min(1000, Math.max(1, Math.trunc(options.limit || 200))) + const params: unknown[] = [AFFILIATE_FAILURE_STATUSES, AFFILIATE_SUCCESS_STATUSES, limit] + const cutoffCreatedAt = String(options.cutoffCreatedAt || '').trim() + let cutoffFilter = '' + if (cutoffCreatedAt && Number.isFinite(Date.parse(cutoffCreatedAt))) { + params.push(cutoffCreatedAt) + cutoffFilter = `AND o.created_at >= $${params.length}::timestamptz` + } const candidates = await query<{ id: number order_item_id: number @@ -80,10 +88,11 @@ export async function runAffiliateFallback( AND ft.executor_key = 'affiliate_dash' AND ft.task_status = ANY($2::text[]) ) + ${cutoffFilter} ORDER BY oi.id LIMIT $3 `, - [AFFILIATE_FAILURE_STATUSES, AFFILIATE_SUCCESS_STATUSES, limit], + params, ) const switchedIds: number[] = [] @@ -91,50 +100,51 @@ export async function runAffiliateFallback( for (const row of candidates.rows) { const updated = await withTransaction(async (client) => { - const result = await client.query( - ` - UPDATE order_items - SET - original_channel = COALESCE(original_channel, 'affiliate_dash'), - fulfillment_channel = 'manual', - fulfillment_status = 'assigned', - fulfillment_source = 'affiliate_fallback', - channel_switch_reason = 'affiliate_failed', - channel_switched_at = NOW(), - fulfillment_config_id = COALESCE(fulfillment_config_id, $2), - canonical_product_id = COALESCE($3, canonical_product_id), - fulfillment_pending_reason = NULL, - fulfillment_pending_detail = NULL, - fulfillment_decided_at = NOW(), - item_snapshot_json = COALESCE(item_snapshot_json, '{}'::jsonb) || jsonb_build_object( - 'internalSkuChannel', 'manual', - 'internalSkuChannelSource', 'affiliate_fallback', - 'internalSkuChannelReason', '自动发货失败,转人工兜底', - 'isConfigured', TRUE, - 'affiliateDash', NULL, - 'fulfillmentRoute', jsonb_build_object( - 'selectedExecutorKey', 'manual_dispatch', - 'selectedRuleId', '', - 'reason', '自动发货失败,转人工兜底', - 'matchedRule', NULL, - 'candidates', '[]'::jsonb, - 'skipped', '[]'::jsonb - ), - 'classification', jsonb_build_object( - 'channel', 'manual', - 'canonicalSku', resolve_internal_sku(manual_sku_key, sku_name, sku_code) - ) - ), - updated_at = NOW() - WHERE id = $1 - AND fulfillment_status = 'assigned' - AND fulfillment_channel = 'affiliate_dash' - AND fulfillment_locked = FALSE - RETURNING id - `, - [row.id, row.config_id, row.config_product_id], - ) - if ((result.rowCount || 0) === 0) return false + const transitioned = await transitionFulfillmentStateWithExecutor(client, { + orderItemId: Number(row.id), + expected: { + status: 'assigned', + channel: 'affiliate_dash', + locked: false, + }, + patch: { + originalChannelIfNull: 'affiliate_dash', + channel: 'manual', + status: 'assigned', + source: 'affiliate_fallback', + switchReason: 'affiliate_failed', + switchedAt: new Date().toISOString(), + ...(row.config_id ? { configIdIfNull: Number(row.config_id) } : {}), + ...(row.config_product_id + ? { canonicalProductIdIfNull: Number(row.config_product_id) } + : {}), + pendingReason: null, + pendingDetail: null, + decidedAt: new Date().toISOString(), + itemSnapshotPatch: { + internalSkuChannel: 'manual', + internalSkuChannelSource: 'affiliate_fallback', + internalSkuChannelReason: '自动发货失败,转人工兜底', + isConfigured: true, + affiliateDash: null, + fulfillmentRoute: { + selectedExecutorKey: 'manual_dispatch', + selectedRuleId: '', + reason: '自动发货失败,转人工兜底', + matchedRule: null, + candidates: [], + skipped: [], + }, + classification: { + channel: 'manual', + canonicalSku: row.internal_sku, + }, + }, + }, + eventType: 'affiliate_failed_fallback_to_manual', + reason: 'affiliate_failed', + }) + if (!transitioned) return false await client.query( ` diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index 5b013571..5a1e2bf0 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -10,6 +10,7 @@ import { findConflictingCarrier, loadActiveCarrierEvidenceForItem, } from './carrier-evidence-service.js' +import { attachFulfillmentConfigurations } from './fulfillment-state-service.js' function parseObject(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { @@ -54,7 +55,11 @@ async function hasAffiliateTask(orderItemId: number): Promise { return result.rows[0]?.exists === true } -const BLOCKED_INACTIVE_CARRIER_STATUSES = new Set(['closed', 'cancelled', 'manual_review']) +const BLOCKED_WORK_ORDER_STATUSES = new Set(['closed', 'cancelled', 'manual_review']) +const HARD_BLOCKED_INACTIVE_REASONS = new Set([ + 'carrier_task_user_refund', + 'carrier_task_cancelled', +]) async function findBlockingInactiveCarrier(input: { orderItemId: number @@ -64,16 +69,16 @@ async function findBlockingInactiveCarrier(input: { const result = input.channel === 'affiliate_dash' - ? await query<{ status: string }>( - `SELECT task_status AS status + ? await query<{ status: string; result_code: string }>( + `SELECT task_status AS status, result_code FROM fulfillment_tasks WHERE order_item_id = $1 AND executor_key = 'affiliate_dash' ORDER BY id DESC LIMIT 1`, [input.orderItemId], ) - : await query<{ status: string }>( - `SELECT status + : await query<{ status: string; result_code: string }>( + `SELECT status, '' AS result_code FROM work_orders WHERE order_item_id = $1 ORDER BY id DESC @@ -82,7 +87,14 @@ async function findBlockingInactiveCarrier(input: { ) const status = String(result.rows[0]?.status || '') - if (!BLOCKED_INACTIVE_CARRIER_STATUSES.has(status)) return null + const resultCode = String(result.rows[0]?.result_code || '') + const shouldBlock = + input.channel === 'affiliate_dash' + ? INACTIVE_CARRIER_TASK_STATUSES.includes( + status as (typeof INACTIVE_CARRIER_TASK_STATUSES)[number], + ) + : BLOCKED_WORK_ORDER_STATUSES.has(status) + if (!shouldBlock) return null if (status === 'manual_review') { return { @@ -91,6 +103,34 @@ async function findBlockingInactiveCarrier(input: { message: '已有履约载体进入人工复核,需先处理后再重试', } } + if (status === 'retry_pending' || status === 'failed') { + return { + status, + reason: 'carrier_task_retry_pending', + message: '已有履约任务失败待重试,需由运营确认后重新创建', + } + } + if (status === 'expired') { + return { + status, + reason: 'carrier_task_expired', + message: '已有履约任务已过期,需由运营确认后重新创建', + } + } + if (resultCode === 'USER_APPLY_REFUND') { + return { + status, + reason: 'carrier_task_user_refund', + message: '用户已申请退款,不能自动重新发货', + } + } + if (resultCode === 'affiliate_dash_cancelled') { + return { + status, + reason: 'carrier_task_cancelled', + message: '上游履约订单已取消,不能自动重新发货', + } + } return { status, reason: 'carrier_task_closed', @@ -101,6 +141,14 @@ async function findBlockingInactiveCarrier(input: { } } +function shouldBlockInactiveCarrier( + inactiveCarrier: { reason: string } | null, + retryInactiveCarrier: boolean, +): inactiveCarrier is { status: string; reason: string; message: string } { + if (!inactiveCarrier) return false + return !retryInactiveCarrier || HARD_BLOCKED_INACTIVE_REASONS.has(inactiveCarrier.reason) +} + function toResolvedItem(item: OrderItemRow): ResolvedFulfillmentItem { return { ...item, @@ -127,15 +175,8 @@ export async function ensureCarrierForOrderItem(input: { item: OrderItemRow channel: FulfillmentChannel retryInactiveCarrier?: boolean - allowInactiveTaskRetry?: boolean }): Promise { - const { - order, - item, - channel, - retryInactiveCarrier = false, - allowInactiveTaskRetry = false, - } = input + const { order, item, channel, retryInactiveCarrier = false } = input const existingCarrier = findConflictingCarrier( await loadActiveCarrierEvidenceForItem(Number(item.id)), channel, @@ -160,14 +201,18 @@ export async function ensureCarrierForOrderItem(input: { ) const row = config.rows[0] if (row) { - await query( - `UPDATE order_items - SET fulfillment_config_id = $2, - canonical_product_id = COALESCE(canonical_product_id, $3), - updated_at = NOW() - WHERE id = $1`, - [item.id, row.id, row.canonical_product_id], - ) + await attachFulfillmentConfigurations({ + mappings: [ + { + orderItemId: Number(item.id), + configId: Number(row.id), + ...(row.canonical_product_id + ? { canonicalProductId: Number(row.canonical_product_id) } + : {}), + }, + ], + onlyWhenCanonicalProductNull: true, + }) return { ready: true, created: false, reason: 'manual_config_ready', message: '' } } return { @@ -194,7 +239,7 @@ export async function ensureCarrierForOrderItem(input: { orderItemId: Number(item.id), channel, }) - if (inactiveCarrier && !retryInactiveCarrier) { + if (shouldBlockInactiveCarrier(inactiveCarrier, retryInactiveCarrier)) { return { ready: false, created: false, @@ -274,7 +319,7 @@ export async function ensureCarrierForOrderItem(input: { orderItemId: Number(item.id), channel, }) - if (inactiveCarrier && !retryInactiveCarrier) { + if (shouldBlockInactiveCarrier(inactiveCarrier, retryInactiveCarrier)) { return { ready: false, created: false, @@ -326,7 +371,7 @@ export async function ensureCarrierForOrderItem(input: { item_snapshot_json: snapshot, } as OrderItemRow, ], - { allowInactiveTaskRetry: retryInactiveCarrier || allowInactiveTaskRetry }, + { allowInactiveTaskRetry: retryInactiveCarrier }, ) } const ready = await hasAffiliateTask(Number(item.id)) diff --git a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts index 1fcc4782..55d53cb8 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -1,4 +1,4 @@ -import { query } from '../../db/client.js' +import { query, withTransaction } from '../../db/client.js' import { getOrderById } from '../../repositories/order-repo.js' import type { OrderItemRow } from '../../types/repository/rows.js' import { logWarn } from '../../utils/logger.js' @@ -8,6 +8,10 @@ import { type FulfillmentChannel, } from './channel-types.js' import { ensureCarrierForOrderItem } from './carrier-creation-service.js' +import { + transitionFulfillmentState, + transitionFulfillmentStateWithExecutor, +} from './fulfillment-state-service.js' import { planInternalSkuChannels, type InternalSkuChannelRow, @@ -52,6 +56,7 @@ async function loadCandidateItems(input: { assignedMissingCarrier?: boolean afterId?: number includeLocked?: boolean + cutoffCreatedAt?: string }): Promise { const ids = (input.orderItemIds || []) .map(Number) @@ -65,6 +70,12 @@ async function loadCandidateItems(input: { params.push(Math.max(0, Math.trunc(Number(input.afterId)))) idFilter = `AND oi.id > $${params.length}` } + const cutoffCreatedAt = String(input.cutoffCreatedAt || '').trim() + let cutoffFilter = '' + if (cutoffCreatedAt && Number.isFinite(Date.parse(cutoffCreatedAt))) { + params.push(cutoffCreatedAt) + cutoffFilter = `AND oi.created_at >= $${params.length}::timestamptz` + } const statusFilter = input.assignedMissingCarrier ? `oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') @@ -76,7 +87,14 @@ async function loadCandidateItems(input: { SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')}) - )` + ) + AND COALESCE(( + SELECT ft.result_code + FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + ORDER BY ft.id DESC + LIMIT 1 + ), '') NOT IN ('USER_APPLY_REFUND', 'affiliate_dash_cancelled')` : `oi.fulfillment_status = 'pending' ${input.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}` @@ -116,6 +134,7 @@ async function loadCandidateItems(input: { ) capability ON jsonb_array_length(capability.channels) > 0 WHERE ${statusFilter} ${idFilter} + ${cutoffFilter} ORDER BY oi.id LIMIT $1 `, @@ -130,24 +149,50 @@ async function markPendingWithoutCarrier(input: { attempts: Array<{ channel: FulfillmentChannel; reason: string; message: string }> }): Promise { const carrierIssue = input.attempts[0]?.reason || 'carrier_attempt_failed' - await query( - ` - UPDATE order_items - SET fulfillment_pending_reason = 'no_carrier', - fulfillment_pending_detail = $2::jsonb, - updated_at = NOW() - WHERE id = $1 AND fulfillment_status = 'pending' - `, - [ - input.itemId, - JSON.stringify({ + await transitionFulfillmentState({ + orderItemId: input.itemId, + expected: { status: 'pending' }, + patch: { + pendingReason: 'no_carrier', + pendingDetail: { internalSku: input.internalSku, carrierIssue, carrierMessage: input.attempts[0]?.message || '', attempts: input.attempts, - }), - ], - ) + }, + }, + eventType: 'carrier_attempt_failed', + reason: carrierIssue, + }) +} + +async function markAssignedMissingCarrierPending(input: { + itemId: number + previousChannel: string + internalSku: string + attempts: Array<{ channel: FulfillmentChannel; reason: string; message: string }> +}): Promise { + const carrierIssue = input.attempts[0]?.reason || 'carrier_attempt_failed' + await transitionFulfillmentState({ + orderItemId: input.itemId, + expected: { status: 'assigned' }, + patch: { + status: 'pending', + channel: null, + source: 'auto_mapping', + locked: false, + pendingReason: 'no_carrier', + pendingDetail: { + internalSku: input.internalSku, + previousChannel: input.previousChannel, + carrierIssue, + carrierMessage: input.attempts[0]?.message || '', + attempts: input.attempts, + }, + }, + eventType: 'assigned_carrier_missing', + reason: carrierIssue, + }) } async function selectSafeFallback(input: { @@ -157,39 +202,41 @@ async function selectSafeFallback(input: { reason: string includeAssignedMissingCarrier: boolean }): Promise { - const result = await query( - ` - UPDATE order_items - SET fulfillment_channel = $2, - fulfillment_status = 'assigned', - fulfillment_source = 'auto_mapping', - fulfillment_reason = $3, - channel_switch_reason = $3, - channel_switched_at = NOW(), - canonical_product_id = NULL, - fulfillment_config_id = NULL, - fulfillment_pending_reason = NULL, - fulfillment_pending_detail = NULL, - updated_at = NOW() - WHERE id = $1 - AND ( - (fulfillment_status = 'pending' AND fulfillment_locked = FALSE) - OR ( - $4::boolean - AND fulfillment_status = 'assigned' - AND fulfillment_locked = FALSE - AND fulfillment_source NOT IN ('operator_assigned', 'manual_confirm') - ) - ) - `, - [ - input.orderItemId, - input.channel, - `主渠道 ${input.primaryChannel} 无法创建载体:${input.reason}`, - input.includeAssignedMissingCarrier, - ], - ) - const updated = (result.rowCount || 0) > 0 + const reason = `主渠道 ${input.primaryChannel} 无法创建载体:${input.reason}` + const updated = await withTransaction(async (client) => { + const common = { + orderItemId: input.orderItemId, + patch: { + status: 'assigned' as const, + channel: input.channel, + source: 'auto_mapping', + reason, + switchReason: reason, + switchedAt: new Date().toISOString(), + canonicalProductId: null, + configId: null, + pendingReason: null, + pendingDetail: null, + }, + eventType: 'fallback_carrier_selected', + reason, + } + const pending = await transitionFulfillmentStateWithExecutor(client, { + ...common, + expected: { status: 'pending', locked: false }, + }) + if (pending) return true + if (!input.includeAssignedMissingCarrier) return false + const assigned = await transitionFulfillmentStateWithExecutor(client, { + ...common, + expected: { + status: 'assigned', + locked: false, + sourceNotIn: ['operator_assigned', 'manual_confirm'], + }, + }) + return Boolean(assigned) + }) if (updated) { await scheduleVoucherConsumeForOrderItems([input.orderItemId], 'order_received').catch( () => undefined, @@ -213,6 +260,7 @@ export async function reconcilePendingCarriers( afterId?: number includeLocked?: boolean retryInactiveCarrier?: boolean + cutoffCreatedAt?: string } = {}, ): Promise<{ scanned: number @@ -229,6 +277,7 @@ export async function reconcilePendingCarriers( assignedMissingCarrier: input.includeAssignedMissingCarrier === true, ...(input.afterId != null ? { afterId: input.afterId } : {}), ...(input.includeLocked ? { includeLocked: true } : {}), + ...(input.cutoffCreatedAt ? { cutoffCreatedAt: input.cutoffCreatedAt } : {}), ...(input.orderItemIds ? { orderItemIds: input.orderItemIds } : {}), }) @@ -271,20 +320,19 @@ export async function reconcilePendingCarriers( if (attempts.length === 0) { failed += 1 if (!input.includeAssignedMissingCarrier) { - await query( - `UPDATE order_items - SET fulfillment_pending_reason = 'routing_conflict', - fulfillment_pending_detail = $2::jsonb, - updated_at = NOW() - WHERE id = $1 AND fulfillment_status = 'pending'`, - [ - item.id, - JSON.stringify({ + await transitionFulfillmentState({ + orderItemId: Number(item.id), + expected: { status: 'pending' }, + patch: { + pendingReason: 'routing_conflict', + pendingDetail: { internalSku: candidate.internal_sku, channels: plan.conflicts.map((channel) => channel.channel), - }), - ], - ) + }, + }, + eventType: 'carrier_routing_conflict', + reason: 'routing_conflict', + }) } continue } @@ -299,7 +347,6 @@ export async function reconcilePendingCarriers( item, channel: attempt.channel, retryInactiveCarrier: input.retryInactiveCarrier === true, - allowInactiveTaskRetry: true, }) if (!carrier.ready) { failures.push({ @@ -361,6 +408,13 @@ export async function reconcilePendingCarriers( internalSku: candidate.internal_sku, attempts: failures, }) + } else { + await markAssignedMissingCarrierPending({ + itemId: Number(item.id), + previousChannel: assignedChannel, + internalSku: candidate.internal_sku, + attempts: failures, + }) } } } diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 4e08ab3c..704a549a 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -35,6 +35,14 @@ import { reconcilePendingCarriers } from './carrier-reconciliation-service.js' import { TASK_STATUS } from '../../domain/task-status.js' import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js' import { scheduleVoucherConsumeForOrderItems } from './voucher-consume-queue-service.js' +import { + assignFulfillmentChannelsWithExecutor, + attachFulfillmentConfigurations, + transitionFulfillmentState, + transitionFulfillmentStateBatch, + transitionFulfillmentStateBatchWithExecutor, + transitionFulfillmentStateWithExecutor, +} from './fulfillment-state-service.js' /** * 管道 A:渠道判定。 @@ -254,6 +262,7 @@ export async function assignPendingOrderItemChannels( afterId?: number orderItemIds?: number[] includeLocked?: boolean + cutoffCreatedAt?: string } = {}, ): Promise { const batchSize = Math.min(5000, Math.max(1, Math.trunc(options.batchSize || 500))) @@ -288,6 +297,12 @@ export async function assignPendingOrderItemChannels( params.push(requestedIds) requestedFilter = `AND oi.id = ANY($${params.length}::bigint[])` } + const cutoffCreatedAt = String(options.cutoffCreatedAt || '').trim() + let cutoffFilter = '' + if (cutoffCreatedAt && Number.isFinite(Date.parse(cutoffCreatedAt))) { + params.push(cutoffCreatedAt) + cutoffFilter = `AND o.created_at >= $${params.length}::timestamptz` + } for (let batch = 0; batch < maxBatches; batch += 1) { params[0] = cursor @@ -323,6 +338,7 @@ export async function assignPendingOrderItemChannels( ${options.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'} AND oi.id > $1 ${requestedFilter} + ${cutoffFilter} ORDER BY oi.id LIMIT $2 `, @@ -357,20 +373,19 @@ export async function assignPendingOrderItemChannels( if (decision.channel) affectedDates.add(item.basis_date) if (decision.conflict) { result.conflicted += 1 - await query( - `UPDATE order_items - SET fulfillment_pending_reason = 'routing_conflict', - fulfillment_pending_detail = $2::jsonb, - updated_at = NOW() - WHERE id = $1 AND fulfillment_status = 'pending'`, - [ - item.id, - JSON.stringify({ + await transitionFulfillmentState({ + orderItemId: Number(item.id), + expected: { status: 'pending' }, + patch: { + pendingReason: 'routing_conflict', + pendingDetail: { channels: decision.conflictChannels, ruleIds: decision.matchedRuleIds, - }), - ], - ) + }, + }, + eventType: 'routing_conflict_detected', + reason: 'routing_conflict', + }) await upsertAggregatedException({ reason: 'routing_conflict', scopeKey: `routing_conflict|-|-|${item.internal_sku}|-`, @@ -383,20 +398,18 @@ export async function assignPendingOrderItemChannels( result.stillPending += 1 const pending = resolvePendingReason(item) result.pendingReasons[pending.reason] = (result.pendingReasons[pending.reason] || 0) + 1 - await query( - `UPDATE order_items - SET fulfillment_pending_reason = $2, - fulfillment_pending_detail = CASE - WHEN $2 = 'no_carrier' - AND COALESCE(fulfillment_pending_detail->>'carrierIssue', '') - NOT IN ('', 'carrier_not_attempted') - THEN fulfillment_pending_detail - ELSE $3::jsonb - END, - updated_at = NOW() - WHERE id = $1 AND fulfillment_status = 'pending'`, - [item.id, pending.reason, JSON.stringify(pending.detail)], - ) + await transitionFulfillmentState({ + orderItemId: Number(item.id), + expected: { status: 'pending' }, + patch: { + pendingReason: pending.reason, + ...(pending.reason === 'no_carrier' + ? { pendingDetailUnlessCarrierIssue: pending.detail } + : { pendingDetail: pending.detail }), + }, + eventType: 'routing_pending', + reason: pending.reason, + }) continue } assignments.push({ id: item.id, channel: decision.channel, source: decision.source }) @@ -404,32 +417,19 @@ export async function assignPendingOrderItemChannels( } if (assignments.length > 0) { - const updated = await query<{ id: number }>( - ` - UPDATE order_items oi - SET - fulfillment_channel = input.channel, - fulfillment_status = 'assigned', - fulfillment_source = input.source, - fulfillment_decided_at = NOW(), - fulfillment_pending_reason = NULL, - fulfillment_pending_detail = NULL, - updated_at = NOW() - FROM unnest($1::bigint[], $2::text[], $3::text[]) - AS input(id, channel, source) - WHERE oi.id = input.id - AND oi.fulfillment_status = 'pending' - AND (oi.fulfillment_locked = FALSE OR $4::boolean) - RETURNING oi.id - `, - [ - assignments.map((row) => row.id), - assignments.map((row) => row.channel), - assignments.map((row) => row.source), - options.includeLocked === true, - ], + const updated = await withTransaction((client) => + assignFulfillmentChannelsWithExecutor(client, { + assignments: assignments.map((row) => ({ + orderItemId: row.id, + channel: row.channel, + source: row.source, + })), + includeLocked: options.includeLocked === true, + eventType: 'routing_channel_assigned', + reason: 'automatic_routing', + }), ) - const assignedItemIds = updated.rows + const assignedItemIds = updated .map((row) => Number(row.id)) .filter((id) => id > 0) .slice(0, 2000) @@ -495,12 +495,13 @@ export async function assignOrderItemsAfterCarrierReady(input: { ) { // 旧载体尚未撤销时禁止直接换渠道,否则会同时留下两个履约执行方。 carrierFailed += 1 - await query( - `UPDATE order_items - SET fulfillment_reason = $2, updated_at = NOW() - WHERE id = $1 AND fulfillment_status = 'assigned'`, - [item.id, '切换渠道前需先撤销现有履约载体'], - ) + await transitionFulfillmentState({ + orderItemId: Number(item.id), + expected: { status: 'assigned' }, + patch: { reason: '切换渠道前需先撤销现有履约载体' }, + eventType: 'channel_switch_blocked_by_existing_carrier', + reason: String(item.fulfillment_channel || ''), + }) continue } @@ -511,7 +512,6 @@ export async function assignOrderItemsAfterCarrierReady(input: { item, channel: input.channel, retryInactiveCarrier: true, - allowInactiveTaskRetry: true, }) } catch (error) { carrier = { @@ -531,78 +531,71 @@ export async function assignOrderItemsAfterCarrierReady(input: { }) if (item.fulfillment_status === 'assigned') { // 运营切换渠道失败时恢复原渠道,宁可保留旧状态,也不制造渠道与载体不一致。 - await query( - `UPDATE order_items - SET fulfillment_channel = $2, - fulfillment_status = $3, - fulfillment_source = $4, - fulfillment_locked = $5, - fulfillment_reason = $6, - fulfillment_pending_reason = $7, - fulfillment_pending_detail = $8::jsonb, - updated_at = NOW() - WHERE id = $1 AND fulfillment_status <> 'archived'`, - [ - item.id, - item.fulfillment_channel, - item.fulfillment_status, - item.fulfillment_source, - item.fulfillment_locked, - item.fulfillment_reason, - item.fulfillment_pending_reason, - JSON.stringify(item.fulfillment_pending_detail || null), - ], - ) + await transitionFulfillmentState({ + orderItemId: Number(item.id), + expected: { status: 'assigned' }, + patch: { + channel: item.fulfillment_channel as FulfillmentChannel, + status: 'assigned', + source: item.fulfillment_source || '', + locked: Boolean(item.fulfillment_locked), + reason: item.fulfillment_reason || null, + pendingReason: item.fulfillment_pending_reason || null, + pendingDetail: + item.fulfillment_pending_detail && typeof item.fulfillment_pending_detail === 'object' + ? (item.fulfillment_pending_detail as Record) + : null, + }, + eventType: 'channel_switch_failed_existing_state_kept', + reason: carrier.reason, + }) } else { - await query( - `UPDATE order_items - SET fulfillment_channel = $3, - fulfillment_status = 'pending', - fulfillment_source = 'operator_assigned', - fulfillment_locked = TRUE, - fulfillment_pending_reason = 'no_carrier', - fulfillment_pending_detail = $2::jsonb, - canonical_product_id = COALESCE($4, canonical_product_id), - updated_at = NOW() - WHERE id = $1 AND fulfillment_status <> 'archived'`, - [ - item.id, - JSON.stringify({ + await transitionFulfillmentState({ + orderItemId: Number(item.id), + expected: { status: 'pending' }, + patch: { + channel: null, + status: 'pending', + source: 'operator_assigned', + locked: true, + pendingReason: 'no_carrier', + pendingDetail: { channel: input.channel, reason: carrier.reason, message: carrier.message, carrierIssue: carrier.reason, - }), - input.channel, - input.canonicalProductId || null, - ], - ) + }, + ...(input.canonicalProductId + ? { canonicalProductIdIfNull: Number(input.canonicalProductId) } + : {}), + }, + eventType: 'operator_channel_assign_missing_carrier', + actor: { type: 'operator' }, + reason: carrier.reason, + }) } continue } - const updated = await query( - `UPDATE order_items - SET fulfillment_channel = $2, - fulfillment_status = 'assigned', - fulfillment_source = $3, - fulfillment_locked = $4, - canonical_product_id = COALESCE($6, canonical_product_id), - fulfillment_reason = COALESCE(NULLIF($5, ''), fulfillment_reason), - fulfillment_pending_reason = NULL, - fulfillment_pending_detail = NULL, - updated_at = NOW() - WHERE id = $1 AND fulfillment_status <> 'archived'`, - [ - item.id, - input.channel, - input.source, - input.lock, - String(input.note || '').slice(0, 200), - input.canonicalProductId || null, - ], - ) - if ((updated.rowCount || 0) > 0) assignedIds.push(Number(item.id)) + const updated = await transitionFulfillmentState({ + orderItemId: Number(item.id), + patch: { + channel: input.channel, + status: 'assigned', + source: input.source, + locked: input.lock, + ...(input.canonicalProductId + ? { canonicalProductIdIfNull: Number(input.canonicalProductId) } + : {}), + ...(String(input.note || '').trim() ? { reason: String(input.note).slice(0, 200) } : {}), + pendingReason: null, + pendingDetail: null, + }, + eventType: 'operator_carrier_ready', + actor: { type: 'operator' }, + reason: input.note || '', + }) + if (updated) assignedIds.push(Number(item.id)) } if (assignedIds.length > 0) { @@ -619,25 +612,22 @@ export async function assignOrderItemsAfterCarrierReady(input: { ] await refreshFactsForDates(dates).catch(() => undefined) } - const final = await query<{ count: number }>( - `WITH updated AS ( - UPDATE order_items - SET fulfillment_source = $3, - fulfillment_locked = $4, - fulfillment_pending_reason = NULL, - fulfillment_pending_detail = NULL, - updated_at = NOW() - WHERE id = ANY($1::bigint[]) - AND fulfillment_status = 'assigned' - AND fulfillment_channel = $2 - RETURNING id - ) - SELECT count(*)::int AS count FROM updated`, - [ids, input.channel, input.source, input.lock], - ) + const finalized = await transitionFulfillmentStateBatch({ + orderItemIds: ids, + expected: { status: 'assigned', channel: input.channel }, + patch: { + source: input.source, + locked: input.lock, + pendingReason: null, + pendingDetail: null, + }, + eventType: 'assigned_channel_snapshot_updated', + actor: { type: 'operator' }, + reason: input.note || '', + }) await syncAssignedChannelSnapshots(ids, input.channel, input.source) return { - updated: Number(final.rows[0]?.count || assignedIds.length), + updated: finalized.length || assignedIds.length, carrierFailed, carrierFailures, } @@ -761,6 +751,14 @@ export async function switchChannelForOrderItems(input: { switchInternalSkus.length === 0 ? 'internal_sku_required' : 'multiple_internal_skus', }) } + const targetInternalSku = switchInternalSkus[0]! + const draftProductId = await ensureDraftProductForChannel(targetInternalSku, input.channel) + if (!draftProductId) { + throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', { + statusCode: 409, + errorCode: 'channel_standard_product_required', + }) + } const items = ( await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) @@ -853,24 +851,29 @@ export async function switchChannelForOrderItems(input: { WHERE order_item_id = $1 AND superseded_at IS NULL AND reversed_at IS NULL`, [item.id, now], ) - await client.query( - `UPDATE order_items - SET original_channel = COALESCE(original_channel, fulfillment_channel), - fulfillment_channel = NULL, - fulfillment_status = 'pending', - fulfillment_source = 'operator_assigned', - fulfillment_locked = FALSE, - fulfillment_reason = $2, - channel_switch_reason = 'operator', - channel_switched_at = $3, - canonical_product_id = NULL, - fulfillment_config_id = NULL, - fulfillment_pending_reason = NULL, - fulfillment_pending_detail = NULL, - updated_at = $3 - WHERE id = $1`, - [item.id, String(input.note || '运营切换履约渠道').slice(0, 200), now], - ) + await transitionFulfillmentStateWithExecutor(client, { + orderItemId: Number(item.id), + expected: { status: item.fulfillment_status as 'pending' | 'assigned' | 'archived' }, + patch: { + ...(isFulfillmentChannel(item.fulfillment_channel) + ? { originalChannelIfNull: item.fulfillment_channel } + : {}), + channel: null, + status: 'pending', + source: 'operator_assigned', + locked: false, + reason: String(input.note || '运营切换履约渠道').slice(0, 200), + switchReason: 'operator', + switchedAt: now, + canonicalProductId: null, + configId: null, + pendingReason: null, + pendingDetail: null, + }, + eventType: 'operator_channel_switched', + actor: { type: 'operator', id: input.adminUserId }, + reason: input.note || '', + }) }) for (const workOrder of workOrders.rows) { @@ -909,20 +912,24 @@ export async function switchChannelForOrderItems(input: { source: 'operator_assigned', lock: true, note: input.note, + canonicalProductId: draftProductId, + }) + await transitionFulfillmentStateBatch({ + orderItemIds: readyIds, + patch: { + decidedBy: input.adminUserId, + decidedAt: nowIso(), + }, + eventType: 'operator_channel_decision_recorded', + actor: { type: 'operator', id: input.adminUserId }, + reason: input.note || '', }) - await query( - `UPDATE order_items - SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW() - WHERE id = ANY($1::bigint[])`, - [readyIds, input.adminUserId], - ) if (assigned.updated > 0) { - const internalSku = switchInternalSkus[0]! - const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel) + const carrier = await resolveInternalSkuCarrierConfig(targetInternalSku, input.channel) await setInternalSkuPrimaryChannel({ - internalSku, + internalSku: targetInternalSku, channel: input.channel, - canonicalProductId: carrier.canonicalProductId, + canonicalProductId: draftProductId, configJson: carrier.configJson, source: 'operator', note: input.note, @@ -986,11 +993,14 @@ export async function assignChannelToOrderItems(input: { export async function unlockChannel(orderItemIds: number[]): Promise<{ updated: number }> { const ids = [...new Set(orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))] if (ids.length === 0) return { updated: 0 } - const result = await query( - `UPDATE order_items SET fulfillment_locked = FALSE, updated_at = NOW() WHERE id = ANY($1::bigint[])`, - [ids], - ) - return { updated: result.rowCount || 0 } + const updated = await transitionFulfillmentStateBatch({ + orderItemIds: ids, + expected: { locked: true }, + patch: { locked: false }, + eventType: 'fulfillment_unlocked', + actor: { type: 'operator' }, + }) + return { updated: updated.length } } export type PendingGroupRow = { @@ -1010,6 +1020,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 = { @@ -1036,21 +1064,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 ') @@ -1059,10 +1130,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 @@ -1076,57 +1146,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, @@ -1139,16 +1276,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, @@ -1173,7 +1307,7 @@ export async function listPendingChannelGroups(input: { usage_count DESC, shop_name `, - [groupKeys], + [groupKeys, issueCategory], ) const seen = new Set() @@ -1209,9 +1343,17 @@ export type RetryPendingCarrierGroupResult = { remaining: number } -/** 运营确认后重试整组载体:允许重开已关闭的历史尝试,并包含运营锁定的订单项。 */ +/** + * 运营确认后重试整组载体。 + * + * 默认只处理未锁定且没有历史终态任务的订单;重开旧任务必须显式传入 + * retryInactiveCarrier。锁定项需要先解除锁定,避免一次操作误伤整组。 + */ export async function retryPendingCarrierGroup(input: { internalSku: string + retryInactiveCarrier?: boolean + includeLocked?: boolean + issueCodes?: string[] }): Promise { const internalSku = canonicalizeInternalSku(input.internalSku) if (!internalSku || !containsCjk(internalSku)) { @@ -1221,13 +1363,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 @@ -1240,8 +1406,8 @@ export async function retryPendingCarrierGroup(input: { const carrier = await reconcilePendingCarriers({ orderItemIds: batchIds, limit: batchIds.length, - includeLocked: true, - retryInactiveCarrier: true, + includeLocked: input.includeLocked === true, + retryInactiveCarrier: input.retryInactiveCarrier === true, }) workOrdersCreated += carrier.workOrdersCreated affiliateTasksCreated += carrier.affiliateTasksCreated @@ -1251,25 +1417,44 @@ export async function retryPendingCarrierGroup(input: { batchSize: batchIds.length, maxBatches: 1, afterId: 0, - includeLocked: true, + includeLocked: input.includeLocked === true, }) assignedItemIds.push(...applied.assignedItemIds) } - await query( - `UPDATE order_items oi - SET fulfillment_config_id = cfg.id, - canonical_product_id = COALESCE(oi.canonical_product_id, cfg.canonical_product_id), - updated_at = NOW() - FROM manual_fulfillment_configs cfg + const manualConfigs = await query<{ + order_item_id: number + config_id: number + canonical_product_id: number | null + }>( + `SELECT DISTINCT ON (oi.id) + oi.id AS order_item_id, + cfg.id AS config_id, + cfg.canonical_product_id + FROM order_items oi + JOIN manual_fulfillment_configs cfg + ON canonicalize_internal_sku(cfg.sku_key) = + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) + AND cfg.enabled = TRUE WHERE oi.id = ANY($1::bigint[]) AND oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' - AND cfg.enabled = TRUE - AND canonicalize_internal_sku(cfg.sku_key) = - resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)`, + ORDER BY oi.id, cfg.id`, [ids], ) + if (manualConfigs.rows.length > 0) { + await attachFulfillmentConfigurations({ + mappings: manualConfigs.rows.map((row) => ({ + orderItemId: Number(row.order_item_id), + configId: Number(row.config_id), + ...(row.canonical_product_id + ? { canonicalProductId: Number(row.canonical_product_id) } + : {}), + })), + expected: { status: 'assigned', channel: 'manual' }, + onlyWhenCanonicalProductNull: true, + }) + } if (assignedItemIds.length > 0) { await normalizeProductsForItems(assignedItemIds).catch(() => undefined) const cost = await syncCostSnapshotsForItems(assignedItemIds).catch(() => null) @@ -1296,6 +1481,51 @@ 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) { + throw createHttpError('缺少待分类队列分组标识', { + statusCode: 400, + errorCode: 'pending_group_key_required', + }) + } + + const params: unknown[] = [] + const where = ["state.fulfillment_status = 'pending'", 'state.fulfillment_locked = TRUE'] + if (scope.type === 'internal_sku') { + params.push(scope.internalSku) + where.push(`state.internal_sku = $${params.length}`) + } else { + params.push(scope.provider, scope.shopId, scope.skuCode) + 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 matched = await query<{ order_item_id: number }>( + `SELECT state.order_item_id + FROM v_fulfillment_item_state state + WHERE ${where.join(' AND ')} + ORDER BY state.order_item_id`, + params, + ) + const updated = await transitionFulfillmentStateBatch({ + orderItemIds: matched.rows.map((row) => Number(row.order_item_id)), + expected: { status: 'pending', locked: true }, + patch: { locked: false }, + eventType: 'pending_group_unlocked', + actor: { type: 'operator' }, + }) + return { updated: updated.length } +} + export type DeletePendingChannelGroupResult = { archivedItems: number preservedOrders: number @@ -1346,6 +1576,7 @@ function resolvePendingGroupScopeInput(input: { export async function deletePendingChannelGroup(input: { groupKey?: string internalSku?: string + issueCategory?: string adminUserId: number | null }): Promise { const scope = resolvePendingGroupScopeInput(input) @@ -1366,6 +1597,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 @@ -1429,27 +1670,26 @@ export async function deletePendingChannelGroup(input: { [itemIds, exceptionIds], ) - const archived = await client.query( - ` - UPDATE order_items - SET fulfillment_channel = NULL, - fulfillment_status = 'archived', - fulfillment_source = 'cutover_archive', - fulfillment_locked = TRUE, - fulfillment_reason = '运营从待分类队列删除', - fulfillment_pending_reason = NULL, - fulfillment_pending_detail = NULL, - fulfillment_decided_by = $2, - fulfillment_decided_at = NOW(), - updated_at = NOW() - WHERE id = ANY($1::bigint[]) - AND fulfillment_status = 'pending' - `, - [itemIds, input.adminUserId], - ) + const archived = await transitionFulfillmentStateBatchWithExecutor(client, { + orderItemIds: itemIds, + expected: { status: 'pending' }, + patch: { + channel: null, + status: 'archived', + source: 'cutover_archive', + locked: true, + reason: '运营从待分类队列删除', + pendingReason: null, + pendingDetail: null, + decidedBy: input.adminUserId, + decidedAt: nowIso(), + }, + eventType: 'pending_group_archived', + actor: { type: 'operator', id: input.adminUserId }, + }) return { - archivedItems: archived.rowCount || 0, + archivedItems: archived.length, preservedOrders: new Set(items.rows.map((row) => Number(row.order_id))).size, deletedCostSnapshots: snapshots.rowCount || 0, resolvedExceptions: resolved.rowCount || 0, @@ -1473,6 +1713,7 @@ export async function assignPendingChannelGroup(input: { internalSku?: string mappedInternalSku?: string channel: string + issueCategory?: string adminUserId: number | null note?: string | undefined }): Promise<{ @@ -1497,7 +1738,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) @@ -1509,8 +1762,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 已发生变化,请刷新后重试', { @@ -1521,6 +1776,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, }) @@ -1528,6 +1785,7 @@ export async function assignPendingChannelGroup(input: { return assignChannelByInternalSku({ internalSku: scope.internalSku, channel: input.channel, + issueCategory: input.issueCategory || '', adminUserId: input.adminUserId, note: input.note, }) @@ -1537,6 +1795,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 @@ -1565,36 +1825,46 @@ 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).catch( - (error) => { - logWarn('[channel-assign]', '自动创建渠道草稿商品失败', { - internalSku, - channel: input.channel, - error: error instanceof Error ? error.message : String(error), - }) - return null - }, - ) + const draftProductId = await ensureDraftProductForChannel(internalSku, input.channel) + if (!draftProductId) { + throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', { + statusCode: 409, + errorCode: 'channel_standard_product_required', + }) + } const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel) await setInternalSkuPrimaryChannel({ internalSku, channel: input.channel, - canonicalProductId: carrier.canonicalProductId, + canonicalProductId: draftProductId, source: 'operator', configJson: { ...carrier.configJson, @@ -1615,12 +1885,16 @@ export async function assignChannelByInternalSku(input: { note: input.note, canonicalProductId: draftProductId, }) - await query( - `UPDATE order_items - SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW() - WHERE id = ANY($1::bigint[])`, - [ids, input.adminUserId], - ) + await transitionFulfillmentStateBatch({ + orderItemIds: ids, + patch: { + decidedBy: input.adminUserId, + decidedAt: nowIso(), + }, + eventType: 'operator_channel_decision_recorded', + actor: { type: 'operator', id: input.adminUserId }, + reason: input.note || '', + }) return { ...applied, @@ -1818,7 +2092,18 @@ async function ensureDraftProductForChannel( $5::jsonb, $6, $6 ) ON CONFLICT (canonical_sku) DO UPDATE - SET updated_at = product_catalog.updated_at + SET status = 'active', + canonical_name = CASE + WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name + ELSE product_catalog.canonical_name + END, + attributes_json = ( + product_catalog.attributes_json + - 'deletedAt' + - 'deletedBy' + - 'archivedAt' + ) || jsonb_build_object('reactivatedAt', EXCLUDED.updated_at), + updated_at = EXCLUDED.updated_at RETURNING id, status `, [ @@ -1891,7 +2176,7 @@ async function resolveInternalSkuCarrierConfig( FROM product_catalog WHERE status = 'active' AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3) - AND canonicalize_internal_sku(canonical_name) = $1 + AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1 ORDER BY id`, [key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`], ) @@ -1958,19 +2243,28 @@ export async function upsertChannelRule(input: { }) } - const priority = Math.trunc(Number(input.priority) || 100) + const draftProductId = await ensureDraftProductForChannel(matchKey, input.channel) + if (!draftProductId) { + throw createHttpError('渠道标准商品创建或启用失败,请先在商品配置中处理', { + statusCode: 409, + errorCode: 'channel_standard_product_required', + }) + } + + const priority = + input.channel === 'third_party_voucher' ? 10 : Math.trunc(Number(input.priority) || 100) const carrier = await resolveInternalSkuCarrierConfig(matchKey, input.channel) const id = await upsertInternalSkuChannel({ internalSku: matchKey, channel: input.channel, priority, - isFallback: priority >= 90, + isFallback: input.channel === 'third_party_voucher' ? false : priority >= 90, enabled: input.enabled !== false, source: source === 'operator' ? 'operator' : `rule:${source}`, note: String(input.note || '').slice(0, 300), adminUserId: input.adminUserId, replacePriority: true, - canonicalProductId: carrier.canonicalProductId, + canonicalProductId: draftProductId, configJson: carrier.configJson, }) return { id } @@ -2079,15 +2373,18 @@ export async function ensureVoucherRuleForSku(skuCode: string): Promise LIMIT 1`, [`tpv:${key}`], ) + const productId = Number(product.rows[0]?.id || 0) + if (!productId) return false await upsertInternalSkuChannel({ internalSku: key, channel: 'third_party_voucher', priority: 10, isFallback: false, enabled: true, - canonicalProductId: Number(product.rows[0]?.id || 0) || null, + canonicalProductId: productId, configJson: { voucherSku: key }, source: 'voucher', + replacePriority: true, }) return true } @@ -2110,7 +2407,8 @@ export async function ensureVoucherChannelRules(): Promise<{ FROM product_catalog product WHERE product.status = 'active' AND product.canonical_sku LIKE 'tpv:%' - AND canonicalize_internal_sku(channel.internal_sku) = canonicalize_internal_sku(product.canonical_name) + AND canonicalize_internal_sku(channel.internal_sku) = + canonicalize_internal_sku(substring(product.canonical_sku from 5)) AND channel.channel <> 'third_party_voucher' AND channel.enabled = TRUE AND channel.is_fallback = FALSE`, @@ -2122,17 +2420,21 @@ export async function ensureVoucherChannelRules(): Promise<{ canonical_product_id, config_json, source, created_at, updated_at ) SELECT - canonicalize_internal_sku(p.canonical_name), 'third_party_voucher', 10, FALSE, TRUE, + canonicalize_internal_sku(substring(p.canonical_sku from 5)), + 'third_party_voucher', 10, FALSE, TRUE, p.id, jsonb_build_object('voucherSku', substring(p.canonical_sku from 5)), 'voucher', NOW(), NOW() FROM product_catalog p WHERE p.status = 'active' AND p.canonical_sku LIKE 'tpv:%' - AND BTRIM(COALESCE(p.canonical_name, '')) <> '' + AND canonicalize_internal_sku(substring(p.canonical_sku from 5)) <> '' ON CONFLICT (internal_sku, channel) DO UPDATE SET + priority = 10, + is_fallback = FALSE, enabled = TRUE, - canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), + canonical_product_id = EXCLUDED.canonical_product_id, config_json = internal_sku_channels.config_json || EXCLUDED.config_json, + source = EXCLUDED.source, updated_at = NOW() RETURNING id `, diff --git a/apps/backend/src/services/fulfillment/fulfillment-automation-config-service.ts b/apps/backend/src/services/fulfillment/fulfillment-automation-config-service.ts new file mode 100644 index 00000000..83b70b57 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-automation-config-service.ts @@ -0,0 +1,30 @@ +import { query } from '../../db/client.js' +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' + +type FulfillmentAutomationConfig = { + cutoff: string +} + +export async function getFulfillmentAutomationCutoff(): Promise { + const result = await query<{ config_json: FulfillmentAutomationConfig | null }>( + `SELECT config_json + FROM app_config_entries + WHERE config_key = $1 + LIMIT 1`, + [APP_CONFIG_KEYS.fulfillmentAutomationCutoff], + ) + const cutoff = String(result.rows[0]?.config_json?.cutoff || '').trim() + if (!cutoff || !Number.isFinite(Date.parse(cutoff))) return '' + return cutoff +} + +export function fulfillmentCreatedAfterSql( + params: unknown[], + cutoff: string, + alias = 'oi', +): string { + const normalized = String(cutoff || '').trim() + if (!normalized || !Number.isFinite(Date.parse(normalized))) return '' + params.push(normalized) + return `AND ${alias}.created_at >= $${params.length}::timestamptz` +} diff --git a/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts new file mode 100644 index 00000000..7b5709e7 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const carrierCreation = readFileSync( + new URL('./carrier-creation-service.ts', import.meta.url), + 'utf8', +) +const carrierReconciliation = readFileSync( + new URL('./carrier-reconciliation-service.ts', import.meta.url), + 'utf8', +) +const channelAssign = readFileSync(new URL('./channel-assign-service.ts', import.meta.url), 'utf8') +const taskRepo = readFileSync(new URL('../../repositories/task-repo.ts', import.meta.url), 'utf8') +const currentAttemptMigration = readFileSync( + new URL('../../db/migrations/138_fulfillment_current_attempt.sql', import.meta.url), + 'utf8', +) + +test('自动发货历史非活跃任务默认阻止后台自动重开', () => { + assert.ok(carrierCreation.includes('INACTIVE_CARRIER_TASK_STATUSES.includes')) + assert.ok(carrierCreation.includes("reason: 'carrier_task_retry_pending'")) + assert.ok(carrierCreation.includes('HARD_BLOCKED_INACTIVE_REASONS')) + assert.ok(carrierCreation.includes("'carrier_task_user_refund'")) + assert.ok(!carrierReconciliation.includes('allowInactiveTaskRetry: true')) +}) + +test('已分配但载体失效的订单会退回 pending/no_carrier', () => { + assert.ok(carrierReconciliation.includes('markAssignedMissingCarrierPending')) + assert.ok(carrierReconciliation.includes('transitionFulfillmentState')) + assert.ok(carrierReconciliation.includes("status: 'pending'")) + assert.ok(carrierReconciliation.includes("pendingReason: 'no_carrier'")) + assert.ok(carrierReconciliation.includes("'USER_APPLY_REFUND', 'affiliate_dash_cancelled'")) +}) + +test('整组补载体默认不包含锁定项,也不强制重开历史任务', () => { + assert.ok(channelAssign.includes('retryInactiveCarrier?: boolean')) + assert.ok(channelAssign.includes('includeLocked?: boolean')) + assert.ok(channelAssign.includes('retryInactiveCarrier: input.retryInactiveCarrier === true')) + assert.ok(channelAssign.includes('includeLocked: input.includeLocked === true')) + assert.ok(channelAssign.includes('unlockPendingChannelGroup')) +}) + +test('任务列表默认只展示每个订单项单元的最新尝试', () => { + assert.ok(taskRepo.includes('currentOnly = false')) + assert.ok(taskRepo.includes('ft.is_current = TRUE')) +}) + +test('创建新履约尝试时自动退休旧当前尝试', () => { + assert.ok(currentAttemptMigration.includes('attempt_no INTEGER')) + assert.ok(currentAttemptMigration.includes('is_current BOOLEAN')) + assert.ok(currentAttemptMigration.includes('uq_fulfillment_tasks_current_unit')) + assert.ok(taskRepo.includes('MAX(attempt_no)')) + assert.ok(taskRepo.includes('SET is_current = FALSE')) + assert.ok(taskRepo.includes('$26, TRUE')) + assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE')) +}) 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 46131fc6..42f659c2 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -19,6 +19,7 @@ test( process.env.DATABASE_URL = TEST_DATABASE_URL const { closeDb, query } = await import('../../db/client.js') const { + assignPendingOrderItemChannels, assignChannelByInternalSku, deletePendingChannelGroup, deleteChannelRules, @@ -29,11 +30,15 @@ test( listPendingChannelGroups, retryPendingCarrierGroup, switchChannelForOrderItems, + unlockPendingChannelGroup, upsertChannelRule, } = await import('./channel-assign-service.js') const { deleteManualSkuClassification, saveManualFulfillmentConfig } = await import('../cost-center/manual-fulfillment-config-service.js') const { runAffiliateFallback } = await import('./affiliate-fallback-service.js') + const { getFulfillmentAutomationCutoff } = + await import('./fulfillment-automation-config-service.js') + const { reconcilePendingCarriers } = await import('./carrier-reconciliation-service.js') const { retryPendingWorkOrderRouting } = await import('./pending-channel-repair-service.js') const { deleteInternalSkuMapping, @@ -43,6 +48,8 @@ test( } = await import('./internal-sku-mapping-service.js') const { resolveOrderItemForFulfillment } = await import('./product-resolution-service.js') const { updateOrderItemSourceSnapshot } = await import('../../repositories/order-item-repo.js') + const { listTasks } = await import('../../repositories/task-repo.js') + const { listVoucherConsumeJobs } = await import('./voucher-consume-queue-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const shopId = `it-routing-${suffix}` const manualSku = `集成测试人工-${suffix}` @@ -54,6 +61,10 @@ test( const retryWorkOrderSku = `集成测试重试接单-${suffix}` const manualRemoveSku = `集成测试移除人工-${suffix}` const protectedRuleSku = `集成测试强制删除-${suffix}` + const missingVoucherSku = `集成测试无商品卡券-${suffix}` + const reactivatedVoucherSku = `集成测试归档卡券-${suffix}` + const retryPendingAffiliateSku = `集成测试失败待重试-${suffix}` + const cutoffTestSku = `集成测试自动化边界-${suffix}` const retryWorkOrderRuleKey = `it-retry-${suffix}` const orderIds: number[] = [] const orderItemIds: number[] = [] @@ -69,8 +80,12 @@ test( retryWorkOrderSku, manualRemoveSku, protectedRuleSku, + missingVoucherSku, + reactivatedVoucherSku, + retryPendingAffiliateSku, + cutoffTestSku, ] - let generatedVoucherProductId: number | null = null + const createdVoucherProductIds: number[] = [] let autoManualProductId: number | null = null let retryWorkOrderRuleId: number | null = null let fallbackProfileId: number | null = null @@ -130,6 +145,40 @@ test( assert.equal(canonicalResolution.rows[0]?.platform_only, null) assert.equal(canonicalResolution.rows[0]?.cjk_extension, '𠀀') assert.equal(canonicalResolution.rows[0]?.case_variant, '黑色rock长袖t恤') + assert.ok(Number.isFinite(Date.parse(await getFulfillmentAutomationCutoff()))) + + const cutoffNewItemId = await createPendingOrderItem(cutoffTestSku) + const cutoffOldItemId = await createPendingOrderItem(cutoffTestSku) + await query( + `UPDATE orders + SET created_at = '2019-01-01T00:00:00+08:00', + updated_at = '2019-01-01T00:00:00+08:00' + WHERE id = (SELECT order_id FROM order_items WHERE id = $1)`, + [cutoffOldItemId], + ) + await query( + `INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, config_json, source + ) VALUES ($1, 'third_party_voucher', 10, FALSE, TRUE, '{}'::jsonb, 'integration_test')`, + [cutoffTestSku], + ) + const cutoffAssignment = await assignPendingOrderItemChannels({ + orderItemIds: [cutoffOldItemId, cutoffNewItemId], + batchSize: 10, + maxBatches: 1, + cutoffCreatedAt: '2020-01-01T00:00:00+08:00', + }) + assert.equal(cutoffAssignment.scanned, 1) + assert.equal(cutoffAssignment.assigned, 1) + const cutoffStates = await query<{ id: number; fulfillment_status: string }>( + `SELECT id, fulfillment_status + FROM order_items + WHERE id = ANY($1::bigint[]) + ORDER BY id DESC`, + [[cutoffOldItemId, cutoffNewItemId]], + ) + assert.equal(cutoffStates.rows[0]?.fulfillment_status, 'pending') + assert.equal(cutoffStates.rows[1]?.fulfillment_status, 'assigned') await query( `INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source) @@ -137,7 +186,13 @@ test( [` 集成测试 规范-${suffix} `], ) const normalizedInsert = await query<{ internal_sku: string }>( - `SELECT internal_sku FROM internal_sku_channels WHERE source = 'integration_test'`, + `SELECT internal_sku + FROM internal_sku_channels + WHERE source = 'integration_test' + AND canonicalize_internal_sku(internal_sku) = canonicalize_internal_sku($1) + ORDER BY id DESC + LIMIT 1`, + [triggerSku], ) assert.equal(normalizedInsert.rows[0]?.internal_sku, triggerSku) @@ -251,8 +306,6 @@ test( 'third_party_voucher', ) assert.equal(switchedItem.rows[0]?.item_snapshot_json.isConfigured, false) - generatedVoucherProductId = Number(switchedItem.rows[0]?.canonical_product_id || 0) || null - const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku) const affiliateFailed = await assignChannelByInternalSku({ internalSku: missingAffiliateSku, @@ -381,6 +434,7 @@ test( ) const retriedCarrier = await retryPendingCarrierGroup({ internalSku: retryWorkOrderSku, + retryInactiveCarrier: true, }) assert.equal(retriedCarrier.scanned, 1) assert.equal(retriedCarrier.assigned, 1) @@ -421,6 +475,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, @@ -609,13 +681,21 @@ test( [retryTaskItemId], ) const retryTaskOrderRow = retryTaskOrder.rows[0]! - const insertRetryTask = (taskNo: string, status: string) => + const insertRetryTask = ( + taskNo: string, + status: string, + attemptNo: number, + isCurrent: boolean, + ) => query( `INSERT INTO fulfillment_tasks ( order_id, order_item_id, unit_index, task_no, provider, platform, shop_id, shop_name, platform_order_id, profile_id, executor_key, - task_status, created_at, updated_at - ) VALUES ($1, $2, 1, $3, $4, $5, $6, $7, $8, $9, 'affiliate_dash', $10, NOW(), NOW())`, + task_status, attempt_no, is_current, created_at, updated_at + ) VALUES ( + $1, $2, 1, $3, $4, $5, $6, $7, $8, $9, 'affiliate_dash', + $10, $11, $12, NOW(), NOW() + )`, [ retryTaskOrderRow.order_id, retryTaskItemId, @@ -627,12 +707,14 @@ test( retryTaskOrderRow.platform_order_id, fallbackProfileId, status, + attemptNo, + isCurrent, ], ) - await insertRetryTask(`IT-AFF-CLOSED-${suffix}`, 'closed') - await insertRetryTask(`IT-AFF-ACTIVE-${suffix}`, 'paid') + await insertRetryTask(`IT-AFF-CLOSED-${suffix}`, 'closed', 1, false) + await insertRetryTask(`IT-AFF-ACTIVE-${suffix}`, 'paid', 2, true) await assert.rejects( - () => insertRetryTask(`IT-AFF-DUPLICATE-${suffix}`, 'claimed'), + () => insertRetryTask(`IT-AFF-DUPLICATE-${suffix}`, 'claimed', 3, false), /duplicate key value violates unique constraint/, ) const retryTaskRows = await query<{ task_status: string }>( @@ -643,14 +725,63 @@ test( retryTaskRows.rows.map((row) => row.task_status), ['closed', 'paid'], ) + const currentTasksOnly = await listTasks({ + platformOrderId: retryTaskOrderRow.platform_order_id, + currentOnly: true, + page: 1, + pageSize: 20, + }) + assert.equal(currentTasksOnly.total, 1) + assert.equal(currentTasksOnly.items[0]?.task_status, 'paid') + const allTaskAttempts = await listTasks({ + platformOrderId: retryTaskOrderRow.platform_order_id, + currentOnly: false, + page: 1, + pageSize: 20, + }) + assert.equal(allTaskAttempts.total, 2) - await ensureVoucherRuleForSku(voucherSku) - const voucherChannel = await query<{ channel: string; source: string }>( - `SELECT channel, source FROM internal_sku_channels + assert.equal(await ensureVoucherRuleForSku(missingVoucherSku), false) + const missingVoucherRules = await query<{ count: number }>( + `SELECT count(*)::int AS count FROM internal_sku_channels + WHERE internal_sku = $1 AND channel = 'third_party_voucher'`, + [missingVoucherSku], + ) + assert.equal(missingVoucherRules.rows[0]?.count, 0) + + const voucherProduct = await query<{ id: number }>( + `INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ($1, $2, '卡券', '张', 'active', '{}'::jsonb, NOW(), NOW()) + RETURNING id`, + [`tpv:${voucherSku}`, `卡券展示名-${suffix}`], + ) + createdVoucherProductIds.push(Number(voucherProduct.rows[0]!.id)) + assert.equal(await ensureVoucherRuleForSku(voucherSku), true) + const voucherChannel = await query<{ + channel: string + source: string + canonical_product_id: string | null + priority: number + is_fallback: boolean + config_json: Record + }>( + `SELECT channel, source, canonical_product_id, priority, is_fallback, config_json + FROM internal_sku_channels WHERE internal_sku = $1 ORDER BY channel`, [voucherSku], ) - assert.deepEqual(voucherChannel.rows, [{ channel: 'third_party_voucher', source: 'voucher' }]) + assert.equal(voucherChannel.rows.length, 1) + assert.equal(voucherChannel.rows[0]?.channel, 'third_party_voucher') + assert.equal(voucherChannel.rows[0]?.source, 'voucher') + assert.equal( + Number(voucherChannel.rows[0]?.canonical_product_id), + createdVoucherProductIds[0], + ) + assert.equal(voucherChannel.rows[0]?.priority, 10) + assert.equal(voucherChannel.rows[0]?.is_fallback, false) + assert.equal(voucherChannel.rows[0]?.config_json.voucherSku, voucherSku) const legacyVoucherRules = await query<{ count: number }>( `SELECT count(*)::int AS count FROM sku_channel_rules WHERE match_key = $1`, @@ -692,6 +823,233 @@ test( const deletedRules = await deleteChannelRules([savedRule.id]) assert.equal(deletedRules.deletedCount, 1) assert.deepEqual(deletedRules.deletedIds, [savedRule.id]) + + const archivedVoucherProduct = await query<{ id: number }>( + `INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ( + $1, $2, '卡券', '张', 'archived', + '{"deletedAt":"2026-01-01T00:00:00.000Z","deletedBy":"integration_test"}'::jsonb, + NOW(), NOW() + ) + RETURNING id`, + [`tpv:${reactivatedVoucherSku}`, reactivatedVoucherSku], + ) + const archivedVoucherProductId = Number(archivedVoucherProduct.rows[0]!.id) + createdVoucherProductIds.push(archivedVoucherProductId) + const reactivatedRule = await upsertChannelRule({ + matchKeyType: 'internal_sku', + matchKey: reactivatedVoucherSku, + channel: 'third_party_voucher', + priority: 80, + enabled: true, + note: '集成测试重新激活归档商品', + adminUserId: null, + }) + const reactivated = await query<{ + status: string + attributes_json: Record + canonical_product_id: string | null + priority: number + is_fallback: boolean + }>( + `SELECT product.status, product.attributes_json, + channel.canonical_product_id, channel.priority, channel.is_fallback + FROM internal_sku_channels channel + JOIN product_catalog product ON product.id = channel.canonical_product_id + WHERE channel.id = $1`, + [reactivatedRule.id], + ) + assert.equal(reactivated.rows[0]?.status, 'active') + assert.equal(Number(reactivated.rows[0]?.canonical_product_id), archivedVoucherProductId) + assert.equal(reactivated.rows[0]?.priority, 10) + assert.equal(reactivated.rows[0]?.is_fallback, false) + assert.equal(reactivated.rows[0]?.attributes_json.deletedAt, undefined) + assert.equal(typeof reactivated.rows[0]?.attributes_json.reactivatedAt, 'string') + + const retryPendingItemId = await createPendingOrderItem(retryPendingAffiliateSku) + await query( + `INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, config_json, source + ) VALUES ( + $1, 'affiliate_dash', 10, FALSE, TRUE, + jsonb_build_object('affiliateSku', $1::text), 'integration_test' + )`, + [retryPendingAffiliateSku], + ) + await query( + `UPDATE order_items + SET fulfillment_status = 'assigned', + fulfillment_channel = 'affiliate_dash', + fulfillment_source = 'carrier_evidence', + updated_at = NOW() + WHERE id = $1`, + [retryPendingItemId], + ) + await query( + `INSERT INTO fulfillment_tasks ( + order_id, order_item_id, unit_index, task_no, provider, platform, + shop_id, shop_name, platform_order_id, profile_id, executor_key, + task_status, created_at, updated_at + ) + SELECT oi.order_id, oi.id, 1, $2, o.provider, o.platform, + o.shop_id, o.shop_name, o.platform_order_id, profile.id, 'affiliate_dash', + 'retry_pending', NOW(), NOW() + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN LATERAL ( + SELECT id FROM fulfillment_profiles + WHERE profile_key = 'affiliate_dash' AND executor_key = 'affiliate_dash' + ORDER BY id LIMIT 1 + ) profile ON TRUE + WHERE oi.id = $1`, + [retryPendingItemId, `IT-RETRY-PENDING-${suffix}`], + ) + const blockedRetry = await reconcilePendingCarriers({ + orderItemIds: [retryPendingItemId], + includeAssignedMissingCarrier: true, + limit: 1, + }) + assert.equal(blockedRetry.affiliateTasksCreated, 0) + const retryPendingState = await query<{ + fulfillment_status: string + fulfillment_channel: string | null + fulfillment_pending_reason: string | null + fulfillment_pending_detail: Record + task_count: number + }>( + `SELECT + oi.fulfillment_status, + oi.fulfillment_channel, + oi.fulfillment_pending_reason, + oi.fulfillment_pending_detail, + (SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count + FROM order_items oi + WHERE oi.id = $1`, + [retryPendingItemId], + ) + assert.equal(retryPendingState.rows[0]?.fulfillment_status, 'pending') + assert.equal(retryPendingState.rows[0]?.fulfillment_channel, null) + assert.equal(retryPendingState.rows[0]?.fulfillment_pending_reason, 'no_carrier') + assert.equal( + retryPendingState.rows[0]?.fulfillment_pending_detail.carrierIssue, + 'carrier_task_retry_pending', + ) + assert.equal(retryPendingState.rows[0]?.task_count, 1) + await query( + `UPDATE order_items SET fulfillment_locked = TRUE, updated_at = NOW() WHERE id = $1`, + [retryPendingItemId], + ) + const unlockedPendingGroup = await unlockPendingChannelGroup({ + internalSku: retryPendingAffiliateSku, + }) + assert.equal(unlockedPendingGroup.updated, 1) + const unlockedPendingItem = await query<{ fulfillment_locked: boolean }>( + `SELECT fulfillment_locked FROM order_items WHERE id = $1`, + [retryPendingItemId], + ) + assert.equal(unlockedPendingItem.rows[0]?.fulfillment_locked, false) + + await query( + `UPDATE fulfillment_tasks + SET task_status = 'closed', + result_code = 'USER_APPLY_REFUND', + updated_at = NOW() + WHERE order_item_id = $1 AND executor_key = 'affiliate_dash'`, + [retryPendingItemId], + ) + await query( + `UPDATE order_items + SET fulfillment_status = 'pending', + fulfillment_channel = NULL, + fulfillment_source = 'auto_mapping', + fulfillment_pending_reason = NULL, + fulfillment_pending_detail = NULL, + updated_at = NOW() + WHERE id = $1`, + [retryPendingItemId], + ) + await assert.rejects( + () => + retryPendingCarrierGroup({ + internalSku: retryPendingAffiliateSku, + retryInactiveCarrier: true, + issueCodes: ['user_refund'], + }), + /不允许重试/, + ) + const protectedRetryState = await query<{ + fulfillment_status: string + fulfillment_pending_detail: Record | null + task_count: number + }>( + `SELECT + oi.fulfillment_status, + oi.fulfillment_pending_detail, + (SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count + FROM order_items oi + WHERE oi.id = $1`, + [retryPendingItemId], + ) + assert.equal(protectedRetryState.rows[0]?.fulfillment_status, 'pending') + assert.equal(protectedRetryState.rows[0]?.fulfillment_pending_detail, null) + assert.equal(protectedRetryState.rows[0]?.task_count, 1) + + const voucherJobPrefix = `IT-VOUCHER-LIST-${suffix}` + const voucherJobs = await query<{ id: number }>( + `INSERT INTO voucher_consume_jobs ( + provider, channel, trigger_event, target_key, platform_order_id, + due_at, status, attempt_count, max_attempts, updated_at + ) VALUES + ('kuaishou_industry', 'affiliate_dash', 'order_received', $1, $2, NOW(), 'succeeded', 1, 8, NOW()), + ('kuaishou_industry', 'manual', 'order_received', $3, $4, NOW(), 'failed', 2, 8, NOW()), + ('douyin_mashida', 'manual', 'order_received', $5, $6, NOW(), 'failed', 1, 8, NOW()) + RETURNING id`, + [ + `${voucherJobPrefix}-KS-AFF`, + `${voucherJobPrefix}-KS-AFF-ORDER`, + `${voucherJobPrefix}-KS-MANUAL`, + `${voucherJobPrefix}-KS-MANUAL-ORDER`, + `${voucherJobPrefix}-DY-MANUAL`, + `${voucherJobPrefix}-DY-MANUAL-ORDER`, + ], + ) + const firstVoucherJobPage = await listVoucherConsumeJobs({ + keyword: voucherJobPrefix, + page: 1, + pageSize: 2, + }) + assert.equal(firstVoucherJobPage.total, 3) + assert.equal(firstVoucherJobPage.items.length, 2) + assert.equal(firstVoucherJobPage.page, 1) + assert.equal(firstVoucherJobPage.pageSize, 2) + const secondVoucherJobPage = await listVoucherConsumeJobs({ + keyword: voucherJobPrefix, + page: 2, + pageSize: 2, + }) + assert.equal(secondVoucherJobPage.total, 3) + assert.equal(secondVoucherJobPage.items.length, 1) + const filteredVoucherJobs = await listVoucherConsumeJobs({ + keyword: voucherJobPrefix, + provider: 'kuaishou_industry', + channel: 'manual', + status: 'failed', + page: 1, + pageSize: 20, + }) + assert.equal(filteredVoucherJobs.total, 1) + assert.equal(filteredVoucherJobs.items[0]?.provider, 'kuaishou_industry') + assert.equal(filteredVoucherJobs.items[0]?.channel, 'manual') + assert.equal(filteredVoucherJobs.items[0]?.status, 'failed') + const searchedVoucherJob = await listVoucherConsumeJobs({ + keyword: String(voucherJobs.rows[1]?.id || ''), + status: 'failed', + page: 1, + pageSize: 20, + }) + assert.ok(searchedVoucherJob.items.some((job) => job.id === voucherJobs.rows[1]?.id)) } finally { if (orderItemIds.length > 0) { await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [ @@ -715,6 +1073,9 @@ test( await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds]) } await query(`DELETE FROM cost_exceptions WHERE scope_key LIKE $1`, [`%${suffix}%`]) + await query(`DELETE FROM voucher_consume_jobs WHERE platform_order_id LIKE $1`, [ + `IT-VOUCHER-LIST-${suffix}%`, + ]) await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = ANY($1::text[])`, [ internalSkus, ]) @@ -731,32 +1092,40 @@ test( WHERE provider = '91kaquan' AND shop_id = $1 AND match_key = ANY($2::text[])`, [shopId, internalSkus], ) - if (generatedVoucherProductId) { - await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [ - generatedVoucherProductId, + const generatedProducts = await query<{ id: string }>( + `SELECT id + FROM product_catalog + WHERE canonicalize_internal_sku(substring(canonical_sku from 5)) = ANY($1::text[])`, + [internalSkus], + ) + const generatedProductIds = generatedProducts.rows.map((row) => Number(row.id)) + if (generatedProductIds.length > 0) { + await query( + `DELETE FROM cost_snapshots + WHERE canonical_product_id = ANY($1::bigint[]) + OR cost_version_id IN ( + SELECT id FROM cost_versions WHERE canonical_product_id = ANY($1::bigint[]) + )`, + [generatedProductIds], + ) + await query( + `DELETE FROM sales_daily_facts WHERE canonical_product_id = ANY($1::bigint[])`, + [generatedProductIds], + ) + await query( + `DELETE FROM channel_product_mappings WHERE canonical_product_id = ANY($1::bigint[])`, + [generatedProductIds], + ) + await query(`DELETE FROM cost_versions WHERE canonical_product_id = ANY($1::bigint[])`, [ + generatedProductIds, ]) - await query(`DELETE FROM cost_snapshots WHERE canonical_product_id = $1`, [ - generatedVoucherProductId, + await query( + `DELETE FROM product_channel_capabilities WHERE product_id = ANY($1::bigint[])`, + [generatedProductIds], + ) + await query(`DELETE FROM product_catalog WHERE id = ANY($1::bigint[])`, [ + generatedProductIds, ]) - await query(`DELETE FROM channel_product_mappings WHERE canonical_product_id = $1`, [ - generatedVoucherProductId, - ]) - await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [ - generatedVoucherProductId, - ]) - await query(`DELETE FROM product_catalog WHERE id = $1 AND canonical_sku = $2`, [ - generatedVoucherProductId, - `tpv:${manualSku}`, - ]) - } - if (autoManualProductId) { - await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [ - autoManualProductId, - ]) - await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [ - autoManualProductId, - ]) - await query(`DELETE FROM product_catalog WHERE id = $1`, [autoManualProductId]) } if (retryWorkOrderRuleId) { await query(`DELETE FROM work_product_match_logs WHERE rule_id = $1`, [ diff --git a/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts b/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts new file mode 100644 index 00000000..a0484cb8 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const migration = readFileSync( + new URL('../../db/migrations/141_fulfillment_shadow_model.sql', import.meta.url), + 'utf8', +) +const auditScript = readFileSync( + new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url), + 'utf8', +) + +test('正式履约影子模型包含履约、尝试和异常三张表', () => { + assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments')) + assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_attempts')) + assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_issues')) + assert.ok(migration.includes('uq_fulfillment_attempts_current_unit')) + assert.ok(migration.includes('UNIQUE (fulfillment_id, issue_code)')) +}) + +test('影子模型从现有真源回填并持续双写', () => { + assert.ok(migration.includes('SELECT sync_fulfillment_shadow(state.order_item_id)')) + assert.ok(migration.includes('trg_order_items_fulfillment_shadow')) + assert.ok(migration.includes('trg_fulfillment_tasks_shadow')) + assert.ok(migration.includes('trg_work_orders_fulfillment_shadow')) + assert.ok(migration.includes('assign_fulfillment_event_shadow')) +}) + +test('明确渠道即视为已路由,不用渠道配置缺失覆盖已选渠道', () => { + assert.ok(migration.includes('v_fulfillment_item_state_raw')) + assert.ok(migration.includes("WHEN raw.intended_channel IS NOT NULL THEN 'configured'")) + assert.ok( + migration.includes( + "WHEN raw.route_status = 'missing_channel'\n AND raw.intended_channel IS NOT NULL\n THEN ''", + ), + ) +}) + +test('审计脚本核对影子表、执行尝试、开放异常和事件关联', () => { + assert.ok(auditScript.includes('影子履约缺失')) + assert.ok(auditScript.includes('影子履约状态差异')) + assert.ok(auditScript.includes('影子执行尝试缺失')) + assert.ok(auditScript.includes('影子开放异常缺失')) + assert.ok(auditScript.includes('履约事件未关联影子记录')) +}) diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts new file mode 100644 index 00000000..fda1f1f3 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts @@ -0,0 +1,368 @@ +/** + * 履约状态写入口集成测试(需要已执行完全部迁移的 PostgreSQL)。 + * + * FULFILLMENT_STATE_TEST_DATABASE_URL=postgres://... \ + * npx tsx --test src/services/fulfillment/fulfillment-state-service.integration.test.ts + */ +import assert from 'node:assert/strict' +import test from 'node:test' + +const TEST_DATABASE_URL = String(process.env.FULFILLMENT_STATE_TEST_DATABASE_URL || '').trim() + +test( + '履约状态事件覆盖状态写入口、遗留直写,并保持变更幂等', + { skip: !TEST_DATABASE_URL ? '未设置 FULFILLMENT_STATE_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query } = await import('../../db/client.js') + const { + assignFulfillmentChannelsWithExecutor, + attachFulfillmentConfigurations, + listFulfillmentStateEvents, + runWithFulfillmentStateTransaction, + transitionFulfillmentState, + } = await import('./fulfillment-state-service.js') + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + let orderId = 0 + let orderItemId = 0 + let manualConfigId = 0 + let productId = 0 + let profileId = 0 + + try { + const order = await query<{ id: number }>( + `INSERT INTO orders ( + provider, platform, shop_id, shop_name, platform_order_id, + order_status, pay_status, total_amount, paid_at, created_at, updated_at + ) VALUES ( + '91kaquan', 'kuaishou-91', $1, '状态事件集成测试', $2, + 'paid', 'paid', 1000, NOW(), NOW(), NOW() + ) + RETURNING id`, + [`ff-state-${suffix}`, `FF-STATE-${suffix}`], + ) + orderId = Number(order.rows[0]!.id) + const item = await query<{ id: number }>( + `INSERT INTO order_items ( + order_id, sku_code, sku_name, quantity, spec_json, + revenue_amount_fen, revenue_basis, created_at, updated_at + ) VALUES ($1, $2, $2, 1, '{}'::jsonb, 1000, 'order_total', NOW(), NOW()) + RETURNING id`, + [orderId, `状态事件测试-${suffix}`], + ) + orderItemId = Number(item.rows[0]!.id) + + const initialShadow = await query<{ + route_status: string + carrier_status: string + issue_code: string + }>( + `SELECT route_status, carrier_status, issue_code + FROM fulfillments + WHERE order_item_id = $1`, + [orderItemId], + ) + assert.equal(initialShadow.rows[0]?.route_status, 'unrouted') + assert.equal(initialShadow.rows[0]?.issue_code, 'missing_channel') + + const pendingDetail = { + internalSku: `状态事件测试-${suffix}`, + channel: 'manual', + carrierIssue: 'manual_config_missing', + } + const first = await transitionFulfillmentState({ + orderItemId, + expected: { status: 'pending' }, + patch: { + pendingReason: 'no_carrier', + pendingDetail, + }, + eventType: 'test_carrier_pending', + actor: { type: 'operator', id: 42 }, + reason: 'integration_test', + }) + assert.equal(first?.fulfillment_pending_reason, 'no_carrier') + + let events = await listFulfillmentStateEvents(orderItemId) + assert.equal(events.length, 1) + assert.equal(events[0]?.event_type, 'test_carrier_pending') + assert.equal(events[0]?.actor_type, 'operator') + assert.equal(events[0]?.actor_id, '42') + assert.equal(events[0]?.reason, 'integration_test') + assert.deepEqual(events[0]?.next_pending_detail, pendingDetail) + + await transitionFulfillmentState({ + orderItemId, + expected: { status: 'pending' }, + patch: { + pendingReason: 'no_carrier', + pendingDetail, + }, + eventType: 'test_same_state_replay', + }) + events = await listFulfillmentStateEvents(orderItemId) + assert.equal(events.length, 1, '相同状态重复写入不能制造第二条事件') + + const stale = await transitionFulfillmentState({ + orderItemId, + expected: { status: 'assigned' }, + patch: { + channel: 'manual', + status: 'assigned', + }, + eventType: 'test_stale_transition', + }) + assert.equal(stale, null, '期望状态不一致时必须拒绝写入') + assert.equal((await listFulfillmentStateEvents(orderItemId)).length, 1) + + await query( + `UPDATE order_items + SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE), + updated_at = NOW() + WHERE id = $1`, + [orderItemId], + ) + events = await listFulfillmentStateEvents(orderItemId) + assert.equal(events.length, 2, '遗留直写也必须由触发器留痕') + assert.equal(events[0]?.event_type, 'fulfillment_state_changed') + assert.equal(events[0]?.actor_type, 'system') + + await query( + `UPDATE order_items + SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE), + updated_at = NOW() + WHERE id = $1`, + [orderItemId], + ) + events = await listFulfillmentStateEvents(orderItemId) + assert.equal(events.length, 2, '无字段变化的更新不能再记事件') + + await runWithFulfillmentStateTransaction(async (client) => { + const assigned = await assignFulfillmentChannelsWithExecutor(client, { + assignments: [ + { + orderItemId, + channel: 'third_party_voucher', + source: 'auto_mapping', + }, + ], + eventType: 'test_channel_assigned', + reason: 'integration_test', + }) + assert.equal(assigned.length, 1) + assert.equal(assigned[0]?.fulfillment_status, 'assigned') + assert.equal(assigned[0]?.fulfillment_channel, 'third_party_voucher') + }) + events = await listFulfillmentStateEvents(orderItemId) + assert.equal(events[0]?.event_type, 'test_channel_assigned') + assert.equal(events[0]?.previous_status, 'pending') + assert.equal(events[0]?.next_status, 'assigned') + assert.equal(events[0]?.previous_channel, null) + assert.equal(events[0]?.next_channel, 'third_party_voucher') + + const assignedShadow = await query<{ + route_status: string + channel: string + carrier_status: string + issue_code: string + event_count: number + }>( + `SELECT + f.route_status, + f.channel, + f.carrier_status, + f.issue_code, + ( + SELECT count(*)::int + FROM fulfillment_state_events event + WHERE event.fulfillment_id = f.id + ) AS event_count + FROM fulfillments f + WHERE f.order_item_id = $1`, + [orderItemId], + ) + assert.equal(assignedShadow.rows[0]?.route_status, 'configured') + assert.equal(assignedShadow.rows[0]?.channel, 'third_party_voucher') + assert.equal(assignedShadow.rows[0]?.carrier_status, 'not_required') + assert.equal(assignedShadow.rows[0]?.issue_code, '') + assert.equal(Number(assignedShadow.rows[0]?.event_count), 3) + + const product = await query<{ id: number }>( + `INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ($1, $2, '测试', '件', 'active', '{}'::jsonb, NOW(), NOW()) + RETURNING id`, + [`test:ff-state-${suffix}`, `状态事件商品-${suffix}`], + ) + productId = Number(product.rows[0]!.id) + const config = await query<{ id: number }>( + `INSERT INTO manual_fulfillment_configs ( + sku_key, canonical_product_id, platform_key, credit_quantity, + cost_mode, enabled, note, created_at, updated_at + ) VALUES ($1, $2, 'nanhang', 1, 'fixed', TRUE, 'integration_test', NOW(), NOW()) + RETURNING id`, + [`状态事件测试-${suffix}`, productId], + ) + manualConfigId = Number(config.rows[0]!.id) + const staleConfig = await attachFulfillmentConfigurations({ + mappings: [ + { + orderItemId, + configId: manualConfigId, + canonicalProductId: productId, + expectedCanonicalProductId: manualConfigId, + }, + ], + onlyWhenCanonicalProductNull: true, + }) + assert.equal(staleConfig.length, 0, '原商品值已变化时不能覆盖并发结果') + + const configured = await attachFulfillmentConfigurations({ + mappings: [ + { + orderItemId, + configId: manualConfigId, + canonicalProductId: productId, + }, + ], + expected: { status: 'assigned', channel: 'third_party_voucher' }, + onlyWhenConfigNull: true, + onlyWhenCanonicalProductNull: true, + }) + assert.equal(configured.length, 1) + assert.equal(Number(configured[0]?.fulfillment_config_id), manualConfigId) + assert.equal(Number(configured[0]?.canonical_product_id), productId) + + const profile = await query<{ id: number }>( + `INSERT INTO fulfillment_profiles ( + profile_key, name, executor_key, requires_claim, auto_dispatch, + inventory_strategy, config_json, created_at, updated_at + ) VALUES ($1, '状态事件任务', 'affiliate_dash', TRUE, FALSE, + 'external_platform', '{}'::jsonb, NOW(), NOW()) + RETURNING id`, + [`ff-state-profile-${suffix}`], + ) + profileId = Number(profile.rows[0]!.id) + await query( + `INSERT INTO fulfillment_tasks ( + order_id, order_item_id, unit_index, task_no, provider, platform, + shop_id, shop_name, platform_order_id, profile_id, executor_key, + task_status, attempt_no, is_current, created_at, updated_at + ) + SELECT + item.order_id, item.id, 1, $2, source.provider, source.platform, + source.shop_id, source.shop_name, source.platform_order_id, $3, 'affiliate_dash', + 'paid', 1, TRUE, NOW(), NOW() + FROM order_items item + JOIN orders source ON source.id = item.order_id + WHERE item.id = $1`, + [orderItemId, `FF-SHADOW-TASK-${suffix}`, profileId], + ) + const attempts = await query<{ + attempt_type: string + unit_index: number + attempt_no: number + source_id: number + is_current: boolean + }>( + `SELECT attempt_type, unit_index, attempt_no, source_id, is_current + FROM fulfillment_attempts + WHERE fulfillment_id = ( + SELECT id FROM fulfillments WHERE order_item_id = $1 + ) + ORDER BY id`, + [orderItemId], + ) + assert.equal(attempts.rows.length, 1) + assert.equal(attempts.rows[0]?.attempt_type, 'task') + assert.equal(attempts.rows[0]?.unit_index, 1) + assert.equal(attempts.rows[0]?.attempt_no, 1) + assert.equal(attempts.rows[0]?.is_current, true) + + const shadowDiff = await query<{ + fulfillment_state_diffs: number + missing_attempts: number + missing_open_issues: number + unlinked_events: number + }>( + `SELECT + COUNT(*) FILTER ( + WHERE ROW( + fulfillment.route_status, + fulfillment.channel, + fulfillment.carrier_status, + fulfillment.current_attempt_type, + fulfillment.current_attempt_source_id, + fulfillment.issue_code, + fulfillment.issue_category + ) IS DISTINCT FROM ROW( + CASE WHEN state.route_status = 'configured' THEN 'configured' ELSE 'unrouted' END, + state.intended_channel, + state.carrier_status, + state.current_attempt_type, + NULLIF(state.current_attempt_id, 0), + state.issue_code, + state.issue_category + ) + )::int AS fulfillment_state_diffs, + ( + SELECT COUNT(*)::int + FROM fulfillment_tasks task + WHERE task.order_item_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM fulfillment_attempts attempt + WHERE attempt.attempt_type = 'task' AND attempt.source_id = task.id + ) + ) AS missing_attempts, + COUNT(*) FILTER ( + WHERE state.issue_code <> '' + AND NOT EXISTS ( + SELECT 1 FROM fulfillment_issues issue + WHERE issue.fulfillment_id = fulfillment.id + AND issue.issue_code = state.issue_code + AND issue.status <> 'resolved' + ) + )::int AS missing_open_issues, + ( + SELECT COUNT(*)::int + FROM fulfillment_state_events event + WHERE event.order_item_id = $1 + AND event.fulfillment_id IS NULL + ) AS unlinked_events + FROM v_fulfillment_item_state state + JOIN fulfillments fulfillment ON fulfillment.order_item_id = state.order_item_id + WHERE state.order_item_id = $1`, + [orderItemId], + ) + assert.equal(Number(shadowDiff.rows[0]?.fulfillment_state_diffs), 0) + assert.equal(Number(shadowDiff.rows[0]?.missing_attempts), 0) + assert.equal(Number(shadowDiff.rows[0]?.missing_open_issues), 0) + assert.equal(Number(shadowDiff.rows[0]?.unlinked_events), 0) + + await assert.rejects( + query(`UPDATE fulfillment_state_events SET reason = 'tampered' WHERE order_item_id = $1`, [ + orderItemId, + ]), + /append-only/, + ) + } finally { + if (orderItemId > 0) { + await query(`DELETE FROM order_items WHERE id = $1`, [orderItemId]) + } + if (manualConfigId > 0) { + await query(`DELETE FROM manual_fulfillment_configs WHERE id = $1`, [manualConfigId]) + } + if (productId > 0) { + await query(`DELETE FROM product_catalog WHERE id = $1`, [productId]) + } + if (profileId > 0) { + await query(`DELETE FROM fulfillment_profiles WHERE id = $1`, [profileId]) + } + if (orderId > 0) { + await query(`DELETE FROM orders WHERE id = $1`, [orderId]) + } + await closeDb() + } + }, +) diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts b/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts new file mode 100644 index 00000000..fa808a78 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +function read(relativePath: string) { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8') +} + +const migration = read('../../db/migrations/140_fulfillment_state_events.sql') +const stateService = read('./fulfillment-state-service.ts') +const channelAssign = read('./channel-assign-service.ts') +const carrierReconciliation = read('./carrier-reconciliation-service.ts') +const affiliateFallback = read('./affiliate-fallback-service.ts') +const carrierCreation = read('./carrier-creation-service.ts') +const manualFulfillment = read('../finance/manual-fulfillment-service.ts') +const manualConfig = read('../cost-center/manual-fulfillment-config-service.ts') +const productNormalize = read('../cost-center/product-normalize-service.ts') +const productCatalog = read('../cost-center/product-catalog-service.ts') +const affiliateProduct = read('../cost-center/affiliate-product-service.ts') +const workOrderProduct = read('../cost-center/work-order-product-service.ts') +const templateBootstrap = read('../cost-center/template-bootstrap-service.ts') +const schedulerJobs = read('../scheduler/finance-pipeline-jobs.ts') +const taskRepo = read('../../repositories/task-repo.ts') + +test('履约状态事件表记录前后状态并禁止应用修改', () => { + assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_state_events')) + assert.ok(migration.includes('previous_pending_detail JSONB')) + assert.ok(migration.includes('next_pending_detail JSONB')) + assert.ok(migration.includes('record_fulfillment_state_event')) + assert.ok(migration.includes("current_setting('app.fulfillment_actor_type', TRUE)")) + assert.ok(migration.includes('BEFORE UPDATE ON fulfillment_state_events')) + assert.ok(migration.includes('fulfillment_state_events is append-only')) +}) + +test('状态写入口串行锁订单项,并在写入前校验期望状态', () => { + assert.ok(stateService.includes('SELECT * FROM order_items WHERE id = $1 FOR UPDATE')) + assert.ok(stateService.includes('expected.status')) + assert.ok(stateService.includes('expected.channel')) + assert.ok(stateService.includes('sourceNotIn')) + assert.ok(stateService.includes("set_config('app.fulfillment_event_type'")) + assert.ok(stateService.includes('itemSnapshotPatch')) +}) + +test('高风险履约流程统一通过状态服务写入', () => { + for (const source of [channelAssign, carrierReconciliation, affiliateFallback]) { + assert.ok(source.includes('fulfillment-state-service')) + assert.ok(!source.includes('SET fulfillment_status =')) + assert.ok(!source.includes('fulfillment_status = $')) + } +}) + +test('履约配置和标准商品关联统一通过状态服务的映射接口写入', () => { + for (const source of [ + carrierCreation, + manualFulfillment, + manualConfig, + productNormalize, + productCatalog, + affiliateProduct, + workOrderProduct, + templateBootstrap, + schedulerJobs, + ]) { + assert.ok(source.includes('fulfillment-state-service')) + assert.doesNotMatch(source, /UPDATE\s+order_items\b[\s\S]{0,300}?\bfulfillment_config_id\s*=/i) + assert.doesNotMatch(source, /UPDATE\s+order_items\b[\s\S]{0,300}?\bcanonical_product_id\s*=/i) + } + assert.ok(stateService.includes('attachFulfillmentConfigurationsWithExecutor')) + assert.ok(stateService.includes('onlyWhenCanonicalProductNull')) +}) + +test('任务创建先锁订单项并复用已有活跃尝试', () => { + assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE')) + assert.ok(taskRepo.includes('task_status NOT IN (')) + assert.ok(taskRepo.includes('ORDER BY is_current DESC, id DESC')) +}) diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-service.ts b/apps/backend/src/services/fulfillment/fulfillment-state-service.ts new file mode 100644 index 00000000..2e04a758 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.ts @@ -0,0 +1,587 @@ +import type { PoolClient, QueryResult, QueryResultRow } from 'pg' + +import { query, withTransaction } from '../../db/client.js' +import type { JsonObject } from '../../types/json.js' +import type { OrderItemRow } from '../../types/repository/rows.js' +import { + isFulfillmentChannel, + type FulfillmentChannel, + type FulfillmentSource, + type FulfillmentStatus, +} from './channel-types.js' + +export type FulfillmentStateExecutor = { + query: ( + text: string, + params?: unknown[], + ) => Promise> +} + +export type FulfillmentStateActor = { + type?: 'system' | 'operator' | 'worker' | 'finance' | 'api' + id?: number | string | null +} + +export type FulfillmentStatePatch = { + status?: FulfillmentStatus + channel?: FulfillmentChannel | null + source?: FulfillmentSource | string + locked?: boolean + pendingReason?: string | null + pendingDetail?: JsonObject | null + pendingDetailUnlessCarrierIssue?: JsonObject + reason?: string | null + decidedBy?: number | null + decidedAt?: string | null + originalChannelIfNull?: FulfillmentChannel + switchReason?: string | null + switchedAt?: string | null + configId?: number | null + configIdIfNull?: number + canonicalProductId?: number | null + canonicalProductIdIfNull?: number + itemSnapshotJson?: JsonObject + itemSnapshotPatch?: JsonObject +} + +export type FulfillmentStateExpected = { + status?: FulfillmentStatus | null + channel?: FulfillmentChannel | null + source?: string | null + sourceNotIn?: string[] + locked?: boolean | null +} + +export type FulfillmentStateTransition = { + orderItemId: number + patch: FulfillmentStatePatch + expected?: FulfillmentStateExpected + eventType: string + actor?: FulfillmentStateActor + reason?: string +} + +export type FulfillmentStateEventRow = { + [column: string]: unknown + id: number + order_item_id: number + order_id: number + event_type: string + actor_type: string + actor_id: string + reason: string + previous_status: string | null + next_status: string | null + previous_channel: string | null + next_channel: string | null + previous_source: string | null + next_source: string | null + previous_locked: boolean | null + next_locked: boolean | null + previous_pending_reason: string | null + next_pending_reason: string | null + previous_pending_detail: JsonObject | null + next_pending_detail: JsonObject | null + payload_json: JsonObject + created_at: string +} + +export type FulfillmentConfigurationMapping = { + orderItemId: number + configId?: number | null + canonicalProductId?: number | null + expectedCanonicalProductId?: number | null +} + +type StateColumn = { + column: string + value: unknown + cast?: 'jsonb' + mergeSnapshot?: boolean + coalesceCurrent?: boolean + preserveCarrierIssue?: boolean +} + +function normalizeId(value: unknown): number { + const id = Number(value) + if (!Number.isInteger(id) || id <= 0) { + throw new TypeError('orderItemId must be a positive integer') + } + return id +} + +function buildPatchColumns(patch: FulfillmentStatePatch): StateColumn[] { + if (!patch || typeof patch !== 'object') { + throw new TypeError('fulfillment state patch is required') + } + const columns: StateColumn[] = [] + if (patch.status !== undefined) { + if (!['pending', 'assigned', 'archived'].includes(patch.status)) { + throw new TypeError(`invalid fulfillment status: ${patch.status}`) + } + columns.push({ column: 'fulfillment_status', value: patch.status }) + } + if (patch.channel !== undefined) { + if (patch.channel !== null && !isFulfillmentChannel(patch.channel)) { + throw new TypeError(`invalid fulfillment channel: ${patch.channel}`) + } + columns.push({ column: 'fulfillment_channel', value: patch.channel }) + } + if (patch.source !== undefined) { + columns.push({ column: 'fulfillment_source', value: patch.source }) + } + if (patch.locked !== undefined) { + columns.push({ column: 'fulfillment_locked', value: Boolean(patch.locked) }) + } + if (patch.pendingReason !== undefined) { + columns.push({ column: 'fulfillment_pending_reason', value: patch.pendingReason || null }) + } + if (patch.pendingDetail !== undefined) { + columns.push({ + column: 'fulfillment_pending_detail', + value: patch.pendingDetail, + cast: 'jsonb', + }) + } + if (patch.pendingDetailUnlessCarrierIssue !== undefined) { + columns.push({ + column: 'fulfillment_pending_detail', + value: patch.pendingDetailUnlessCarrierIssue, + cast: 'jsonb', + preserveCarrierIssue: true, + }) + } + if (patch.reason !== undefined) { + columns.push({ column: 'fulfillment_reason', value: patch.reason || null }) + } + if (patch.decidedBy !== undefined) { + columns.push({ column: 'fulfillment_decided_by', value: patch.decidedBy }) + } + if (patch.decidedAt !== undefined) { + columns.push({ column: 'fulfillment_decided_at', value: patch.decidedAt }) + } + if (patch.originalChannelIfNull !== undefined) { + if (!isFulfillmentChannel(patch.originalChannelIfNull)) { + throw new TypeError(`invalid original fulfillment channel: ${patch.originalChannelIfNull}`) + } + columns.push({ + column: 'original_channel', + value: patch.originalChannelIfNull, + coalesceCurrent: true, + }) + } + if (patch.switchReason !== undefined) { + columns.push({ column: 'channel_switch_reason', value: patch.switchReason || null }) + } + if (patch.switchedAt !== undefined) { + columns.push({ column: 'channel_switched_at', value: patch.switchedAt }) + } + if (patch.configId !== undefined) { + columns.push({ column: 'fulfillment_config_id', value: patch.configId }) + } + if (patch.configIdIfNull !== undefined) { + columns.push({ + column: 'fulfillment_config_id', + value: patch.configIdIfNull, + coalesceCurrent: true, + }) + } + if (patch.canonicalProductId !== undefined) { + columns.push({ column: 'canonical_product_id', value: patch.canonicalProductId }) + } + if (patch.canonicalProductIdIfNull !== undefined) { + columns.push({ + column: 'canonical_product_id', + value: patch.canonicalProductIdIfNull, + coalesceCurrent: true, + }) + } + if (patch.itemSnapshotJson !== undefined) { + columns.push({ + column: 'item_snapshot_json', + value: patch.itemSnapshotJson, + cast: 'jsonb', + }) + } + if (patch.itemSnapshotPatch !== undefined) { + columns.push({ + column: 'item_snapshot_json', + value: patch.itemSnapshotPatch, + cast: 'jsonb', + mergeSnapshot: true, + }) + } + if (columns.length === 0) { + throw new TypeError('fulfillment state patch must change at least one column') + } + return columns +} + +async function setEventContext( + executor: FulfillmentStateExecutor, + transition: Pick & { + actor?: FulfillmentStateActor + reason?: string + }, +) { + const eventType = String(transition.eventType || '').trim() + if (!eventType) throw new TypeError('fulfillment state eventType is required') + await executor.query( + `SELECT + set_config('app.fulfillment_event_type', $1, TRUE), + set_config('app.fulfillment_actor_type', $2, TRUE), + set_config('app.fulfillment_actor_id', $3, TRUE), + set_config('app.fulfillment_reason', $4, TRUE)`, + [ + eventType, + transition.actor?.type || 'system', + String(transition.actor?.id ?? ''), + String(transition.reason || '').slice(0, 500), + ], + ) +} + +async function clearEventContext(executor: FulfillmentStateExecutor) { + await executor + .query( + `SELECT + set_config('app.fulfillment_event_type', '', TRUE), + set_config('app.fulfillment_actor_type', '', TRUE), + set_config('app.fulfillment_actor_id', '', TRUE), + set_config('app.fulfillment_reason', '', TRUE)`, + ) + .catch(() => undefined) +} + +export async function transitionFulfillmentStateWithExecutor( + executor: FulfillmentStateExecutor, + input: FulfillmentStateTransition, +): Promise { + const orderItemId = normalizeId(input.orderItemId) + const columns = buildPatchColumns(input.patch) + await setEventContext(executor, input) + try { + const lockResult = await executor.query( + `SELECT * FROM order_items WHERE id = $1 FOR UPDATE`, + [orderItemId], + ) + const current = lockResult.rows[0] + if (!current) return null + + if ( + input.expected?.status !== undefined && + current.fulfillment_status !== input.expected.status + ) { + return null + } + if ( + input.expected?.channel !== undefined && + (current.fulfillment_channel ?? null) !== input.expected.channel + ) { + return null + } + if ( + input.expected?.source !== undefined && + (current.fulfillment_source || '') !== (input.expected.source || '') + ) { + return null + } + if ( + input.expected?.sourceNotIn && + input.expected.sourceNotIn.includes(String(current.fulfillment_source || '')) + ) { + return null + } + if ( + input.expected?.locked !== undefined && + Boolean(current.fulfillment_locked) !== Boolean(input.expected.locked) + ) { + return null + } + + const params: unknown[] = [] + const assignments = columns.map((entry) => { + params.push(entry.value) + let expression = `$${params.length}` + if (entry.cast) expression += `::${entry.cast}` + if (entry.mergeSnapshot) { + expression = `COALESCE(item_snapshot_json, '{}'::jsonb) || ${expression}` + } else if (entry.preserveCarrierIssue) { + expression = + `CASE WHEN COALESCE(fulfillment_pending_detail->>'carrierIssue', '') ` + + `NOT IN ('', 'carrier_not_attempted') ` + + `THEN fulfillment_pending_detail ELSE ${expression} END` + } else if (entry.coalesceCurrent) { + expression = `COALESCE(${entry.column}, ${expression})` + } + return `${entry.column} = ${expression}` + }) + params.push(new Date().toISOString()) + assignments.push(`updated_at = $${params.length}::timestamptz`) + params.push(orderItemId) + + const updated = await executor.query( + `UPDATE order_items + SET ${assignments.join(', ')} + WHERE id = $${params.length} + RETURNING *`, + params, + ) + return updated.rows[0] || null + } finally { + await clearEventContext(executor) + } +} + +export async function transitionFulfillmentState( + input: FulfillmentStateTransition, +): Promise { + return withTransaction((client) => + transitionFulfillmentStateWithExecutor(client as FulfillmentStateExecutor, input), + ) +} + +export async function transitionFulfillmentStateBatchWithExecutor( + executor: FulfillmentStateExecutor, + input: { + orderItemIds: number[] + patch: FulfillmentStatePatch + expected?: FulfillmentStateExpected + eventType: string + actor?: FulfillmentStateActor + reason?: string + }, +): Promise { + const ids = [ + ...new Set( + (input.orderItemIds || []).map(Number).filter((id) => Number.isInteger(id) && id > 0), + ), + ] + const results: OrderItemRow[] = [] + for (const orderItemId of ids) { + const updated = await transitionFulfillmentStateWithExecutor(executor, { + orderItemId, + patch: input.patch, + eventType: input.eventType, + ...(input.expected ? { expected: input.expected } : {}), + ...(input.actor ? { actor: input.actor } : {}), + ...(input.reason ? { reason: input.reason } : {}), + }) + if (updated) results.push(updated) + } + return results +} + +export async function transitionFulfillmentStateBatch( + input: Parameters[1], +): Promise { + return withTransaction((client) => + transitionFulfillmentStateBatchWithExecutor(client as FulfillmentStateExecutor, input), + ) +} + +export async function assignFulfillmentChannelsWithExecutor( + executor: FulfillmentStateExecutor, + input: { + assignments: Array<{ + orderItemId: number + channel: FulfillmentChannel + source: FulfillmentSource | string + }> + includeLocked?: boolean + eventType?: string + actor?: FulfillmentStateActor + reason?: string + }, +): Promise { + const assignments = (input.assignments || []) + .map((row) => ({ + orderItemId: normalizeId(row.orderItemId), + channel: row.channel, + source: row.source, + })) + .filter((row) => isFulfillmentChannel(row.channel)) + if (assignments.length === 0) return [] + + await setEventContext(executor, { + eventType: input.eventType || 'fulfillment_channel_assigned', + ...(input.actor ? { actor: input.actor } : {}), + ...(input.reason ? { reason: input.reason } : {}), + }) + try { + const result = await executor.query( + `UPDATE order_items item + SET fulfillment_channel = input.channel, + fulfillment_status = 'assigned', + fulfillment_source = input.source, + fulfillment_decided_at = NOW(), + fulfillment_pending_reason = NULL, + fulfillment_pending_detail = NULL, + updated_at = NOW() + FROM unnest($1::bigint[], $2::text[], $3::text[]) + AS input(order_item_id, channel, source) + WHERE item.id = input.order_item_id + AND item.fulfillment_status = 'pending' + AND (item.fulfillment_locked = FALSE OR $4::boolean) + RETURNING item.*`, + [ + assignments.map((row) => row.orderItemId), + assignments.map((row) => row.channel), + assignments.map((row) => row.source), + input.includeLocked === true, + ], + ) + return result.rows + } finally { + await clearEventContext(executor) + } +} + +export async function attachFulfillmentConfigurationsWithExecutor( + executor: FulfillmentStateExecutor, + input: { + mappings: FulfillmentConfigurationMapping[] + expected?: Pick + onlyWhenConfigNull?: boolean + onlyWhenCanonicalProductNull?: boolean + onlyWhenCanonicalProductDifferent?: boolean + }, +): Promise { + const mappings = (input.mappings || []).map((row) => { + const setConfig = row.configId !== undefined + const setProduct = row.canonicalProductId !== undefined + const requireExpectedProduct = row.expectedCanonicalProductId !== undefined + if (!setConfig && !setProduct) { + throw new TypeError( + 'fulfillment configuration mapping must set configId or canonicalProductId', + ) + } + return { + orderItemId: normalizeId(row.orderItemId), + setConfig, + configId: + row.configId === null || row.configId === undefined ? null : normalizeId(row.configId), + setProduct, + canonicalProductId: + row.canonicalProductId === null || row.canonicalProductId === undefined + ? null + : normalizeId(row.canonicalProductId), + requireExpectedProduct, + expectedCanonicalProductId: + row.expectedCanonicalProductId === null || row.expectedCanonicalProductId === undefined + ? null + : normalizeId(row.expectedCanonicalProductId), + } + }) + if (mappings.length === 0) return [] + if (new Set(mappings.map((row) => row.orderItemId)).size !== mappings.length) { + throw new TypeError('fulfillment configuration mappings must contain unique orderItemId') + } + + const params: unknown[] = [ + mappings.map((row) => row.orderItemId), + mappings.map((row) => row.setConfig), + mappings.map((row) => row.configId), + mappings.map((row) => row.setProduct), + mappings.map((row) => row.canonicalProductId), + mappings.map((row) => row.requireExpectedProduct), + mappings.map((row) => row.expectedCanonicalProductId), + ] + const where = ['item.id = input.order_item_id'] + if (input.expected?.status !== undefined) { + params.push(input.expected.status) + where.push(`item.fulfillment_status IS NOT DISTINCT FROM $${params.length}`) + } + if (input.expected?.channel !== undefined) { + params.push(input.expected.channel) + where.push(`item.fulfillment_channel IS NOT DISTINCT FROM $${params.length}`) + } + if (input.onlyWhenConfigNull) { + where.push('item.fulfillment_config_id IS NULL') + } + if (input.onlyWhenCanonicalProductDifferent) { + where.push( + '(NOT input.set_product OR item.canonical_product_id IS DISTINCT FROM input.canonical_product_id)', + ) + } + where.push( + '(NOT input.require_expected_product ' + + 'OR item.canonical_product_id IS NOT DISTINCT FROM input.expected_canonical_product_id)', + ) + const canonicalProductIfNull = input.onlyWhenCanonicalProductNull === true + params.push(canonicalProductIfNull) + const canonicalProductIfNullParam = params.length + params.push(new Date().toISOString()) + const updatedAtParam = params.length + + const result = await executor.query( + `UPDATE order_items item + SET fulfillment_config_id = CASE + WHEN input.set_config THEN input.config_id + ELSE item.fulfillment_config_id + END, + canonical_product_id = CASE + WHEN NOT input.set_product THEN item.canonical_product_id + WHEN $${canonicalProductIfNullParam}::boolean + THEN COALESCE(item.canonical_product_id, input.canonical_product_id) + ELSE input.canonical_product_id + END, + updated_at = $${updatedAtParam}::timestamptz + FROM unnest( + $1::bigint[], + $2::boolean[], + $3::bigint[], + $4::boolean[], + $5::bigint[], + $6::boolean[], + $7::bigint[] + ) AS input( + order_item_id, + set_config, + config_id, + set_product, + canonical_product_id, + require_expected_product, + expected_canonical_product_id + ) + WHERE ${where.join(' AND ')} + RETURNING item.*`, + params, + ) + return result.rows +} + +export async function attachFulfillmentConfigurations(input: { + mappings: FulfillmentConfigurationMapping[] + expected?: Pick + onlyWhenConfigNull?: boolean + onlyWhenCanonicalProductNull?: boolean + onlyWhenCanonicalProductDifferent?: boolean +}): Promise { + return withTransaction((client) => + attachFulfillmentConfigurationsWithExecutor(client as FulfillmentStateExecutor, input), + ) +} + +export async function listFulfillmentStateEvents( + orderItemId: number | string, + options: { limit?: number } = {}, +): Promise { + const id = normalizeId(orderItemId) + const limit = Math.min(500, Math.max(1, Math.trunc(Number(options.limit) || 100))) + const result = await query( + `SELECT * + FROM fulfillment_state_events + WHERE order_item_id = $1 + ORDER BY id DESC + LIMIT $2`, + [id, limit], + ) + return result.rows +} + +export async function runWithFulfillmentStateTransaction( + fn: (client: PoolClient) => Promise, +): Promise { + return withTransaction(fn) +} 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..48cae4bc --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-state-view.test.ts @@ -0,0 +1,73 @@ +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', +) +const automationCutoffMigration = readFileSync( + new URL('../../db/migrations/139_fulfillment_automation_cutoff.sql', import.meta.url), + 'utf8', +) +const automationConfig = readFileSync( + new URL('./fulfillment-automation-config-service.ts', import.meta.url), + 'utf8', +) +const schedulerJobs = readFileSync( + new URL('../scheduler/finance-pipeline-jobs.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')) +}) + +test('历史履约数据不进入自动任务,只处理自动化生效时间之后的新订单', () => { + assert.ok(automationCutoffMigration.includes('fulfillment_automation_cutoff')) + assert.ok(automationConfig.includes('getFulfillmentAutomationCutoff')) + assert.ok(automationConfig.includes('fulfillmentCreatedAfterSql')) + assert.ok(schedulerJobs.includes('cutoffCreatedAt')) + assert.ok(schedulerJobs.includes('getFulfillmentAutomationCutoff')) + assert.ok(schedulerJobs.includes('runAffiliateFallback')) +}) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts b/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts new file mode 100644 index 00000000..30da99a5 --- /dev/null +++ b/apps/backend/src/services/fulfillment/voucher-consume-job-list.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./voucher-consume-queue-service.ts', import.meta.url), 'utf8') + +test('核销任务列表支持关键词、平台、渠道和状态组合筛选', () => { + assert.ok(source.includes('keyword?: string')) + assert.ok(source.includes('provider?: string')) + assert.ok(source.includes('channel?: string')) + assert.ok(source.includes('status?: string')) + assert.ok(source.includes('provider = $')) + assert.ok(source.includes('channel = $')) + assert.ok(source.includes('status = $')) + assert.ok(source.includes('platform_order_id ILIKE')) + assert.ok(source.includes('target_key ILIKE')) +}) + +test('核销任务列表使用服务端总数和分页参数', () => { + assert.ok(source.includes('SELECT COUNT(*) AS total FROM voucher_consume_jobs')) + assert.ok(source.includes('LIMIT $')) + assert.ok(source.includes('OFFSET $')) + assert.ok(source.includes('ORDER BY updated_at DESC, id DESC')) +}) diff --git a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts index ea852d31..d9f75f65 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts @@ -22,6 +22,8 @@ import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-indus import { verifyCertificate } from '../redeem/certificate-service.js' import { logIntegration, logWarn } from '../../utils/logger.js' import { + VOUCHER_CONSUME_CHANNELS, + VOUCHER_CONSUME_PROVIDERS, getVoucherConsumePolicy, resolveVoucherConsumePolicy, type VoucherConsumeChannel, @@ -99,6 +101,13 @@ type VoucherOrderItemTargetRow = { const RETRY_DELAYS_SECONDS = [10, 30, 60, 300, 600, 900, 1800] const PROCESSING_LOCK_SECONDS = 120 +const VOUCHER_CONSUME_JOB_STATUSES = [ + 'pending', + 'processing', + 'succeeded', + 'failed', + 'cancelled', +] as const export async function scheduleVoucherConsumeForTask( task: TaskRow, @@ -443,6 +452,9 @@ export async function runVoucherConsumeDispatchJob( export async function listVoucherConsumeJobs( input: { + keyword?: string + provider?: string + channel?: string status?: string page?: number pageSize?: number @@ -450,10 +462,43 @@ export async function listVoucherConsumeJobs( ) { const page = clampInteger(input.page, 1, 1, Number.MAX_SAFE_INTEGER) const pageSize = clampInteger(input.pageSize, 20, 1, 100) - const status = String(input.status || '').trim() + const keyword = String(input.keyword || '') + .trim() + .slice(0, 100) + const providerInput = String(input.provider || '').trim() + const provider = (VOUCHER_CONSUME_PROVIDERS as readonly string[]).includes(providerInput) + ? providerInput + : '' + const channel = (VOUCHER_CONSUME_CHANNELS as readonly string[]).includes( + String(input.channel || '').trim(), + ) + ? String(input.channel).trim() + : '' + const status = (VOUCHER_CONSUME_JOB_STATUSES as readonly string[]).includes( + String(input.status || '').trim(), + ) + ? String(input.status).trim() + : '' const params: unknown[] = [] const where: string[] = [] + if (keyword) { + params.push(`%${keyword}%`) + where.push( + `(CAST(id AS TEXT) ILIKE $${params.length} + OR platform_order_id ILIKE $${params.length} + OR target_key ILIKE $${params.length} + OR CAST(order_id AS TEXT) ILIKE $${params.length})`, + ) + } + if (provider) { + params.push(provider) + where.push(`provider = $${params.length}`) + } + if (channel) { + params.push(channel) + where.push(`channel = $${params.length}`) + } if (status) { params.push(status) where.push(`status = $${params.length}`) diff --git a/apps/backend/src/services/order/delivery-task-service.test.ts b/apps/backend/src/services/order/delivery-task-service.test.ts index 9f38cb80..0a924270 100644 --- a/apps/backend/src/services/order/delivery-task-service.test.ts +++ b/apps/backend/src/services/order/delivery-task-service.test.ts @@ -66,7 +66,7 @@ function stubCreatedTask(id: number, input: CreatedTaskInput): TaskRow { unit_index: Number(input.unitIndex || 1), platform_order_id: '', profile_id: 0, - task_no: 'DT-test', + task_no: String(input.taskNo || 'DT-test'), executor_key: String(input.executorKey || ''), task_status: String(input.taskStatus || 'pending'), delivery_status: 'pending', @@ -428,6 +428,51 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy assert.equal(eventCount, 0) }) +test('createTask 幂等返回已有活跃任务时不重复记计划事件', async () => { + const existingTask = { + ...stubCreatedTask(402, { + orderId: 61, + orderItemId: 51, + unitIndex: 1, + executorKey: 'affiliate_dash', + }), + task_no: 'DT-existing', + } + let eventCount = 0 + + const tasks = await syncDeliveryTasksForOrderWithDeps( + stubOrder({ id: 61, platform_order_id: 'idempotent-create-order' }), + [ + stubOrderItem({ + id: 51, + order_id: 61, + sku_code: 'SKU-idempotent', + item_snapshot_json: buildAffiliateSnapshot('AFF-IDEMPOTENT', '商品 IDEMPOTENT'), + }), + ], + { + loadActiveCarrierEvidence: async () => new Map(), + listTasksByOrderId: async () => [], + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), + createTask: async () => existingTask, + createTaskEvent: async () => { + eventCount += 1 + return null + }, + logInfo: () => null, + logWarn: () => null, + nowIso: () => '2026-05-29T09:05:46.658Z', + randomId: () => 'DT-reused', + }, + ) + + assert.deepEqual( + tasks.map((task) => task.id), + [402], + ) + assert.equal(eventCount, 0) +}) + test('来源重推时已分配其它渠道的订单不会补建 affiliate 任务', async () => { let createdCount = 0 const tasks = await syncDeliveryTasksForOrderWithDeps( diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index fb3a26a2..3770db0a 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -181,8 +181,9 @@ export async function syncDeliveryTasksForOrderWithDeps( ? resolvePaidTaskStatus(plan.profile) : TASK_STATUS.PENDING_PAYMENT - let createdFresh = true + let createdFresh = false let task: TaskRow | null + const plannedTaskNo = createRandomId('DT') try { task = await createDeliveryTask({ orderId: order.id, @@ -193,7 +194,7 @@ export async function syncDeliveryTasksForOrderWithDeps( shopId: order.shop_id, shopName: order.shop_name, platformOrderId: order.platform_order_id, - taskNo: createRandomId('DT'), + taskNo: plannedTaskNo, profileId: plan.profileId, executorKey: plan.executorKey, taskStatus: initialStatus, @@ -211,6 +212,7 @@ export async function syncDeliveryTasksForOrderWithDeps( createdAt, updatedAt: createdAt, }) + createdFresh = task?.task_no === plannedTaskNo } catch (error) { const concurrentTasks = await listTasks(order.id) task = @@ -229,6 +231,14 @@ export async function syncDeliveryTasksForOrderWithDeps( profileId: plan.profileId, profileKey: plan.profileKey, executorKey: plan.executorKey, + candidateTasks: concurrentTasks + .filter((candidate) => candidate.order_item_id === item.id) + .map((candidate) => ({ + id: candidate.id, + unitIndex: candidate.unit_index, + status: candidate.task_status, + isCurrent: candidate.is_current, + })), error: error instanceof Error ? error.message : String(error), }) throw error diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index 7ff70079..2a0dfd62 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts @@ -3,6 +3,11 @@ import { query } from '../../db/client.js' import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js' import { runAffiliateFallback } from '../fulfillment/affiliate-fallback-service.js' import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js' +import { attachFulfillmentConfigurations } from '../fulfillment/fulfillment-state-service.js' +import { + fulfillmentCreatedAfterSql, + getFulfillmentAutomationCutoff, +} from '../fulfillment/fulfillment-automation-config-service.js' import { processDueOrderServiceAutoPublishJobs } from '../worker-platform/order-service-auto-publish-service.js' import { resolveExceptionsForItemsByReason, @@ -52,12 +57,14 @@ function clampInt(value: unknown, fallback: number, min: number, max: number): n export async function runChannelAssignJob(job: JsonObject = {}) { const config = jobConfig(job) + const cutoffCreatedAt = await getFulfillmentAutomationCutoff() const result = await assignPendingOrderItemChannels({ batchSize: clampInt(config.batchSize, 500, 1, 5000), maxBatches: clampInt(config.maxBatches, 20, 1, 200), ...(config.afterId != null ? { afterId: clampInt(config.afterId, 0, 0, Number.MAX_SAFE_INTEGER) } : {}), + ...(cutoffCreatedAt ? { cutoffCreatedAt } : {}), }) await touchWatermark('channel_assign', { lastItemId: result.nextCursor }) @@ -85,6 +92,7 @@ export async function runChannelAssignJob(job: JsonObject = {}) { export async function runCarrierDispatchJob(job: JsonObject = {}) { const config = jobConfig(job) + const cutoffCreatedAt = await getFulfillmentAutomationCutoff() const limit = clampInt(config.limit, 200, 1, 2000) const reconcileCursor = config.carrierReconcileAfterId != null @@ -93,13 +101,19 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { const reconciled = await reconcilePendingCarriers({ limit: Math.min(limit, 200), afterId: reconcileCursor, + ...(cutoffCreatedAt ? { cutoffCreatedAt } : {}), }) await touchWatermark('carrier_reconcile', { lastItemId: reconciled.nextCursor }) const repairedAssigned = await reconcilePendingCarriers({ limit: Math.min(limit, 200), includeAssignedMissingCarrier: true, + ...(cutoffCreatedAt ? { cutoffCreatedAt } : {}), }) - await assignPendingOrderItemChannels({ batchSize: 500, maxBatches: 1 }).catch(() => undefined) + await assignPendingOrderItemChannels({ + batchSize: 500, + maxBatches: 1, + ...(cutoffCreatedAt ? { cutoffCreatedAt } : {}), + }).catch(() => undefined) const cursor = config.afterId != null ? Math.max(0, Math.trunc(Number(config.afterId))) @@ -109,6 +123,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { 'SELECT canonicalize_internal_sku(sku_key) AS sku_key FROM manual_fulfillment_configs WHERE enabled', ) const configKeys = configKeysResult.rows.map((row) => row.sku_key) + const manualParams: unknown[] = [limit, cursor, configKeys] + const manualCutoff = fulfillmentCreatedAfterSql(manualParams, cutoffCreatedAt) const manual = await query<{ id: number; sku_key: string; config_id: number }>( ` @@ -126,26 +142,29 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { AND oi.fulfillment_config_id IS NULL AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = ANY($3::text[]) AND oi.id > $2 + ${manualCutoff} ORDER BY oi.id LIMIT $1 `, - [limit, cursor, configKeys], + manualParams, ) let attached = 0 - for (const row of manual.rows) { - const updated = await query( - ` - UPDATE order_items - SET fulfillment_config_id = $2, updated_at = NOW() - WHERE id = $1 AND fulfillment_config_id IS NULL - `, - [row.id, row.config_id], - ) - attached += updated.rowCount || 0 + if (manual.rows.length > 0) { + const configured = await attachFulfillmentConfigurations({ + mappings: manual.rows.map((row) => ({ + orderItemId: Number(row.id), + configId: Number(row.config_id), + })), + expected: { status: 'assigned', channel: 'manual' }, + onlyWhenConfigNull: true, + }) + attached = configured.length } // 人工渠道但没有配置:进「人工 SKU 未配置」队列。 + const missingManualParams: unknown[] = [limit, cursor, configKeys] + const missingManualCutoff = fulfillmentCreatedAfterSql(missingManualParams, cutoffCreatedAt) const missingManual = await query<{ id: number; sku_key: string }>( ` SELECT oi.id, @@ -164,10 +183,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { AND ce.status <> 'resolved' ) AND oi.id > $2 + ${missingManualCutoff} ORDER BY oi.id LIMIT $1 `, - [limit, cursor, configKeys], + missingManualParams, ) const exceptionEntries: AggregatedExceptionEntry[] = [] @@ -181,6 +201,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { } // 已配置人工 SKU、但还没确认履约的单:进入「人工成本未记录」队列。 + const pendingManualCostParams: unknown[] = [limit, cursor] + const pendingManualCostCutoff = fulfillmentCreatedAfterSql( + pendingManualCostParams, + cutoffCreatedAt, + ) const pendingManualCost = await query<{ id: number; sku_key: string }>( ` SELECT oi.id, @@ -204,10 +229,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { AND ce.status <> 'resolved' ) AND oi.id > $2 + ${pendingManualCostCutoff} ORDER BY oi.id LIMIT $1 `, - [limit, cursor], + pendingManualCostParams, ) for (const row of pendingManualCost.rows) { @@ -220,6 +246,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { } // 自动发货 / 接单平台被运营指定后却没有载体:不能静默卡住,进问题队列。 + const missingCarrierParams: unknown[] = [limit, cursor] + const missingCarrierCutoff = fulfillmentCreatedAfterSql(missingCarrierParams, cutoffCreatedAt) const missingCarrierRaw = await query<{ id: number channel: string @@ -241,10 +269,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled') ) AND oi.id > $2 + ${missingCarrierCutoff} ORDER BY oi.id LIMIT $1 `, - [limit, cursor], + missingCarrierParams, ) const queuedCarrier = await query<{ order_item_id: number }>( @@ -273,6 +302,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { } // 已分配订单出现两个真实执行方时不能继续算正常渠道,必须进冲突队列人工处置。 + const conflictingCarrierParams: unknown[] = [limit] + const conflictingCarrierCutoff = fulfillmentCreatedAfterSql( + conflictingCarrierParams, + cutoffCreatedAt, + ) const conflictingCarriers = await query<{ id: number channel: string @@ -299,10 +333,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled') ) )::int > 1 + ${conflictingCarrierCutoff} ORDER BY oi.id LIMIT $1 `, - [limit], + conflictingCarrierParams, ) for (const row of conflictingCarriers.rows) { exceptionEntries.push({ @@ -331,8 +366,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { maxId = Math.max(maxId, Number(row.id)) } - const resolved = await resolveCarrierDispatchExceptions() - const fallback = await runAffiliateFallback({ limit: 200 }).catch(() => ({ + const resolved = await resolveCarrierDispatchExceptions(cutoffCreatedAt) + const fallback = await runAffiliateFallback({ + limit: 200, + ...(cutoffCreatedAt ? { cutoffCreatedAt } : {}), + }).catch(() => ({ scanned: 0, switched: 0, })) @@ -360,17 +398,24 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { /** * 载体/配置补上后,把对应异常自动关掉(每轮最多处理 500 条)。 */ -async function resolveCarrierDispatchExceptions(): Promise { +async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promise { // 游标会跳过已扫过的订单项;配置是后来补的,这里兜底把配置挂上。 - await query( + const attachParams: unknown[] = [] + const attachCutoff = fulfillmentCreatedAfterSql(attachParams, cutoffCreatedAt) + const attachCandidates = await query<{ order_item_id: number; config_id: number }>( ` - UPDATE order_items oi - SET fulfillment_config_id = cfg.id, updated_at = NOW() - FROM manual_fulfillment_configs cfg + SELECT DISTINCT ON (oi.id) + oi.id AS order_item_id, + cfg.id AS config_id + FROM order_items oi + JOIN manual_fulfillment_configs cfg + ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) + AND cfg.enabled WHERE canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( oi.manual_sku_key, oi.sku_name, oi.sku_code ) - AND cfg.enabled AND oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NULL @@ -382,9 +427,29 @@ async function resolveCarrierDispatchExceptions(): Promise { AND ce.reason = 'manual_config_missing' AND ce.status <> 'resolved' ) + ${attachCutoff} + ORDER BY oi.id, cfg.id + LIMIT 500 `, + attachParams, ) + if (attachCandidates.rows.length > 0) { + await attachFulfillmentConfigurations({ + mappings: attachCandidates.rows.map((row) => ({ + orderItemId: Number(row.order_item_id), + configId: Number(row.config_id), + })), + expected: { status: 'assigned', channel: 'manual' }, + onlyWhenConfigNull: true, + }) + } + const carrierParams: unknown[] = [] + const carrierCutoff = fulfillmentCreatedAfterSql(carrierParams, cutoffCreatedAt) + const configParams: unknown[] = [] + const configCutoff = fulfillmentCreatedAfterSql(configParams, cutoffCreatedAt) + const manualCostParams: unknown[] = [] + const manualCostCutoff = fulfillmentCreatedAfterSql(manualCostParams, cutoffCreatedAt) const [carrier, config, manualCost] = await Promise.all([ query<{ order_item_id: number }>( ` @@ -404,8 +469,10 @@ async function resolveCarrierDispatchExceptions(): Promise { AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled') )) ) + ${carrierCutoff} LIMIT 500 `, + carrierParams, ), query<{ order_item_id: number }>( ` @@ -416,14 +483,17 @@ async function resolveCarrierDispatchExceptions(): Promise { WHERE ce.reason = 'manual_config_missing' AND ce.status <> 'resolved' AND oi.fulfillment_config_id IS NOT NULL + ${configCutoff} LIMIT 500 `, + configParams, ), query<{ order_item_id: number }>( ` SELECT DISTINCT i.order_item_id FROM cost_exception_items i JOIN cost_exceptions ce ON ce.id = i.exception_id + JOIN order_items oi ON oi.id = i.order_item_id WHERE ce.reason = 'manual_cost_missing' AND ce.status <> 'resolved' AND EXISTS ( @@ -432,8 +502,10 @@ async function resolveCarrierDispatchExceptions(): Promise { AND cs.order_item_id = i.order_item_id AND cs.superseded_at IS NULL AND reversed_at IS NULL ) + ${manualCostCutoff} LIMIT 500 `, + manualCostParams, ), ]) diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.test.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.test.ts new file mode 100644 index 00000000..4673380f --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { normalizeInventoryDate } from './consignment-admin-service.js' + +test('寄售仓库入库日期筛选只接受合法的 YYYY-MM-DD', () => { + assert.equal(normalizeInventoryDate('2026-11-05'), '2026-11-05') + assert.equal(normalizeInventoryDate(' 2026-11-05 '), '2026-11-05') + assert.equal(normalizeInventoryDate('2026-02-31'), undefined) + assert.equal(normalizeInventoryDate('2026/11/05'), undefined) + assert.equal(normalizeInventoryDate(''), undefined) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index ff4f6038..3fa300c7 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -391,6 +391,8 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) { const allowMatching = query.allowMatching !== undefined ? String(query.allowMatching) === 'true' : undefined const keyword = query.keyword ? String(query.keyword).trim() : undefined + const dateFrom = normalizeInventoryDate(query.dateFrom) + const dateTo = normalizeInventoryDate(query.dateTo) const page = query.page ? Number(query.page) : 1 const pageSize = query.pageSize ? Number(query.pageSize) : 20 @@ -406,6 +408,8 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) { diamondLevel, allowMatching, keyword, + dateFrom, + dateTo, page, pageSize, }) @@ -416,6 +420,20 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) { } } +/** + * 寄售仓库的“通过审核入库时间”按自然日筛选。 + * 只接受 YYYY-MM-DD,并通过 UTC roundtrip 拦截 2026-02-31 这类会被 Date 溢出的日期。 + */ +export function normalizeInventoryDate(rawValue: unknown) { + const normalized = String(rawValue || '').trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return undefined + + const parsed = new Date(`${normalized}T00:00:00.000Z`) + return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== normalized + ? undefined + : normalized +} + export async function fetchAdminConsignmentStats() { return getConsignmentInventoryStats() } diff --git a/apps/backend/src/types/admin/read-inputs.ts b/apps/backend/src/types/admin/read-inputs.ts index 6b0e681f..197d5e0b 100644 --- a/apps/backend/src/types/admin/read-inputs.ts +++ b/apps/backend/src/types/admin/read-inputs.ts @@ -32,6 +32,7 @@ export type AdminTaskListQueryInput = { roleId?: string dateFrom?: string dateTo?: string + currentOnly?: boolean | string } export type AdminEntityIdInput = number | string diff --git a/apps/backend/src/types/repository/inputs.ts b/apps/backend/src/types/repository/inputs.ts index 986b4652..788de813 100644 --- a/apps/backend/src/types/repository/inputs.ts +++ b/apps/backend/src/types/repository/inputs.ts @@ -62,6 +62,7 @@ export type TaskListQueryInput = { roleId?: string dateFrom?: string dateTo?: string + currentOnly?: boolean } export type TaskRuntimeContextPatch = { diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index 6eef53cd..4b59e0c3 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -84,6 +84,8 @@ export type TaskRow = { screenshot_path: string last_error: string retry_count: number + attempt_no?: number + is_current?: boolean created_at: string updated_at: string kuaishou_ticket_status?: string diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 7deb2ffc..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, @@ -33,6 +34,7 @@ import { } from 'antd' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' import PercentTable from '@/components/common/PercentTable' import FinanceSubNav from '@/components/admin/FinanceSubNav' @@ -51,6 +53,7 @@ import { retryPendingCarriers, retryPendingWorkOrderRouting, runFinancePipeline, + unlockPendingChannelGroup, type FinancePipelineStatus, type InternalSkuMapping, type InternalSkuMappingMatchType, @@ -99,6 +102,16 @@ const ENABLED_FILTER_OPTIONS = [ 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 @@ -131,15 +144,17 @@ export default function AdminFulfillmentChannelPage() { 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({ @@ -151,6 +166,7 @@ function PendingPanel() { ? '' : String(internalSkuDrafts[rowKey(row)] || '').trim(), channel: selections[rowKey(row)] || '', + issueCategory, }), onSuccess: (result) => { const productMessage = result.data.draftProductId ? '和标准商品' : '' @@ -195,8 +211,16 @@ function PendingPanel() { }) const retryCarrierMutation = useMutation({ - mutationFn: (row: PendingChannelGroup) => - retryPendingCarriers({ internalSku: row.internal_sku || '' }), + 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 const detail = [ @@ -216,9 +240,23 @@ function PendingPanel() { onError: (error) => message.error(error instanceof Error ? error.message : '重试履约载体失败'), }) + const unlockMutation = useMutation({ + mutationFn: (row: PendingChannelGroup) => + unlockPendingChannelGroup({ + groupKey: row.group_key, + internalSku: row.internal_sku || '', + issueCategory, + }), + onSuccess: (result) => { + message.success(`已解除 ${result.data.updated} 个订单项的运营锁定`) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) + }, + onError: (error) => message.error(error instanceof Error ? error.message : '解除运营锁定失败'), + }) + 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( @@ -326,26 +364,59 @@ function PendingPanel() { carrier_task_manual_review: '载体人工复核', carrier_not_attempted: '尚未尝试补载体', carrier_attempt_failed: '载体创建失败', + carrier_task_retry_pending: '载体失败待确认重试', + carrier_task_expired: '载体已过期', + carrier_task_user_refund: '用户已申请退款', + carrier_task_cancelled: '上游履约已取消', manual_config_missing: '缺人工配置', work_rule_missing: '缺接单模板', 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} 项 + + 已人工指定渠道,等待补配置 {row.locked_item_count} 项 + ) : null} ) @@ -367,77 +438,168 @@ function PendingPanel() { { title: '指定渠道', width: 440, - render: (_: unknown, row: PendingChannelGroup) => ( - - setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))} + /> + + {canRunScopedActions && hasConfigIssue ? ( + + ) : null} + {canRunScopedActions && hasManualReview ? ( + + ) : null} + {canRunScopedActions && hasOnlyRetryableIssues && hasRetryable && hasUnlockedItems ? ( - - ) : null} - - + + ) : null} + {canRunScopedActions && row.locked_item_count > 0 ? ( + + ) : null} + {canRunScopedActions ? ( + + + + { + setPage(nextPageSize !== pageSize ? 1 : nextPage) + setPageSize(nextPageSize) + }, + })} + scroll={{ x: 1200 }} /> ) diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 8adbb34c..f0dd3780 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -403,6 +403,7 @@ export function deleteAdminManualSku(skuKey: string) { deletedConfigs: number deletedRegistry: number deletedRules: number + archivedProducts: number refreshedDays: number skippedClosedDays: number }>(`/api/v1/admin/cost-center/manual-skus/${encodeURIComponent(skuKey)}`) @@ -555,3 +556,17 @@ export function ensureVoucherChannelRules() { '/api/v1/admin/cost-center/voucher-products/ensure-rules', ) } + +export function deleteAdminVoucherProduct(productId: number) { + return apiDelete<{ + action: 'deleted' | 'archived' + deleted: boolean + product: ProductCatalogItem + deletedCostVersionCount?: number + preserved: { + mappingCount: number + snapshotCount: number + orderItemCount: number + } + }>(`/api/v1/admin/cost-center/voucher-products/${productId}`) +} diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index a9284d78..26097680 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -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('/api/v1/admin/fulfillment/pending', params) @@ -222,7 +241,14 @@ export function saveVoucherConsumePolicy(payload: VoucherConsumePolicy) { } export function fetchVoucherConsumeJobs( - params: { status?: string; page?: number; pageSize?: number } = {}, + params: { + keyword?: string + provider?: VoucherConsumeProvider + channel?: VoucherConsumeChannel + status?: VoucherConsumeJob['status'] + page?: number + pageSize?: number + } = {}, ) { return apiGet( '/api/v1/admin/fulfillment/voucher-consume-jobs', @@ -257,14 +283,27 @@ export function retryPendingWorkOrderRouting( ) } -export function retryPendingCarriers(payload: { internalSku: string }) { +export function retryPendingCarriers(payload: { + internalSku: string + retryInactiveCarrier?: boolean + includeLocked?: boolean + issueCodes?: string[] +}) { return apiPost( '/api/v1/admin/fulfillment/pending/retry-carriers', payload, ) } -export function deletePendingChannelGroup(payload: { groupKey: 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; issueCategory?: string }) { return apiPost( '/api/v1/admin/fulfillment/pending/delete', payload, @@ -276,6 +315,7 @@ export function assignChannelByInternalSku(payload: { internalSku: string mappedInternalSku?: string channel: string + issueCategory?: string note?: string }) { return apiPost<{ diff --git a/apps/frontend/tests/admin-task-current-view.test.ts b/apps/frontend/tests/admin-task-current-view.test.ts new file mode 100644 index 00000000..fcfcf5d0 --- /dev/null +++ b/apps/frontend/tests/admin-task-current-view.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const page = readFileSync(new URL('../src/pages/admin/AdminTasksPage.tsx', import.meta.url), 'utf8') + +test('任务页默认仅展示当前任务,并可切换全部历史尝试', () => { + assert.ok(page.includes("searchParams.get('scope') === 'all' ? 'all' : 'current'")) + assert.ok(page.includes('currentOnly: filters.scope !==')) + assert.ok(page.includes("label: '当前任务'")) + assert.ok(page.includes("label: '全部历史尝试'")) +}) diff --git a/apps/frontend/tests/consignment-inventory-date-filter.test.ts b/apps/frontend/tests/consignment-inventory-date-filter.test.ts new file mode 100644 index 00000000..07a7b627 --- /dev/null +++ b/apps/frontend/tests/consignment-inventory-date-filter.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +test('寄售仓库必须提供通过审核入库时间范围筛选,并透传到库存查询', () => { + const source = readFileSync( + new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url).pathname, + 'utf8', + ) + + assert.ok( + source.includes("from '@/components/admin/AdminDatePicker'"), + '必须复用后台日期范围控件', + ) + assert.ok(source.includes("placeholder={['入库开始日期', '入库结束日期']}"), '必须明确筛选口径') + assert.ok(source.includes('dateFrom: inboundRange?.[0]'), '开始日期必须透传后端') + assert.ok(source.includes('dateTo: inboundRange?.[1]'), '结束日期必须透传后端') + assert.ok( + source.includes('inboundRange?.[0],\n inboundRange?.[1],'), + '日期变化必须进入 React Query key 并触发重新查询', + ) + assert.ok( + source + .slice(source.indexOf(' { + assert.ok(panel.includes('去补配置')) + assert.ok(panel.includes('处理人工复核')) + assert.ok(panel.includes('确认重试')) + assert.ok(panel.includes('解除锁定')) + 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('重开历史载体必须二次确认且不会处理锁定项', () => { + assert.ok(panel.includes('确认重开履约载体')) + 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')) +}) diff --git a/apps/frontend/tests/manual-sku-config-ui.test.ts b/apps/frontend/tests/manual-sku-config-ui.test.ts new file mode 100644 index 00000000..11157747 --- /dev/null +++ b/apps/frontend/tests/manual-sku-config-ui.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('人工成本配置直接展示标准商品,不再要求按日期扫描内部 SKU', async () => { + const source = await readFile( + new URL('../src/pages/admin/cost-center/ManualSkuConfigPanel.tsx', import.meta.url), + 'utf8', + ) + + assert.ok(source.includes('人工标准商品与成本配置一一对应')) + assert.ok(source.includes("title: '人工标准商品'")) + assert.ok(source.includes('SKU:{row.skuKey}')) + assert.ok(!source.includes('扫描内部 SKU')) + assert.ok(!source.includes('DatePicker.RangePicker')) + assert.ok(!source.includes('rematchAdminManualFulfillments')) +}) diff --git a/apps/frontend/tests/voucher-consume-job-list-ui.test.ts b/apps/frontend/tests/voucher-consume-job-list-ui.test.ts new file mode 100644 index 00000000..9a244f1c --- /dev/null +++ b/apps/frontend/tests/voucher-consume-job-list-ui.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const panel = readFileSync( + new URL('../src/pages/admin/panels/VoucherConsumePolicyPanel.tsx', import.meta.url), + 'utf8', +) + +test('最近核销任务提供关键词、平台、渠道和状态筛选', () => { + assert.ok(panel.includes('placeholder="任务号 / 订单号 / 平台单号"')) + assert.ok(panel.includes('placeholder="全部平台"')) + assert.ok(panel.includes('placeholder="全部渠道"')) + assert.ok(panel.includes('placeholder="全部状态"')) + assert.ok(panel.includes('resetJobFilters')) +}) + +test('最近核销任务使用服务端分页', () => { + assert.ok(panel.includes('buildAdminTablePagination')) + assert.ok(panel.includes('total: jobsQuery.data?.data.total')) + assert.ok(panel.includes('setPageSize(nextPageSize)')) + assert.ok(!panel.includes('pagination={false}\n scroll={{ x: 1200 }}')) +}) diff --git a/apps/frontend/tests/voucher-sku-delete-ui.test.ts b/apps/frontend/tests/voucher-sku-delete-ui.test.ts new file mode 100644 index 00000000..688a5159 --- /dev/null +++ b/apps/frontend/tests/voucher-sku-delete-ui.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +test('第三方卡券面板提供安全删除入口并格式化生效时间', async () => { + const [panel, service] = await Promise.all([ + readFile( + new URL('../src/pages/admin/cost-center/VoucherSkuPanel.tsx', import.meta.url), + 'utf8', + ), + readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'), + ]) + + assert.ok(panel.includes('deleteAdminVoucherProduct')) + assert.ok(panel.includes('DeleteOutlined')) + assert.ok(panel.includes('Popconfirm')) + assert.ok(panel.includes('已有历史订单或成本快照时会自动归档')) + assert.ok(panel.includes('formatAdminDateTime(value)')) + assert.ok(service.includes('/api/v1/admin/cost-center/voucher-products/${productId}')) + assert.ok(service.includes("action: 'deleted' | 'archived'")) +}) diff --git a/docs/README.md b/docs/README.md index b4be8e8b..1076b9a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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`,开发分支 `dev_11.5_2` 已推进到迁移 `141`(履约统一状态、追加事件与正式影子模型)。 +> 数据基线:生产 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) | diff --git a/docs/履约系统架构复盘与优化方案-2026-11-05.md b/docs/履约系统架构复盘与优化方案-2026-11-05.md new file mode 100644 index 00000000..c9cf399d --- /dev/null +++ b/docs/履约系统架构复盘与优化方案-2026-11-05.md @@ -0,0 +1,767 @@ +--- +type: 架构优化方案 +description: 全面复盘订单履约链路、后台队列、任务载体和财务边界,并给出分阶段优化方案。 +tags: [订单, 履约, 状态机, 后台队列, 架构优化] +status: draft +--- + +# 履约系统架构复盘与优化方案 + +> 状态:**方案**,尚未实施。 +> 最后校准:2026-11-05。 +> 代码基线:`e8b76b3b` + `dev_11.5_2` 履约架构改造。 +> 数据库基线:生产迁移 `136`;开发分支迁移 `141`。 +> 生产核对方式:只读 SQL、后台页面、现行文档与源码交叉检查。 + +## 实施进度 + +2026-11-05 已在 `dev_11.5_2` 完成阶段 0、阶段 1,并落地阶段 2 的核心写入口: + +- 已完成:迁移 `137` 新增 `v_fulfillment_item_state` 统一读模型。 +- 已完成:履约问题按 `classification / configuration / carrier / manual_review / after_sales / system` 分类。 +- 已完成:待分类查询返回每类问题的订单项数量,而不是只返回原因集合。 +- 已完成:保存渠道、补载体、确认重试、解除锁定和归档按当前问题分类限定作用域。 +- 已完成:新增 `npm run audit:fulfillment` 只读审计脚本。 +- 已完成:迁移 `138` 为履约任务增加 `attempt_no / is_current`,每个订单项单元只允许一个当前尝试。 +- 已完成:创建新任务时自动退休旧当前尝试,任务页“当前任务”改为读取正式指针。 +- 已完成:迁移 `139` 写入 `fulfillment_automation_cutoff`,自动渠道判定、补载体、自动转人工和异常收口只处理生效时间之后创建的订单。 +- 已完成:迁移 `140` 新增只追加的 `fulfillment_state_events`;订单项履约核心字段每次真实变化都由触发器留痕,遗留直写至少记录为 `system` 事件。 +- 已完成:新增 `FulfillmentStateService`,履约状态写入先锁订单项、校验期望状态,再统一更新并由事件触发器记录前后值、操作者和原因。 +- 已完成:自动判渠、补载体、自动发货兜底、人工确认、人工分类移除、后台锁定/解锁/归档和渠道切换均接入统一状态写入口。 +- 已完成:人工履约配置、标准商品关联、自动发货/接单平台商品回填、模板重置和调度补挂统一通过状态映射接口写入,并校验并发期间的原商品值。 +- 已完成:任务创建先锁订单项并复用已有活跃尝试,避免并发调度重复插入同一当前尝试。 +- 已完成:新增真实 PostgreSQL 状态事件集成测试;全新库 `001 → 140` 迁移和既有履约路由集成测试通过。 +- 已完成:迁移 `141` 建立 `fulfillments / fulfillment_attempts / fulfillment_issues` 影子模型,从现有读模型回填,并由订单项、任务、工单触发器持续双写。 +- 已完成:审计脚本增加影子履约、尝试、开放异常和事件关联差异检查;当前读路径仍保持原表,未切换。 +- 已完成:修正统一视图“明确渠道仍被判定为缺渠道”的旧口径,已有渠道直接视为已路由。 +- 待继续:观察生产影子差异、切换读路径、停止旧字段写入、独立售后队列和生产存量修复。 + +### 历史数据边界 + +生产历史履约异常已经由人工处理完成。历史订单只保留查询和审计价值,不参与任何自动尝试: + +- 不自动重新判定渠道。 +- 不自动补建任务或工单。 +- 不自动重开失败、关闭、待重试任务。 +- 不自动转人工或修改历史异常状态。 +- 运营仍可在后台显式查看和处理历史订单,但必须经过人工动作。 + +自动化边界以 `app_config_entries.fulfillment_automation_cutoff` 为唯一真源。 + +## 一、结论 + +这套系统并不需要推倒重写,但不能继续靠新增字段、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 +``` + +迁移 `140` 之后,履约核心状态变化由数据库触发器兜底记事件;统一状态服务还会记录操作者、动作原因,并用期望状态阻止陈旧页面覆盖新状态。 + +运营指标: + +```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. 财务关账是否必须等待所有履约异常关闭。 +