|
|
@@ -138,12 +138,54 @@ public class AdoS0ProductStructuresController : ControllerBase
|
|
|
if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
|
|
|
var m = await _masterRep.ByIdScopedAsync(id, tenantId);
|
|
|
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)
|
|
|
- .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();
|
|
|
- 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]
|
|
|
@@ -159,13 +201,12 @@ public class AdoS0ProductStructuresController : ControllerBase
|
|
|
var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
|
|
|
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;
|
|
|
await db.Ado.BeginTranAsync();
|
|
|
@@ -178,7 +219,8 @@ public class AdoS0ProductStructuresController : ControllerBase
|
|
|
entity.ComponentItem = componentItem!;
|
|
|
entity.CreatedAt = DateTime.Now;
|
|
|
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();
|
|
|
return await GetDetailAsync(entity.Id);
|
|
|
}
|
|
|
@@ -205,30 +247,22 @@ public class AdoS0ProductStructuresController : ControllerBase
|
|
|
var (parentItem, componentItem, itemErr) = await ResolveItemNumsAsync(dto.ParentMaterialId, dto.ComponentMaterialId, tenantId);
|
|
|
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;
|
|
|
await db.Ado.BeginTranAsync();
|
|
|
try
|
|
|
@@ -239,7 +273,8 @@ public class AdoS0ProductStructuresController : ControllerBase
|
|
|
existing.UpdatedAt = DateTime.Now;
|
|
|
await _masterRep.AsUpdateable(existing).ExecuteCommandAsync();
|
|
|
|
|
|
- // 工序集合与库中完全一致 → 不触碰子表(这正是本 guard 的全部作用)
|
|
|
+ await SyncOpsDiffAsync(existing, opsIn, tenantId, existingById,
|
|
|
+ ResolveDomain(existing), CurrentUserCode());
|
|
|
|
|
|
await db.Ado.CommitTranAsync();
|
|
|
return await GetDetailAsync(id);
|
|
|
@@ -274,7 +309,11 @@ public class AdoS0ProductStructuresController : ControllerBase
|
|
|
await db.Ado.BeginTranAsync();
|
|
|
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 db.Ado.CommitTranAsync();
|
|
|
return Ok(new { message = "删除成功" });
|
|
|
@@ -316,29 +355,171 @@ public class AdoS0ProductStructuresController : ControllerBase
|
|
|
/// 归一化提交的工序号:过滤非正数,**保留重复项**(多重集合语义)。
|
|
|
/// 不做 Distinct —— 历史数据里同一 master 下多行共享同一个 Op 是合法形态。
|
|
|
/// </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 必须 > 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,
|
|
|
- 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
|
|
|
- };
|
|
|
- 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>
|