InventoryTransactionService.cs 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory;
  2. /// <summary>
  3. /// C5 库存事务服务(服务化 pr_SFM_InventoryTransactionProcessing2)。
  4. /// 职责边界:仅库存事务合法性(9 守卫) + 量变(calculator) + 流水;不含 C1/C2/C3/C4/C6/C7/Outbox/Domain 解析/Controller。
  5. /// 流程:LoadContext → 9 守卫(失败=ValidationFailure,0 写) → BuildWriteSet → store.WriteAtomic(单事务,异常全回滚=Error)。
  6. /// </summary>
  7. public sealed class InventoryTransactionService
  8. {
  9. private readonly IInventoryUnitOfWorkFactory _uowFactory;
  10. public InventoryTransactionService(IInventoryUnitOfWorkFactory uowFactory)
  11. {
  12. _uowFactory = uowFactory ?? throw new ArgumentNullException(nameof(uowFactory));
  13. }
  14. public async Task<InventoryPostingResult> PostAsync(IReadOnlyList<InventoryTransactionCommand> cmds)
  15. {
  16. if (cmds == null) throw new ArgumentNullException(nameof(cmds));
  17. if (cmds.Count == 0)
  18. return new InventoryPostingResult { Outcome = InventoryPostingOutcome.Success, WrittenRows = 0, LegacyReturnMsg = "保存成功" };
  19. // 独立入口:自建 UoW(锁边界)→ ExecuteWithinAsync(C5 内部规则)→ Commit。
  20. await using var uow = _uowFactory.Create();
  21. try
  22. {
  23. var ctx = await uow.LockAndLoadAsync(cmds);
  24. var result = await ExecuteWithinAsync(ctx, cmds, ws => uow.WriteAsync(ws));
  25. if (result.Outcome == InventoryPostingOutcome.Success)
  26. await uow.CommitAsync();
  27. else
  28. await uow.RollbackAsync(); // 守卫失败 → 结束事务、0 写
  29. return result;
  30. }
  31. catch (Exception ex)
  32. {
  33. try { await uow.RollbackAsync(); } catch { /* 回滚失败不掩盖原异常 */ }
  34. return new InventoryPostingResult
  35. {
  36. Outcome = InventoryPostingOutcome.Error,
  37. WrittenRows = 0,
  38. ErrorCode = "INV_POST_FAILED",
  39. ErrorMessage = ex.Message,
  40. LegacyReturnMsg = "保存失败," + (ex.Message ?? string.Empty),
  41. };
  42. }
  43. }
  44. /// <summary>
  45. /// 组合事务入口:在**调用方已开事务/已锁定加载**的前提下执行 C5 内部规则——
  46. /// **G1-G9 守卫(本服务拥有)+ Calculator + BuildWriteSet + 经 writeInventory 落写**;**不 Commit**。
  47. /// 供 IqcReceiptOrchestrator 在外层事务内调用,避免 Orchestrator 直接构造写集绕过守卫。
  48. /// LIVE 顺序:调用方先 CheckInv→C6,再调本方法(内部才跑 9 守卫),与 LIVE 一致。
  49. /// </summary>
  50. public static async Task<InventoryPostingResult> ExecuteWithinAsync(
  51. InventoryContext lockedCtx, IReadOnlyList<InventoryTransactionCommand> cmds, Func<InventoryWriteSet, Task> writeInventory)
  52. {
  53. if (lockedCtx == null) throw new ArgumentNullException(nameof(lockedCtx));
  54. if (cmds == null) throw new ArgumentNullException(nameof(cmds));
  55. if (writeInventory == null) throw new ArgumentNullException(nameof(writeInventory));
  56. if (cmds.Count == 0)
  57. return new InventoryPostingResult { Outcome = InventoryPostingOutcome.Success, WrittenRows = 0, LegacyReturnMsg = "保存成功" };
  58. var errors = InventoryGuardValidator.Validate(cmds, lockedCtx); // ← G1-G9 归属 C5
  59. if (errors.Count > 0)
  60. return new InventoryPostingResult
  61. {
  62. Outcome = InventoryPostingOutcome.ValidationFailure,
  63. GuardErrors = errors,
  64. WrittenRows = 0,
  65. ErrorCode = "INV_GUARD_" + (int)errors[0].Code,
  66. ErrorMessage = errors[0].Message,
  67. LegacyReturnMsg = errors[0].Message,
  68. };
  69. var writeSet = BuildWriteSet(cmds, lockedCtx);
  70. await writeInventory(writeSet);
  71. return new InventoryPostingResult
  72. {
  73. Outcome = InventoryPostingOutcome.Success,
  74. WrittenRows = writeSet.TotalWrites,
  75. LegacyReturnMsg = "保存成功",
  76. };
  77. }
  78. /// <summary>据命令 + 上下文计算 W1-W6 写集(纯,无副作用;可单测路由/量变)。</summary>
  79. public static InventoryWriteSet BuildWriteSet(IReadOnlyList<InventoryTransactionCommand> cmds, InventoryContext ctx)
  80. {
  81. if (cmds == null) throw new ArgumentNullException(nameof(cmds));
  82. if (ctx == null) throw new ArgumentNullException(nameof(ctx));
  83. var ws = new InventoryWriteSet();
  84. // W1/W2 InvMaster:按 item+loc 聚合
  85. foreach (var g in cmds.GroupBy(c => (c.ItemNum, c.Location)))
  86. {
  87. var delta = SumDeltas(g);
  88. var before = ctx.GetInvMaster(g.Key.ItemNum, g.Key.Location) ?? new InventoryBalance();
  89. var hasInbound = g.Any(c => c.ChangeQty > 0m);
  90. ws.InvMasterWrites.Add(new InvMasterWrite
  91. {
  92. TenantId = g.First().TenantId,
  93. DomainCode = g.First().DomainCode,
  94. ItemNum = g.Key.ItemNum,
  95. Location = g.Key.Location,
  96. Delta = delta,
  97. After = InventoryBalanceCalculator.Apply(before, delta),
  98. InsertIfMissing = !ctx.HasInvMaster(g.Key.ItemNum, g.Key.Location) && hasInbound,
  99. User = g.First().User,
  100. });
  101. }
  102. // W3 ItemMaster.Location → ado_item_location_state:仅当当前默认库位为空、且有非空 Location 的命令(取首个)
  103. foreach (var g in cmds.GroupBy(c => c.ItemNum))
  104. {
  105. if (!string.IsNullOrEmpty(ctx.GetItemDefaultLocation(g.Key))) continue;
  106. var loc = g.Select(c => c.Location).FirstOrDefault(l => !string.IsNullOrEmpty(l));
  107. if (string.IsNullOrEmpty(loc)) continue;
  108. ws.ItemLocationWrites.Add(new ItemLocationWrite
  109. {
  110. TenantId = g.First().TenantId,
  111. DomainCode = g.First().DomainCode,
  112. ItemNum = g.Key,
  113. DefaultLocation = loc,
  114. User = g.First().User,
  115. });
  116. }
  117. // W4/W5 LocationDetail:按 item+loc+lot+refs 聚合(写入维度始终含批次,与 LIVE 一致)
  118. foreach (var g in cmds.GroupBy(c => (c.ItemNum, c.Location, Lot: c.LotSerial ?? "", Refs: c.Refs ?? "")))
  119. {
  120. var delta = SumDeltas(g);
  121. var before = ctx.GetLocationDetail(g.Key.ItemNum, g.Key.Location, g.Key.Lot, g.Key.Refs) ?? new InventoryBalance();
  122. var hasInbound = g.Any(c => c.ChangeQty > 0m);
  123. ws.LocationDetailWrites.Add(new LocationDetailWrite
  124. {
  125. TenantId = g.First().TenantId,
  126. DomainCode = g.First().DomainCode,
  127. ItemNum = g.Key.ItemNum,
  128. Location = g.Key.Location,
  129. LotSerial = g.Key.Lot, // 空批空串(Phase 2 契约)
  130. Refs = g.Key.Refs,
  131. Delta = delta,
  132. After = InventoryBalanceCalculator.Apply(before, delta),
  133. InsertIfMissing = ctx.GetLocationDetail(g.Key.ItemNum, g.Key.Location, g.Key.Lot, g.Key.Refs) == null && hasInbound,
  134. Cost = g.Max(c => c.Price),
  135. Curr = g.Select(c => c.Curr).FirstOrDefault(x => !string.IsNullOrEmpty(x)),
  136. User = g.First().User,
  137. });
  138. }
  139. // W6 流水:每命令一行(按 Seq 稳定顺序),BeginBalance = 变动前 LocationDetail 在手
  140. foreach (var c in cmds.OrderBy(c => c.Seq))
  141. {
  142. var d = InventoryBalanceCalculator.ComputeRowDeltas(c);
  143. var beforeQoh = ctx.GetLocationDetail(c.ItemNum, c.Location, c.LotSerial ?? "", c.Refs ?? "")?.QtyOnHand ?? 0m;
  144. var hist = InventoryBalanceCalculator.BuildTransHist(c, d, beforeQoh);
  145. ws.TransactionRows.Add(new InventoryTransactionRow
  146. {
  147. TenantId = c.TenantId,
  148. DomainCode = c.DomainCode,
  149. PostingId = c.PostingId,
  150. TransactionGroupId = c.TransactionGroupId,
  151. Seq = c.Seq,
  152. ItemNum = c.ItemNum,
  153. Location = c.Location,
  154. LotSerial = c.LotSerial ?? "",
  155. TransType = hist.TransType,
  156. QtyChange = hist.QtyChange,
  157. BeginBalance = hist.BeginBalance,
  158. FreezeQty = hist.FreezeQty,
  159. Assay = hist.Assay,
  160. Amt = hist.Amt,
  161. WorkOrd = c.WorkOrd,
  162. ShipperNum = c.ShipperNum,
  163. Remark = c.Remark,
  164. Fbillno = c.Fbillno,
  165. Receiver = c.Receiver,
  166. RctQcNbr = c.RctQcNbr,
  167. User = c.User,
  168. });
  169. }
  170. return ws;
  171. }
  172. private static InventoryDeltas SumDeltas(IEnumerable<InventoryTransactionCommand> group)
  173. {
  174. var acc = new InventoryDeltas();
  175. foreach (var c in group)
  176. {
  177. var d = InventoryBalanceCalculator.ComputeRowDeltas(c);
  178. acc.Conv += d.Conv;
  179. acc.DeltaQtyOnHand += d.DeltaQtyOnHand;
  180. acc.DeltaAvailStatusQty += d.DeltaAvailStatusQty;
  181. acc.DeltaAssay += d.DeltaAssay;
  182. acc.DeltaFreezeQty += d.DeltaFreezeQty;
  183. acc.DeltaQtyOnOrd += d.DeltaQtyOnOrd;
  184. }
  185. return acc;
  186. }
  187. }