-- Petstore Phase 1:可执行回访任务与再次预约归因 -- 前置:20260802_create_store_customer_timeline.sql 已执行。 -- ReportLead 保留宠主同意/来源事实;FollowUpTask 承载门店操作状态。 CREATE TABLE t_follow_up_task ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, task_id VARCHAR(36) NOT NULL, store_id BIGINT NOT NULL, store_customer_id BIGINT NOT NULL, source_lead_id BIGINT NOT NULL, source_report_id BIGINT NULL, status VARCHAR(16) NOT NULL COMMENT 'pending | in_progress | completed | canceled', outcome VARCHAR(24) NULL COMMENT 'rebooked | not_interested | invalid_contact | unsubscribed', due_date DATE NOT NULL, assigned_user_id BIGINT NULL, attempt_count INT NOT NULL DEFAULT 0, last_attempt_outcome VARCHAR(24) NULL COMMENT 'no_answer | follow_later', last_contacted_at DATETIME NULL, last_contacted_by_user_id BIGINT NULL, closed_at DATETIME NULL, closed_by_user_id BIGINT NULL, rebooked_appointment_id BIGINT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, version BIGINT NOT NULL DEFAULT 0, CONSTRAINT uk_follow_up_task_public_id UNIQUE (task_id), INDEX idx_follow_up_store_status_due (store_id, status, due_date), INDEX idx_follow_up_customer_status (store_customer_id, status), INDEX idx_follow_up_lead_status (source_lead_id, status), INDEX idx_follow_up_rebook (rebooked_appointment_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店回访执行任务'; -- 只为仍待处理的历史留资建立开放任务;不得猜测已发送/取消记录的真实结果。 INSERT INTO t_follow_up_task ( task_id, store_id, store_customer_id, source_lead_id, source_report_id, status, outcome, due_date, assigned_user_id, attempt_count, last_attempt_outcome, last_contacted_at, last_contacted_by_user_id, closed_at, closed_by_user_id, rebooked_appointment_id, create_time, update_time, version ) SELECT UUID(), l.store_id, sc.id, l.id, l.report_id, 'pending', NULL, COALESCE(l.remind_date, CURRENT_DATE), NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, COALESCE(l.create_time, l.update_time, NOW()), COALESCE(l.update_time, l.create_time, NOW()), 0 FROM t_report_lead l JOIN t_store_customer sc ON sc.store_id = l.store_id AND sc.phone = l.phone AND sc.deleted = 0 WHERE l.store_id IS NOT NULL AND l.remind_status = 'pending'; -- 历史开放任务形成可靠的 task_created 事实;不复制手机号、IP、token 或微信标识。 INSERT INTO t_business_event ( event_id, event_type, event_version, store_id, store_customer_id, aggregate_type, aggregate_id, actor_user_id, actor_role, source, occurred_at, metadata_json, idempotency_key, create_time ) SELECT UUID(), 'follow_up_created', 1, t.store_id, t.store_customer_id, 'follow_up_task', t.id, NULL, NULL, 'migration', t.create_time, CONCAT('{"dueDate":"', t.due_date, '"}'), CONCAT('follow_up_created:', t.id), NOW() FROM t_follow_up_task t; -- 验证:以下五项必须全部为 0。 SELECT COUNT(*) AS pending_leads_without_open_task_count FROM t_report_lead l LEFT JOIN t_follow_up_task t ON t.source_lead_id = l.id AND t.status IN ('pending', 'in_progress') WHERE l.store_id IS NOT NULL AND l.remind_status = 'pending' AND t.id IS NULL; SELECT COUNT(*) AS invalid_follow_up_customer_scope_count FROM t_follow_up_task t LEFT JOIN t_store_customer sc ON sc.id = t.store_customer_id AND sc.store_id = t.store_id LEFT JOIN t_store_customer canonical ON canonical.id = CASE WHEN sc.deleted = 0 THEN sc.id ELSE sc.merged_into_store_customer_id END AND canonical.store_id = t.store_id AND canonical.deleted = 0 AND canonical.merged_into_store_customer_id IS NULL WHERE sc.id IS NULL OR canonical.id IS NULL; SELECT COUNT(*) AS invalid_follow_up_state_count FROM t_follow_up_task t WHERE t.status NOT IN ('pending', 'in_progress', 'completed', 'canceled') OR (t.status IN ('pending', 'in_progress') AND (t.outcome IS NOT NULL OR t.closed_at IS NOT NULL OR t.closed_by_user_id IS NOT NULL OR t.rebooked_appointment_id IS NOT NULL)) OR (t.status = 'pending' AND t.assigned_user_id IS NOT NULL) OR (t.status = 'in_progress' AND t.assigned_user_id IS NULL) OR (t.status = 'completed' AND (COALESCE(t.outcome, '') NOT IN ('rebooked', 'not_interested', 'invalid_contact') OR t.closed_at IS NULL OR t.closed_by_user_id IS NULL OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL OR t.attempt_count < 1)) OR (t.status = 'canceled' AND (COALESCE(t.outcome, '') <> 'unsubscribed' OR t.closed_at IS NULL OR t.closed_by_user_id IS NOT NULL)) OR (t.last_attempt_outcome IS NOT NULL AND (t.last_attempt_outcome NOT IN ('no_answer', 'follow_later') OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL)) OR (t.outcome = 'rebooked' AND t.rebooked_appointment_id IS NULL) OR (COALESCE(t.outcome, '') <> 'rebooked' AND t.rebooked_appointment_id IS NOT NULL) OR (t.rebooked_appointment_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM t_appointment a WHERE a.id = t.rebooked_appointment_id AND a.store_id = t.store_id AND a.deleted = 0 )) OR t.attempt_count < 0; SELECT COUNT(*) AS duplicate_open_follow_up_task_count FROM ( SELECT source_lead_id FROM t_follow_up_task WHERE status IN ('pending', 'in_progress') GROUP BY source_lead_id HAVING COUNT(*) > 1 ) duplicated; SELECT COUNT(*) AS follow_up_tasks_without_created_event_count FROM t_follow_up_task t LEFT JOIN t_business_event e ON e.idempotency_key = CONCAT('follow_up_created:', t.id) AND e.event_type = 'follow_up_created' AND e.store_id = t.store_id AND e.aggregate_type = 'follow_up_task' AND e.aggregate_id = t.id WHERE e.id IS NULL;