IqcInspectionService.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
  2. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  3. /// <summary>
  4. /// S5 IQC 项目级检验结果录入(Batch 3)。只保存结果,**不推进流程**。
  5. ///
  6. /// 【职责边界】
  7. /// save-detail = 保存 item-level 测量事实与判定(草稿,可反复保存)
  8. /// submit-result = 校验 + 写整单结论 + 推进 N1→N2(仍在 IqcInspBillFlowService,本类不碰)
  9. /// ⇒ 保存 ≠ 审批,两者不可混合(原则:save-detail 绝不 Approve)
  10. ///
  11. /// 【单一真源】
  12. /// NUMERIC → 测量真源 = ado_s5_iqc_insp_bill_item_sample;item 两个 qty 必须 NULL
  13. /// NON_NUMERIC → 测量真源 = item.qualified_qty / unqualified_qty;该 item 不得有样本行
  14. /// 违反者一律 400,不静默存双真源(DB 侧另有 ck_s5_iqc_item_truth 兜底)
  15. ///
  16. /// 【编辑授权】唯一判据是 IqcInspBillEditGate.CanEditAsync(N1 可编辑 / N2·N3·Done 只读)。
  17. /// 刻意不叠加 jyfzr==当前用户(与 S7 FqcInspectionService 的做法分歧,理由见 Gate 注释)。
  18. /// 注意:S7 的 save-detail 完全不查流程态,只要是 jyfzr 本人在 N2/N3/终态仍可改明细
  19. /// (FqcInspectionService.cs:104-155)—— 这是已上线的口子,本类刻意不复制。
  20. ///
  21. /// 【前端不得 author 的字段】sampleNumeric · sample.judgement · itemJudgement · judgementSource
  22. /// · inspectorId · inspectionTime · lastResultBy · lastResultAt · tenantId
  23. /// —— 即使 DTO 里出现也一律忽略/拒绝,全部由后端派生。
  24. /// </summary>
  25. [ApiDescriptionSettings(Order = 312, Description = "来料检验项目录入")]
  26. [Route("api/S5IqcInspection")]
  27. [NonUnify]
  28. public class IqcInspectionService : IDynamicApiController, ITransient
  29. {
  30. /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
  31. private const long MainTenantId = 1300000000001L;
  32. private readonly ISqlSugarClient _db;
  33. private readonly UserManager _userManager;
  34. private readonly IqcInspBillEditGate _editGate;
  35. private readonly IqcInspBillItemReader _reader;
  36. public IqcInspectionService(
  37. ISqlSugarClient db,
  38. UserManager userManager,
  39. IqcInspBillEditGate editGate,
  40. IqcInspBillItemReader reader)
  41. {
  42. _db = db;
  43. _userManager = userManager;
  44. _editGate = editGate;
  45. _reader = reader;
  46. }
  47. /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token 或超管未选租户 → 拒绝;不读前端 tenantId。</summary>
  48. private long ResolveTenantOrThrow()
  49. {
  50. var tid = _userManager.TenantId;
  51. if (tid <= 0) throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户");
  52. if (_userManager.SuperAdmin && tid == MainTenantId)
  53. throw Oops.Oh("超级管理员操作前必须选择目标租户");
  54. return tid;
  55. }
  56. /// <summary>
  57. /// 保存项目级检验结果。item 级增量 + item 内样本全量替换;全事务,任一项失败整体回滚。
  58. /// </summary>
  59. [DisplayName("保存来料检验项目结果")]
  60. [HttpPost("save-detail")]
  61. public async Task<IqcSaveDetailOutput> SaveDetail([FromBody] IqcSaveDetailInput input)
  62. {
  63. var tenantId = ResolveTenantOrThrow();
  64. if (input == null || input.BillId <= 0) throw Oops.Oh("检验单 id 非法");
  65. if (input.Items == null || input.Items.Count == 0) throw Oops.Oh("请至少提交一个检验项目");
  66. // ── ① 单据归属(租户谓词) ──
  67. var owns = await _db.Ado.GetIntAsync(
  68. "SELECT COUNT(1) FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId",
  69. new SugarParameter("@id", input.BillId),
  70. new SugarParameter("@TenantId", tenantId));
  71. if (owns == 0) throw Oops.Oh("检验单不存在或不属于当前租户");
  72. // ── ② 编辑授权(唯一判据) ──
  73. if (!await _editGate.CanEditAsync(input.BillId, tenantId))
  74. throw Oops.Oh("当前节点不允许修改检验明细");
  75. // ── ③ itemId 重复检测 ──
  76. var dup = input.Items.GroupBy(x => x.ItemId).FirstOrDefault(g => g.Count() > 1);
  77. if (dup != null) throw Oops.Oh($"检验项目 {dup.Key} 在本次请求中重复提交");
  78. // ── ④ 只认本单本租户的项目(跨 bill / 跨租户注入在进入事务前即被挡下) ──
  79. var cfgList = await _db.Ado.SqlQueryAsync<ItemCfgRow>(
  80. """
  81. SELECT id AS Id, result_type AS ResultType, upper_limit AS UpperLimit, lower_limit AS LowerLimit,
  82. qualified_qty AS QualifiedQty, unqualified_qty AS UnqualifiedQty,
  83. item_judgement AS ItemJudgement, judgement_source AS JudgementSource
  84. FROM ado_s5_iqc_insp_bill_item
  85. WHERE tenant_id = @TenantId AND inspection_bill_id = @BillId
  86. """,
  87. new List<SugarParameter>
  88. {
  89. new("@TenantId", tenantId),
  90. new("@BillId", input.BillId),
  91. });
  92. var cfg = cfgList.ToDictionary(x => x.Id);
  93. foreach (var it in input.Items)
  94. {
  95. if (it.ItemId <= 0) throw Oops.Oh("检验项目 id 非法");
  96. if (!cfg.ContainsKey(it.ItemId))
  97. throw Oops.Oh($"检验明细 {it.ItemId} 不属于该检验单");
  98. }
  99. // ── ⑤ 逐项语义校验(全部在事务外完成 ⇒ 失败时 0 partial writes) ──
  100. var plans = new List<ItemPlan>(input.Items.Count);
  101. foreach (var it in input.Items)
  102. {
  103. var db = cfg[it.ItemId];
  104. var plan = BuildPlan(it, db);
  105. plans.Add(plan);
  106. }
  107. // ── ⑥ 单事务写入 ──
  108. var savedItems = 0;
  109. var savedSamples = 0;
  110. var deletedSamples = 0;
  111. var clearedByTypeChange = new List<long>();
  112. var manualInvalidated = new List<long>();
  113. var now = DateTime.Now;
  114. var uid = _userManager.UserId;
  115. var tran = await _db.Ado.UseTranAsync(async () =>
  116. {
  117. foreach (var p in plans)
  118. {
  119. var itemPars = new List<SugarParameter>
  120. {
  121. new("@TenantId", tenantId),
  122. new("@BillId", input.BillId),
  123. new("@ItemId", p.ItemId),
  124. };
  125. // 类型切换:清空该项目的**全部**旧测量事实(两类真源都清),避免残留第二真源
  126. if (p.TypeChanged)
  127. {
  128. deletedSamples += await _db.Ado.ExecuteCommandAsync(
  129. "DELETE FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId",
  130. itemPars);
  131. clearedByTypeChange.Add(p.ItemId);
  132. }
  133. // NUMERIC:样本全量替换(携带 samples 时先删后插;未携带则不动样本集)
  134. if (p.EffectiveResultType == IqcResultType.Numeric && p.Samples != null)
  135. {
  136. if (!p.TypeChanged)
  137. {
  138. deletedSamples += await _db.Ado.ExecuteCommandAsync(
  139. "DELETE FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId",
  140. itemPars);
  141. }
  142. foreach (var s in p.Samples)
  143. {
  144. savedSamples += await _db.Ado.ExecuteCommandAsync(
  145. """
  146. INSERT INTO ado_s5_iqc_insp_bill_item_sample (
  147. tenant_id, inspection_bill_id, item_id, sample_index,
  148. sample_value_raw, sample_numeric, judgement, remark,
  149. inspector_id, inspection_time
  150. ) VALUES (
  151. @TenantId, @BillId, @ItemId, @Idx,
  152. @Raw, @Num, @Judge, @Remark,
  153. @Uid, @Now
  154. )
  155. """,
  156. new List<SugarParameter>
  157. {
  158. new("@TenantId", tenantId),
  159. new("@BillId", input.BillId),
  160. new("@ItemId", p.ItemId),
  161. new("@Idx", s.SampleIndex),
  162. new("@Raw", s.Raw),
  163. new("@Num", (object?)s.Numeric ?? DBNull.Value),
  164. new("@Judge", s.Judgement),
  165. new("@Remark", (object?)s.Remark ?? DBNull.Value),
  166. new("@Uid", (object?)uid ?? DBNull.Value),
  167. new("@Now", now),
  168. });
  169. }
  170. }
  171. // NON_NUMERIC:确保无样本残留(类型切换已删;此处兜住历史脏数据)
  172. if (p.EffectiveResultType == IqcResultType.NonNumeric && !p.TypeChanged)
  173. {
  174. deletedSamples += await _db.Ado.ExecuteCommandAsync(
  175. "DELETE FROM ado_s5_iqc_insp_bill_item_sample WHERE tenant_id=@TenantId AND inspection_bill_id=@BillId AND item_id=@ItemId",
  176. itemPars);
  177. }
  178. // 判定:AUTO 由真源实时重算;MANUAL 只覆盖 verdict、不改 measurement
  179. string? finalJudgement;
  180. string? finalSource;
  181. if (p.ManualJudgement != null)
  182. {
  183. finalJudgement = p.ManualJudgement;
  184. finalSource = IqcJudgementSource.Manual;
  185. }
  186. else if (p.KeepExistingVerdict)
  187. {
  188. // 未改测量事实且原判定为 MANUAL ⇒ 原样保留(只更新备注/审计)
  189. finalJudgement = p.DbItemJudgement;
  190. finalSource = p.DbJudgementSource;
  191. }
  192. else
  193. {
  194. finalSource = IqcJudgementSource.Auto;
  195. if (p.EffectiveResultType == IqcResultType.Numeric)
  196. {
  197. // 样本判定清单:本次替换过则用内存值,否则回读该项目现存样本
  198. List<string?> judgements;
  199. if (p.Samples != null)
  200. {
  201. judgements = p.Samples.Select(x => (string?)x.Judgement).ToList();
  202. }
  203. else
  204. {
  205. var existing = await _db.Ado.SqlQueryAsync<string>(
  206. "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",
  207. itemPars);
  208. judgements = existing.Select(x => (string?)x).ToList();
  209. }
  210. finalJudgement = IqcInspectionJudge.AggregateNumericItem(judgements);
  211. }
  212. else if (p.EffectiveResultType == IqcResultType.NonNumeric)
  213. {
  214. finalJudgement = IqcInspectionJudge.JudgeNonNumericItem(p.FinalQualifiedQty, p.FinalUnqualifiedQty);
  215. }
  216. else
  217. {
  218. // 类型未定 ⇒ 不允许有任何测量事实,判定必为空
  219. finalJudgement = null;
  220. }
  221. if (p.DbJudgementSource == IqcJudgementSource.Manual)
  222. manualInvalidated.Add(p.ItemId);
  223. }
  224. savedItems += await _db.Ado.ExecuteCommandAsync(
  225. """
  226. UPDATE ado_s5_iqc_insp_bill_item
  227. SET result_type = @ResultType,
  228. qualified_qty = @QualifiedQty,
  229. unqualified_qty = @UnqualifiedQty,
  230. item_judgement = @ItemJudgement,
  231. judgement_source = @JudgementSource,
  232. result_remark = @ResultRemark,
  233. last_result_by = @Uid,
  234. last_result_at = @Now
  235. WHERE id = @ItemId AND inspection_bill_id = @BillId AND tenant_id = @TenantId
  236. """,
  237. new List<SugarParameter>
  238. {
  239. new("@ResultType", (object?)p.EffectiveResultType ?? DBNull.Value),
  240. new("@QualifiedQty", (object?)p.FinalQualifiedQty ?? DBNull.Value),
  241. new("@UnqualifiedQty", (object?)p.FinalUnqualifiedQty ?? DBNull.Value),
  242. new("@ItemJudgement", (object?)finalJudgement ?? DBNull.Value),
  243. new("@JudgementSource", (object?)finalSource ?? DBNull.Value),
  244. new("@ResultRemark", (object?)p.ResultRemark ?? DBNull.Value),
  245. new("@Uid", (object?)uid ?? DBNull.Value),
  246. new("@Now", now),
  247. new("@ItemId", p.ItemId),
  248. new("@BillId", input.BillId),
  249. new("@TenantId", tenantId),
  250. });
  251. }
  252. });
  253. if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("保存检验项目结果失败");
  254. // ── ⑦ 回执:重读全量行 + 实时汇总 ──
  255. var lines = await _reader.LoadLinesAsync(input.BillId, tenantId);
  256. var touched = plans.Select(p => p.ItemId).ToHashSet();
  257. return new IqcSaveDetailOutput
  258. {
  259. Ok = true,
  260. SavedItems = savedItems,
  261. SavedSamples = savedSamples,
  262. DeletedSamples = deletedSamples,
  263. ClearedByTypeChange = clearedByTypeChange,
  264. ManualInvalidated = manualInvalidated,
  265. LineSummary = IqcInspBillItemReader.Summarize(lines),
  266. Items = lines.Where(l => touched.Contains(l.Id)).ToList(),
  267. };
  268. }
  269. // ───────────────────────── 计划构建(纯校验,无 DB) ─────────────────────────
  270. /// <summary>
  271. /// 把入参归一成写入计划,并在此完成全部语义校验。任何违规直接抛,保证事务前失败。
  272. /// </summary>
  273. private static ItemPlan BuildPlan(IqcSaveDetailItemInput it, ItemCfgRow db)
  274. {
  275. // 录入类型
  276. if (it.ResultType != null && !IqcResultType.IsValid(it.ResultType))
  277. throw Oops.Oh($"检验明细 {it.ItemId} 的录入类型仅支持 NUMERIC / NON_NUMERIC");
  278. var effective = it.ResultType ?? db.ResultType;
  279. var typeChanged = it.ResultType != null && it.ResultType != db.ResultType;
  280. var hasQty = it.QualifiedQty.HasValue || it.UnqualifiedQty.HasValue;
  281. var hasSamples = it.Samples != null;
  282. // 单一真源:类型与测量形态必须匹配
  283. if (effective == IqcResultType.Numeric && hasQty)
  284. throw Oops.Oh($"检验明细 {it.ItemId} 为数值型,测量真源是样本值,不接受合格/不合格数");
  285. if (effective == IqcResultType.NonNumeric && hasSamples)
  286. throw Oops.Oh($"检验明细 {it.ItemId} 为非数值型,测量真源是合格/不合格数,不接受样本值");
  287. if (effective == null && (hasQty || hasSamples))
  288. throw Oops.Oh($"检验明细 {it.ItemId} 尚未选择录入类型,不能录入测量结果");
  289. // 数量合法性
  290. if (it.QualifiedQty is < 0m) throw Oops.Oh($"检验明细 {it.ItemId} 的合格数不能为负");
  291. if (it.UnqualifiedQty is < 0m) throw Oops.Oh($"检验明细 {it.ItemId} 的不合格数不能为负");
  292. // 人工判定
  293. if (it.ManualJudgement != null && !IqcInspectionJudge.IsValidManualJudgement(it.ManualJudgement))
  294. throw Oops.Oh($"检验明细 {it.ItemId} 的人工判定仅允许 PASS / FAIL");
  295. // 样本:序号 >0、项目内不重复、原文非空
  296. List<SamplePlan>? samples = null;
  297. if (hasSamples)
  298. {
  299. samples = new List<SamplePlan>(it.Samples!.Count);
  300. var seen = new HashSet<int>();
  301. foreach (var s in it.Samples!)
  302. {
  303. if (s.SampleIndex < 1)
  304. throw Oops.Oh($"检验明细 {it.ItemId} 的样本序号必须大于 0");
  305. if (!seen.Add(s.SampleIndex))
  306. throw Oops.Oh($"检验明细 {it.ItemId} 的样本序号 {s.SampleIndex} 重复");
  307. var raw = s.SampleValueRaw?.Trim();
  308. if (string.IsNullOrEmpty(raw))
  309. throw Oops.Oh($"检验明细 {it.ItemId} 的样本 {s.SampleIndex} 未填录入值(如需删除请从数组中移除该样本)");
  310. samples.Add(new SamplePlan
  311. {
  312. SampleIndex = s.SampleIndex,
  313. Raw = raw,
  314. Numeric = IqcInspectionJudge.ParseNumeric(raw),
  315. // 判定只用快照冻结的上下限,绝不解析 inspection_standard 自由文本
  316. Judgement = IqcInspectionJudge.JudgeSampleNumeric(raw, db.LowerLimit, db.UpperLimit),
  317. Remark = s.Remark,
  318. });
  319. }
  320. samples = samples.OrderBy(x => x.SampleIndex).ToList();
  321. }
  322. // 计数真源的最终值:切到非数值型、或本次显式给了数量 → 取本次值;否则沿用库中值
  323. decimal? finalQualified;
  324. decimal? finalUnqualified;
  325. if (effective == IqcResultType.Numeric)
  326. {
  327. finalQualified = null; // 强制:数值型不得携带计数
  328. finalUnqualified = null;
  329. }
  330. else if (effective == IqcResultType.NonNumeric && (typeChanged || hasQty))
  331. {
  332. finalQualified = it.QualifiedQty;
  333. finalUnqualified = it.UnqualifiedQty;
  334. }
  335. else if (effective == IqcResultType.NonNumeric)
  336. {
  337. finalQualified = db.QualifiedQty;
  338. finalUnqualified = db.UnqualifiedQty;
  339. }
  340. else
  341. {
  342. finalQualified = null;
  343. finalUnqualified = null;
  344. }
  345. // 测量事实是否变化 —— 决定旧 MANUAL 是否失效(§禁止 stale verdict)
  346. var measurementChanged = typeChanged || hasSamples || hasQty;
  347. return new ItemPlan
  348. {
  349. ItemId = it.ItemId,
  350. EffectiveResultType = effective,
  351. TypeChanged = typeChanged,
  352. Samples = samples,
  353. FinalQualifiedQty = finalQualified,
  354. FinalUnqualifiedQty = finalUnqualified,
  355. ResultRemark = it.ResultRemark,
  356. ManualJudgement = it.ManualJudgement,
  357. DbItemJudgement = db.ItemJudgement,
  358. DbJudgementSource = db.JudgementSource,
  359. // 未改测量事实且原本是人工结论 ⇒ 保留原判定(只更备注/审计),不被 AUTO 覆盖
  360. KeepExistingVerdict = !measurementChanged && db.JudgementSource == IqcJudgementSource.Manual,
  361. };
  362. }
  363. private sealed class ItemCfgRow
  364. {
  365. public long Id { get; set; }
  366. public string? ResultType { get; set; }
  367. public string? UpperLimit { get; set; }
  368. public string? LowerLimit { get; set; }
  369. public decimal? QualifiedQty { get; set; }
  370. public decimal? UnqualifiedQty { get; set; }
  371. public string? ItemJudgement { get; set; }
  372. public string? JudgementSource { get; set; }
  373. }
  374. private sealed class ItemPlan
  375. {
  376. public long ItemId { get; set; }
  377. public string? EffectiveResultType { get; set; }
  378. public bool TypeChanged { get; set; }
  379. public List<SamplePlan>? Samples { get; set; }
  380. public decimal? FinalQualifiedQty { get; set; }
  381. public decimal? FinalUnqualifiedQty { get; set; }
  382. public string? ResultRemark { get; set; }
  383. public string? ManualJudgement { get; set; }
  384. public string? DbItemJudgement { get; set; }
  385. public string? DbJudgementSource { get; set; }
  386. public bool KeepExistingVerdict { get; set; }
  387. }
  388. private sealed class SamplePlan
  389. {
  390. public int SampleIndex { get; set; }
  391. public string Raw { get; set; } = "";
  392. public decimal? Numeric { get; set; }
  393. public string Judgement { get; set; } = "";
  394. public string? Remark { get; set; }
  395. }
  396. }