using System.ComponentModel.DataAnnotations; using Admin.NET.Core; using Microsoft.AspNetCore.Http; using Admin.NET.Plugin.AiDOP.Dto.S0.Quality; using Admin.NET.Plugin.AiDOP.Entity.S0.Quality; using Admin.NET.Plugin.AiDOP.Infrastructure; using static Admin.NET.Plugin.AiDOP.Controllers.S0.Quality.AdoS0QmsControllerHelpers; namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality; /// /// S0 质量建模 - 4 个检验规范列表(原材料 / 过程 / FQC / OQC)Excel 模板导入。 /// 每个列表提供“下载导入模板”(GET import-template) 与“上传导入”(POST import) 两个端点。 /// 模板为一张平铺表:主表列 + 明细列,按“文件编号”分组还原为一条主单 + 多条明细。 /// 写入语义复刻各列表现有 Create(纯新增,不去重、不 upsert)。 /// /// 【全或无】UAT-S0-10 拍板(2026-09-02):先做整份 preflight 校验,任一行或任一组不合格即整份拒绝、 /// 0 落库;全部通过后才进入写事务。必填契约与普通 Create 入口共用 AdoS0QmsSpecAggregateGuard。 /// [ApiController] [Route("api/s0/quality")] [NonUnify] public class AdoS0QmsSpecImportController : ControllerBase { private readonly SqlSugarRepository _rawRep; private readonly SqlSugarRepository _rawEntryRep; private readonly SqlSugarRepository _processRep; private readonly SqlSugarRepository _processEntryRep; // FQC 侧已切到 S7 运行链路表 qms_ccjygf / qms_ccjygfzb(见 AdoS0QmsFqcInspectionSpecsController 注释) private readonly SqlSugarRepository _fqcRep; private readonly SqlSugarRepository _fqcEntryRep; private readonly SqlSugarRepository _oqcRep; private readonly SqlSugarRepository _oqcEntryRep; private readonly FinishedWarehouse.FqcSpecMaterialMapService _specMap; /// S5 IQC 检规↔物料桥表同步(B-1):原材料检规导入后重建派生映射。 private readonly MaterialWarehouse.S5IqcSpecMaterialMapService _iqcSpecMap; public AdoS0QmsSpecImportController( SqlSugarRepository rawRep, SqlSugarRepository rawEntryRep, SqlSugarRepository processRep, SqlSugarRepository processEntryRep, SqlSugarRepository fqcRep, SqlSugarRepository fqcEntryRep, SqlSugarRepository oqcRep, SqlSugarRepository oqcEntryRep, FinishedWarehouse.FqcSpecMaterialMapService specMap, MaterialWarehouse.S5IqcSpecMaterialMapService iqcSpecMap) { _iqcSpecMap = iqcSpecMap; _rawRep = rawRep; _rawEntryRep = rawEntryRep; _processRep = processRep; _processEntryRep = processEntryRep; _fqcRep = fqcRep; _fqcEntryRep = fqcEntryRep; _oqcRep = oqcRep; _oqcEntryRep = oqcEntryRep; _specMap = specMap; } // ==================== 模板下载 ==================== [HttpGet("raw-inspection-specs/import-template")] public Task RawTemplate() => MiniExcelUtil.ExportExcelTemplate("原材料检验规范导入模板"); [HttpGet("process-inspection-specs/import-template")] public Task ProcessTemplate() => MiniExcelUtil.ExportExcelTemplate("过程检验规范导入模板"); [HttpGet("fqc-inspection-specs/import-template")] public Task FqcTemplate() => MiniExcelUtil.ExportExcelTemplate("FQC检验规范导入模板"); [HttpGet("oqc-inspection-specs/import-template")] public Task OqcTemplate() => MiniExcelUtil.ExportExcelTemplate("OQC检验规范导入模板"); // ==================== 导入 ==================== [HttpPost("raw-inspection-specs/import")] public async Task RawImport(IFormFile file) { 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(); var result = await RunImportAsync( file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeRawHeader, RawRequiredHeaderFields, _rawRep, _rawEntryRep, (r, fn) => new AdoS0QmsRawInspectionSpec { FileNumber = fn, VersionNo = NullIfWhiteSpace(r.VersionNo), DrawingNo = NullIfWhiteSpace(r.DrawingNo), RawMaterialName = NullIfWhiteSpace(r.RawMaterialName), MaterialCode = NullIfWhiteSpace(r.MaterialCode), EffectiveDate = NullIfWhiteSpace(r.EffectiveDate), DrawingVersion = NullIfWhiteSpace(r.DrawingVersion), MaterialGrade = NullIfWhiteSpace(r.MaterialGrade), CavityOrMold = NullIfWhiteSpace(r.CavityOrMold), Attachment = NullIfWhiteSpace(r.Attachment), FileName = NullIfWhiteSpace(r.FileName), Title = NullIfWhiteSpace(r.Title), }, m => m.Id, (r, mid) => new AdoS0QmsRawInspectionSpecEntry { MasterId = mid, Seq = r.Seq, InspectionItem = NullIfWhiteSpace(r.InspectionItem), InspectionStandard = NullIfWhiteSpace(r.InspectionStandard), InspectionMethod = NullIfWhiteSpace(r.InspectionMethod), SamplingScheme = NullIfWhiteSpace(r.SamplingScheme), UpperLimit = NullIfWhiteSpace(r.UpperLimit), LowerLimit = NullIfWhiteSpace(r.LowerLimit), }, 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); } [HttpPost("process-inspection-specs/import")] public async Task ProcessImport(IFormFile file) { 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( file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeProcessLikeHeader, ProcessLikeRequiredHeaderFields, _processRep, _processEntryRep, (r, fn) => new AdoS0QmsProcessInspectionSpec { FileNumber = fn, ApplicableModel = NullIfWhiteSpace(r.ApplicableModel), VersionNo = NullIfWhiteSpace(r.VersionNo), 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(r, mid), tenantId); return Ok(result); } [HttpPost("fqc-inspection-specs/import")] public async Task FqcImport(IFormFile file) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; if (file == null || file.Length == 0) return BadRequest(new { message = "请上传 Excel 文件" }); var createdIds = new List(); var result = await RunImportAsync( file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeFinishedHeader, FinishedRequiredHeaderFields, _fqcRep, _fqcEntryRep, (r, fn) => new AdoS0QmsFinishedInspectionSpec { FileNumber = fn, VersionNo = NullIfWhiteSpace(r.VersionNo), ProductName = NullIfWhiteSpace(r.ProductName), ProductModel = NullIfWhiteSpace(r.ProductModel), EffectiveDate = NullIfWhiteSpace(r.EffectiveDate), MaterialCode = NullIfWhiteSpace(r.MaterialCode), Attachment = NullIfWhiteSpace(r.Attachment), }, m => m.Id, (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); } [HttpPost("oqc-inspection-specs/import")] public async Task OqcImport(IFormFile file) { 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( file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeProcessLikeHeader, ProcessLikeRequiredHeaderFields, _oqcRep, _oqcEntryRep, (r, fn) => new AdoS0QmsOqcInspectionSpec { FileNumber = fn, ApplicableModel = NullIfWhiteSpace(r.ApplicableModel), VersionNo = NullIfWhiteSpace(r.VersionNo), 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(r, mid), tenantId); return Ok(result); } // ==================== 共用逻辑 ==================== /// 过程/FQC/OQC 三表明细结构一致,共用一个明细构造器(各表 Entry 字段名相同)。 private static TEntry BuildProcessLikeEntry(AdoS0QmsProcessLikeInspectionSpecImportRow r, long masterId) where TEntry : class, new() { var entry = new TEntry(); switch (entry) { case AdoS0QmsProcessInspectionSpecEntry p: p.MasterId = masterId; p.OperationCode = NullIfWhiteSpace(r.OperationCode); p.OperationName = NullIfWhiteSpace(r.OperationName); p.InspectionItem = NullIfWhiteSpace(r.InspectionItem); p.InspectionMethod = NullIfWhiteSpace(r.InspectionMethod); p.InspectionSpec = NullIfWhiteSpace(r.InspectionSpec); p.InspectionFrequency = NullIfWhiteSpace(r.InspectionFrequency); p.UpperLimit = NullIfWhiteSpace(r.UpperLimit); p.LowerLimit = NullIfWhiteSpace(r.LowerLimit); break; case AdoS0QmsFqcInspectionSpecEntry f: f.MasterId = masterId; f.OperationCode = NullIfWhiteSpace(r.OperationCode); f.OperationName = NullIfWhiteSpace(r.OperationName); f.InspectionItem = NullIfWhiteSpace(r.InspectionItem); f.InspectionMethod = NullIfWhiteSpace(r.InspectionMethod); f.InspectionSpec = NullIfWhiteSpace(r.InspectionSpec); f.InspectionFrequency = NullIfWhiteSpace(r.InspectionFrequency); f.UpperLimit = NullIfWhiteSpace(r.UpperLimit); f.LowerLimit = NullIfWhiteSpace(r.LowerLimit); break; case AdoS0QmsOqcInspectionSpecEntry o: o.MasterId = masterId; o.OperationCode = NullIfWhiteSpace(r.OperationCode); o.OperationName = NullIfWhiteSpace(r.OperationName); o.InspectionItem = NullIfWhiteSpace(r.InspectionItem); o.InspectionMethod = NullIfWhiteSpace(r.InspectionMethod); o.InspectionSpec = NullIfWhiteSpace(r.InspectionSpec); o.InspectionFrequency = NullIfWhiteSpace(r.InspectionFrequency); o.UpperLimit = NullIfWhiteSpace(r.UpperLimit); o.LowerLimit = NullIfWhiteSpace(r.LowerLimit); break; } return entry; } private async Task RunImportAsync( IFormFile file, Func keySelector, Func detailHasValue, Func, TRow> headerMerger, Func> requiredHeaderFields, SqlSugarRepository masterRep, SqlSugarRepository entryRep, Func masterFactory, Func idOf, Func entryFactory, long tenantId, List? createdMasterIds = null) where TRow : class, new() where TMaster : class, new() where TEntry : class, new() { var rows = (await MiniExcelUtil.GetImportExcelData(file)).ToList(); var result = new AdoS0QmsSpecImportResultDto(); // ---------- Preflight(全或无):本段完全不碰数据库 ---------- var groups = BuildGroups(rows, keySelector, result); // 归组之后再合并头字段,然后按聚合校验必填——续行允许省略除文件编号外的头字段, // 逐行校验会把合法续行误判为空值非法,故必填只在这一层判。 var merged = new Dictionary, TRow>(); foreach (var g in groups) { var header = headerMerger(g.Rows); merged[g] = header; var itemCount = g.Rows.Count(detailHasValue); var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(requiredHeaderFields(header), itemCount); if (error != null) { result.FailedRows++; result.RowMessages.Add($"文件编号「{g.FileNumber}」(第 {g.FirstRowNo} 行起):{error}"); } } if (groups.Count == 0 && result.FailedRows == 0) { // 空文件不报成功,否则用户会看到绿色的“新增 0 条规范”而误以为导入生效。 result.Accepted = false; result.RowMessages.Add("未读取到任何数据行,请确认使用的是本页下载的导入模板且已填写数据。"); result.Message = "导入已拒绝:文件中没有数据行,未写入任何数据。"; return result; } if (result.FailedRows > 0) { result.Accepted = false; result.Message = $"导入已拒绝:共读取 {result.TotalRows} 行,发现 {result.FailedRows} 处不合格,未写入任何数据。请修正后重新导入。"; return result; } // ---------- 全部通过后才进入写事务 ---------- var tid = tenantId; var db = masterRep.Context; await db.Ado.BeginTranAsync(); try { foreach (var g in groups) { var master = masterFactory(merged[g], g.FileNumber); 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) { if (!detailHasValue(row)) continue; var entry = entryFactory(row, masterId); typeof(TEntry).GetProperty(nameof(AdoS0QmsRawInspectionSpecEntry.TenantId))?.SetValue(entry, tid); await entryRep.AsInsertable(entry).ExecuteCommandAsync(); result.ImportedItems++; } } await db.Ado.CommitTranAsync(); } catch { await db.Ado.RollbackTranAsync(); throw; } result.Accepted = true; result.Message = $"导入完成:新增 {result.ImportedMasters} 条规范、{result.ImportedItems} 条明细,共读取 {result.TotalRows} 行。"; return result; } /// /// 按“文件编号”分组:同一文件编号的多行合并为一条主单 + 多条明细。 /// 文件编号是分组键,必须逐行出现——缺失的行无法归属任何主单,记为不合格(全或无语义下将导致整份拒绝)。 /// 同时逐行做特性校验([MaxLength],按实测 DB 列长),把超长值挡在 DB 之外,避免透到 INSERT 变成裸 500。 /// private static List> BuildGroups(IEnumerable rows, Func keySelector, AdoS0QmsSpecImportResultDto result) where TRow : class { var groups = new List>(); var index = new Dictionary>(); var rowNo = 1; // 表头占第 1 行,首个数据行为第 2 行 foreach (var row in rows) { rowNo++; result.TotalRows++; var validationResults = new List(); if (!Validator.TryValidateObject(row, new ValidationContext(row), validationResults, validateAllProperties: true)) { foreach (var v in validationResults) { result.FailedRows++; result.RowMessages.Add($"第 {rowNo} 行:{v.ErrorMessage}"); } } var key = keySelector(row)?.Trim(); if (string.IsNullOrWhiteSpace(key)) { result.FailedRows++; result.RowMessages.Add($"第 {rowNo} 行:文件编号为空,无法归属任何规范"); continue; } if (!index.TryGetValue(key, out var g)) { g = new SpecGroup { FileNumber = key, FirstRowNo = rowNo }; index[key] = g; groups.Add(g); } g.Rows.Add(row); } return groups; } // ==================== 头字段合并与必填集 ==================== // 合并口径:同组内每个头字段取第一个非空值。续行留空不覆盖,也不因首行留空而丢值。 private static string? FirstNonBlank(List rows, Func selector) { foreach (var row in rows) { var v = selector(row); if (!string.IsNullOrWhiteSpace(v)) return v; } return null; } private static AdoS0QmsRawInspectionSpecImportRow MergeRawHeader(List rows) => new() { FileNumber = FirstNonBlank(rows, r => r.FileNumber), VersionNo = FirstNonBlank(rows, r => r.VersionNo), DrawingNo = FirstNonBlank(rows, r => r.DrawingNo), RawMaterialName = FirstNonBlank(rows, r => r.RawMaterialName), MaterialCode = FirstNonBlank(rows, r => r.MaterialCode), EffectiveDate = FirstNonBlank(rows, r => r.EffectiveDate), DrawingVersion = FirstNonBlank(rows, r => r.DrawingVersion), MaterialGrade = FirstNonBlank(rows, r => r.MaterialGrade), CavityOrMold = FirstNonBlank(rows, r => r.CavityOrMold), Attachment = FirstNonBlank(rows, r => r.Attachment), FileName = FirstNonBlank(rows, r => r.FileName), Title = FirstNonBlank(rows, r => r.Title), }; private static AdoS0QmsProcessLikeInspectionSpecImportRow MergeProcessLikeHeader(List rows) => new() { ApplicableModel = FirstNonBlank(rows, r => r.ApplicableModel), FileNumber = FirstNonBlank(rows, r => r.FileNumber), VersionNo = FirstNonBlank(rows, r => r.VersionNo), EffectiveDate = FirstNonBlank(rows, r => r.EffectiveDate), MaterialCode = FirstNonBlank(rows, r => r.MaterialCode), Attachment = FirstNonBlank(rows, r => r.Attachment), Attachment2 = FirstNonBlank(rows, r => r.Attachment2), Version = rows.Select(r => r.Version).FirstOrDefault(v => v.HasValue), }; /// 成品检规导入头合并(续行可省略除文件编号外的头字段)。 private static AdoS0QmsFinishedInspectionSpecImportRow MergeFinishedHeader(List 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), }; /// 成品检规必填集(与 Create/Update 同口径:文件编号 / 物料编码 / 版本)。 private static IReadOnlyList<(string, string?)> FinishedRequiredHeaderFields(AdoS0QmsFinishedInspectionSpecImportRow r) => [ ("文件编号", r.FileNumber), ("物料编码", r.MaterialCode), ("版本", r.VersionNo), ]; /// 原材料检规必填集(UAT-S0-10 拍板)。 private static IReadOnlyList<(string, string?)> RawRequiredHeaderFields(AdoS0QmsRawInspectionSpecImportRow r) => [ ("文件编号", r.FileNumber), ("物料编码", r.MaterialCode), ("原材料名称", r.RawMaterialName), ("版本", r.VersionNo), ]; /// 过程 / FQC / OQC 检规必填集(本轮不含「适用型号」,缺业务依据,见拍板第 1 条)。 private static IReadOnlyList<(string, string?)> ProcessLikeRequiredHeaderFields(AdoS0QmsProcessLikeInspectionSpecImportRow r) => [ ("文件编号", r.FileNumber), ("物料编码", r.MaterialCode), ("版本", r.VersionNo), ]; private sealed class SpecGroup { public string FileNumber { get; init; } = string.Empty; /// 该分组首次出现的 Excel 行号,仅用于错误定位。 public int FirstRowNo { get; init; } public List Rows { get; } = []; } }