Browse Source

feat(s5): establish IQC spec-material map foundation | server 1.0.466

B-1:为 S5 IQC 检规解析链建立 qms_jygf ↔ 物料的派生映射基础设施。
本批不改变任何现有业务写路径:GenerateInspBill 与 qms_qcp_inspbill 未触碰,
检验单的 jgbh/jgbb/jgsxrq/jygfid 仍为 NULL(B-2 才接 Resolver 与固化)。

新增
- S5IqcMaterialTokenizer:全链路唯一分词/标准化/分级口径。
  分隔符 ; ; , , 、 : : / 空白(实测 ItemMaster 22684 行中出现次数均为 0);
  刻意排除 . - _(实测存在 9.0314.01.001 等合法物料码)。
  不复用 S7 正则:其缺顿号,而顿号是 IQC 侧主力分隔符(qms_jygf 中 273 行)。
- S5IqcSpecMaterialMapService:SyncSpecAsync / PurgeSpecAsync / Rebuild / Bind / Unbind / list。
  自动重算只删 is_manual=0,人工绑定永不被自动同步删除;检规删除时整条清理避免悬挂引用。
  本服务不含任何候选择一逻辑(属 B-2 Resolver 职责)。
- ado_s5_iqc_spec_material_map(1.0.466.sql):表结构多租户通用,
  seed 仅覆盖 UAT-A 租户,且用分隔符守卫只处理无分隔符单 token,
  保证 SQL 与 C# 分词结果可证明相同;已实测两者输出逐字节一致。

S0 写路径挂钩
- 原材料检规 Create / Update / Delete / Import 四处接同步。
  桥表为派生数据,其失败不回滚已提交的业务写,改以 specMapWarning 显式回传,
  并可经 POST /api/S5IqcSpecMap/sync/{specId} 幂等重试。

验证
- 后端 build 0 error;IqcTaskEntryService.cs 零改动。
- UAT-A 回填 8 spec / 8 token / 8 EXACT / 0 NOT_FOUND / 0 GLUE,与迁移前审计一致;
  重复执行幂等;qms_jygf、qms_jygfzb、qms_qcp_inspbill、报检分录行数均未变。
- 租户隔离:跨租户 spec 同步返回 specMissing、跨租户物料绑定被拒。
- 页面冒烟:S5 来料检验任务列表与 S0 原材料检验规范均正常渲染,无控制台报错。
YY968XX 2 ngày trước cách đây
mục cha
commit
62999231e4

+ 6 - 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.465</AssemblyVersion>
-    <FileVersion>1.0.465</FileVersion>
-    <Version>1.0.465</Version>
+    <AssemblyVersion>1.0.466</AssemblyVersion>
+    <FileVersion>1.0.466</FileVersion>
+    <Version>1.0.466</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -652,6 +652,9 @@
     <None Update="UpdateScripts\1.0.458.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.466.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 137 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.466.sql

@@ -0,0 +1,137 @@
+-- ============================================================================
+-- S5-IQC-B1 · 原材料检规 ↔ 物料 派生映射桥表
+--
+-- 目标:为 S5 IQC 建立 qms_jygf → ItemMaster 的稳定映射基础设施。
+--       本批 **不改变任何现有业务写路径行为**:不动 qms_qcp_inspbill、
+--       不动 GenerateInspBill、不动 qms_jygf / qms_jygfzb、不动报检数据。
+--
+-- ⚠️ 数据回填范围:**仅 UAT-A 租户(SysOrg.Code = 'UATTEST_CHL')**
+-- ⚠️ 明确不回填 797403760988229 的 4704 条历史检规,不治理其他租户。
+-- ⚠️ 表结构本身是多租户通用的;只有下方 ③ 的 seed 是 tenant scoped。
+--
+-- 写入范围:CREATE TABLE 1 · INSERT(仅 UAT-A) · UPDATE 0 · DELETE 0 · 其他 DDL 0
+--
+-- ---------------------------------------------------------------------------
+-- 【分词等价性保证 —— 本脚本最重要的约束】
+--
+-- 运行期分词口径唯一定义在 C# S5IqcMaterialTokenizer:
+--     分隔符 = ; ; , , 、 : : / 空格 Tab CR LF        (**不含 . - _**,它们出现在合法物料码内部)
+--     标准化 = TRIM + UPPER
+--     分级   = EXACT(命中 ItemMaster) / NOT_FOUND / GLUE_UNRESOLVED
+--
+-- SQL 无法可靠复刻上述正则。为杜绝「迁移一套规则、运行期另一套规则」,本脚本
+-- **只为不含任何分隔符的单 token 检规生成映射**(见 ③ 的 NOT LIKE 守卫)——
+-- 此时分词退化为恒等变换 TRIM(UPPER(wlbm)),SQL 与 C# 结果**可证明相同**。
+-- 含分隔符的检规一律不由本脚本落地,必须调用 POST /api/S5IqcSpecMap/rebuild 生成。
+--
+-- 实测(2026-09-02)UAT-A 8 条检规的 wlbm 全部无分隔符 → 本脚本覆盖 8/8,无遗漏。
+-- 该守卫同时保证:即使将来 UAT-A 新增了多物料检规,脚本也只会「少做」不会「做错」。
+-- ---------------------------------------------------------------------------
+--
+-- 幂等:CREATE TABLE IF NOT EXISTS + INSERT ... WHERE NOT EXISTS(唯一业务键)。
+--       重复执行结果恒等,不产生重复行。
+-- ============================================================================
+
+-- ===========================================================================
+-- ① 桥表
+--    唯一约束 uk_s5_iqc_map(tenant_id, spec_id, raw_token, is_manual):
+--      raw_token 决定 material_code,故该组合即「spec_id + material_code 唯一」的
+--      等价防重复机制,且在 material_code 为 NULL(NOT_FOUND/GLUE) 时依然有效
+--      (MySQL 唯一索引允许多个 NULL,若用 material_code 做键将无法防重)。
+-- ===========================================================================
+CREATE TABLE IF NOT EXISTS ado_s5_iqc_spec_material_map (
+    id             BIGINT       NOT NULL COMMENT '主键(雪花)',
+    tenant_id      BIGINT       NOT NULL COMMENT '租户ID',
+    spec_id        BIGINT       NOT NULL COMMENT '原材料检规ID → qms_jygf.id',
+    spec_no        VARCHAR(100) NULL     COMMENT '检规文件编号 wjbh(冗余,随主表同步刷新)',
+    spec_version   VARCHAR(50)  NULL     COMMENT '检规版本 bb(冗余,随主表同步刷新)',
+    seq            INT          NOT NULL DEFAULT 0 COMMENT 'token 在 wlbm 中的序号;人工绑定行为 0',
+    raw_token      VARCHAR(200) NOT NULL COMMENT 'wlbm 切分出的原始片段(保留原貌供审计)',
+    material_code  VARCHAR(100) NULL     COMMENT '标准化后的物料码;仅 EXACT/MANUAL 落值,其余 NULL(绝不猜物料)',
+    match_status   VARCHAR(20)  NOT NULL COMMENT 'EXACT / MANUAL / NOT_FOUND / GLUE_UNRESOLVED',
+    source_raw     TEXT         NULL     COMMENT '检规 wlbm 原始全串(审计用)',
+    is_manual      TINYINT      NOT NULL DEFAULT 0 COMMENT '1=人工绑定,自动同步永不删除',
+    note           VARCHAR(500) NULL     COMMENT '人工绑定备注',
+    create_time    DATETIME     NULL,
+    update_time    DATETIME     NULL,
+    create_user_id BIGINT       NULL,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_s5_iqc_map (tenant_id, spec_id, raw_token, is_manual),
+    KEY idx_s5_iqc_map_item (tenant_id, material_code),
+    KEY idx_s5_iqc_map_spec (tenant_id, spec_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+  COMMENT='S5 IQC 原材料检规↔物料 派生映射(B-1)';
+
+-- ===========================================================================
+-- ② 目标租户解析(UAT-A)。解析不到则下方 INSERT 全部为空,脚本安全空转。
+-- ===========================================================================
+SET @uat_a := (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);
+
+-- ===========================================================================
+-- ③ UAT-A 回填(仅无分隔符的单 token 检规;仅 EXACT/NOT_FOUND 两态)
+--
+--    分隔符守卫:wlbm 不得含 ; ; , , 、 : : / 空格 Tab CR LF
+--    ('\\' 为 MySQL LIKE 默认转义符,此处无需转义任何目标字符)
+--    GLUE_UNRESOLVED 在「单 token」前提下只可能因长度越界或含中文产生,
+--    本脚本对这两种情况也如实落 GLUE_UNRESOLVED,与 C# Classify 完全一致。
+-- ===========================================================================
+INSERT INTO ado_s5_iqc_spec_material_map
+    (id, tenant_id, spec_id, spec_no, spec_version, seq, raw_token, material_code,
+     match_status, source_raw, is_manual, note, create_time, update_time, create_user_id)
+SELECT
+    -- 确定性 ID = 3000000000000030000 + 该 spec 在本租户 qms_jygf 中按 id 升序的稳定排名。
+    -- 【为什么不用 @rn := @rn + 1】MySQL 8 明确规定 SELECT 中用户变量的求值顺序未定义,
+    --   实测会产生重复值并触发 PRIMARY 冲突(1062)。
+    -- 【为什么不用 ROW_NUMBER()】窗口序号是对**过滤后**结果集编号,重跑时若部分行已存在,
+    --   剩余行会被重新编号 → 与首次写入的 id 冲突。
+    -- 相关子查询排名只依赖 qms_jygf 自身内容,与本表已有多少行无关,故重跑恒等。
+    3000000000000030000 + (
+        SELECT COUNT(*) FROM qms_jygf r
+        WHERE r.tenant_id = g.tenant_id AND r.id <= g.id
+    ),
+    g.tenant_id,
+    g.id,
+    g.wjbh,
+    g.bb,
+    1,
+    TRIM(g.wlbm),
+    CASE WHEN im.ItemNum IS NOT NULL THEN UPPER(TRIM(g.wlbm)) ELSE NULL END,
+    CASE
+        WHEN im.ItemNum IS NOT NULL THEN 'EXACT'
+        WHEN CHAR_LENGTH(TRIM(g.wlbm)) < 4 OR CHAR_LENGTH(TRIM(g.wlbm)) > 16 THEN 'GLUE_UNRESOLVED'
+        WHEN TRIM(g.wlbm) REGEXP '[\\x{4E00}-\\x{9FFF}]' THEN 'GLUE_UNRESOLVED'
+        ELSE 'NOT_FOUND'
+    END,
+    g.wlbm,
+    0,
+    NULL,
+    NOW(), NOW(), NULL
+FROM qms_jygf g
+LEFT JOIN ItemMaster im
+       ON im.tenant_id = g.tenant_id
+      AND im.ItemNum = UPPER(TRIM(g.wlbm))
+WHERE @uat_a IS NOT NULL
+  AND g.tenant_id = @uat_a
+  AND g.wlbm IS NOT NULL
+  AND TRIM(g.wlbm) <> ''
+  -- 分词等价性守卫:只处理无分隔符的单 token
+  AND TRIM(g.wlbm) NOT LIKE '%;%'
+  AND TRIM(g.wlbm) NOT LIKE '%;%'
+  AND TRIM(g.wlbm) NOT LIKE '%,%'
+  AND TRIM(g.wlbm) NOT LIKE '%,%'
+  AND TRIM(g.wlbm) NOT LIKE '%、%'
+  AND TRIM(g.wlbm) NOT LIKE '%:%'
+  AND TRIM(g.wlbm) NOT LIKE '%:%'
+  AND TRIM(g.wlbm) NOT LIKE '%/%'
+  AND TRIM(g.wlbm) NOT LIKE '% %'
+  AND TRIM(g.wlbm) NOT LIKE CONCAT('%', CHAR(9), '%')
+  AND TRIM(g.wlbm) NOT LIKE CONCAT('%', CHAR(13), '%')
+  AND TRIM(g.wlbm) NOT LIKE CONCAT('%', CHAR(10), '%')
+  -- 幂等:同 (tenant, spec, raw_token, is_manual) 已存在则跳过
+  AND NOT EXISTS (
+        SELECT 1 FROM ado_s5_iqc_spec_material_map x
+        WHERE x.tenant_id = g.tenant_id
+          AND x.spec_id   = g.id
+          AND x.raw_token = TRIM(g.wlbm)
+          AND x.is_manual = 0);

+ 25 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QmsSpecImportController.cs

@@ -33,6 +33,9 @@ public class AdoS0QmsSpecImportController : ControllerBase
     private readonly SqlSugarRepository<AdoS0QmsOqcInspectionSpecEntry> _oqcEntryRep;
     private readonly FinishedWarehouse.FqcSpecMaterialMapService _specMap;
 
+    /// <summary>S5 IQC 检规↔物料桥表同步(B-1):原材料检规导入后重建派生映射。</summary>
+    private readonly MaterialWarehouse.S5IqcSpecMaterialMapService _iqcSpecMap;
+
     public AdoS0QmsSpecImportController(
         SqlSugarRepository<AdoS0QmsRawInspectionSpec> rawRep,
         SqlSugarRepository<AdoS0QmsRawInspectionSpecEntry> rawEntryRep,
@@ -42,8 +45,10 @@ public class AdoS0QmsSpecImportController : ControllerBase
         SqlSugarRepository<AdoS0QmsFinishedInspectionSpecEntry> fqcEntryRep,
         SqlSugarRepository<AdoS0QmsOqcInspectionSpec> oqcRep,
         SqlSugarRepository<AdoS0QmsOqcInspectionSpecEntry> oqcEntryRep,
-        FinishedWarehouse.FqcSpecMaterialMapService specMap)
+        FinishedWarehouse.FqcSpecMaterialMapService specMap,
+        MaterialWarehouse.S5IqcSpecMaterialMapService iqcSpecMap)
     {
+        _iqcSpecMap = iqcSpecMap;
         _rawRep = rawRep;
         _rawEntryRep = rawEntryRep;
         _processRep = processRep;
@@ -80,6 +85,7 @@ public class AdoS0QmsSpecImportController : ControllerBase
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (file == null || file.Length == 0) return BadRequest(new { message = "请上传 Excel 文件" });
+        var createdRawIds = new List<long>();
         var result = await RunImportAsync<AdoS0QmsRawInspectionSpecImportRow, AdoS0QmsRawInspectionSpec, AdoS0QmsRawInspectionSpecEntry>(
             file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeRawHeader, RawRequiredHeaderFields, _rawRep, _rawEntryRep,
             (r, fn) => new AdoS0QmsRawInspectionSpec
@@ -109,7 +115,24 @@ public class AdoS0QmsSpecImportController : ControllerBase
                 UpperLimit = NullIfWhiteSpace(r.UpperLimit),
                 LowerLimit = NullIfWhiteSpace(r.LowerLimit),
             },
-            tenantId);
+            tenantId, createdRawIds);
+
+        // 导入是「全或无」:Accepted=false 时未写入任何数据,无需同步。
+        // Accepted=true 时逐条同步 S5 IQC 桥表,否则导入进来的原材料检规对 B-2 Resolver 完全不可见。
+        // 桥表是派生数据:同步失败不推翻已成功的导入,以 SpecMapWarning 显式回传并可幂等重试。
+        if (result.Accepted)
+        {
+            foreach (var id in createdRawIds)
+            {
+                try { await _iqcSpecMap.SyncSpecAsync(tenantId, id); }
+                catch (Exception ex)
+                {
+                    result.SpecMapWarning = $"导入成功,但部分 IQC 检规物料映射同步失败(首个失败 specId={id}),"
+                                          + $"请调用 POST /api/S5IqcSpecMap/rebuild 重建:{ex.Message}";
+                    break;
+                }
+            }
+        }
         return Ok(result);
     }
 

+ 49 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityAggregateControllers.cs

@@ -479,12 +479,21 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
     private readonly SqlSugarRepository<AdoS0QmsRawInspectionSpec> _rep;
     private readonly SqlSugarRepository<AdoS0QmsRawInspectionSpecEntry> _entryRep;
 
+    /// <summary>
+    /// S5 IQC 检规↔物料桥表同步(B-1)。原材料检规是 S5 IQC 的检规源,
+    /// 主表 wlbm 变化后必须同步派生映射,否则 B-2 Resolver 会读到过期关系。
+    /// 与 S7 在 AdoS0QualityFqcOqcControllers 中的 FqcSpecMaterialMapService 用法同构。
+    /// </summary>
+    private readonly MaterialWarehouse.S5IqcSpecMaterialMapService _iqcSpecMap;
+
     public AdoS0QmsRawInspectionSpecsController(
         SqlSugarRepository<AdoS0QmsRawInspectionSpec> rep,
-        SqlSugarRepository<AdoS0QmsRawInspectionSpecEntry> entryRep)
+        SqlSugarRepository<AdoS0QmsRawInspectionSpecEntry> entryRep,
+        MaterialWarehouse.S5IqcSpecMaterialMapService iqcSpecMap)
     {
         _rep = rep;
         _entryRep = entryRep;
+        _iqcSpecMap = iqcSpecMap;
     }
 
     [HttpGet]
@@ -503,13 +512,38 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
     }
 
     [HttpGet("{id:long}")]
-    public async Task<IActionResult> GetDetailAsync(long id)
+    public Task<IActionResult> GetDetailAsync(long id) => GetDetailAsync(id, null);
+
+    /// <summary>
+    /// 详情。<paramref name="specMapWarning"/> 仅在 Create/Update 后回传 S5 IQC 桥表同步告警,
+    /// 正常读取恒为 null(响应形状对既有前端向后兼容,仅新增可选字段)。
+    /// </summary>
+    [NonAction]
+    private async Task<IActionResult> GetDetailAsync(long id, string? specMapWarning)
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         var items = await _entryRep.AsQueryable().Where(x => x.MasterId == id).OrderBy(x => x.Seq).OrderBy(x => x.Id).ToListAsync();
-        return Ok(new { master, items });
+        return Ok(new { master, items, specMapWarning });
+    }
+
+    /// <summary>
+    /// 同步 S5 IQC 检规物料桥表;失败**不抛**(业务写已提交,桥表是派生数据),
+    /// 以告警字符串回传调用方,并可通过 POST /api/S5IqcSpecMap/sync/{specId} 幂等重试。
+    /// </summary>
+    [NonAction]
+    private async Task<string?> TrySyncIqcSpecMapAsync(long tenantId, long specId)
+    {
+        try
+        {
+            await _iqcSpecMap.SyncSpecAsync(tenantId, specId);
+            return null;
+        }
+        catch (Exception ex)
+        {
+            return $"检规已保存,但 IQC 检规物料映射同步失败,请调用 POST /api/S5IqcSpecMap/sync/{specId} 重试:{ex.Message}";
+        }
     }
 
     [HttpPost]
@@ -529,7 +563,10 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
             await SyncRawInspectionSpecEntriesAsync(master.Id, items, tenantId);
             await db.Ado.CommitTranAsync();
-            return await GetDetailAsync(master.Id);
+            // 业务写已提交后再同步 S5 IQC 桥表(同构 S7 AdoS0QualityFqcOqcControllers)。
+            // 桥表是派生数据,其失败不得回滚已成功的检规保存;失败以 specMapWarning 显式回传,可人工 resync。
+            var mapWarn = await TrySyncIqcSpecMapAsync(tenantId, master.Id);
+            return await GetDetailAsync(master.Id, mapWarn);
         }
         catch
         {
@@ -567,7 +604,9 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
             await SyncRawInspectionSpecEntriesAsync(id, items, tenantId);
             await db.Ado.CommitTranAsync();
-            return await GetDetailAsync(id);
+            // wlbm/wjbh/bb 可能已变更 → 重建该检规的自动派生映射(人工绑定行不受影响)
+            var mapWarn = await TrySyncIqcSpecMapAsync(tenantId, id);
+            return await GetDetailAsync(id, mapWarn);
         }
         catch
         {
@@ -589,7 +628,11 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
             await _rep.DeleteAsync(master);
             await db.Ado.CommitTranAsync();
-            return Ok(new { message = "删除成功" });
+            // 检规已删除 → 桥表整条清理(含人工绑定行:指向已删检规的人工绑定属悬挂引用)
+            string? mapWarn = null;
+            try { await _iqcSpecMap.PurgeSpecAsync(tenantId, id); }
+            catch (Exception ex) { mapWarn = $"检规已删除,但 IQC 检规物料映射清理失败,请调用 POST /api/S5IqcSpecMap/sync/{id} 重试:{ex.Message}"; }
+            return Ok(new { message = "删除成功", specMapWarning = mapWarn });
         }
         catch
         {

+ 7 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Quality/AdoS0QualitySpecImportDtos.cs

@@ -87,4 +87,11 @@ public class AdoS0QmsSpecImportResultDto
 
     /// <summary>汇总文案</summary>
     public string Message { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 派生映射同步告警(当前仅原材料检规导入使用)。导入本身已成功提交,
+    /// 但 S5 IQC 检规物料桥表同步失败时在此说明,可调用 POST /api/S5IqcSpecMap/rebuild 幂等重建。
+    /// 恒为可空,既有响应结构向后兼容。
+    /// </summary>
+    public string? SpecMapWarning { get; set; }
 }

+ 101 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/S5IqcSpecMaterialMapDto.cs

@@ -0,0 +1,101 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+/// <summary>S5 IQC 检规物料映射 列表查询入参(全部为等值/精确过滤,禁 LIKE 物料码)。</summary>
+public class S5IqcSpecMapListInput
+{
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+
+    /// <summary>物料编码(精确匹配 material_code,禁子串)。</summary>
+    public string? MaterialCode { get; set; }
+
+    /// <summary>检规 id(qms_jygf.id)。</summary>
+    public long? SpecId { get; set; }
+
+    /// <summary>检规文件编号 wjbh(精确)。</summary>
+    public string? SpecNo { get; set; }
+
+    /// <summary>EXACT / MANUAL / NOT_FOUND / GLUE_UNRESOLVED。</summary>
+    public string? MatchStatus { get; set; }
+}
+
+/// <summary>S5 IQC 检规物料映射 行。</summary>
+public class S5IqcSpecMapRow
+{
+    public long Id { get; set; }
+    public long SpecId { get; set; }
+    public string? SpecNo { get; set; }
+    public string? SpecVersion { get; set; }
+    public int Seq { get; set; }
+    public string? RawToken { get; set; }
+    public string? MaterialCode { get; set; }
+    public string? MatchStatus { get; set; }
+    public string? SourceRaw { get; set; }
+    public bool IsManual { get; set; }
+    public string? Note { get; set; }
+    public DateTime? CreateTime { get; set; }
+    public DateTime? UpdateTime { get; set; }
+}
+
+/// <summary>人工绑定入参。</summary>
+public class S5IqcSpecMapBindInput
+{
+    /// <summary>检规 id(qms_jygf.id),必须属当前租户。</summary>
+    public long SpecId { get; set; }
+
+    /// <summary>物料编码,必须存在于当前租户 ItemMaster。</summary>
+    public string? MaterialCode { get; set; }
+
+    public string? Note { get; set; }
+}
+
+/// <summary>人工解绑入参。</summary>
+public class S5IqcSpecMapUnbindInput
+{
+    /// <summary>桥表行 id(仅允许删除 is_manual=1 的行)。</summary>
+    public long Id { get; set; }
+}
+
+/// <summary>单检规同步结果摘要。</summary>
+public class S5IqcSpecSyncSummary
+{
+    public bool Ok { get; set; }
+    public long SpecId { get; set; }
+
+    /// <summary>本次产生的 token 总数(去重后)。</summary>
+    public int TokenCount { get; set; }
+
+    public int Exact { get; set; }
+    public int NotFound { get; set; }
+    public int GlueUnresolved { get; set; }
+
+    /// <summary>spec 内重复 token 被丢弃的条数。</summary>
+    public int DuplicateDropped { get; set; }
+
+    /// <summary>被删除并重建的自动派生行数。</summary>
+    public int AutoRowsReplaced { get; set; }
+
+    /// <summary>保留未动的人工绑定行数。</summary>
+    public int ManualRowsPreserved { get; set; }
+
+    /// <summary>检规已不存在(Purge 场景)。</summary>
+    public bool SpecMissing { get; set; }
+
+    public string? Message { get; set; }
+}
+
+/// <summary>整租户重建结果摘要。</summary>
+public class S5IqcSpecRebuildSummary
+{
+    public bool Ok { get; set; }
+    public int SpecCount { get; set; }
+    public int TokenCount { get; set; }
+    public int Exact { get; set; }
+    public int NotFound { get; set; }
+    public int GlueUnresolved { get; set; }
+    public int DuplicateDropped { get; set; }
+    public int ManualRowsPreserved { get; set; }
+
+    /// <summary>至少有一个 EXACT 命中的检规数。</summary>
+    public int SpecsWithExact { get; set; }
+}

+ 115 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5IqcMaterialTokenizer.cs

@@ -0,0 +1,115 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC 原材料检规 <c>qms_jygf.wlbm</c> 分词 / 标准化 / 分级 —— 全链路唯一口径来源。
+///
+/// 【为什么不复用 S7 的 tokenizer】
+/// S7 <c>FqcSpecMaterialMapService.TokenSplitter</c> = <c>[;;,,\r\n\t/::\s]+</c>,**不含顿号 `、`**;
+/// 而实测 <c>qms_jygf</c> 4712 行中含 `、` 者 273 行、含换行 186 行、含 `;`/`,` 者 **0 行** ——
+/// 顿号才是 IQC 侧的主力分隔符,原样复用会把 273 行多物料串整体当成一个 token。
+/// 同理 S7 的 <c>PlainCodeShape = ^[A-Za-z0-9]{4,12}$</c> 也不能复用:实测 ItemMaster 存在
+/// <c>9.0314.01.001</c>(13 位含点) 与 <c>钉11匣转向杆-加长0.3</c>(13 位含中文与横杠) 两个合法物料码。
+///
+/// 【分隔符安全性证据(实测 ItemMaster 22684 行)】
+/// <c>; ; , , 、 空格 : : /</c> 在 ItemNum 中出现次数**全为 0** → 可安全作为分隔符;
+/// <c>.</c> 出现 2 次、<c>-</c> 出现 1 次、<c>_</c> 出现 0 次 → **绝不可作为分隔符**(会切断合法编码)。
+///
+/// 【标准化】实测 ItemMaster 全部已是大写(<c>ItemNum &lt;&gt; UPPER(ItemNum)</c> 计数 = 0),
+/// 故 Trim + ToUpperInvariant 是无损的,且标准化结果即 ItemMaster 的规范写法。
+///
+/// 本类为纯函数、无 DB、无租户概念,便于单测;运行期 SyncSpecAsync 与 UpdateScripts 迁移脚本
+/// 必须共用本类定义的同一套规则(迁移脚本的等价性保证见 <see cref="IsSingleTokenSafeForSqlSeed"/>)。
+/// </summary>
+public static class S5IqcMaterialTokenizer
+{
+    /// <summary>
+    /// wlbm 分隔符集合:半/全角分号、半/全角逗号、顿号、半/全角冒号、斜杠、空白(空格/Tab/CR/LF)。
+    /// **不含 `.` `-` `_`** —— 它们出现在合法物料编码内部。
+    /// </summary>
+    public const string DelimiterChars = ";;,,、::/ \t\r\n";
+
+    private static readonly Regex Splitter = new(@"[;;,,、::/\s]+", RegexOptions.Compiled);
+
+    /// <summary>物料码长度合理区间。实测 ItemMaster 实际区间为 [5,13],此处放宽为 [4,16] 留容差。</summary>
+    private const int MinCodeLength = 4;
+    private const int MaxCodeLength = 16;
+
+    /// <summary>
+    /// 切分 wlbm 为原始片段(保序、去空白片段)。**不做去重、不做大小写变换** —— 保留 raw_token 原貌用于审计。
+    /// </summary>
+    public static IReadOnlyList<string> Split(string? wlbm)
+    {
+        if (string.IsNullOrWhiteSpace(wlbm)) return Array.Empty<string>();
+        return Splitter.Split(wlbm)
+            .Select(x => x.Trim())
+            .Where(x => x.Length > 0)
+            .ToList();
+    }
+
+    /// <summary>标准化:Trim + 转大写不变文化。实测 ItemMaster 全大写,故该结果即规范物料码写法。</summary>
+    public static string Normalize(string rawToken)
+        => (rawToken ?? string.Empty).Trim().ToUpperInvariant();
+
+    /// <summary>
+    /// 分级:EXACT / NOT_FOUND / GLUE_UNRESOLVED。
+    /// 判定顺序固定:先精确命中,再形状判粘连,最后未找到。
+    /// GLUE_UNRESOLVED 表示"该片段根本不像一个物料码"(长度越界或含 CJK 文本,如 `00参考明细列表`);
+    /// 注意含 CJK 的**合法**物料码会在第一步 EXACT 命中,不会走到形状判断,故此规则安全。
+    /// </summary>
+    public static string Classify(string normalizedToken, ISet<string> itemCodeSet)
+    {
+        ArgumentNullException.ThrowIfNull(itemCodeSet);
+        if (string.IsNullOrEmpty(normalizedToken)) return S5IqcMapStatus.GlueUnresolved;
+        if (itemCodeSet.Contains(normalizedToken)) return S5IqcMapStatus.Exact;
+        if (normalizedToken.Length < MinCodeLength || normalizedToken.Length > MaxCodeLength)
+            return S5IqcMapStatus.GlueUnresolved;
+        if (ContainsCjk(normalizedToken)) return S5IqcMapStatus.GlueUnresolved;
+        return S5IqcMapStatus.NotFound;
+    }
+
+    private static bool ContainsCjk(string s)
+    {
+        foreach (var ch in s)
+        {
+            // CJK 统一表意文字主区 + 扩展A + 兼容表意 + 中文标点
+            if ((ch >= '一' && ch <= '鿿')
+                || (ch >= '㐀' && ch <= '䶿')
+                || (ch >= '豈' && ch <= '﫿')
+                || (ch >= ' ' && ch <= '〿'))
+                return true;
+        }
+        return false;
+    }
+
+    /// <summary>
+    /// SQL 迁移等价性守卫:判断某条 wlbm 是否为"无分隔符单 token"。
+    ///
+    /// UpdateScripts 迁移脚本**只允许**为该函数返回 true 的行生成映射 —— 此时分词退化为恒等变换
+    /// (<c>TRIM(UPPER(wlbm))</c>),SQL 与本 tokenizer 的结果**可证明相同**,杜绝"迁移一套规则、
+    /// 运行期另一套规则"。含分隔符的行一律不由 SQL 落地,改由 <c>SyncSpecAsync</c> / <c>Rebuild</c> 生成。
+    /// </summary>
+    public static bool IsSingleTokenSafeForSqlSeed(string? wlbm)
+    {
+        if (string.IsNullOrWhiteSpace(wlbm)) return false;
+        return wlbm.Trim().IndexOfAny(DelimiterChars.ToCharArray()) < 0;
+    }
+}
+
+/// <summary>桥表 match_status 取值(与 S7 <c>ado_s7_fqc_spec_material_map</c> 同名同义)。</summary>
+public static class S5IqcMapStatus
+{
+    /// <summary>token 与 ItemMaster.ItemNum 精确命中(自动派生)。</summary>
+    public const string Exact = "EXACT";
+
+    /// <summary>人工绑定(脏 wlbm 的订正结果),优先级高于自动派生。</summary>
+    public const string Manual = "MANUAL";
+
+    /// <summary>形状像物料码但 ItemMaster 无此码。</summary>
+    public const string NotFound = "NOT_FOUND";
+
+    /// <summary>不像单个物料码(长度越界 / 含中文描述),如 `00参考明细列表`。</summary>
+    public const string GlueUnresolved = "GLUE_UNRESOLVED";
+}

+ 391 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5IqcSpecMaterialMapService.cs

@@ -0,0 +1,391 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+using Yitter.IdGenerator;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC 原材料检规 ↔ 物料 派生映射服务(B-1)。
+///
+/// 职责边界(**本服务只建立 Spec ↔ Material 关系,不做任何"选哪条检规"的裁决**):
+///   qms_jygf.wlbm ──tokenize/normalize──> 与 ItemMaster.ItemNum 精确比对 ──> ado_s5_iqc_spec_material_map
+///
+/// 硬边界:
+///   ① 绝不修改源 qms_jygf / qms_jygfzb 任何字段;
+///   ② 禁止 LIKE 子串匹配,token 与 ItemMaster.ItemNum **等值**比对;
+///   ③ 自动重算只删自己产生的行(is_manual=0),**人工绑定行(is_manual=1)永不被自动同步删除**;
+///   ④ 全部按 AidopTenantScope 登录租户隔离,绝不跨租户匹配、绝不透传源行租户;
+///   ⑤ **本服务不得出现候选排序/择一逻辑**(如 OrderByDescending(sxrq).First())——
+///      那是 B-2 Resolver 的职责。本服务的查询接口一律返回全部候选,不做选择。
+///
+/// 分词/标准化/分级规则集中在 <see cref="S5IqcMaterialTokenizer"/>,运行期与 UpdateScripts 迁移共用同一口径。
+/// </summary>
+[ApiDescriptionSettings(Order = 341, Description = "IQC检规物料映射")]
+[Route("api/S5IqcSpecMap")]
+[NonUnify]
+public class S5IqcSpecMaterialMapService : IDynamicApiController, ITransient
+{
+    private const string MapTable = "ado_s5_iqc_spec_material_map";
+
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public S5IqcSpecMaterialMapService(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    private long ResolveTenantOrThrow() => AidopTenantScope.ResolveOrThrow(_userManager);
+
+    // ==================================================================
+    // 查询(只读,返回全部候选,**不做择一**)
+    // ==================================================================
+
+    /// <summary>映射列表(分页)。物料编码为等值过滤,禁子串匹配。</summary>
+    [DisplayName("IQC检规物料映射列表")]
+    [HttpGet("list")]
+    public async Task<object> GetList([FromQuery] S5IqcSpecMapListInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        var page = input.Page <= 0 ? 1 : input.Page;
+        var pageSize = input.PageSize <= 0 ? 20 : (input.PageSize > 200 ? 200 : input.PageSize);
+
+        var where = new List<string> { "m.tenant_id=@t" };
+        var pars = new List<SugarParameter> { new("@t", tenantId) };
+
+        if (!string.IsNullOrWhiteSpace(input.MaterialCode))
+        {
+            where.Add("m.material_code=@mc"); // 等值,禁 LIKE
+            pars.Add(new SugarParameter("@mc", S5IqcMaterialTokenizer.Normalize(input.MaterialCode)));
+        }
+        if (input.SpecId is > 0)
+        {
+            where.Add("m.spec_id=@sid");
+            pars.Add(new SugarParameter("@sid", input.SpecId!.Value));
+        }
+        if (!string.IsNullOrWhiteSpace(input.SpecNo))
+        {
+            where.Add("m.spec_no=@sno");
+            pars.Add(new SugarParameter("@sno", input.SpecNo.Trim()));
+        }
+        if (!string.IsNullOrWhiteSpace(input.MatchStatus))
+        {
+            where.Add("m.match_status=@ms");
+            pars.Add(new SugarParameter("@ms", input.MatchStatus.Trim()));
+        }
+
+        var whereSql = string.Join(" AND ", where);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM {MapTable} m WHERE {whereSql}", pars);
+
+        // 排序仅为分页稳定性(spec_id/seq/id 全序),**不是候选优先级**,不含任何业务择一语义。
+        var list = await _db.Ado.SqlQueryAsync<S5IqcSpecMapRow>(
+            $@"SELECT m.id AS Id, m.spec_id AS SpecId, m.spec_no AS SpecNo, m.spec_version AS SpecVersion,
+                      m.seq AS Seq, m.raw_token AS RawToken, m.material_code AS MaterialCode,
+                      m.match_status AS MatchStatus, m.source_raw AS SourceRaw, m.is_manual AS IsManual,
+                      m.note AS Note, m.create_time AS CreateTime, m.update_time AS UpdateTime
+               FROM {MapTable} m
+               WHERE {whereSql}
+               ORDER BY m.spec_id, m.seq, m.id
+               LIMIT {pageSize} OFFSET {(page - 1) * pageSize}", pars);
+
+        return new { total, page, pageSize, list };
+    }
+
+    // ==================================================================
+    // 同步
+    // ==================================================================
+
+    /// <summary>手动重新同步单条检规的映射(人工 resync 入口,供钩子失败后补偿)。</summary>
+    [DisplayName("重新同步单条检规映射")]
+    [HttpPost("sync/{specId:long}")]
+    public async Task<S5IqcSpecSyncSummary> Sync(long specId)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        return await SyncSpecAsync(tenantId, specId);
+    }
+
+    /// <summary>
+    /// 单检规同步(S0 CRUD / 导入后调用):只重建**这一条检规**的自动派生映射。
+    ///
+    /// 语义(对应 B-1 验收问题):
+    ///   · AUTO 重算:先 DELETE 本 (tenant, spec, is_manual=0) 全部旧行,再按当前 wlbm 重建 → 旧 EXACT 会被删除;
+    ///     wlbm 被改动后旧物料的映射因此自然失效,无需额外过期逻辑。
+    ///   · MANUAL:DELETE 条件恒带 is_manual=0,人工行**永不**被自动同步删除;仅刷新其 spec_no/spec_version 元数据。
+    ///   · 检规不存在(已删除):走 <see cref="PurgeSpecAsync"/> 语义由调用方决定;本方法在 spec 缺失时只清 AUTO 行。
+    ///   · 幂等:同一 (tenant, spec) 重复调用 → 先删后建,结果集恒等,无重复行。
+    /// </summary>
+    public async Task<S5IqcSpecSyncSummary> SyncSpecAsync(long tenantId, long specId)
+    {
+        var summary = new S5IqcSpecSyncSummary { SpecId = specId };
+        if (tenantId <= 0 || specId <= 0)
+        {
+            summary.Message = "invalid-args";
+            return summary;
+        }
+
+        var spec = await _db.Ado.SqlQuerySingleAsync<SpecRow>(
+            "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, wlbm AS Wlbm FROM qms_jygf WHERE id=@id AND tenant_id=@t LIMIT 1",
+            new SugarParameter("@id", specId), new SugarParameter("@t", tenantId));
+
+        summary.SpecMissing = spec == null;
+
+        var rows = new List<Dictionary<string, object>>();
+        if (spec != null)
+        {
+            var itemSet = await LoadItemCodeSetAsync(tenantId);
+            var now = DateTime.Now;
+            var userId = _userManager.UserId;
+            var seen = new HashSet<string>(StringComparer.Ordinal);
+            var seq = 0;
+
+            foreach (var raw in S5IqcMaterialTokenizer.Split(spec.Wlbm))
+            {
+                var normalized = S5IqcMaterialTokenizer.Normalize(raw);
+                if (normalized.Length == 0) continue;
+                if (!seen.Add(normalized)) { summary.DuplicateDropped++; continue; }
+
+                var status = S5IqcMaterialTokenizer.Classify(normalized, itemSet);
+                switch (status)
+                {
+                    case S5IqcMapStatus.Exact: summary.Exact++; break;
+                    case S5IqcMapStatus.GlueUnresolved: summary.GlueUnresolved++; break;
+                    default: summary.NotFound++; break;
+                }
+
+                seq++;
+                summary.TokenCount++;
+                rows.Add(new Dictionary<string, object>
+                {
+                    ["id"] = YitIdHelper.NextId(),
+                    ["tenant_id"] = tenantId,
+                    ["spec_id"] = spec.Id,
+                    ["spec_no"] = (object?)spec.Wjbh ?? DBNull.Value,
+                    ["spec_version"] = (object?)spec.Bb ?? DBNull.Value,
+                    ["seq"] = seq,
+                    ["raw_token"] = raw,
+                    // 只有 EXACT 才落 material_code;NOT_FOUND / GLUE_UNRESOLVED 一律 NULL,绝不猜物料
+                    ["material_code"] = status == S5IqcMapStatus.Exact ? normalized : DBNull.Value,
+                    ["match_status"] = status,
+                    ["source_raw"] = (object?)spec.Wlbm ?? DBNull.Value,
+                    ["is_manual"] = 0,
+                    ["note"] = DBNull.Value,
+                    ["create_time"] = now,
+                    ["update_time"] = now,
+                    ["create_user_id"] = (object?)userId ?? DBNull.Value,
+                });
+            }
+        }
+
+        var tran = await _db.Ado.UseTranAsync(async () =>
+        {
+            summary.AutoRowsReplaced = await _db.Ado.ExecuteCommandAsync(
+                $"DELETE FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s AND is_manual=0",
+                new SugarParameter("@t", tenantId), new SugarParameter("@s", specId));
+
+            if (rows.Count > 0) await BulkInsertAsync(rows);
+
+            if (spec != null)
+            {
+                // 人工行随主表 wjbh/bb 变更刷新元数据(不改归属、不改 is_manual、不删除)
+                await _db.Ado.ExecuteCommandAsync(
+                    $@"UPDATE {MapTable} SET spec_no=@w, spec_version=@b, update_time=@u
+                       WHERE tenant_id=@t AND spec_id=@s AND is_manual=1",
+                    new SugarParameter("@w", (object?)spec.Wjbh ?? DBNull.Value),
+                    new SugarParameter("@b", (object?)spec.Bb ?? DBNull.Value),
+                    new SugarParameter("@u", DateTime.Now),
+                    new SugarParameter("@t", tenantId), new SugarParameter("@s", specId));
+            }
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("同步 IQC 检规物料映射失败");
+
+        summary.ManualRowsPreserved = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s AND is_manual=1",
+            new SugarParameter("@t", tenantId), new SugarParameter("@s", specId));
+
+        summary.Ok = true;
+        return summary;
+    }
+
+    /// <summary>
+    /// 检规删除后的映射清理:删除该 (tenant, spec) 的**全部**行,含人工绑定行。
+    ///
+    /// 与 <see cref="SyncSpecAsync"/> 的差别是刻意的:检规已不存在,指向它的人工绑定是悬挂引用,
+    /// 保留只会让 B-2 Resolver JOIN 到空行。故 Delete 场景全清(不同于 S7 保留人工行待处置)。
+    /// </summary>
+    public async Task<S5IqcSpecSyncSummary> PurgeSpecAsync(long tenantId, long specId)
+    {
+        var summary = new S5IqcSpecSyncSummary { SpecId = specId, SpecMissing = true };
+        if (tenantId <= 0 || specId <= 0)
+        {
+            summary.Message = "invalid-args";
+            return summary;
+        }
+
+        summary.AutoRowsReplaced = await _db.Ado.ExecuteCommandAsync(
+            $"DELETE FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s",
+            new SugarParameter("@t", tenantId), new SugarParameter("@s", specId));
+        summary.Ok = true;
+        summary.Message = "purged";
+        return summary;
+    }
+
+    /// <summary>整租户重建自动派生映射(人工行原样保留)。用于首次建库或批量订正后的兜底。</summary>
+    [DisplayName("重建IQC检规物料映射")]
+    [HttpPost("rebuild")]
+    public async Task<S5IqcSpecRebuildSummary> Rebuild()
+    {
+        var tenantId = ResolveTenantOrThrow();
+        var result = new S5IqcSpecRebuildSummary();
+
+        var specs = await _db.Ado.SqlQueryAsync<SpecRow>(
+            "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, wlbm AS Wlbm FROM qms_jygf WHERE tenant_id=@t ORDER BY id",
+            new SugarParameter("@t", tenantId));
+        result.SpecCount = specs.Count;
+
+        foreach (var s in specs)
+        {
+            var one = await SyncSpecAsync(tenantId, s.Id);
+            result.TokenCount += one.TokenCount;
+            result.Exact += one.Exact;
+            result.NotFound += one.NotFound;
+            result.GlueUnresolved += one.GlueUnresolved;
+            result.DuplicateDropped += one.DuplicateDropped;
+            if (one.Exact > 0) result.SpecsWithExact++;
+        }
+
+        result.ManualRowsPreserved = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM {MapTable} WHERE tenant_id=@t AND is_manual=1",
+            new SugarParameter("@t", tenantId));
+        result.Ok = true;
+        return result;
+    }
+
+    // ==================================================================
+    // 人工订正
+    // ==================================================================
+
+    /// <summary>人工绑定:把一个标准物料码绑定到指定检规(is_manual=1)。不改源 wlbm。</summary>
+    [DisplayName("人工绑定IQC检规物料")]
+    [HttpPost("bind")]
+    public async Task<object> Bind([FromBody] S5IqcSpecMapBindInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.SpecId <= 0 || string.IsNullOrWhiteSpace(input.MaterialCode))
+            throw Oops.Oh("检规 id 与物料编码必填");
+        var materialCode = S5IqcMaterialTokenizer.Normalize(input.MaterialCode);
+
+        var spec = await _db.Ado.SqlQuerySingleAsync<SpecRow>(
+            "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, wlbm AS Wlbm FROM qms_jygf WHERE id=@id AND tenant_id=@t LIMIT 1",
+            new SugarParameter("@id", input.SpecId), new SugarParameter("@t", tenantId));
+        if (spec == null) throw Oops.Oh("检规不存在或不属于当前租户");
+
+        var itemExists = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM ItemMaster WHERE tenant_id=@t AND ItemNum=@mc",
+            new SugarParameter("@t", tenantId), new SugarParameter("@mc", materialCode));
+        if (itemExists == 0) throw Oops.Oh($"物料 {materialCode} 不在当前租户 ItemMaster,无法绑定");
+
+        var dup = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s AND raw_token=@rt AND is_manual=1",
+            new SugarParameter("@t", tenantId), new SugarParameter("@s", input.SpecId),
+            new SugarParameter("@rt", materialCode));
+        if (dup > 0) return new { ok = true, message = "已存在该人工绑定" };
+
+        var now = DateTime.Now;
+        await BulkInsertAsync(new List<Dictionary<string, object>>
+        {
+            new()
+            {
+                ["id"] = YitIdHelper.NextId(),
+                ["tenant_id"] = tenantId,
+                ["spec_id"] = input.SpecId,
+                ["spec_no"] = (object?)spec.Wjbh ?? DBNull.Value,
+                ["spec_version"] = (object?)spec.Bb ?? DBNull.Value,
+                ["seq"] = 0,
+                ["raw_token"] = materialCode,
+                ["material_code"] = materialCode,
+                ["match_status"] = S5IqcMapStatus.Manual,
+                ["source_raw"] = (object?)spec.Wlbm ?? DBNull.Value,
+                ["is_manual"] = 1,
+                ["note"] = (object?)input.Note ?? DBNull.Value,
+                ["create_time"] = now,
+                ["update_time"] = now,
+                ["create_user_id"] = (object?)_userManager.UserId ?? DBNull.Value,
+            }
+        });
+        return new { ok = true };
+    }
+
+    /// <summary>解绑人工绑定(只允许删除 is_manual=1 的行)。</summary>
+    [DisplayName("解绑IQC检规物料")]
+    [HttpPost("unbind")]
+    public async Task<object> Unbind([FromBody] S5IqcSpecMapUnbindInput input)
+    {
+        var tenantId = ResolveTenantOrThrow();
+        if (input == null || input.Id <= 0) throw Oops.Oh("id 必填");
+        var n = await _db.Ado.ExecuteCommandAsync(
+            $"DELETE FROM {MapTable} WHERE id=@id AND tenant_id=@t AND is_manual=1",
+            new SugarParameter("@id", input.Id), new SugarParameter("@t", tenantId));
+        return new { ok = n > 0 };
+    }
+
+    // ==================================================================
+    // 内部
+    // ==================================================================
+
+    /// <summary>取当前租户 ItemMaster 全部物料码(标准化后)。绝不跨租户。</summary>
+    private async Task<HashSet<string>> LoadItemCodeSetAsync(long tenantId)
+    {
+        var codes = await _db.Ado.SqlQueryAsync<string>(
+            "SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id=@t AND ItemNum IS NOT NULL",
+            new SugarParameter("@t", tenantId));
+        return new HashSet<string>(
+            codes.Where(x => !string.IsNullOrWhiteSpace(x)).Select(S5IqcMaterialTokenizer.Normalize),
+            StringComparer.Ordinal);
+    }
+
+    private static readonly string[] MapColumns =
+    {
+        "id", "tenant_id", "spec_id", "spec_no", "spec_version", "seq", "raw_token", "material_code",
+        "match_status", "source_raw", "is_manual", "note", "create_time", "update_time", "create_user_id"
+    };
+
+    /// <summary>
+    /// 参数化分块批插。不用 db.Insertable(List&lt;Dictionary&gt;):SqlSugar 字典批插对 nullable 列的
+    /// DBNull 渲染在 S7 侧曾触发 MySQL "Incorrect datetime value: ''"(见 FqcSpecMaterialMapService 同名方法注释)。
+    /// </summary>
+    private async Task BulkInsertAsync(List<Dictionary<string, object>> rows)
+    {
+        if (rows == null || rows.Count == 0) return;
+        const int chunkSize = 200;
+        var colList = string.Join(", ", MapColumns);
+        for (var start = 0; start < rows.Count; start += chunkSize)
+        {
+            var slice = rows.GetRange(start, Math.Min(chunkSize, rows.Count - start));
+            var valueClauses = new List<string>(slice.Count);
+            var pars = new List<SugarParameter>(slice.Count * MapColumns.Length);
+            for (var r = 0; r < slice.Count; r++)
+            {
+                var placeholders = new string[MapColumns.Length];
+                for (var c = 0; c < MapColumns.Length; c++)
+                {
+                    var name = $"@{MapColumns[c]}_{r}";
+                    placeholders[c] = name;
+                    var val = slice[r].TryGetValue(MapColumns[c], out var v) ? (v ?? DBNull.Value) : DBNull.Value;
+                    pars.Add(new SugarParameter(name, val));
+                }
+                valueClauses.Add($"({string.Join(", ", placeholders)})");
+            }
+            await _db.Ado.ExecuteCommandAsync(
+                $"INSERT INTO {MapTable} ({colList}) VALUES {string.Join(", ", valueClauses)}", pars);
+        }
+    }
+
+    private sealed class SpecRow
+    {
+        public long Id { get; set; }
+        public string? Wjbh { get; set; }
+        public string? Bb { get; set; }
+        public string? Wlbm { get; set; }
+    }
+}