using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto; namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse; /// /// S5 IQC 项目级检验结果录入(Batch 3)。只保存结果,**不推进流程**。 /// /// 【职责边界】 /// save-detail = 保存 item-level 测量事实与判定(草稿,可反复保存) /// submit-result = 校验 + 写整单结论 + 推进 N1→N2(仍在 IqcInspBillFlowService,本类不碰) /// ⇒ 保存 ≠ 审批,两者不可混合(原则:save-detail 绝不 Approve) /// /// 【单一真源】 /// NUMERIC → 测量真源 = ado_s5_iqc_insp_bill_item_sample;item 两个 qty 必须 NULL /// NON_NUMERIC → 测量真源 = item.qualified_qty / unqualified_qty;该 item 不得有样本行 /// 违反者一律 400,不静默存双真源(DB 侧另有 ck_s5_iqc_item_truth 兜底) /// /// 【编辑授权】唯一判据是 IqcInspBillEditGate.CanEditAsync(N1 可编辑 / N2·N3·Done 只读)。 /// 刻意不叠加 jyfzr==当前用户(与 S7 FqcInspectionService 的做法分歧,理由见 Gate 注释)。 /// 注意:S7 的 save-detail 完全不查流程态,只要是 jyfzr 本人在 N2/N3/终态仍可改明细 /// (FqcInspectionService.cs:104-155)—— 这是已上线的口子,本类刻意不复制。 /// /// 【前端不得 author 的字段】sampleNumeric · sample.judgement · itemJudgement · judgementSource /// · inspectorId · inspectionTime · lastResultBy · lastResultAt · tenantId /// —— 即使 DTO 里出现也一律忽略/拒绝,全部由后端派生。 /// [ApiDescriptionSettings(Order = 312, Description = "来料检验项目录入")] [Route("api/S5IqcInspection")] [NonUnify] public class IqcInspectionService : IDynamicApiController, ITransient { /// 主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。 private const long MainTenantId = 1300000000001L; private readonly ISqlSugarClient _db; private readonly UserManager _userManager; private readonly IqcInspBillEditGate _editGate; private readonly IqcInspBillItemReader _reader; public IqcInspectionService( ISqlSugarClient db, UserManager userManager, IqcInspBillEditGate editGate, IqcInspBillItemReader reader) { _db = db; _userManager = userManager; _editGate = editGate; _reader = reader; } /// 严格可信租户解析:只来自认证后 JWT;无 Token 或超管未选租户 → 拒绝;不读前端 tenantId。 private long ResolveTenantOrThrow() { var tid = _userManager.TenantId; if (tid <= 0) throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户"); if (_userManager.SuperAdmin && tid == MainTenantId) throw Oops.Oh("超级管理员操作前必须选择目标租户"); return tid; } /// /// 保存项目级检验结果。item 级增量 + item 内样本全量替换;全事务,任一项失败整体回滚。 /// [DisplayName("保存来料检验项目结果")] [HttpPost("save-detail")] public async Task SaveDetail([FromBody] IqcSaveDetailInput input) { var tenantId = ResolveTenantOrThrow(); if (input == null || input.BillId <= 0) throw Oops.Oh("检验单 id 非法"); if (input.Items == null || input.Items.Count == 0) throw Oops.Oh("请至少提交一个检验项目"); // ── ① 单据归属(租户谓词) ── var owns = await _db.Ado.GetIntAsync( "SELECT COUNT(1) FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId", new SugarParameter("@id", input.BillId), new SugarParameter("@TenantId", tenantId)); if (owns == 0) throw Oops.Oh("检验单不存在或不属于当前租户"); // ── ② 编辑授权(唯一判据) ── if (!await _editGate.CanEditAsync(input.BillId, tenantId)) throw Oops.Oh("当前节点不允许修改检验明细"); // ── ③ itemId 重复检测 ── var dup = input.Items.GroupBy(x => x.ItemId).FirstOrDefault(g => g.Count() > 1); if (dup != null) throw Oops.Oh($"检验项目 {dup.Key} 在本次请求中重复提交"); // ── ④ 只认本单本租户的项目(跨 bill / 跨租户注入在进入事务前即被挡下) ── var cfgList = await _db.Ado.SqlQueryAsync( """ SELECT id AS Id, result_type AS ResultType, upper_limit AS UpperLimit, lower_limit AS LowerLimit, qualified_qty AS QualifiedQty, unqualified_qty AS UnqualifiedQty, item_judgement AS ItemJudgement, judgement_source AS JudgementSource FROM ado_s5_iqc_insp_bill_item WHERE tenant_id = @TenantId AND inspection_bill_id = @BillId """, new List { new("@TenantId", tenantId), new("@BillId", input.BillId), }); var cfg = cfgList.ToDictionary(x => x.Id); foreach (var it in input.Items) { if (it.ItemId <= 0) throw Oops.Oh("检验项目 id 非法"); if (!cfg.ContainsKey(it.ItemId)) throw Oops.Oh($"检验明细 {it.ItemId} 不属于该检验单"); } // ── ⑤ 逐项语义校验(全部在事务外完成 ⇒ 失败时 0 partial writes) ── var plans = new List(input.Items.Count); foreach (var it in input.Items) { var db = cfg[it.ItemId]; var plan = BuildPlan(it, db); plans.Add(plan); } // ── ⑥ 单事务写入 ── var savedItems = 0; var savedSamples = 0; var deletedSamples = 0; var clearedByTypeChange = new List(); var manualInvalidated = new List(); var now = DateTime.Now; var uid = _userManager.UserId; var tran = await _db.Ado.UseTranAsync(async () => { foreach (var p in plans) { var itemPars = new List { new("@TenantId", tenantId), new("@BillId", input.BillId), new("@ItemId", p.ItemId), }; // 类型切换:清空该项目的**全部**旧测量事实(两类真源都清),避免残留第二真源 if (p.TypeChanged) { deletedSamples += await _db.Ado.ExecuteCommandAsync( "DELETE FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId", itemPars); clearedByTypeChange.Add(p.ItemId); } // NUMERIC:样本全量替换(携带 samples 时先删后插;未携带则不动样本集) if (p.EffectiveResultType == IqcResultType.Numeric && p.Samples != null) { if (!p.TypeChanged) { deletedSamples += await _db.Ado.ExecuteCommandAsync( "DELETE FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId", itemPars); } foreach (var s in p.Samples) { savedSamples += await _db.Ado.ExecuteCommandAsync( """ INSERT INTO ado_s5_iqc_insp_bill_item_sample ( tenant_id, inspection_bill_id, item_id, sample_index, sample_value_raw, sample_numeric, judgement, remark, inspector_id, inspection_time ) VALUES ( @TenantId, @BillId, @ItemId, @Idx, @Raw, @Num, @Judge, @Remark, @Uid, @Now ) """, new List { new("@TenantId", tenantId), new("@BillId", input.BillId), new("@ItemId", p.ItemId), new("@Idx", s.SampleIndex), new("@Raw", s.Raw), new("@Num", (object?)s.Numeric ?? DBNull.Value), new("@Judge", s.Judgement), new("@Remark", (object?)s.Remark ?? DBNull.Value), new("@Uid", (object?)uid ?? DBNull.Value), new("@Now", now), }); } } // NON_NUMERIC:确保无样本残留(类型切换已删;此处兜住历史脏数据) if (p.EffectiveResultType == IqcResultType.NonNumeric && !p.TypeChanged) { deletedSamples += await _db.Ado.ExecuteCommandAsync( "DELETE FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId", itemPars); } // 判定:AUTO 由真源实时重算;MANUAL 只覆盖 verdict、不改 measurement string? finalJudgement; string? finalSource; if (p.ManualJudgement != null) { finalJudgement = p.ManualJudgement; finalSource = IqcJudgementSource.Manual; } else if (p.KeepExistingVerdict) { // 未改测量事实且原判定为 MANUAL ⇒ 原样保留(只更新备注/审计) finalJudgement = p.DbItemJudgement; finalSource = p.DbJudgementSource; } else { finalSource = IqcJudgementSource.Auto; if (p.EffectiveResultType == IqcResultType.Numeric) { // 样本判定清单:本次替换过则用内存值,否则回读该项目现存样本 List judgements; if (p.Samples != null) { judgements = p.Samples.Select(x => (string?)x.Judgement).ToList(); } else { var existing = await _db.Ado.SqlQueryAsync( "SELECT judgement FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId ORDER BY sample_index", itemPars); judgements = existing.Select(x => (string?)x).ToList(); } finalJudgement = IqcInspectionJudge.AggregateNumericItem(judgements); } else if (p.EffectiveResultType == IqcResultType.NonNumeric) { finalJudgement = IqcInspectionJudge.JudgeNonNumericItem(p.FinalQualifiedQty, p.FinalUnqualifiedQty); } else { // 类型未定 ⇒ 不允许有任何测量事实,判定必为空 finalJudgement = null; } if (p.DbJudgementSource == IqcJudgementSource.Manual) manualInvalidated.Add(p.ItemId); } savedItems += await _db.Ado.ExecuteCommandAsync( """ UPDATE ado_s5_iqc_insp_bill_item SET result_type = @ResultType, qualified_qty = @QualifiedQty, unqualified_qty = @UnqualifiedQty, item_judgement = @ItemJudgement, judgement_source = @JudgementSource, result_remark = @ResultRemark, last_result_by = @Uid, last_result_at = @Now WHERE id = @ItemId AND inspection_bill_id = @BillId AND tenant_id = @TenantId """, new List { new("@ResultType", (object?)p.EffectiveResultType ?? DBNull.Value), new("@QualifiedQty", (object?)p.FinalQualifiedQty ?? DBNull.Value), new("@UnqualifiedQty", (object?)p.FinalUnqualifiedQty ?? DBNull.Value), new("@ItemJudgement", (object?)finalJudgement ?? DBNull.Value), new("@JudgementSource", (object?)finalSource ?? DBNull.Value), new("@ResultRemark", (object?)p.ResultRemark ?? DBNull.Value), new("@Uid", (object?)uid ?? DBNull.Value), new("@Now", now), new("@ItemId", p.ItemId), new("@BillId", input.BillId), new("@TenantId", tenantId), }); } }); if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("保存检验项目结果失败"); // ── ⑦ 回执:重读全量行 + 实时汇总 ── var lines = await _reader.LoadLinesAsync(input.BillId, tenantId); var touched = plans.Select(p => p.ItemId).ToHashSet(); return new IqcSaveDetailOutput { Ok = true, SavedItems = savedItems, SavedSamples = savedSamples, DeletedSamples = deletedSamples, ClearedByTypeChange = clearedByTypeChange, ManualInvalidated = manualInvalidated, LineSummary = IqcInspBillItemReader.Summarize(lines), Items = lines.Where(l => touched.Contains(l.Id)).ToList(), }; } // ───────────────────────── 计划构建(纯校验,无 DB) ───────────────────────── /// /// 把入参归一成写入计划,并在此完成全部语义校验。任何违规直接抛,保证事务前失败。 /// private static ItemPlan BuildPlan(IqcSaveDetailItemInput it, ItemCfgRow db) { // 录入类型 if (it.ResultType != null && !IqcResultType.IsValid(it.ResultType)) throw Oops.Oh($"检验明细 {it.ItemId} 的录入类型仅支持 NUMERIC / NON_NUMERIC"); var effective = it.ResultType ?? db.ResultType; var typeChanged = it.ResultType != null && it.ResultType != db.ResultType; var hasQty = it.QualifiedQty.HasValue || it.UnqualifiedQty.HasValue; var hasSamples = it.Samples != null; // 单一真源:类型与测量形态必须匹配 if (effective == IqcResultType.Numeric && hasQty) throw Oops.Oh($"检验明细 {it.ItemId} 为数值型,测量真源是样本值,不接受合格/不合格数"); if (effective == IqcResultType.NonNumeric && hasSamples) throw Oops.Oh($"检验明细 {it.ItemId} 为非数值型,测量真源是合格/不合格数,不接受样本值"); if (effective == null && (hasQty || hasSamples)) throw Oops.Oh($"检验明细 {it.ItemId} 尚未选择录入类型,不能录入测量结果"); // 数量合法性 if (it.QualifiedQty is < 0m) throw Oops.Oh($"检验明细 {it.ItemId} 的合格数不能为负"); if (it.UnqualifiedQty is < 0m) throw Oops.Oh($"检验明细 {it.ItemId} 的不合格数不能为负"); // 人工判定 if (it.ManualJudgement != null && !IqcInspectionJudge.IsValidManualJudgement(it.ManualJudgement)) throw Oops.Oh($"检验明细 {it.ItemId} 的人工判定仅允许 PASS / FAIL"); // 样本:序号 >0、项目内不重复、原文非空 List? samples = null; if (hasSamples) { samples = new List(it.Samples!.Count); var seen = new HashSet(); foreach (var s in it.Samples!) { if (s.SampleIndex < 1) throw Oops.Oh($"检验明细 {it.ItemId} 的样本序号必须大于 0"); if (!seen.Add(s.SampleIndex)) throw Oops.Oh($"检验明细 {it.ItemId} 的样本序号 {s.SampleIndex} 重复"); var raw = s.SampleValueRaw?.Trim(); if (string.IsNullOrEmpty(raw)) throw Oops.Oh($"检验明细 {it.ItemId} 的样本 {s.SampleIndex} 未填录入值(如需删除请从数组中移除该样本)"); samples.Add(new SamplePlan { SampleIndex = s.SampleIndex, Raw = raw, Numeric = IqcInspectionJudge.ParseNumeric(raw), // 判定只用快照冻结的上下限,绝不解析 inspection_standard 自由文本 Judgement = IqcInspectionJudge.JudgeSampleNumeric(raw, db.LowerLimit, db.UpperLimit), Remark = s.Remark, }); } samples = samples.OrderBy(x => x.SampleIndex).ToList(); } // 计数真源的最终值:切到非数值型、或本次显式给了数量 → 取本次值;否则沿用库中值 decimal? finalQualified; decimal? finalUnqualified; if (effective == IqcResultType.Numeric) { finalQualified = null; // 强制:数值型不得携带计数 finalUnqualified = null; } else if (effective == IqcResultType.NonNumeric && (typeChanged || hasQty)) { finalQualified = it.QualifiedQty; finalUnqualified = it.UnqualifiedQty; } else if (effective == IqcResultType.NonNumeric) { finalQualified = db.QualifiedQty; finalUnqualified = db.UnqualifiedQty; } else { finalQualified = null; finalUnqualified = null; } // 测量事实是否变化 —— 决定旧 MANUAL 是否失效(§禁止 stale verdict) var measurementChanged = typeChanged || hasSamples || hasQty; return new ItemPlan { ItemId = it.ItemId, EffectiveResultType = effective, TypeChanged = typeChanged, Samples = samples, FinalQualifiedQty = finalQualified, FinalUnqualifiedQty = finalUnqualified, ResultRemark = it.ResultRemark, ManualJudgement = it.ManualJudgement, DbItemJudgement = db.ItemJudgement, DbJudgementSource = db.JudgementSource, // 未改测量事实且原本是人工结论 ⇒ 保留原判定(只更备注/审计),不被 AUTO 覆盖 KeepExistingVerdict = !measurementChanged && db.JudgementSource == IqcJudgementSource.Manual, }; } private sealed class ItemCfgRow { public long Id { get; set; } public string? ResultType { get; set; } public string? UpperLimit { get; set; } public string? LowerLimit { get; set; } public decimal? QualifiedQty { get; set; } public decimal? UnqualifiedQty { get; set; } public string? ItemJudgement { get; set; } public string? JudgementSource { get; set; } } private sealed class ItemPlan { public long ItemId { get; set; } public string? EffectiveResultType { get; set; } public bool TypeChanged { get; set; } public List? Samples { get; set; } public decimal? FinalQualifiedQty { get; set; } public decimal? FinalUnqualifiedQty { get; set; } public string? ResultRemark { get; set; } public string? ManualJudgement { get; set; } public string? DbItemJudgement { get; set; } public string? DbJudgementSource { get; set; } public bool KeepExistingVerdict { get; set; } } private sealed class SamplePlan { public int SampleIndex { get; set; } public string Raw { get; set; } = ""; public decimal? Numeric { get; set; } public string Judgement { get; set; } = ""; public string? Remark { get; set; } } }