Эх сурвалжийг харах

fix(s0): enforce inspection spec aggregate contract | Web 2.4.352 / server 1.0.448

UAT-S0-10:只填「文件编号」的 Excel 被判导入成功并落库空壳检规。根因是四张检规表的
必填契约在前端表单、后端 DTO、DB 列三层都只约束文件编号,导入与手工新增同样宽松。

- 新增 AdoS0QmsSpecAggregateGuard 作为必填契约的唯一实现处,Create / Import / Update 共用
- 新增严格:文件编号 + 物料编码 + 版本(原材料另加原材料名称)+ 至少 1 条有内容的明细
- 编辑历史兼容、禁止劣化:原本为空的必填字段可继续为空,原本有值的不得清空;
  原本 0 明细可保持 0,原本有明细不得删到 0
- 移除四个 UpsertDto 上 FileNumber 的 [Required]:它在模型绑定阶段先于守卫触发,
  会把 wjbh 为空的历史记录锁死不可编辑(实测 qms_gcjygf 有 3 行)
- 导入改为全或无:preflight 全程不碰数据库,任一行或任一组不合格即整份拒绝、0 落库;
  归组后先合并头字段再校验聚合,避免把省略头字段的合法续行误判为空值非法
- 明细归一化:全空占位行不计数、不落库,Create / Update / Import 三条入口口径一致
- MaxLength 按 aidopdev 实测列长逐列对齐,不做 255 一刀切:text 列不设限、
  上下限为 varchar(50)、过程/FQC/OQC 的物料编码是 text 而原材料是 varchar(255)
- 前端必填标记与明细数校验同步收紧,导入结果按 accepted 分流,不再一律绿色成功
YY968XX 4 өдөр өмнө
parent
commit
84f23e661e

+ 1 - 1
Web/package.json

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

+ 5 - 0
Web/src/views/aidop/s0/api/s0QualityApi.ts

@@ -69,9 +69,14 @@ export const s0InspectionItemsApi = buildSimpleCrudApi('/api/s0/quality/inspecti
 export const s0InspectionFrequenciesApi = buildSimpleCrudApi('/api/s0/quality/inspection-frequencies');
 
 export interface SpecImportResult {
+	/** 全或无语义:false 表示整份 Excel 被拒、未写入任何数据 */
+	accepted: boolean;
 	totalRows: number;
 	importedMasters: number;
 	importedItems: number;
+	/** 不合格的行/组数量 */
+	failedRows: number;
+	/** @deprecated 全或无语义下不再跳过任何行,后端恒返回 0 */
 	skippedRows: number;
 	rowMessages: string[];
 	message: string;

+ 52 - 5
Web/src/views/aidop/s0/quality/components/QualityAggregateCrudPage.vue

@@ -153,6 +153,18 @@ const formRef = ref<FormInstance>();
 const master = reactive<Record<string, any>>({});
 const items = ref<Record<string, any>[]>([]);
 const lookupOptions = ref<Partial<Record<QualityLookupKey, QualityOption[]>>>({});
+/** 编辑时打开弹窗那一刻的原始快照;null 表示新增。用于与后端「历史兼容、禁止劣化」保持同一口径。 */
+const originalMaster = ref<Record<string, any> | null>(null);
+const originalItemCount = ref(0);
+
+function isBlank(value: unknown): boolean {
+	return value === null || value === undefined || String(value).trim() === '';
+}
+
+/** 与后端 AdoS0QmsSpecAggregateGuard.HasValue 同口径:整行全空的明细不计数。 */
+function hasItemContent(item: Record<string, any>): boolean {
+	return Object.values(item).some((v) => !isBlank(v));
+}
 
 /** 本页用到的全部 lookup(列表列 + 头表字段 + 明细列去重)——每个 key 只取一次,无 per-row 请求 */
 const usedLookups = computed<QualityLookupKey[]>(() => [
@@ -188,13 +200,19 @@ function normalizeLookupValues(target: Record<string, any>, defs: Array<{ prop:
 
 const rules = computed<FormRules>(() =>
 	props.config.headFields.reduce<FormRules>((acc, field) => {
-		if (field.required) acc[field.prop] = [{ required: true, message: `请填写${field.label}`, trigger: 'blur' }];
+		if (!field.required) return acc;
+		// 编辑历史记录时,该字段原本就为空 → 不强制本次补齐(后端同样放行);
+		// 原本有值 → 保留必填,等同于「不允许清空」。
+		if (originalMaster.value && isBlank(originalMaster.value[field.prop])) return acc;
+		acc[field.prop] = [{ required: true, message: `请填写${field.label}`, trigger: 'blur' }];
 		return acc;
 	}, {})
 );
 
 function resetForm() {
 	editingId.value = null;
+	originalMaster.value = null;
+	originalItemCount.value = 0;
 	Object.keys(master).forEach((key) => delete master[key]);
 	Object.assign(master, structuredClone(props.config.initialMaster));
 	items.value = [];
@@ -247,13 +265,31 @@ async function openEdit(row: Record<string, any>) {
 	const detail = await props.config.api.get(editingId.value);
 	Object.assign(master, structuredClone(detail.master ?? {}));
 	items.value = structuredClone(detail.items ?? []);
+	originalMaster.value = structuredClone(detail.master ?? {});
+	originalItemCount.value = items.value.length;
 	normalizeLookupValues(master, props.config.headFields);
 	items.value.forEach((it) => normalizeLookupValues(it, props.config.detailColumns));
 	dialogVisible.value = true;
 }
 
+/**
+ * 明细数量校验,与后端守卫同口径:
+ * · 新增 → 必须至少 1 条有内容的明细;
+ * · 编辑且原本有明细 → 不允许删空;
+ * · 编辑且原本 0 明细 → 允许保持 0(历史兼容)。
+ */
+function validateItems(): boolean {
+	if (!props.config.requireAtLeastOneItem) return true;
+	if (items.value.some(hasItemContent)) return true;
+	const isCreate = originalMaster.value === null;
+	if (!isCreate && originalItemCount.value === 0) return true;
+	ElMessage.error(isCreate ? '检验明细至少需要 1 条' : `该${props.config.entityLabel}原有 ${originalItemCount.value} 条检验明细,不允许全部删除`);
+	return false;
+}
+
 async function submitForm() {
 	await formRef.value?.validate();
+	if (!validateItems()) return;
 	saving.value = true;
 	try {
 		const payload = { ...master, items: items.value.map((item) => ({ ...item })) };
@@ -286,12 +322,23 @@ async function onImportFileChange(uploadFile: { raw?: File }) {
 	importing.value = true;
 	try {
 		const result = await props.config.importApi.import(uploadFile.raw);
-		ElMessage.success(result?.message || '导入完成');
+		// 全或无:accepted=false 表示整份被拒、0 落库,不能再报成功。
+		const accepted = result?.accepted !== false;
+		if (accepted) {
+			ElMessage.success(result?.message || '导入完成');
+		} else {
+			ElMessage.error(result?.message || '导入已拒绝,未写入任何数据');
+		}
 		if (result?.rowMessages?.length) {
-			ElMessageBox.alert(result.rowMessages.join('\n'), '导入提示', { type: 'warning' });
+			ElMessageBox.alert(result.rowMessages.join('\n'), accepted ? '导入提示' : '导入已拒绝', {
+				type: accepted ? 'warning' : 'error',
+			});
+		}
+		// 被拒时未写入任何数据,无需重新拉列表。
+		if (accepted) {
+			query.page = 1;
+			await loadList();
 		}
-		query.page = 1;
-		await loadList();
 	} catch (e: any) {
 		ElMessage.error(e?.message || '导入失败');
 	} finally {

+ 18 - 9
Web/src/views/aidop/s0/quality/qualityConfigs.ts

@@ -74,6 +74,11 @@ export interface QualityAggregatePageConfig {
 	};
 	// 存在时,聚合页显示“下载导入模板 / 导入 Excel”入口
 	importApi?: SpecImportApi;
+	/**
+	 * 新增时必须至少 1 条有内容的明细(UAT-S0-10 拍板,仅 4 张检规表启用)。
+	 * 编辑历史记录时按后端「禁止劣化」同口径:原本 0 明细允许保持 0,原本有明细不允许删空。
+	 */
+	requireAtLeastOneItem?: boolean;
 }
 
 export const qualityBaseTypeConfig: QualitySimplePageConfig = {
@@ -355,6 +360,7 @@ export const inspectionPlanConfig: QualityAggregatePageConfig = {
 };
 
 export const rawInspectionSpecConfig: QualityAggregatePageConfig = {
+	requireAtLeastOneItem: true,
 	subtitle: 'S0 / 质量建模 / 原材料检验规范',
 	entityLabel: '原材料检验规范',
 	queryPlaceholder: '文件编号/原材料名称/物料编码',
@@ -368,10 +374,10 @@ export const rawInspectionSpecConfig: QualityAggregatePageConfig = {
 	],
 	headFields: [
 		{ prop: 'fileNumber', label: '文件编号', required: true },
-		{ prop: 'versionNo', label: '版本' },
+		{ prop: 'versionNo', label: '版本', required: true },
 		{ prop: 'drawingNo', label: '图号' },
-		{ prop: 'rawMaterialName', label: '原材料名称' },
-		{ prop: 'materialCode', label: '物料编码' },
+		{ prop: 'rawMaterialName', label: '原材料名称', required: true },
+		{ prop: 'materialCode', label: '物料编码', required: true },
 		{ prop: 'effectiveDate', label: '生效日期' },
 		{ prop: 'drawingVersion', label: '图纸版本' },
 		{ prop: 'materialGrade', label: '材质/材料牌号' },
@@ -394,6 +400,7 @@ export const rawInspectionSpecConfig: QualityAggregatePageConfig = {
 };
 
 export const processInspectionSpecConfig: QualityAggregatePageConfig = {
+	requireAtLeastOneItem: true,
 	subtitle: 'S0 / 质量建模 / 过程检验规范',
 	entityLabel: '过程检验规范',
 	queryPlaceholder: '文件编号/适用型号/物料编码',
@@ -408,10 +415,10 @@ export const processInspectionSpecConfig: QualityAggregatePageConfig = {
 	headFields: [
 		{ prop: 'applicableModel', label: '适用型号' },
 		{ prop: 'fileNumber', label: '文件编号', required: true },
-		{ prop: 'versionNo', label: '版本' },
+		{ prop: 'versionNo', label: '版本', required: true },
 		{ prop: 'effectiveDate', label: '生效日期' },
 		{ prop: 'attachment', label: '附件', type: 'textarea' },
-		{ prop: 'materialCode', label: '物料编码' },
+		{ prop: 'materialCode', label: '物料编码', required: true },
 		{ prop: 'attachment2', label: '附件2', type: 'textarea' },
 		{ prop: 'version', label: '版本号', type: 'number' },
 	],
@@ -430,6 +437,7 @@ export const processInspectionSpecConfig: QualityAggregatePageConfig = {
 };
 
 export const fqcInspectionSpecConfig: QualityAggregatePageConfig = {
+	requireAtLeastOneItem: true,
 	subtitle: 'S0 / 质量建模 / FQC检验规范',
 	entityLabel: 'FQC检验规范',
 	queryPlaceholder: '文件编号/适用型号/物料编码',
@@ -444,10 +452,10 @@ export const fqcInspectionSpecConfig: QualityAggregatePageConfig = {
 	headFields: [
 		{ prop: 'applicableModel', label: '适用型号' },
 		{ prop: 'fileNumber', label: '文件编号', required: true },
-		{ prop: 'versionNo', label: '版本' },
+		{ prop: 'versionNo', label: '版本', required: true },
 		{ prop: 'effectiveDate', label: '生效日期' },
 		{ prop: 'attachment', label: '附件', type: 'textarea' },
-		{ prop: 'materialCode', label: '物料编码' },
+		{ prop: 'materialCode', label: '物料编码', required: true },
 		{ prop: 'attachment2', label: '附件2', type: 'textarea' },
 		{ prop: 'version', label: '版本号', type: 'number' },
 	],
@@ -466,6 +474,7 @@ export const fqcInspectionSpecConfig: QualityAggregatePageConfig = {
 };
 
 export const oqcInspectionSpecConfig: QualityAggregatePageConfig = {
+	requireAtLeastOneItem: true,
 	subtitle: 'S0 / 质量建模 / OQC检验规范',
 	entityLabel: 'OQC检验规范',
 	queryPlaceholder: '文件编号/适用型号/物料编码',
@@ -480,10 +489,10 @@ export const oqcInspectionSpecConfig: QualityAggregatePageConfig = {
 	headFields: [
 		{ prop: 'applicableModel', label: '适用型号' },
 		{ prop: 'fileNumber', label: '文件编号', required: true },
-		{ prop: 'versionNo', label: '版本' },
+		{ prop: 'versionNo', label: '版本', required: true },
 		{ prop: 'effectiveDate', label: '生效日期' },
 		{ prop: 'attachment', label: '附件', type: 'textarea' },
-		{ prop: 'materialCode', label: '物料编码' },
+		{ prop: 'materialCode', label: '物料编码', required: true },
 		{ prop: 'attachment2', label: '附件2', type: 'textarea' },
 		{ prop: 'version', label: '版本号', type: 'number' },
 	],

+ 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.447</AssemblyVersion>
-    <FileVersion>1.0.447</FileVersion>
-    <Version>1.0.447</Version>
+    <AssemblyVersion>1.0.448</AssemblyVersion>
+    <FileVersion>1.0.448</FileVersion>
+    <Version>1.0.448</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 247 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Quality/AdoS0QmsSpecAggregateGuardTests.cs

@@ -0,0 +1,247 @@
+using Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
+using Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S0.Quality;
+
+/// <summary>
+/// 检验规范聚合必填契约(UAT-S0-10 拍板,2026-09-02)。
+/// 新增严格 / 编辑「历史兼容、禁止劣化」是两套规则,分别覆盖。
+/// </summary>
+public class AdoS0QmsSpecAggregateGuardTests
+{
+    private static (string, string?)[] RawFields(string? file, string? mat, string? name, string? ver) =>
+        [("文件编号", file), ("物料编码", mat), ("原材料名称", name), ("版本", ver)];
+
+    // ==================== 新增 ====================
+
+    [Fact]
+    public void Create_WhenAllRequiredPresentAndHasItem_Passes()
+    {
+        var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), 1);
+        Assert.Null(error);
+    }
+
+    /// <summary>UAT-S0-10 原始场景:只填文件编号、其余必填全空。</summary>
+    [Fact]
+    public void Create_WhenOnlyFileNumberFilled_IsRejected()
+    {
+        var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("UATA-BAD-SPEC", null, null, null), 0);
+        Assert.Equal("物料编码不能为空", error);
+    }
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("   ")]
+    public void Create_TreatsWhitespaceAsMissing(string? blank)
+    {
+        var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", blank, "V1"), 1);
+        Assert.Equal("原材料名称不能为空", error);
+    }
+
+    /// <summary>头字段齐全但 0 条明细,仍必须拒绝——Create / Import 两条入口共用本规则。</summary>
+    [Fact]
+    public void Create_WhenNoDetail_IsRejected()
+    {
+        var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), 0);
+        Assert.Equal("检验明细至少需要 1 条", error);
+    }
+
+    // ==================== 编辑:历史兼容 ====================
+
+    [Fact]
+    public void Update_WhenHistoricalFieldWasAlreadyBlank_AllowsStayingBlank()
+    {
+        // 实测 aidopdev 有 72 条历史记录缺必填字段;它们必须仍可编辑其他字段。
+        var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [("物料编码", null, null), ("版本", "V1", "V2")], existingItemCount: 3, incomingMeaningfulItemCount: 3);
+        Assert.Null(error);
+    }
+
+    [Fact]
+    public void Update_WhenHistoricalRecordHadNoDetail_AllowsStayingZero()
+    {
+        // 实测 22 条历史记录 0 明细。
+        var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [("物料编码", "M-001", "M-001")], existingItemCount: 0, incomingMeaningfulItemCount: 0);
+        Assert.Null(error);
+    }
+
+    [Fact]
+    public void Update_WhenHistoricalBlankFieldGetsFilled_Passes()
+    {
+        var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [("物料编码", "", "M-001")], existingItemCount: 1, incomingMeaningfulItemCount: 1);
+        Assert.Null(error);
+    }
+
+    // ==================== 编辑:禁止劣化 ====================
+
+    [Fact]
+    public void Update_WhenClearingAPopulatedRequiredField_IsRejected()
+    {
+        var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [("物料编码", "M-001", "  ")], existingItemCount: 1, incomingMeaningfulItemCount: 1);
+        Assert.Equal("物料编码原本已有值,不允许清空", error);
+    }
+
+    [Fact]
+    public void Update_WhenDeletingAllExistingDetails_IsRejected()
+    {
+        var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [("物料编码", "M-001", "M-001")], existingItemCount: 5, incomingMeaningfulItemCount: 0);
+        Assert.Equal("该检规原有 5 条检验明细,不允许全部删除", error);
+    }
+
+    // ==================== 文件编号:必填已从 [Required] 迁入守卫 ====================
+
+    /// <summary>阶段 1.1 · 场景 1:历史 FileNumber 为空的记录,改其他字段必须能存。</summary>
+    [Fact]
+    public void Update_WhenHistoricalFileNumberIsNull_AllowsEditingOtherFields()
+    {
+        // 对应实测的 3 行 qms_gcjygf.wjbh IS NULL。
+        var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [
+                ("文件编号", null, null),
+                ("物料编码", "M-001", "M-002"),
+                ("版本", "V1", "V1")
+            ],
+            existingItemCount: 2, incomingMeaningfulItemCount: 2);
+        Assert.Null(error);
+    }
+
+    /// <summary>阶段 1.1 · 场景 2:原本有文件编号,Update 清空必须拒绝。</summary>
+    [Fact]
+    public void Update_WhenClearingAnExistingFileNumber_IsRejected()
+    {
+        var error = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [("文件编号", "F-001", "")], existingItemCount: 1, incomingMeaningfulItemCount: 1);
+        Assert.Equal("文件编号原本已有值,不允许清空", error);
+    }
+
+    [Fact]
+    public void Create_WhenFileNumberMissing_IsRejected()
+    {
+        var error = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields(null, "M-001", "钢板", "V1"), 1);
+        Assert.Equal("文件编号不能为空", error);
+    }
+
+    // ==================== 明细归一化:全空占位不落库 ====================
+
+    /// <summary>阶段 1.1 · 场景 3:1 实 + 1 空 → 只保留 1 条真实明细。</summary>
+    [Fact]
+    public void Meaningful_DropsBlankPlaceholderRows_KeepingOnlyRealDetails()
+    {
+        var incoming = new List<AdoS0QmsRawInspectionSpecEntryDto>
+        {
+            new() { InspectionItem = "外观", UpperLimit = "10" },
+            new() { InspectionItem = "", InspectionStandard = "   ", UpperLimit = null }, // 前端 addItem 的空占位
+        };
+
+        var kept = AdoS0QmsSpecAggregateGuard.Meaningful(incoming);
+
+        Assert.Single(kept);
+        Assert.Equal("外观", kept[0].InspectionItem);
+        // 过滤结果同时用于数量校验,保证「校验看到的」与「写进库的」一致
+        Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), kept.Count));
+    }
+
+    /// <summary>阶段 1.1 · 场景 4:明细全为空占位 → Create 按 0 明细拒绝。</summary>
+    [Fact]
+    public void Create_WhenAllDetailsAreBlankPlaceholders_IsRejected()
+    {
+        var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List<AdoS0QmsRawInspectionSpecEntryDto> { new(), new() });
+
+        Assert.Empty(kept);
+        Assert.Equal("检验明细至少需要 1 条",
+            AdoS0QmsSpecAggregateGuard.ValidateCreate(RawFields("F-001", "M-001", "钢板", "V1"), kept.Count));
+    }
+
+    /// <summary>阶段 1.1 · 场景 5:历史 0 明细 + UI 空占位 → 放行,且不得写出任何明细。</summary>
+    [Fact]
+    public void Update_WhenHistoricalHadNoDetailAndUiAddsBlankPlaceholder_WritesNothing()
+    {
+        var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List<AdoS0QmsProcessInspectionSpecEntryDto> { new() });
+
+        Assert.Empty(kept); // 写库用的就是这份 → 不会新增全空明细
+        Assert.Null(AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [("物料编码", "M-001", "M-001")], existingItemCount: 0, incomingMeaningfulItemCount: kept.Count));
+    }
+
+    /// <summary>阶段 1.1 · 场景 6:原有真实明细被删到只剩空占位 → 按「删到 0」拒绝。</summary>
+    [Fact]
+    public void Update_WhenRealDetailsReducedToBlankPlaceholderOnly_IsRejected()
+    {
+        var kept = AdoS0QmsSpecAggregateGuard.Meaningful(new List<AdoS0QmsFqcInspectionSpecEntryDto> { new() });
+
+        Assert.Empty(kept);
+        Assert.Equal("该检规原有 3 条检验明细,不允许全部删除",
+            AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+                [("物料编码", "M-001", "M-001")], existingItemCount: 3, incomingMeaningfulItemCount: kept.Count));
+    }
+
+    /// <summary>阶段 1.1 · 场景 7:导入侧口径与实体侧同源——空明细行不落库,全空 aggregate 拒绝。</summary>
+    [Fact]
+    public void Import_BlankDetailRowsAreNotCountedAndAllBlankAggregateIsRejected()
+    {
+        var headerOnly = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001", MaterialCode = "M-001", RawMaterialName = "钢板", VersionNo = "V1" };
+        var blankDetail = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001" };
+        var realDetail = new AdoS0QmsRawInspectionSpecImportRow { FileNumber = "F-001", InspectionItem = "外观" };
+
+        Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(headerOnly));
+        Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(blankDetail));
+        Assert.True(AdoS0QmsSpecAggregateGuard.HasDetailValue(realDetail));
+
+        // 只有表头行 + 空行的分组 → 0 条明细 → 整份拒绝
+        var group = new[] { headerOnly, blankDetail };
+        Assert.Equal("检验明细至少需要 1 条",
+            AdoS0QmsSpecAggregateGuard.ValidateCreate(
+                RawFields("F-001", "M-001", "钢板", "V1"),
+                group.Count(AdoS0QmsSpecAggregateGuard.HasDetailValue)));
+    }
+
+    [Fact]
+    public void Import_ProcessLikeBlankRowIsNotADetail()
+    {
+        Assert.False(AdoS0QmsSpecAggregateGuard.HasDetailValue(
+            new AdoS0QmsProcessLikeInspectionSpecImportRow { FileNumber = "F-001", MaterialCode = "M-001", VersionNo = "V1" }));
+        Assert.True(AdoS0QmsSpecAggregateGuard.HasDetailValue(
+            new AdoS0QmsProcessLikeInspectionSpecImportRow { FileNumber = "F-001", OperationCode = "OP10" }));
+    }
+
+    // ==================== 明细「有内容」判据 ====================
+
+    [Fact]
+    public void HasValue_WhenRawEntryIsEntirelyBlank_ReturnsFalse()
+    {
+        // 前端 openCreate 会自动补一行空明细;它不得让「≥1 条」形同虚设。
+        Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto
+        {
+            InspectionItem = "", InspectionStandard = "   ", UpperLimit = null
+        }));
+    }
+
+    [Fact]
+    public void HasValue_WhenRawEntryHasAnyContent_ReturnsTrue()
+    {
+        Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto { InspectionItem = "外观" }));
+        Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsRawInspectionSpecEntryDto { Seq = 1 }));
+    }
+
+    [Fact]
+    public void HasValue_WhenProcessLikeEntryIsEntirelyBlank_ReturnsFalse()
+    {
+        Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsProcessInspectionSpecEntryDto()));
+        Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsFqcInspectionSpecEntryDto()));
+        Assert.False(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsOqcInspectionSpecEntryDto()));
+    }
+
+    [Fact]
+    public void HasValue_WhenProcessLikeEntryHasAnyContent_ReturnsTrue()
+    {
+        Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsProcessInspectionSpecEntryDto { OperationCode = "OP10" }));
+        Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsFqcInspectionSpecEntryDto { InspectionItem = "尺寸" }));
+        Assert.True(AdoS0QmsSpecAggregateGuard.HasValue(new AdoS0QmsOqcInspectionSpecEntryDto { PeelingForce = 5 }));
+    }
+}

+ 11 - 2
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Quality/AdoS0QualityDtosValidationTests.cs

@@ -45,13 +45,22 @@ public class AdoS0QualityDtosValidationTests
         Assert.Contains(results, r => r.ErrorMessage == "名称不能为空");
     }
 
+    /// <summary>
+    /// 四张检规表的 UpsertDto 上不得残留任何 [Required]。
+    /// Create 与 Update 共用同一 DTO,任何 [Required] 都会在模型绑定阶段先于
+    /// AdoS0QmsSpecAggregateGuard 触发、把 Update 挡成 400,从而破坏「历史兼容」——
+    /// 实测 qms_gcjygf 有 3 行 wjbh 为空,正是被遗留的 FileNumber[Required] 锁死。
+    /// 必填一律由守卫按新增/编辑分别判定,见 AdoS0QmsSpecAggregateGuardTests。
+    /// </summary>
     [Theory]
     [InlineData(typeof(AdoS0QmsRawInspectionSpecUpsertDto))]
     [InlineData(typeof(AdoS0QmsProcessInspectionSpecUpsertDto))]
-    public void FileNumberDtos_WhenDefaultsUsed_ReturnRequiredError(Type dtoType)
+    [InlineData(typeof(AdoS0QmsFqcInspectionSpecUpsertDto))]
+    [InlineData(typeof(AdoS0QmsOqcInspectionSpecUpsertDto))]
+    public void SpecUpsertDtos_CarryNoRequiredAttribute_SoUpdateReachesTheGuard(Type dtoType)
     {
         var dto = Activator.CreateInstance(dtoType)!;
         var results = Validate(dto);
-        Assert.Contains(results, r => r.ErrorMessage == "文件编号不能为空");
+        Assert.Empty(results);
     }
 }

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

@@ -0,0 +1,116 @@
+using Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality;
+
+/// <summary>
+/// 检验规范聚合(主单 + 检验明细)的必填契约守卫。
+///
+/// 契约来源:UAT-S0-10 拍板(2026-09-02)。此前四张检规表在前端表单、后端 DTO、DB 列三层
+/// 都只约束「文件编号」,导致只填文件编号即可落库空壳检规。本守卫是该契约的唯一实现处,
+/// Create / Import 两条写入入口共用,避免再次出现「导入宽于表单」。
+///
+/// 两条入口的规则不同,刻意分成两个方法:
+/// · 新增(Create / Import)——必填字段必须全部有值,且至少 1 条有内容的检验明细;
+/// · 编辑(Update)——「历史兼容、禁止劣化」:历史记录原本就缺的字段允许继续为空,
+///   但原本有值的必填字段不允许被清空;原本 0 明细允许保持 0,原本有明细不允许删空。
+///
+/// 【为什么四张检规表的 DTO 上一个 [Required] 都不留】
+/// Create 与 Update 共用同一个 UpsertDto。任何 [Required] 都会在 MVC 模型绑定阶段先于本守卫触发,
+/// 把 Update 直接挡成 400,从而绕过并破坏 ValidateUpdate(existing, incoming) 的历史兼容语义
+/// ——实测 qms_gcjygf 有 3 行 wjbh 为空,正是被遗留的 FileNumber[Required] 锁死不可编辑。
+/// 因此「文件编号」的新增必填也统一迁到本守卫,特性层不再承担任何必填职责。
+///
+/// 【明细归一化】全空明细是 UI 占位行 / Excel 空行,不是业务明细,一律不落库。
+/// Create / Update / Import 三条入口都必须先经 Meaningful(...) 过滤,再用过滤结果做数量校验与写入,
+/// 保证「校验看到的」与「写进库的」是同一份数据。
+/// </summary>
+public static class AdoS0QmsSpecAggregateGuard
+{
+    /// <summary>新增校验。返回 null 表示通过,否则为面向用户的错误文案。</summary>
+    public static string? ValidateCreate(IReadOnlyList<(string Label, string? Value)> requiredFields, int meaningfulItemCount)
+    {
+        foreach (var (label, value) in requiredFields)
+        {
+            if (string.IsNullOrWhiteSpace(value)) return $"{label}不能为空";
+        }
+        return meaningfulItemCount > 0 ? null : "检验明细至少需要 1 条";
+    }
+
+    /// <summary>编辑校验(历史兼容、禁止劣化)。返回 null 表示通过,否则为面向用户的错误文案。</summary>
+    public static string? ValidateUpdate(
+        IReadOnlyList<(string Label, string? Existing, string? Incoming)> requiredFields,
+        int existingItemCount,
+        int incomingMeaningfulItemCount)
+    {
+        foreach (var (label, existing, incoming) in requiredFields)
+        {
+            // 原本为空 → 放行(不强制本次补齐);原本有值 → 不允许清空。
+            if (!string.IsNullOrWhiteSpace(existing) && string.IsNullOrWhiteSpace(incoming))
+                return $"{label}原本已有值,不允许清空";
+        }
+        if (existingItemCount > 0 && incomingMeaningfulItemCount == 0)
+            return $"该检规原有 {existingItemCount} 条检验明细,不允许全部删除";
+        return null;
+    }
+
+    // ==================== 明细归一化:过滤全空占位行 ====================
+    // 唯一入口。控制器一律用 Meaningful(...) 的返回值同时做数量校验与写库,
+    // 不得再各自 Where/Count 一遍,避免「校验一套、写入另一套」。
+
+    public static List<AdoS0QmsRawInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsRawInspectionSpecEntryDto> items)
+        => items.Where(HasValue).ToList();
+
+    public static List<AdoS0QmsProcessInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsProcessInspectionSpecEntryDto> items)
+        => items.Where(HasValue).ToList();
+
+    public static List<AdoS0QmsFqcInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsFqcInspectionSpecEntryDto> items)
+        => items.Where(HasValue).ToList();
+
+    public static List<AdoS0QmsOqcInspectionSpecEntryDto> Meaningful(IEnumerable<AdoS0QmsOqcInspectionSpecEntryDto> items)
+        => items.Where(HasValue).ToList();
+
+    // ==================== 明细「有内容」判据 ====================
+    // 整行全空的明细不计数、不落库,否则前端 openCreate 自动补的那一行空明细
+    // 会让「≥1 条」形同虚设,并持续制造新的空明细脏数据。
+    //
+    // 导入行(ImportRow)的重载与实体侧同语义,只是模板列少了图像分类/备注/附件等非模板列——
+    // 判据仍集中在本类,导入侧不再自带一份定义。
+
+    public static bool HasDetailValue(AdoS0QmsRawInspectionSpecImportRow r)
+        => r.Seq.HasValue
+           || AnyNotBlank(r.InspectionItem, r.InspectionStandard, r.InspectionMethod,
+               r.SamplingScheme, r.UpperLimit, r.LowerLimit);
+
+    public static bool HasDetailValue(AdoS0QmsProcessLikeInspectionSpecImportRow r)
+        => AnyNotBlank(r.OperationCode, r.OperationName, r.InspectionItem, r.InspectionMethod,
+            r.InspectionSpec, r.InspectionFrequency, r.UpperLimit, r.LowerLimit);
+
+    public static bool HasValue(AdoS0QmsRawInspectionSpecEntryDto e)
+        => e.Seq.HasValue
+           || AnyNotBlank(e.InspectionItem, e.InspectionStandard, e.InspectionMethod, e.ImageCategory,
+               e.SamplingScheme, e.Remark, e.Attachment, e.UpperLimit, e.LowerLimit);
+
+    public static bool HasValue(AdoS0QmsProcessInspectionSpecEntryDto e)
+        => e.PeelingForce.HasValue
+           || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
+               e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit);
+
+    public static bool HasValue(AdoS0QmsFqcInspectionSpecEntryDto e)
+        => e.PeelingForce.HasValue
+           || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
+               e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit);
+
+    public static bool HasValue(AdoS0QmsOqcInspectionSpecEntryDto e)
+        => e.PeelingForce.HasValue
+           || AnyNotBlank(e.OperationCode, e.OperationName, e.InspectionItem, e.InspectionMethod, e.InspectionSpec,
+               e.ImageCategory, e.InspectionFrequency, e.TechnicalStandard, e.UpperLimit, e.LowerLimit);
+
+    private static bool AnyNotBlank(params string?[] values)
+    {
+        foreach (var v in values)
+        {
+            if (!string.IsNullOrWhiteSpace(v)) return true;
+        }
+        return false;
+    }
+}

+ 130 - 30
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QmsSpecImportController.cs

@@ -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; } = [];
     }
 }

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

@@ -516,6 +516,9 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsRawInspectionSpecUpsertDto dto)
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        var guardError = AdoS0QmsSpecAggregateGuard.ValidateCreate(RawRequiredFields(dto), items.Count);
+        if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -524,7 +527,7 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
             ApplyRawInspectionSpec(master, dto);
             master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncRawInspectionSpecEntriesAsync(master.Id, dto.Items, tenantId);
+            await SyncRawInspectionSpecEntriesAsync(master.Id, items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -541,6 +544,18 @@ public class AdoS0QmsRawInspectionSpecsController : 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 existingItemCount = await _entryRep.AsQueryable().Where(x => x.MasterId == id).CountAsync();
+        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [
+                ("文件编号", master.FileNumber, dto.FileNumber),
+                ("物料编码", master.MaterialCode, dto.MaterialCode),
+                ("原材料名称", master.RawMaterialName, dto.RawMaterialName),
+                ("版本", master.VersionNo, dto.VersionNo)
+            ],
+            existingItemCount,
+            items.Count);
+        if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -548,7 +563,7 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
             ApplyRawInspectionSpec(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncRawInspectionSpecEntriesAsync(id, dto.Items, tenantId);
+            await SyncRawInspectionSpecEntriesAsync(id, items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -581,9 +596,19 @@ public class AdoS0QmsRawInspectionSpecsController : ControllerBase
         }
     }
 
+    /// <summary>原材料检规必填集(UAT-S0-10 拍板)。与 Excel 导入入口共用同一守卫,口径单一。</summary>
+    private static IReadOnlyList<(string, string?)> RawRequiredFields(AdoS0QmsRawInspectionSpecUpsertDto dto) =>
+    [
+        ("文件编号", dto.FileNumber),
+        ("物料编码", dto.MaterialCode),
+        ("原材料名称", dto.RawMaterialName),
+        ("版本", dto.VersionNo),
+    ];
+
+
     private static void ApplyRawInspectionSpec(AdoS0QmsRawInspectionSpec entity, AdoS0QmsRawInspectionSpecUpsertDto dto)
     {
-        entity.FileNumber = dto.FileNumber.Trim();
+        entity.FileNumber = NullIfWhiteSpace(dto.FileNumber);
         entity.VersionNo = NullIfWhiteSpace(dto.VersionNo);
         entity.DrawingNo = NullIfWhiteSpace(dto.DrawingNo);
         entity.RawMaterialName = NullIfWhiteSpace(dto.RawMaterialName);
@@ -667,6 +692,9 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsProcessInspectionSpecUpsertDto dto)
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        var guardError = AdoS0QmsSpecAggregateGuard.ValidateCreate(ProcessRequiredFields(dto), items.Count);
+        if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -675,7 +703,7 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
             ApplyProcessInspectionSpec(master, dto);
             master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncProcessInspectionSpecEntriesAsync(master.Id, dto.Items, tenantId);
+            await SyncProcessInspectionSpecEntriesAsync(master.Id, items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -692,6 +720,17 @@ public class AdoS0QmsProcessInspectionSpecsController : 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 existingItemCount = await _entryRep.AsQueryable().Where(x => x.MasterId == id).CountAsync();
+        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [
+                ("文件编号", master.FileNumber, dto.FileNumber),
+                ("物料编码", master.MaterialCode, dto.MaterialCode),
+                ("版本", master.VersionNo, dto.VersionNo)
+            ],
+            existingItemCount,
+            items.Count);
+        if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -699,7 +738,7 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
             ApplyProcessInspectionSpec(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncProcessInspectionSpecEntriesAsync(id, dto.Items, tenantId);
+            await SyncProcessInspectionSpecEntriesAsync(id, items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -732,10 +771,19 @@ public class AdoS0QmsProcessInspectionSpecsController : ControllerBase
         }
     }
 
+    /// <summary>过程检规必填集(UAT-S0-10 拍板;本轮不含「适用型号」,缺业务依据)。</summary>
+    private static IReadOnlyList<(string, string?)> ProcessRequiredFields(AdoS0QmsProcessInspectionSpecUpsertDto dto) =>
+    [
+        ("文件编号", dto.FileNumber),
+        ("物料编码", dto.MaterialCode),
+        ("版本", dto.VersionNo),
+    ];
+
+
     private static void ApplyProcessInspectionSpec(AdoS0QmsProcessInspectionSpec entity, AdoS0QmsProcessInspectionSpecUpsertDto dto)
     {
         entity.ApplicableModel = NullIfWhiteSpace(dto.ApplicableModel);
-        entity.FileNumber = dto.FileNumber.Trim();
+        entity.FileNumber = NullIfWhiteSpace(dto.FileNumber);
         entity.VersionNo = NullIfWhiteSpace(dto.VersionNo);
         entity.EffectiveDate = NullIfWhiteSpace(dto.EffectiveDate);
         entity.Attachment = NullIfWhiteSpace(dto.Attachment);

+ 52 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Quality/AdoS0QualityFqcOqcControllers.cs

@@ -50,6 +50,9 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsFqcInspectionSpecUpsertDto 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);
+        if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -58,7 +61,7 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
             ApplyFqcSpec(master, dto);
             master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncFqcEntriesAsync(master.Id, dto.Items, tenantId);
+            await SyncFqcEntriesAsync(master.Id, items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -75,6 +78,17 @@ 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 existingItemCount = await _entryRep.AsQueryable().Where(x => x.MasterId == id).CountAsync();
+        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [
+                ("文件编号", master.FileNumber, dto.FileNumber),
+                ("物料编码", master.MaterialCode, dto.MaterialCode),
+                ("版本", master.VersionNo, dto.VersionNo)
+            ],
+            existingItemCount,
+            items.Count);
+        if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -82,7 +96,7 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
             ApplyFqcSpec(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncFqcEntriesAsync(id, dto.Items, tenantId);
+            await SyncFqcEntriesAsync(id, items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -115,10 +129,19 @@ public class AdoS0QmsFqcInspectionSpecsController : ControllerBase
         }
     }
 
+    /// <summary>FQC 检规必填集(UAT-S0-10 拍板;本轮不含「适用型号」,缺业务依据)。</summary>
+    private static IReadOnlyList<(string, string?)> FqcRequiredFields(AdoS0QmsFqcInspectionSpecUpsertDto dto) =>
+    [
+        ("文件编号", dto.FileNumber),
+        ("物料编码", dto.MaterialCode),
+        ("版本", dto.VersionNo),
+    ];
+
+
     private static void ApplyFqcSpec(AdoS0QmsFqcInspectionSpec entity, AdoS0QmsFqcInspectionSpecUpsertDto dto)
     {
         entity.ApplicableModel = NullIfWhiteSpace(dto.ApplicableModel);
-        entity.FileNumber = dto.FileNumber.Trim();
+        entity.FileNumber = NullIfWhiteSpace(dto.FileNumber);
         entity.VersionNo = NullIfWhiteSpace(dto.VersionNo);
         entity.EffectiveDate = NullIfWhiteSpace(dto.EffectiveDate);
         entity.Attachment = NullIfWhiteSpace(dto.Attachment);
@@ -197,6 +220,9 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0QmsOqcInspectionSpecUpsertDto dto)
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        var guardError = AdoS0QmsSpecAggregateGuard.ValidateCreate(OqcRequiredFields(dto), items.Count);
+        if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -205,7 +231,7 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
             ApplyOqcSpec(master, dto);
             master.TenantId = tenantId;
             await _rep.AsInsertable(master).ExecuteReturnEntityAsync();
-            await SyncOqcEntriesAsync(master.Id, dto.Items, tenantId);
+            await SyncOqcEntriesAsync(master.Id, items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(master.Id);
         }
@@ -222,6 +248,17 @@ public class AdoS0QmsOqcInspectionSpecsController : 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 existingItemCount = await _entryRep.AsQueryable().Where(x => x.MasterId == id).CountAsync();
+        var items = AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items);
+        var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate(
+            [
+                ("文件编号", master.FileNumber, dto.FileNumber),
+                ("物料编码", master.MaterialCode, dto.MaterialCode),
+                ("版本", master.VersionNo, dto.VersionNo)
+            ],
+            existingItemCount,
+            items.Count);
+        if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError);
         var db = _rep.Context;
         await db.Ado.BeginTranAsync();
         try
@@ -229,7 +266,7 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
             ApplyOqcSpec(master, dto);
             await _rep.AsUpdateable(master).ExecuteCommandAsync();
             await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync();
-            await SyncOqcEntriesAsync(id, dto.Items, tenantId);
+            await SyncOqcEntriesAsync(id, items, tenantId);
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
         }
@@ -262,10 +299,19 @@ public class AdoS0QmsOqcInspectionSpecsController : ControllerBase
         }
     }
 
+    /// <summary>OQC 检规必填集(UAT-S0-10 拍板;本轮不含「适用型号」,缺业务依据)。</summary>
+    private static IReadOnlyList<(string, string?)> OqcRequiredFields(AdoS0QmsOqcInspectionSpecUpsertDto dto) =>
+    [
+        ("文件编号", dto.FileNumber),
+        ("物料编码", dto.MaterialCode),
+        ("版本", dto.VersionNo),
+    ];
+
+
     private static void ApplyOqcSpec(AdoS0QmsOqcInspectionSpec entity, AdoS0QmsOqcInspectionSpecUpsertDto dto)
     {
         entity.ApplicableModel = NullIfWhiteSpace(dto.ApplicableModel);
-        entity.FileNumber = dto.FileNumber.Trim();
+        entity.FileNumber = NullIfWhiteSpace(dto.FileNumber);
         entity.VersionNo = NullIfWhiteSpace(dto.VersionNo);
         entity.EffectiveDate = NullIfWhiteSpace(dto.EffectiveDate);
         entity.Attachment = NullIfWhiteSpace(dto.Attachment);

+ 69 - 38
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Quality/AdoS0QualityDtos.cs

@@ -293,6 +293,13 @@ public class AdoS0QmsInspectionPlanUpsertDto
     public List<AdoS0QmsInspectionPlanEntryDto> Items { get; set; } = [];
 }
 
+// 【检验规范四表的必填与长度约束】
+// · 必填(文件编号 / 物料编码 / 原材料名称 / 版本 / ≥1 明细)不写成 [Required],
+//   因为 Create 与 Update 共用本 DTO,加特性会把实测的 72 条历史脏记录锁死不可编辑。
+//   必填一律由 AdoS0QmsSpecAggregateGuard 在控制器内按「新增严格 / 编辑禁止劣化」分别判定。
+// · [MaxLength] 按 aidopdev 实测列长逐列对齐(information_schema,as_of 2026-09-02),不做 255 一刀切:
+//   text 列不设限;上/下限是 varchar(50);过程/FQC/OQC 的「物料编码」是 text,与原材料的 varchar(255) 不同。
+
 public class AdoS0QmsRawInspectionSpecEntryDto
 {
     public long? Id { get; set; }
@@ -304,26 +311,32 @@ public class AdoS0QmsRawInspectionSpecEntryDto
     public string? SamplingScheme { get; set; }
     public string? Remark { get; set; }
     public string? Attachment { get; set; }
+
+    [MaxLength(50)]
     public string? UpperLimit { get; set; }
+
+    [MaxLength(50)]
     public string? LowerLimit { get; set; }
 }
 
 public class AdoS0QmsRawInspectionSpecUpsertDto
 {
-    [Required(ErrorMessage = "文件编号不能为空")]
-    public string FileNumber { get; set; } = string.Empty;
-
-    public string? VersionNo { get; set; }
-    public string? DrawingNo { get; set; }
-    public string? RawMaterialName { get; set; }
-    public string? MaterialCode { get; set; }
-    public string? EffectiveDate { get; set; }
-    public string? DrawingVersion { get; set; }
-    public string? MaterialGrade { get; set; }
-    public string? CavityOrMold { get; set; }
-    public string? Attachment { get; set; }
-    public string? FileName { get; set; }
-    public string? Title { get; set; }
+    // 必填不在特性层:[Required] 会先于 AdoS0QmsSpecAggregateGuard 触发,把 Update 直接挡成 400,
+    // 破坏「历史兼容」(实测 qms_gcjygf 有 3 行 wjbh 为空,曾因此不可编辑)。
+    [MaxLength(255)]
+    public string? FileNumber { get; set; }
+
+    [MaxLength(255)] public string? VersionNo { get; set; }
+    [MaxLength(255)] public string? DrawingNo { get; set; }
+    [MaxLength(255)] public string? RawMaterialName { get; set; }
+    [MaxLength(255)] public string? MaterialCode { get; set; }
+    [MaxLength(255)] public string? EffectiveDate { get; set; }
+    [MaxLength(255)] public string? DrawingVersion { get; set; }
+    [MaxLength(255)] public string? MaterialGrade { get; set; }
+    [MaxLength(255)] public string? CavityOrMold { get; set; }
+    public string? Attachment { get; set; }   // fj text
+    public string? FileName { get; set; }     // filename text
+    [MaxLength(255)] public string? Title { get; set; }
     public List<AdoS0QmsRawInspectionSpecEntryDto> Items { get; set; } = [];
 }
 
@@ -339,22 +352,28 @@ public class AdoS0QmsProcessInspectionSpecEntryDto
     public string? InspectionFrequency { get; set; }
     public string? TechnicalStandard { get; set; }
     public long? PeelingForce { get; set; }
+
+    [MaxLength(50)]
     public string? UpperLimit { get; set; }
+
+    [MaxLength(50)]
     public string? LowerLimit { get; set; }
 }
 
 public class AdoS0QmsProcessInspectionSpecUpsertDto
 {
-    public string? ApplicableModel { get; set; }
+    [MaxLength(255)] public string? ApplicableModel { get; set; }
 
-    [Required(ErrorMessage = "文件编号不能为空")]
-    public string FileNumber { get; set; } = string.Empty;
+    // 必填不在特性层:[Required] 会先于 AdoS0QmsSpecAggregateGuard 触发,把 Update 直接挡成 400,
+    // 破坏「历史兼容」(实测 qms_gcjygf 有 3 行 wjbh 为空,曾因此不可编辑)。
+    [MaxLength(255)]
+    public string? FileNumber { get; set; }
 
-    public string? VersionNo { get; set; }
-    public string? EffectiveDate { get; set; }
-    public string? Attachment { get; set; }
-    public string? MaterialCode { get; set; }
-    public string? Attachment2 { get; set; }
+    [MaxLength(255)] public string? VersionNo { get; set; }
+    [MaxLength(255)] public string? EffectiveDate { get; set; }
+    public string? Attachment { get; set; }    // fj text
+    public string? MaterialCode { get; set; }  // wlbm text(与原材料表 varchar(255) 不同)
+    public string? Attachment2 { get; set; }   // fj2 text
     public int? Version { get; set; }
     public List<AdoS0QmsProcessInspectionSpecEntryDto> Items { get; set; } = [];
 }
@@ -371,22 +390,28 @@ public class AdoS0QmsFqcInspectionSpecEntryDto
     public string? InspectionFrequency { get; set; }
     public string? TechnicalStandard { get; set; }
     public long? PeelingForce { get; set; }
+
+    [MaxLength(50)]
     public string? UpperLimit { get; set; }
+
+    [MaxLength(50)]
     public string? LowerLimit { get; set; }
 }
 
 public class AdoS0QmsFqcInspectionSpecUpsertDto
 {
-    public string? ApplicableModel { get; set; }
+    [MaxLength(255)] public string? ApplicableModel { get; set; }
 
-    [Required(ErrorMessage = "文件编号不能为空")]
-    public string FileNumber { get; set; } = string.Empty;
+    // 必填不在特性层:[Required] 会先于 AdoS0QmsSpecAggregateGuard 触发,把 Update 直接挡成 400,
+    // 破坏「历史兼容」(实测 qms_gcjygf 有 3 行 wjbh 为空,曾因此不可编辑)。
+    [MaxLength(255)]
+    public string? FileNumber { get; set; }
 
-    public string? VersionNo { get; set; }
-    public string? EffectiveDate { get; set; }
-    public string? Attachment { get; set; }
-    public string? MaterialCode { get; set; }
-    public string? Attachment2 { get; set; }
+    [MaxLength(255)] public string? VersionNo { get; set; }
+    [MaxLength(255)] public string? EffectiveDate { get; set; }
+    public string? Attachment { get; set; }    // fj text
+    public string? MaterialCode { get; set; }  // wlbm text
+    public string? Attachment2 { get; set; }   // fj2 text
     public int? Version { get; set; }
     public List<AdoS0QmsFqcInspectionSpecEntryDto> Items { get; set; } = [];
 }
@@ -403,22 +428,28 @@ public class AdoS0QmsOqcInspectionSpecEntryDto
     public string? InspectionFrequency { get; set; }
     public string? TechnicalStandard { get; set; }
     public long? PeelingForce { get; set; }
+
+    [MaxLength(50)]
     public string? UpperLimit { get; set; }
+
+    [MaxLength(50)]
     public string? LowerLimit { get; set; }
 }
 
 public class AdoS0QmsOqcInspectionSpecUpsertDto
 {
-    public string? ApplicableModel { get; set; }
+    [MaxLength(255)] public string? ApplicableModel { get; set; }
 
-    [Required(ErrorMessage = "文件编号不能为空")]
-    public string FileNumber { get; set; } = string.Empty;
+    // 必填不在特性层:[Required] 会先于 AdoS0QmsSpecAggregateGuard 触发,把 Update 直接挡成 400,
+    // 破坏「历史兼容」(实测 qms_gcjygf 有 3 行 wjbh 为空,曾因此不可编辑)。
+    [MaxLength(255)]
+    public string? FileNumber { get; set; }
 
-    public string? VersionNo { get; set; }
-    public string? EffectiveDate { get; set; }
-    public string? Attachment { get; set; }
-    public string? MaterialCode { get; set; }
-    public string? Attachment2 { get; set; }
+    [MaxLength(255)] public string? VersionNo { get; set; }
+    [MaxLength(255)] public string? EffectiveDate { get; set; }
+    public string? Attachment { get; set; }    // fj text
+    public string? MaterialCode { get; set; }  // wlbm text
+    public string? Attachment2 { get; set; }   // fj2 text
     public int? Version { get; set; }
     public List<AdoS0QmsOqcInspectionSpecEntryDto> Items { get; set; } = [];
 }

+ 52 - 37
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Quality/AdoS0QualitySpecImportDtos.cs

@@ -1,73 +1,88 @@
+using System.ComponentModel.DataAnnotations;
 using MiniExcelLibs.Attributes;
 
 namespace Admin.NET.Plugin.AiDOP.Dto.S0.Quality;
 
 // 检验规范 Excel 导入行 DTO:主表列 + 明细列平铺在一张表内,按“文件编号”分组还原为“一条主单 + 多条明细”。
 // 列名([ExcelColumnName])与各检验规范页 qualityConfigs 现有字段一一对齐,导出模板与导入解析共用同一 DTO,保证列头单一来源。
+//
+// 【为什么这里没有 [Required]】——同一主单的多行里,只有「文件编号」(分组键)必须逐行出现,
+// 其余头字段允许在续行留空。若在行 DTO 上加 [Required],合法续行会被误判为空值非法。
+// 因此必填性一律在归组 + 头字段合并之后,由 AdoS0QmsSpecAggregateGuard 按聚合校验。
+//
+// 【[MaxLength] 取值依据】——按 aidopdev 实测列长(information_schema,as_of 2026-09-02)逐列对齐,
+// 不做 255 一刀切:text 列(65535)不设限,上/下限是 varchar(50),
+// 且过程/FQC/OQC 的「物料编码」是 text 而原材料的是 varchar(255),两者不同。
 
 /// <summary>原材料检验规范导入行(对应 qms_jygf / qms_jygfzb)</summary>
 public class AdoS0QmsRawInspectionSpecImportRow
 {
-    [ExcelColumnName("文件编号")] public string? FileNumber { get; set; }
-    [ExcelColumnName("版本")] public string? VersionNo { get; set; }
-    [ExcelColumnName("图号")] public string? DrawingNo { get; set; }
-    [ExcelColumnName("原材料名称")] public string? RawMaterialName { get; set; }
-    [ExcelColumnName("物料编码")] public string? MaterialCode { get; set; }
-    [ExcelColumnName("生效日期")] public string? EffectiveDate { get; set; }
-    [ExcelColumnName("图纸版本")] public string? DrawingVersion { get; set; }
-    [ExcelColumnName("材质/材料牌号")] public string? MaterialGrade { get; set; }
-    [ExcelColumnName("穴/模")] public string? CavityOrMold { get; set; }
-    [ExcelColumnName("附件")] public string? Attachment { get; set; }
-    [ExcelColumnName("附件文件名")] public string? FileName { get; set; }
-    [ExcelColumnName("附件标题")] public string? Title { get; set; }
+    [ExcelColumnName("文件编号")] [MaxLength(255)] public string? FileNumber { get; set; }
+    [ExcelColumnName("版本")] [MaxLength(255)] public string? VersionNo { get; set; }
+    [ExcelColumnName("图号")] [MaxLength(255)] public string? DrawingNo { get; set; }
+    [ExcelColumnName("原材料名称")] [MaxLength(255)] public string? RawMaterialName { get; set; }
+    [ExcelColumnName("物料编码")] [MaxLength(255)] public string? MaterialCode { get; set; }
+    [ExcelColumnName("生效日期")] [MaxLength(255)] public string? EffectiveDate { get; set; }
+    [ExcelColumnName("图纸版本")] [MaxLength(255)] public string? DrawingVersion { get; set; }
+    [ExcelColumnName("材质/材料牌号")] [MaxLength(255)] public string? MaterialGrade { get; set; }
+    [ExcelColumnName("穴/模")] [MaxLength(255)] public string? CavityOrMold { get; set; }
+    [ExcelColumnName("附件")] public string? Attachment { get; set; }              // fj text
+    [ExcelColumnName("附件文件名")] public string? FileName { get; set; }           // filename text
+    [ExcelColumnName("附件标题")] [MaxLength(255)] public string? Title { get; set; }
 
     [ExcelColumnName("明细-序号")] public int? Seq { get; set; }
-    [ExcelColumnName("明细-检验项目")] public string? InspectionItem { get; set; }
-    [ExcelColumnName("明细-检验标准")] public string? InspectionStandard { get; set; }
-    [ExcelColumnName("明细-检验工具/方法")] public string? InspectionMethod { get; set; }
-    [ExcelColumnName("明细-抽样方案")] public string? SamplingScheme { get; set; }
-    [ExcelColumnName("明细-上限")] public string? UpperLimit { get; set; }
-    [ExcelColumnName("明细-下限")] public string? LowerLimit { get; set; }
+    [ExcelColumnName("明细-检验项目")] public string? InspectionItem { get; set; }       // jyxm text
+    [ExcelColumnName("明细-检验标准")] public string? InspectionStandard { get; set; }   // jybz text
+    [ExcelColumnName("明细-检验工具/方法")] public string? InspectionMethod { get; set; } // jygjjyff text
+    [ExcelColumnName("明细-抽样方案")] public string? SamplingScheme { get; set; }       // cyfa text
+    [ExcelColumnName("明细-上限")] [MaxLength(50)] public string? UpperLimit { get; set; }
+    [ExcelColumnName("明细-下限")] [MaxLength(50)] public string? LowerLimit { get; set; }
 }
 
 /// <summary>过程 / FQC / OQC 检验规范导入行(三者字段一致,对应 qms_gcjygf(zb) / qms_fqcjygf(zb) / qms_oqcjygf(zb))</summary>
 public class AdoS0QmsProcessLikeInspectionSpecImportRow
 {
-    [ExcelColumnName("适用型号")] public string? ApplicableModel { get; set; }
-    [ExcelColumnName("文件编号")] public string? FileNumber { get; set; }
-    [ExcelColumnName("版本")] public string? VersionNo { get; set; }
-    [ExcelColumnName("生效日期")] public string? EffectiveDate { get; set; }
-    [ExcelColumnName("物料编码")] public string? MaterialCode { get; set; }
-    [ExcelColumnName("附件")] public string? Attachment { get; set; }
-    [ExcelColumnName("附件2")] public string? Attachment2 { get; set; }
+    [ExcelColumnName("适用型号")] [MaxLength(255)] public string? ApplicableModel { get; set; }
+    [ExcelColumnName("文件编号")] [MaxLength(255)] public string? FileNumber { get; set; }
+    [ExcelColumnName("版本")] [MaxLength(255)] public string? VersionNo { get; set; }
+    [ExcelColumnName("生效日期")] [MaxLength(255)] public string? EffectiveDate { get; set; }
+    [ExcelColumnName("物料编码")] public string? MaterialCode { get; set; }   // wlbm text(与原材料表不同,不设 255)
+    [ExcelColumnName("附件")] public string? Attachment { get; set; }         // fj text
+    [ExcelColumnName("附件2")] public string? Attachment2 { get; set; }       // fj2 text
     [ExcelColumnName("版本号")] public int? Version { get; set; }
 
-    [ExcelColumnName("明细-工序代号")] public string? OperationCode { get; set; }
-    [ExcelColumnName("明细-工序名称")] public string? OperationName { get; set; }
-    [ExcelColumnName("明细-检验项目")] public string? InspectionItem { get; set; }
-    [ExcelColumnName("明细-检验方法")] public string? InspectionMethod { get; set; }
-    [ExcelColumnName("明细-检验规格")] public string? InspectionSpec { get; set; }
-    [ExcelColumnName("明细-检验频次")] public string? InspectionFrequency { get; set; }
-    [ExcelColumnName("明细-上限")] public string? UpperLimit { get; set; }
-    [ExcelColumnName("明细-下限")] public string? LowerLimit { get; set; }
+    [ExcelColumnName("明细-工序代号")] public string? OperationCode { get; set; }        // gxdh text
+    [ExcelColumnName("明细-工序名称")] public string? OperationName { get; set; }        // gxmc text
+    [ExcelColumnName("明细-检验项目")] public string? InspectionItem { get; set; }       // jyxm text
+    [ExcelColumnName("明细-检验方法")] public string? InspectionMethod { get; set; }     // jyff text
+    [ExcelColumnName("明细-检验规格")] public string? InspectionSpec { get; set; }       // jygg text
+    [ExcelColumnName("明细-检验频次")] public string? InspectionFrequency { get; set; }  // jypc text
+    [ExcelColumnName("明细-上限")] [MaxLength(50)] public string? UpperLimit { get; set; }
+    [ExcelColumnName("明细-下限")] [MaxLength(50)] public string? LowerLimit { get; set; }
 }
 
 /// <summary>检验规范 Excel 导入结果</summary>
 public class AdoS0QmsSpecImportResultDto
 {
+    /// <summary>本次导入是否被接受。false 时未写入任何数据(全或无语义)。</summary>
+    public bool Accepted { get; set; }
+
     /// <summary>读取到的数据行总数(不含表头)</summary>
     public int TotalRows { get; set; }
 
-    /// <summary>新增主表(规范)条数</summary>
+    /// <summary>新增主表(规范)条数。Accepted=false 时恒为 0。</summary>
     public int ImportedMasters { get; set; }
 
-    /// <summary>新增明细条数</summary>
+    /// <summary>新增明细条数。Accepted=false 时恒为 0。</summary>
     public int ImportedItems { get; set; }
 
-    /// <summary>跳过行数(如文件编号为空)</summary>
+    /// <summary>不合格的行/组数量</summary>
+    public int FailedRows { get; set; }
+
+    /// <summary>跳过行数。全或无语义下不再跳过任何行,保留字段仅为兼容既有响应结构,恒为 0。</summary>
     public int SkippedRows { get; set; }
 
-    /// <summary>逐行提示(跳过/异常原因)。注意:不要命名为 errors,前端响应拦截器会把非空 errors 字段判为请求失败。</summary>
+    /// <summary>逐行提示(不合格原因)。注意:不要命名为 errors,前端响应拦截器会把非空 errors 字段判为请求失败。</summary>
     public List<string> RowMessages { get; set; } = [];
 
     /// <summary>汇总文案</summary>