Ver Fonte

feat(s0): switch FQC spec CRUD to qms_ccjygf and auto-sync spec-material bridge

FUNC-S0-052 原先写 qms_fqcjygf/qms_fqcjygfzb,该表零下游消费者——维护出来的检规
S7 报检根本读不到。改写 qms_ccjygf/qms_ccjygfzb,即 FqcApplyService 生成检验单时
经桥表解析并快照的表,使 S0 维护 → S7 消费真正打通。

路由 /api/s0/quality/fqc-inspection-specs、FUNC-S0-052 权限码、菜单结构均未改动。

- 新增 AdoS0QmsFinishedInspectionSpec(Entry) 实体:逐列显式 ColumnName
  (UpperToleranceLimit/SizeSpecifications 为 PascalCase,EnableUnderLine=false 下不显式必错列);
  只纳入检验单快照真正消费的列,bztk/isPeel/isDisplay 不入实体以免 CodeFirst 误改结构
- DTO 明细扩到 14 字段:序号/录入类型 lrlx/上下限/技术要求/仪器/条款/抽样方案/记录/尺寸规格;
  沿用「不放 [Required]」约定,必填全部由 AdoS0QmsSpecAggregateGuard 承担
- 守卫追加成品检规重载 + ValidateFinishedEntries:口径对齐 FqcApplyService 三条运行期兜底
  (检验项目非空 / lrlx 仅 NUMERIC|NON_NUMERIC / NUMERIC 上下限须数值且下限<=上限),
  把注定生成不了检验单的检规挡在 S0 侧
- FqcSpecMaterialMapService 新增 SyncSpecAsync:单检规粒度重建自动映射,
  DELETE 严格三条件带 is_manual=0,人工绑定行只同步 wjbh/bb/生效日期元数据、绝不删除;
  Create/Update/Delete/Import 四条写路径均调用,不再依赖手工全量 Rebuild
- Delete 加引用守卫:已被 qms_qcpp_inspbill 引用则拒绝物理删除
  (jygfid 是 varchar 无 FK,删除会静默产生悬空指针、断掉受控文件追溯链)
- 明细查询与删除补 TenantId 谓词:目标表存在 52 个 phantom glid / 444 行无主明细,
  只按 MasterId 过滤存在误删无主行的实际风险
- 导入切到新模板行(含「明细-录入类型」列);RunImportAsync 追加可选 createdMasterIds
  收集主表 id,Accepted 后逐条同步桥表;Raw/Process/OQC 三条导入路径未受影响

验证:dotnet build 0 error;vue-tsc 581 < 基线 597;pnpm build 通过。
UAT 实测 Create/Read/Update/Delete 全通过,S0 新建 UATB-FQC-91HC0497 → 桥表自动生成
→ 报检生成检验单 FQC202609020002 → NUMERIC 区间内判合格、超差判不合格,NON_NUMERIC 留人工;
改 wlbm 后旧自动映射撤销、新映射生成、MANUAL 行不受影响;已被引用的检规删除被正确拒绝。
租户回归:UAT 只解析到本租户检规,797 的 68/1053/1554 三表基线零变化。

版本:server 1.0.459 / Web 2.4.358
YY968XX há 3 semanas atrás
pai
commit
58fe40d466

+ 1 - 1
Web/package.json

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

+ 34 - 17
Web/src/views/aidop/s0/quality/qualityConfigs.ts

@@ -436,41 +436,58 @@ export const processInspectionSpecConfig: QualityAggregatePageConfig = {
 	createEmptyItem: () => ({ operationCode: '', operationName: '', inspectionItem: '', inspectionMethod: '', inspectionSpec: '', imageCategory: '', inspectionFrequency: '', technicalStandard: '', peelingForce: undefined, upperLimit: '', lowerLimit: '' }),
 };
 
+/**
+ * FQC 成品检验规范。
+ *
+ * 底层已切到 S7 运行链路表 qms_ccjygf / qms_ccjygfzb(原 qms_fqcjygf 无下游消费者),
+ * 因此字段集换成 S7 生成检验单时真正快照的那一组:
+ *   序号 / 检验项目 / 录入类型 / 上下限 / 抽样方案 / 技术要求 / 检验方法 / 仪器 / 技术标准 / 尺寸规格。
+ * 其中「录入类型」是判定开关:NUMERIC 走上下限自动判定,NON_NUMERIC 由检验员人工确认。
+ * API 路径与 FUNC-S0-052 权限码保持不变。
+ */
 export const fqcInspectionSpecConfig: QualityAggregatePageConfig = {
 	requireAtLeastOneItem: true,
-	subtitle: 'S0 / 质量建模 / FQC检验规范',
+	subtitle: 'S0 / 质量建模 / FQC检验规范(S7 成品检验直接消费)',
 	entityLabel: 'FQC检验规范',
-	queryPlaceholder: '文件编号/适用型号/物料编码',
+	queryPlaceholder: '文件编号/产品名称/产品型号/物料编码',
 	api: s0FqcInspectionSpecsApi,
 	importApi: s0FqcInspectionSpecsImportApi,
 	listColumns: [
-		{ prop: 'fileNumber', label: '文件编号', width: 180 },
-		{ prop: 'applicableModel', label: '适用型号', minWidth: 220 },
+		{ prop: 'fileNumber', label: '文件编号', width: 200 },
+		{ prop: 'productName', label: '产品名称', minWidth: 200 },
+		{ prop: 'productModel', label: '产品型号', width: 160 },
 		{ prop: 'materialCode', label: '物料编码', width: 180 },
 		{ prop: 'versionNo', label: '版本', width: 100 },
+		{ prop: 'effectiveDate', label: '生效日期', width: 160 },
 	],
 	headFields: [
-		{ prop: 'applicableModel', label: '适用型号' },
 		{ prop: 'fileNumber', label: '文件编号', required: true },
 		{ prop: 'versionNo', label: '版本', required: true },
+		{ prop: 'productName', label: '产品名称' },
+		{ prop: 'productModel', label: '产品型号' },
 		{ prop: 'effectiveDate', label: '生效日期' },
-		{ prop: 'attachment', label: '附件', type: 'textarea' },
 		{ prop: 'materialCode', label: '物料编码', required: true },
-		{ prop: 'attachment2', label: '附件2', type: 'textarea' },
-		{ prop: 'version', label: '版本号', type: 'number' },
+		{ prop: 'attachment', label: '附件', type: 'textarea' },
 	],
 	detailColumns: [
-		{ prop: 'operationCode', label: '工序代号' },
-		{ prop: 'operationName', label: '工序名称' },
+		{ prop: 'seqNo', label: '序号' },
 		{ prop: 'inspectionItem', label: '检验项目' },
-		{ prop: 'inspectionMethod', label: '检验方法' },
-		{ prop: 'inspectionSpec', label: '检验规格' },
-		{ prop: 'inspectionFrequency', label: '检验频次' },
-		{ prop: 'upperLimit', label: '上限' },
+		{ prop: 'resultType', label: '录入类型' },
 		{ prop: 'lowerLimit', label: '下限' },
-	],
-	initialMaster: { applicableModel: '', fileNumber: '', versionNo: '', effectiveDate: '', attachment: '', materialCode: '', attachment2: '', version: undefined },
-	createEmptyItem: () => ({ operationCode: '', operationName: '', inspectionItem: '', inspectionMethod: '', inspectionSpec: '', imageCategory: '', inspectionFrequency: '', technicalStandard: '', peelingForce: undefined, upperLimit: '', lowerLimit: '' }),
+		{ prop: 'upperLimit', label: '上限' },
+		{ prop: 'samplingPlan', label: '抽样方案' },
+		{ prop: 'technicalRequirement', label: '技术要求' },
+		{ prop: 'inspectionMethod', label: '检验方法' },
+		{ prop: 'instrument', label: '检验仪器' },
+		{ prop: 'technicalStandard', label: '技术标准' },
+		{ prop: 'sizeSpecifications', label: '尺寸规格' },
+	],
+	initialMaster: { fileNumber: '', versionNo: '', productName: '', productModel: '', effectiveDate: '', materialCode: '', attachment: '' },
+	createEmptyItem: () => ({
+		seqNo: '', inspectionItem: '', resultType: '', upperLimit: '', lowerLimit: '', samplingPlan: '',
+		technicalRequirement: '', inspectionMethod: '', instrument: '', inspectionClause: '',
+		technicalStandard: '', record: '', sizeSpecifications: '', remarks: '',
+	}),
 };
 
 export const oqcInspectionSpecConfig: QualityAggregatePageConfig = {

+ 3 - 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.458</AssemblyVersion>
-    <FileVersion>1.0.458</FileVersion>
-    <Version>1.0.458</Version>
+    <AssemblyVersion>1.0.459</AssemblyVersion>
+    <FileVersion>1.0.459</FileVersion>
+    <Version>1.0.459</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 61 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QmsSpecAggregateGuard.cs

@@ -157,6 +157,67 @@ public static class AdoS0QmsSpecAggregateGuard
            || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
                e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit);
 
+    // ==================== 成品检验规范(qms_ccjygf / qms_ccjygfzb)====================
+    // 与上面四组同语义,只是字段集不同:成品检规明细不含工序代号/工序名称/图像分类/检验频次/PeelingForce,
+    // 改为承载 S7 检验单快照真正消费的列(序号/录入类型/上下限/技术要求/仪器/条款/抽样方案/记录/尺寸规格)。
+
+    public static List<AdoS0QmsFinishedInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsFinishedInspectionSpecEntryDto> items)
+        => items.Where(HasValue).ToList();
+
+    public static List<AdoS0QmsFinishedInspectionSpecEntryDto> ForUpdateWrite(IEnumerable<AdoS0QmsFinishedInspectionSpecEntryDto> items)
+        => items.Where(x => HasValue(x) || x.Id.HasValue).ToList();
+
+    public static int CountMeaningful(IEnumerable<AdoS0QmsFinishedInspectionSpecEntry> rows)
+        => rows.Count(e => AnyNotBlank(e.SeqNo, e.InspectionItem, e.ResultType, e.UpperLimit, e.LowerLimit,
+            e.TechnicalRequirement, e.InspectionMethod, e.Instrument, e.InspectionClause,
+            e.TechnicalStandard, e.SamplingPlan, e.Record, e.SizeSpecifications));
+
+    public static bool HasValue(AdoS0QmsFinishedInspectionSpecEntryDto e)
+        => AnyNotBlank(e.SeqNo, e.InspectionItem, e.ResultType, e.UpperLimit, e.LowerLimit,
+            e.TechnicalRequirement, e.InspectionMethod, e.Instrument, e.InspectionClause,
+            e.TechnicalStandard, e.SamplingPlan, e.Record, e.SizeSpecifications);
+
+    public static bool HasDetailValue(AdoS0QmsFinishedInspectionSpecImportRow r)
+        => AnyNotBlank(r.SeqNo, r.InspectionItem, r.ResultType, r.UpperLimit, r.LowerLimit,
+            r.TechnicalRequirement, r.InspectionMethod, r.Instrument,
+            r.TechnicalStandard, r.SamplingPlan, r.SizeSpecifications);
+
+    /// <summary>
+    /// 成品检规明细的**运行期可用性**校验(S0 侧前置,避免把注定生成不了检验单的检规存进库)。
+    /// 口径与 `FqcApplyService.GenerateInspection` 的三条兜底一致:
+    ///  ① 检验项目非空;② lrlx 仅 NUMERIC/NON_NUMERIC/空;③ NUMERIC 的上下限须是数值且 下限≤上限。
+    /// 返回 null 表示通过。
+    /// </summary>
+    public static string? ValidateFinishedEntries(IReadOnlyList<AdoS0QmsFinishedInspectionSpecEntryDto> items)
+    {
+        foreach (var e in items)
+        {
+            if (string.IsNullOrWhiteSpace(e.InspectionItem))
+                return "存在未填写「检验项目」的明细行,该检规将无法生成检验单";
+
+            var type = (e.ResultType ?? string.Empty).Trim().ToUpperInvariant();
+            if (type.Length > 0 && type != "NUMERIC" && type != "NON_NUMERIC")
+                return $"检验项目「{e.InspectionItem}」的录入类型非法,只能是 NUMERIC 或 NON_NUMERIC";
+
+            var upper = (e.UpperLimit ?? string.Empty).Trim();
+            var lower = (e.LowerLimit ?? string.Empty).Trim();
+            var hasLimit = upper.Length > 0 || lower.Length > 0;
+
+            if (type == "NON_NUMERIC" && hasLimit)
+                return $"检验项目「{e.InspectionItem}」是非数值型,不能配置上下限";
+
+            if (!hasLimit) continue;
+
+            if (!decimal.TryParse(upper, out var u) && upper.Length > 0)
+                return $"检验项目「{e.InspectionItem}」的上限必须是数值";
+            if (!decimal.TryParse(lower, out var l) && lower.Length > 0)
+                return $"检验项目「{e.InspectionItem}」的下限必须是数值";
+            if (upper.Length > 0 && lower.Length > 0 && l > u)
+                return $"检验项目「{e.InspectionItem}」的下限不能大于上限";
+        }
+        return null;
+    }
+
     private static bool AnyNotBlank(params string?[] values)
     {
         foreach (var v in values)

+ 62 - 15
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QmsSpecImportController.cs

@@ -26,20 +26,23 @@ public class AdoS0QmsSpecImportController : ControllerBase
     private readonly SqlSugarRepository<AdoS0QmsRawInspectionSpecEntry> _rawEntryRep;
     private readonly SqlSugarRepository<AdoS0QmsProcessInspectionSpec> _processRep;
     private readonly SqlSugarRepository<AdoS0QmsProcessInspectionSpecEntry> _processEntryRep;
-    private readonly SqlSugarRepository<AdoS0QmsFqcInspectionSpec> _fqcRep;
-    private readonly SqlSugarRepository<AdoS0QmsFqcInspectionSpecEntry> _fqcEntryRep;
+    // FQC 侧已切到 S7 运行链路表 qms_ccjygf / qms_ccjygfzb(见 AdoS0QmsFqcInspectionSpecsController 注释)
+    private readonly SqlSugarRepository<AdoS0QmsFinishedInspectionSpec> _fqcRep;
+    private readonly SqlSugarRepository<AdoS0QmsFinishedInspectionSpecEntry> _fqcEntryRep;
     private readonly SqlSugarRepository<AdoS0QmsOqcInspectionSpec> _oqcRep;
     private readonly SqlSugarRepository<AdoS0QmsOqcInspectionSpecEntry> _oqcEntryRep;
+    private readonly FinishedWarehouse.FqcSpecMaterialMapService _specMap;
 
     public AdoS0QmsSpecImportController(
         SqlSugarRepository<AdoS0QmsRawInspectionSpec> rawRep,
         SqlSugarRepository<AdoS0QmsRawInspectionSpecEntry> rawEntryRep,
         SqlSugarRepository<AdoS0QmsProcessInspectionSpec> processRep,
         SqlSugarRepository<AdoS0QmsProcessInspectionSpecEntry> processEntryRep,
-        SqlSugarRepository<AdoS0QmsFqcInspectionSpec> fqcRep,
-        SqlSugarRepository<AdoS0QmsFqcInspectionSpecEntry> fqcEntryRep,
+        SqlSugarRepository<AdoS0QmsFinishedInspectionSpec> fqcRep,
+        SqlSugarRepository<AdoS0QmsFinishedInspectionSpecEntry> fqcEntryRep,
         SqlSugarRepository<AdoS0QmsOqcInspectionSpec> oqcRep,
-        SqlSugarRepository<AdoS0QmsOqcInspectionSpecEntry> oqcEntryRep)
+        SqlSugarRepository<AdoS0QmsOqcInspectionSpecEntry> oqcEntryRep,
+        FinishedWarehouse.FqcSpecMaterialMapService specMap)
     {
         _rawRep = rawRep;
         _rawEntryRep = rawEntryRep;
@@ -49,6 +52,7 @@ public class AdoS0QmsSpecImportController : ControllerBase
         _fqcEntryRep = fqcEntryRep;
         _oqcRep = oqcRep;
         _oqcEntryRep = oqcEntryRep;
+        _specMap = specMap;
     }
 
     // ==================== 模板下载 ====================
@@ -63,7 +67,7 @@ public class AdoS0QmsSpecImportController : ControllerBase
 
     [HttpGet("fqc-inspection-specs/import-template")]
     public Task<IActionResult> FqcTemplate()
-        => MiniExcelUtil.ExportExcelTemplate<AdoS0QmsProcessLikeInspectionSpecImportRow>("FQC检验规范导入模板");
+        => MiniExcelUtil.ExportExcelTemplate<AdoS0QmsFinishedInspectionSpecImportRow>("FQC检验规范导入模板");
 
     [HttpGet("oqc-inspection-specs/import-template")]
     public Task<IActionResult> OqcTemplate()
@@ -138,22 +142,43 @@ 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 result = await RunImportAsync<AdoS0QmsProcessLikeInspectionSpecImportRow, AdoS0QmsFqcInspectionSpec, AdoS0QmsFqcInspectionSpecEntry>(
-            file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeProcessLikeHeader, ProcessLikeRequiredHeaderFields, _fqcRep, _fqcEntryRep,
-            (r, fn) => new AdoS0QmsFqcInspectionSpec
+        var createdIds = new List<long>();
+        var result = await RunImportAsync<AdoS0QmsFinishedInspectionSpecImportRow, AdoS0QmsFinishedInspectionSpec, AdoS0QmsFinishedInspectionSpecEntry>(
+            file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeFinishedHeader, FinishedRequiredHeaderFields, _fqcRep, _fqcEntryRep,
+            (r, fn) => new AdoS0QmsFinishedInspectionSpec
             {
                 FileNumber = fn,
-                ApplicableModel = NullIfWhiteSpace(r.ApplicableModel),
                 VersionNo = NullIfWhiteSpace(r.VersionNo),
+                ProductName = NullIfWhiteSpace(r.ProductName),
+                ProductModel = NullIfWhiteSpace(r.ProductModel),
                 EffectiveDate = NullIfWhiteSpace(r.EffectiveDate),
                 MaterialCode = NullIfWhiteSpace(r.MaterialCode),
                 Attachment = NullIfWhiteSpace(r.Attachment),
-                Attachment2 = NullIfWhiteSpace(r.Attachment2),
-                Version = r.Version,
             },
             m => m.Id,
-            (r, mid) => BuildProcessLikeEntry<AdoS0QmsFqcInspectionSpecEntry>(r, mid),
-            tenantId);
+            (r, mid) => new AdoS0QmsFinishedInspectionSpecEntry
+            {
+                MasterId = mid,
+                SeqNo = NullIfWhiteSpace(r.SeqNo),
+                InspectionItem = NullIfWhiteSpace(r.InspectionItem),
+                ResultType = AdoS0QmsFqcInspectionSpecsController.NormalizeResultType(r.ResultType),
+                UpperLimit = AdoS0QmsFqcInspectionSpecsController.NormalizeResultType(r.ResultType) == "NON_NUMERIC" ? null : NullIfWhiteSpace(r.UpperLimit),
+                LowerLimit = AdoS0QmsFqcInspectionSpecsController.NormalizeResultType(r.ResultType) == "NON_NUMERIC" ? null : NullIfWhiteSpace(r.LowerLimit),
+                TechnicalRequirement = NullIfWhiteSpace(r.TechnicalRequirement),
+                InspectionMethod = NullIfWhiteSpace(r.InspectionMethod),
+                Instrument = NullIfWhiteSpace(r.Instrument),
+                TechnicalStandard = NullIfWhiteSpace(r.TechnicalStandard),
+                SamplingPlan = NullIfWhiteSpace(r.SamplingPlan),
+                SizeSpecifications = NullIfWhiteSpace(r.SizeSpecifications),
+            },
+            tenantId, createdIds);
+
+        // 导入是「全或无」:Accepted=false 时未写入任何数据,无需同步。
+        // Accepted=true 时逐条同步桥表,否则导入进来的检规对 S7 完全不可见。
+        if (result.Accepted)
+        {
+            foreach (var id in createdIds) await _specMap.SyncSpecAsync(tenantId, id);
+        }
         return Ok(result);
     }
 
@@ -238,7 +263,8 @@ public class AdoS0QmsSpecImportController : ControllerBase
         Func<TRow, string, TMaster> masterFactory,
         Func<TMaster, long> idOf,
         Func<TRow, long, TEntry> entryFactory,
-        long tenantId)
+        long tenantId,
+        List<long>? createdMasterIds = null)
         where TRow : class, new()
         where TMaster : class, new()
         where TEntry : class, new()
@@ -293,6 +319,7 @@ public class AdoS0QmsSpecImportController : ControllerBase
                 typeof(TMaster).GetProperty(nameof(AdoS0QmsRawInspectionSpec.TenantId))?.SetValue(master, tid);
                 await masterRep.AsInsertable(master).ExecuteReturnEntityAsync();
                 var masterId = idOf(master);
+                createdMasterIds?.Add(masterId);
                 result.ImportedMasters++;
 
                 foreach (var row in g.Rows)
@@ -402,6 +429,26 @@ public class AdoS0QmsSpecImportController : ControllerBase
         Version = rows.Select(r => r.Version).FirstOrDefault(v => v.HasValue),
     };
 
+    /// <summary>成品检规导入头合并(续行可省略除文件编号外的头字段)。</summary>
+    private static AdoS0QmsFinishedInspectionSpecImportRow MergeFinishedHeader(List<AdoS0QmsFinishedInspectionSpecImportRow> rows) => new()
+    {
+        FileNumber = FirstNonBlank(rows, r => r.FileNumber),
+        VersionNo = FirstNonBlank(rows, r => r.VersionNo),
+        ProductName = FirstNonBlank(rows, r => r.ProductName),
+        ProductModel = FirstNonBlank(rows, r => r.ProductModel),
+        EffectiveDate = FirstNonBlank(rows, r => r.EffectiveDate),
+        MaterialCode = FirstNonBlank(rows, r => r.MaterialCode),
+        Attachment = FirstNonBlank(rows, r => r.Attachment),
+    };
+
+    /// <summary>成品检规必填集(与 Create/Update 同口径:文件编号 / 物料编码 / 版本)。</summary>
+    private static IReadOnlyList<(string, string?)> FinishedRequiredHeaderFields(AdoS0QmsFinishedInspectionSpecImportRow r) =>
+    [
+        ("文件编号", r.FileNumber),
+        ("物料编码", r.MaterialCode),
+        ("版本", r.VersionNo),
+    ];
+
     /// <summary>原材料检规必填集(UAT-S0-10 拍板)。</summary>
     private static IReadOnlyList<(string, string?)> RawRequiredHeaderFields(AdoS0QmsRawInspectionSpecImportRow r) =>
     [

+ 100 - 34
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityFqcOqcControllers.cs

@@ -1,24 +1,44 @@
 using Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Quality;
+using Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using static Admin.NET.Plugin.AiDOP.Controllers.S0.Quality.AdoS0QmsControllerHelpers;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
 
+/// <summary>
+/// FQC(成品)检验规范维护 —— FUNC-S0-052。
+///
+/// 【本轮变更:底层表切到 S7 运行链路】原实现写 `qms_fqcjygf` / `qms_fqcjygfzb`,该表**零下游消费者**,
+/// 维护出来的检规 S7 报检根本读不到。现改写 `qms_ccjygf` / `qms_ccjygfzb`——它才是
+/// `FqcApplyService` 生成检验单时经桥表解析并快照的表。见 `S7:D-002`(唯一事实源)。
+/// **路由、FUNC 权限码、菜单结构全部不变**,只换底层实体与字段集。
+///
+/// 【写后必须同步桥表】`ado_s7_fqc_spec_material_map` 是「物料 → 检规」的唯一解析入口;
+/// 不同步则新建的检规对 S7 完全不可见。Create / Update / Delete 三条写路径都调
+/// `FqcSpecMaterialMapService.SyncSpecAsync`(单检规粒度,人工绑定行受保护)。
+///
+/// 【租户】`qms_ccjygf(zb)` 实体不实现 `ITenantIdFilter`,全局过滤器不生效;
+/// 六条路径全部显式 `tenant_id` 谓词,**明细查询/删除也必须带 TenantId**——目标表存在
+/// 52 个 phantom glid / 444 行无主明细,只按 MasterId 过滤存在误删无主行的实际风险。
+/// </summary>
 [ApiController]
 [Route("api/s0/quality/fqc-inspection-specs")]
 [NonUnify]
 public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
 {
-    private readonly SqlSugarRepository<AdoS0QmsFqcInspectionSpec> _rep;
-    private readonly SqlSugarRepository<AdoS0QmsFqcInspectionSpecEntry> _entryRep;
+    private readonly SqlSugarRepository<AdoS0QmsFinishedInspectionSpec> _rep;
+    private readonly SqlSugarRepository<AdoS0QmsFinishedInspectionSpecEntry> _entryRep;
+    private readonly FqcSpecMaterialMapService _specMap;
 
     public AdoS0QmsFqcInspectionSpecsController(
-        SqlSugarRepository<AdoS0QmsFqcInspectionSpec> rep,
-        SqlSugarRepository<AdoS0QmsFqcInspectionSpecEntry> entryRep)
+        SqlSugarRepository<AdoS0QmsFinishedInspectionSpec> rep,
+        SqlSugarRepository<AdoS0QmsFinishedInspectionSpecEntry> entryRep,
+        FqcSpecMaterialMapService specMap)
     {
         _rep = rep;
         _entryRep = entryRep;
+        _specMap = specMap;
     }
 
     [HttpGet]
@@ -29,7 +49,8 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
         var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x =>
                 (x.FileNumber != null && x.FileNumber.Contains(q.Keyword!))
-                || (x.ApplicableModel != null && x.ApplicableModel.Contains(q.Keyword!))
+                || (x.ProductName != null && x.ProductName.Contains(q.Keyword!))
+                || (x.ProductModel != null && x.ProductModel.Contains(q.Keyword!))
                 || (x.MaterialCode != null && x.MaterialCode.Contains(q.Keyword!)));
         var total = await query.CountAsync();
         var list = await query.OrderByDescending(x => x.Id).Skip((q.Page - 1) * q.PageSize).Take(q.PageSize).ToListAsync();
@@ -42,45 +63,51 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
         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.Id).ToListAsync();
+        var items = await QueryEntriesAsync(id, tenantId);
         return Ok(new { master, items });
     }
 
     [HttpPost]
-    public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsFqcInspectionSpecUpsertDto dto)
+    public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsFinishedInspectionSpecUpsertDto dto)
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
-        var guardError = AdoS0QmsSpecAggregateGuard.ValidateCreate(FqcRequiredFields(dto), items.Count);
+        var guardError = AdoS0QmsSpecAggregateGuard.ValidateCreate(FqcRequiredFields(dto), items.Count)
+                         ?? AdoS0QmsSpecAggregateGuard.ValidateFinishedEntries(items);
         if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
+        long newId;
         try
         {
-            var master = new AdoS0QmsFqcInspectionSpec();
+            var master = new AdoS0QmsFinishedInspectionSpec();
             ApplyFqcSpec(master, dto);
             master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
             await SyncFqcEntriesAsync(master.Id, items, tenantId);
             await db.Ado.CommitTranAsync();
-            return await GetDetailAsync(master.Id);
+            newId = master.Id;
         }
         catch
         {
             await db.Ado.RollbackTranAsync();
             throw;
         }
+        // 桥表同步放在主事务之外:同步失败不应回滚已保存的检规(用户可在维护页重试),
+        // 但必须在返回前完成,否则前端拿到「保存成功」而 S7 仍解析不到。
+        await _specMap.SyncSpecAsync(tenantId, newId);
+        return await GetDetailAsync(newId);
     }
 
     [HttpPut("{id:long}")]
-    public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsFqcInspectionSpecUpsertDto dto)
+    public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0QmsFinishedInspectionSpecUpsertDto dto)
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
         // 写入集保留历史已持久化的明细行(含空行),只丢弃本次新补的空占位;
         // 「原有明细数」按 meaningful 计,避免全空明细的历史单被自己的空行锁死。
-        var existingRows = await _entryRep.AsQueryable().Where(x => x.MasterId == id).ToListAsync();
+        var existingRows = await QueryEntriesAsync(id, tenantId);
         var items = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(dto.Items);
         var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
             [
@@ -89,7 +116,8 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
                 ("版本", master.VersionNo, dto.VersionNo)
             ],
             AdoS0QmsSpecAggregateGuard.CountMeaningful(existingRows),
-            AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items).Count);
+            AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items).Count)
+            ?? AdoS0QmsSpecAggregateGuard.ValidateFinishedEntries(items);
         if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
@@ -97,84 +125,122 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
         {
             ApplyFqcSpec(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
-            await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
+            await DeleteEntriesAsync(id, tenantId);
             await SyncFqcEntriesAsync(id, items, tenantId);
             await db.Ado.CommitTranAsync();
-            return await GetDetailAsync(id);
         }
         catch
         {
             await db.Ado.RollbackTranAsync();
             throw;
         }
+        // wlbm / sxrq / bb 任一变更都会改变解析结果与生效日期排序,一律重同步(判断成本高于直接跑)
+        await _specMap.SyncSpecAsync(tenantId, id);
+        return await GetDetailAsync(id);
     }
 
+    /// <summary>
+    /// 删除检规。**已被检验单引用则拒绝**——`qms_qcpp_inspbill.jygfid` 是 varchar 无 FK,
+    /// 物理删除会静默产生悬空指针、断掉质量记录的受控文件追溯链。
+    /// </summary>
     [HttpDelete("{id:long}")]
     public async Task<IActionResult> DeleteAsync(long id)
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var master = await _rep.ByIdScopedByColumnAsync(id, tenantId);
         if (master == null) return NotFound();
+
+        var referenced = await _rep.Context.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM qms_qcpp_inspbill WHERE tenant_id=@t AND jygfid=@j",
+            new SugarParameter("@t", tenantId), new SugarParameter("@j", id.ToString()));
+        if (referenced > 0)
+            return AdoS0ApiErrors.InvalidRequest($"该检规已被 {referenced} 张检验单引用,不能删除");
+
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
         {
-            await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
+            await DeleteEntriesAsync(id, tenantId);
             await _rep.DeleteAsync(master);
             await db.Ado.CommitTranAsync();
-            return Ok(new { message = "删除成功" });
         }
         catch
         {
             await db.Ado.RollbackTranAsync();
             throw;
         }
+        // 主表已不存在 → SyncSpec 只清自动映射行;人工绑定行保留待人工处置(不静默删人工痕迹)
+        await _specMap.SyncSpecAsync(tenantId, id);
+        return Ok(new { message = "删除成功" });
     }
 
+    // ==================== 内部 ====================
+
+    /// <summary>明细查询:MasterId + TenantId 双条件(目标表存在无主明细,不能只按 MasterId)。</summary>
+    private Task<List<AdoS0QmsFinishedInspectionSpecEntry>> QueryEntriesAsync(long masterId, long tenantId)
+        => _entryRep.AsQueryable()
+            .Where(x => x.MasterId == masterId && x.TenantId == tenantId)
+            .OrderBy(x => x.SeqNo).OrderBy(x => x.Id).ToListAsync();
+
+    /// <summary>明细删除:同上,必须带 TenantId。</summary>
+    private Task<int> DeleteEntriesAsync(long masterId, long tenantId)
+        => _entryRep.AsDeleteable()
+            .Where(x => x.MasterId == masterId && x.TenantId == tenantId).ExecuteCommandAsync();
+
     /// <summary>FQC 检规必填集(UAT-S0-10 拍板;本轮不含「适用型号」,缺业务依据)。</summary>
-    private static IReadOnlyList<(string, string?)> FqcRequiredFields(AdoS0QmsFqcInspectionSpecUpsertDto dto) =>
+    private static IReadOnlyList<(string, string?)> FqcRequiredFields(AdoS0QmsFinishedInspectionSpecUpsertDto dto) =>
     [
         ("文件编号", dto.FileNumber),
         ("物料编码", dto.MaterialCode),
         ("版本", dto.VersionNo),
     ];
 
-
-    private static void ApplyFqcSpec(AdoS0QmsFqcInspectionSpec entity, AdoS0QmsFqcInspectionSpecUpsertDto dto)
+    private static void ApplyFqcSpec(AdoS0QmsFinishedInspectionSpec entity, AdoS0QmsFinishedInspectionSpecUpsertDto dto)
     {
-        entity.ApplicableModel = NullIfWhiteSpace(dto.ApplicableModel);
         entity.FileNumber = NullIfWhiteSpace(dto.FileNumber);
         entity.VersionNo = NullIfWhiteSpace(dto.VersionNo);
+        entity.ProductName = NullIfWhiteSpace(dto.ProductName);
+        entity.ProductModel = NullIfWhiteSpace(dto.ProductModel);
         entity.EffectiveDate = NullIfWhiteSpace(dto.EffectiveDate);
-        entity.Attachment = NullIfWhiteSpace(dto.Attachment);
         entity.MaterialCode = NullIfWhiteSpace(dto.MaterialCode);
-        entity.Attachment2 = NullIfWhiteSpace(dto.Attachment2);
-        entity.Version = dto.Version;
+        entity.Attachment = NullIfWhiteSpace(dto.Attachment);
     }
 
-    private async Task SyncFqcEntriesAsync(long masterId, IEnumerable<AdoS0QmsFqcInspectionSpecEntryDto> items, long tenantId)
+    private async Task SyncFqcEntriesAsync(long masterId, IEnumerable<AdoS0QmsFinishedInspectionSpecEntryDto> items, long tenantId)
     {
         foreach (var item in items)
         {
-            var entity = new AdoS0QmsFqcInspectionSpecEntry
+            var entity = new AdoS0QmsFinishedInspectionSpecEntry
             {
                 TenantId = tenantId,
                 MasterId = masterId,
-                OperationCode = NullIfWhiteSpace(item.OperationCode),
-                OperationName = NullIfWhiteSpace(item.OperationName),
+                SeqNo = NullIfWhiteSpace(item.SeqNo),
                 InspectionItem = NullIfWhiteSpace(item.InspectionItem),
+                ResultType = NormalizeResultType(item.ResultType),
+                UpperLimit = NullIfWhiteSpace(item.UpperLimit),
+                LowerLimit = NullIfWhiteSpace(item.LowerLimit),
+                TechnicalRequirement = NullIfWhiteSpace(item.TechnicalRequirement),
                 InspectionMethod = NullIfWhiteSpace(item.InspectionMethod),
-                InspectionSpec = NullIfWhiteSpace(item.InspectionSpec),
-                ImageCategory = NullIfWhiteSpace(item.ImageCategory),
-                InspectionFrequency = NullIfWhiteSpace(item.InspectionFrequency),
+                Instrument = NullIfWhiteSpace(item.Instrument),
+                InspectionClause = NullIfWhiteSpace(item.InspectionClause),
                 TechnicalStandard = NullIfWhiteSpace(item.TechnicalStandard),
-                PeelingForce = item.PeelingForce,
-                UpperLimit = NullIfWhiteSpace(item.UpperLimit),
-                LowerLimit = NullIfWhiteSpace(item.LowerLimit)
+                SamplingPlan = NullIfWhiteSpace(item.SamplingPlan),
+                Record = NullIfWhiteSpace(item.Record),
+                SizeSpecifications = NullIfWhiteSpace(item.SizeSpecifications),
+                Remarks = NullIfWhiteSpace(item.Remarks)
             };
+            // 非数值型不承载上下限:避免脏残留在生成检验单时被当成数值判定条件快照出去
+            if (entity.ResultType == "NON_NUMERIC") { entity.UpperLimit = null; entity.LowerLimit = null; }
             await _entryRep.AsInsertable(entity).ExecuteCommandAsync();
         }
     }
+
+    /// <summary>录入类型归一:大小写不敏感,空/未知一律落 NULL(=未设置,运行期退化为人工判定)。</summary>
+    internal static string? NormalizeResultType(string? value)
+    {
+        var v = (value ?? string.Empty).Trim().ToUpperInvariant();
+        return v is "NUMERIC" or "NON_NUMERIC" ? v : null;
+    }
 }
 
 [ApiController]

+ 85 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Quality/AdoS0QmsFinishedSpecDtos.cs

@@ -0,0 +1,85 @@
+using System.ComponentModel.DataAnnotations;
+using MiniExcelLibs.Attributes;
+
+namespace Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
+
+/// <summary>
+/// 成品检验规范(FQC)明细 DTO。
+/// 字段集对齐 `qms_ccjygfzb` 中被检验单快照消费的列——少一个,S7 生成检验单时该列就是空的。
+/// </summary>
+public class AdoS0QmsFinishedInspectionSpecEntryDto
+{
+    public long? Id { get; set; }
+
+    /// <summary>序号。空/非数字不报错,但 S7 快照的 xh 会落 NULL、排序退化为按 id。</summary>
+    [MaxLength(800)] public string? SeqNo { get; set; }
+
+    /// <summary>检验项目。S7 生成检验单时**强制非空**(空值会整单拒绝生成)。</summary>
+    public string? InspectionItem { get; set; }
+
+    /// <summary>录入类型:NUMERIC / NON_NUMERIC / 空(未设置)。其它值一律拒绝。</summary>
+    [MaxLength(20)] public string? ResultType { get; set; }
+
+    /// <summary>数值上限。仅 NUMERIC 有意义;长度上界 255 由快照列 inspbillst.sx 决定。</summary>
+    [MaxLength(255)] public string? UpperLimit { get; set; }
+
+    /// <summary>数值下限。同上。</summary>
+    [MaxLength(255)] public string? LowerLimit { get; set; }
+
+    public string? TechnicalRequirement { get; set; }
+    public string? InspectionMethod { get; set; }
+    public string? Instrument { get; set; }
+    public string? InspectionClause { get; set; }
+    public string? TechnicalStandard { get; set; }
+    [MaxLength(1000)] public string? SamplingPlan { get; set; }
+    public string? Record { get; set; }
+    [MaxLength(800)] public string? SizeSpecifications { get; set; }
+    [MaxLength(800)] public string? Remarks { get; set; }
+}
+
+/// <summary>
+/// 成品检验规范(FQC)主单 + 明细 上行 DTO。
+///
+/// 【为什么一个 [Required] 都没有】Create 与 Update 共用本 DTO;任何 [Required] 都会在
+/// MVC 模型绑定阶段先于 <see cref="Controllers.S0.Quality.AdoS0QmsSpecAggregateGuard"/> 触发,
+/// 把 Update 直接挡成 400,破坏「历史兼容、禁止劣化」语义。必填一律由守卫承担。
+/// </summary>
+public class AdoS0QmsFinishedInspectionSpecUpsertDto
+{
+    [MaxLength(800)] public string? FileNumber { get; set; }
+    [MaxLength(800)] public string? VersionNo { get; set; }
+    [MaxLength(800)] public string? ProductName { get; set; }
+    [MaxLength(800)] public string? ProductModel { get; set; }
+    [MaxLength(800)] public string? EffectiveDate { get; set; }
+
+    /// <summary>适用物料编码。桥表按分隔符切 token 后与 ItemMaster 精确匹配(禁 LIKE)。</summary>
+    public string? MaterialCode { get; set; }
+
+    public string? Attachment { get; set; }
+
+    public List<AdoS0QmsFinishedInspectionSpecEntryDto> Items { get; set; } = [];
+}
+
+/// <summary>成品检验规范 Excel 导入行(主从同表,续行可省略除文件编号外的头字段)。</summary>
+public class AdoS0QmsFinishedInspectionSpecImportRow
+{
+    [ExcelColumnName("文件编号")] [MaxLength(800)] public string? FileNumber { get; set; }
+    [ExcelColumnName("版本")] [MaxLength(800)] public string? VersionNo { get; set; }
+    [ExcelColumnName("产品名称")] [MaxLength(800)] public string? ProductName { get; set; }
+    [ExcelColumnName("产品型号")] [MaxLength(800)] public string? ProductModel { get; set; }
+    [ExcelColumnName("生效日期")] [MaxLength(800)] public string? EffectiveDate { get; set; }
+    [ExcelColumnName("物料编码")] public string? MaterialCode { get; set; }
+    [ExcelColumnName("附件")] public string? Attachment { get; set; }
+
+    [ExcelColumnName("明细-序号")] public string? SeqNo { get; set; }
+    [ExcelColumnName("明细-检验项目")] public string? InspectionItem { get; set; }
+    [ExcelColumnName("明细-录入类型(NUMERIC/NON_NUMERIC)")] public string? ResultType { get; set; }
+    [ExcelColumnName("明细-上限")] [MaxLength(255)] public string? UpperLimit { get; set; }
+    [ExcelColumnName("明细-下限")] [MaxLength(255)] public string? LowerLimit { get; set; }
+    [ExcelColumnName("明细-技术要求")] public string? TechnicalRequirement { get; set; }
+    [ExcelColumnName("明细-检验方法")] public string? InspectionMethod { get; set; }
+    [ExcelColumnName("明细-检验仪器")] public string? Instrument { get; set; }
+    [ExcelColumnName("明细-技术标准")] public string? TechnicalStandard { get; set; }
+    [ExcelColumnName("明细-抽样方案")] public string? SamplingPlan { get; set; }
+    [ExcelColumnName("明细-尺寸规格")] public string? SizeSpecifications { get; set; }
+}

+ 117 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Quality/AdoS0QmsFinishedSpecEntities.cs

@@ -0,0 +1,117 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S0.Quality;
+
+/// <summary>
+/// 成品检验规范(FQC)主表 —— S7 FQC 运行链路的唯一事实源。
+///
+/// 【为什么不是 qms_fqcjygf】S0 侧原 `qms_fqcjygf` 无任何下游消费者;S7 报检 → 生成检验单
+/// 实际读的是 `qms_ccjygf` / `qms_ccjygfzb`(`FqcApplyService` 经桥表 `ado_s7_fqc_spec_material_map`
+/// 精确解析)。本实体把 S0 维护入口直接落到运行链路的表上,见 `S7:D-002`。
+///
+/// 【物理事实(2026-09-02 实测,勿假设)】
+///  · 本表**无 PRIMARY KEY、无任何索引、无唯一约束**;Id 由 SqlSugar `DataExecuting` AOP
+///    在 `IsPrimaryKey=true && long==0` 时赋雪花值,不依赖 DB 自增;
+///  · 无 status / org / 审计列,字段就是下面这 9 个,不要凭空引用不存在的列;
+///  · 列名大小写混杂(`qms_ccjygfzb.UpperToleranceLimit` 为 PascalCase),
+///    在 `EnableUnderLine=false` 下必须逐列显式 `ColumnName`,否则映射错列。
+///
+/// 【租户】本实体**不实现 `ITenantIdFilter`**,全局租户过滤器对它不生效;
+/// 租户隔离 100% 依赖调用方显式谓词(见 `AdoS0QmsFqcInspectionSpecsController`)。
+/// </summary>
+[SugarTable("qms_ccjygf", "成品检验规范")]
+public class AdoS0QmsFinishedInspectionSpec
+{
+    [SugarColumn(ColumnName = "id", ColumnDescription = "主键", IsPrimaryKey = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDescription = "租户Id", IsNullable = false, ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "wjbh", ColumnDescription = "文件编号", IsNullable = true, Length = 800)]
+    public string? FileNumber { get; set; }
+
+    [SugarColumn(ColumnName = "bb", ColumnDescription = "版本", IsNullable = true, Length = 800)]
+    public string? VersionNo { get; set; }
+
+    [SugarColumn(ColumnName = "cpmc", ColumnDescription = "产品名称", IsNullable = true, Length = 800)]
+    public string? ProductName { get; set; }
+
+    [SugarColumn(ColumnName = "cpxh", ColumnDescription = "产品型号", IsNullable = true, Length = 800)]
+    public string? ProductModel { get; set; }
+
+    /// <summary>生效日期。源列是 varchar(800) 自由文本(历史含 `2023..07.18`、`见受控章` 等脏值),
+    /// 故保持 string,不改列类型;桥表侧由 NormalizeDate 归一为 datetime。</summary>
+    [SugarColumn(ColumnName = "sxrq", ColumnDescription = "生效日期", IsNullable = true, Length = 800)]
+    public string? EffectiveDate { get; set; }
+
+    /// <summary>适用物料编码。历史为多物料脏串(分隔符不统一),新建议单码或分号分隔。</summary>
+    [SugarColumn(ColumnName = "wlbm", ColumnDescription = "物料编码", IsNullable = true, ColumnDataType = "text")]
+    public string? MaterialCode { get; set; }
+
+    [SugarColumn(ColumnName = "fj", ColumnDescription = "附件", IsNullable = true, ColumnDataType = "text")]
+    public string? Attachment { get; set; }
+}
+
+/// <summary>
+/// 成品检验规范-检验明细。列集合直接对齐 `FqcApplyService` 生成检验单时的快照读取集
+/// (`xh / jyxm / UpperToleranceLimit / LowerToleranceLimit / lrlx / jsyq / jyyqsbgj /
+///   jyff / jytk / jsbz / cyfa / jl / SizeSpecifications`),未被快照消费的历史列
+/// (`bztk` / `isPeel` / `isDisplay`)不纳入实体,避免 CodeFirst 误改结构。
+/// </summary>
+[SugarTable("qms_ccjygfzb", "成品检验规范-检验明细")]
+public class AdoS0QmsFinishedInspectionSpecEntry
+{
+    [SugarColumn(ColumnName = "id", ColumnDescription = "主键", IsPrimaryKey = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDescription = "租户Id", IsNullable = false, ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "glid", ColumnDescription = "主表ID", IsNullable = true, ColumnDataType = "bigint")]
+    public long? MasterId { get; set; }
+
+    /// <summary>序号。源列为 varchar(800);`FqcApplyService.ParseXh` 会尝试解析为 int,
+    /// 解析失败落 NULL(不报错),故此处保持 string。</summary>
+    [SugarColumn(ColumnName = "xh", ColumnDescription = "序号", IsNullable = true, Length = 800)]
+    public string? SeqNo { get; set; }
+
+    [SugarColumn(ColumnName = "jyxm", ColumnDescription = "检验项目", IsNullable = true, ColumnDataType = "text")]
+    public string? InspectionItem { get; set; }
+
+    /// <summary>录入类型:NUMERIC / NON_NUMERIC / NULL(未设置)。判定开关——
+    /// NUMERIC 走上下限自动判定,NON_NUMERIC 由检验员人工确认。</summary>
+    [SugarColumn(ColumnName = "lrlx", ColumnDescription = "录入类型", IsNullable = true, Length = 20)]
+    public string? ResultType { get; set; }
+
+    [SugarColumn(ColumnName = "UpperToleranceLimit", ColumnDescription = "上限", IsNullable = true, Length = 800)]
+    public string? UpperLimit { get; set; }
+
+    [SugarColumn(ColumnName = "LowerToleranceLimit", ColumnDescription = "下限", IsNullable = true, Length = 800)]
+    public string? LowerLimit { get; set; }
+
+    [SugarColumn(ColumnName = "jsyq", ColumnDescription = "技术要求", IsNullable = true, ColumnDataType = "text")]
+    public string? TechnicalRequirement { get; set; }
+
+    [SugarColumn(ColumnName = "jyff", ColumnDescription = "检验方法", IsNullable = true, ColumnDataType = "text")]
+    public string? InspectionMethod { get; set; }
+
+    [SugarColumn(ColumnName = "jyyqsbgj", ColumnDescription = "检验仪器设备工具", IsNullable = true, ColumnDataType = "text")]
+    public string? Instrument { get; set; }
+
+    [SugarColumn(ColumnName = "jytk", ColumnDescription = "检验条款", IsNullable = true, ColumnDataType = "text")]
+    public string? InspectionClause { get; set; }
+
+    [SugarColumn(ColumnName = "jsbz", ColumnDescription = "技术标准", IsNullable = true, ColumnDataType = "text")]
+    public string? TechnicalStandard { get; set; }
+
+    [SugarColumn(ColumnName = "cyfa", ColumnDescription = "抽样方案", IsNullable = true, Length = 1000)]
+    public string? SamplingPlan { get; set; }
+
+    [SugarColumn(ColumnName = "jl", ColumnDescription = "记录", IsNullable = true, ColumnDataType = "text")]
+    public string? Record { get; set; }
+
+    [SugarColumn(ColumnName = "SizeSpecifications", ColumnDescription = "尺寸规格", IsNullable = true, Length = 800)]
+    public string? SizeSpecifications { get; set; }
+
+    [SugarColumn(ColumnName = "Remarks", ColumnDescription = "备注", IsNullable = true, Length = 800)]
+    public string? Remarks { get; set; }
+}

+ 100 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcSpecMaterialMapService.cs

@@ -220,6 +220,106 @@ public class FqcSpecMaterialMapService : IDynamicApiController, ITransient
         return new { ok = true };
     }
 
+    /// <summary>
+    /// 单检规同步(S0 CRUD 后调用):只重建**这一条检规**的自动派生映射,不触碰同租户其他检规。
+    ///
+    /// 与 <see cref="Rebuild"/> 的区别:Rebuild 是「单租户全量」——会 DELETE 该租户所有 is_manual=0 行
+    /// 再整体重建;一次 S0 保存去跑全量既慢又会把其他检规的映射卷进同一事务。本方法把粒度收到
+    /// jygfid,用于 Create / Update(wlbm|sxrq|bb 变更) 后的即时一致。
+    ///
+    /// 硬边界(与 Rebuild 完全一致,不得放宽):
+    ///  ① DELETE 条件必须同时带 tenant_id + jygfid + **is_manual=0**——人工绑定行(is_manual=1)
+    ///     是脏 wlbm 的人工订正结果,优先级高于源串,绝不能被自动同步删掉;
+    ///  ② 禁止 LIKE 子串匹配,token 与 ItemMaster.ItemNum 精确比对;
+    ///  ③ 绝不修改源 qms_ccjygf.wlbm / sxrq。
+    ///
+    /// 调用方需自行保证 jygfid 属于 tenantId(本方法只按传入租户过滤,不做归属反查)。
+    /// </summary>
+    public async Task<object> SyncSpecAsync(long tenantId, long jygfid)
+    {
+        if (tenantId <= 0 || jygfid <= 0) return new { ok = false, reason = "invalid-args" };
+
+        var spec = await _db.Ado.SqlQuerySingleAsync<SpecRow>(
+            "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, sxrq AS Sxrq, wlbm AS Wlbm FROM qms_ccjygf WHERE id=@id AND tenant_id=@t LIMIT 1",
+            new SugarParameter("@id", jygfid), new SugarParameter("@t", tenantId));
+
+        // 检规已不存在(例如 Delete 后调用)→ 只清自动行,人工行保留待人工处置
+        var norm = spec == null ? null : NormalizeDate(spec.Sxrq);
+        var rows = new List<Dictionary<string, object>>();
+        int exact = 0, notFound = 0, glue = 0, total = 0;
+
+        if (spec != null)
+        {
+            var itemSet = new HashSet<string>(
+                await _db.Ado.SqlQueryAsync<string>(
+                    "SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id=@t AND ItemNum IS NOT NULL",
+                    new SugarParameter("@t", tenantId)),
+                StringComparer.OrdinalIgnoreCase);
+
+            var now = DateTime.Now;
+            var userId = _userManager.UserId;
+            var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+            int seq = 0;
+            foreach (var raw in SplitTokens(spec.Wlbm))
+            {
+                var cleaned = raw.Trim();
+                if (cleaned.Length == 0) continue;
+                if (!seen.Add(cleaned)) continue;
+                seq++; total++;
+
+                string status;
+                string? matched = null;
+                if (itemSet.Contains(cleaned)) { status = "EXACT"; matched = cleaned; exact++; }
+                else if (!PlainCodeShape.IsMatch(cleaned)) { status = "GLUE_UNRESOLVED"; glue++; }
+                else { status = "NOT_FOUND"; notFound++; }
+
+                rows.Add(new Dictionary<string, object>
+                {
+                    ["id"] = YitIdHelper.NextId(),
+                    ["tenant_id"] = tenantId,
+                    ["jygfid"] = spec.Id,
+                    ["wjbh"] = spec.Wjbh,
+                    ["bb"] = spec.Bb,
+                    ["seq"] = seq,
+                    ["raw_token"] = raw,
+                    ["cleaned_token"] = cleaned,
+                    ["material_code"] = (object?)matched ?? DBNull.Value,
+                    ["match_status"] = status,
+                    ["normalized_effective_date"] = (object?)norm ?? DBNull.Value,
+                    ["note"] = DBNull.Value,
+                    ["is_manual"] = 0,
+                    ["status"] = 1,
+                    ["create_time"] = now,
+                    ["create_user_id"] = (object?)userId ?? DBNull.Value,
+                });
+            }
+        }
+
+        var tran = await _db.Ado.UseTranAsync(async () =>
+        {
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM ado_s7_fqc_spec_material_map WHERE tenant_id=@t AND jygfid=@j AND is_manual=0",
+                new SugarParameter("@t", tenantId), new SugarParameter("@j", jygfid));
+            if (rows.Count > 0) await BulkInsertMapRowsAsync(rows);
+
+            // 人工行随主表 wjbh/bb/sxrq 变更同步元数据(不改归属、不改 is_manual、不删除)
+            if (spec != null)
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    @"UPDATE ado_s7_fqc_spec_material_map
+                      SET wjbh=@w, bb=@b, normalized_effective_date=@d
+                      WHERE tenant_id=@t AND jygfid=@j AND is_manual=1",
+                    new SugarParameter("@w", (object?)spec.Wjbh ?? DBNull.Value),
+                    new SugarParameter("@b", (object?)spec.Bb ?? DBNull.Value),
+                    new SugarParameter("@d", (object?)norm ?? DBNull.Value),
+                    new SugarParameter("@t", tenantId), new SugarParameter("@j", jygfid));
+            }
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("同步检规物料映射失败");
+
+        return new { ok = true, jygfid, token = total, exact, notFound, glueUnresolved = glue };
+    }
+
     /// <summary>解绑人工绑定。</summary>
     [DisplayName("解绑检规物料")]
     [HttpPost("unbind")]