|
|
@@ -1,3 +1,4 @@
|
|
|
+using System.ComponentModel.DataAnnotations;
|
|
|
using Admin.NET.Core;
|
|
|
using Microsoft.AspNetCore.Http;
|
|
|
using Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
|
|
|
@@ -11,7 +12,10 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
|
|
|
/// S0 质量建模 - 4 个检验规范列表(原材料 / 过程 / FQC / OQC)Excel 模板导入。
|
|
|
/// 每个列表提供“下载导入模板”(GET import-template) 与“上传导入”(POST import) 两个端点。
|
|
|
/// 模板为一张平铺表:主表列 + 明细列,按“文件编号”分组还原为一条主单 + 多条明细。
|
|
|
-/// 写入语义复刻各列表现有 Create(纯新增,不去重、不 upsert),全过程单事务。
|
|
|
+/// 写入语义复刻各列表现有 Create(纯新增,不去重、不 upsert)。
|
|
|
+///
|
|
|
+/// 【全或无】UAT-S0-10 拍板(2026-09-02):先做整份 preflight 校验,任一行或任一组不合格即整份拒绝、
|
|
|
+/// 0 落库;全部通过后才进入写事务。必填契约与普通 Create 入口共用 AdoS0QmsSpecAggregateGuard。
|
|
|
/// </summary>
|
|
|
[ApiController]
|
|
|
[Route("api/s0/quality")]
|
|
|
@@ -73,7 +77,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 result = await RunImportAsync<AdoS0QmsRawInspectionSpecImportRow, AdoS0QmsRawInspectionSpec, AdoS0QmsRawInspectionSpecEntry>(
|
|
|
- file, r => r.FileNumber, RawDetailHasValue, _rawRep, _rawEntryRep,
|
|
|
+ file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeRawHeader, RawRequiredHeaderFields, _rawRep, _rawEntryRep,
|
|
|
(r, fn) => new AdoS0QmsRawInspectionSpec
|
|
|
{
|
|
|
FileNumber = fn,
|
|
|
@@ -111,7 +115,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 result = await RunImportAsync<AdoS0QmsProcessLikeInspectionSpecImportRow, AdoS0QmsProcessInspectionSpec, AdoS0QmsProcessInspectionSpecEntry>(
|
|
|
- file, r => r.FileNumber, ProcessLikeDetailHasValue, _processRep, _processEntryRep,
|
|
|
+ file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeProcessLikeHeader, ProcessLikeRequiredHeaderFields, _processRep, _processEntryRep,
|
|
|
(r, fn) => new AdoS0QmsProcessInspectionSpec
|
|
|
{
|
|
|
FileNumber = fn,
|
|
|
@@ -135,7 +139,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 result = await RunImportAsync<AdoS0QmsProcessLikeInspectionSpecImportRow, AdoS0QmsFqcInspectionSpec, AdoS0QmsFqcInspectionSpecEntry>(
|
|
|
- file, r => r.FileNumber, ProcessLikeDetailHasValue, _fqcRep, _fqcEntryRep,
|
|
|
+ file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeProcessLikeHeader, ProcessLikeRequiredHeaderFields, _fqcRep, _fqcEntryRep,
|
|
|
(r, fn) => new AdoS0QmsFqcInspectionSpec
|
|
|
{
|
|
|
FileNumber = fn,
|
|
|
@@ -159,7 +163,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 result = await RunImportAsync<AdoS0QmsProcessLikeInspectionSpecImportRow, AdoS0QmsOqcInspectionSpec, AdoS0QmsOqcInspectionSpecEntry>(
|
|
|
- file, r => r.FileNumber, ProcessLikeDetailHasValue, _oqcRep, _oqcEntryRep,
|
|
|
+ file, r => r.FileNumber, AdoS0QmsSpecAggregateGuard.HasDetailValue, MergeProcessLikeHeader, ProcessLikeRequiredHeaderFields, _oqcRep, _oqcEntryRep,
|
|
|
(r, fn) => new AdoS0QmsOqcInspectionSpec
|
|
|
{
|
|
|
FileNumber = fn,
|
|
|
@@ -227,6 +231,8 @@ public class AdoS0QmsSpecImportController : ControllerBase
|
|
|
IFormFile file,
|
|
|
Func<TRow, string?> keySelector,
|
|
|
Func<TRow, bool> detailHasValue,
|
|
|
+ Func<List<TRow>, TRow> headerMerger,
|
|
|
+ Func<TRow, IReadOnlyList<(string Label, string? Value)>> requiredHeaderFields,
|
|
|
SqlSugarRepository<TMaster> masterRep,
|
|
|
SqlSugarRepository<TEntry> entryRep,
|
|
|
Func<TRow, string, TMaster> masterFactory,
|
|
|
@@ -239,8 +245,43 @@ public class AdoS0QmsSpecImportController : ControllerBase
|
|
|
{
|
|
|
var rows = (await MiniExcelUtil.GetImportExcelData<TRow>(file)).ToList();
|
|
|
var result = new AdoS0QmsSpecImportResultDto();
|
|
|
+
|
|
|
+ // ---------- Preflight(全或无):本段完全不碰数据库 ----------
|
|
|
var groups = BuildGroups(rows, keySelector, result);
|
|
|
|
|
|
+ // 归组之后再合并头字段,然后按聚合校验必填——续行允许省略除文件编号外的头字段,
|
|
|
+ // 逐行校验会把合法续行误判为空值非法,故必填只在这一层判。
|
|
|
+ var merged = new Dictionary<SpecGroup<TRow>, 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();
|
|
|
@@ -248,7 +289,7 @@ public class AdoS0QmsSpecImportController : ControllerBase
|
|
|
{
|
|
|
foreach (var g in groups)
|
|
|
{
|
|
|
- var master = masterFactory(g.FirstRow, g.FileNumber);
|
|
|
+ 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);
|
|
|
@@ -271,12 +312,18 @@ public class AdoS0QmsSpecImportController : ControllerBase
|
|
|
throw;
|
|
|
}
|
|
|
|
|
|
- result.Message = $"导入完成:新增 {result.ImportedMasters} 条规范、{result.ImportedItems} 条明细,共读取 {result.TotalRows} 行,跳过 {result.SkippedRows} 行。";
|
|
|
+ result.Accepted = true;
|
|
|
+ result.Message = $"导入完成:新增 {result.ImportedMasters} 条规范、{result.ImportedItems} 条明细,共读取 {result.TotalRows} 行。";
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
- /// <summary>按“文件编号”分组:同一文件编号的多行合并为一条主单 + 多条明细;文件编号为空的行跳过并记录。</summary>
|
|
|
+ /// <summary>
|
|
|
+ /// 按“文件编号”分组:同一文件编号的多行合并为一条主单 + 多条明细。
|
|
|
+ /// 文件编号是分组键,必须逐行出现——缺失的行无法归属任何主单,记为不合格(全或无语义下将导致整份拒绝)。
|
|
|
+ /// 同时逐行做特性校验([MaxLength],按实测 DB 列长),把超长值挡在 DB 之外,避免透到 INSERT 变成裸 500。
|
|
|
+ /// </summary>
|
|
|
private static List<SpecGroup<TRow>> BuildGroups<TRow>(IEnumerable<TRow> rows, Func<TRow, string?> keySelector, AdoS0QmsSpecImportResultDto result)
|
|
|
+ where TRow : class
|
|
|
{
|
|
|
var groups = new List<SpecGroup<TRow>>();
|
|
|
var index = new Dictionary<string, SpecGroup<TRow>>();
|
|
|
@@ -285,16 +332,27 @@ public class AdoS0QmsSpecImportController : ControllerBase
|
|
|
{
|
|
|
rowNo++;
|
|
|
result.TotalRows++;
|
|
|
+
|
|
|
+ var validationResults = new List<ValidationResult>();
|
|
|
+ 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.SkippedRows++;
|
|
|
- result.RowMessages.Add($"第 {rowNo} 行:文件编号为空,已跳过");
|
|
|
+ result.FailedRows++;
|
|
|
+ result.RowMessages.Add($"第 {rowNo} 行:文件编号为空,无法归属任何规范");
|
|
|
continue;
|
|
|
}
|
|
|
if (!index.TryGetValue(key, out var g))
|
|
|
{
|
|
|
- g = new SpecGroup<TRow> { FileNumber = key, FirstRow = row };
|
|
|
+ g = new SpecGroup<TRow> { FileNumber = key, FirstRowNo = rowNo };
|
|
|
index[key] = g;
|
|
|
groups.Add(g);
|
|
|
}
|
|
|
@@ -303,29 +361,71 @@ public class AdoS0QmsSpecImportController : ControllerBase
|
|
|
return groups;
|
|
|
}
|
|
|
|
|
|
- private static bool RawDetailHasValue(AdoS0QmsRawInspectionSpecImportRow r)
|
|
|
- => r.Seq.HasValue
|
|
|
- || !string.IsNullOrWhiteSpace(r.InspectionItem)
|
|
|
- || !string.IsNullOrWhiteSpace(r.InspectionStandard)
|
|
|
- || !string.IsNullOrWhiteSpace(r.InspectionMethod)
|
|
|
- || !string.IsNullOrWhiteSpace(r.SamplingScheme)
|
|
|
- || !string.IsNullOrWhiteSpace(r.UpperLimit)
|
|
|
- || !string.IsNullOrWhiteSpace(r.LowerLimit);
|
|
|
-
|
|
|
- private static bool ProcessLikeDetailHasValue(AdoS0QmsProcessLikeInspectionSpecImportRow r)
|
|
|
- => !string.IsNullOrWhiteSpace(r.OperationCode)
|
|
|
- || !string.IsNullOrWhiteSpace(r.OperationName)
|
|
|
- || !string.IsNullOrWhiteSpace(r.InspectionItem)
|
|
|
- || !string.IsNullOrWhiteSpace(r.InspectionMethod)
|
|
|
- || !string.IsNullOrWhiteSpace(r.InspectionSpec)
|
|
|
- || !string.IsNullOrWhiteSpace(r.InspectionFrequency)
|
|
|
- || !string.IsNullOrWhiteSpace(r.UpperLimit)
|
|
|
- || !string.IsNullOrWhiteSpace(r.LowerLimit);
|
|
|
+ // ==================== 头字段合并与必填集 ====================
|
|
|
+ // 合并口径:同组内每个头字段取第一个非空值。续行留空不覆盖,也不因首行留空而丢值。
|
|
|
+
|
|
|
+ private static string? FirstNonBlank<TRow>(List<TRow> rows, Func<TRow, string?> selector)
|
|
|
+ {
|
|
|
+ foreach (var row in rows)
|
|
|
+ {
|
|
|
+ var v = selector(row);
|
|
|
+ if (!string.IsNullOrWhiteSpace(v)) return v;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static AdoS0QmsRawInspectionSpecImportRow MergeRawHeader(List<AdoS0QmsRawInspectionSpecImportRow> 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<AdoS0QmsProcessLikeInspectionSpecImportRow> 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),
|
|
|
+ };
|
|
|
+
|
|
|
+ /// <summary>原材料检规必填集(UAT-S0-10 拍板)。</summary>
|
|
|
+ private static IReadOnlyList<(string, string?)> RawRequiredHeaderFields(AdoS0QmsRawInspectionSpecImportRow r) =>
|
|
|
+ [
|
|
|
+ ("文件编号", r.FileNumber),
|
|
|
+ ("物料编码", r.MaterialCode),
|
|
|
+ ("原材料名称", r.RawMaterialName),
|
|
|
+ ("版本", r.VersionNo),
|
|
|
+ ];
|
|
|
+
|
|
|
+ /// <summary>过程 / FQC / OQC 检规必填集(本轮不含「适用型号」,缺业务依据,见拍板第 1 条)。</summary>
|
|
|
+ private static IReadOnlyList<(string, string?)> ProcessLikeRequiredHeaderFields(AdoS0QmsProcessLikeInspectionSpecImportRow r) =>
|
|
|
+ [
|
|
|
+ ("文件编号", r.FileNumber),
|
|
|
+ ("物料编码", r.MaterialCode),
|
|
|
+ ("版本", r.VersionNo),
|
|
|
+ ];
|
|
|
|
|
|
private sealed class SpecGroup<TRow>
|
|
|
{
|
|
|
public string FileNumber { get; init; } = string.Empty;
|
|
|
- public TRow FirstRow { get; init; } = default!;
|
|
|
+
|
|
|
+ /// <summary>该分组首次出现的 Excel 行号,仅用于错误定位。</summary>
|
|
|
+ public int FirstRowNo { get; init; }
|
|
|
+
|
|
|
public List<TRow> Rows { get; } = [];
|
|
|
}
|
|
|
}
|