Jelajahi Sumber

fix(mdp): reconcile mdp_entity inbound defaults | server 1.0.554

S1/S2 的实体注册用裸 SQL INSERT INTO mdp_entity (...) ON DUPLICATE KEY
UPDATE,显式列清单不含 inbound_enabled / inbound_max_rows /
stale_guard_enabled。生产这三列 NOT NULL 且缺 DEFAULT,sql_mode 含
STRICT_TRANS_TABLES,于是持续报 Error 1364。MySQL 每次只报第一个缺失
列,故三列必须同批收口,否则要三次部署才收敛。

漂移成因不是 migration 写错:1.0.536 对三列都写了正确的 TYPE + DEFAULT
(:118/:124/:130),但 mdp_inbound_* 六表的 create_time 实测为
2026-09-15 23:03:31~33,由 SqlSugar CodeFirst 抢先创建;1.0.536 正式执行
是 2026-09-16 20:36:37,晚 21 小时,其 IF(@exist=0, ADD COLUMN ...) 守卫
整段 no-op。CodeFirst 按实体生成 int / 无 DEFAULT。

本脚本采用 TARGET-STATE RECONCILIATION:判定「定义是否等于目标」而非
「列是否存在」——后者正是 1.0.536 的失败模式。COLUMN_DEFAULT 比较用
NULL-safe 的 <=>:COLUMN_DEFAULT 为 NULL 时 `COLUMN_DEFAULT = '500'`
求值为 NULL 而非 FALSE,被 WHERE 按假丢弃导致漏检。实证:仅
inbound_max_rows 缺 DEFAULT 的环境下,= 谓词 drift=0(migration 报
Success 然后静默跳过),<=> 谓词 drift=1,而该环境实际 INSERT 确实报
1364。

算法在本地 3307 同构演练库实测:INSTANT 被拒(Need to rebuild the
table)、INPLACE 被拒(Cannot change column type INPLACE)、LOCK=NONE
被拒(COPY algorithm requires a lock)。故显式声明 ALGORITHM=COPY,
LOCK=SHARED,让锁行为在脚本内可见可审计。202 行实测 72ms,写阻塞约
72ms,读不受影响。

门禁前均附一条可读诊断 SELECT:SIGNAL 不支持 prepared statement,条件
成立时实际抛 ERROR 1295 而非预期消息(1.0.551/1.0.553 同 idiom,只因
门禁从未触发而未暴露)。fail-closed 仍成立,但需让日志先打印是哪道门禁。

范围:只动 mdp_entity 三列。不碰 uk_inbound_grant(1.0.553 已建,仅回归
断言)、不碰 R2-B 的 7 项 dormant drift、不碰任何 P0 对象。

演练:完整 fixture(六表同构、mdp_entity 202 行真实漂移态)下 migration
无错、verify ok=1;连跑三次幂等;人为退回单列后仍一次收敛。
YY968XX 2 hari lalu
induk
melakukan
a293498d4d

+ 9 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.553</AssemblyVersion>
-    <FileVersion>1.0.553</FileVersion>
-    <Version>1.0.553</Version>
+    <AssemblyVersion>1.0.554</AssemblyVersion>
+    <FileVersion>1.0.554</FileVersion>
+    <Version>1.0.554</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -898,6 +898,12 @@
     <None Update="UpdateScripts\1.0.553.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.554.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.554.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 148 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.554.sql

@@ -0,0 +1,148 @@
+-- ============================================================================
+-- 1.0.554 · R2-A:mdp_entity 三列 schema drift 收口
+--
+-- 背景(已取证):
+--   S1_MDP_SYNC_TRANSFORM / S2_MDP_SYNC_TRANSFORM 在服务启动期用裸 SQL
+--   INSERT INTO mdp_entity (...) ... ON DUPLICATE KEY UPDATE 注册实体,
+--   其显式列清单不含 inbound_enabled / inbound_max_rows / stale_guard_enabled。
+--   生产这三列为 NOT NULL 且缺 DEFAULT,sql_mode 含 STRICT_TRANS_TABLES,
+--   于是 MySQL 报 Error 1364: Field 'xxx' doesn't have a default value。
+--   MySQL 每次只报第一个缺失列,故三列必须同批收口,否则要三次部署才收敛。
+--
+-- 漂移成因(已取证,非 migration 写错):
+--   1.0.536 的 SQL 文本对三列都写了正确的 TYPE + DEFAULT(见其 :118/:124/:130),
+--   但 mdp_inbound_* 六表的 create_time 实测为 2026-09-15 23:03:31~33,
+--   由 SqlSugar CodeFirst 抢先创建;1.0.536 正式执行是 2026-09-16 20:36:37,
+--   晚 21 小时,其 IF(@exist=0, ADD COLUMN ...) 守卫整段 no-op。
+--   CodeFirst 按实体生成 int / 无 DEFAULT,与 contract 不符。
+--
+-- 设计原则:TARGET-STATE RECONCILIATION,不是 OBJECT-EXISTENCE MIGRATION。
+--   判定「定义是否等于目标」,而非「列是否存在」——后者正是 1.0.536 的失败模式。
+--   COLUMN_DEFAULT 比较必须用 NULL-safe 的 <=>:COLUMN_DEFAULT 为 NULL 时
+--   `COLUMN_DEFAULT = '500'` 求值为 NULL(非 FALSE),WHERE 按假丢弃 → 漏检。
+--   实证:仅 inbound_max_rows 缺 DEFAULT 的环境下,`=` 谓词 drift=0(静默跳过),
+--   `<=>` 谓词 drift=1(正确检出),而该环境实际 INSERT 确实报 1364。
+--
+-- 注释处理:drift 判定只比对 type / nullable / default,不比对 COMMENT,
+--   避免与 CodeFirst 的注释同步反复互踩。inbound_enabled 保留生产现有注释
+--   (信息量更大),另两列原为空,补回 1.0.536 的契约注释。
+--
+-- 算法:本地 3307 同构演练实测 —— INSTANT 被拒(Need to rebuild the table)、
+--   INPLACE 被拒(Cannot change column type INPLACE)、LOCK=NONE 被拒
+--   (COPY algorithm requires a lock)。故显式声明 ALGORITHM=COPY, LOCK=SHARED,
+--   让锁行为在脚本内可见可审计,且未来 MySQL 行为变化会显式报错而非静默改变。
+--   202 行实测耗时 72ms,写阻塞约 72ms,读不受影响。
+--
+-- 范围边界:只动 mdp_entity 三列。
+--   不碰 uk_inbound_grant(1.0.553 已建,仅回归断言)。
+--   不碰 R2-B 的 7 项 dormant drift。不碰任何 P0 对象。
+-- ============================================================================
+
+-- ── Step 0 · G1 表存在 ──────────────────────────────────────────────────────
+SET @t := (SELECT COUNT(*) FROM information_schema.TABLES
+            WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mdp_entity');
+SELECT IF(@t <> 1, 'R2A-G1 FAIL: mdp_entity 不存在', 'R2A-G1 ok') AS gate_g1;
+SET @sql := IF(@t <> 1,
+  "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'R2A-G1: mdp_entity 不存在,中止'",
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── Step 0 · G2 inbound_enabled data-fit(TINYINT 值域 + 无 NULL)───────────
+SET @g2_null := (SELECT COUNT(*) FROM `mdp_entity` WHERE `inbound_enabled` IS NULL);
+SET @g2_oor  := (SELECT COUNT(*) FROM `mdp_entity` WHERE `inbound_enabled` NOT BETWEEN -128 AND 127);
+SELECT @g2_null AS g2_null_rows, @g2_oor AS g2_out_of_range;
+SELECT IF(@g2_null <> 0 OR @g2_oor <> 0, 'R2A-G2 FAIL: inbound_enabled 存在 NULL 或超出 TINYINT 值域', 'R2A-G2 ok') AS gate_g2;
+SET @sql := IF(@g2_null <> 0 OR @g2_oor <> 0,
+  "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'R2A-G2: inbound_enabled 存在 NULL 或超出 TINYINT 值域,中止'",
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── Step 0 · G3 stale_guard_enabled data-fit ───────────────────────────────
+SET @g3_null := (SELECT COUNT(*) FROM `mdp_entity` WHERE `stale_guard_enabled` IS NULL);
+SET @g3_oor  := (SELECT COUNT(*) FROM `mdp_entity` WHERE `stale_guard_enabled` NOT BETWEEN -128 AND 127);
+SELECT @g3_null AS g3_null_rows, @g3_oor AS g3_out_of_range;
+SELECT IF(@g3_null <> 0 OR @g3_oor <> 0, 'R2A-G3 FAIL: stale_guard_enabled 存在 NULL 或超出 TINYINT 值域', 'R2A-G3 ok') AS gate_g3;
+SET @sql := IF(@g3_null <> 0 OR @g3_oor <> 0,
+  "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'R2A-G3: stale_guard_enabled 存在 NULL 或超出 TINYINT 值域,中止'",
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── Step 0 · G4 inbound_max_rows data-fit(类型不变,只查 NULL;不新增业务范围)──
+SET @g4_null := (SELECT COUNT(*) FROM `mdp_entity` WHERE `inbound_max_rows` IS NULL);
+SELECT @g4_null AS g4_null_rows;
+SELECT IF(@g4_null <> 0, 'R2A-G4 FAIL: inbound_max_rows 存在 NULL', 'R2A-G4 ok') AS gate_g4;
+SET @sql := IF(@g4_null <> 0,
+  "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'R2A-G4: inbound_max_rows 存在 NULL,中止'",
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── Step 0 · G5 行数基线(只记录,不作硬编码门禁)──────────────────────────
+SET @rows_before := (SELECT COUNT(*) FROM `mdp_entity`);
+SELECT @rows_before AS rows_before;
+
+-- ── Step 0 · G6 回归断言:uk_inbound_grant 必须仍存在且定义正确 ─────────────
+--    该索引由 1.0.553 创建,本脚本绝不修改它,只确认未被破坏。
+SET @uk_cols := (SELECT GROUP_CONCAT(`COLUMN_NAME` ORDER BY `SEQ_IN_INDEX` SEPARATOR ',')
+                   FROM information_schema.`STATISTICS`
+                  WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_inbound_grant'
+                    AND `INDEX_NAME` = 'uk_inbound_grant');
+SET @uk_unique := (SELECT MIN(`NON_UNIQUE`) FROM information_schema.`STATISTICS`
+                    WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_inbound_grant'
+                      AND `INDEX_NAME` = 'uk_inbound_grant');
+SELECT @uk_cols AS uk_inbound_grant_cols, @uk_unique AS uk_non_unique;
+SELECT IF(NOT (@uk_cols <=> 'access_key,entity_code') OR NOT (@uk_unique <=> 0), 'R2A-G6 FAIL: uk_inbound_grant 缺失或定义不符(应由 1.0.553 保证)', 'R2A-G6 ok') AS gate_g6;
+SET @sql := IF(NOT (@uk_cols <=> 'access_key,entity_code') OR NOT (@uk_unique <=> 0),
+  "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'R2A-G6: uk_inbound_grant 缺失或定义不符(应由 1.0.553 保证),中止'",
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── Step 1 · 目标态比对(NULL-safe <=>;只比 type / nullable / default)─────
+SET @drift := (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+  WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_entity' AND (
+     (`COLUMN_NAME` = 'inbound_enabled'
+        AND NOT (`COLUMN_TYPE` = 'tinyint' AND `IS_NULLABLE` = 'NO' AND `COLUMN_DEFAULT` <=> '0'))
+  OR (`COLUMN_NAME` = 'inbound_max_rows'
+        AND NOT (`COLUMN_TYPE` = 'int'     AND `IS_NULLABLE` = 'NO' AND `COLUMN_DEFAULT` <=> '500'))
+  OR (`COLUMN_NAME` = 'stale_guard_enabled'
+        AND NOT (`COLUMN_TYPE` = 'tinyint' AND `IS_NULLABLE` = 'NO' AND `COLUMN_DEFAULT` <=> '1'))
+  ));
+SELECT @drift AS drift_before;
+
+-- ── Step 2 · 仅 drift > 0 时执行;三列同一条 ALTER;显式算法与锁 ────────────
+SET @sql := IF(@drift > 0,
+  'ALTER TABLE `mdp_entity`
+     MODIFY COLUMN `inbound_enabled`     TINYINT NOT NULL DEFAULT 0
+            COMMENT ''入站开通开关。不走 CodeFirst 自动加列,以 UpdateScripts 为准。'',
+     MODIFY COLUMN `inbound_max_rows`    INT     NOT NULL DEFAULT 500
+            COMMENT ''单请求行数上限(快照类 2000)'',
+     MODIFY COLUMN `stale_guard_enabled` TINYINT NOT NULL DEFAULT 1
+            COMMENT ''乱序 stale 守卫'',
+     ALGORITHM=COPY, LOCK=SHARED',
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── Step 3 · 事后自断言:drift 必须归零 ────────────────────────────────────
+SET @drift_after := (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+  WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_entity' AND (
+     (`COLUMN_NAME` = 'inbound_enabled'
+        AND NOT (`COLUMN_TYPE` = 'tinyint' AND `IS_NULLABLE` = 'NO' AND `COLUMN_DEFAULT` <=> '0'))
+  OR (`COLUMN_NAME` = 'inbound_max_rows'
+        AND NOT (`COLUMN_TYPE` = 'int'     AND `IS_NULLABLE` = 'NO' AND `COLUMN_DEFAULT` <=> '500'))
+  OR (`COLUMN_NAME` = 'stale_guard_enabled'
+        AND NOT (`COLUMN_TYPE` = 'tinyint' AND `IS_NULLABLE` = 'NO' AND `COLUMN_DEFAULT` <=> '1'))
+  ));
+SELECT @drift_after AS drift_after;
+SELECT IF(@drift_after <> 0, 'R2A-POST1 FAIL: 三列未全部收口到目标态', 'R2A-POST1 ok') AS gate_post1;
+SET @sql := IF(@drift_after <> 0,
+  "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'R2A-POST: 三列未全部收口到目标态,中止'",
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── Step 4 · 行数完整性:ALTER 不得增减行 ──────────────────────────────────
+SET @rows_after := (SELECT COUNT(*) FROM `mdp_entity`);
+SELECT @rows_before AS rows_before, @rows_after AS rows_after;
+SELECT IF(@rows_after <> @rows_before, 'R2A-POST2 FAIL: mdp_entity 行数在 ALTER 前后不一致', 'R2A-POST2 ok') AS gate_post2;
+SET @sql := IF(@rows_after <> @rows_before,
+  "SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'R2A-POST: mdp_entity 行数在 ALTER 前后不一致,中止'",
+  'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

+ 98 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.554.verify.sql

@@ -0,0 +1,98 @@
+-- ============================================================================
+-- 1.0.554.verify.sql · R2-A 执行后校验
+--   返回单列 ok:1 = 全部断言通过,0 = 任一失败。
+--
+--   V1  inbound_enabled     = tinyint / NOT NULL / DEFAULT 0
+--   V2  inbound_max_rows    = int     / NOT NULL / DEFAULT 500
+--   V3  stale_guard_enabled = tinyint / NOT NULL / DEFAULT 1
+--   V4  三列无 NULL(NOT NULL 语义未被削弱)
+--   V5  值域合法:inbound_enabled / stale_guard_enabled ∈ {0,1}
+--   V6  drift 计数为 0(与 migration 同口径,NULL-safe <=>)
+--   V7  uk_inbound_grant 仍存在且列序正确(1.0.553 产物,本批只读回归)
+--   V8  R2-B 对象未被本批触碰(列数守恒)
+--   V9  P0 对象未被触碰(UK 列序 / CHECK / po_no·po_line NOT NULL)
+--   V10 mdp_entity 仍有行(ALTER 未清空表)
+--
+--   注:不比对 COLUMN_COMMENT —— 注释不属于功能契约,且会与 CodeFirst 的
+--       注释同步互踩,纳入比对会造成假失败。
+-- ============================================================================
+SELECT CASE WHEN
+
+-- V1 · inbound_enabled
+    (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_entity'
+        AND `COLUMN_NAME` = 'inbound_enabled'
+        AND `COLUMN_TYPE` = 'tinyint' AND `IS_NULLABLE` = 'NO'
+        AND `COLUMN_DEFAULT` <=> '0') = 1
+
+-- V2 · inbound_max_rows(类型本就是 int,本批只补 DEFAULT)
+AND (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_entity'
+        AND `COLUMN_NAME` = 'inbound_max_rows'
+        AND `COLUMN_TYPE` = 'int' AND `IS_NULLABLE` = 'NO'
+        AND `COLUMN_DEFAULT` <=> '500') = 1
+
+-- V3 · stale_guard_enabled
+AND (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_entity'
+        AND `COLUMN_NAME` = 'stale_guard_enabled'
+        AND `COLUMN_TYPE` = 'tinyint' AND `IS_NULLABLE` = 'NO'
+        AND `COLUMN_DEFAULT` <=> '1') = 1
+
+-- V4 · 三列无 NULL
+AND (SELECT COUNT(*) FROM `mdp_entity`
+      WHERE `inbound_enabled` IS NULL
+         OR `inbound_max_rows` IS NULL
+         OR `stale_guard_enabled` IS NULL) = 0
+
+-- V5 · 值域合法
+AND (SELECT COUNT(*) FROM `mdp_entity` WHERE `inbound_enabled`     NOT IN (0, 1)) = 0
+AND (SELECT COUNT(*) FROM `mdp_entity` WHERE `stale_guard_enabled` NOT IN (0, 1)) = 0
+
+-- V6 · drift 归零(与 migration Step 1/3 同口径)
+AND (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_entity' AND (
+         (`COLUMN_NAME` = 'inbound_enabled'
+            AND NOT (`COLUMN_TYPE` = 'tinyint' AND `IS_NULLABLE` = 'NO' AND `COLUMN_DEFAULT` <=> '0'))
+      OR (`COLUMN_NAME` = 'inbound_max_rows'
+            AND NOT (`COLUMN_TYPE` = 'int'     AND `IS_NULLABLE` = 'NO' AND `COLUMN_DEFAULT` <=> '500'))
+      OR (`COLUMN_NAME` = 'stale_guard_enabled'
+            AND NOT (`COLUMN_TYPE` = 'tinyint' AND `IS_NULLABLE` = 'NO' AND `COLUMN_DEFAULT` <=> '1'))
+      )) = 0
+
+-- V7 · uk_inbound_grant 回归(1.0.553 产物;NON_UNIQUE=0 数的是索引列数=2)
+AND (SELECT COUNT(*) FROM information_schema.`STATISTICS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_inbound_grant'
+        AND `INDEX_NAME` = 'uk_inbound_grant' AND `NON_UNIQUE` = 0) = 2
+AND (SELECT GROUP_CONCAT(`COLUMN_NAME` ORDER BY `SEQ_IN_INDEX` SEPARATOR ',')
+       FROM information_schema.`STATISTICS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_inbound_grant'
+        AND `INDEX_NAME` = 'uk_inbound_grant') = 'access_key,entity_code'
+
+-- V8 · R2-B 对象未被触碰(列数守恒,取自审计基线)
+AND (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_inbound_grant')   = 13
+AND (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_inbound_request')  = 21
+AND (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_inbound_envelope') = 10
+AND (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_field_map')        = 10
+
+-- V9 · P0 对象未被触碰
+AND (SELECT GROUP_CONCAT(`COLUMN_NAME` ORDER BY `SEQ_IN_INDEX` SEPARATOR ',')
+       FROM information_schema.`STATISTICS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_process_outsource_delivery'
+        AND `INDEX_NAME` = 'uk_po_line_stat') = 'tenant_id,factory_id,stat_date,po_no,po_line'
+AND (SELECT COUNT(*) FROM information_schema.`TABLE_CONSTRAINTS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_process_outsource_delivery'
+        AND `CONSTRAINT_TYPE` = 'CHECK'
+        AND `CONSTRAINT_NAME` = 'ck_dwd_process_outsource_valid_tenant') = 1
+AND (SELECT COUNT(*) FROM information_schema.`COLUMNS`
+      WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_process_outsource_delivery'
+        AND `COLUMN_NAME` IN ('po_no', 'po_line') AND `IS_NULLABLE` = 'YES') = 0
+
+-- V10 · mdp_entity 仍有行(ALTER 未清空表)
+AND (SELECT COUNT(*) FROM `mdp_entity`) > 0
+
+THEN 1 ELSE 0 END AS ok;