diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/DriverIdBackfillSqlTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/DriverIdBackfillSqlTest.java new file mode 100644 index 00000000..968f1671 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/DriverIdBackfillSqlTest.java @@ -0,0 +1,289 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * 存量司机档案 ID 回填脚本的文本护栏。 + *

+ * 仓库里没有连库的测试基座,回填脚本的正确性只能靠「读脚本、断言不变量」来守。 + * 这里守的是「改脚本时最容易写坏、写坏了又不会报错」的几条: + *

+ * 这些断言只在脚本文本层面成立,不证明数据结果正确;数据结果由脚本第 2 节校验负责。 + */ +class DriverIdBackfillSqlTest { + + /** + * 每个用例跑两张表:运单一份、配载单一份,规则完全相同。 + */ + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("只更新 driver_id 为 NULL 的行") + void onlyFillsNullDriverId(String fileName, String table) { + List updates = updateStatements(script(fileName)); + assertFalse(updates.isEmpty(), fileName + " 里找不到 UPDATE 语句:回填脚本必须真的写 driver_id"); + + for (String update : updates) { + String where = whereClause(update).toUpperCase(); + assertTrue(where.contains("DRIVER_ID IS NULL"), + table + " 的 UPDATE 缺少 driver_id IS NULL:" + + "条件一放宽就会覆盖 -1(无主哨兵)与已派单的行。原文:" + squeeze(update)); + assertTrue(setClause(update).toUpperCase().contains("DRIVER_ID"), + table + " 的 UPDATE 没有写 driver_id:" + squeeze(update)); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("driver_id = -1 的哨兵行有一道显式排除") + void keepsSentinelRowsUntouched(String fileName, String table) { + List updates = updateStatements(script(fileName)); + assertFalse(updates.isEmpty(), fileName + " 里找不到 UPDATE 语句"); + + for (String update : updates) { + String where = whereClause(update); + assertTrue(where.contains("-1"), + table + " 的 UPDATE 没有出现 -1 保护:哨兵行会被回填覆盖。原文:" + squeeze(update)); + assertTrue(where.toUpperCase().contains("IS NOT TRUE") || where.toUpperCase().contains("NOT ("), + table + " 的 UPDATE 里 -1 只是一笔带过(例如 driver_id <> -1)," + + "对 NULL 行的比较结果是 NULL、条件不成立,要填的行会被整批漏掉。原文:" + squeeze(update)); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("跨表比较手机号与租户时显式 COLLATE") + void comparesAcrossTablesWithExplicitCollate(String fileName, String table) { + for (String line : body(fileName).split("\\R")) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || !trimmed.contains("=")) { + continue; + } + boolean tenantComparison = countOf(trimmed, ".tenant_id") >= 2; + boolean mobileComparison = trimmed.contains(".mobile") && trimmed.contains("driver_phone"); + if (!tenantComparison && !mobileComparison) { + continue; + } + assertTrue(trimmed.toUpperCase().contains("COLLATE"), + table + " 的跨表比较没有显式 COLLATE(会抛 ERROR 1267 Illegal mix of collations):" + trimmed); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("不改写 driver_phone 快照") + void neverRewritesPhoneSnapshot(String fileName, String table) { + for (String update : updateStatements(script(fileName))) { + assertFalse(setClause(update).toUpperCase().contains("DRIVER_PHONE"), + table + " 的 UPDATE 改写了 driver_phone:手机号是派单时的号码快照,回填只补归属。原文:" + squeeze(update)); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("不改写 update_time") + void neverRewritesUpdateTime(String fileName, String table) { + for (String update : updateStatements(script(fileName))) { + assertFalse(setClause(update).toUpperCase().contains("UPDATE_TIME"), + table + " 的 UPDATE 改写了 update_time:回填不是业务动作,改了就看不出单据是否真被人动过。原文:" + squeeze(update)); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("同号多档案不填:命中映射要求唯一") + void requiresUniqueMobileMatch(String fileName, String table) { + String body = body(fileName); + assertTrue(Pattern.compile("(?i)HAVING\\s+COUNT\\(\\*\\)\\s*=\\s*1").matcher(body).find(), + table + " 的回填映射没有 HAVING COUNT(*) = 1:同号多档案时会把单挂到其中一条档案上"); + assertTrue(Pattern.compile("(?i)COUNT\\(DISTINCT\\s+d\\.id\\)\\s*>\\s*1").matcher(body).find(), + table + " 没有同号多档案的冲突清单:跳过多少、为什么跳过必须可复核"); + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("可重复执行:存档表与命中映射都带重复执行保护") + void isRerunnable(String fileName, String table) { + String body = body(fileName); + assertTrue(Pattern.compile("(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS").matcher(body).find(), + table + " 的存档表没有 IF NOT EXISTS:第二次执行会报 1050 Table already exists,跑不完"); + assertTrue(Pattern.compile("(?i)INSERT\\s+IGNORE").matcher(body).find(), + table + " 的命中映射没有 INSERT IGNORE:第二次执行会撞主键,跑不完"); + + String insert = statement(body, "INSERT IGNORE"); + assertFalse(insert.isEmpty(), table + " 的 INSERT IGNORE 不是一条完整语句"); + assertTrue(insert.toUpperCase().contains("DRIVER_ID IS NULL"), + table + " 的命中映射没有 driver_id IS NULL 兜底:重复执行会给已经填过的行反复建映射"); + assertTrue(body.contains("ROW_COUNT()"), + table + " 的分批循环没有 ROW_COUNT() 退出条件:会死循环或只跑第一批"); + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("分批执行:循环里带 LIMIT 的批次大小") + void batchesInBoundedChunks(String fileName, String table) { + String body = body(fileName); + assertTrue(body.contains("REPEAT") || body.contains("WHILE"), + table + " 的回填没有分批循环:一次性 UPDATE 全表会长时间锁行"); + assertTrue(Pattern.compile("(?i)LIMIT\\s+(\\d+|v_\\w+)").matcher(body).find(), + table + " 的 LIMIT 后面不是批次大小的数字或变量:不是真正的分批"); + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("脚本按目录惯例分了 0 存档 / 1 订正 / 2 校验 / 3 回退") + void hasRequiredSections(String fileName, String table) { + String script = script(fileName); + assertTrue(script.contains("-- 0. 存档"), table + " 缺 -- 0. 存档 段"); + assertTrue(script.contains("-- 1. 订正"), table + " 缺 -- 1. 订正 段"); + assertTrue(script.contains("-- 2. 校验"), table + " 缺 -- 2. 校验 段"); + assertTrue(script.contains("-- 3. 回退"), table + " 缺 -- 3. 回退 段"); + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("取数与写入全在 SQL 内:不经过 JSON 中转") + void keepsSnowflakeIdsInsideSql(String fileName, String table) { + String body = body(fileName).toUpperCase(); + assertFalse(body.contains("JSON_"), + table + " 的脚本用了 JSON 函数中转:19 位雪花 ID 过 JSON 会丢精度,取数与写入必须在 SQL 内完成"); + } + + private static String script(String fileName) { + Path path = locate(fileName); + try { + return Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("读不了回填脚本:" + path, exception); + } + } + + private static Path locate(String fileName) { + List candidates = List.of( + Path.of("..", "..", "doc", "sql", "transport", fileName), + Path.of("doc", "sql", "transport", fileName)); + List tried = new ArrayList<>(); + for (Path candidate : candidates) { + if (Files.isRegularFile(candidate)) { + return candidate; + } + tried.add(candidate.toAbsolutePath().normalize().toString()); + } + fail("找不到回填脚本 " + fileName + "。surefire 的工作目录是模块目录(blade-service/blade-transport)," + + "脚本应在 ../../doc/sql/transport/ 下。已尝试:" + String.join(";", tried)); + return null; + } + + private static List updateStatements(String script) { + List updates = new ArrayList<>(); + Matcher matcher = Pattern.compile("(?is)\\bUPDATE\\b.*?;").matcher(stripComments(script)); + while (matcher.find()) { + updates.add(matcher.group()); + } + return updates; + } + + private static String statement(String body, String prefix) { + Matcher matcher = Pattern.compile("(?is)\\b" + Pattern.quote(prefix) + "\\b.*?;").matcher(body); + return matcher.find() ? matcher.group() : ""; + } + + private static String setClause(String update) { + String upper = update.toUpperCase(); + int set = upper.indexOf(" SET "); + if (set < 0) { + fail("UPDATE 里没有 SET:" + squeeze(update)); + } + int where = upper.indexOf(" WHERE ", set); + return where < 0 ? update.substring(set) : update.substring(set, where); + } + + private static String whereClause(String update) { + int where = update.toUpperCase().indexOf(" WHERE "); + if (where < 0) { + fail("UPDATE 里没有 WHERE:" + squeeze(update)); + } + return update.substring(where); + } + + private static String body(String fileName) { + return stripComments(script(fileName)); + } + + private static String stripComments(String script) { + return script.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("--[^\\r\\n]*", " "); + } + + private static int countOf(String text, String needle) { + int count = 0; + int index = text.indexOf(needle); + while (index >= 0) { + count++; + index = text.indexOf(needle, index + needle.length()); + } + return count; + } + + private static String squeeze(String sql) { + return sql.replaceAll("\\s+", " ").trim(); + } + +} diff --git a/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql b/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql new file mode 100644 index 00000000..8aa2da8a --- /dev/null +++ b/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql @@ -0,0 +1,238 @@ +-- ============================================================ +-- blade_loading_manage_driver_id_backfill_20261028.sql +-- ============================================================ +-- 存量配载单补司机档案 ID:把「归属」从手机号快照切到 blade_transport_driver.id +-- 写业务数据。第 0 节建存档表,第 1 节分批回填,第 2 节校验(只读,执行后必看),第 3 节回退。可重复执行。 +-- +-- 为什么要订正: +-- 票 01 之后配载单的归属键是 driver_id(blade_transport_driver.id),driver_phone 只是配载时的号码快照。 +-- 存量配载单全部只有号码、没有 driver_id(票 01 才加的列),司机端按档案 ID 查配载单会一张都查不到; +-- 按号码反查又会踩二次放号与改号,所以才需要先按「唯一命中」把能确定的补齐。 +-- 本脚本只处理「号码在本租户内唯一命中一条未删除档案」的配载单,其余留在原样、进清单等人工处置。 +-- +-- 判定与边界(严格按票面口径,不扩大): +-- ① 只动 driver_id IS NULL 的行。driver_id = -1 是「无主」哨兵,正数表示已有归属,两者一行都不动; +-- 第 1 节的 WHERE 里另加了一道显式排除((driver_id = -1) IS NOT TRUE), +-- 防止后人把条件放宽成 driver_id <= 0 时把哨兵一起覆盖。 +-- ② 唯一命中:同租户内、is_deleted = 0、mobile 与 driver_phone 相等的档案恰好 1 条。 +-- 命中 0 条 → 进「无法匹配」清单;命中 ≥2 条 → 进「冲突」清单。两类都跳过,不猜。 +-- 逻辑删除的档案不参与命中:档案删了以后重跑脚本,不能把这些单挂到废弃档案上。 +-- ③ 手机号为空、空白、含 * 的跳过(* 是脱敏占位,不是真号码)。 +-- ④ driver_phone、update_time 与其它任何列都不改,只写 driver_id。 +-- ⑤ 逻辑删除的配载单同样回填:票面口径只有 driver_id IS NULL,不额外收窄范围; +-- 这些单不会回到列表,回填对它们无副作用。 +-- ⑥ 跨表比较显式 COLLATE:blade_loading_manage.driver_phone / tenant_id 是 utf8mb4_0900_ai_ci, +-- blade_transport_driver.mobile / tenant_id 是 utf8mb4_general_ci; +-- 不显式 COLLATE 会抛 ERROR 1267 Illegal mix of collations,只有真跑库才会暴露。 +-- ⑦ 取数与写入全在 SQL 内完成,不经 JSON / 客户端中转:19 位雪花 ID 过 JSON 会丢精度。 +-- ⑧ 分批执行(默认每批 500 行),每批独立提交,中断后重跑从剩余 NULL 行继续。 +-- +-- 范围说明:与运单脚本同一口径,只做「手机号 → 档案唯一命中」这一条规则;其余留给人工处置。 +-- 幂等:命中映射先落存档表;重复执行时映射新增 0 行、回填更新 0 行,结果不变(第 2.2 节校验)。 +-- 执行方式(脚本里有 DELIMITER 与存储过程,按分号切句的 GUI 客户端会切错): +-- mysql -h127.0.0.1 -uroot -p transport < blade_loading_manage_driver_id_backfill_20261028.sql + +-- ------------------------------------------------------------ +-- 0. 存档(必须在订正之前执行;重复执行不会覆盖已有存档) +-- ------------------------------------------------------------ +-- 存档表同时是第 1 节的「命中映射」:一行 = 一个待回填的配载单 + 它唯一命中的档案 ID。 +-- 回填前先把映射定下来:回填只按映射写,跑之前就能看出「打算填谁」,回退也按它还原。 +CREATE TABLE IF NOT EXISTS blade_loading_manage_driver_id_bak_20261028 ( + loading_id bigint NOT NULL COMMENT '配载单ID', + driver_id_before bigint DEFAULT NULL COMMENT '回填前的司机ID(本脚本只处理 NULL)', + driver_id_after bigint DEFAULT NULL COMMENT '回填写入的司机档案ID(blade_transport_driver.id)', + driver_phone varchar(50) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写', + update_time_before datetime DEFAULT NULL COMMENT '回填前的更新时间,用来证明没有被改写', + PRIMARY KEY (loading_id), + KEY idx_driver_id_after (driver_id_after) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='20261028 配载单司机档案ID回填存档'; + +-- 命中映射:只收「号码唯一命中一条未删除档案」的 NULL 行。 +-- INSERT IGNORE + WHERE driver_id IS NULL:第二次执行不会重复建映射,已经填过的行不会再进映射。 +INSERT IGNORE INTO blade_loading_manage_driver_id_bak_20261028 (loading_id, driver_id_before, driver_id_after, driver_phone, update_time_before) +SELECT matched.biz_id, NULL, matched.driver_id, matched.driver_phone, matched.update_time + FROM ( + SELECT w.id AS biz_id, + MIN(d.id) AS driver_id, + w.driver_phone AS driver_phone, + w.update_time AS update_time + FROM blade_loading_manage w + JOIN blade_transport_driver d + ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0 + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + GROUP BY w.id, w.driver_phone, w.update_time + HAVING COUNT(*) = 1 + ) matched; + +-- ------------------------------------------------------------ +-- 1. 订正(分批回填,默认每批 500 行) +-- ------------------------------------------------------------ +-- 循环退出条件是「这一批没有更新任何行」,所以重复执行时第一批就是 0 行,脚本空转结束。 +-- 每批单独提交(不开长事务):大表上不会长时间持锁,中断后重跑从剩余 NULL 行继续。 +DELIMITER $$ +DROP PROCEDURE IF EXISTS backfill_blade_loading_manage_driver_id_20261028$$ +CREATE PROCEDURE backfill_blade_loading_manage_driver_id_20261028(IN p_batch_size INT) +BEGIN + DECLARE v_batch_size INT DEFAULT 500; + DECLARE v_affected INT DEFAULT 0; + + IF p_batch_size IS NOT NULL AND p_batch_size > 0 THEN + SET v_batch_size = p_batch_size; + END IF; + + CREATE TEMPORARY TABLE IF NOT EXISTS tmp_loading_manage_driver_id_20261028 ( + loading_id bigint NOT NULL, + PRIMARY KEY (loading_id) + ) ENGINE = InnoDB; + + REPEAT + -- 取批:只取还空着的行,填过的行不再入批,所以每一批都在推进,不会原地打转 + DELETE FROM tmp_loading_manage_driver_id_20261028; + + INSERT INTO tmp_loading_manage_driver_id_20261028 (loading_id) + SELECT b.loading_id + FROM blade_loading_manage_driver_id_bak_20261028 b + JOIN blade_loading_manage w ON w.id = b.loading_id + WHERE w.driver_id IS NULL + ORDER BY b.loading_id + LIMIT v_batch_size; + + UPDATE blade_loading_manage w + JOIN blade_loading_manage_driver_id_bak_20261028 b ON b.loading_id = w.id + JOIN tmp_loading_manage_driver_id_20261028 t ON t.loading_id = w.id + SET w.driver_id = b.driver_id_after + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND b.driver_id_after IS NOT NULL; + + SET v_affected = ROW_COUNT(); + UNTIL v_affected = 0 END REPEAT; + + DROP TEMPORARY TABLE IF EXISTS tmp_loading_manage_driver_id_20261028; +END$$ +DELIMITER ; + +CALL backfill_blade_loading_manage_driver_id_20261028(500); +DROP PROCEDURE IF EXISTS backfill_blade_loading_manage_driver_id_20261028; + +-- ------------------------------------------------------------ +-- 2. 校验(执行后必看;本节只有 SELECT,不动数据) +-- ------------------------------------------------------------ +-- 2.1 规模:存档多少行、还剩多少 NULL 行、正数归属多少行(跑前跑后各查一次) +-- remaining_null_rows 只会减少「本次命中」的那部分;差额应当等于第 2.7 节的命中行数。 +SELECT COUNT(*) AS archived_rows FROM blade_loading_manage_driver_id_bak_20261028; +SELECT COUNT(*) AS remaining_null_rows FROM blade_loading_manage WHERE driver_id IS NULL; +SELECT SUM(driver_id > 0) AS owned_rows, + SUM(driver_id = -1) AS sentinel_rows, + SUM(driver_id IS NULL) AS null_rows + FROM blade_loading_manage; + +-- 2.2 哨兵不变:跑前跑后各执行一次,四列必须完全相同(-1 一行都不能动) +SELECT COUNT(*) AS sentinel_rows, + SUM(id) AS sentinel_id_sum, + MIN(id) AS min_id, + MAX(id) AS max_id + FROM blade_loading_manage + WHERE driver_id = -1; + +-- 2.3 写入正确:本次映射的行,driver_id 必须等于映射值(期望 0 行) +SELECT COUNT(*) AS wrong_driver_id + FROM blade_loading_manage_driver_id_bak_20261028 b + JOIN blade_loading_manage w ON w.id = b.loading_id + WHERE NOT (w.driver_id <=> b.driver_id_after); + +-- 2.4 快照没有被改写:本次映射的行,driver_phone 与 update_time 必须与存档逐行一致(期望 0 行) +SELECT COUNT(*) AS snapshot_rewritten + FROM blade_loading_manage_driver_id_bak_20261028 b + JOIN blade_loading_manage w ON w.id = b.loading_id + WHERE NOT (w.driver_phone <=> b.driver_phone) + OR NOT (w.update_time <=> b.update_time_before); + +-- 2.5 冲突清单:同号多档案,这些行被跳过(期望 0 行;有输出就是「为什么没填」的答案) +SELECT w.driver_phone AS conflicting_phone, + COUNT(DISTINCT w.id) AS pending_rows, + COUNT(DISTINCT d.id) AS driver_profiles, + GROUP_CONCAT(DISTINCT d.id ORDER BY d.id) AS driver_profile_ids + FROM blade_loading_manage w + JOIN blade_transport_driver d + ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0 + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + GROUP BY w.driver_phone +HAVING COUNT(DISTINCT d.id) > 1; + +-- 2.6 无法匹配清单:号码在本租户内命中 0 条档案,这些行按规则跳过(等人工处置,脚本不猜) +SELECT w.driver_phone AS unmatched_phone, + COUNT(*) AS pending_rows + FROM blade_loading_manage w + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + AND NOT EXISTS ( + SELECT 1 + FROM blade_transport_driver d + WHERE d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0) + GROUP BY w.driver_phone + ORDER BY pending_rows DESC, unmatched_phone; + +-- 2.7 逐司机对照:按手机号可见单量 vs 按档案 ID 可见单量 +-- by_phone = 号码快照等于该档案号码的单量(旧口径,改号/二次放号会串) +-- by_driver_id = 档案 ID 等于该档案的单量(新口径) +-- phone_only_null = 号码对上、但归属还空着的单(本次应被回填的那部分) +-- phone_only_sentinel = 号码对上、但归属被标成 -1 无主的单(回填不碰,需要人工确认) +-- phone_only_other_owner = 号码对上、却已归属另一条档案的单(改号/换司机后号码被复用) +-- id_only_phone_differs = 已归属该档案、但号码快照不是该档案当前号码的单(改号后的历史快照) +SELECT d.id AS driver_id, + d.driver_name, + d.mobile, + (SELECT COUNT(*) FROM blade_loading_manage w WHERE w.driver_id = d.id) AS by_driver_id, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile) AS by_phone, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id IS NULL) AS phone_only_null, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id = -1) AS phone_only_sentinel, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id > 0 + AND w.driver_id <> d.id) AS phone_only_other_owner, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.driver_id = d.id + AND (w.driver_phone IS NULL + OR NOT (w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile))) AS id_only_phone_differs + FROM blade_transport_driver d + WHERE d.is_deleted = 0 + ORDER BY by_phone DESC, by_driver_id DESC, driver_id; + +-- ------------------------------------------------------------ +-- 3. 回退(仅在需要撤销本次回填时手工执行) +-- ------------------------------------------------------------ +-- 按存档表把本次写入的行还原成 NULL。只还原「当前值仍等于本次写入值」的行: +-- 回填之后又被人改派过的单不会被冲掉。 +-- UPDATE blade_loading_manage w +-- JOIN blade_loading_manage_driver_id_bak_20261028 b ON b.loading_id = w.id +-- SET w.driver_id = b.driver_id_before +-- WHERE w.driver_id <=> b.driver_id_after; +-- 回退只碰 driver_id,不碰 driver_phone / update_time。备份表确认不再需要时再删: +-- DROP TABLE blade_loading_manage_driver_id_bak_20261028; diff --git a/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql b/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql new file mode 100644 index 00000000..e42a82b9 --- /dev/null +++ b/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql @@ -0,0 +1,241 @@ +-- ============================================================ +-- blade_waybill_driver_id_backfill_20261028.sql +-- ============================================================ +-- 存量运单补司机档案 ID:把「归属」从手机号快照切到 blade_transport_driver.id +-- 写业务数据。第 0 节建存档表,第 1 节分批回填,第 2 节校验(只读,执行后必看),第 3 节回退。可重复执行。 +-- +-- 为什么要订正: +-- 票 01 之后运单的归属键是 driver_id(blade_transport_driver.id),driver_phone 只是派单时的号码快照。 +-- 存量运单大多只有号码、没有 driver_id,司机端按档案 ID 查单会整批漏掉它们; +-- 按号码反查又会踩二次放号与改号(本机库实测:黄宇富档案已改号 15577122721, +-- 名下 92 张单仍写着旧号 17587421208,其中 20 张 driver_id 为空、5 张能从接单司机推出来, +-- 剩下 15 张号码已经对不上任何档案——本脚本不猜,它们会出现在第 2.6 节清单里)。 +-- 本脚本只处理「号码在本租户内唯一命中一条未删除档案」的运单,其余留在原样、进清单等人工处置。 +-- +-- 判定与边界(严格按票面口径,不扩大): +-- ① 只动 driver_id IS NULL 的行。driver_id = -1 是「无主」哨兵,正数表示已有归属,两者一行都不动; +-- 第 1 节的 WHERE 里另加了一道显式排除((driver_id = -1) IS NOT TRUE), +-- 防止后人把条件放宽成 driver_id <= 0 时把哨兵一起覆盖。 +-- ② 唯一命中:同租户内、is_deleted = 0、mobile 与 driver_phone 相等的档案恰好 1 条。 +-- 命中 0 条 → 进「无法匹配」清单;命中 ≥2 条 → 进「冲突」清单。两类都跳过,不猜。 +-- 逻辑删除的档案不参与命中:档案删了以后重跑脚本,不能把这些单挂到废弃档案上。 +-- ③ 手机号为空、空白、含 * 的跳过(* 是脱敏占位,不是真号码)。 +-- ④ driver_phone、update_time 与其它任何列都不改,只写 driver_id。 +-- ⑤ 逻辑删除的运单同样回填:票面口径只有 driver_id IS NULL,不额外收窄范围; +-- 这些单不会回到列表,回填对它们无副作用。 +-- ⑥ 跨表比较显式 COLLATE:blade_waybill.driver_phone / tenant_id 是 utf8mb4_0900_ai_ci, +-- blade_transport_driver.mobile / tenant_id 是 utf8mb4_general_ci; +-- 不显式 COLLATE 会抛 ERROR 1267 Illegal mix of collations,只有真跑库才会暴露。 +-- ⑦ 取数与写入全在 SQL 内完成,不经 JSON / 客户端中转:19 位雪花 ID 过 JSON 会丢精度。 +-- ⑧ 分批执行(默认每批 500 行),每批独立提交,中断后重跑从剩余 NULL 行继续。 +-- +-- 范围说明:本脚本只做「手机号 → 档案唯一命中」这一条规则。 +-- 不引入 driver_accept_driver_id 推断,也不按姓名/历史号码猜归属:这些属于人工处置,在票面范围外。 +-- 幂等:命中映射先落存档表;重复执行时映射新增 0 行、回填更新 0 行,结果不变(第 2.2 节校验)。 +-- 执行方式(脚本里有 DELIMITER 与存储过程,按分号切句的 GUI 客户端会切错): +-- mysql -h127.0.0.1 -uroot -p transport < blade_waybill_driver_id_backfill_20261028.sql + +-- ------------------------------------------------------------ +-- 0. 存档(必须在订正之前执行;重复执行不会覆盖已有存档) +-- ------------------------------------------------------------ +-- 存档表同时是第 1 节的「命中映射」:一行 = 一个待回填的运单 + 它唯一命中的档案 ID。 +-- 回填前先把映射定下来:回填只按映射写,跑之前就能看出「打算填谁」,回退也按它还原。 +CREATE TABLE IF NOT EXISTS blade_waybill_driver_id_bak_20261028 ( + waybill_id bigint NOT NULL COMMENT '运单ID', + driver_id_before bigint DEFAULT NULL COMMENT '回填前的司机ID(本脚本只处理 NULL)', + driver_id_after bigint DEFAULT NULL COMMENT '回填写入的司机档案ID(blade_transport_driver.id)', + driver_phone varchar(50) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写', + update_time_before datetime DEFAULT NULL COMMENT '回填前的更新时间,用来证明没有被改写', + PRIMARY KEY (waybill_id), + KEY idx_driver_id_after (driver_id_after) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='20261028 运单司机档案ID回填存档'; + +-- 命中映射:只收「号码唯一命中一条未删除档案」的 NULL 行。 +-- INSERT IGNORE + WHERE driver_id IS NULL:第二次执行不会重复建映射,已经填过的行不会再进映射。 +INSERT IGNORE INTO blade_waybill_driver_id_bak_20261028 (waybill_id, driver_id_before, driver_id_after, driver_phone, update_time_before) +SELECT matched.biz_id, NULL, matched.driver_id, matched.driver_phone, matched.update_time + FROM ( + SELECT w.id AS biz_id, + MIN(d.id) AS driver_id, + w.driver_phone AS driver_phone, + w.update_time AS update_time + FROM blade_waybill w + JOIN blade_transport_driver d + ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0 + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + GROUP BY w.id, w.driver_phone, w.update_time + HAVING COUNT(*) = 1 + ) matched; + +-- ------------------------------------------------------------ +-- 1. 订正(分批回填,默认每批 500 行) +-- ------------------------------------------------------------ +-- 循环退出条件是「这一批没有更新任何行」,所以重复执行时第一批就是 0 行,脚本空转结束。 +-- 每批单独提交(不开长事务):大表上不会长时间持锁,中断后重跑从剩余 NULL 行继续。 +DELIMITER $$ +DROP PROCEDURE IF EXISTS backfill_blade_waybill_driver_id_20261028$$ +CREATE PROCEDURE backfill_blade_waybill_driver_id_20261028(IN p_batch_size INT) +BEGIN + DECLARE v_batch_size INT DEFAULT 500; + DECLARE v_affected INT DEFAULT 0; + + IF p_batch_size IS NOT NULL AND p_batch_size > 0 THEN + SET v_batch_size = p_batch_size; + END IF; + + CREATE TEMPORARY TABLE IF NOT EXISTS tmp_waybill_driver_id_20261028 ( + waybill_id bigint NOT NULL, + PRIMARY KEY (waybill_id) + ) ENGINE = InnoDB; + + REPEAT + -- 取批:只取还空着的行,填过的行不再入批,所以每一批都在推进,不会原地打转 + DELETE FROM tmp_waybill_driver_id_20261028; + + INSERT INTO tmp_waybill_driver_id_20261028 (waybill_id) + SELECT b.waybill_id + FROM blade_waybill_driver_id_bak_20261028 b + JOIN blade_waybill w ON w.id = b.waybill_id + WHERE w.driver_id IS NULL + ORDER BY b.waybill_id + LIMIT v_batch_size; + + UPDATE blade_waybill w + JOIN blade_waybill_driver_id_bak_20261028 b ON b.waybill_id = w.id + JOIN tmp_waybill_driver_id_20261028 t ON t.waybill_id = w.id + SET w.driver_id = b.driver_id_after + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND b.driver_id_after IS NOT NULL; + + SET v_affected = ROW_COUNT(); + UNTIL v_affected = 0 END REPEAT; + + DROP TEMPORARY TABLE IF EXISTS tmp_waybill_driver_id_20261028; +END$$ +DELIMITER ; + +CALL backfill_blade_waybill_driver_id_20261028(500); +DROP PROCEDURE IF EXISTS backfill_blade_waybill_driver_id_20261028; + +-- ------------------------------------------------------------ +-- 2. 校验(执行后必看;本节只有 SELECT,不动数据) +-- ------------------------------------------------------------ +-- 2.1 规模:存档多少行、还剩多少 NULL 行、正数归属多少行(跑前跑后各查一次) +-- remaining_null_rows 只会减少「本次命中」的那部分;差额应当等于第 2.7 节的命中行数。 +SELECT COUNT(*) AS archived_rows FROM blade_waybill_driver_id_bak_20261028; +SELECT COUNT(*) AS remaining_null_rows FROM blade_waybill WHERE driver_id IS NULL; +SELECT SUM(driver_id > 0) AS owned_rows, + SUM(driver_id = -1) AS sentinel_rows, + SUM(driver_id IS NULL) AS null_rows + FROM blade_waybill; + +-- 2.2 哨兵不变:跑前跑后各执行一次,四列必须完全相同(-1 一行都不能动) +SELECT COUNT(*) AS sentinel_rows, + SUM(id) AS sentinel_id_sum, + MIN(id) AS min_id, + MAX(id) AS max_id + FROM blade_waybill + WHERE driver_id = -1; + +-- 2.3 写入正确:本次映射的行,driver_id 必须等于映射值(期望 0 行) +SELECT COUNT(*) AS wrong_driver_id + FROM blade_waybill_driver_id_bak_20261028 b + JOIN blade_waybill w ON w.id = b.waybill_id + WHERE NOT (w.driver_id <=> b.driver_id_after); + +-- 2.4 快照没有被改写:本次映射的行,driver_phone 与 update_time 必须与存档逐行一致(期望 0 行) +SELECT COUNT(*) AS snapshot_rewritten + FROM blade_waybill_driver_id_bak_20261028 b + JOIN blade_waybill w ON w.id = b.waybill_id + WHERE NOT (w.driver_phone <=> b.driver_phone) + OR NOT (w.update_time <=> b.update_time_before); + +-- 2.5 冲突清单:同号多档案,这些行被跳过(期望 0 行;有输出就是「为什么没填」的答案) +SELECT w.driver_phone AS conflicting_phone, + COUNT(DISTINCT w.id) AS pending_rows, + COUNT(DISTINCT d.id) AS driver_profiles, + GROUP_CONCAT(DISTINCT d.id ORDER BY d.id) AS driver_profile_ids + FROM blade_waybill w + JOIN blade_transport_driver d + ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0 + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + GROUP BY w.driver_phone +HAVING COUNT(DISTINCT d.id) > 1; + +-- 2.6 无法匹配清单:号码在本租户内命中 0 条档案,这些行按规则跳过(等人工处置,脚本不猜) +SELECT w.driver_phone AS unmatched_phone, + COUNT(*) AS pending_rows + FROM blade_waybill w + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + AND NOT EXISTS ( + SELECT 1 + FROM blade_transport_driver d + WHERE d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0) + GROUP BY w.driver_phone + ORDER BY pending_rows DESC, unmatched_phone; + +-- 2.7 逐司机对照:按手机号可见单量 vs 按档案 ID 可见单量 +-- by_phone = 号码快照等于该档案号码的单量(旧口径,改号/二次放号会串) +-- by_driver_id = 档案 ID 等于该档案的单量(新口径) +-- phone_only_null = 号码对上、但归属还空着的单(本次应被回填的那部分) +-- phone_only_sentinel = 号码对上、但归属被标成 -1 无主的单(回填不碰,需要人工确认) +-- phone_only_other_owner = 号码对上、却已归属另一条档案的单(改号/换司机后号码被复用) +-- id_only_phone_differs = 已归属该档案、但号码快照不是该档案当前号码的单(改号后的历史快照) +SELECT d.id AS driver_id, + d.driver_name, + d.mobile, + (SELECT COUNT(*) FROM blade_waybill w WHERE w.driver_id = d.id) AS by_driver_id, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile) AS by_phone, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id IS NULL) AS phone_only_null, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id = -1) AS phone_only_sentinel, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id > 0 + AND w.driver_id <> d.id) AS phone_only_other_owner, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.driver_id = d.id + AND (w.driver_phone IS NULL + OR NOT (w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile))) AS id_only_phone_differs + FROM blade_transport_driver d + WHERE d.is_deleted = 0 + ORDER BY by_phone DESC, by_driver_id DESC, driver_id; + +-- ------------------------------------------------------------ +-- 3. 回退(仅在需要撤销本次回填时手工执行) +-- ------------------------------------------------------------ +-- 按存档表把本次写入的行还原成 NULL。只还原「当前值仍等于本次写入值」的行: +-- 回填之后又被人改派过的单不会被冲掉。 +-- UPDATE blade_waybill w +-- JOIN blade_waybill_driver_id_bak_20261028 b ON b.waybill_id = w.id +-- SET w.driver_id = b.driver_id_before +-- WHERE w.driver_id <=> b.driver_id_after; +-- 回退只碰 driver_id,不碰 driver_phone / update_time。备份表确认不再需要时再删: +-- DROP TABLE blade_waybill_driver_id_bak_20261028;