using Admin.NET.Plugin.AiDOP.Dto.S0.Quality; using Admin.NET.Plugin.AiDOP.Entity.S0.Quality; using Admin.NET.Plugin.AiDOP.FinishedWarehouse; using Admin.NET.Plugin.AiDOP.Infrastructure; using static Admin.NET.Plugin.AiDOP.Controllers.S0.Quality.AdoS0QmsControllerHelpers; namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Quality; /// /// FQC(成品)检验规范维护 —— FUNC-S0-052。 /// /// 【本轮变更:底层表切到 S7 运行链路】原实现写 `qms_fqcjygf` / `qms_fqcjygfzb`,该表**零下游消费者**, /// 维护出来的检规 S7 报检根本读不到。现改写 `qms_ccjygf` / `qms_ccjygfzb`——它才是 /// `FqcApplyService` 生成检验单时经桥表解析并快照的表。见 `S7:D-002`(唯一事实源)。 /// **路由、FUNC 权限码、菜单结构全部不变**,只换底层实体与字段集。 /// /// 【写后必须同步桥表】`ado_s7_fqc_spec_material_map` 是「物料 → 检规」的唯一解析入口; /// 不同步则新建的检规对 S7 完全不可见。Create / Update / Delete 三条写路径都调 /// `FqcSpecMaterialMapService.SyncSpecAsync`(单检规粒度,人工绑定行受保护)。 /// /// 【租户】`qms_ccjygf(zb)` 实体不实现 `ITenantIdFilter`,全局过滤器不生效; /// 六条路径全部显式 `tenant_id` 谓词,**明细查询/删除也必须带 TenantId**——目标表存在 /// 52 个 phantom glid / 444 行无主明细,只按 MasterId 过滤存在误删无主行的实际风险。 /// [ApiController] [Route("api/s0/quality/fqc-inspection-specs")] [NonUnify] public class AdoS0QmsFqcInspectionSpecsController : ControllerBase { private readonly SqlSugarRepository _rep; private readonly SqlSugarRepository _entryRep; private readonly FqcSpecMaterialMapService _specMap; public AdoS0QmsFqcInspectionSpecsController( SqlSugarRepository rep, SqlSugarRepository entryRep, FqcSpecMaterialMapService specMap) { _rep = rep; _entryRep = entryRep; _specMap = specMap; } [HttpGet] public async Task GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto q) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize); var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId) .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x => (x.FileNumber != null && x.FileNumber.Contains(q.Keyword!)) || (x.ProductName != null && x.ProductName.Contains(q.Keyword!)) || (x.ProductModel != null && x.ProductModel.Contains(q.Keyword!)) || (x.MaterialCode != null && x.MaterialCode.Contains(q.Keyword!))); var total = await query.CountAsync(); var list = await query.OrderByDescending(x => x.Id).Skip((q.Page - 1) * q.PageSize).Take(q.PageSize).ToListAsync(); return Ok(new { total, page = q.Page, pageSize = q.PageSize, list }); } [HttpGet("{id:long}")] public async Task GetDetailAsync(long id) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; var master = await _rep.ByIdScopedByColumnAsync(id, tenantId); if (master == null) return NotFound(); var items = await QueryEntriesAsync(id, tenantId); return Ok(new { master, items }); } [HttpPost] public async Task CreateAsync([FromBody] AdoS0QmsFinishedInspectionSpecUpsertDto 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) ?? AdoS0QmsSpecAggregateGuard.ValidateFinishedEntries(items); if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError); var db = _rep.Context; await db.Ado.BeginTranAsync(); long newId; try { var master = new AdoS0QmsFinishedInspectionSpec(); ApplyFqcSpec(master, dto); master.TenantId = tenantId; await _rep.AsInsertable(master).ExecuteReturnEntityAsync(); await SyncFqcEntriesAsync(master.Id, items, tenantId); await db.Ado.CommitTranAsync(); newId = master.Id; } catch { await db.Ado.RollbackTranAsync(); throw; } // 桥表同步放在主事务之外:同步失败不应回滚已保存的检规(用户可在维护页重试), // 但必须在返回前完成,否则前端拿到「保存成功」而 S7 仍解析不到。 await _specMap.SyncSpecAsync(tenantId, newId); return await GetDetailAsync(newId); } [HttpPut("{id:long}")] public async Task UpdateAsync(long id, [FromBody] AdoS0QmsFinishedInspectionSpecUpsertDto dto) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; var master = await _rep.ByIdScopedByColumnAsync(id, tenantId); if (master == null) return NotFound(); // 写入集保留历史已持久化的明细行(含空行),只丢弃本次新补的空占位; // 「原有明细数」按 meaningful 计,避免全空明细的历史单被自己的空行锁死。 var existingRows = await QueryEntriesAsync(id, tenantId); var items = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(dto.Items); var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate( [ ("文件编号", master.FileNumber, dto.FileNumber), ("物料编码", master.MaterialCode, dto.MaterialCode), ("版本", master.VersionNo, dto.VersionNo) ], AdoS0QmsSpecAggregateGuard.CountMeaningful(existingRows), AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items).Count) ?? AdoS0QmsSpecAggregateGuard.ValidateFinishedEntries(items); if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError); var db = _rep.Context; await db.Ado.BeginTranAsync(); try { ApplyFqcSpec(master, dto); await _rep.AsUpdateable(master).ExecuteCommandAsync(); await DeleteEntriesAsync(id, tenantId); await SyncFqcEntriesAsync(id, items, tenantId); await db.Ado.CommitTranAsync(); } catch { await db.Ado.RollbackTranAsync(); throw; } // wlbm / sxrq / bb 任一变更都会改变解析结果与生效日期排序,一律重同步(判断成本高于直接跑) await _specMap.SyncSpecAsync(tenantId, id); return await GetDetailAsync(id); } /// /// 删除检规。**已被检验单引用则拒绝**——`qms_qcpp_inspbill.jygfid` 是 varchar 无 FK, /// 物理删除会静默产生悬空指针、断掉质量记录的受控文件追溯链。 /// [HttpDelete("{id:long}")] public async Task DeleteAsync(long id) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; var master = await _rep.ByIdScopedByColumnAsync(id, tenantId); if (master == null) return NotFound(); var referenced = await _rep.Context.Ado.GetIntAsync( "SELECT COUNT(1) FROM qms_qcpp_inspbill WHERE tenant_id=@t AND jygfid=@j", new SugarParameter("@t", tenantId), new SugarParameter("@j", id.ToString())); if (referenced > 0) return AdoS0ApiErrors.InvalidRequest($"该检规已被 {referenced} 张检验单引用,不能删除"); var db = _rep.Context; await db.Ado.BeginTranAsync(); try { await DeleteEntriesAsync(id, tenantId); await _rep.DeleteAsync(master); await db.Ado.CommitTranAsync(); } catch { await db.Ado.RollbackTranAsync(); throw; } // 主表已不存在 → SyncSpec 只清自动映射行;人工绑定行保留待人工处置(不静默删人工痕迹) await _specMap.SyncSpecAsync(tenantId, id); return Ok(new { message = "删除成功" }); } // ==================== 内部 ==================== /// 明细查询:MasterId + TenantId 双条件(目标表存在无主明细,不能只按 MasterId)。 private Task> QueryEntriesAsync(long masterId, long tenantId) => _entryRep.AsQueryable() .Where(x => x.MasterId == masterId && x.TenantId == tenantId) .OrderBy(x => x.SeqNo).OrderBy(x => x.Id).ToListAsync(); /// 明细删除:同上,必须带 TenantId。 private Task DeleteEntriesAsync(long masterId, long tenantId) => _entryRep.AsDeleteable() .Where(x => x.MasterId == masterId && x.TenantId == tenantId).ExecuteCommandAsync(); /// FQC 检规必填集(UAT-S0-10 拍板;本轮不含「适用型号」,缺业务依据)。 private static IReadOnlyList<(string, string?)> FqcRequiredFields(AdoS0QmsFinishedInspectionSpecUpsertDto dto) => [ ("文件编号", dto.FileNumber), ("物料编码", dto.MaterialCode), ("版本", dto.VersionNo), ]; private static void ApplyFqcSpec(AdoS0QmsFinishedInspectionSpec entity, AdoS0QmsFinishedInspectionSpecUpsertDto dto) { entity.FileNumber = NullIfWhiteSpace(dto.FileNumber); entity.VersionNo = NullIfWhiteSpace(dto.VersionNo); entity.ProductName = NullIfWhiteSpace(dto.ProductName); entity.ProductModel = NullIfWhiteSpace(dto.ProductModel); entity.EffectiveDate = NullIfWhiteSpace(dto.EffectiveDate); entity.MaterialCode = NullIfWhiteSpace(dto.MaterialCode); entity.Attachment = NullIfWhiteSpace(dto.Attachment); } private async Task SyncFqcEntriesAsync(long masterId, IEnumerable items, long tenantId) { foreach (var item in items) { var entity = new AdoS0QmsFinishedInspectionSpecEntry { TenantId = tenantId, MasterId = masterId, SeqNo = NullIfWhiteSpace(item.SeqNo), InspectionItem = NullIfWhiteSpace(item.InspectionItem), ResultType = NormalizeResultType(item.ResultType), UpperLimit = NullIfWhiteSpace(item.UpperLimit), LowerLimit = NullIfWhiteSpace(item.LowerLimit), TechnicalRequirement = NullIfWhiteSpace(item.TechnicalRequirement), InspectionMethod = NullIfWhiteSpace(item.InspectionMethod), Instrument = NullIfWhiteSpace(item.Instrument), InspectionClause = NullIfWhiteSpace(item.InspectionClause), TechnicalStandard = NullIfWhiteSpace(item.TechnicalStandard), SamplingPlan = NullIfWhiteSpace(item.SamplingPlan), Record = NullIfWhiteSpace(item.Record), SizeSpecifications = NullIfWhiteSpace(item.SizeSpecifications), Remarks = NullIfWhiteSpace(item.Remarks) }; // 非数值型不承载上下限:避免脏残留在生成检验单时被当成数值判定条件快照出去 if (entity.ResultType == "NON_NUMERIC") { entity.UpperLimit = null; entity.LowerLimit = null; } await _entryRep.AsInsertable(entity).ExecuteCommandAsync(); } } /// 录入类型归一:大小写不敏感,空/未知一律落 NULL(=未设置,运行期退化为人工判定)。 internal static string? NormalizeResultType(string? value) { var v = (value ?? string.Empty).Trim().ToUpperInvariant(); return v is "NUMERIC" or "NON_NUMERIC" ? v : null; } } [ApiController] [Route("api/s0/quality/oqc-inspection-specs")] [NonUnify] public class AdoS0QmsOqcInspectionSpecsController : ControllerBase { private readonly SqlSugarRepository _rep; private readonly SqlSugarRepository _entryRep; public AdoS0QmsOqcInspectionSpecsController( SqlSugarRepository rep, SqlSugarRepository entryRep) { _rep = rep; _entryRep = entryRep; } [HttpGet] public async Task GetPagedAsync([FromQuery] AdoS0QualityPagedQueryDto q) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize); var query = _rep.AsQueryable().Where(x => x.TenantId == tenantId) .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), x => (x.FileNumber != null && x.FileNumber.Contains(q.Keyword!)) || (x.ApplicableModel != null && x.ApplicableModel.Contains(q.Keyword!)) || (x.MaterialCode != null && x.MaterialCode.Contains(q.Keyword!))); var total = await query.CountAsync(); var list = await query.OrderByDescending(x => x.Id).Skip((q.Page - 1) * q.PageSize).Take(q.PageSize).ToListAsync(); return Ok(new { total, page = q.Page, pageSize = q.PageSize, list }); } [HttpGet("{id:long}")] public async Task GetDetailAsync(long id) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; var master = await _rep.ByIdScopedByColumnAsync(id, tenantId); if (master == null) return NotFound(); var items = await _entryRep.AsQueryable().Where(x => x.MasterId == id).OrderBy(x => x.Id).ToListAsync(); return Ok(new { master, items }); } [HttpPost] public async Task 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 { var master = new AdoS0QmsOqcInspectionSpec(); ApplyOqcSpec(master, dto); master.TenantId = tenantId; await _rep.AsInsertable(master).ExecuteReturnEntityAsync(); await SyncOqcEntriesAsync(master.Id, items, tenantId); await db.Ado.CommitTranAsync(); return await GetDetailAsync(master.Id); } catch { await db.Ado.RollbackTranAsync(); throw; } } [HttpPut("{id:long}")] public async Task UpdateAsync(long id, [FromBody] AdoS0QmsOqcInspectionSpecUpsertDto dto) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; var master = await _rep.ByIdScopedByColumnAsync(id, tenantId); if (master == null) return NotFound(); // 写入集保留历史已持久化的明细行(含空行),只丢弃本次新补的空占位; // 「原有明细数」按 meaningful 计,避免全空明细的历史单被自己的空行锁死。 var existingRows = await _entryRep.AsQueryable().Where(x => x.MasterId == id).ToListAsync(); var items = AdoS0QmsSpecAggregateGuard.ForUpdateWrite(dto.Items); var guardError = AdoS0QmsSpecAggregateGuard.ValidateUpdate( [ ("文件编号", master.FileNumber, dto.FileNumber), ("物料编码", master.MaterialCode, dto.MaterialCode), ("版本", master.VersionNo, dto.VersionNo) ], AdoS0QmsSpecAggregateGuard.CountMeaningful(existingRows), AdoS0QmsSpecAggregateGuard.Meaningful(dto.Items).Count); if (guardError != null) return AdoS0ApiErrors.InvalidRequest(guardError); var db = _rep.Context; await db.Ado.BeginTranAsync(); try { ApplyOqcSpec(master, dto); await _rep.AsUpdateable(master).ExecuteCommandAsync(); await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync(); await SyncOqcEntriesAsync(id, items, tenantId); await db.Ado.CommitTranAsync(); return await GetDetailAsync(id); } catch { await db.Ado.RollbackTranAsync(); throw; } } [HttpDelete("{id:long}")] public async Task DeleteAsync(long id) { if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!; var master = await _rep.ByIdScopedByColumnAsync(id, tenantId); if (master == null) return NotFound(); var db = _rep.Context; await db.Ado.BeginTranAsync(); try { await _entryRep.AsDeleteable().Where(x => x.MasterId == id).ExecuteCommandAsync(); await _rep.DeleteAsync(master); await db.Ado.CommitTranAsync(); return Ok(new { message = "删除成功" }); } catch { await db.Ado.RollbackTranAsync(); throw; } } /// OQC 检规必填集(UAT-S0-10 拍板;本轮不含「适用型号」,缺业务依据)。 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 = NullIfWhiteSpace(dto.FileNumber); entity.VersionNo = NullIfWhiteSpace(dto.VersionNo); entity.EffectiveDate = NullIfWhiteSpace(dto.EffectiveDate); entity.Attachment = NullIfWhiteSpace(dto.Attachment); entity.MaterialCode = NullIfWhiteSpace(dto.MaterialCode); entity.Attachment2 = NullIfWhiteSpace(dto.Attachment2); entity.Version = dto.Version; } private async Task SyncOqcEntriesAsync(long masterId, IEnumerable items, long tenantId) { foreach (var item in items) { var entity = new AdoS0QmsOqcInspectionSpecEntry { TenantId = tenantId, MasterId = masterId, OperationCode = NullIfWhiteSpace(item.OperationCode), OperationName = NullIfWhiteSpace(item.OperationName), InspectionItem = NullIfWhiteSpace(item.InspectionItem), InspectionMethod = NullIfWhiteSpace(item.InspectionMethod), InspectionSpec = NullIfWhiteSpace(item.InspectionSpec), ImageCategory = NullIfWhiteSpace(item.ImageCategory), InspectionFrequency = NullIfWhiteSpace(item.InspectionFrequency), TechnicalStandard = NullIfWhiteSpace(item.TechnicalStandard), PeelingForce = item.PeelingForce, UpperLimit = NullIfWhiteSpace(item.UpperLimit), LowerLimit = NullIfWhiteSpace(item.LowerLimit) }; await _entryRep.AsInsertable(entity).ExecuteCommandAsync(); } } }