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();
}
}
}