Procházet zdrojové kódy

feat(s5): add IQC item result API and submit validation

YY968XX před 4 dny
rodič
revize
23cb5126ca

+ 17 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcInspBillDto.cs

@@ -242,4 +242,21 @@ public class IqcInspBillDetailRow
     public string? BizCode { get; set; }
     /// <summary>流程提示</summary>
     public string? FlowHint { get; set; }
+
+    // ── H 项目级检验结果(Batch 3 纯加法;N2/N3/终态同样返回,只读与否由 CanEdit 表达)──
+
+    /// <summary>
+    /// 检验单项目实例(检规快照 + 项目级结果 + 逐样本)。
+    /// 恒为数组:itemCount=0(历史单 / NO_SPEC 新单 / 命中检规但 0 明细的新单)时为空数组,不为 null。
+    /// </summary>
+    public List<IqcInspBillItemLineRow> Lines { get; set; } = [];
+
+    /// <summary>项目级结果汇总(后端实时聚合)。itemCount=0 时各计数全为 0。</summary>
+    public IqcInspBillLineSummary LineSummary { get; set; } = new();
+
+    /// <summary>
+    /// 当前用户此刻是否可编辑项目级结果。**后端是唯一 authority**,
+    /// 前端不得自行按当前节点推断(判据见 IqcInspBillEditGate)。
+    /// </summary>
+    public bool CanEdit { get; set; }
 }

+ 191 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/Dto/IqcInspectionDto.cs

@@ -0,0 +1,191 @@
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+// ════════════════════════════════════════════════════════════════════════════
+//  S5 IQC 项目级结果(Batch 3)读写契约
+//
+//  读:GET /api/S5IqcInspBill/detail 的 lines[] / lineSummary / canEdit(纯加法)
+//  写:POST /api/S5IqcInspection/save-detail
+//
+//  后端派生 / 审计字段(前端一律不得 author,传了也被忽略):
+//      sampleNumeric · sample.judgement · itemJudgement · judgementSource
+//      inspectorId · inspectionTime · lastResultBy · lastResultAt · tenantId
+// ════════════════════════════════════════════════════════════════════════════
+
+/// <summary>
+/// 检验单项目实例(检规快照 + 项目级结果)——Detail lines[] 的行。
+/// 快照字段组生成后不可变;结果字段组仅 N1 可编辑态可写。
+/// </summary>
+public class IqcInspBillItemLineRow
+{
+    /// <summary>项目实例主键(save-detail 回传用)</summary>
+    public long Id { get; set; }
+
+    /// <summary>展示序号(快照自 qms_jygfzb.xh,可空可重复,不作键)</summary>
+    public int? Seq { get; set; }
+
+    // ── 来源追溯(仅 lineage,允许悬挂,前端不展示)──
+    public long SourceSpecId { get; set; }
+    public long SourceSpecItemId { get; set; }
+
+    // ── 检规快照(只读,永不回查 S0)──
+    public string? InspectionItem { get; set; }
+    public string? InspectionStandard { get; set; }
+    public string? MethodTool { get; set; }
+    public string? ImageCategory { get; set; }
+    public string? SamplingScheme { get; set; }
+    public string? SpecRemark { get; set; }
+    public string? UpperLimit { get; set; }
+    public string? LowerLimit { get; set; }
+
+    // ── 项目级结果 ──
+    /// <summary>NUMERIC / NON_NUMERIC;NULL = 未定(合法初态,由 N1 首次录入时选定)</summary>
+    public string? ResultType { get; set; }
+
+    /// <summary>PASS / FAIL / INVALID;NULL = 未录。后端唯一写入方</summary>
+    public string? ItemJudgement { get; set; }
+
+    /// <summary>AUTO / MANUAL;测量事实变更即强制复位为 AUTO</summary>
+    public string? JudgementSource { get; set; }
+
+    /// <summary>NON_NUMERIC 的测量真源之一;NUMERIC 恒为 NULL</summary>
+    public decimal? QualifiedQty { get; set; }
+
+    /// <summary>NON_NUMERIC 的测量真源之一;NUMERIC 恒为 NULL</summary>
+    public decimal? UnqualifiedQty { get; set; }
+
+    public string? ResultRemark { get; set; }
+    public long? LastResultBy { get; set; }
+    public DateTime? LastResultAt { get; set; }
+
+    /// <summary>NUMERIC 的测量真源;NON_NUMERIC 恒为空数组</summary>
+    public List<IqcInspBillItemSampleRow> Samples { get; set; } = [];
+}
+
+/// <summary>逐样本实测值(仅 NUMERIC 使用)。</summary>
+public class IqcInspBillItemSampleRow
+{
+    public long Id { get; set; }
+    public int SampleIndex { get; set; }
+
+    /// <summary>检验员录入原文(原样保留,供 INVALID 复盘)</summary>
+    public string? SampleValueRaw { get; set; }
+
+    /// <summary>后端解析值;不可解析 = NULL(不丢原文)</summary>
+    public decimal? SampleNumeric { get; set; }
+
+    /// <summary>PASS / FAIL / INVALID;后端唯一写入方</summary>
+    public string? Judgement { get; set; }
+
+    public string? Remark { get; set; }
+    public long? InspectorId { get; set; }
+    public DateTime? InspectionTime { get; set; }
+}
+
+/// <summary>
+/// 项目级结果汇总(后端实时聚合,不落表)。
+/// itemCount = 0 时全部为 0(LEGACY MODE:历史单 / NO_SPEC 新单 / MATCHED+0 明细新单同一语义)。
+/// </summary>
+public class IqcInspBillLineSummary
+{
+    /// <summary>项目实例总数;0 ⇒ LEGACY MODE</summary>
+    public int ItemCount { get; set; }
+
+    /// <summary>已给出 PASS/FAIL 的项目数(可提交口径)</summary>
+    public int JudgedCount { get; set; }
+
+    public int PassCount { get; set; }
+    public int FailCount { get; set; }
+    public int InvalidCount { get; set; }
+
+    /// <summary>itemJudgement 为 NULL 的项目数</summary>
+    public int UnjudgedCount { get; set; }
+}
+
+// ────────────────────────────── save-detail 入参 ──────────────────────────────
+
+/// <summary>
+/// 保存项目级检验结果(草稿,**不推进流程**)。
+/// item 级增量:未出现在 items 中的项目完全不动。
+/// item 内样本全量替换:携带的 samples 即该项目的完整新集合。
+/// </summary>
+public class IqcSaveDetailInput
+{
+    /// <summary>检验单 id(qms_qcp_inspbill.id)</summary>
+    public long BillId { get; set; }
+
+    public List<IqcSaveDetailItemInput> Items { get; set; } = [];
+}
+
+/// <summary>单个项目的结果入参。</summary>
+public class IqcSaveDetailItemInput
+{
+    /// <summary>项目实例 id;必须属当前租户 + 当前 bill,否则整单 400</summary>
+    public long ItemId { get; set; }
+
+    /// <summary>
+    /// 录入类型 NUMERIC / NON_NUMERIC。
+    /// 与库中现值不同(含 NULL→非 NULL)视为类型切换,会清空该项目的全部旧测量事实。
+    /// 不传(null)表示沿用库中现值。
+    /// </summary>
+    public string? ResultType { get; set; }
+
+    /// <summary>NON_NUMERIC 测量真源;NUMERIC 传值 → 400(禁双真源)</summary>
+    public decimal? QualifiedQty { get; set; }
+
+    /// <summary>NON_NUMERIC 测量真源;NUMERIC 传值 → 400(禁双真源)</summary>
+    public decimal? UnqualifiedQty { get; set; }
+
+    public string? ResultRemark { get; set; }
+
+    /// <summary>
+    /// 人工判定,仅允许 PASS / FAIL(不得人工设 INVALID)。
+    /// 传值 ⇒ judgementSource=MANUAL;不传 ⇒ 按测量真源 AUTO 重算。
+    /// </summary>
+    public string? ManualJudgement { get; set; }
+
+    /// <summary>
+    /// NUMERIC 测量真源,**该项目的完整新集合**(未出现的旧样本会被删除)。
+    /// NON_NUMERIC 携带 → 400。
+    /// </summary>
+    public List<IqcSaveDetailSampleInput>? Samples { get; set; }
+}
+
+/// <summary>单个样本入参。</summary>
+public class IqcSaveDetailSampleInput
+{
+    /// <summary>样本序号,必须 &gt;= 1 且在同一项目内不重复</summary>
+    public int SampleIndex { get; set; }
+
+    /// <summary>录入原文,非空(空值语义 = 该样本不存在 ⇒ 请从数组中移除,而不是传空串)</summary>
+    public string? SampleValueRaw { get; set; }
+
+    public string? Remark { get; set; }
+}
+
+// ────────────────────────────── save-detail 出参 ──────────────────────────────
+
+/// <summary>保存结果。回传受影响项目的最终判定与实时汇总,供前端免二次拉取。</summary>
+public class IqcSaveDetailOutput
+{
+    public bool Ok { get; set; } = true;
+
+    /// <summary>本次实际更新的项目数</summary>
+    public int SavedItems { get; set; }
+
+    /// <summary>本次写入的样本行数</summary>
+    public int SavedSamples { get; set; }
+
+    /// <summary>本次删除的样本行数(含类型切换清理与全量替换剪枝)</summary>
+    public int DeletedSamples { get; set; }
+
+    /// <summary>因类型切换而清空过测量事实的项目 id</summary>
+    public List<long> ClearedByTypeChange { get; set; } = [];
+
+    /// <summary>因测量事实变更而被作废的旧 MANUAL 判定所属项目 id</summary>
+    public List<long> ManualInvalidated { get; set; } = [];
+
+    public IqcInspBillLineSummary LineSummary { get; set; } = new();
+
+    /// <summary>本次涉及项目的最终结果(含重算后的 judgement / source)</summary>
+    public List<IqcInspBillItemLineRow> Items { get; set; } = [];
+}

+ 98 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillEditGate.cs

@@ -0,0 +1,98 @@
+using Admin.NET.Plugin.ApprovalFlow;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC 检验单「项目级结果是否可编辑」的唯一判据(Batch 3)。
+///
+/// 单独成类而非散落在 Service 内,目的是让编辑授权只有一个真源:
+/// Detail 的 canEdit、save-detail 的门禁都必须走这里,不允许各处自行判 currentNode。
+///
+/// 【判据】
+///   ① bill 必须存在且属当前租户(带租户谓词,见 tenant 边界说明)
+///   ② 取该 bill 最新流程实例(BizType + BizId)
+///      · 实例不存在(尚未起流)        → 具备检验员角色即可编辑
+///      · 实例非 Running(终态/拒绝/撤销)→ 只读
+///      · Running 且当前用户有 N1_INSPECT 待办 → 可编辑
+///      · 其余(N2 / GW_PD / N3 / end;或在 N1 但待办属他人)→ 只读
+///
+/// 【为什么必须有「实例不存在」分支】
+///   GenerateInspBill **不起流**,流程是 submit-result 惰性 StartFlow
+///   (IqcInspBillFlowService.cs:95-107)。新生成的检验单没有实例、也没有 N1 待办,
+///   若只认「有实例且在 N1」,检验员将永远无法在提交前录入结果,本能力直接失效。
+///   该分支与既有 IqcInspBillFlowService.BuildStateAsync 对 CanSubmitResult 的判法
+///   (实例为空时 = HasRole(RoleInspectorId))完全一致 —— 保证「能存」与「能提交」
+///   权限口径同一,不会出现可存不可提交、或可提交不可存的矛盾。
+///
+/// 【为什么不叠加 jyfzr == 当前用户】
+///   jyfzr 在报检分录上、由认领/调配写入;ApprovalFlow 的 N1 受让人由角色成员解析,
+///   两者是不同人群。同时要求会制造与 S7 FQC 同类的审批人死结。
+///   N1 待办本身已是更强的授权凭据,故刻意不加该条件。
+///
+/// 【租户边界的结构性限制(实测)】
+///   ApprovalFlowInstance / ApprovalFlowTask **没有 TenantId 列**(只有 OrgId / CreateUserId),
+///   因此流程查询无法直接加租户谓词。本类的做法是:先用带 tenant_id 谓词的 bill 查询确认
+///   billId 属当前租户,再以该 billId 作 BizId 查流程 —— 租户约束经 bill 传递。
+///   这是该框架表结构下可达的最强隔离。
+/// </summary>
+public class IqcInspBillEditGate : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly UserManager _userManager;
+
+    public IqcInspBillEditGate(ISqlSugarClient db, UserManager userManager)
+    {
+        _db = db;
+        _userManager = userManager;
+    }
+
+    /// <summary>
+    /// 该检验单当前是否允许由**当前用户**编辑项目级结果。
+    /// billId 不属当前租户时返回 false(不区分“不存在”与“属他人租户”,避免存在性泄漏)。
+    /// </summary>
+    public async Task<bool> CanEditAsync(long billId, long tenantId)
+    {
+        if (billId <= 0 || tenantId <= 0) return false;
+
+        // ① 租户归属(唯一的租户谓词落点;后续流程查询经 billId 传递该约束)
+        var owns = await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId",
+            new SugarParameter("@id", billId),
+            new SugarParameter("@TenantId", tenantId));
+        if (owns == 0) return false;
+
+        // ② 最新流程实例
+        var inst = await _db.Queryable<ApprovalFlowInstance>()
+            .ClearFilter()
+            .Where(x => x.BizType == IqcInspBillFlowConst.BizType && x.BizId == billId)
+            .OrderByDescending(x => x.Id)
+            .FirstAsync();
+
+        // 尚未起流:检验员可先录入,提交时再惰性起流
+        if (inst == null)
+            return await HasInspectorRoleAsync();
+
+        // 已结束(Approved / Rejected / Cancelled / Terminated)→ 只读
+        if (inst.Status != FlowInstanceStatusEnum.Running) return false;
+
+        // Running:必须当前用户在 N1_INSPECT 有待办
+        var userId = _userManager.UserId;
+        var myTask = await _db.Queryable<ApprovalFlowTask>()
+            .ClearFilter()
+            .Where(x => x.InstanceId == inst.Id
+                     && x.AssigneeId == userId
+                     && x.Status == FlowTaskStatusEnum.Pending)
+            .FirstAsync();
+
+        return myTask != null && myTask.NodeId == IqcInspBillFlowConst.NodeInspect;
+    }
+
+    /// <summary>当前用户是否具备 S5 IQC 检验员角色(跨数据范围,与 Flow 服务同口径)。</summary>
+    private async Task<bool> HasInspectorRoleAsync()
+    {
+        var userId = _userManager.UserId;
+        return await _db.Queryable<SysUserRole>()
+            .ClearFilter()
+            .AnyAsync(x => x.UserId == userId && x.RoleId == IqcInspBillFlowConst.RoleInspectorId);
+    }
+}

+ 50 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillFlowService.cs

@@ -29,16 +29,21 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
     private readonly UserManager _userManager;
     private readonly IqcWmsResultPushService _wmsPush;
 
+    /// <summary>Batch 3:项目级结果读取器,仅用于 submit-result 的只读前置校验。</summary>
+    private readonly IqcInspBillItemReader _itemReader;
+
     public IqcInspBillFlowService(
         ISqlSugarClient db,
         FlowEngineService flowEngine,
         UserManager userManager,
-        IqcWmsResultPushService wmsPush)
+        IqcWmsResultPushService wmsPush,
+        IqcInspBillItemReader itemReader)
     {
         _db = db;
         _flowEngine = flowEngine;
         _userManager = userManager;
         _wmsPush = wmsPush;
+        _itemReader = itemReader;
     }
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
@@ -86,6 +91,10 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
             if (input.Clfs is not (0 or 1 or 2)) throw Oops.Oh("判定不合格时处理方式 clfs 必填且只能为 0(让步接收)/1(挑选)/2(报废)");
         }
 
+        // Batch 3:项目级前置校验。刻意放在事务之前、Header 写入与流程推进之前 ——
+        // 纯只读 SELECT,失败直接抛,零写入残留、无需回滚。
+        await ValidateItemResultsBeforeSubmitAsync(input.Id, input.Pd);
+
         var inst = await GetLatestInstanceAsync(input.Id);
         if (inst != null && inst.Status != FlowInstanceStatusEnum.Running)
             throw Oops.Oh("该检验单流程已结束,无法再提交检验结果");
@@ -274,6 +283,46 @@ public class IqcInspBillFlowService : IDynamicApiController, ITransient
 
     // ───────────────────────── helpers ─────────────────────────
 
+    /// <summary>
+    /// Batch 3:item-mode 提交前置校验(**纯只读**,不写任何表、不推进流程)。
+    ///
+    /// 【LEGACY MODE · itemCount = 0】
+    ///   涵盖历史单、NO_SPEC 新单、命中检规但该检规 0 明细的新单 —— 三者同一语义。
+    ///   立即短路返回,**不追加任何项目/样本/类型校验**,行为与 Batch 3 之前完全一致。
+    ///   判据只看数据(COUNT),不看日期 / 单号 / 版本。
+    ///
+    /// 【ITEM MODE · itemCount &gt; 0】两条硬规则:
+    ///   ① 每个项目必须已给出 PASS / FAIL —— NULL(未录)与 INVALID(系统无法判定)均阻断提交;
+    ///   ② 存在 FAIL 项目时,整单判定不得为「合格」(pd=0) —— HARD ERROR,不是 warning,
+    ///      杜绝「项目不合格 + 整单合格」流入主管审核。
+    ///
+    /// 【刻意不做】不由项目结果反推 pd / dhsl / bhgsl / clfs —— Header 语义保持既有,本方法只校验。
+    /// 全部项目 PASS 而用户提交 pd=1(不合格)本批**不反向强制纠正**,按既有 Header 语义放行。
+    /// </summary>
+    private async Task ValidateItemResultsBeforeSubmitAsync(long billId, int pd)
+    {
+        var tid = ResolveTenantOrThrow();
+
+        var itemCount = await _itemReader.CountItemsAsync(billId, tid);
+        if (itemCount == 0) return; // LEGACY MODE:原样放行
+
+        var lines = await _itemReader.LoadLinesAsync(billId, tid);
+
+        var unready = lines.Where(l => !IqcInspectionJudge.IsSubmittable(l.ItemJudgement)).ToList();
+        if (unready.Count > 0)
+        {
+            var names = string.Join("、", unready.Take(5).Select(l => l.InspectionItem ?? $"#{l.Seq}"));
+            throw Oops.Oh($"INSPECTION_INCOMPLETE:仍有 {unready.Count} 个检验项目未给出有效判定({names}),无法提交");
+        }
+
+        var failed = lines.Where(l => l.ItemJudgement == IqcJudgement.Fail).ToList();
+        if (pd == 0 && failed.Count > 0)
+        {
+            var names = string.Join("、", failed.Take(5).Select(l => l.InspectionItem ?? $"#{l.Seq}"));
+            throw Oops.Oh($"存在 {failed.Count} 个不合格检验项目({names}),整单判定不能为合格");
+        }
+    }
+
     /// <summary>校验检验单存在并返回 FBILLNO。</summary>
     private async Task<string> EnsureBillExistsAsync(long id)
     {

+ 141 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillItemReader.cs

@@ -0,0 +1,141 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC 项目实例 + 逐样本 的只读读取器(Batch 3)。
+///
+/// 单独成类的原因:Detail(GET)与 save-detail(POST 回执)需要**完全相同**的行形状与汇总口径,
+/// 放一处可避免两边 SQL 漂移。本类只读,不含任何写入。
+///
+/// 【租户边界】所有查询都显式带 tenant_id 谓词,且首列即 tenant_id(命中 uk/idx 最左前缀)。
+/// 绝不依赖 billId / itemId 的全局唯一性作为隔离手段。
+/// </summary>
+public class IqcInspBillItemReader : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public IqcInspBillItemReader(ISqlSugarClient db) => _db = db;
+
+    /// <summary>
+    /// 该检验单的项目实例条数。0 ⇒ LEGACY MODE(历史单 / NO_SPEC 新单 / MATCHED+0 明细新单,同一语义)。
+    /// 判据只看数据,不看日期 / 单号 / 版本。
+    /// </summary>
+    public async Task<int> CountItemsAsync(long billId, long tenantId)
+        => await _db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM ado_s5_iqc_insp_bill_item WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId",
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@BillId", billId));
+
+    /// <summary>
+    /// 载入该检验单的全部项目实例(含其样本,按 seq/id 与 sample_index 升序)。
+    /// itemCount=0 时返回空列表(不是 null)。
+    /// </summary>
+    public async Task<List<IqcInspBillItemLineRow>> LoadLinesAsync(long billId, long tenantId)
+    {
+        var pars = new List<SugarParameter>
+        {
+            new("@TenantId", tenantId),
+            new("@BillId", billId),
+        };
+
+        var lines = await _db.Ado.SqlQueryAsync<IqcInspBillItemLineRow>(
+            """
+            SELECT
+                id                  AS Id,
+                seq                 AS Seq,
+                source_spec_id      AS SourceSpecId,
+                source_spec_item_id AS SourceSpecItemId,
+                inspection_item     AS InspectionItem,
+                inspection_standard AS InspectionStandard,
+                method_tool         AS MethodTool,
+                image_category      AS ImageCategory,
+                sampling_scheme     AS SamplingScheme,
+                spec_remark         AS SpecRemark,
+                upper_limit         AS UpperLimit,
+                lower_limit         AS LowerLimit,
+                result_type         AS ResultType,
+                item_judgement      AS ItemJudgement,
+                judgement_source    AS JudgementSource,
+                qualified_qty       AS QualifiedQty,
+                unqualified_qty     AS UnqualifiedQty,
+                result_remark       AS ResultRemark,
+                last_result_by      AS LastResultBy,
+                last_result_at      AS LastResultAt
+            FROM ado_s5_iqc_insp_bill_item
+            WHERE tenant_id = @TenantId AND inspection_bill_id = @BillId
+            ORDER BY seq, id
+            """,
+            pars);
+
+        if (lines.Count == 0) return lines;
+
+        // 样本按单一次取(反规范化的 inspection_bill_id 使其免 JOIN),再按 item 分组
+        var samples = await _db.Ado.SqlQueryAsync<SampleRow>(
+            """
+            SELECT
+                id               AS Id,
+                item_id          AS ItemId,
+                sample_index     AS SampleIndex,
+                sample_value_raw AS SampleValueRaw,
+                sample_numeric   AS SampleNumeric,
+                judgement        AS Judgement,
+                remark           AS Remark,
+                inspector_id     AS InspectorId,
+                inspection_time  AS InspectionTime
+            FROM ado_s5_iqc_insp_bill_item_sample
+            WHERE tenant_id = @TenantId AND inspection_bill_id = @BillId
+            ORDER BY item_id, sample_index
+            """,
+            pars);
+
+        var byItem = samples.GroupBy(s => s.ItemId).ToDictionary(g => g.Key, g => g.ToList());
+        foreach (var line in lines)
+        {
+            if (!byItem.TryGetValue(line.Id, out var list)) continue;
+            line.Samples = list.Select(s => new IqcInspBillItemSampleRow
+            {
+                Id = s.Id,
+                SampleIndex = s.SampleIndex,
+                SampleValueRaw = s.SampleValueRaw,
+                SampleNumeric = s.SampleNumeric,
+                Judgement = s.Judgement,
+                Remark = s.Remark,
+                InspectorId = s.InspectorId,
+                InspectionTime = s.InspectionTime,
+            }).ToList();
+        }
+        return lines;
+    }
+
+    /// <summary>实时聚合汇总(不落表,避免与 item 行形成第二个判定真源)。</summary>
+    public static IqcInspBillLineSummary Summarize(IReadOnlyList<IqcInspBillItemLineRow> lines)
+    {
+        var s = new IqcInspBillLineSummary { ItemCount = lines.Count };
+        foreach (var l in lines)
+        {
+            switch (l.ItemJudgement)
+            {
+                case IqcJudgement.Pass: s.PassCount++; s.JudgedCount++; break;
+                case IqcJudgement.Fail: s.FailCount++; s.JudgedCount++; break;
+                case IqcJudgement.Invalid: s.InvalidCount++; break;
+                default: s.UnjudgedCount++; break;
+            }
+        }
+        return s;
+    }
+
+    /// <summary>样本原始行(含 ItemId 用于分组,不外泄到 API 契约)。</summary>
+    private sealed class SampleRow
+    {
+        public long Id { get; set; }
+        public long ItemId { get; set; }
+        public int SampleIndex { get; set; }
+        public string? SampleValueRaw { get; set; }
+        public decimal? SampleNumeric { get; set; }
+        public string? Judgement { get; set; }
+        public string? Remark { get; set; }
+        public long? InspectorId { get; set; }
+        public DateTime? InspectionTime { get; set; }
+    }
+}

+ 21 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspBillService.cs

@@ -25,10 +25,22 @@ public class IqcInspBillService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
 
-    public IqcInspBillService(ISqlSugarClient db, UserManager userManager)
+    /// <summary>Batch 3:项目实例 + 逐样本 的只读读取器(与 save-detail 回执共用同一行形状与汇总口径)。</summary>
+    private readonly IqcInspBillItemReader _itemReader;
+
+    /// <summary>Batch 3:编辑授权唯一判据,供详情回传 canEdit(前端不得自行按节点推断)。</summary>
+    private readonly IqcInspBillEditGate _editGate;
+
+    public IqcInspBillService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        IqcInspBillItemReader itemReader,
+        IqcInspBillEditGate editGate)
     {
         _db = db;
         _userManager = userManager;
+        _itemReader = itemReader;
+        _editGate = editGate;
     }
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
@@ -249,6 +261,14 @@ public class IqcInspBillService : IDynamicApiController, ITransient
         row.BizId = row.Id;
         row.BizCode = row.BillNo;
         row.FlowHint = "流程将在后续 C'/D'/E' 批次接入";
+
+        // ── Batch 3 纯加法:项目级结果 ──
+        // 与 Header 同一个已解析租户,谓词 tenant_id + inspection_bill_id(首列即租户,命中 uk/idx 最左前缀)。
+        // 只读快照,绝不回查 S0 的 qms_jygfzb —— 否则 S0 改检规会篡改历史检验单的展示。
+        // N2 / N3 / 终态同样返回(Backend 不因节点隐藏历史测量事实),是否允许编辑由 CanEdit 表达。
+        row.Lines = await _itemReader.LoadLinesAsync(id, tenantId);
+        row.LineSummary = IqcInspBillItemReader.Summarize(row.Lines);
+        row.CanEdit = await _editGate.CanEditAsync(id, tenantId);
         return row;
     }
 }

+ 139 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspectionJudge.cs

@@ -0,0 +1,139 @@
+using System.Globalization;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>录入类型(ado_s5_iqc_insp_bill_item.result_type 的合法值,与 ck_s5_iqc_item_result_type 对齐)。</summary>
+public static class IqcResultType
+{
+    public const string Numeric = "NUMERIC";
+    public const string NonNumeric = "NON_NUMERIC";
+
+    public static bool IsValid(string? v) => v == Numeric || v == NonNumeric;
+}
+
+/// <summary>
+/// 判定值(与 ck_s5_iqc_item_judgement / ck_s5_iqc_sample_judgement 对齐)。
+/// 刻意**不含** PENDING —— Batch 1 已从枚举中移除:未录 = NULL,已录 = PASS/FAIL/INVALID 三值封闭。
+/// </summary>
+public static class IqcJudgement
+{
+    public const string Pass = "PASS";
+    public const string Fail = "FAIL";
+
+    /// <summary>系统无法形成有效测量判定(限值缺失 / 值不可解析)。**不允许人工设置**。</summary>
+    public const string Invalid = "INVALID";
+}
+
+/// <summary>判定出处(与 ck_s5_iqc_item_jsrc 对齐)。</summary>
+public static class IqcJudgementSource
+{
+    /// <summary>由测量真源实时重算,每次保存无条件覆盖。</summary>
+    public const string Auto = "AUTO";
+
+    /// <summary>检验员显式结论;测量事实一旦变更即强制复位为 AUTO。</summary>
+    public const string Manual = "MANUAL";
+}
+
+/// <summary>
+/// S5 IQC 项目级判定引擎(Batch 3,纯函数,后端唯一判定真源)。
+///
+/// 只依据「快照冻结的限值」+「实录测量事实」判定,**绝不回查 qms_jygfzb**(否则 S0 改检规会篡改历史判定)。
+/// 前端传入的任何 judgement / judgementSource 一律忽略。
+///
+/// 【单一真源】
+///   NUMERIC      → 测量真源 = ado_s5_iqc_insp_bill_item_sample 行集;item 上两个 qty 必须为 NULL
+///   NON_NUMERIC  → 测量真源 = item.qualified_qty / unqualified_qty;该 item 不得有 sample 行
+///
+/// 【与 S6 的差异(刻意)】
+///   ① S6 的 NON_NUMERIC 是逐样本 OK/NG 模型(ado_s6_process_inspection_result_item.actual_non_numeric),
+///      IQC 是 item 级计数模型且禁止样本行 ⇒ S6 该分支代码不可迁移;
+///   ② S6 的 AggregateItem / AllItemsCompleted 依赖快照的 sample_count(要求样本量),
+///      IQC 无该数据来源(qms_jygfzb 无对应列)⇒ 完整性只能基于 item_judgement 是否已定,
+///      不引入「要求样本量」概念;
+///   ③ NUMERIC 逐样本判定的四个分支与 S6ProcessInspectionJudge.JudgeSample 语义一致,按同规则实现。
+/// </summary>
+public static class IqcInspectionJudge
+{
+    /// <summary>
+    /// 解析十进制数(限值与样本值共用)。空/空白/不可解析 → null。
+    /// 口径沿用 S6ProcessInspectionJudge:NumberStyles.Any + InvariantCulture + Trim,
+    /// 避免服务器区域设置漂移导致小数点/千分位误判(刻意不采用 S7 的默认 Culture 写法)。
+    /// </summary>
+    public static decimal? ParseNumeric(string? raw)
+    {
+        if (string.IsNullOrWhiteSpace(raw)) return null;
+        return decimal.TryParse(raw.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
+    }
+
+    /// <summary>
+    /// NUMERIC 逐样本判定。只用快照冻结的 upper_limit / lower_limit,**绝不解析 inspection_standard 自由文本**。
+    ///
+    ///   上下限均缺失            → INVALID(无判据,不可默认 PASS)
+    ///   样本值不可解析          → INVALID(保留 raw 供复盘)
+    ///   双侧齐备                → lower ≤ v ≤ upper
+    ///   仅上限                  → v ≤ upper
+    ///   仅下限                  → v ≥ lower
+    /// </summary>
+    public static string JudgeSampleNumeric(string? actualRaw, string? lowerLimitRaw, string? upperLimitRaw)
+    {
+        var lo = ParseNumeric(lowerLimitRaw);
+        var up = ParseNumeric(upperLimitRaw);
+        if (lo == null && up == null) return IqcJudgement.Invalid;
+
+        var actual = ParseNumeric(actualRaw);
+        if (actual == null) return IqcJudgement.Invalid;
+
+        var ok = (lo == null || actual.Value >= lo.Value)
+              && (up == null || actual.Value <= up.Value);
+        return ok ? IqcJudgement.Pass : IqcJudgement.Fail;
+    }
+
+    /// <summary>
+    /// NUMERIC 项目级聚合(由该 item 全部样本的逐样本判定得出)。
+    ///
+    ///   无样本        → null(未录;**不可自动 PASS**)
+    ///   任一 INVALID  → INVALID
+    ///   任一 FAIL     → FAIL
+    ///   全部 PASS     → PASS
+    ///
+    /// 刻意不引入「录满样本量才算完成」——IQC 无 required_sample_qty 数据来源。
+    /// </summary>
+    public static string? AggregateNumericItem(IReadOnlyList<string?>? sampleJudgements)
+    {
+        if (sampleJudgements == null || sampleJudgements.Count == 0) return null;
+        if (sampleJudgements.Any(j => j == IqcJudgement.Invalid)) return IqcJudgement.Invalid;
+        if (sampleJudgements.Any(j => j == IqcJudgement.Fail)) return IqcJudgement.Fail;
+        if (sampleJudgements.All(j => j == IqcJudgement.Pass)) return IqcJudgement.Pass;
+        // 出现非三值枚举的脏值(理论不可达:judgement 由本引擎唯一写入 + DB CHECK 兜底)
+        return IqcJudgement.Invalid;
+    }
+
+    /// <summary>
+    /// NON_NUMERIC 项目级判定(直接消费计数真源,不经过样本)。
+    ///
+    ///   unqualified &gt; 0                        → FAIL
+    ///   unqualified = 0 且 qualified 非空       → PASS
+    ///   其余(数据不足:任一为空)              → null(未录)
+    ///
+    /// 「数据不足」刻意判为 null 而非 INVALID:INVALID 的语义是「系统无法形成有效测量判定」,
+    /// 而计数为空只是「还没录」,与 NUMERIC 无样本时返回 null 保持一致。
+    /// **绝不**根据 Header 报检数量自动补齐 qualified。
+    /// </summary>
+    public static string? JudgeNonNumericItem(decimal? qualifiedQty, decimal? unqualifiedQty)
+    {
+        if (unqualifiedQty == null || qualifiedQty == null) return null;
+        if (unqualifiedQty.Value > 0m) return IqcJudgement.Fail;
+        return IqcJudgement.Pass;
+    }
+
+    /// <summary>
+    /// 人工判定合法性。只允许 PASS / FAIL —— 不得人工设置 INVALID
+    /// (INVALID 表示系统无法判定,是派生事实,不是人可以宣称的结论)。
+    /// </summary>
+    public static bool IsValidManualJudgement(string? judgement)
+        => judgement == IqcJudgement.Pass || judgement == IqcJudgement.Fail;
+
+    /// <summary>提交放行判据:只有已给出 PASS / FAIL 的项目才算完成(NULL 与 INVALID 均不放行)。</summary>
+    public static bool IsSubmittable(string? itemJudgement)
+        => itemJudgement == IqcJudgement.Pass || itemJudgement == IqcJudgement.Fail;
+}

+ 434 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspectionService.cs

@@ -0,0 +1,434 @@
+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; }
+    }
+}