| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434 |
- using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
- namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
- /// <summary>
- /// 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 里出现也一律忽略/拒绝,全部由后端派生。
- /// </summary>
- [ApiDescriptionSettings(Order = 312, Description = "来料检验项目录入")]
- [Route("api/S5IqcInspection")]
- [NonUnify]
- public class IqcInspectionService : IDynamicApiController, ITransient
- {
- /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
- 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;
- }
- /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token 或超管未选租户 → 拒绝;不读前端 tenantId。</summary>
- private long ResolveTenantOrThrow()
- {
- var tid = _userManager.TenantId;
- if (tid <= 0) throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户");
- if (_userManager.SuperAdmin && tid == MainTenantId)
- throw Oops.Oh("超级管理员操作前必须选择目标租户");
- return tid;
- }
- /// <summary>
- /// 保存项目级检验结果。item 级增量 + item 内样本全量替换;全事务,任一项失败整体回滚。
- /// </summary>
- [DisplayName("保存来料检验项目结果")]
- [HttpPost("save-detail")]
- public async Task<IqcSaveDetailOutput> 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<ItemCfgRow>(
- """
- 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<SugarParameter>
- {
- 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<ItemPlan>(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<long>();
- var manualInvalidated = new List<long>();
- var now = DateTime.Now;
- var uid = _userManager.UserId;
- var tran = await _db.Ado.UseTranAsync(async () =>
- {
- foreach (var p in plans)
- {
- var itemPars = new List<SugarParameter>
- {
- 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<SugarParameter>
- {
- 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<string?> judgements;
- if (p.Samples != null)
- {
- judgements = p.Samples.Select(x => (string?)x.Judgement).ToList();
- }
- else
- {
- var existing = await _db.Ado.SqlQueryAsync<string>(
- "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<SugarParameter>
- {
- 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) ─────────────────────────
- /// <summary>
- /// 把入参归一成写入计划,并在此完成全部语义校验。任何违规直接抛,保证事务前失败。
- /// </summary>
- 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<SamplePlan>? samples = null;
- if (hasSamples)
- {
- samples = new List<SamplePlan>(it.Samples!.Count);
- var seen = new HashSet<int>();
- 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<SamplePlan>? 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; }
- }
- }
|