Forráskód Böngészése

fix(s0): correct LineSkill multi-tenant unique index and seed line-post data

产线岗位(line-post)收口。本批含 schema 修正 + 演示数据 + 前端 lookup。

【schema correctness,本批主要目的】
LineSkillMaster / LineSkillDetail 的 UNIQUE 索引缺 tenant_id:
  IX_LineSkillMaster : Domain+Site+ProdLine+JOBNo
  IX_LineSkillDetail : Domain+Site+ProdLine+JOBNo+SkillNo
二者跨租户唯一,一旦 Site 被启用即会让两个租户互相顶撞,属多租户索引缺陷。
重建为 tenant_id 前置的复合唯一索引。

★ 该 DDL 不是为了让数据插得进去:已实测证明当前 Site 全为 NULL,
  MySQL UNIQUE 不对 NULL 判等,即使不改索引 11+11 也能插入成功
  (事务内实测 11/11 + 11/11 后回滚)。索引修复的唯一目的是 schema 正确性。

【数据】来源 AIDOP 797,仅取 ProdLine 命中目标租户 LineMaster 的 11 条产线;
其余 29 条因产线不存在于目标租户而丢弃。11 master + 11 detail。
Site 保持 NULL —— 族级证据显示 Site 为工作组/工作中心类历史列
(EmpSkills.Site 注释「工作组」、AIDOPB 实测为 数控组/冲压组 等),
且 LineSkill 全库 296 行 Site 100% NULL、无 UI、无校验,故不得写入租户/工厂/组织码。

内部 ID remap:明细的 LineSkillRecID 按业务键回查目标 master RecID;
PersonSkillId 改指目标租户 PersonSkill.Code='102' 的 Id
(源库该列指向他租户的 PersonSkill,属源侧既有缺陷,未沿用)。

⚠️ 非原子:MySQL DDL 隐式提交且 runner 不开显式事务,故脚本写成可重入——
DDL 以「索引是否已含 tenant_id」为条件,数据以业务键 NOT EXISTS 兜底,
重跑影响行数为 0(已实测)。

【前端】生产线改为 LineMaster 业务编码下拉;技能明细此前是 el-input-number
需用户手输 PersonSkill 内部主键,改为按「编码 / 名称」选择(value 仍为 schema
要求的内部 Id),用户不再接触内部 ID。

chore: bump version server 1.0.412 / Web 2.4.333
YY968XX 1 napja
szülő
commit
751933e805

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 {
 	"name": "admin.net",
 	"name": "admin.net",
 	"type": "module",
 	"type": "module",
-	"version": "2.4.332",
+	"version": "2.4.333",
 	"packageManager": "pnpm@10.32.1",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 10 - 1
Web/src/views/aidop/s0/composables/useS0BusinessLookups.ts

@@ -19,7 +19,7 @@ export interface S0LookupOption {
 	label: string;
 	label: string;
 }
 }
 
 
-export type S0LookupKey = 'item' | 'line' | 'personSkill' | 'workCenter' | 'location' | 'employee' | 'customer' | 'supplier' | 'party' | 'barcodeType';
+export type S0LookupKey = 'item' | 'line' | 'personSkill' | 'workCenter' | 'location' | 'employee' | 'customer' | 'supplier' | 'party' | 'barcodeType' | 'personSkillId';
 
 
 /** 下拉一次性取数上限:S0 主数据为配置级数据量,单页取全量即可,避免分页拼装。 */
 /** 下拉一次性取数上限:S0 主数据为配置级数据量,单页取全量即可,避免分页拼装。 */
 const PAGE_SIZE = 500;
 const PAGE_SIZE = 500;
@@ -75,6 +75,15 @@ const loaders: Record<S0LookupKey, () => Promise<S0LookupOption[]>> = {
 		const d = await s0SuppliersApi.list({ page: 1, pageSize: PAGE_SIZE });
 		const d = await s0SuppliersApi.list({ page: 1, pageSize: PAGE_SIZE });
 		return dedupe((d.list ?? []).map((x: any) => norm(x.supp, x.sortName)));
 		return dedupe((d.list ?? []).map((x: any) => norm(x.supp, x.sortName)));
 	},
 	},
+	/**
+	 * 人员技能 —— 以【内部主键 Id】为 value。
+	 * LineSkillDetail.PersonSkillId 存的就是 PersonSkill 的自增主键(schema 决定,非本批可改),
+	 * 因此这里 value 必须是 Id;但用户只按「编码 / 名称」选择,绝不手输内部 Id。
+	 */
+	personSkillId: async () => {
+		const d = await s0MfgPersonSkillsApi.options();
+		return dedupe((d ?? []).map((x: any) => norm(x.value, [x.code, x.name].filter(Boolean).join(' / '))));
+	},
 	/**
 	/**
 	 * 客户 + 供应商合并主体。
 	 * 客户 + 供应商合并主体。
 	 * BarCodeNbr.Customer 是 legacy 字段(列描述即「客户/供应商编码」),同一字段两种主体共用,
 	 * BarCodeNbr.Customer 是 legacy 字段(列描述即「客户/供应商编码」),同一字段两种主体共用,

+ 10 - 2
Web/src/views/aidop/s0/manufacturing/LinePostList.vue

@@ -44,7 +44,7 @@
 			<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
 			<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
 				<el-row :gutter="12">
 				<el-row :gutter="12">
 					<el-col :span="6"><el-form-item label="工厂域" prop="domain"><el-input v-model="form.domain" /></el-form-item></el-col>
 					<el-col :span="6"><el-form-item label="工厂域" prop="domain"><el-input v-model="form.domain" /></el-form-item></el-col>
-					<el-col :span="6"><el-form-item label="生产线" prop="prodLine"><el-input v-model="form.prodLine" /></el-form-item></el-col>
+					<el-col :span="6"><el-form-item label="生产线" prop="prodLine"><el-select v-model="form.prodLine" filterable clearable allow-create default-first-option placeholder="请选择生产线" style="width: 100%"><el-option v-for="o in lookup.line" :key="o.value" :label="o.label" :value="o.value" /></el-select></el-form-item></el-col>
 					<el-col :span="6"><el-form-item label="岗位编码" prop="jobNo"><el-input v-model="form.jobNo" /></el-form-item></el-col>
 					<el-col :span="6"><el-form-item label="岗位编码" prop="jobNo"><el-input v-model="form.jobNo" /></el-form-item></el-col>
 					<el-col :span="6"><el-form-item label="岗位描述"><el-input v-model="form.jobDescr" /></el-form-item></el-col>
 					<el-col :span="6"><el-form-item label="岗位描述"><el-input v-model="form.jobDescr" /></el-form-item></el-col>
 				</el-row>
 				</el-row>
@@ -58,7 +58,7 @@
 			<el-table :data="form.skills" border stripe style="width: 100%" row-key="__k" @selection-change="onSelectionChange">
 			<el-table :data="form.skills" border stripe style="width: 100%" row-key="__k" @selection-change="onSelectionChange">
 				<el-table-column type="selection" width="45" />
 				<el-table-column type="selection" width="45" />
 				<el-table-column label="人员技能Id" width="140">
 				<el-table-column label="人员技能Id" width="140">
-					<template #default="{ row }"><el-input-number v-model="row.personSkillId" :controls="false" style="width: 100%" /></template>
+					<template #default="{ row }"><el-select v-model="row.personSkillId" filterable clearable placeholder="请选择技能" style="width: 100%"><el-option v-for="o in lookup.personSkillId" :key="o.value" :label="o.label" :value="o.value" /></el-select></template>
 				</el-table-column>
 				</el-table-column>
 				<el-table-column label="要求等级" width="120">
 				<el-table-column label="要求等级" width="120">
 					<template #default="{ row }"><el-input v-model="row.requiredLevel" /></template>
 					<template #default="{ row }"><el-input v-model="row.requiredLevel" /></template>
@@ -85,6 +85,14 @@
 </template>
 </template>
 
 
 <script setup lang="ts" name="aidopS0MfgLinePost">
 <script setup lang="ts" name="aidopS0MfgLinePost">
+import { reactive as __reactive_lk } from 'vue';
+import { loadS0Lookup, type S0LookupOption } from '../composables/useS0BusinessLookups';
+// 生产线按业务编码(LineMaster.Line)选择;技能按编码/名称选择但绑定 PersonSkill 内部主键
+//(LineSkillDetail.PersonSkillId 的 schema 要求),用户不再手输内部 Id。
+const lookup = __reactive_lk<Record<string, S0LookupOption[]>>({ line: [], personSkillId: [] });
+void loadS0Lookup('line').then((r) => (lookup.line = r));
+void loadS0Lookup('personSkillId').then((r) => (lookup.personSkillId = r));
+
 import { computed, onMounted, reactive, ref, watch } from 'vue';
 import { computed, onMounted, reactive, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
 import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.411</AssemblyVersion>
-    <FileVersion>1.0.411</FileVersion>
-    <Version>1.0.411</Version>
+    <AssemblyVersion>1.0.412</AssemblyVersion>
+    <FileVersion>1.0.412</FileVersion>
+    <Version>1.0.412</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -571,6 +571,12 @@
     <None Update="UpdateScripts\1.0.408.verify.sql">
     <None Update="UpdateScripts\1.0.408.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.412.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.412.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
   </ItemGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 160 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.412.sql

@@ -0,0 +1,160 @@
+-- ===========================================================================
+-- S0-LINESKILL-TENANT-UK-CLOSURE-1 —— 产线岗位(line-post)
+--
+-- 本脚本做两件互相独立的事:
+--
+-- 【1】MULTI-TENANT SCHEMA CORRECTNESS(主要目的)
+--     LineSkillMaster / LineSkillDetail 的 UNIQUE 索引缺少 tenant_id:
+--        IX_LineSkillMaster : Domain + Site + ProdLine + JOBNo
+--        IX_LineSkillDetail : Domain + Site + ProdLine + JOBNo + SkillNo
+--     两者都是「跨租户唯一」,一旦 Site 被启用(非 NULL),两个租户持有相同
+--     (Domain, Site, ProdLine, JOBNo) 就会互相顶撞。这属于多租户索引缺陷,
+--     必须补 tenant_id。
+--
+--     ⚠️ 这不是为了「让下面 22 行插得进去」。已实测证明:当前 Site 全为 NULL,
+--     MySQL 的 UNIQUE 索引不对 NULL 做相等判定,因此即使不改索引,
+--     11 master + 11 detail 也能插入成功(事务内实测 11/11 + 11/11,随后回滚)。
+--     索引修复的唯一目的是 schema 正确性。
+--
+-- 【2】line-post 演示数据(22 行)
+--     来源 AIDOP 797:仅取 ProdLine 命中目标租户 LineMaster 的 11 条产线;
+--     其余 29 条因产线不存在于目标租户而丢弃,不为凑数复制。
+--
+-- Site 处理:保持 NULL。
+--     族级证据显示 Site = 工作组/工作中心类历史列(EmpSkills.Site 注释「工作组」,
+--     AIDOPB 实测取值为 数控组/冲压组 等;ProdLineDetail.Site 注释「工作中心编码」),
+--     且 LineSkill 全库 296 行 Site 100% 为 NULL、无 UI、无校验。
+--     因此新行沿用 NULL —— 不得写入租户/工厂/组织编码。
+--
+-- ⚠️ 事务性说明:MySQL 的 DDL 会隐式提交,且 AutoVersionUpdate 的
+--     SqlScriptSplitter.Execute 不开启显式事务。因此本脚本**不是原子的**。
+--     为此所有语句均写成可安全重入:
+--       - DDL 以「索引是否已包含 tenant_id」为条件,已修则整段跳过;
+--       - 数据以业务键 NOT EXISTS 兜底,重跑影响行数为 0。
+-- ===========================================================================
+
+SET @src := (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId WHERE o.Code = 'AIDOP'       AND t.Status = 1 LIMIT 1);
+SET @dst := (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1);
+
+
+-- ---------------------------------------------------------------------------
+-- 01 · 前置守卫:新 UK 定义下不得存在重复(否则不能建唯一索引)
+--      重复则抛错中止,不带病改索引。
+-- ---------------------------------------------------------------------------
+SET @dup_m := (SELECT COUNT(*) FROM (
+    SELECT tenant_id, Domain, Site, ProdLine, JOBNo
+    FROM LineSkillMaster GROUP BY tenant_id, Domain, Site, ProdLine, JOBNo HAVING COUNT(*) > 1) x);
+
+SET @dup_d := (SELECT COUNT(*) FROM (
+    SELECT tenant_id, Domain, Site, ProdLine, JOBNo, SkillNo
+    FROM LineSkillDetail GROUP BY tenant_id, Domain, Site, ProdLine, JOBNo, SkillNo HAVING COUNT(*) > 1) x);
+
+SET @g := IF(@dup_m = 0 AND @dup_d = 0, 'DO 0',
+    'SELECT RAISE_LINESKILL_UK_DUPLICATE_FOUND');
+PREPARE gstmt FROM @g;
+EXECUTE gstmt;
+DEALLOCATE PREPARE gstmt;
+
+
+-- ---------------------------------------------------------------------------
+-- 02 · IX_LineSkillMaster → 补 tenant_id
+--      条件:该索引存在且**不含** tenant_id 才重建;已含则整段 no-op(可重入)。
+-- ---------------------------------------------------------------------------
+SET @m_fixed := (SELECT COUNT(*) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillMaster'
+      AND INDEX_NAME = 'IX_LineSkillMaster' AND COLUMN_NAME = 'tenant_id');
+
+SET @m_exists := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillMaster'
+      AND INDEX_NAME = 'IX_LineSkillMaster');
+
+SET @s := IF(@m_fixed = 0 AND @m_exists > 0,
+    'DROP INDEX IX_LineSkillMaster ON LineSkillMaster', 'DO 0');
+PREPARE stmt FROM @s;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @m_now := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillMaster'
+      AND INDEX_NAME = 'IX_LineSkillMaster');
+
+SET @s := IF(@m_now = 0,
+    'CREATE UNIQUE INDEX IX_LineSkillMaster ON LineSkillMaster (tenant_id, Domain, Site, ProdLine, JOBNo)',
+    'DO 0');
+PREPARE stmt FROM @s;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+
+-- ---------------------------------------------------------------------------
+-- 03 · IX_LineSkillDetail → 补 tenant_id(同样条件化、可重入)
+-- ---------------------------------------------------------------------------
+SET @d_fixed := (SELECT COUNT(*) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillDetail'
+      AND INDEX_NAME = 'IX_LineSkillDetail' AND COLUMN_NAME = 'tenant_id');
+
+SET @d_exists := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillDetail'
+      AND INDEX_NAME = 'IX_LineSkillDetail');
+
+SET @s := IF(@d_fixed = 0 AND @d_exists > 0,
+    'DROP INDEX IX_LineSkillDetail ON LineSkillDetail', 'DO 0');
+PREPARE stmt FROM @s;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @d_now := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillDetail'
+      AND INDEX_NAME = 'IX_LineSkillDetail');
+
+SET @s := IF(@d_now = 0,
+    'CREATE UNIQUE INDEX IX_LineSkillDetail ON LineSkillDetail (tenant_id, Domain, Site, ProdLine, JOBNo, SkillNo)',
+    'DO 0');
+PREPARE stmt FROM @s;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+
+-- ---------------------------------------------------------------------------
+-- 04 · LineSkillMaster COPY 11
+--      只取 ProdLine 命中目标租户 LineMaster 的产线(JOIN 即门禁);
+--      Site 保持 NULL;RecID 为 auto_increment,不赋值、不复制源主键。
+-- ---------------------------------------------------------------------------
+INSERT INTO LineSkillMaster
+    (tenant_id, Domain, Site, ProdLine, JOBNo, JOBDescr, Orderby, WType, RunCrew, CreateTime, UpdateTime)
+SELECT @dst, s.Domain, NULL, s.ProdLine, s.JOBNo, s.JOBDescr, s.Orderby, s.WType, s.RunCrew, NOW(), NOW()
+FROM LineSkillMaster s
+JOIN LineMaster l ON l.tenant_id = @dst AND l.Line = s.ProdLine
+WHERE s.tenant_id = @src
+  AND @src IS NOT NULL AND @dst IS NOT NULL
+  AND NOT EXISTS (
+      SELECT 1 FROM LineSkillMaster x
+      WHERE x.tenant_id = @dst AND x.Domain = s.Domain
+        AND x.ProdLine = s.ProdLine AND x.JOBNo = s.JOBNo AND x.Site IS NULL);
+
+
+-- ---------------------------------------------------------------------------
+-- 05 · LineSkillDetail COPY 11
+--      ★ 内部 ID remap(禁止复制源主键 / 源 PersonSkillId):
+--        LineSkillRecID ← 目标租户刚插入的 master RecID(按业务键回查)
+--        PersonSkillId  ← 目标租户 PersonSkill.Code = 源 SkillNo 的 Id
+--        (源库该列指向的是他租户的 PersonSkill,属源侧既有缺陷,不得沿用)
+-- ---------------------------------------------------------------------------
+INSERT INTO LineSkillDetail
+    (tenant_id, Domain, Site, ProdLine, JOBNo, SkillNo, LineSkillRecID, PersonSkillId,
+     required_level, is_enabled, CreateTime, UpdateTime)
+SELECT @dst, m.Domain, NULL, m.ProdLine, m.JOBNo, sd.SkillNo, m.RecID, p.Id,
+       sd.required_level, 1, NOW(), NOW()
+FROM LineSkillMaster m
+JOIN LineSkillMaster sm
+      ON sm.tenant_id = @src AND sm.Domain = m.Domain
+     AND sm.ProdLine = m.ProdLine AND sm.JOBNo = m.JOBNo AND sm.Site IS NULL
+JOIN LineSkillDetail sd
+      ON sd.tenant_id = @src AND sd.LineSkillRecID = sm.RecID
+JOIN PersonSkill p
+      ON p.tenant_id = @dst AND p.Code = sd.SkillNo
+WHERE m.tenant_id = @dst AND m.Site IS NULL
+  AND @src IS NOT NULL AND @dst IS NOT NULL
+  AND NOT EXISTS (
+      SELECT 1 FROM LineSkillDetail d
+      WHERE d.tenant_id = @dst AND d.LineSkillRecID = m.RecID AND d.PersonSkillId = p.Id);

+ 83 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.412.verify.sql

@@ -0,0 +1,83 @@
+-- ===========================================================================
+-- LINE-POST TENANT-UK CLOSURE VERIFY —— 全部 SELECT 真值标量断言,无 SET / 无用户变量
+-- ===========================================================================
+
+-- ---------- §1 索引已含 tenant_id(schema correctness 的核心断言) ----------
+SELECT COUNT(*) = 1 FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillMaster'
+    AND INDEX_NAME = 'IX_LineSkillMaster' AND COLUMN_NAME = 'tenant_id';
+SELECT COUNT(*) = 1 FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillDetail'
+    AND INDEX_NAME = 'IX_LineSkillDetail' AND COLUMN_NAME = 'tenant_id';
+
+-- 索引仍是 UNIQUE
+SELECT MIN(NON_UNIQUE) = 0 FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillMaster' AND INDEX_NAME = 'IX_LineSkillMaster';
+SELECT MIN(NON_UNIQUE) = 0 FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillDetail' AND INDEX_NAME = 'IX_LineSkillDetail';
+
+-- 索引列数与顺序(tenant_id 必须在第 1 位)
+SELECT COUNT(*) = 5 FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillMaster' AND INDEX_NAME = 'IX_LineSkillMaster';
+SELECT COUNT(*) = 6 FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillDetail' AND INDEX_NAME = 'IX_LineSkillDetail';
+SELECT COUNT(*) = 1 FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillMaster'
+    AND INDEX_NAME = 'IX_LineSkillMaster' AND COLUMN_NAME = 'tenant_id' AND SEQ_IN_INDEX = 1;
+SELECT COUNT(*) = 1 FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LineSkillDetail'
+    AND INDEX_NAME = 'IX_LineSkillDetail' AND COLUMN_NAME = 'tenant_id' AND SEQ_IN_INDEX = 1;
+
+-- ---------- §2 目标行数 ----------
+SELECT COUNT(*) = 11 FROM LineSkillMaster
+  WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1);
+SELECT COUNT(*) = 11 FROM LineSkillDetail
+  WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1);
+
+-- ---------- §3 Site 必须全为 NULL(不得被写成租户/工厂/组织码) ----------
+SELECT COUNT(*) = 11 FROM LineSkillMaster
+  WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1)
+    AND Site IS NULL;
+SELECT COUNT(*) = 11 FROM LineSkillDetail
+  WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1)
+    AND Site IS NULL;
+
+-- ---------- §4 FK 闭合 ----------
+SELECT COUNT(*) = 0 FROM LineSkillMaster m
+  WHERE m.tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1)
+    AND NOT EXISTS (SELECT 1 FROM LineMaster l WHERE l.tenant_id = m.tenant_id AND l.Line = m.ProdLine);
+SELECT COUNT(*) = 0 FROM LineSkillDetail d
+  WHERE d.tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1)
+    AND NOT EXISTS (SELECT 1 FROM LineSkillMaster m WHERE m.RecID = d.LineSkillRecID AND m.tenant_id = d.tenant_id);
+SELECT COUNT(*) = 0 FROM LineSkillDetail d
+  WHERE d.tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1)
+    AND NOT EXISTS (SELECT 1 FROM PersonSkill p WHERE p.Id = d.PersonSkillId AND p.tenant_id = d.tenant_id);
+
+-- ---------- §5 scope / domain ----------
+SELECT COUNT(*) = 11 FROM LineSkillMaster
+  WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1)
+    AND Domain = '8010' AND JOBNo = '102';
+SELECT COUNT(*) = 11 FROM LineSkillDetail
+  WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1)
+    AND Domain = '8010' AND JOBNo = '102' AND SkillNo = '102';
+
+-- ---------- §6 业务键唯一 ----------
+SELECT COUNT(*) = 0 FROM (
+  SELECT ProdLine FROM LineSkillMaster
+  WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1)
+  GROUP BY ProdLine HAVING COUNT(*) > 1) x;
+SELECT COUNT(*) = 0 FROM (
+  SELECT LineSkillRecID, PersonSkillId FROM LineSkillDetail
+  WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1)
+  GROUP BY LineSkillRecID, PersonSkillId HAVING COUNT(*) > 1) x;
+
+-- ---------- §7 源与他租户零变更 ----------
+SELECT COUNT(*) = 40 FROM LineSkillMaster WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='AIDOP' AND t.Status=1 LIMIT 1);
+SELECT COUNT(*) = 40 FROM LineSkillDetail WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='AIDOP' AND t.Status=1 LIMIT 1);
+SELECT COUNT(*) = 6 FROM LineSkillMaster WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='AIDOPB' AND t.Status=1 LIMIT 1);
+SELECT COUNT(*) = 210 FROM LineSkillDetail WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='AIDOPB' AND t.Status=1 LIMIT 1);
+
+-- ---------- §8 既有 S0 回归 ----------
+SELECT COUNT(*) = 20 FROM LineMaster WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1);
+SELECT COUNT(*) = 5 FROM PersonSkill WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1);
+SELECT COUNT(*) = 10 FROM EmpSkills WHERE tenant_id = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id=t.OrgId WHERE o.Code='UATTEST_CHL' AND t.Status=1 LIMIT 1);