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