✨ 司机档案 ID 存量回填脚本与文本护栏测试

This commit is contained in:
2026-10-27 20:55:03 +08:00
parent 0869aa7a9d
commit 3ff2c82938
3 changed files with 768 additions and 0 deletions
@@ -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 回填脚本的文本护栏。
* <p>
* 仓库里没有连库的测试基座,回填脚本的正确性只能靠「读脚本、断言不变量」来守。
* 这里守的是「改脚本时最容易写坏、写坏了又不会报错」的几条:
* <ul>
* <li>只填 {@code driver_id IS NULL} 的行 —— 手滑把条件放宽成 {@code <= 0} 就会覆盖
* {@code -1}(无主哨兵)和已经派给别人的单。</li>
* <li>{@code driver_id = -1} 必须有一道显式排除 —— 只靠 {@code IS NULL} 挡住是隐式的,
* 后人看不出「-1 不能动」是需求,容易在重构时丢掉。</li>
* <li>跨表比较必须显式 {@code COLLATE} —— 运单/配载单侧是 {@code utf8mb4_0900_ai_ci},
* 司机档案侧是 {@code utf8mb4_general_ci},漏了就在执行时抛
* {@code ERROR 1267 Illegal mix of collations},而这只有真跑库才会暴露。</li>
* <li>不许 {@code SET driver_phone} / {@code SET update_time} —— 手机号是派单时的号码快照,
* 回填是「补归属」不是「改快照」;{@code update_time} 一改,业务上就分不清这张单是不是
* 真的被人动过。</li>
* <li>唯一命中({@code COUNT(*) = 1})与可重复执行({@code IF NOT EXISTS} / {@code INSERT IGNORE}
* / {@code IS NULL} 兜底)—— 同号多档案时填空会把单挂错人,重复跑必须零变更。</li>
* </ul>
* 这些断言只在脚本文本层面成立,不证明数据结果正确;数据结果由脚本第 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<String> 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<String> 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<Path> candidates = List.of(
Path.of("..", "..", "doc", "sql", "transport", fileName),
Path.of("doc", "sql", "transport", fileName));
List<String> 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<String> updateStatements(String script) {
List<String> 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();
}
}
@@ -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;
@@ -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;