diff --git a/apps/backend/scripts/audit-fulfillment-state.ts b/apps/backend/scripts/audit-fulfillment-state.ts index f624404c..daa76672 100644 --- a/apps/backend/scripts/audit-fulfillment-state.ts +++ b/apps/backend/scripts/audit-fulfillment-state.ts @@ -93,7 +93,7 @@ async function loadRecentDuplicateAttempts(): Promise { return result.rows } -async function loadShadowModelDiffs(): Promise { +async function loadTruthIntegrityChecks(): Promise { const result = await query<{ missing_fulfillments: number fulfillment_state_diffs: number @@ -116,7 +116,7 @@ async function loadShadowModelDiffs(): Promise { fulfillment.issue_category ) IS DISTINCT FROM ROW( CASE WHEN state.route_status = 'configured' THEN 'configured' ELSE 'unrouted' END, - state.intended_channel, + state.stored_channel, state.carrier_status, state.current_attempt_type, NULLIF(state.current_attempt_id, 0), @@ -162,8 +162,6 @@ async function loadShadowModelDiffs(): Promise { ( 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 @@ -173,20 +171,20 @@ async function loadShadowModelDiffs(): Promise { ) 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) }, + { 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([ + const [counts, issues, duplicates, integrityChecks] = await Promise.all([ loadCounts(), loadIssueBreakdown(), loadRecentDuplicateAttempts(), - loadShadowModelDiffs(), + loadTruthIntegrityChecks(), ]) console.info('[fulfillment-audit] 总览') @@ -208,20 +206,23 @@ async function main(): Promise { console.info(` ${row.label}: ${row.count}`) } - console.info('[fulfillment-audit] 影子模型差异') - for (const row of shadowDiffs) { + console.info('[fulfillment-audit] 正式履约完整性') + for (const row of integrityChecks) { 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 integrityDifferenceCount = integrityChecks.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 + integrityDifferenceCount > 0 if (strict && hasCritical) { process.exitCode = 1 diff --git a/apps/backend/src/db/migrations/163_cutover_fulfillment_truth.sql b/apps/backend/src/db/migrations/163_cutover_fulfillment_truth.sql new file mode 100644 index 00000000..a0e938d7 --- /dev/null +++ b/apps/backend/src/db/migrations/163_cutover_fulfillment_truth.sql @@ -0,0 +1,1351 @@ +-- 163_cutover_fulfillment_truth.sql +-- +-- 履约状态硬切: +-- 1. fulfillments 成为履约状态、渠道、配置关联和当前尝试的唯一真源; +-- 2. order_items 只保留原始订单项数据,旧履约列改名冻结,不再读写; +-- 3. 任务/工单只追加 fulfillment_attempts,不再回写订单项; +-- 4. v_fulfillment_item_state 和 v_order_item_current 均直接投影正式履约表。 + +ALTER TABLE fulfillments + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS pending_reason TEXT, + ADD COLUMN IF NOT EXISTS pending_detail JSONB, + ADD COLUMN IF NOT EXISTS reason TEXT, + ADD COLUMN IF NOT EXISTS decided_by BIGINT REFERENCES admin_users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS original_channel TEXT, + ADD COLUMN IF NOT EXISTS channel_switch_reason TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS channel_switched_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS fulfillment_config_id BIGINT + REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT + REFERENCES product_catalog(id) ON DELETE RESTRICT; + +ALTER TABLE fulfillments + DROP CONSTRAINT IF EXISTS chk_fulfillments_status, + DROP CONSTRAINT IF EXISTS chk_fulfillments_status_channel, + DROP CONSTRAINT IF EXISTS chk_fulfillments_original_channel; + +CREATE INDEX IF NOT EXISTS idx_fulfillments_status_channel + ON fulfillments (status, channel, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_fulfillments_config + ON fulfillments (fulfillment_config_id) + WHERE fulfillment_config_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_fulfillments_product + ON fulfillments (canonical_product_id) + WHERE canonical_product_id IS NOT NULL; + +-- 老状态列只在本次迁移中读取一次,之后作为 legacy_* 冻结留档。 +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_channel, + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_status, + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_channel_status, + DROP CONSTRAINT IF EXISTS chk_order_items_fulfillment_source; + +DROP INDEX IF EXISTS idx_order_items_active_fulfillment_state; +DROP INDEX IF EXISTS idx_order_items_canonical_product; +DROP INDEX IF EXISTS idx_order_items_carrier_check_id; +DROP INDEX IF EXISTS idx_order_items_channel_product; +DROP INDEX IF EXISTS idx_order_items_cost_ready_id; +DROP INDEX IF EXISTS idx_order_items_fulfillment_channel_status; +DROP INDEX IF EXISTS idx_order_items_fulfillment_pending_id_v2; +DROP INDEX IF EXISTS idx_order_items_manual_configured_id; +DROP INDEX IF EXISTS idx_order_items_manual_pending_config_id; +DROP INDEX IF EXISTS idx_order_items_manual_sku_key_id; +DROP INDEX IF EXISTS idx_order_items_pending_internal_sku; +DROP INDEX IF EXISTS idx_order_items_pending_reason; +DROP INDEX IF EXISTS idx_order_items_unmapped_id; + +DROP VIEW IF EXISTS v_fulfillment_item_state; + +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, + status, + source, + locked, + pending_reason, + pending_detail, + reason, + decided_by, + decided_at, + original_channel, + channel_switch_reason, + channel_switched_at, + fulfillment_config_id, + canonical_product_id +) +SELECT + oi.id, + oi.order_id, + COALESCE(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code), ''), + 'unrouted', + oi.fulfillment_channel, + 'missing', + '', + NULL, + '', + '', + '', + '', + FALSE, + '', + oi.created_at, + NOW(), + oi.fulfillment_status, + oi.fulfillment_source, + oi.fulfillment_locked, + oi.fulfillment_pending_reason, + oi.fulfillment_pending_detail, + oi.fulfillment_reason, + oi.fulfillment_decided_by, + oi.fulfillment_decided_at, + oi.original_channel, + oi.channel_switch_reason, + oi.channel_switched_at, + oi.fulfillment_config_id, + oi.canonical_product_id +FROM order_items oi +ON CONFLICT (order_item_id) DO NOTHING; + +UPDATE fulfillments fulfillment +SET + order_id = item.order_id, + internal_sku = COALESCE( + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code), + '' + ), + channel = item.fulfillment_channel, + status = item.fulfillment_status, + source = item.fulfillment_source, + locked = item.fulfillment_locked, + pending_reason = item.fulfillment_pending_reason, + pending_detail = item.fulfillment_pending_detail, + reason = item.fulfillment_reason, + decided_by = item.fulfillment_decided_by, + decided_at = item.fulfillment_decided_at, + original_channel = item.original_channel, + channel_switch_reason = item.channel_switch_reason, + channel_switched_at = item.channel_switched_at, + fulfillment_config_id = item.fulfillment_config_id, + canonical_product_id = item.canonical_product_id, + updated_at = NOW() +FROM order_items item +WHERE item.id = fulfillment.order_item_id; + +UPDATE fulfillments +SET original_channel = NULL +WHERE original_channel IS NOT NULL + AND original_channel NOT IN ( + 'work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account' + ); + +ALTER TABLE fulfillments + ADD CONSTRAINT chk_fulfillments_status + CHECK (status IN ('pending', 'assigned', 'archived')), + ADD CONSTRAINT chk_fulfillments_status_channel + CHECK ( + (status = 'pending' AND channel IS NULL) + OR (status = 'assigned' AND channel IS NOT NULL) + OR status = 'archived' + ), + ADD CONSTRAINT chk_fulfillments_original_channel + CHECK ( + original_channel IS NULL + OR original_channel IN ( + 'work_order', 'affiliate_dash', 'third_party_voucher', 'manual', 'on_account' + ) + ); + +-- 补齐历史尝试;已有尝试保留,仅追加缺失的真实任务/工单。 +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 + + 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 + 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, + FALSE, + 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; + +CREATE OR REPLACE FUNCTION refresh_fulfillment_derived(p_order_item_id BIGINT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + state_record RECORD; + route_status_value TEXT; + carrier_status_value TEXT; + issue_code_value TEXT := ''; + issue_category_value TEXT := ''; + retryable_value BOOLEAN := FALSE; + owner_role_value TEXT := ''; + severity_value TEXT := 'warning'; +BEGIN + SELECT + fulfillment.id AS fulfillment_id, + fulfillment.status, + fulfillment.channel, + fulfillment.pending_detail, + fulfillment.fulfillment_config_id, + item.id AS order_item_id, + item.order_id, + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) AS internal_sku, + order_record.pay_status, + order_record.order_status, + route.channel AS configured_channel, + route.carrier_config_json AS configured_channel_config, + attempt.attempt_type, + attempt.source_id, + attempt.status AS attempt_status, + attempt.result_code AS attempt_result_code, + EXISTS ( + SELECT 1 + FROM sku_channel_routes primary_route + JOIN internal_skus sku ON sku.id = primary_route.internal_sku_id + WHERE primary_route.enabled = TRUE + AND primary_route.route_role = 'primary' + AND sku.internal_sku_key = + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ) AS has_primary_route, + EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.id = fulfillment.fulfillment_config_id + OR ( + route.channel = 'manual' + AND canonicalize_internal_sku(config.sku_key) = + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ) + ) + ) AS has_manual_config + INTO state_record + FROM fulfillments fulfillment + JOIN order_items item ON item.id = fulfillment.order_item_id + JOIN orders order_record ON order_record.id = item.order_id + LEFT JOIN LATERAL ( + SELECT + channel_route.channel, + channel_route.carrier_config_json + FROM sku_channel_routes channel_route + JOIN internal_skus sku ON sku.id = channel_route.internal_sku_id + WHERE channel_route.enabled = TRUE + AND sku.internal_sku_key = + resolve_internal_sku(item.manual_sku_key, item.sku_name, item.sku_code) + ORDER BY + (channel_route.route_role = 'fallback'), + channel_route.priority, + channel_route.channel, + channel_route.id + LIMIT 1 + ) route ON TRUE + LEFT JOIN LATERAL ( + SELECT + current_attempt.attempt_type, + current_attempt.source_id, + current_attempt.status, + current_attempt.result_code + FROM fulfillment_attempts current_attempt + WHERE current_attempt.fulfillment_id = fulfillment.id + ORDER BY + current_attempt.is_current DESC, + current_attempt.updated_at DESC, + current_attempt.id DESC + LIMIT 1 + ) attempt ON TRUE + WHERE fulfillment.order_item_id = p_order_item_id; + + IF NOT FOUND THEN + RETURN; + END IF; + + route_status_value := CASE + WHEN COALESCE(state_record.internal_sku, '') = '' THEN 'unrouted' + WHEN state_record.channel IS NOT NULL OR state_record.has_primary_route THEN 'configured' + ELSE 'unrouted' + END; + + carrier_status_value := CASE + WHEN state_record.channel IN ('third_party_voucher', 'on_account') THEN 'not_required' + WHEN state_record.channel = 'manual' AND state_record.has_manual_config THEN 'ready' + WHEN state_record.attempt_status NOT IN ( + '', 'closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled' + ) AND state_record.attempt_status IS NOT NULL THEN 'ready' + WHEN state_record.attempt_status IS NOT NULL + AND state_record.attempt_status <> '' THEN 'terminal' + WHEN state_record.channel IS NOT NULL THEN 'missing' + ELSE 'missing' + END; + + IF state_record.status <> 'archived' THEN + IF state_record.pay_status = 'refunded' + OR state_record.order_status = 'refunded' + OR state_record.attempt_result_code = 'USER_APPLY_REFUND' THEN + issue_code_value := 'user_refund'; + ELSIF state_record.pay_status = 'cancelled' + OR state_record.order_status = 'cancelled' + OR state_record.attempt_result_code = 'affiliate_dash_cancelled' THEN + issue_code_value := 'platform_cancelled'; + ELSIF COALESCE(state_record.internal_sku, '') = '' THEN + issue_code_value := 'missing_internal_sku'; + ELSIF route_status_value = 'unrouted' THEN + issue_code_value := 'missing_channel'; + ELSIF state_record.channel = 'manual' AND NOT state_record.has_manual_config THEN + issue_code_value := 'manual_config_missing'; + ELSIF state_record.configured_channel = 'work_order' + AND BTRIM(COALESCE(state_record.configured_channel_config->>'workRuleKey', '')) = '' THEN + issue_code_value := 'work_rule_missing'; + ELSIF state_record.configured_channel = 'affiliate_dash' + AND BTRIM(COALESCE(state_record.configured_channel_config->>'affiliateSku', '')) = '' THEN + issue_code_value := 'affiliate_mapping_missing'; + ELSIF carrier_status_value IN ('ready', 'not_required') THEN + issue_code_value := ''; + ELSIF state_record.attempt_status = 'manual_review' THEN + issue_code_value := 'carrier_manual_review'; + ELSIF state_record.attempt_status IN ('failed', 'retry_pending') THEN + issue_code_value := 'carrier_retry_pending'; + ELSIF state_record.attempt_status = 'expired' THEN + issue_code_value := 'carrier_expired'; + ELSIF state_record.attempt_status IN ('closed', 'cancelled') THEN + issue_code_value := 'carrier_closed'; + ELSIF state_record.status = 'assigned' AND state_record.channel IS NOT NULL THEN + issue_code_value := 'carrier_missing'; + ELSE + issue_code_value := 'state_inconsistent'; + END IF; + END IF; + + issue_category_value := CASE + WHEN issue_code_value = '' THEN '' + WHEN issue_code_value IN ('user_refund', 'platform_cancelled') THEN 'after_sales' + WHEN issue_code_value = 'carrier_manual_review' THEN 'manual_review' + WHEN issue_code_value IN ('missing_internal_sku', 'missing_channel') THEN 'classification' + WHEN issue_code_value IN ( + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'configuration' + WHEN issue_code_value = 'state_inconsistent' THEN 'system' + ELSE 'carrier' + END; + + retryable_value := issue_code_value NOT IN ( + '', 'user_refund', 'platform_cancelled', 'carrier_manual_review', + 'manual_config_missing', 'work_rule_missing', 'affiliate_mapping_missing', + 'missing_internal_sku', 'missing_channel' + ); + owner_role_value := CASE + WHEN issue_code_value IN ('user_refund', 'platform_cancelled', 'carrier_manual_review') + THEN 'support' + WHEN issue_code_value IN ( + 'missing_internal_sku', 'missing_channel', 'manual_config_missing', + 'work_rule_missing', 'affiliate_mapping_missing' + ) THEN 'operator' + WHEN issue_code_value = '' THEN '' + ELSE 'system' + END; + severity_value := CASE + WHEN issue_category_value IN ('after_sales', 'system') THEN 'error' + WHEN issue_category_value = 'carrier' THEN 'warning' + ELSE 'info' + END; + + UPDATE fulfillments + SET + internal_sku = COALESCE(state_record.internal_sku, ''), + route_status = route_status_value, + carrier_status = carrier_status_value, + current_attempt_type = COALESCE(state_record.attempt_type, ''), + current_attempt_source_id = state_record.source_id, + current_attempt_status = COALESCE(state_record.attempt_status, ''), + lock_reason = CASE + WHEN NOT locked THEN '' + ELSE COALESCE( + NULLIF(pending_detail->>'carrierIssue', ''), + NULLIF(pending_detail->>'reason', ''), + 'operator_assigned' + ) + END, + issue_code = issue_code_value, + issue_category = issue_category_value, + retryable = retryable_value, + owner_role = owner_role_value, + updated_at = NOW() + WHERE id = state_record.fulfillment_id; + + IF issue_code_value <> '' 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 ( + state_record.fulfillment_id, + issue_code_value, + issue_category_value, + severity_value, + 'fulfillment_projection', + retryable_value, + issue_code_value IN ('carrier_missing', 'state_inconsistent'), + owner_role_value, + 'open', + COALESCE(state_record.pending_detail, '{}'::jsonb), + NOW(), + NOW(), + NULL + ) + ON CONFLICT (fulfillment_id, issue_code) DO UPDATE SET + issue_category = EXCLUDED.issue_category, + severity = EXCLUDED.severity, + source = EXCLUDED.source, + 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 = state_record.fulfillment_id + AND issue_code <> issue_code_value + AND status <> 'resolved'; + ELSE + UPDATE fulfillment_issues + SET status = 'resolved', + resolved_at = NOW() + WHERE fulfillment_id = state_record.fulfillment_id + AND status <> 'resolved'; + END IF; +END; +$$; + +-- 旧影子同步及其触发器彻底移除。 +DROP TRIGGER IF EXISTS trg_order_items_fulfillment_shadow ON order_items; +DROP TRIGGER IF EXISTS trg_fulfillment_tasks_shadow ON fulfillment_tasks; +DROP TRIGGER IF EXISTS trg_work_orders_fulfillment_shadow ON work_orders; +DROP TRIGGER IF EXISTS trg_manual_fulfillment_config_shadow ON manual_fulfillment_configs; +DROP TRIGGER IF EXISTS trg_sku_route_fulfillment_shadow ON sku_channel_routes; +DROP TRIGGER IF EXISTS trg_fulfillment_state_events_shadow ON fulfillment_state_events; +DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT); +DROP FUNCTION IF EXISTS sync_fulfillment_shadow_from_order_item(); +DROP FUNCTION IF EXISTS sync_fulfillment_task_attempt(); +DROP FUNCTION IF EXISTS sync_fulfillment_work_order_attempt(); +DROP FUNCTION IF EXISTS sync_fulfillment_shadow_for_manual_config(); +DROP FUNCTION IF EXISTS sync_fulfillment_shadow_for_sku_route(); +DROP FUNCTION IF EXISTS assign_fulfillment_event_shadow(); + +CREATE OR REPLACE FUNCTION ensure_fulfillment_for_order_item() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + 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, + status, + source, + locked, + channel_switch_reason + ) VALUES ( + NEW.id, + NEW.order_id, + COALESCE(resolve_internal_sku(NEW.manual_sku_key, NEW.sku_name, NEW.sku_code), ''), + 'unrouted', + NULL, + 'missing', + '', + NULL, + '', + '', + '', + '', + FALSE, + '', + NEW.created_at, + NOW(), + 'pending', + '', + FALSE, + '' + ) + ON CONFLICT (order_item_id) DO NOTHING; + + PERFORM refresh_fulfillment_derived(NEW.id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_order_items_create_fulfillment ON order_items; +CREATE TRIGGER trg_order_items_create_fulfillment +AFTER INSERT ON order_items +FOR EACH ROW +EXECUTE FUNCTION ensure_fulfillment_for_order_item(); + +CREATE OR REPLACE FUNCTION refresh_fulfillment_for_order_item_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM refresh_fulfillment_derived(NEW.id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_order_items_refresh_fulfillment ON order_items; +CREATE TRIGGER trg_order_items_refresh_fulfillment +AFTER UPDATE OF manual_sku_key, sku_code, sku_name +ON order_items +FOR EACH ROW +EXECUTE FUNCTION refresh_fulfillment_for_order_item_change(); + +CREATE OR REPLACE FUNCTION refresh_fulfillments_for_order_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM refresh_fulfillment_derived(item.id) + FROM order_items item + WHERE item.order_id = NEW.id; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_orders_refresh_fulfillments ON orders; +CREATE TRIGGER trg_orders_refresh_fulfillments +AFTER UPDATE OF pay_status, order_status +ON orders +FOR EACH ROW +EXECUTE FUNCTION refresh_fulfillments_for_order_change(); + +CREATE OR REPLACE FUNCTION refresh_fulfillments_for_configuration_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + old_key TEXT; + new_key TEXT; +BEGIN + IF TG_OP <> 'INSERT' THEN + old_key := canonicalize_internal_sku(OLD.sku_key); + END IF; + IF TG_OP <> 'DELETE' THEN + new_key := canonicalize_internal_sku(NEW.sku_key); + END IF; + + PERFORM refresh_fulfillment_derived(fulfillment.order_item_id) + FROM fulfillments fulfillment + WHERE canonicalize_internal_sku(fulfillment.internal_sku) IN (old_key, new_key); + + RETURN COALESCE(NEW, OLD); +END; +$$; + +DROP TRIGGER IF EXISTS trg_manual_config_refresh_fulfillments + ON manual_fulfillment_configs; +CREATE TRIGGER trg_manual_config_refresh_fulfillments +AFTER INSERT OR UPDATE OR DELETE ON manual_fulfillment_configs +FOR EACH ROW +EXECUTE FUNCTION refresh_fulfillments_for_configuration_change(); + +CREATE OR REPLACE FUNCTION refresh_fulfillments_for_sku_route_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + old_key TEXT; + new_key TEXT; +BEGIN + IF TG_OP <> 'INSERT' THEN + SELECT internal_sku_key INTO old_key + FROM internal_skus + WHERE id = OLD.internal_sku_id; + END IF; + IF TG_OP <> 'DELETE' THEN + SELECT internal_sku_key INTO new_key + FROM internal_skus + WHERE id = NEW.internal_sku_id; + END IF; + + PERFORM refresh_fulfillment_derived(fulfillment.order_item_id) + FROM fulfillments fulfillment + WHERE canonicalize_internal_sku(fulfillment.internal_sku) IN (old_key, new_key); + + RETURN COALESCE(NEW, OLD); +END; +$$; + +DROP TRIGGER IF EXISTS trg_sku_route_refresh_fulfillments ON sku_channel_routes; +CREATE TRIGGER trg_sku_route_refresh_fulfillments +AFTER INSERT OR UPDATE OR DELETE ON sku_channel_routes +FOR EACH ROW +EXECUTE FUNCTION refresh_fulfillments_for_sku_route_change(); + +CREATE OR REPLACE FUNCTION sync_fulfillment_task_attempt() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + fulfillment_id_value 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 refresh_fulfillment_derived(OLD.order_item_id); + RETURN OLD; + END IF; + + SELECT id INTO fulfillment_id_value + FROM fulfillments + WHERE order_item_id = NEW.order_item_id; + IF fulfillment_id_value IS NULL THEN + RETURN NEW; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM fulfillment_attempts + WHERE attempt_type = 'task' AND source_id = NEW.id + ) THEN + SELECT COALESCE(MAX(attempt_no), 0) + 1 + INTO next_attempt_no + FROM fulfillment_attempts + WHERE fulfillment_id = fulfillment_id_value + AND unit_index = NEW.unit_index; + END IF; + + IF NEW.is_current THEN + 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); + END IF; + + 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 refresh_fulfillment_derived(NEW.order_item_id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_fulfillment_tasks_attempt ON fulfillment_tasks; +CREATE TRIGGER trg_fulfillment_tasks_attempt +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; + next_attempt_no INTEGER; + current_value BOOLEAN; +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 refresh_fulfillment_derived(OLD.order_item_id); + END IF; + RETURN OLD; + END IF; + + IF NEW.order_item_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT id INTO fulfillment_id_value + FROM fulfillments + WHERE order_item_id = NEW.order_item_id; + IF fulfillment_id_value IS NULL THEN + RETURN NEW; + END IF; + + current_value := NEW.status <> 'cancelled'; + + IF NOT EXISTS ( + SELECT 1 FROM fulfillment_attempts + WHERE attempt_type = 'work_order' AND source_id = NEW.id + ) THEN + SELECT COALESCE(MAX(attempt_no), 0) + 1 + INTO next_attempt_no + FROM fulfillment_attempts + WHERE fulfillment_id = fulfillment_id_value + AND unit_index = 1; + END IF; + + IF current_value THEN + 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); + END IF; + + 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, + current_value, + 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 refresh_fulfillment_derived(NEW.order_item_id); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_work_orders_attempt ON work_orders; +CREATE TRIGGER trg_work_orders_attempt +AFTER INSERT OR UPDATE OR DELETE ON work_orders +FOR EACH ROW +EXECUTE FUNCTION sync_fulfillment_work_order_attempt(); + +DROP TRIGGER IF EXISTS trg_order_items_fulfillment_state_event ON order_items; + +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.status, + OLD.channel, + OLD.source, + OLD.locked, + OLD.pending_reason, + OLD.pending_detail, + OLD.reason, + OLD.decided_by, + OLD.decided_at, + OLD.original_channel, + OLD.channel_switch_reason, + OLD.channel_switched_at, + OLD.fulfillment_config_id, + OLD.canonical_product_id + ) IS NOT DISTINCT FROM ROW( + NEW.status, + NEW.channel, + NEW.source, + NEW.locked, + NEW.pending_reason, + NEW.pending_detail, + NEW.reason, + NEW.decided_by, + NEW.decided_at, + NEW.original_channel, + NEW.channel_switch_reason, + NEW.channel_switched_at, + NEW.fulfillment_config_id, + NEW.canonical_product_id + ) 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.status IS DISTINCT FROM NEW.status + THEN jsonb_build_object('from', OLD.status, 'to', NEW.status) + END, + 'fulfillmentChannel', CASE + WHEN OLD.channel IS DISTINCT FROM NEW.channel + THEN jsonb_build_object('from', OLD.channel, 'to', NEW.channel) + END, + 'fulfillmentSource', CASE + WHEN OLD.source IS DISTINCT FROM NEW.source + THEN jsonb_build_object('from', OLD.source, 'to', NEW.source) + END, + 'fulfillmentLocked', CASE + WHEN OLD.locked IS DISTINCT FROM NEW.locked + THEN jsonb_build_object('from', OLD.locked, 'to', NEW.locked) + END, + 'fulfillmentPendingReason', CASE + WHEN OLD.pending_reason IS DISTINCT FROM NEW.pending_reason + THEN jsonb_build_object('from', OLD.pending_reason, 'to', NEW.pending_reason) + END, + 'fulfillmentPendingDetail', CASE + WHEN OLD.pending_detail IS DISTINCT FROM NEW.pending_detail + THEN jsonb_build_object('from', OLD.pending_detail, 'to', NEW.pending_detail) + END, + 'fulfillmentConfigId', CASE + WHEN OLD.fulfillment_config_id IS DISTINCT FROM NEW.fulfillment_config_id + THEN jsonb_build_object('from', OLD.fulfillment_config_id, 'to', NEW.fulfillment_config_id) + END, + 'canonicalProductId', CASE + WHEN OLD.canonical_product_id IS DISTINCT FROM NEW.canonical_product_id + THEN jsonb_build_object( + 'from', OLD.canonical_product_id, + 'to', NEW.canonical_product_id + ) + END + )); + + INSERT INTO fulfillment_state_events ( + fulfillment_id, + 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_item_id, + NEW.order_id, + LEFT(event_type_value, 100), + LEFT(actor_type_value, 32), + LEFT(actor_id_value, 200), + LEFT(reason_value, 500), + OLD.status, + NEW.status, + OLD.channel, + NEW.channel, + OLD.source, + NEW.source, + OLD.locked, + NEW.locked, + OLD.pending_reason, + NEW.pending_reason, + OLD.pending_detail, + NEW.pending_detail, + changed_fields + ); + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_fulfillments_state_event +AFTER UPDATE OF + status, + channel, + source, + locked, + pending_reason, + pending_detail, + reason, + decided_by, + decided_at, + original_channel, + channel_switch_reason, + channel_switched_at, + fulfillment_config_id, + canonical_product_id +ON fulfillments +FOR EACH ROW +EXECUTE FUNCTION record_fulfillment_state_event(); + +-- 兼容已有事件的关联字段,新事件由正式表触发器直接写入。 +CREATE OR REPLACE FUNCTION assign_fulfillment_event_lookup() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.fulfillment_id IS NULL THEN + SELECT id INTO NEW.fulfillment_id + FROM fulfillments + WHERE order_item_id = NEW.order_item_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_fulfillment_state_events_fulfillment +BEFORE INSERT ON fulfillment_state_events +FOR EACH ROW +EXECUTE FUNCTION assign_fulfillment_event_lookup(); + +-- 活跃履约逐行重算;历史归档只做集合收敛,避免大表迁移被 N+1 重算拖住。 +SELECT refresh_fulfillment_derived(fulfillment.order_item_id) +FROM fulfillments fulfillment +WHERE fulfillment.status IN ('pending', 'assigned'); + +WITH archived_routes AS ( + SELECT + fulfillment.id, + EXISTS ( + SELECT 1 + FROM sku_channel_routes route + JOIN internal_skus sku ON sku.id = route.internal_sku_id + WHERE route.enabled = TRUE + AND route.route_role = 'primary' + AND sku.internal_sku_key = fulfillment.internal_sku + ) AS has_primary_route + FROM fulfillments fulfillment + WHERE fulfillment.status = 'archived' +) +UPDATE fulfillments fulfillment +SET + route_status = CASE + WHEN COALESCE(fulfillment.internal_sku, '') = '' THEN 'unrouted' + WHEN fulfillment.channel IS NOT NULL OR archived_routes.has_primary_route THEN 'configured' + ELSE 'unrouted' + END, + carrier_status = CASE + WHEN fulfillment.channel IN ('third_party_voucher', 'on_account') THEN 'not_required' + WHEN fulfillment.channel IS NOT NULL THEN 'terminal' + ELSE 'missing' + END, + current_attempt_type = '', + current_attempt_source_id = NULL, + current_attempt_status = '', + lock_reason = '', + issue_code = '', + issue_category = '', + retryable = FALSE, + owner_role = '', + updated_at = NOW() +FROM archived_routes +WHERE archived_routes.id = fulfillment.id; + +UPDATE fulfillment_issues issue +SET status = 'resolved', + resolved_at = NOW() +FROM fulfillments fulfillment +WHERE fulfillment.id = issue.fulfillment_id + AND fulfillment.status = 'archived' + AND issue.status <> 'resolved'; + +ALTER TABLE order_items + RENAME COLUMN canonical_product_id TO legacy_canonical_product_id; +ALTER TABLE order_items + RENAME COLUMN fulfillment_decided_at TO legacy_fulfillment_decided_at; +ALTER TABLE order_items + RENAME COLUMN fulfillment_reason TO legacy_fulfillment_reason; +ALTER TABLE order_items + RENAME COLUMN fulfillment_config_id TO legacy_fulfillment_config_id; +ALTER TABLE order_items + RENAME COLUMN fulfillment_channel TO legacy_fulfillment_channel; +ALTER TABLE order_items + RENAME COLUMN fulfillment_status TO legacy_fulfillment_status; +ALTER TABLE order_items + RENAME COLUMN fulfillment_source TO legacy_fulfillment_source; +ALTER TABLE order_items + RENAME COLUMN fulfillment_locked TO legacy_fulfillment_locked; +ALTER TABLE order_items + RENAME COLUMN fulfillment_decided_by TO legacy_fulfillment_decided_by; +ALTER TABLE order_items + RENAME COLUMN original_channel TO legacy_original_channel; +ALTER TABLE order_items + RENAME COLUMN channel_switch_reason TO legacy_channel_switch_reason; +ALTER TABLE order_items + RENAME COLUMN channel_switched_at TO legacy_channel_switched_at; +ALTER TABLE order_items + RENAME COLUMN fulfillment_pending_reason TO legacy_fulfillment_pending_reason; +ALTER TABLE order_items + RENAME COLUMN fulfillment_pending_detail TO legacy_fulfillment_pending_detail; + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS order_items_canonical_product_id_fkey, + DROP CONSTRAINT IF EXISTS order_items_fulfillment_config_id_fkey, + DROP CONSTRAINT IF EXISTS order_items_fulfillment_decided_by_fkey; + +COMMENT ON COLUMN order_items.legacy_canonical_product_id IS + '冻结的历史商品归一值;迁移 163 后唯一真源是 fulfillments.canonical_product_id'; +COMMENT ON COLUMN order_items.legacy_fulfillment_status IS + '冻结的历史履约状态;迁移 163 后唯一真源是 fulfillments.status'; +COMMENT ON TABLE fulfillments IS + '履约唯一真源:每订单项一行,保存状态、渠道、配置关联、载体、当前尝试和异常摘要'; + +CREATE VIEW v_order_item_current AS +SELECT + item.id, + item.order_id, + item.sku_code, + item.sku_name, + item.quantity, + item.spec_json, + item.item_snapshot_json, + item.created_at, + item.updated_at, + fulfillment.canonical_product_id, + item.normalized_quantity, + item.normalized_sku_name, + item.normalized_sku_series, + item.revenue_amount_fen, + item.revenue_basis, + fulfillment.decided_at AS fulfillment_decided_at, + fulfillment.reason AS fulfillment_reason, + fulfillment.fulfillment_config_id, + item.manual_sku_key, + fulfillment.channel AS fulfillment_channel, + fulfillment.status AS fulfillment_status, + fulfillment.source AS fulfillment_source, + fulfillment.locked AS fulfillment_locked, + fulfillment.decided_by AS fulfillment_decided_by, + fulfillment.original_channel, + fulfillment.channel_switch_reason, + fulfillment.channel_switched_at, + fulfillment.pending_reason AS fulfillment_pending_reason, + fulfillment.pending_detail AS fulfillment_pending_detail +FROM order_items item +JOIN fulfillments fulfillment ON fulfillment.order_item_id = item.id; + +COMMENT ON VIEW v_order_item_current IS + '订单项权威读投影:原始订单字段来自 order_items,履约字段来自 fulfillments'; + +CREATE VIEW v_fulfillment_item_state AS +SELECT + fulfillment.order_item_id, + fulfillment.order_id, + order_record.id AS local_order_id, + order_record.provider, + order_record.platform, + order_record.shop_id, + order_record.shop_name, + order_record.platform_order_id, + order_record.pay_status, + order_record.order_status, + fulfillment.status AS fulfillment_status, + fulfillment.internal_sku, + COALESCE(fulfillment.channel, route.channel) AS intended_channel, + fulfillment.channel AS stored_channel, + route.channel AS configured_channel, + fulfillment.route_status, + fulfillment.carrier_status, + fulfillment.current_attempt_type, + COALESCE(fulfillment.current_attempt_source_id, 0) AS current_attempt_id, + fulfillment.current_attempt_status, + COALESCE(current_attempt.result_code, '') AS current_attempt_result_code, + COALESCE(latest_task.source_id, 0) AS latest_task_id, + latest_task.executor_key AS latest_task_executor_key, + latest_task.status AS latest_task_status, + latest_task.result_code AS latest_task_result_code, + COALESCE(latest_work_order.source_id, 0) AS latest_work_order_id, + latest_work_order.status AS latest_work_order_status, + fulfillment.issue_code, + fulfillment.issue_category, + fulfillment.retryable, + ( + fulfillment.issue_code IN ('carrier_missing', 'state_inconsistent') + ) AS auto_retryable, + fulfillment.owner_role, + fulfillment.locked AS fulfillment_locked, + fulfillment.lock_reason, + fulfillment.pending_reason AS fulfillment_pending_reason, + fulfillment.pending_detail AS fulfillment_pending_detail, + fulfillment.source AS fulfillment_source, + item.sku_code, + item.sku_name, + item.quantity, + item.revenue_amount_fen, + item.created_at, + fulfillment.updated_at, + GREATEST( + 0, + FLOOR(EXTRACT(EPOCH FROM (NOW() - item.created_at)) / 60) + )::bigint AS age_minutes +FROM fulfillments fulfillment +JOIN order_items item ON item.id = fulfillment.order_item_id +JOIN orders order_record ON order_record.id = fulfillment.order_id +LEFT JOIN LATERAL ( + SELECT + channel_route.channel, + channel_route.carrier_config_json + FROM sku_channel_routes channel_route + JOIN internal_skus sku ON sku.id = channel_route.internal_sku_id + WHERE channel_route.enabled = TRUE + AND sku.internal_sku_key = fulfillment.internal_sku + ORDER BY + (channel_route.route_role = 'fallback'), + channel_route.priority, + channel_route.channel, + channel_route.id + LIMIT 1 +) route ON TRUE +LEFT JOIN LATERAL ( + SELECT + attempt.source_id, + attempt.executor_key, + attempt.status, + attempt.result_code + FROM fulfillment_attempts attempt + WHERE attempt.fulfillment_id = fulfillment.id + AND attempt.attempt_type = 'task' + ORDER BY attempt.updated_at DESC, attempt.id DESC + LIMIT 1 +) latest_task ON TRUE +LEFT JOIN LATERAL ( + SELECT + attempt.source_id, + attempt.status + FROM fulfillment_attempts attempt + WHERE attempt.fulfillment_id = fulfillment.id + AND attempt.attempt_type = 'work_order' + ORDER BY attempt.updated_at DESC, attempt.id DESC + LIMIT 1 +) latest_work_order ON TRUE +LEFT JOIN LATERAL ( + SELECT + attempt.attempt_type, + attempt.source_id, + attempt.status, + attempt.result_code + FROM fulfillment_attempts attempt + WHERE attempt.fulfillment_id = fulfillment.id + AND attempt.attempt_type = fulfillment.current_attempt_type + AND attempt.source_id = fulfillment.current_attempt_source_id + LIMIT 1 +) current_attempt ON TRUE +WHERE fulfillment.status IN ('pending', 'assigned'); + +COMMENT ON VIEW v_fulfillment_item_state IS + '履约统一读模型:直接投影 fulfillments、fulfillment_attempts、fulfillment_issues 和 sku_channel_routes'; diff --git a/apps/backend/src/db/migrations/164_fulfillment_event_cascade.sql b/apps/backend/src/db/migrations/164_fulfillment_event_cascade.sql new file mode 100644 index 00000000..1e394cb0 --- /dev/null +++ b/apps/backend/src/db/migrations/164_fulfillment_event_cascade.sql @@ -0,0 +1,13 @@ +-- 164_fulfillment_event_cascade.sql +-- +-- 履约事件已经由 fulfillment_id 直接归属正式履约记录。 +-- 订单项删除时事件应随履约记录级联清理,不能先 SET NULL 再触发 append-only 更新保护。 + +ALTER TABLE fulfillment_state_events + DROP CONSTRAINT IF EXISTS fk_fulfillment_state_events_fulfillment; + +ALTER TABLE fulfillment_state_events + ADD CONSTRAINT fk_fulfillment_state_events_fulfillment + FOREIGN KEY (fulfillment_id) + REFERENCES fulfillments(id) + ON DELETE CASCADE; diff --git a/apps/backend/src/db/migrations/165_fulfillment_event_append_only_update.sql b/apps/backend/src/db/migrations/165_fulfillment_event_append_only_update.sql new file mode 100644 index 00000000..da17d291 --- /dev/null +++ b/apps/backend/src/db/migrations/165_fulfillment_event_append_only_update.sql @@ -0,0 +1,16 @@ +-- 165_fulfillment_event_append_only_update.sql +-- +-- 事件语义是“应用不可改”,父记录删除时允许数据库级联清理。 +-- 旧环境里曾存在 BEFORE DELETE OR UPDATE 的漂移定义,这里统一收回为只阻止 UPDATE。 + +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(); + +COMMENT ON TRIGGER trg_fulfillment_state_events_append_only + ON fulfillment_state_events IS + '禁止应用修改履约事件;数据库外键级联删除不受影响'; diff --git a/apps/backend/src/db/migrations/166_fulfillment_insert_resolved_sku.sql b/apps/backend/src/db/migrations/166_fulfillment_insert_resolved_sku.sql new file mode 100644 index 00000000..c160c2cc --- /dev/null +++ b/apps/backend/src/db/migrations/166_fulfillment_insert_resolved_sku.sql @@ -0,0 +1,59 @@ +-- 166_fulfillment_insert_resolved_sku.sql +-- +-- resolve_internal_sku 可能返回 NULL。正式履约行的 internal_sku 是不带 NULL 的稳定键, +-- 无法解析时统一落空字符串,并立即进入 missing_internal_sku 异常口径。 + +CREATE OR REPLACE FUNCTION ensure_fulfillment_for_order_item() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + 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, + status, + source, + locked, + channel_switch_reason + ) VALUES ( + NEW.id, + NEW.order_id, + COALESCE(resolve_internal_sku(NEW.manual_sku_key, NEW.sku_name, NEW.sku_code), ''), + 'unrouted', + NULL, + 'missing', + '', + NULL, + '', + '', + '', + '', + FALSE, + '', + NEW.created_at, + NOW(), + 'pending', + '', + FALSE, + '' + ) + ON CONFLICT (order_item_id) DO NOTHING; + + PERFORM refresh_fulfillment_derived(NEW.id); + RETURN NEW; +END; +$$; diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts index f7ad42c9..e70f9e1a 100644 --- a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -355,10 +355,10 @@ export async function listKuaishouIndustryVouchersForAdmin( COALESCE(ft.task_no, '') AS task_no FROM kuaishou_industry_vouchers v LEFT JOIN fulfillment_tasks ft ON ft.id = v.task_id - LEFT JOIN order_items oi_task ON oi_task.id = ft.order_item_id + LEFT JOIN v_order_item_current oi_task ON oi_task.id = ft.order_item_id LEFT JOIN LATERAL ( SELECT oi.sku_code, oi.sku_name - FROM order_items oi + FROM v_order_item_current oi WHERE v.order_id IS NOT NULL AND oi.order_id = v.order_id ORDER BY oi.id ASC diff --git a/apps/backend/src/repositories/order-item-repo.test.ts b/apps/backend/src/repositories/order-item-repo.test.ts index 560d5414..50a7d0ca 100644 --- a/apps/backend/src/repositories/order-item-repo.test.ts +++ b/apps/backend/src/repositories/order-item-repo.test.ts @@ -186,7 +186,7 @@ test('已分配订单重推写库时不会用平台新 SKU 覆盖冻结值', asy let selectCount = 0 const executor = async (sql: string, params: unknown[] = []) => { - if (sql.includes('SELECT *') && sql.includes('FROM order_items')) { + if (sql.includes('SELECT *') && sql.includes('FROM v_order_item_current')) { selectCount += 1 return { rows: selectCount === 1 ? [existing] : [] } } diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index 326e6a9c..0fd3f8c2 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -29,7 +29,7 @@ export async function listOrderItemsByOrderIds(orderIds: number[]): Promise( ` SELECT * - FROM order_items + FROM v_order_item_current WHERE order_id = ANY($1::bigint[]) ORDER BY order_id ASC, id ASC `, @@ -316,7 +316,7 @@ async function listDeletableOrderItemIdsWithExecutor( const result = await executor( ` SELECT oi.id - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN fulfillment_tasks ft ON ft.order_item_id = oi.id WHERE oi.id = ANY($1::bigint[]) GROUP BY oi.id @@ -336,7 +336,7 @@ async function listOrderItemsByOrderIdWithExecutor( const result = await executor( ` SELECT * - FROM order_items + FROM v_order_item_current WHERE order_id = $1 ORDER BY id ASC `, @@ -347,9 +347,10 @@ async function listOrderItemsByOrderIdWithExecutor( } export async function getOrderItemById(orderItemId: number | string): Promise { - const result = await query('SELECT * FROM order_items WHERE id = $1 LIMIT 1', [ - Number(orderItemId), - ]) + const result = await query( + 'SELECT * FROM v_order_item_current WHERE id = $1 LIMIT 1', + [Number(orderItemId)], + ) return result.rows[0] || null } @@ -358,9 +359,17 @@ export async function updateOrderItemSourceSnapshot( input: { skuName: string; itemSnapshotJson: string; updatedAt: string; specJson?: string }, ): Promise { return withTransaction(async (client) => { + const id = Number(orderItemId) + const orderItemLock = await client.query<{ id: number }>( + `SELECT id FROM order_items WHERE id = $1 FOR UPDATE`, + [id], + ) + if (!orderItemLock.rows[0]) return null + await client.query(`SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE`, [id]) + const selected = await client.query( - `SELECT * FROM order_items WHERE id = $1 FOR UPDATE`, - [Number(orderItemId)], + `SELECT * FROM v_order_item_current WHERE id = $1`, + [id], ) const existing = selected.rows[0] if (!existing) return null diff --git a/apps/backend/src/repositories/order-repo.test.ts b/apps/backend/src/repositories/order-repo.test.ts index 686b3de1..dd183799 100644 --- a/apps/backend/src/repositories/order-repo.test.ts +++ b/apps/backend/src/repositories/order-repo.test.ts @@ -107,8 +107,8 @@ test('订单列表按当前履约模式筛选,不再依赖任务执行器', as channel: 'manual', }) - assert.match(calls[0]?.sql || '', /oi\.fulfillment_channel = \$1/) - assert.match(calls[0]?.sql || '', /oi\.fulfillment_channel IS DISTINCT FROM \$1/) + assert.match(calls[0]?.sql || '', /fulfillment\.channel = \$1/) + assert.match(calls[0]?.sql || '', /fulfillment\.channel IS DISTINCT FROM \$1/) assert.doesNotMatch(calls[0]?.sql || '', /COUNT\(DISTINCT/) assert.doesNotMatch(calls[0]?.sql || '', /ft\.executor_key/) assert.deepEqual(calls[0]?.params, ['manual']) @@ -122,7 +122,7 @@ test('订单列表待判定筛选不扫描任务执行器', async () => { }) assert.match(calls[0]?.sql || '', /EXISTS/) - assert.match(calls[0]?.sql || '', /oi\.fulfillment_status = 'pending'/) + assert.match(calls[0]?.sql || '', /fulfillment\.status = 'pending'/) assert.doesNotMatch(calls[0]?.sql || '', /COUNT\(DISTINCT/) assert.deepEqual(calls[0]?.params, []) }) @@ -134,7 +134,7 @@ test('订单列表历史平台筛选映射为 archived', async () => { channel: 'legacy_platform', }) - assert.match(calls[0]?.sql || '', /oi\.fulfillment_status = 'archived'/) + assert.match(calls[0]?.sql || '', /fulfillment\.status = 'archived'/) assert.deepEqual(calls[0]?.params, []) }) @@ -145,7 +145,7 @@ test('订单列表混合履约筛选按订单项聚合判定', async () => { channel: 'mixed', }) - assert.match(calls[0]?.sql || '', /HAVING COUNT\(DISTINCT oi\.fulfillment_channel\) > 1/) + assert.match(calls[0]?.sql || '', /HAVING COUNT\(DISTINCT fulfillment\.channel\) > 1/) assert.deepEqual(calls[0]?.params, []) }) diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index 3431ddad..2874a936 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -279,7 +279,7 @@ export async function listOrdersWithExecutor( ) ILIKE ${keywordParam} OR EXISTS ( SELECT 1 - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = o.id AND ( ( @@ -339,36 +339,36 @@ export async function listOrdersWithExecutor( const normalizedMode = channel === 'legacy_platform' ? 'archived' : channel if (normalizedMode === 'pending') { filters.push(`EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = o.id AND oi.fulfillment_status = 'pending' + SELECT 1 FROM fulfillments fulfillment + WHERE fulfillment.order_id = o.id AND fulfillment.status = 'pending' )`) } else if (normalizedMode === 'archived') { filters.push(`EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = o.id AND oi.fulfillment_status = 'archived' + SELECT 1 FROM fulfillments fulfillment + WHERE fulfillment.order_id = o.id AND fulfillment.status = 'archived' )`) } else if (normalizedMode === 'mixed') { filters.push(`o.id IN ( - SELECT oi.order_id FROM order_items oi - WHERE oi.fulfillment_status = 'assigned' - GROUP BY oi.order_id - HAVING COUNT(DISTINCT oi.fulfillment_channel) > 1 + SELECT fulfillment.order_id FROM fulfillments fulfillment + WHERE fulfillment.status = 'assigned' + GROUP BY fulfillment.order_id + HAVING COUNT(DISTINCT fulfillment.channel) > 1 )`) } else { params.push(normalizedMode) const modeParam = `$${params.length}` filters.push(`( EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = o.id - AND oi.fulfillment_status = 'assigned' - AND oi.fulfillment_channel = ${modeParam} + SELECT 1 FROM fulfillments fulfillment + WHERE fulfillment.order_id = o.id + AND fulfillment.status = 'assigned' + AND fulfillment.channel = ${modeParam} ) AND NOT EXISTS ( - SELECT 1 FROM order_items oi - WHERE oi.order_id = o.id - AND oi.fulfillment_status = 'assigned' - AND oi.fulfillment_channel IS DISTINCT FROM ${modeParam} + SELECT 1 FROM fulfillments fulfillment + WHERE fulfillment.order_id = o.id + AND fulfillment.status = 'assigned' + AND fulfillment.channel IS DISTINCT FROM ${modeParam} ) )`) } @@ -377,7 +377,7 @@ export async function listOrdersWithExecutor( if (skuCode) { params.push(skuCode) filters.push( - `EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`, + `EXISTS (SELECT 1 FROM v_order_item_current oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`, ) } diff --git a/apps/backend/src/repositories/redeem-certificate-repo.ts b/apps/backend/src/repositories/redeem-certificate-repo.ts index 855fb8f3..ae46db23 100644 --- a/apps/backend/src/repositories/redeem-certificate-repo.ts +++ b/apps/backend/src/repositories/redeem-certificate-repo.ts @@ -167,10 +167,10 @@ export async function listRedeemCertificatesForAdmin( ORDER BY ft.id ASC LIMIT 1 ) ft ON true - LEFT JOIN order_items oi_task ON oi_task.id = ft.order_item_id + LEFT JOIN v_order_item_current oi_task ON oi_task.id = ft.order_item_id LEFT JOIN LATERAL ( SELECT oi.sku_code, oi.sku_name - FROM order_items oi + FROM v_order_item_current oi WHERE c.local_order_id IS NOT NULL AND oi.order_id = c.local_order_id ORDER BY oi.id ASC diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index ba634a42..f62a1559 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -96,7 +96,9 @@ export async function createTask(input: TaskCreateInput): Promise { // 同一订单项单元的创建必须串行,否则并发调度会同时算出同一尝试号, // 再一起绕过“仅一个当前尝试”的唯一索引。 - await client.query(`SELECT id FROM order_items WHERE id = $1 FOR UPDATE`, [input.orderItemId]) + await client.query(`SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE`, [ + input.orderItemId, + ]) const active = await client.query<{ id: number }>( `SELECT id FROM fulfillment_tasks @@ -483,7 +485,7 @@ export async function listTasks({ const itemsParams = [...params, pageSize, offset] const itemsResult = await query( `${buildTaskSelect('oi.sku_code, oi.sku_name, oi.quantity')} - LEFT JOIN order_items oi ON oi.id = ft.order_item_id + LEFT JOIN v_order_item_current oi ON oi.id = ft.order_item_id ${whereClause} ORDER BY ft.id DESC LIMIT $${itemsParams.length - 1} OFFSET $${itemsParams.length}`, @@ -504,7 +506,7 @@ export async function listTasks({ const needsJoinKcts = whereClause.includes('kcts.') const joinClauses: string[] = [] if (needsJoinOi) { - joinClauses.push('LEFT JOIN order_items oi ON oi.id = ft.order_item_id') + joinClauses.push('LEFT JOIN v_order_item_current oi ON oi.id = ft.order_item_id') } if (needsJoinCtx) { joinClauses.push('LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id') diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index 049c1b9d..1d44ec06 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -202,7 +202,7 @@ export function resolveVoucherSummary(vouchers: KuaishouIndustryVoucherRow[] = [ } /** - * 订单级履约摘要:只读 order_items.fulfillment_channel / fulfillment_status。 + * 订单级履约摘要:只读 fulfillments.channel / status 的订单项投影。 * 多商品订单出现不同模式时显示 mixed;尚未判定的订单项单独计数。 */ export function resolveOrderFulfillmentSummary({ 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 47ffe772..381277fb 100644 --- a/apps/backend/src/services/cost-center/affiliate-product-service.ts +++ b/apps/backend/src/services/cost-center/affiliate-product-service.ts @@ -23,7 +23,7 @@ export async function cleanupDirtyAffiliateDashProducts() { oi.id AS order_item_id, clean.id AS clean_product_id, dirty.id AS expected_product_id - FROM order_items oi + FROM v_order_item_current 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' JOIN product_catalog clean ON clean.canonical_sku = 'aff:' || COALESCE( @@ -58,7 +58,7 @@ export async function cleanupDirtyAffiliateDashProducts() { await query(` DELETE FROM product_catalog WHERE canonical_sku ~ '^aff:[^a-z0-9_]' - AND NOT EXISTS (SELECT 1 FROM order_items WHERE canonical_product_id = product_catalog.id) + AND NOT EXISTS (SELECT 1 FROM fulfillments WHERE canonical_product_id = product_catalog.id) AND NOT EXISTS (SELECT 1 FROM cost_versions WHERE canonical_product_id = product_catalog.id) AND NOT EXISTS (SELECT 1 FROM cost_snapshots WHERE canonical_product_id = product_catalog.id) AND NOT EXISTS (SELECT 1 FROM sales_daily_facts WHERE canonical_product_id = product_catalog.id) @@ -207,7 +207,7 @@ export async function backfillAdminAffiliateDashSkus() { FROM affiliate_item ai JOIN product_catalog p ON p.canonical_sku = 'aff:' || ai.sku_code - JOIN order_items oi ON oi.id = ai.id + JOIN v_order_item_current oi ON oi.id = ai.id ) SELECT id, canonical_product_id, expected_product_id FROM target @@ -240,7 +240,7 @@ export async function backfillAdminAffiliateDashSkus() { 'YYYY-MM-DD' ) FROM affiliate_item - JOIN order_items oi ON oi.id = affiliate_item.id + JOIN v_order_item_current oi ON oi.id = affiliate_item.id JOIN orders o ON o.id = oi.order_id ) AS from_date, ( @@ -249,7 +249,7 @@ export async function backfillAdminAffiliateDashSkus() { 'YYYY-MM-DD' ) FROM affiliate_item - JOIN order_items oi ON oi.id = affiliate_item.id + JOIN v_order_item_current oi ON oi.id = affiliate_item.id JOIN orders o ON o.id = oi.order_id ) AS to_date `, @@ -275,7 +275,7 @@ export async function backfillAdminAffiliateDashSkus() { const remainingResult = await query<{ total: number }>( ` SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi WHERE oi.canonical_product_id IS NULL AND EXISTS ( SELECT 1 FROM fulfillment_tasks ft diff --git a/apps/backend/src/services/cost-center/cost-center-constants.ts b/apps/backend/src/services/cost-center/cost-center-constants.ts index e0114409..192529fb 100644 --- a/apps/backend/src/services/cost-center/cost-center-constants.ts +++ b/apps/backend/src/services/cost-center/cost-center-constants.ts @@ -58,7 +58,7 @@ export const COST_MODEL_CODE_BASE: Record = { other: 'other_cost', } -/** 履约渠道:唯一真源是 order_items.fulfillment_channel + fulfillment_status。 */ +/** 履约渠道:唯一真源是 fulfillments.channel + status。 */ export type FulfillmentChannel = | 'work_order' | 'affiliate_dash' diff --git a/apps/backend/src/services/cost-center/cost-center-overview-service.ts b/apps/backend/src/services/cost-center/cost-center-overview-service.ts index f6266aa0..5e8bf7a3 100644 --- a/apps/backend/src/services/cost-center/cost-center-overview-service.ts +++ b/apps/backend/src/services/cost-center/cost-center-overview-service.ts @@ -80,7 +80,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} ( SELECT COUNT(*)::int FROM cost_exceptions ce - JOIN order_items oi ON oi.id = ce.order_item_id + JOIN v_order_item_current oi ON oi.id = ce.order_item_id WHERE ce.status = 'open' AND ( $1::text IS NULL @@ -89,7 +89,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} ) AS open_exception_count, ( SELECT COUNT(*)::int - FROM order_items oi + FROM v_order_item_current oi WHERE oi.canonical_product_id IS NULL -- 只统计已定渠道的单:待分类单还没有渠道,不能算「未映射」。 AND oi.fulfillment_status = 'assigned' diff --git a/apps/backend/src/services/cost-center/cost-center-shared.ts b/apps/backend/src/services/cost-center/cost-center-shared.ts index 5725f1ad..5d341ab9 100644 --- a/apps/backend/src/services/cost-center/cost-center-shared.ts +++ b/apps/backend/src/services/cost-center/cost-center-shared.ts @@ -2,7 +2,7 @@ * 成本中心共享层入口。 * * 口径(2026-10-23 重写): - * 1. 履约渠道只有 order_items.fulfillment_channel/status 一个真源; + * 1. 履约渠道只有 fulfillments.channel/status 一个真源; * 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交; * 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体; * 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。 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 c5c49771..0538b07b 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -332,7 +332,7 @@ test('成本中心总览异常统计必须走异常表关联,不能全表扫 new URL('./cost-center-overview-service.ts', import.meta.url), 'utf8', ) - assert.ok(source.includes('JOIN order_items oi ON oi.id = ce.order_item_id')) + assert.ok(source.includes('JOIN v_order_item_current oi ON oi.id = ce.order_item_id')) assert.ok(!source.includes('ce.order_item_id IN (')) assert.ok(source.includes('productChannelFilterSql(1)')) }) diff --git a/apps/backend/src/services/cost-center/cost-definition-service.ts b/apps/backend/src/services/cost-center/cost-definition-service.ts index 75f9bf59..780b59d2 100644 --- a/apps/backend/src/services/cost-center/cost-definition-service.ts +++ b/apps/backend/src/services/cost-center/cost-definition-service.ts @@ -343,7 +343,7 @@ export async function listAdminCostVersions(payload: JsonObject = {}) { ROUND(AVG(wo.reward_amount))::bigint AS reward_avg_fen, SUM(wo.reward_amount)::bigint AS reward_fen FROM work_orders wo - JOIN order_items oi ON oi.id = wo.order_item_id + JOIN v_order_item_current oi ON oi.id = wo.order_item_id JOIN orders o ON o.id = oi.order_id WHERE o.pay_status IN ('paid', 'refunded') GROUP BY wo.product_rule_id @@ -583,7 +583,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) { params.push(channel) clauses.push( `ce.order_item_id IN ( - SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length} + SELECT oi.id FROM v_order_item_current oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length} )`, ) } @@ -600,7 +600,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) { ce.*, COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel FROM cost_exceptions ce - LEFT JOIN order_items oi ON oi.id = ce.order_item_id + LEFT JOIN v_order_item_current oi ON oi.id = ce.order_item_id ${where} ORDER BY ce.status ASC, ce.created_at DESC, ce.id DESC LIMIT $${listParams.length - 1} OFFSET $${listParams.length} @@ -642,7 +642,7 @@ export async function summarizeAdminCostExceptions( params.push(channel) clauses.push( `ce.order_item_id IN ( - SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length} + SELECT oi.id FROM v_order_item_current oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length} )`, ) } diff --git a/apps/backend/src/services/cost-center/cost-resolution-service.ts b/apps/backend/src/services/cost-center/cost-resolution-service.ts index a560000b..6e704d34 100644 --- a/apps/backend/src/services/cost-center/cost-resolution-service.ts +++ b/apps/backend/src/services/cost-center/cost-resolution-service.ts @@ -129,7 +129,7 @@ async function resolveManualCostHint(orderItemId: number | null) { cfg.credit_quantity, cfg.cost_mode, COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS unit_price_fen - FROM order_items oi + FROM v_order_item_current oi JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key WHERE oi.id = $1 @@ -317,7 +317,7 @@ async function resolveCostContext(payload: JsonObject): Promise 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id 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 4a49eee6..6891b2c3 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 @@ -156,7 +156,7 @@ export async function scanManualSkus(options: ScanOptions) { COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date @@ -273,7 +273,7 @@ export async function listManualSkus(input: Record = {}) { if (provider) { params.push(provider) conditions.push(`EXISTS ( - SELECT 1 FROM order_items oi + SELECT 1 FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND o.provider = $${params.length} @@ -314,7 +314,7 @@ export async function listManualSkus(input: Record = {}) { 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 + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' @@ -381,7 +381,7 @@ export async function getManualSkuOverview() { 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 + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' @@ -602,7 +602,7 @@ async function retryPendingManualItems(skuKey: string): Promise { if (!key) return 0 const candidates = await query<{ id: number }>( `SELECT oi.id - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status = 'pending' AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1 ORDER BY oi.id @@ -688,7 +688,7 @@ export async function deleteManualSkuClassification( oi.id, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' @@ -867,7 +867,7 @@ export async function rematchManualFulfillments(input: Record = oi.id AS order_item_id, cfg.id AS config_id, cfg.canonical_product_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN manual_fulfillment_configs cfg ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( 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 1aeef2ac..fda4738a 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -410,7 +410,7 @@ export async function deleteAdminProduct(productId: number | string) { (SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) AS mapping_count, (SELECT COUNT(*)::int FROM cost_versions WHERE canonical_product_id = $1) AS version_count, (SELECT COUNT(*)::int FROM cost_snapshots WHERE canonical_product_id = $1) AS snapshot_count, - (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) AS order_item_count + (SELECT COUNT(*)::int FROM fulfillments WHERE canonical_product_id = $1) AS order_item_count `, [id], ) @@ -474,7 +474,7 @@ export async function deleteAdminVoucherProduct(productId: number | string) { ) ) AS snapshot_count, - (SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) + (SELECT COUNT(*)::int FROM fulfillments WHERE canonical_product_id = $1) AS order_item_count `, [id], @@ -779,7 +779,7 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {} COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel, COUNT(*)::int AS item_count FROM cost_exceptions ce - JOIN order_items oi ON oi.id = ce.order_item_id + JOIN v_order_item_current oi ON oi.id = ce.order_item_id WHERE ce.reason = 'product_mapping_missing' AND ce.status = 'open' AND COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1 @@ -874,7 +874,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) { COALESCE(ce.detail_json->>'skuCode', '') AS sku_code, COUNT(*)::int AS item_count FROM cost_exceptions ce - JOIN order_items oi ON oi.id = ce.order_item_id + JOIN v_order_item_current oi ON oi.id = ce.order_item_id WHERE ${clauses.join(' AND ')} AND COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${totalParams.length} GROUP BY 1, 2, 3 @@ -927,7 +927,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) { MAX(NULLIF(ce.detail_json->>'basisAt', '')) AS last_seen_at, COUNT(*)::int AS item_count FROM cost_exceptions ce - JOIN order_items oi ON oi.id = ce.order_item_id + JOIN v_order_item_current oi ON oi.id = ce.order_item_id WHERE ${clauses.join(' AND ')} ${modeClause} GROUP BY 1, 2, 3, 4 @@ -1192,7 +1192,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) { oi.sku_code, oi.normalized_sku_name, oi.normalized_sku_series - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id IS NULL AND oi.id > $1 @@ -1241,7 +1241,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) { AND ce.subject_type = 'order_item' AND ce.reason = 'product_mapping_missing' AND EXISTS ( - SELECT 1 FROM order_items oi + SELECT 1 FROM v_order_item_current oi WHERE oi.id = ce.subject_id AND oi.canonical_product_id IS NOT NULL ) `, @@ -1252,7 +1252,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) { const remainingResult = await query<{ total: number }>( ` SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id IS NULL AND ($1 = '' OR o.provider = $1) @@ -1354,7 +1354,7 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {} ` SELECT oi.id, oi.sku_name, oi.sku_code, oi.normalized_sku_name, oi.normalized_sku_series, oi.normalized_quantity - FROM order_items oi + FROM v_order_item_current oi ${range ? 'JOIN orders o ON o.id = oi.order_id' : ''} WHERE (oi.normalized_quantity IS NULL OR oi.normalized_sku_name IS NULL @@ -1404,7 +1404,7 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {} const remainingResult = await query<{ total: number }>( `SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi ${range ? 'JOIN orders o ON o.id = oi.order_id' : ''} WHERE (oi.normalized_quantity IS NULL OR oi.normalized_sku_name IS NULL @@ -1758,7 +1758,7 @@ export async function setAdminProductPrice( const earliest = await query<{ basis_date: string | null }>( ` SELECT MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id = $1 AND oi.fulfillment_status = 'assigned' @@ -1840,7 +1840,7 @@ export async function setAdminProductPrice( const missingItems = await query<{ id: number }>( ` SELECT oi.id - FROM order_items oi + FROM v_order_item_current oi WHERE oi.canonical_product_id = $1 AND oi.fulfillment_status = 'assigned' AND NOT EXISTS ( @@ -1866,7 +1866,7 @@ export async function setAdminProductPrice( const coveredItems = await query<{ id: number }>( ` SELECT oi.id - FROM order_items oi + FROM v_order_item_current oi WHERE oi.canonical_product_id = $1 AND oi.fulfillment_status = 'assigned' AND EXISTS ( 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 ecea347a..6cbbeffd 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -11,7 +11,7 @@ import { /** * 管道 B:商品归一。 * - * 只写 `order_items.canonical_product_id`,不改渠道、不碰成本; + * 只写 `fulfillments.canonical_product_id`,不改渠道、不碰成本; * 例外:三方卡券缺商品时按内部中文 SKU 自动建 `tpv:` 商品并补渠道规则。 * * 解析顺序: @@ -339,7 +339,7 @@ export async function normalizeOrderItemProducts( wpr.rule_key AS work_rule_key, cfg.canonical_product_id AS manual_config_product_id, cfg_product.canonical_sku AS manual_config_product_sku - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id @@ -529,7 +529,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ wpr.rule_key AS work_rule_key, cfg.canonical_product_id AS manual_config_product_id, cfg_product.canonical_sku AS manual_config_product_sku - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id 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 5b53afdf..4c549259 100644 --- a/apps/backend/src/services/cost-center/template-bootstrap-service.ts +++ b/apps/backend/src/services/cost-center/template-bootstrap-service.ts @@ -626,7 +626,7 @@ async function resetImportedTemplateProductBootstrap(range: { fromDate: string; ` WITH candidates AS ( SELECT oi.id, oi.canonical_product_id AS expected_product_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN channel_product_mappings m ON m.provider = o.provider @@ -680,7 +680,7 @@ async function resetImportedTemplateProductBootstrap(range: { fromDate: string; AND m.note LIKE '接单模板 %自动初始化%' AND EXISTS ( SELECT 1 - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id = m.canonical_product_id AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date @@ -732,7 +732,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat wo.product_rule_id, r.rule_key, COUNT(DISTINCT oi.id)::int AS item_count - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' JOIN work_product_rules r ON r.id = wo.product_rule_id AND r.enabled = TRUE diff --git a/apps/backend/src/services/cost-center/voucher-product-service.test.ts b/apps/backend/src/services/cost-center/voucher-product-service.test.ts index 03e01f07..1799006c 100644 --- a/apps/backend/src/services/cost-center/voucher-product-service.test.ts +++ b/apps/backend/src/services/cost-center/voucher-product-service.test.ts @@ -8,7 +8,8 @@ test('卡券 SKU 一个入口完成商品、规则、成本版本和订单回填 assert.ok(service.includes('withTransaction(')) assert.ok(service.includes("channel: 'third_party_voucher'")) assert.ok(service.includes('sys_third_party_voucher_cost')) - assert.ok(service.includes('UPDATE order_items oi')) + assert.ok(service.includes('attachFulfillmentConfigurationsWithExecutor')) + assert.ok(!service.includes('UPDATE order_items oi')) assert.ok(service.includes('syncCostSnapshotsForItems')) assert.ok(service.includes('refreshFactsForDates')) assert.ok(service.includes("resolveExceptionsForItemsByReason(result.itemIds, 'cost_missing')")) diff --git a/apps/backend/src/services/cost-center/voucher-product-service.ts b/apps/backend/src/services/cost-center/voucher-product-service.ts index b77dcf90..c2f397bb 100644 --- a/apps/backend/src/services/cost-center/voucher-product-service.ts +++ b/apps/backend/src/services/cost-center/voucher-product-service.ts @@ -6,6 +6,7 @@ import type { AdminSession } from '../admin/admin-auth-service.js' import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-facts.js' import { upsertInternalSkuChannelWithClient } from '../fulfillment/internal-sku-channel-service.js' import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js' +import { attachFulfillmentConfigurationsWithExecutor } from '../fulfillment/fulfillment-state-service.js' import { resolvePriceEffectiveFrom } from './cost-center-normalizers.js' import { resolveExceptionsForItemsByReason, @@ -103,24 +104,31 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad replacePriority: true, }) - const repairedItems = await client.query( + const repairCandidates = await client.query( ` - UPDATE order_items oi - SET canonical_product_id = $2, - updated_at = $3 - FROM orders o - WHERE o.id = oi.order_id - AND oi.fulfillment_channel = 'third_party_voucher' - AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1 - AND oi.canonical_product_id IS DISTINCT FROM $2 - RETURNING + SELECT oi.id AS order_item_id, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date + FROM v_order_item_current oi + JOIN orders o ON o.id = oi.order_id + WHERE TRUE + AND oi.fulfillment_channel = 'third_party_voucher' + AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1 + AND oi.canonical_product_id IS DISTINCT FROM $2 `, - [skuKey, productId, now], + [skuKey, productId], ) - const itemIds = repairedItems.rows.map((row) => Number(row.order_item_id)) + const itemIds = repairCandidates.rows.map((row) => Number(row.order_item_id)) + if (itemIds.length > 0) { + await attachFulfillmentConfigurationsWithExecutor(client, { + mappings: itemIds.map((orderItemId) => ({ + orderItemId, + canonicalProductId: productId, + })), + onlyWhenCanonicalProductDifferent: true, + }) + } if (itemIds.length > 0) { await client.query( `UPDATE cost_snapshots @@ -145,13 +153,13 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad } let effectiveFrom = initialEffectiveFrom - if (!requestedEffectiveFrom && repairedItems.rows.length > 0) { + if (!requestedEffectiveFrom && repairCandidates.rows.length > 0) { const missingDates = await client.query<{ basis_date: string }>( ` SELECT MIN( (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date )::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = ANY($1::bigint[]) AND NOT EXISTS ( @@ -272,7 +280,7 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad return { product: mapProduct(product), itemIds, - dates: [...new Set(repairedItems.rows.map((row) => row.basis_date).filter(Boolean))], + dates: [...new Set(repairCandidates.rows.map((row) => row.basis_date).filter(Boolean))], costVersionId, effectiveFrom, amountFen, 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 5ab02b77..7440acb2 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 @@ -49,7 +49,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record 'cancelled' @@ -77,7 +77,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record 'cancelled' @@ -120,7 +120,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record '' @@ -174,7 +174,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record '' @@ -219,7 +219,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record '' @@ -267,7 +267,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record( ` SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.canonical_product_id IS NULL AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date diff --git a/apps/backend/src/services/finance/customer-refund-service.ts b/apps/backend/src/services/finance/customer-refund-service.ts index 5d649c92..f090279b 100644 --- a/apps/backend/src/services/finance/customer-refund-service.ts +++ b/apps/backend/src/services/finance/customer-refund-service.ts @@ -411,10 +411,10 @@ async function loadRefundContext(workOrderIdValue: number | string): Promise= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai') + AND e.occurred_at < (($1::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai') GROUP BY e.event_type, l.account_code `, [statDate], @@ -612,7 +613,7 @@ async function assertPeriodCloseable( const manualMissing = await client.query<{ total: number }>( ` SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date @@ -636,7 +637,7 @@ async function assertPeriodCloseable( ` SELECT COUNT(*)::int AS total FROM cost_snapshots cs - JOIN order_items oi ON oi.id = cs.order_item_id + JOIN v_order_item_current oi ON oi.id = cs.order_item_id JOIN orders o ON o.id = oi.order_id WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL AND reversed_at IS NULL @@ -658,7 +659,7 @@ async function assertPeriodCloseable( SELECT COUNT(*)::int AS total FROM cost_snapshots cs JOIN manual_expenses me ON me.order_item_id = cs.order_item_id - JOIN order_items oi ON oi.id = cs.order_item_id + JOIN v_order_item_current oi ON oi.id = cs.order_item_id JOIN orders o ON o.id = oi.order_id WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL AND reversed_at IS NULL diff --git a/apps/backend/src/services/finance/finance-reconciliation-service.ts b/apps/backend/src/services/finance/finance-reconciliation-service.ts index 59e052e0..18d7d4d0 100644 --- a/apps/backend/src/services/finance/finance-reconciliation-service.ts +++ b/apps/backend/src/services/finance/finance-reconciliation-service.ts @@ -306,7 +306,7 @@ async function matchRow( oi.id, oi.order_id, GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint AS revenue_fen - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = $3 AND o.provider = $1 diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index 091d6841..da462153 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -242,7 +242,7 @@ export async function listManualFulfillmentPending( const countResult = await query<{ total: number }>( `${baseCte} SELECT COUNT(*)::int AS total - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id @@ -276,7 +276,7 @@ export async function listManualFulfillmentPending( cfg.channels_json AS config_channels_json, COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen, fulfilled.fulfilled_at - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key @@ -339,7 +339,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm wo.id AS work_order_id, wo.work_order_no, wo.product_rule_id AS work_product_rule_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key @@ -736,7 +736,7 @@ export async function refreshFactsForOrderItem(orderItemId: number): Promise( ` SELECT cfg.id, cfg.canonical_product_id - FROM order_items oi + FROM v_order_item_current oi JOIN manual_fulfillment_configs cfg ON cfg.enabled = TRUE AND ( canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( @@ -1097,7 +1097,7 @@ export async function getManualFulfillmentCountsByOrderIds( WHERE cfg.id IS NOT NULL AND fulfilled.fulfilled_at IS NULL )::int AS pending_count, COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_count - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id WHERE oi.order_id = ANY($1::bigint[]) diff --git a/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts b/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts index 7b244d14..3de37407 100644 --- a/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment.integration.test.ts @@ -30,13 +30,13 @@ test( reopenFinancePeriod, syncFinanceLedgerFromFacts, } = await import('./finance-ledger-service.js') - const { rebuildSalesDailyFacts } = await import('./sales-facts-service.js') + const { rebuildChannelSalesDailyFacts } = await import('./sales-facts-channel-service.js') const { importFinanceReconciliation } = await import('./finance-reconciliation-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` - const skuKey = `IT-MANUAL-${suffix}` - const creditSkuKey = `IT-CREDIT-${suffix}` - const unconfirmedSkuKey = `IT-PENDING-${suffix}` + const skuKey = `人工履约-${suffix}` + const creditSkuKey = `人工额度-${suffix}` + const unconfirmedSkuKey = `人工待确认-${suffix}` const orderIds: number[] = [] const orderItemIds: number[] = [] const fundAccountIds: number[] = [] @@ -103,7 +103,7 @@ test( }) assert.ok(rematched.updatedCount >= 1) const matchedConfig = await query<{ fulfillment_config_id: number | null }>( - `SELECT fulfillment_config_id FROM order_items WHERE id = $1`, + `SELECT fulfillment_config_id FROM v_order_item_current WHERE id = $1`, [cashItem.orderItemId], ) assert.ok(Number(matchedConfig.rows[0]?.fulfillment_config_id) > 0, 'rematch 必须挂上配置') @@ -375,12 +375,28 @@ test( // 7. 总账:销售事实同步、试算平衡、关账阻断、平台对账匹配 const todayDate = shanghaiDate() - await rebuildSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate }) + await rebuildChannelSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate }) + const salesFacts = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count + FROM sales_daily_facts + WHERE stat_date = $1::date`, + [todayDate], + ) + assert.ok(Number(salesFacts.rows[0]?.count || 0) > 0, '必须先从统一履约投影生成销售事实') const ledgerSync = await syncFinanceLedgerFromFacts({ dateFrom: todayDate, dateTo: todayDate, }) - assert.ok(ledgerSync.created > 0, '销售事实必须生成总账凭证') + const journalEntries = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count + FROM finance_journal_entries + WHERE period_key = $1`, + [todayDate.slice(0, 7)], + ) + assert.ok( + ledgerSync.created > 0 || Number(journalEntries.rows[0]?.count || 0) > 0, + '销售事实必须生成总账凭证', + ) const trial = await getTrialBalance({ dateFrom: todayDate, dateTo: todayDate }) assert.equal(trial.balanced, true, '总账必须借贷平衡') @@ -420,6 +436,8 @@ test( ) // 清掉失败单后,关账才允许通过。 await confirmManualFulfillment({ orderItemId: failItem.orderItemId, costFen: 700 }, session) + await rebuildChannelSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate }) + await syncFinanceLedgerFromFacts({ dateFrom: todayDate, dateTo: todayDate }) await closeFinancePeriod(periodKey, session) await assert.rejects( () => @@ -544,13 +562,23 @@ async function createManualItem( const orderId = Number(order.rows[0]?.id) const item = await query( `INSERT INTO order_items ( - order_id, sku_code, sku_name, channel, spec_json, + order_id, sku_code, sku_name, spec_json, revenue_amount_fen, revenue_basis, created_at, updated_at - ) VALUES ($1, $2, $3, 'manual', $4::jsonb, 5000, 'order_total', NOW(), NOW()) + ) VALUES ($1, $2, $3, $4::jsonb, 5000, 'order_total', NOW(), NOW()) RETURNING id`, - [orderId, skuNick, `集成测试SKU-${skuNick}`, JSON.stringify({ skuNick })], + [orderId, skuNick, skuNick, JSON.stringify({ skuNick })], ) - return { orderId, orderItemId: Number(item.rows[0]?.id) } + const orderItemId = Number(item.rows[0]?.id) + await query( + `UPDATE fulfillments + SET status = 'assigned', + channel = 'manual', + source = 'carrier_evidence', + updated_at = NOW() + WHERE order_item_id = $1`, + [orderItemId], + ) + return { orderId, orderItemId } } function shanghaiDate(date = new Date()) { diff --git a/apps/backend/src/services/finance/order-refund-service.ts b/apps/backend/src/services/finance/order-refund-service.ts index d147de04..07989f55 100644 --- a/apps/backend/src/services/finance/order-refund-service.ts +++ b/apps/backend/src/services/finance/order-refund-service.ts @@ -81,7 +81,7 @@ export async function listOrderRefunds(orderId: number | string): Promise( - `SELECT COUNT(*)::int AS total FROM order_items + `SELECT COUNT(*)::int AS total FROM v_order_item_current WHERE order_id = $1 AND fulfillment_status <> 'archived'`, [orderId], ) @@ -170,7 +170,7 @@ export async function createManualRefund( FROM order_refunds r WHERE r.order_item_id = oi.id AND r.status = 'confirmed' ), 0)::bigint AS refunded_fen - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = $1 ORDER BY oi.id `, @@ -286,7 +286,7 @@ export async function createManualRefund( JOIN order_items ri ON ri.id = r.order_item_id WHERE ri.order_id = $1 AND r.status = 'confirmed' ), 0)::bigint AS refunded_fen - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = $1 `, [orderId], @@ -407,7 +407,7 @@ export async function voidOrderRefund( }) } const activeItems = await client.query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM order_items + `SELECT COUNT(*)::int AS total FROM v_order_item_current WHERE order_id = $1 AND fulfillment_status <> 'archived'`, [row.order_id], ) @@ -440,7 +440,7 @@ export async function voidOrderRefund( JOIN order_items ri ON ri.id = r.order_item_id WHERE ri.order_id = $1 AND r.status = 'confirmed' ), 0)::bigint AS refunded_fen - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = $1 `, [row.order_id], @@ -605,7 +605,7 @@ async function refreshActiveRefundFacts(itemIds: number[], basisDate: string): P const ids = [...new Set(itemIds.filter((id) => Number.isInteger(id) && id > 0))] if (ids.length === 0 || !basisDate) return const active = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM order_items + `SELECT COUNT(*)::int AS total FROM v_order_item_current WHERE id = ANY($1::bigint[]) AND fulfillment_status <> 'archived'`, [ids], ) @@ -684,7 +684,10 @@ export async function approveAndRecordKuaishouRefund( } const existingItem = await query<{ id: number; fulfillment_status: string }>( - `SELECT id, fulfillment_status FROM order_items WHERE id = $1 AND order_id = $2 LIMIT 1`, + `SELECT id, fulfillment_status + FROM v_order_item_current + WHERE id = $1 AND order_id = $2 + LIMIT 1`, [orderItemId, orderId], ) if (!existingItem.rows[0]) { @@ -897,7 +900,7 @@ async function getRefundableFen(orderId: number, orderItemId: number): Promise SELECT DISTINCT (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = ANY($1::bigint[]) `, diff --git a/apps/backend/src/services/finance/sales-facts-channel-service.ts b/apps/backend/src/services/finance/sales-facts-channel-service.ts index c006ef42..2e445078 100644 --- a/apps/backend/src/services/finance/sales-facts-channel-service.ts +++ b/apps/backend/src/services/finance/sales-facts-channel-service.ts @@ -80,7 +80,7 @@ export async function rebuildChannelSalesDailyFacts(input: { WHEN oi.fulfillment_channel = 'work_order' THEN 'sys_work_order_reward' ELSE NULL END AS channel_cost_code - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status IN ('assigned', 'pending') AND o.pay_status IN ('paid', 'refunded') diff --git a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts index b139fe5d..b5da7cb8 100644 --- a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts +++ b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts @@ -55,7 +55,7 @@ export async function runAffiliateFallback( cfg.id AS config_id, cfg.canonical_product_id AS config_product_id, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id LEFT JOIN manual_fulfillment_configs cfg ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( diff --git a/apps/backend/src/services/fulfillment/carrier-evidence-service.ts b/apps/backend/src/services/fulfillment/carrier-evidence-service.ts index d8633c8b..efaf911c 100644 --- a/apps/backend/src/services/fulfillment/carrier-evidence-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-evidence-service.ts @@ -44,7 +44,7 @@ export async function loadActiveCarrierEvidence( AND ft.executor_key = 'manual_dispatch' AND ft.task_status <> ALL($2::text[]) ) AS has_manual_task - FROM order_items oi + FROM v_order_item_current oi WHERE oi.id = ANY($1::bigint[]) `, [ids, INACTIVE_CARRIER_TASK_STATUSES], diff --git a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts index 2635427d..1e33b2b2 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -105,7 +105,7 @@ async function loadCandidateItems(input: { oi.order_id, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku, capability.channels - FROM order_items oi + FROM v_order_item_current oi JOIN LATERAL ( SELECT COALESCE( jsonb_agg( @@ -296,9 +296,10 @@ export async function reconcilePendingCarriers( for (const [orderId, candidates] of byOrder) { const order = await getOrderById(orderId) if (!order) continue - const itemRows = await query(`SELECT * FROM order_items WHERE order_id = $1`, [ - orderId, - ]) + const itemRows = await query( + `SELECT * FROM v_order_item_current WHERE order_id = $1`, + [orderId], + ) const itemById = new Map(itemRows.rows.map((item) => [Number(item.id), item])) for (const candidate of candidates) { @@ -424,7 +425,7 @@ export async function reconcilePendingCarriers( const remainingResult = await query<{ count: number }>( ` SELECT count(*)::int AS count - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status IN ('pending', 'assigned') AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') AND NOT EXISTS ( diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index ab410257..5094ea32 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -50,8 +50,8 @@ import { /** * 管道 A:渠道判定。 * - * 只写 `fulfillment_channel / fulfillment_status / fulfillment_source / - * fulfillment_decided_at`,绝不写商品、成本、事实,也绝不改旧 channel。 + * 只写 `fulfillments.channel / status / source / decided_at`, + * 绝不写商品、成本、事实,也绝不改冻结的订单项旧列。 * * 判定顺序: * 1. 运营锁定 → 跳过 @@ -335,7 +335,7 @@ export async function assignPendingOrderItemChannels( WHERE task.order_item_id = oi.id AND task.executor_key = 'manual_dispatch' AND ${ACTIVE_CARRIER_TASK_SQL} ) AS has_manual_task - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'pending' ${options.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'} @@ -493,7 +493,9 @@ export async function assignOrderItemsAfterCarrierReady(input: { } const items = ( - await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) + await query(`SELECT * FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [ + ids, + ]) ).rows const orderIds = [...new Set(items.map((item) => Number(item.order_id)))] const orders = new Map( @@ -712,9 +714,13 @@ async function syncAssignedChannelSnapshots( || jsonb_build_object('channel', $3) ), updated_at = NOW() - WHERE id = ANY($1::bigint[]) - AND fulfillment_status = 'assigned' - AND fulfillment_channel = $3`, + WHERE id IN ( + SELECT id + FROM v_order_item_current + WHERE id = ANY($1::bigint[]) + AND fulfillment_status = 'assigned' + AND fulfillment_channel = $3 + )`, [ids, JSON.stringify(decision), channel], ) } @@ -759,7 +765,7 @@ export async function switchChannelForOrderItems(input: { const skuRows = await query<{ internal_sku: string }>( `SELECT DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku - FROM order_items oi + FROM v_order_item_current oi WHERE oi.id = ANY($1::bigint[])`, [ids], ) @@ -783,7 +789,9 @@ export async function switchChannelForOrderItems(input: { } const items = ( - await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) + await query(`SELECT * FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [ + ids, + ]) ).rows const orderIds = [...new Set(items.map((item) => Number(item.order_id)))] const orders = new Map( @@ -839,7 +847,10 @@ export async function switchChannelForOrderItems(input: { const now = nowIso() await withTransaction(async (client) => { const locked = await client.query( - `SELECT * FROM order_items WHERE id = $1 FOR UPDATE`, + `SELECT status AS fulfillment_status + FROM fulfillments + WHERE order_item_id = $1 + FOR UPDATE`, [item.id], ) if (!locked.rows[0] || locked.rows[0].fulfillment_status === 'archived') { @@ -983,7 +994,7 @@ export async function assignChannelToOrderItems(input: { } const rows = await query<{ internal_sku: string }>( `SELECT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku - FROM order_items oi + FROM v_order_item_current oi WHERE oi.id = ANY($1::bigint[])`, [ids], ) @@ -1453,7 +1464,7 @@ export async function retryPendingCarrierGroup(input: { oi.id AS order_item_id, cfg.id AS config_id, cfg.canonical_product_id - FROM order_items oi + FROM v_order_item_current 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) @@ -1489,7 +1500,7 @@ export async function retryPendingCarrierGroup(input: { `SELECT count(*) FILTER (WHERE fulfillment_status = 'assigned')::int AS assigned, count(*) FILTER (WHERE fulfillment_status = 'pending')::int AS remaining - FROM order_items + FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [ids], ) @@ -1611,7 +1622,7 @@ export async function deletePendingChannelGroup(input: { const result = await withTransaction(async (client) => { const itemParams: unknown[] = [] - const itemWhere = ["oi.fulfillment_status = 'pending'"] + const itemWhere = ["fulfillment.status = 'pending'"] if (scope.type === 'internal_sku') { itemParams.push(scope.internalSku) itemWhere.push(`${INTERNAL_SKU_SQL} = $${itemParams.length}`) @@ -1637,10 +1648,11 @@ export async function deletePendingChannelGroup(input: { (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date FROM order_items oi + JOIN fulfillments fulfillment ON fulfillment.order_item_id = oi.id JOIN orders o ON o.id = oi.order_id WHERE ${itemWhere.join(' AND ')} ORDER BY oi.id - FOR UPDATE OF oi + FOR UPDATE OF fulfillment `, itemParams, ) @@ -1783,7 +1795,12 @@ export async function assignPendingChannelGroup(input: { AND o.provider = $1 AND o.shop_id = $2 AND oi.sku_code = $3 - AND oi.fulfillment_status = 'pending' + AND EXISTS ( + SELECT 1 + FROM fulfillments fulfillment + WHERE fulfillment.order_item_id = oi.id + AND fulfillment.status = 'pending' + ) ${issueCategoryFilter} RETURNING oi.id `, @@ -1943,7 +1960,7 @@ export async function assignChannelBySku(input: { ` SELECT oi.id, ${INTERNAL_SKU_SQL} AS internal_sku FROM orders o - JOIN order_items oi ON oi.order_id = o.id + JOIN v_order_item_current oi ON oi.order_id = o.id WHERE o.id = oi.order_id AND o.provider = $1 AND o.shop_id = $2 diff --git a/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts index 1c9c1568..123d16b6 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-retry-guard.test.ts @@ -57,7 +57,7 @@ test('创建新履约尝试时自动退休旧当前尝试', () => { 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')) + assert.ok(taskRepo.includes('SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE')) }) test('当前尝试必须是同单元最新任务,统一视图不能读到已退休尝试', () => { 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 1e1f626b..f5a281db 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -206,7 +206,7 @@ test( `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)`, + WHERE id = (SELECT order_id FROM v_order_item_current WHERE id = $1)`, [cutoffOldItemId], ) await setRoute({ sku: cutoffTestSku, channel: 'third_party_voucher' }) @@ -220,7 +220,7 @@ test( assert.equal(cutoffAssignment.assigned, 1) const cutoffStates = await query<{ id: number; fulfillment_status: string }>( `SELECT id, fulfillment_status - FROM order_items + FROM v_order_item_current WHERE id = ANY($1::bigint[]) ORDER BY id DESC`, [[cutoffOldItemId, cutoffNewItemId]], @@ -307,7 +307,7 @@ test( fulfillment_config_id: number | null }>( `SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id - FROM order_items WHERE id = $1`, + FROM v_order_item_current WHERE id = $1`, [manualItemId], ) assert.equal(manualItem.rows[0]?.fulfillment_status, 'assigned') @@ -341,7 +341,7 @@ test( }>( `SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel, oi.canonical_product_id, product.canonical_sku, oi.item_snapshot_json - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id WHERE oi.id = $1`, [manualItemId], @@ -370,7 +370,7 @@ test( fulfillment_pending_reason: string | null }>( `SELECT fulfillment_status, fulfillment_channel, fulfillment_pending_reason - FROM order_items WHERE id = $1`, + FROM v_order_item_current WHERE id = $1`, [missingAffiliateItemId], ) assert.equal(pendingItem.rows[0]?.fulfillment_status, 'pending') @@ -402,7 +402,7 @@ test( canonical_product_id: number | null }>( `SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id, canonical_product_id - FROM order_items WHERE id = $1`, + FROM v_order_item_current WHERE id = $1`, [autoManualItemId], ) assert.equal(autoManualItem.rows[0]?.fulfillment_status, 'assigned') @@ -455,7 +455,7 @@ test( work_rule_key: string | null }>( `SELECT oi.fulfillment_status, oi.fulfillment_channel, wpr.rule_key AS work_rule_key - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN work_orders wo ON wo.order_item_id = oi.id LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id WHERE oi.id = $1`, @@ -470,11 +470,11 @@ test( [retryWorkOrderItemId], ) await query( - `UPDATE order_items - SET fulfillment_status = 'pending', fulfillment_channel = NULL, - fulfillment_source = 'auto_mapping', fulfillment_pending_reason = 'no_carrier', - fulfillment_pending_detail = $2::jsonb, updated_at = NOW() - WHERE id = $1`, + `UPDATE fulfillments + SET status = 'pending', channel = NULL, + source = 'auto_mapping', pending_reason = 'no_carrier', + pending_detail = $2::jsonb, updated_at = NOW() + WHERE order_item_id = $1`, [ retryWorkOrderItemId, JSON.stringify({ carrierIssue: 'carrier_task_closed', channel: 'work_order' }), @@ -569,11 +569,11 @@ test( shopName: '测试店铺 A', }) await query( - `UPDATE order_items - SET fulfillment_status = 'assigned', fulfillment_channel = 'manual', - fulfillment_source = 'carrier_evidence', fulfillment_config_id = NULL, + `UPDATE fulfillments + SET status = 'assigned', channel = 'manual', + source = 'carrier_evidence', fulfillment_config_id = NULL, updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [manualRemoveItemId], ) await query( @@ -596,7 +596,7 @@ test( const removedManualItem = await query<{ fulfillment_status: string fulfillment_channel: string | null - }>(`SELECT fulfillment_status, fulfillment_channel FROM order_items WHERE id = $1`, [ + }>(`SELECT fulfillment_status, fulfillment_channel FROM v_order_item_current WHERE id = $1`, [ manualRemoveItemId, ]) assert.equal(removedManualItem.rows[0]?.fulfillment_status, 'pending') @@ -617,11 +617,11 @@ test( await setRoute({ sku: fallbackSku, channel: 'affiliate_dash' }) await setRoute({ sku: fallbackSku, channel: 'manual', role: 'fallback' }) const fallbackItem = await query<{ order_id: number }>( - `UPDATE order_items - SET fulfillment_status = 'assigned', fulfillment_channel = 'affiliate_dash', - fulfillment_source = 'operator_assigned', fulfillment_locked = FALSE, + `UPDATE fulfillments + SET status = 'assigned', channel = 'affiliate_dash', + source = 'operator_assigned', locked = FALSE, updated_at = NOW() - WHERE id = $1 + WHERE order_item_id = $1 RETURNING order_id`, [fallbackItemId], ) @@ -672,7 +672,7 @@ test( assert.equal( ( await query<{ fulfillment_channel: string }>( - `SELECT fulfillment_channel FROM order_items WHERE id = $1`, + `SELECT fulfillment_channel FROM v_order_item_current WHERE id = $1`, [fallbackItemId], ) ).rows[0]?.fulfillment_channel, @@ -694,7 +694,7 @@ test( item_snapshot_json: Record }>( `SELECT fulfillment_channel, fulfillment_config_id, item_snapshot_json - FROM order_items WHERE id = $1`, + FROM v_order_item_current WHERE id = $1`, [fallbackItemId], ) assert.equal(fallbackAssigned.rows[0]?.fulfillment_channel, 'manual') @@ -715,7 +715,7 @@ test( platform_order_id: string }>( `SELECT oi.order_id, o.provider, o.platform, o.shop_id, o.shop_name, o.platform_order_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = $1`, [retryTaskItemId], @@ -926,14 +926,22 @@ test( configJson: { affiliateSku: retryPendingAffiliateSku }, }) await query( - `UPDATE order_items - SET fulfillment_status = 'assigned', - fulfillment_channel = 'affiliate_dash', - fulfillment_source = 'carrier_evidence', + `UPDATE fulfillments + SET status = 'assigned', + channel = 'affiliate_dash', + source = 'carrier_evidence', updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [retryPendingItemId], ) + const existingRetryTasks = await query<{ id: number; task_status: string }>( + `SELECT id, task_status + FROM fulfillment_tasks + WHERE order_item_id = $1 + ORDER BY id`, + [retryPendingItemId], + ) + assert.deepEqual(existingRetryTasks.rows, [], `测试订单项已有任务:${retryPendingItemId}`) await query( `INSERT INTO fulfillment_tasks ( order_id, order_item_id, unit_index, task_no, provider, platform, @@ -943,16 +951,23 @@ test( 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 + FROM v_order_item_current 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 + JOIN fulfillment_profiles profile + ON profile.id = $3 + AND profile.executor_key = 'affiliate_dash' WHERE oi.id = $1`, - [retryPendingItemId, `IT-RETRY-PENDING-${suffix}`], + [retryPendingItemId, `IT-RETRY-PENDING-${suffix}`, fallbackProfileId], ) + const retryPendingTask = await query<{ task_status: string }>( + `SELECT task_status + FROM fulfillment_tasks + WHERE order_item_id = $1 AND executor_key = 'affiliate_dash' + ORDER BY id DESC + LIMIT 1`, + [retryPendingItemId], + ) + assert.equal(retryPendingTask.rows[0]?.task_status, 'retry_pending') const blockedRetry = await reconcilePendingCarriers({ orderItemIds: [retryPendingItemId], includeAssignedMissingCarrier: true, @@ -972,7 +987,7 @@ test( 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 + FROM v_order_item_current oi WHERE oi.id = $1`, [retryPendingItemId], ) @@ -985,7 +1000,7 @@ test( ) assert.equal(retryPendingState.rows[0]?.task_count, 1) await query( - `UPDATE order_items SET fulfillment_locked = TRUE, updated_at = NOW() WHERE id = $1`, + `UPDATE fulfillments SET locked = TRUE, updated_at = NOW() WHERE order_item_id = $1`, [retryPendingItemId], ) const unlockedPendingGroup = await unlockPendingChannelGroup({ @@ -993,7 +1008,7 @@ test( }) assert.equal(unlockedPendingGroup.updated, 1) const unlockedPendingItem = await query<{ fulfillment_locked: boolean }>( - `SELECT fulfillment_locked FROM order_items WHERE id = $1`, + `SELECT fulfillment_locked FROM v_order_item_current WHERE id = $1`, [retryPendingItemId], ) assert.equal(unlockedPendingItem.rows[0]?.fulfillment_locked, false) @@ -1007,14 +1022,14 @@ test( [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, + `UPDATE fulfillments + SET status = 'pending', + channel = NULL, + source = 'auto_mapping', + pending_reason = NULL, + pending_detail = NULL, updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [retryPendingItemId], ) await assert.rejects( @@ -1035,7 +1050,7 @@ test( 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 + FROM v_order_item_current oi WHERE oi.id = $1`, [retryPendingItemId], ) diff --git a/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts b/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts deleted file mode 100644 index ee9b0051..00000000 --- a/apps/backend/src/services/fulfillment/fulfillment-shadow-model.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -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', -) -const configSyncMigration = readFileSync( - new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url), - 'utf8', -) -const routeCutoverMigration = readFileSync( - new URL('../../db/migrations/161_move_shadow_sync_to_sku_routes.sql', 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(configSyncMigration.includes('trg_manual_fulfillment_config_shadow')) - assert.ok(configSyncMigration.includes('sync_fulfillment_shadow(item.id)')) - assert.ok(routeCutoverMigration.includes('trg_sku_route_fulfillment_shadow')) - assert.ok(routeCutoverMigration.includes('sync_fulfillment_shadow_for_sku_route')) - assert.ok( - routeCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow'), - ) -}) - -test('人工履约配置不能关联其他来源商品', () => { - assert.ok(configSyncMigration.includes('trg_manual_config_product_source')) - assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'")) - assert.ok(configSyncMigration.includes("ERRCODE = '23514'")) - assert.ok(configSyncMigration.includes('BEFORE INSERT OR UPDATE ON manual_fulfillment_configs')) - assert.ok(configSyncMigration.includes('config.enabled = TRUE')) - assert.ok(configSyncMigration.includes("SET status = 'active'")) -}) - -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 index fda1f1f3..024832bc 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.integration.test.ts @@ -116,10 +116,10 @@ test( assert.equal((await listFulfillmentStateEvents(orderItemId)).length, 1) await query( - `UPDATE order_items - SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE), + `UPDATE fulfillments + SET pending_detail = jsonb_build_object('legacyWrite', TRUE), updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [orderItemId], ) events = await listFulfillmentStateEvents(orderItemId) @@ -128,10 +128,10 @@ test( assert.equal(events[0]?.actor_type, 'system') await query( - `UPDATE order_items - SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE), + `UPDATE fulfillments + SET pending_detail = jsonb_build_object('legacyWrite', TRUE), updated_at = NOW() - WHERE id = $1`, + WHERE order_item_id = $1`, [orderItemId], ) events = await listFulfillmentStateEvents(orderItemId) @@ -193,7 +193,7 @@ test( attributes_json, created_at, updated_at ) VALUES ($1, $2, '测试', '件', 'active', '{}'::jsonb, NOW(), NOW()) RETURNING id`, - [`test:ff-state-${suffix}`, `状态事件商品-${suffix}`], + [`man:ff-state-${suffix}`, `状态事件商品-${suffix}`], ) productId = Number(product.rows[0]!.id) const config = await query<{ id: number }>( diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts b/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts index fa808a78..d624aa5f 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.test.ts @@ -7,6 +7,7 @@ function read(relativePath: string) { } const migration = read('../../db/migrations/140_fulfillment_state_events.sql') +const cutoverMigration = read('../../db/migrations/163_cutover_fulfillment_truth.sql') const stateService = read('./fulfillment-state-service.ts') const channelAssign = read('./channel-assign-service.ts') const carrierReconciliation = read('./carrier-reconciliation-service.ts') @@ -32,8 +33,10 @@ test('履约状态事件表记录前后状态并禁止应用修改', () => { assert.ok(migration.includes('fulfillment_state_events is append-only')) }) -test('状态写入口串行锁订单项,并在写入前校验期望状态', () => { - assert.ok(stateService.includes('SELECT * FROM order_items WHERE id = $1 FOR UPDATE')) +test('状态写入口串行锁正式履约记录,并在写入前校验期望状态', () => { + assert.ok(stateService.includes('FROM fulfillments')) + assert.ok(stateService.includes('FOR UPDATE')) + 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')) @@ -69,8 +72,18 @@ test('履约配置和标准商品关联统一通过状态服务的映射接口 assert.ok(stateService.includes('onlyWhenCanonicalProductNull')) }) -test('任务创建先锁订单项并复用已有活跃尝试', () => { - assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE')) +test('任务创建先锁正式履约记录并复用已有活跃尝试', () => { + assert.ok(taskRepo.includes('SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE')) assert.ok(taskRepo.includes('task_status NOT IN (')) assert.ok(taskRepo.includes('ORDER BY is_current DESC, id DESC')) }) + +test('订单项状态列冻结,读模型直接投影正式履约表', () => { + assert.ok( + cutoverMigration.includes('RENAME COLUMN fulfillment_status TO legacy_fulfillment_status'), + ) + assert.ok(cutoverMigration.includes('CREATE VIEW v_order_item_current')) + assert.ok(cutoverMigration.includes('FROM fulfillments fulfillment')) + assert.ok(cutoverMigration.includes('DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT)')) + assert.ok(cutoverMigration.includes('CREATE TRIGGER trg_fulfillments_state_event')) +}) diff --git a/apps/backend/src/services/fulfillment/fulfillment-state-service.ts b/apps/backend/src/services/fulfillment/fulfillment-state-service.ts index 2e04a758..0880c4ce 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-state-service.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-state-service.ts @@ -97,7 +97,6 @@ type StateColumn = { column: string value: unknown cast?: 'jsonb' - mergeSnapshot?: boolean coalesceCurrent?: boolean preserveCarrierIssue?: boolean } @@ -119,46 +118,46 @@ function buildPatchColumns(patch: FulfillmentStatePatch): StateColumn[] { if (!['pending', 'assigned', 'archived'].includes(patch.status)) { throw new TypeError(`invalid fulfillment status: ${patch.status}`) } - columns.push({ column: 'fulfillment_status', value: patch.status }) + columns.push({ column: '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 }) + columns.push({ column: 'channel', value: patch.channel }) } if (patch.source !== undefined) { - columns.push({ column: 'fulfillment_source', value: patch.source }) + columns.push({ column: 'source', value: patch.source }) } if (patch.locked !== undefined) { - columns.push({ column: 'fulfillment_locked', value: Boolean(patch.locked) }) + columns.push({ column: 'locked', value: Boolean(patch.locked) }) } if (patch.pendingReason !== undefined) { - columns.push({ column: 'fulfillment_pending_reason', value: patch.pendingReason || null }) + columns.push({ column: 'pending_reason', value: patch.pendingReason || null }) } if (patch.pendingDetail !== undefined) { columns.push({ - column: 'fulfillment_pending_detail', + column: 'pending_detail', value: patch.pendingDetail, cast: 'jsonb', }) } if (patch.pendingDetailUnlessCarrierIssue !== undefined) { columns.push({ - column: 'fulfillment_pending_detail', + column: 'pending_detail', value: patch.pendingDetailUnlessCarrierIssue, cast: 'jsonb', preserveCarrierIssue: true, }) } if (patch.reason !== undefined) { - columns.push({ column: 'fulfillment_reason', value: patch.reason || null }) + columns.push({ column: 'reason', value: patch.reason || null }) } if (patch.decidedBy !== undefined) { - columns.push({ column: 'fulfillment_decided_by', value: patch.decidedBy }) + columns.push({ column: 'decided_by', value: patch.decidedBy }) } if (patch.decidedAt !== undefined) { - columns.push({ column: 'fulfillment_decided_at', value: patch.decidedAt }) + columns.push({ column: 'decided_at', value: patch.decidedAt }) } if (patch.originalChannelIfNull !== undefined) { if (!isFulfillmentChannel(patch.originalChannelIfNull)) { @@ -197,26 +196,49 @@ function buildPatchColumns(patch: FulfillmentStatePatch): StateColumn[] { }) } 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) { + if ( + columns.length === 0 && + patch.itemSnapshotJson === undefined && + patch.itemSnapshotPatch === undefined + ) { throw new TypeError('fulfillment state patch must change at least one column') } return columns } +async function applyOrderItemSnapshotPatch( + executor: FulfillmentStateExecutor, + orderItemId: number, + patch: FulfillmentStatePatch, +) { + if (patch.itemSnapshotJson === undefined && patch.itemSnapshotPatch === undefined) { + return + } + if (patch.itemSnapshotJson !== undefined) { + await executor.query( + `UPDATE order_items + SET item_snapshot_json = $2::jsonb, + updated_at = $3::timestamptz + WHERE id = $1`, + [orderItemId, patch.itemSnapshotJson, new Date().toISOString()], + ) + return + } + await executor.query( + `UPDATE order_items + SET item_snapshot_json = + COALESCE(item_snapshot_json, '{}'::jsonb) || $2::jsonb, + updated_at = $3::timestamptz + WHERE id = $1`, + [orderItemId, patch.itemSnapshotPatch, new Date().toISOString()], + ) +} + async function setEventContext( executor: FulfillmentStateExecutor, transition: Pick & { @@ -261,71 +283,84 @@ export async function transitionFulfillmentStateWithExecutor( const columns = buildPatchColumns(input.patch) await setEventContext(executor, input) try { - const lockResult = await executor.query( - `SELECT * FROM order_items WHERE id = $1 FOR UPDATE`, + const lockResult = await executor.query<{ + id: number + order_item_id: number + status: string + channel: string | null + source: string + locked: boolean + }>( + `SELECT id, order_item_id, status, channel, source, locked + FROM fulfillments + WHERE order_item_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 - ) { + if (input.expected?.status !== undefined && current.status !== input.expected.status) { return null } if ( input.expected?.channel !== undefined && - (current.fulfillment_channel ?? null) !== input.expected.channel + (current.channel ?? null) !== input.expected.channel ) { return null } if ( input.expected?.source !== undefined && - (current.fulfillment_source || '') !== (input.expected.source || '') + (current.source || '') !== (input.expected.source || '') ) { return null } if ( input.expected?.sourceNotIn && - input.expected.sourceNotIn.includes(String(current.fulfillment_source || '')) + input.expected.sourceNotIn.includes(String(current.source || '')) ) { return null } if ( input.expected?.locked !== undefined && - Boolean(current.fulfillment_locked) !== Boolean(input.expected.locked) + Boolean(current.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) + if (columns.length > 0) { + const params: unknown[] = [] + const assignments = columns.map((entry) => { + params.push(entry.value) + let expression = `$${params.length}` + if (entry.cast) expression += `::${entry.cast}` + if (entry.preserveCarrierIssue) { + expression = + `CASE WHEN COALESCE(pending_detail->>'carrierIssue', '') ` + + `NOT IN ('', 'carrier_not_attempted') ` + + `THEN 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(current.id) + await executor.query( + `UPDATE fulfillments + SET ${assignments.join(', ')} + WHERE id = $${params.length}`, + params, + ) + } + + await applyOrderItemSnapshotPatch(executor, orderItemId, input.patch) + await executor.query(`SELECT refresh_fulfillment_derived($1)`, [orderItemId]) const updated = await executor.query( - `UPDATE order_items - SET ${assignments.join(', ')} - WHERE id = $${params.length} - RETURNING *`, - params, + `SELECT * FROM v_order_item_current WHERE id = $1`, + [orderItemId], ) return updated.rows[0] || null } finally { @@ -409,21 +444,21 @@ export async function assignFulfillmentChannelsWithExecutor( ...(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, + const result = await executor.query<{ order_item_id: number }>( + `UPDATE fulfillments fulfillment + SET channel = input.channel, + status = 'assigned', + source = input.source, + decided_at = NOW(), + pending_reason = NULL, + 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.*`, + WHERE fulfillment.order_item_id = input.order_item_id + AND fulfillment.status = 'pending' + AND (fulfillment.locked = FALSE OR $4::boolean) + RETURNING fulfillment.order_item_id`, [ assignments.map((row) => row.orderItemId), assignments.map((row) => row.channel), @@ -431,7 +466,21 @@ export async function assignFulfillmentChannelsWithExecutor( input.includeLocked === true, ], ) - return result.rows + const updatedIds = result.rows.map((row) => Number(row.order_item_id)) + if (updatedIds.length === 0) return [] + await executor.query( + `SELECT refresh_fulfillment_derived(order_item_id) + FROM fulfillments + WHERE order_item_id = ANY($1::bigint[])`, + [updatedIds], + ) + const updated = await executor.query( + `SELECT * FROM v_order_item_current + WHERE id = ANY($1::bigint[]) + ORDER BY id`, + [updatedIds], + ) + return updated.rows } finally { await clearEventContext(executor) } @@ -487,14 +536,14 @@ export async function attachFulfillmentConfigurationsWithExecutor( mappings.map((row) => row.requireExpectedProduct), mappings.map((row) => row.expectedCanonicalProductId), ] - const where = ['item.id = input.order_item_id'] + const where = ['item.order_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}`) + where.push(`item.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}`) + where.push(`item.channel IS NOT DISTINCT FROM $${params.length}`) } if (input.onlyWhenConfigNull) { where.push('item.fulfillment_config_id IS NULL') @@ -514,8 +563,8 @@ export async function attachFulfillmentConfigurationsWithExecutor( params.push(new Date().toISOString()) const updatedAtParam = params.length - const result = await executor.query( - `UPDATE order_items item + const updatedIds = await executor.query<{ order_item_id: number }>( + `UPDATE fulfillments item SET fulfillment_config_id = CASE WHEN input.set_config THEN input.config_id ELSE item.fulfillment_config_id @@ -545,9 +594,23 @@ export async function attachFulfillmentConfigurationsWithExecutor( expected_canonical_product_id ) WHERE ${where.join(' AND ')} - RETURNING item.*`, + RETURNING item.order_item_id`, params, ) + const ids = updatedIds.rows.map((row) => Number(row.order_item_id)) + if (ids.length === 0) return [] + await executor.query( + `SELECT refresh_fulfillment_derived(order_item_id) + FROM fulfillments + WHERE order_item_id = ANY($1::bigint[])`, + [ids], + ) + const result = await executor.query( + `SELECT * FROM v_order_item_current + WHERE id = ANY($1::bigint[]) + ORDER BY id`, + [ids], + ) return result.rows } diff --git a/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts b/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts new file mode 100644 index 00000000..a2812b11 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-truth-model.test.ts @@ -0,0 +1,89 @@ +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', +) +const configSyncMigration = readFileSync( + new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url), + 'utf8', +) +const routeCutoverMigration = readFileSync( + new URL('../../db/migrations/161_move_shadow_sync_to_sku_routes.sql', import.meta.url), + 'utf8', +) +const truthCutoverMigration = readFileSync( + new URL('../../db/migrations/163_cutover_fulfillment_truth.sql', import.meta.url), + 'utf8', +) +const insertSkuMigration = readFileSync( + new URL('../../db/migrations/166_fulfillment_insert_resolved_sku.sql', 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('迁移 163 删除影子双写并直接维护正式履约真源', () => { + assert.ok( + truthCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_order_items_fulfillment_shadow'), + ) + assert.ok( + truthCutoverMigration.includes('DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT)'), + ) + assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_fulfillments_state_event')) + assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_fulfillment_tasks_attempt')) + assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_work_orders_attempt')) + assert.ok(truthCutoverMigration.includes('CREATE VIEW v_order_item_current')) +}) + +test('人工配置和路由变化直接刷新正式履约投影', () => { + assert.ok(truthCutoverMigration.includes('trg_manual_config_refresh_fulfillments')) + assert.ok(truthCutoverMigration.includes('trg_sku_route_refresh_fulfillments')) + assert.ok(truthCutoverMigration.includes('refresh_fulfillment_derived')) + assert.ok( + routeCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow'), + ) +}) + +test('人工履约配置不能关联其他来源商品', () => { + assert.ok(configSyncMigration.includes('trg_manual_config_product_source')) + assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'")) + assert.ok(configSyncMigration.includes("ERRCODE = '23514'")) + assert.ok(configSyncMigration.includes('BEFORE INSERT OR UPDATE ON manual_fulfillment_configs')) + assert.ok(configSyncMigration.includes('config.enabled = TRUE')) + assert.ok(configSyncMigration.includes("SET status = 'active'")) +}) + +test('履约读模型直接投影正式表,订单项旧列冻结', () => { + assert.ok( + truthCutoverMigration.includes('RENAME COLUMN fulfillment_status TO legacy_fulfillment_status'), + ) + assert.ok( + truthCutoverMigration.includes( + 'RENAME COLUMN canonical_product_id TO legacy_canonical_product_id', + ), + ) + assert.ok(truthCutoverMigration.includes('FROM fulfillments fulfillment')) + assert.ok(truthCutoverMigration.includes('FROM fulfillment_attempts attempt')) + assert.ok(insertSkuMigration.includes('COALESCE(resolve_internal_sku')) +}) + +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/internal-sku-routing.integration.test.ts b/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts index 37f236c2..97fb8ed0 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-routing.integration.test.ts @@ -30,7 +30,7 @@ test( WHERE trigger_name IN ( 'trg_sync_sku_channel_routes', 'trg_internal_sku_channel_shadow', - 'trg_sku_route_fulfillment_shadow' + 'trg_sku_route_refresh_fulfillments' ) ORDER BY event_object_table, trigger_name `, @@ -38,7 +38,7 @@ test( assert.deepEqual(triggers.rows, [ { event_object_table: 'sku_channel_routes', - trigger_name: 'trg_sku_route_fulfillment_shadow', + trigger_name: 'trg_sku_route_refresh_fulfillments', }, ]) diff --git a/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts b/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts index 1a890fb4..c2973781 100644 --- a/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts +++ b/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts @@ -35,7 +35,7 @@ export async function retryPendingWorkOrderRouting( if (internalSku) params.push(internalSku) const pending = await query( `SELECT oi.* - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status = 'pending' AND EXISTS ( SELECT 1 @@ -98,7 +98,7 @@ export async function retryPendingWorkOrderRouting( }) const remaining = await query<{ count: number }>( `SELECT COUNT(*)::int AS count - FROM order_items + FROM v_order_item_current WHERE id = ANY($1::bigint[]) AND fulfillment_status = 'pending'`, [ids], ) 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 59a2681b..1f3a705d 100644 --- a/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts +++ b/apps/backend/src/services/fulfillment/voucher-consume-queue-service.ts @@ -212,7 +212,7 @@ export async function scheduleVoucherConsumeForOrderItems( o.provider, o.platform, o.platform_order_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = ANY($1::bigint[]) AND oi.fulfillment_status = 'assigned' @@ -275,7 +275,7 @@ export async function scheduleVoucherConsumeFallbackForOrderItems( o.provider, o.platform, o.platform_order_id - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id WHERE oi.id = ANY($1::bigint[]) AND oi.fulfillment_status = 'pending' diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 92793973..78153313 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -317,7 +317,7 @@ export async function listOpen91Orders({ 'quantity', oi.quantity, 'snapshot', oi.item_snapshot_json ) ORDER BY oi.id ASC) AS items - FROM order_items oi + FROM v_order_item_current oi WHERE oi.order_id = o.id ) item_summary ON TRUE LEFT JOIN LATERAL ( diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index 2a0dfd62..f171afc3 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts @@ -131,7 +131,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { SELECT oi.id, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key, cfg.id AS config_id - FROM order_items oi + FROM v_order_item_current 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 @@ -169,7 +169,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ` SELECT oi.id, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NULL @@ -210,7 +210,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ` SELECT oi.id, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NOT NULL @@ -257,7 +257,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ` SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku - FROM order_items oi + FROM v_order_item_current oi LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') @@ -316,7 +316,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ` SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code, resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku - FROM order_items oi + FROM v_order_item_current oi WHERE oi.fulfillment_status = 'assigned' AND ( EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled') @@ -407,7 +407,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis SELECT DISTINCT ON (oi.id) oi.id AS order_item_id, cfg.id AS config_id - FROM order_items oi + FROM v_order_item_current 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 @@ -456,7 +456,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis 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 + JOIN v_order_item_current oi ON oi.id = i.order_item_id WHERE ce.reason = 'carrier_missing' AND ce.status <> 'resolved' AND ( @@ -479,7 +479,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis 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 + JOIN v_order_item_current oi ON oi.id = i.order_item_id WHERE ce.reason = 'manual_config_missing' AND ce.status <> 'resolved' AND oi.fulfillment_config_id IS NOT NULL @@ -493,7 +493,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis 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 + JOIN v_order_item_current oi ON oi.id = i.order_item_id WHERE ce.reason = 'manual_cost_missing' AND ce.status <> 'resolved' AND EXISTS ( diff --git a/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts b/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts index 2ea08d82..3facc21f 100644 --- a/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts +++ b/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts @@ -210,7 +210,7 @@ export async function listAdminKuaishouMatchSources(payload: JsonObject = {}) { oi.item_snapshot_json, oi.created_at, oi.updated_at - FROM order_items oi + FROM v_order_item_current oi JOIN orders o ON o.id = oi.order_id ORDER BY oi.id DESC LIMIT $1 diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index bc1b15f3..0318780e 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,7 +3,7 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 1.0 +version: 2.0 updated_at: 2026-11-08 --- @@ -12,7 +12,7 @@ updated_at: 2026-11-08 > 状态:**唯一权威文档**。 > 最后校准:2026-11-08。 > 生产基线:迁移 `146`。 -> 开发基线:迁移 `162`(仅本地,尚未部署生产)。 +> 开发基线:迁移 `166`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -76,7 +76,7 @@ updated_at: 2026-11-08 成本、事实和总账不得反向修改订单、路由、载体或商品来源。 7. **派生数据必须可重建。** - 成本快照、销售事实、总账、异常队列、投影和影子履约模型都必须能够从原始订单、 + 成本快照、销售事实、总账、异常队列和读模型都必须能够从原始订单、 输入配置和事件重算。 8. **页面与统计使用同一读模型。** @@ -106,8 +106,10 @@ updated_at: 2026-11-08 - 计算成本。 - 决定标准商品。 -订单项上的 `fulfillment_*`、`canonical_product_id` 等字段在过渡期保留, -但只能由对应领域服务通过兼容投影维护,不允许入单服务自行猜测。 +`order_items` 只保存订单项与平台商品事实。迁移 `163` 后,重复的履约状态、 +渠道、配置关联和商品归一字段已改名为 `legacy_*` 冻结留档; +运行时不得读取或写入这些旧列。订单项履约状态统一从 `v_order_item_current` 或 +`fulfillments` 读取。 ### 3.2 商品身份域 @@ -185,18 +187,29 @@ on_account ### 3.4 履约执行域 -当前 `order_items.fulfillment_channel/status` 和 `v_fulfillment_item_state` -属于过渡读模型。目标模型使用: +唯一真源为 `fulfillments`、`fulfillment_attempts`、`fulfillment_issues`。 +`v_order_item_current` 是订单项与正式履约的一对一读投影; +`v_fulfillment_item_state` 是履约队列专用读模型。两者都不得回退 `order_items.legacy_*`。 ```text fulfillments id order_item_id - internal_sku_id - route_status -- unrouted / configured / locked + order_id + internal_sku + status -- pending / assigned / archived channel + source + locked + pending_reason + pending_detail + fulfillment_config_id + canonical_product_id + route_status -- unrouted / configured carrier_status -- not_required / missing / creating / ready / failed / terminal - current_attempt_id + current_attempt_type -- task / work_order / 空 + current_attempt_source_id + current_attempt_status issue_code issue_category retryable @@ -208,7 +221,7 @@ fulfillment_attempts id fulfillment_id attempt_no - attempt_type -- task / work_order / manual + attempt_type -- task / work_order source_id status result_code @@ -234,6 +247,14 @@ fulfillment_issues resolved_at ``` +状态写入规则: + +- 状态服务锁 `fulfillments`,不再锁或更新 `order_items` 履约状态。 +- 配置关联写入 `fulfillments.fulfillment_config_id / canonical_product_id`。 +- 任务和工单只追加 `fulfillment_attempts`,由触发器刷新当前尝试摘要。 +- 状态变化写入 `fulfillment_state_events`;事件只禁止 UPDATE,父记录删除时允许级联清理。 +- `v_order_item_current` 以正式表字段覆盖订单项旧名,保证所有业务查询只有一个读口径。 + 状态必须正交: ```text @@ -311,7 +332,7 @@ finance_journal_* -- 总账凭证,可重建 2026-11-08 生产只读核对结果: -- 生产迁移 `146`,本地开发基线迁移 `162`。 +- 生产迁移 `146`,本地开发基线迁移 `166`。 - 启用标准商品 `1127`:`aff:` 30、`tpl:` 903、`tpv:` 46、`man:` 148。 - `man:` 商品中只有 105 个当前主渠道为人工;41 个已走其他主渠道,2 个无主渠道。 - 人工页头部显示 `107`,商品列表显示 `148`,相差的 41 个就是跨渠道旧商品。 @@ -348,6 +369,8 @@ finance_journal_* -- 总账凭证,可重建 - 平台售后、退款、支付事实 - `work_orders`、`work_order_shares`、`work_order_events` - `fulfillment_tasks` 中真实执行与结果记录 +- `fulfillments`、`fulfillment_attempts`、`fulfillment_issues` +- `fulfillment_state_events` - `worker_wallet_ledgers`、`fund_accounts` 的真实资金流水 - `admin_audit_logs` - 财务期间、已确认退款和真实资金账户 @@ -373,7 +396,6 @@ A 级数据只能追加新事件或做字段兼容迁移,不能为了“模型 - `cost_exceptions` - `sales_daily_facts` - `finance_journal_entries` / `finance_journal_lines` -- `fulfillments` / `fulfillment_attempts` / `fulfillment_issues` - `v_fulfillment_item_state` 及其依赖投影 - 商品统计投影、渠道统计投影、页面汇总缓存 - 管道水位与失败重试状态 @@ -386,7 +408,7 @@ A 级数据只能追加新事件或做字段兼容迁移,不能为了“模型 - 已冻结的 `internal_sku_channels` - `sku_channel_rules` 中由 `internal_sku_mappings` 和 `sku_channel_routes` 取代的规则 - `manual_sku_registry` 中可由配置重建的登记行 -- 订单项上的重复履约结果字段,在正式模型切换后删除 +- 订单项上的 `legacy_*` 重复履约结果字段,在审计留存期结束后删除 - 商品表中的历史来源前缀判断代码 - 旧路由补丁脚本和一次性迁移工具 @@ -505,12 +527,12 @@ has_active_cost | 阶段 | 状态 | 是否改生产数据 | | --- | --- | --- | | 0 口径冻结与文档收口 | 已完成 | 否 | -| 1 只读审计与领域约束 | 本地进行中 | 否 | -| 2 建立目标身份与投影 | 商品侧本地完成 | 只新增兼容结构 | +| 1 只读审计与领域约束 | 本地完成 | 否 | +| 2 建立目标身份与投影 | 本地完成 | 只新增结构 | | 3 迁移路由与商品配置 | 本地完成,生产未执行 | 是,需备份 | -| 4 重建履约模型 | 本地准备中 | 是,需断代 | +| 4 重建履约模型 | 本地完成,生产未执行 | 是,需备份 | | 5 重建财务派生数据 | 未开始 | 是,需备份与对账 | -| 6 停止旧写路径并删除兼容层 | 未开始 | 是,需观察期结束 | +| 6 停止旧写路径并删除兼容层 | 履约侧已完成,全局未开始 | 是,需观察期结束 | ### 阶段 0:口径冻结与文档收口 @@ -580,7 +602,13 @@ assigned 且 carrier_status=missing 必须进入异常队列 - 迁移 `160`:商品身份全部补齐,运行硬切到 `sku_channel_routes`,删除旧同步触发器; `v_product_current` 不再回退 `source_kind`,`v_fulfillment_item_state` 不再读取旧表。 - 迁移 `161`:履约影子同步触发器从旧表迁移到 `sku_channel_routes`。 -- 迁移 `162`:删除旧履约只读视图和旧表最后一个触发器。 +- 迁移 `162`:删除旧路由只读视图和旧表最后一个触发器。 +- 迁移 `163`:履约状态硬切到 `fulfillments`;新增正式状态、配置关联和商品字段; + 任务/工单只维护尝试历史;订单项旧状态列改名冻结;建立 `v_order_item_current`。 +- 迁移 `164`:履约事件外键改为随正式履约记录级联删除。 +- 迁移 `165`:履约事件只阻止 UPDATE,数据库级联删除不再被 append-only 触发器阻断。 +- 迁移 `166`:无法解析内部 SKU 时,正式履约行的 `internal_sku` 统一落空字符串, + 并进入 `missing_internal_sku` 异常,而不是触发非空约束。 - 商品列表、总览、渠道映射和商品下拉统一读取商品当前渠道。 - 运行期代码停止写入 `product_channel_capabilities`,该表仅保留到兼容期结束。 - 只读审计:`npm run audit:product-routing -- --strict`。 @@ -588,8 +616,7 @@ assigned 且 carrier_status=missing 必须进入异常队列 - 自动/卡券/接单主渠道商品的人工配置均保留为显式 `fallback`,没有被误升为人工主渠道。 - 1 个无主路由、无人工配置、无启用映射的 `man:` 商品已归档,未删除历史引用。 - 旧 `internal_sku_channels` 已冻结:运行时不再读、不再写,也不再有视图或触发器依赖。 -- 空库从迁移 `001` 顺序执行到 `162` 已通过;新库只保留 - `sku_channel_routes` 影子同步触发器,旧表同步触发器不存在。 +- 空库从迁移 `001` 顺序执行到 `166` 已通过;新库不再保留任何旧履约影子同步触发器。 - 无主路由的活动商品会进入“未路由”,不自动猜测渠道。 当前本地仍需业务处理的数据: @@ -630,20 +657,26 @@ assigned 且 carrier_status=missing 必须进入异常队列 ### 阶段 4:重建履约模型 +状态:本地完成,生产未执行。 + 目标: - 以 A 级原始订单和真实载体为输入,重建 `fulfillments`、`fulfillment_attempts`、 `fulfillment_issues`。 -- 停止写入过渡字段。 +- 旧订单项履约状态字段已停止读写并冻结留档。 -工作: +本地产出: -1. 清空旧影子履约和派生异常。 -2. 回填当前履约单。 -3. 回填当前尝试与历史尝试。 -4. 生成结构化 issue。 -5. 对比新旧读模型。 -6. 切换后台与调度读路径。 +1. `fulfillments` 增加 `status / source / locked / pending_* / config / canonical_product` + 等正式字段,并从订单项存量状态一次性回填。 +2. 历史任务和工单补齐到 `fulfillment_attempts`,不再从订单项回调状态。 +3. `transitionFulfillmentStateWithExecutor`、渠道分配和配置关联全部改写正式表。 +4. `v_fulfillment_item_state` 只投影 `fulfillments + fulfillment_attempts + + fulfillment_issues + sku_channel_routes`。 +5. 全部业务读 SQL 切到 `v_order_item_current`,禁止直接读取订单项旧状态列。 +6. 状态事件、任务尝试、工单尝试和配置变化触发器统一围绕正式履约表工作。 +7. 真实 PostgreSQL 集成测试覆盖状态写入、幂等、事件留痕、当前尝试和配置关联。 +8. 本地存量库与空库 `001 → 166` 均完成硬切验证。 验收: @@ -651,9 +684,16 @@ assigned 且 carrier_status=missing 必须进入异常队列 - 所有 `assigned` 都有明确 `carrier_status` 或开放 issue。 - 退款、取消进入售后队列。 - 页面按钮作用域与后端动作一致。 +- 运行时对 `order_items.legacy_fulfillment_*` 和 `legacy_canonical_product_id` + 的读写点均为 `0`。 +- 旧 `sync_fulfillment_shadow*` 函数和旧影子同步触发器均为 `0`。 ### 阶段 5:重建财务派生数据 +本地提前发现并修复:销售凭证增量同步读取当日余额时,必须统计同一天全部 +`sales_revenue_adjustment / sales_refund_adjustment / sales_cost_adjustment`, +不能只按原始凭证的 `ref_id = 日期` 读取,否则重复同步会累计重复补记。 + 目标: - 保留 A 级订单、退款事实和 B 级配置。 @@ -679,11 +719,13 @@ product normalization ### 阶段 6:停止旧写路径并删除兼容层 +状态:履约域已在本地停止旧写路径;商品、成本、人工配置的全局兼容清理仍需后续完成。 + 只有前五个阶段持续稳定后才允许: - 删除 `product_channel_capabilities`。 - 删除 `manual_sku_registry` 的派生行。 -- 删除订单项重复渠道字段。 +- 删除订单项已冻结的 `legacy_*` 重复渠道字段。 - 删除商品前缀业务判断。 - 删除旧管道、旧同步脚本和兼容 API。