Explorar o código

feat(s0): lossless standard BOM op contract with tenant-safe diff/upsert

把标准BOM工序覆盖明细从有损的 opNos: number[] 升级为完整子对象契约,
并以 diff/upsert 取代「删光子行 + 重建」,撤销上一批的临时止血 guard。

【业务口径(已取证)】
ProductStructureOp = 某顶层成品 ProductItem 上下文中,对一条 BOM edge
(ParentItem → ComponentItem) 的工序号覆盖。
依据 ProductDesignService.GetBomAndRouting 的 join 键
(ParentItem, ComponentItem, ProductItem) + CASE 覆盖 master.Op,
以及 lwb 字段对照表「ProductItem = 成品物料编码」。
因此同一 Op 可对应多行(不同成品),旧契约按 Op 去重必然丢行。

【契约】
GET  : ops[] 逐行返回 id/op/productItem/isActive/isConfirm/refs/ufld1/ufld2
       SortNo 全库为 0 无排序能力,改用 (ProductItem, RecID) 稳定序
PUT  : 同构 ops[];id 命中改可编辑字段、缺 id 新增、库中有而未提交则删除
新增 : GET {id}/product-item-options —— 沿 ComponentItem→ParentItem 上溯取顶层成品候选

【安全】
- 提交的 child id 必须同时属于当前租户与当前主表,否则 400;不信客户端
- ProductItem 必须存在于本租户物料主数据,且属于该父件的顶层成品候选集
- 同一 BOM edge 下 ProductItem 不得重复(应用层唯一;DB 暂不加 UNIQUE,
  需全租户碰撞审计后另行设计 migration)
- 未变更的历史行放行,不因当前数据不全把历史行判非法
- 主表删除时的级联清子行显式带上 tenant

【新增行字段来源】
ParentItem/ComponentItem/ParentMaterialId/ComponentMaterialId/CompanyRefId/
FactoryRefId ← master;Domain ← 由主表工厂推导(不硬编码 8010);
IsActive=true;IsConfirm=false;CreateUser=当前认证账号;BizId=0
(该列全库 808 行恒为 0 且无任何代码引用,保持一致)

【前端】逗号分隔的「多工序号」输入框 → 工序覆盖明细表格
(工序号 / 成品物料下拉 / 有效 / 确认 / 逐行删除)。
历史上 Op 303 的 4 行会各占一行显示,不再压缩成一个数字。

实测(UAT 租户):
  GET 返回 ops[] · 候选端点返回该父件顶层成品
  no-op 保存 200 · 新增子行 200(isActive=true / isConfirm=false)
  改工序号 200 且 child id 保持不变(证明非重建)
  逐行删除 200,恢复空态无残留
  负例全部 400:成品重复 / 成品不存在 / 成品不在候选集 / 跨主表 child id / 工序号<=0
  页面:共 52 条、明细表格列齐、空态正常、旧逗号输入框已移除、console 0、API 全 200
零变更闸门:租户 797 的 808 行工序覆盖明细全列快照
  SHA256 df851a55… 前后逐字节一致,changed rows = 0。

未新增 migration,DB migration max 仍为 1.0.412。

⚠️ 未验证:租户 797 的 4 子行同 Op master 页面级冒烟(无该租户凭据,未做凭据探查)。

chore: bump version server 1.0.418 / Web 2.4.338
YY968XX hai 49 minutos
pai
achega
5d3c1d2650

+ 1 - 1
Web/package.json

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

+ 23 - 2
Web/src/views/aidop/s0/api/s0ManufacturingApi.ts

@@ -79,9 +79,27 @@ export interface S0MfgProductStructureMaster {
 	updatedAt?: string | null;
 	updatedAt?: string | null;
 }
 }
 
 
+/**
+ * 工序覆盖明细行(ProductStructureOp)。
+ * 业务含义:某顶层成品 productItem 上下文中,对本 BOM 行的工序号覆盖。
+ * 同一个 op 可能对应多行(不同 productItem),**不可按 op 去重或压缩**。
+ */
+export interface S0MfgStandardBomOp {
+	/** 已有明细的技术主键(string,防雪花/大整数精度丢失);新增行不传。 */
+	id?: string;
+	op: number;
+	productItem: string;
+	/** 旧系统状态列,只读回显 */
+	isActive?: boolean | null;
+	isConfirm?: boolean | null;
+	refs?: string | null;
+	ufld1?: string | null;
+	ufld2?: string | null;
+}
+
 export interface S0MfgStandardBomLineDetail {
 export interface S0MfgStandardBomLineDetail {
 	master: S0MfgProductStructureMaster;
 	master: S0MfgProductStructureMaster;
-	opNos: number[];
+	ops: S0MfgStandardBomOp[];
 }
 }
 
 
 export interface S0MfgStandardBomLineUpsert {
 export interface S0MfgStandardBomLineUpsert {
@@ -100,7 +118,7 @@ export interface S0MfgStandardBomLineUpsert {
 	endEff?: string | null;
 	endEff?: string | null;
 	structureType?: string;
 	structureType?: string;
 	isEnabled: boolean;
 	isEnabled: boolean;
-	opNos: number[];
+	ops: S0MfgStandardBomOp[];
 }
 }
 
 
 /** 标准工艺路线明细行(源 RoutingOpDetail 行级,方案 A)。 */
 /** 标准工艺路线明细行(源 RoutingOpDetail 行级,方案 A)。 */
@@ -660,6 +678,9 @@ export const s0MfgStandardBomLinesApi = {
 	delete: (id: number) => service.delete(`${mfg}/standard-bom-lines/${id}`).then(unwrap),
 	delete: (id: number) => service.delete(`${mfg}/standard-bom-lines/${id}`).then(unwrap),
 	toggleEnabled: (id: number, body: ToggleEnabledBody) =>
 	toggleEnabled: (id: number, body: ToggleEnabledBody) =>
 		service.patch<S0MfgProductStructureMaster>(`${mfg}/standard-bom-lines/${id}/toggle-enabled`, body).then(unwrap),
 		service.patch<S0MfgProductStructureMaster>(`${mfg}/standard-bom-lines/${id}/toggle-enabled`, body).then(unwrap),
+	/** 工序覆盖明细可选的成品物料(该 BOM 行父件的顶层成品候选集)。 */
+	productItemOptions: (id: number) =>
+		service.get<{ value: string; label: string }[]>(`${mfg}/standard-bom-lines/${id}/product-item-options`).then(unwrap),
 };
 };
 
 
 export const s0MfgRoutingOpDetailsApi = {
 export const s0MfgRoutingOpDetailsApi = {

+ 61 - 11
Web/src/views/aidop/s0/manufacturing/StandardBomManagement.vue

@@ -114,8 +114,42 @@
 					<el-col :span="8"><el-form-item label="固定损耗"><el-input-number v-model="form.qtyConsumed" :min="0" :step="0.01" style="width: 100%" /></el-form-item></el-col>
 					<el-col :span="8"><el-form-item label="固定损耗"><el-input-number v-model="form.qtyConsumed" :min="0" :step="0.01" style="width: 100%" /></el-form-item></el-col>
 					<el-col :span="8"><el-form-item label="行工序号"><el-input-number v-model="form.lineOp" :min="0" :controls="false" style="width: 100%" clearable /></el-form-item></el-col>
 					<el-col :span="8"><el-form-item label="行工序号"><el-input-number v-model="form.lineOp" :min="0" :controls="false" style="width: 100%" clearable /></el-form-item></el-col>
 					<el-col :span="24">
 					<el-col :span="24">
-						<el-form-item label="多工序号">
-							<el-input v-model="form.opNosText" placeholder="逗号分隔,如 10,20,30;写入 ProductStructureOp" clearable />
+						<el-form-item label="工序覆盖">
+							<div style="width: 100%">
+								<div style="margin-bottom: 8px">
+									<el-button type="primary" plain size="small" :disabled="!editingId" @click="addOpRow">新增工序覆盖</el-button>
+									<span v-if="!editingId" style="margin-left: 8px; color: #909399; font-size: 12px">
+										成品物料候选依赖已保存的 BOM 行,请先保存主记录后再维护工序覆盖
+									</span>
+								</div>
+								<el-table :data="form.ops" border stripe size="small" row-key="__k" empty-text="暂无工序覆盖明细">
+									<el-table-column label="工序号" width="120">
+										<template #default="{ row }"><el-input-number v-model="row.op" :min="1" :controls="false" style="width: 100%" /></template>
+									</el-table-column>
+									<el-table-column label="成品物料" min-width="240">
+										<template #default="{ row }">
+											<el-select v-model="row.productItem" filterable clearable placeholder="请选择成品物料" style="width: 100%">
+												<el-option v-for="o in productItemOptions" :key="o.value" :label="o.label" :value="o.value" />
+												<el-option
+													v-if="row.productItem && !productItemOptions.some((o) => o.value === row.productItem)"
+													:key="`__cur_${row.productItem}`"
+													:label="`${row.productItem}(当前值)`"
+													:value="row.productItem"
+												/>
+											</el-select>
+										</template>
+									</el-table-column>
+									<el-table-column label="有效" width="70" align="center">
+										<template #default="{ row }">{{ row.isActive === false ? '否' : '是' }}</template>
+									</el-table-column>
+									<el-table-column label="确认" width="70" align="center">
+										<template #default="{ row }">{{ row.isConfirm ? '是' : '否' }}</template>
+									</el-table-column>
+									<el-table-column label="操作" width="80" align="center">
+										<template #default="{ $index }"><el-button type="danger" link @click="removeOpRow($index)">删除</el-button></template>
+									</el-table-column>
+								</el-table>
+							</div>
 						</el-form-item>
 						</el-form-item>
 					</el-col>
 					</el-col>
 					<el-col :span="12"><el-form-item label="生效起"><el-date-picker v-model="form.startEff" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width: 100%" /></el-form-item></el-col>
 					<el-col :span="12"><el-form-item label="生效起"><el-date-picker v-model="form.startEff" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width: 100%" /></el-form-item></el-col>
@@ -193,7 +227,7 @@ const form = reactive({
 	scrap: 0,
 	scrap: 0,
 	qtyConsumed: 0,
 	qtyConsumed: 0,
 	lineOp: undefined as number | undefined,
 	lineOp: undefined as number | undefined,
-	opNosText: '',
+	ops: [] as any[],
 	structureType: '',
 	structureType: '',
 	refs: '',
 	refs: '',
 	remark: '',
 	remark: '',
@@ -226,11 +260,24 @@ const rules: FormRules = {
 	qty: [{ required: true, message: '请填写用量', trigger: 'change' }],
 	qty: [{ required: true, message: '请填写用量', trigger: 'change' }],
 };
 };
 
 
-function parseOpNos(text: string): number[] {
-	return text
-		.split(/[,,\s]+/)
-		.map((s) => parseInt(s.trim(), 10))
-		.filter((n) => !Number.isNaN(n) && n > 0);
+const productItemOptions = ref<{ value: string; label: string }[]>([]);
+
+/** 载入成品物料候选:该 BOM 行父件的顶层成品集合(服务端按 BOM 上溯计算并校验)。 */
+async function loadProductItemOptions(id: number) {
+	try {
+		productItemOptions.value = (await s0MfgStandardBomLinesApi.productItemOptions(id)) || [];
+	} catch {
+		productItemOptions.value = [];
+	}
+}
+
+function addOpRow() {
+	form.ops.push({ __k: `new_${form.ops.length}_${form.ops.length + 1}`, op: undefined, productItem: '' });
+}
+
+/** 逐行删除:只移除用户点中的那一行,绝不按 op 批量删除同号行。 */
+function removeOpRow(index: number) {
+	form.ops.splice(index, 1);
 }
 }
 
 
 async function remoteSearchMaterials(keyword: string) {
 async function remoteSearchMaterials(keyword: string) {
@@ -298,7 +345,7 @@ function resetForm() {
 		scrap: 0,
 		scrap: 0,
 		qtyConsumed: 0,
 		qtyConsumed: 0,
 		lineOp: undefined,
 		lineOp: undefined,
-		opNosText: '',
+		ops: [],
 		structureType: '',
 		structureType: '',
 		refs: '',
 		refs: '',
 		remark: '',
 		remark: '',
@@ -307,6 +354,7 @@ function resetForm() {
 		isEnabled: true,
 		isEnabled: true,
 	});
 	});
 	materialOptions.value = [];
 	materialOptions.value = [];
+	productItemOptions.value = [];
 	formRef.value?.clearValidate();
 	formRef.value?.clearValidate();
 }
 }
 
 
@@ -338,7 +386,8 @@ async function openEdit(row: S0MfgStandardBomLineRow) {
 		scrap: Number(m.scrap),
 		scrap: Number(m.scrap),
 		qtyConsumed: Number(m.qtyConsumed),
 		qtyConsumed: Number(m.qtyConsumed),
 		lineOp: m.lineOp ?? undefined,
 		lineOp: m.lineOp ?? undefined,
-		opNosText: (detail.opNos || []).join(','),
+		// 每条工序覆盖独立一行;同一 op 的多行靠 productItem 区分,禁止按 op 合并
+		ops: (detail.ops || []).map((x: any, i: number) => ({ __k: `${x.id ?? 'n'}_${i}`, ...x })),
 		structureType: m.structureType ?? '',
 		structureType: m.structureType ?? '',
 		refs: m.refs ?? '',
 		refs: m.refs ?? '',
 		remark: m.remark ?? '',
 		remark: m.remark ?? '',
@@ -348,6 +397,7 @@ async function openEdit(row: S0MfgStandardBomLineRow) {
 	});
 	});
 	// 编辑:保留接口返回的原归属(legacy 1000/8010 不转雪花)。
 	// 编辑:保留接口返回的原归属(legacy 1000/8010 不转雪花)。
 	preserveOrgScopeForEdit(form, m);
 	preserveOrgScopeForEdit(form, m);
+	await loadProductItemOptions(row.id);
 	dialogVisible.value = true;
 	dialogVisible.value = true;
 }
 }
 
 
@@ -366,7 +416,7 @@ async function submitForm() {
 		scrap: form.scrap,
 		scrap: form.scrap,
 		qtyConsumed: form.qtyConsumed,
 		qtyConsumed: form.qtyConsumed,
 		lineOp: form.lineOp ?? null,
 		lineOp: form.lineOp ?? null,
-		opNos: parseOpNos(form.opNosText),
+		ops: (form.ops || []).map((x: any) => ({ id: x.id, op: x.op, productItem: x.productItem })),
 		structureType: form.structureType || undefined,
 		structureType: form.structureType || undefined,
 		refs: form.refs || undefined,
 		refs: form.refs || undefined,
 		remark: form.remark || undefined,
 		remark: form.remark || undefined,

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.417</AssemblyVersion>
-    <FileVersion>1.0.417</FileVersion>
-    <Version>1.0.417</Version>
+    <AssemblyVersion>1.0.418</AssemblyVersion>
+    <FileVersion>1.0.418</FileVersion>
+    <Version>1.0.418</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Manufacturing/AdoS0ProductStructureRulesTests.cs

@@ -13,7 +13,7 @@ public class AdoS0ProductStructureRulesTests
         ParentMaterialId = 10,
         ParentMaterialId = 10,
         ComponentMaterialId = 20,
         ComponentMaterialId = 20,
         Qty = 1m,
         Qty = 1m,
-        OpNos = new List<int>()
+        Ops = new List<AdoS0ProductStructureOpDto>()
     };
     };
 
 
     [Fact]
     [Fact]

+ 232 - 51
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0ProductStructuresController.cs

@@ -138,12 +138,54 @@ public class AdoS0ProductStructuresController : ControllerBase
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var m = await _masterRep.ByIdScopedAsync(id, tenantId);
         var m = await _masterRep.ByIdScopedAsync(id, tenantId);
         if (m == null) return NotFound();
         if (m == null) return NotFound();
-        var opNos = await _opRep.ScopedTo(tenantId)
+        // 无损返回:每行工序覆盖明细都要带上 ProductItem,否则同一 Op 的多行会被压缩、
+        // ProductItem 丢失(历史上正是这个契约缺陷导致 808 行被压成 520 行)。
+        // SortNo 全库为 0,不具排序能力,故用 (ProductItem, RecID) 作稳定序,不依赖 DB 自然序。
+        var ops = await _opRep.ScopedTo(tenantId)
             .Where(o => o.ProductStructureMasterId == id)
             .Where(o => o.ProductStructureMasterId == id)
-            .OrderBy(o => o.SortNo)
-            .Select(o => o.Op)
+            .OrderBy(o => o.ProductItem)
+            .OrderBy(o => o.Id)
+            .Select(o => new AdoS0ProductStructureOpDto
+            {
+                Id = o.Id.ToString(),
+                Op = o.Op,
+                ProductItem = o.ProductItem!,
+                IsActive = o.IsActive,
+                IsConfirm = o.IsConfirm,
+                Refs = o.Refs,
+                Ufld1 = o.Ufld1,
+                Ufld2 = o.Ufld2
+            })
             .ToListAsync();
             .ToListAsync();
-        return Ok(new { master = m, opNos });
+        return Ok(new { master = m, ops });
+    }
+
+    /// <summary>
+    /// 工序覆盖明细可选的「成品物料」候选集(只读)。
+    /// 候选 = 该 BOM 行父件在 BOM 中的顶层成品(沿 ComponentItem→ParentItem 上溯的根件),
+    /// 且必须解析到本租户物料主数据。value 为业务编码,不暴露内部主键。
+    /// </summary>
+    [HttpGet("{id:long}/product-item-options")]
+    public async Task<IActionResult> GetProductItemOptionsAsync(long id)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        var master = await _masterRep.ByIdScopedAsync(id, tenantId);
+        if (master == null) return NotFound();
+
+        var codes = await ResolveProductItemCandidatesAsync(tenantId, master.ParentItem);
+        if (codes.Count == 0) return Ok(Array.Empty<object>());
+
+        var items = await _materialRep.ScopedTo(tenantId)
+            .Where(i => codes.Contains(i.ItemNum))
+            .Select(i => new { i.ItemNum, i.Descr })
+            .ToListAsync();
+        var map = items.GroupBy(x => x.ItemNum).ToDictionary(g => g.Key, g => g.First().Descr);
+
+        return Ok(codes.Select(c => new
+        {
+            value = c,
+            label = map.TryGetValue(c, out var d) && !string.IsNullOrWhiteSpace(d) ? $"{c} / {d}" : c
+        }));
     }
     }
 
 
     [HttpPost]
     [HttpPost]
@@ -159,13 +201,12 @@ public class AdoS0ProductStructuresController : ControllerBase
         var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         if (itemErr != null) return BadRequest(new { message = itemErr });
         if (itemErr != null) return BadRequest(new { message = itemErr });
 
 
-        // DATA LOSS GUARD(见下方 SyncOpsAsync 注释):
-        // 当前 op 子表契约只携带 Op 一个字段,无法构造 ProductItem / Domain / ParentItem /
-        // ComponentItem / IsActive / IsConfirm / CreateUser 这 7 列,创建出来的会是稀疏行。
-        // 在完整子表契约落地前,禁止创建带工序明细的标准 BOM;OpNos 为空则照常创建主表。
-        if (NormalizeOps(dto.OpNos).Count > 0)
-            return AdoS0ApiErrors.InvalidRequest(
-                "当前页面的工序编号字段无法完整描述工序明细(缺少成品项等必填业务列),暂不支持新建时录入工序明细。请先创建标准 BOM 主记录。");
+        // 明细无损契约已落地,原「禁止带明细创建」的临时 guard 移除。
+        // 校验放在事务外:任何一条明细不合法即整体拒绝,不会留下半成品主表。
+        var opsIn = dto.Ops ?? new List<AdoS0ProductStructureOpDto>();
+        var createOpsErr = await ValidateOpsAsync(opsIn, (parentItem ?? string.Empty).Trim(),
+            tenantId, new Dictionary<int, AdoS0ProductStructureOp>());
+        if (createOpsErr != null) return AdoS0ApiErrors.InvalidRequest(createOpsErr);
 
 
         var db = _masterRep.Context;
         var db = _masterRep.Context;
         await db.Ado.BeginTranAsync();
         await db.Ado.BeginTranAsync();
@@ -178,7 +219,8 @@ public class AdoS0ProductStructuresController : ControllerBase
             entity.ComponentItem = componentItem!;
             entity.ComponentItem = componentItem!;
             entity.CreatedAt = DateTime.Now;
             entity.CreatedAt = DateTime.Now;
             entity = await _masterRep.AsInsertable(entity).ExecuteReturnEntityAsync();
             entity = await _masterRep.AsInsertable(entity).ExecuteReturnEntityAsync();
-            await SyncOpsAsync(entity.Id, dto, entity.ParentMaterialId, entity.ComponentMaterialId, tenantId);
+            await SyncOpsDiffAsync(entity, opsIn, tenantId,
+                new Dictionary<int, AdoS0ProductStructureOp>(), ResolveDomain(entity), CurrentUserCode());
             await db.Ado.CommitTranAsync();
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(entity.Id);
             return await GetDetailAsync(entity.Id);
         }
         }
@@ -205,30 +247,22 @@ public class AdoS0ProductStructuresController : ControllerBase
         var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
         if (itemErr != null) return BadRequest(new { message = itemErr });
         if (itemErr != null) return BadRequest(new { message = itemErr });
 
 
-        // ── DATA LOSS GUARD ──────────────────────────────────────────────────────
-        // 历史工序明细每行携带 ProductItem 等 7 列业务数据,而当前 DTO 只有 OpNos。
-        // 实测:520 个 master 中 96 个各有 4 行、行内 Op 完全相同、仅 ProductItem 不同;
-        // 原实现「删光子行 + 按 Distinct(OpNos) 重建」会把 808 行压成 520 行(净丢 288 行),
-        // 且重建行的 7 列全部为空。
-        //
-        // 在完整子表契约落地前:
-        //   提交的 Op 多重集合 == 现有子行的 Op 多重集合 → 跳过子表同步(主表照常更新)
-        //   不相同                                      → 拒绝保存,绝不 delete/insert
-        // 必须按【多重集合】比较:existing [303,303,303,303] vs submitted [303] 是「不同」,
-        // 不能因为去重后都是 [303] 就判为相同。
-        var existingOps = await _opRep.ScopedTo(tenantId)
-            .Where(o => o.ProductStructureMasterId == id)
-            .Select(o => o.Op)
-            .ToListAsync();
-        var submittedOps = NormalizeOps(dto.OpNos);
-        if (!existingOps.OrderBy(n => n).SequenceEqual(submittedOps.OrderBy(n => n)))
+        // 明细走 diff/upsert:id 命中改可编辑字段、缺 id 新增、库中有而未提交则删除。
+        // 原「删光子行 + 按 Distinct(OpNos) 重建」及其后的多重集合止血 guard 均已移除 ——
+        // 无损契约逐行携带 ProductItem,同 Op 多行不再产生歧义。
+        var opsIn = dto.Ops ?? new List<AdoS0ProductStructureOpDto>();
+        var existingById = await LoadExistingOpsAsync(tenantId, id);
+
+        foreach (var o in opsIn)   // 提交的 id 必须属于当前租户 + 当前主表,不信客户端
         {
         {
-            return AdoS0ApiErrors.InvalidRequest(
-                existingOps.Count > 0
-                    ? "该标准 BOM 含历史工序明细,当前页面的工序编号字段无法完整描述这些明细(缺少成品项等业务列),暂不支持修改工序集合。工序编号以外的字段可正常保存。"
-                    : "当前页面的工序编号字段无法完整描述工序明细(缺少成品项等必填业务列),暂不支持新增工序明细。");
+            if (string.IsNullOrWhiteSpace(o.Id)) continue;
+            if (!int.TryParse(o.Id, out var rid) || !existingById.ContainsKey(rid))
+                return AdoS0ApiErrors.InvalidRequest($"工序覆盖明细 {o.Id} 不属于当前标准 BOM 行");
         }
         }
 
 
+        var opsErr = await ValidateOpsAsync(opsIn, existing.ParentItem, tenantId, existingById);
+        if (opsErr != null) return AdoS0ApiErrors.InvalidRequest(opsErr);
+
         var db = _masterRep.Context;
         var db = _masterRep.Context;
         await db.Ado.BeginTranAsync();
         await db.Ado.BeginTranAsync();
         try
         try
@@ -239,7 +273,8 @@ public class AdoS0ProductStructuresController : ControllerBase
             existing.UpdatedAt = DateTime.Now;
             existing.UpdatedAt = DateTime.Now;
             await _masterRep.AsUpdateable(existing).ExecuteCommandAsync();
             await _masterRep.AsUpdateable(existing).ExecuteCommandAsync();
 
 
-            // 工序集合与库中完全一致 → 不触碰子表(这正是本 guard 的全部作用)
+            await SyncOpsDiffAsync(existing, opsIn, tenantId, existingById,
+                ResolveDomain(existing), CurrentUserCode());
 
 
             await db.Ado.CommitTranAsync();
             await db.Ado.CommitTranAsync();
             return await GetDetailAsync(id);
             return await GetDetailAsync(id);
@@ -274,7 +309,11 @@ public class AdoS0ProductStructuresController : ControllerBase
         await db.Ado.BeginTranAsync();
         await db.Ado.BeginTranAsync();
         try
         try
         {
         {
-            await _opRep.AsDeleteable().Where(o => o.ProductStructureMasterId == id).ExecuteCommandAsync();
+            // 删除主表时级联清子行。主表已经 ByIdScopedAsync 校验过归属,这里再显式带上租户,
+            // 与 line-post / 本控制器 diff 路径保持同一最小权限口径。
+            await _opRep.AsDeleteable()
+                .Where(o => o.ProductStructureMasterId == id && o.TenantId == tenantId)
+                .ExecuteCommandAsync();
             await _masterRep.DeleteAsync(entity);
             await _masterRep.DeleteAsync(entity);
             await db.Ado.CommitTranAsync();
             await db.Ado.CommitTranAsync();
             return Ok(new { message = "删除成功" });
             return Ok(new { message = "删除成功" });
@@ -316,29 +355,171 @@ public class AdoS0ProductStructuresController : ControllerBase
     /// 归一化提交的工序号:过滤非正数,**保留重复项**(多重集合语义)。
     /// 归一化提交的工序号:过滤非正数,**保留重复项**(多重集合语义)。
     /// 不做 Distinct —— 历史数据里同一 master 下多行共享同一个 Op 是合法形态。
     /// 不做 Distinct —— 历史数据里同一 master 下多行共享同一个 Op 是合法形态。
     /// </summary>
     /// </summary>
-    private static List<int> NormalizeOps(IEnumerable<int>? ops) =>
-        ops?.Where(n => n > 0).ToList() ?? new List<int>();
+    /// <summary>
+    /// 明细的 Domain 取值。历史 808 行恒为工厂域编码;此处由主表所属工厂推导,
+    /// **不硬编码 "8010"**。取不到时留空,由 DB 的 NULL 承接。
+    /// </summary>
+    private static string ResolveDomain(AdoS0ProductStructureMaster master) =>
+        master.FactoryRefId > 0 ? master.FactoryRefId.ToString() : string.Empty;
+
+    /// <summary>新增明细的创建人:当前认证用户账号(与历史行的工号形态一致)。</summary>
+    private static string CurrentUserCode() => App.User?.FindFirst(ClaimConst.Account)?.Value ?? string.Empty;
+
+    /// <summary>
+    /// 某父件可用的「顶层成品」候选集。
+    ///
+    /// 依据已证实的业务关系:ProductStructureOp 是「某顶层成品展开 BOM 时,对某条
+    /// (ParentItem → ComponentItem) 结构行的工序号覆盖」,其 ProductItem 恒为该父件在
+    /// BOM 中的祖先根件(实测 808/808 命中)。这里沿 ComponentItem→ParentItem 逐层上溯,
+    /// 取「不再作为任何行 ComponentItem 出现」的顶层件作为候选。
+    ///
+    /// 注意:候选集是**上限**而非实际值——历史数据里同一父件的 7 个祖先只有 4 个有覆盖行,
+    /// 说明具体选哪些是人工决定,服务端只做合法性收敛,不自动派生。
+    /// </summary>
+    private async Task<List<string>> ResolveProductItemCandidatesAsync(long tenantId, string parentItem)
+    {
+        var roots = new HashSet<string>(StringComparer.Ordinal);
+        var frontier = new List<string> { parentItem };
+        var visited = new HashSet<string>(StringComparer.Ordinal) { parentItem };
+
+        // 逐层上溯,深度设上限防止历史数据成环导致死循环
+        for (var depth = 0; depth < 16 && frontier.Count > 0; depth++)
+        {
+            var parents = await _masterRep.ScopedTo(tenantId)
+                .Where(m => frontier.Contains(m.ComponentItem))
+                .Select(m => m.ParentItem)
+                .Distinct()
+                .ToListAsync();
+
+            var next = new List<string>();
+            foreach (var p in parents)
+            {
+                if (string.IsNullOrWhiteSpace(p) || !visited.Add(p)) continue;
+                roots.Add(p);
+                next.Add(p);
+            }
+            frontier = next;
+        }
+
+        if (roots.Count == 0) return new List<string>();
+
+        // 只保留真正解析得到物料主数据的编码
+        var list = roots.ToList();
+        var resolved = await _materialRep.ScopedTo(tenantId)
+            .Where(i => list.Contains(i.ItemNum))
+            .Select(i => i.ItemNum)
+            .ToListAsync();
+        return resolved.Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToList();
+    }
+
+    /// <summary>
+    /// 工序覆盖明细的业务校验。
+    /// - ProductItem 必填、须存在于本租户物料主数据、且须属于该父件的顶层成品候选集
+    /// - 同一 BOM edge 下 ProductItem 不得重复(应用层唯一,DB 当前无 UNIQUE)
+    /// - Op 必须 &gt; 0;**未变更的历史 Op 一律放行**,不因当前路线数据不全而把历史行判非法
+    /// </summary>
+    private async Task<string?> ValidateOpsAsync(
+        List<AdoS0ProductStructureOpDto> ops, string parentItem, long tenantId,
+        IReadOnlyDictionary<int, AdoS0ProductStructureOp> existingById)
+    {
+        if (ops.Count == 0) return null;
+
+        var dupe = ops.Select(o => (o.ProductItem ?? string.Empty).Trim())
+            .GroupBy(x => x, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1);
+        if (dupe != null)
+            return $"成品物料 {dupe.Key} 在本 BOM 行下重复,同一父件/子件组合下每个成品只能有一条工序覆盖";
+
+        var candidates = await ResolveProductItemCandidatesAsync(tenantId, parentItem);
+        var candidateSet = new HashSet<string>(candidates, StringComparer.Ordinal);
+
+        foreach (var o in ops)
+        {
+            var pi = (o.ProductItem ?? string.Empty).Trim();
+            if (pi.Length == 0) return "成品物料不能为空";
+            if (o.Op <= 0) return $"成品物料 {pi} 的工序号必须大于 0";
+
+            // 未变更的历史行(id 命中且 ProductItem 未改)直接放行,保留历史合法性
+            var unchanged = false;
+            if (!string.IsNullOrWhiteSpace(o.Id) && int.TryParse(o.Id, out var rid)
+                && existingById.TryGetValue(rid, out var old))
+                unchanged = string.Equals(old.ProductItem, pi, StringComparison.Ordinal);
+
+            if (unchanged) continue;
+
+            if (!await _materialRep.ScopedTo(tenantId).AnyAsync(i => i.ItemNum == pi))
+                return $"成品物料 {pi} 不存在于物料主数据";
+            if (!candidateSet.Contains(pi))
+                return $"成品物料 {pi} 不属于父件 {parentItem} 的顶层成品范围,无法作为工序覆盖对象";
+        }
+        return null;
+    }
 
 
-    private async Task SyncOpsAsync(long masterId, AdoS0ProductStructureUpsertDto dto, long parentMaterialId, long componentMaterialId, long tenantId)
+    /// <summary>
+    /// 明细 diff/upsert —— **不做 delete-all + reinsert**。
+    /// id 命中 → 只改可编辑字段(Op / ProductItem),其余旧系统列原样保留;
+    /// id 缺失 → 按 master 与当前用户完整构造新行;
+    /// 库中有而未提交 → 删除(前端为逐行删除,语义明确)。
+    /// 所有 id 必须同时满足 tenant 与 master 归属,不信客户端。
+    /// </summary>
+    private async Task SyncOpsDiffAsync(
+        AdoS0ProductStructureMaster master, List<AdoS0ProductStructureOpDto> ops,
+        long tenantId, Dictionary<int, AdoS0ProductStructureOp> existingById, string domain, string createUser)
     {
     {
-        var distinct = dto.OpNos?.Where(n => n > 0).Distinct().OrderBy(n => n).ToList() ?? new List<int>();
-        var sort = 0;
-        foreach (var op in distinct)
+        var keep = new HashSet<long>();
+        foreach (var o in ops)
         {
         {
-            var row = new AdoS0ProductStructureOp
+            var pi = (o.ProductItem ?? string.Empty).Trim();
+            if (!string.IsNullOrWhiteSpace(o.Id) && int.TryParse(o.Id, out var rid)
+                && existingById.TryGetValue(rid, out var row))
+            {
+                keep.Add(rid);
+                row.Op = o.Op;
+                row.ProductItem = pi;
+                row.UpdateUser = createUser;
+                row.UpdateTime = DateTime.Now;
+                await _opRep.AsUpdateable(row).ExecuteCommandAsync();   // 只更新已映射列,旧系统列原样
+                continue;
+            }
+
+            await _opRep.AsInsertable(new AdoS0ProductStructureOp
             {
             {
                 TenantId = tenantId,
                 TenantId = tenantId,
-                CompanyRefId = dto.CompanyRefId,
-                FactoryRefId = dto.FactoryRefId,
-                ProductStructureMasterId = masterId,
-                ParentMaterialId = parentMaterialId,
-                ComponentMaterialId = componentMaterialId,
-                Op = op,
-                SortNo = sort++,
+                CompanyRefId = master.CompanyRefId,
+                FactoryRefId = master.FactoryRefId,
+                ProductStructureMasterId = master.Id,
+                ParentMaterialId = master.ParentMaterialId,
+                ComponentMaterialId = master.ComponentMaterialId,
+                ParentItem = master.ParentItem,
+                ComponentItem = master.ComponentItem,
+                ProductItem = pi,
+                Op = o.Op,
+                Domain = domain,
+                IsActive = true,
+                IsConfirm = false,
+                CreateUser = createUser,
+                CreateTime = DateTime.Now,
+                SortNo = 0,
+                BizId = 0,                 // 全库恒 0、无任何引用,保持一致
                 CreatedAt = DateTime.Now
                 CreatedAt = DateTime.Now
-            };
-            await _opRep.AsInsertable(row).ExecuteCommandAsync();
+            }).ExecuteCommandAsync();
         }
         }
+
+        foreach (var rid in existingById.Keys)
+        {
+            if (keep.Contains(rid)) continue;
+            await _opRep.AsDeleteable()
+                .Where(x => x.Id == rid && x.TenantId == tenantId && x.ProductStructureMasterId == master.Id)
+                .ExecuteCommandAsync();
+        }
+    }
+
+    /// <summary>取当前主表下的既有明细,按 RecID 索引(tenant + master 双重限定)。</summary>
+    private async Task<Dictionary<int, AdoS0ProductStructureOp>> LoadExistingOpsAsync(long tenantId, long masterId)
+    {
+        var rows = await _opRep.ScopedTo(tenantId)
+            .Where(o => o.ProductStructureMasterId == masterId)
+            .ToListAsync();
+        return rows.ToDictionary(r => r.Id);
     }
     }
 
 
     /// <summary>
     /// <summary>

+ 41 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Manufacturing/AdoS0ManufacturingDtos.cs

@@ -765,6 +765,41 @@ public class AdoS0MfgSopDocumentUpsertDto
 #region 聚合 Upsert(步骤 9)
 #region 聚合 Upsert(步骤 9)
 
 
 /// <summary>标准 BOM 行维护 Upsert(含多工序号,对应 ProductStructureOp)。</summary>
 /// <summary>标准 BOM 行维护 Upsert(含多工序号,对应 ProductStructureOp)。</summary>
+/// <summary>
+/// 工序覆盖明细行(ProductStructureOp)的无损 DTO。
+///
+/// 业务身份:(ParentItem, ComponentItem, ProductItem) —— 与生产端
+/// ProductDesignService.GetBomAndRouting 的 join 键完全一致;
+/// 技术身份:RecID(这里的 Id,long 序列化为 string 以防 JS 精度丢失)。
+/// Op 可编辑,**不可**作为身份键。
+/// </summary>
+public class AdoS0ProductStructureOpDto
+{
+    /// <summary>已有明细的技术主键(RecID)。为空表示新增行。服务端会校验其归属租户与主表。</summary>
+    public string? Id { get; set; }
+
+    /// <summary>工序号(覆盖 master.Op)。可编辑。</summary>
+    public int Op { get; set; }
+
+    /// <summary>成品物料编码。可编辑,取值须属于该父件的 BOM 顶层成品候选集。</summary>
+    public string ProductItem { get; set; } = string.Empty;
+
+    /// <summary>是否有效(旧系统字段)。现阶段只读回显,新增行由服务端置 true。</summary>
+    public bool? IsActive { get; set; }
+
+    /// <summary>确认(旧系统字段)。现阶段只读回显,新增行由服务端置 false。</summary>
+    public bool? IsConfirm { get; set; }
+
+    /// <summary>参考(旧系统字段)。只读保留。</summary>
+    public string? Refs { get; set; }
+
+    /// <summary>预留字段 1(旧系统)。只读保留。</summary>
+    public string? Ufld1 { get; set; }
+
+    /// <summary>预留字段 2(旧系统)。只读保留。</summary>
+    public string? Ufld2 { get; set; }
+}
+
 public class AdoS0ProductStructureUpsertDto
 public class AdoS0ProductStructureUpsertDto
 {
 {
     [Range(1, long.MaxValue, ErrorMessage = "公司不能为空")]
     [Range(1, long.MaxValue, ErrorMessage = "公司不能为空")]
@@ -792,7 +827,12 @@ public class AdoS0ProductStructureUpsertDto
     public bool IsEnabled { get; set; } = true;
     public bool IsEnabled { get; set; } = true;
 
 
     /// <summary>多工序号(去重排序后落 ProductStructureOp)。</summary>
     /// <summary>多工序号(去重排序后落 ProductStructureOp)。</summary>
-    public List<int> OpNos { get; set; } = new();
+    /// <summary>
+    /// 工序覆盖明细(ProductStructureOp)。无损契约:每行代表「某顶层成品 ProductItem
+    /// 上下文中,对本 BOM edge 的工序号覆盖」,故必须逐行携带 ProductItem,
+    /// 不能再退化成 List&lt;int&gt; 的工序号集合(会丢 ProductItem 并压缩行数)。
+    /// </summary>
+    public List<AdoS0ProductStructureOpDto> Ops { get; set; } = new();
 }
 }
 
 
 /// <summary>标准工艺路线明细行(RoutingOpDetail 复刻)Upsert。</summary>
 /// <summary>标准工艺路线明细行(RoutingOpDetail 复刻)Upsert。</summary>