namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory; /// /// C5 库存事务服务(服务化 pr_SFM_InventoryTransactionProcessing2)。 /// 职责边界:仅库存事务合法性(9 守卫) + 量变(calculator) + 流水;不含 C1/C2/C3/C4/C6/C7/Outbox/Domain 解析/Controller。 /// 流程:LoadContext → 9 守卫(失败=ValidationFailure,0 写) → BuildWriteSet → store.WriteAtomic(单事务,异常全回滚=Error)。 /// public sealed class InventoryTransactionService { private readonly IInventoryUnitOfWorkFactory _uowFactory; public InventoryTransactionService(IInventoryUnitOfWorkFactory uowFactory) { _uowFactory = uowFactory ?? throw new ArgumentNullException(nameof(uowFactory)); } public async Task PostAsync(IReadOnlyList cmds) { if (cmds == null) throw new ArgumentNullException(nameof(cmds)); if (cmds.Count == 0) return new InventoryPostingResult { Outcome = InventoryPostingOutcome.Success, WrittenRows = 0, LegacyReturnMsg = "保存成功" }; // 独立入口:自建 UoW(锁边界)→ ExecuteWithinAsync(C5 内部规则)→ Commit。 await using var uow = _uowFactory.Create(); try { var ctx = await uow.LockAndLoadAsync(cmds); var result = await ExecuteWithinAsync(ctx, cmds, ws => uow.WriteAsync(ws)); if (result.Outcome == InventoryPostingOutcome.Success) await uow.CommitAsync(); else await uow.RollbackAsync(); // 守卫失败 → 结束事务、0 写 return result; } catch (Exception ex) { try { await uow.RollbackAsync(); } catch { /* 回滚失败不掩盖原异常 */ } return new InventoryPostingResult { Outcome = InventoryPostingOutcome.Error, WrittenRows = 0, ErrorCode = "INV_POST_FAILED", ErrorMessage = ex.Message, LegacyReturnMsg = "保存失败," + (ex.Message ?? string.Empty), }; } } /// /// 组合事务入口:在**调用方已开事务/已锁定加载**的前提下执行 C5 内部规则—— /// **G1-G9 守卫(本服务拥有)+ Calculator + BuildWriteSet + 经 writeInventory 落写**;**不 Commit**。 /// 供 IqcReceiptOrchestrator 在外层事务内调用,避免 Orchestrator 直接构造写集绕过守卫。 /// LIVE 顺序:调用方先 CheckInv→C6,再调本方法(内部才跑 9 守卫),与 LIVE 一致。 /// public static async Task ExecuteWithinAsync( InventoryContext lockedCtx, IReadOnlyList cmds, Func writeInventory) { if (lockedCtx == null) throw new ArgumentNullException(nameof(lockedCtx)); if (cmds == null) throw new ArgumentNullException(nameof(cmds)); if (writeInventory == null) throw new ArgumentNullException(nameof(writeInventory)); if (cmds.Count == 0) return new InventoryPostingResult { Outcome = InventoryPostingOutcome.Success, WrittenRows = 0, LegacyReturnMsg = "保存成功" }; var errors = InventoryGuardValidator.Validate(cmds, lockedCtx); // ← G1-G9 归属 C5 if (errors.Count > 0) return new InventoryPostingResult { Outcome = InventoryPostingOutcome.ValidationFailure, GuardErrors = errors, WrittenRows = 0, ErrorCode = "INV_GUARD_" + (int)errors[0].Code, ErrorMessage = errors[0].Message, LegacyReturnMsg = errors[0].Message, }; var writeSet = BuildWriteSet(cmds, lockedCtx); await writeInventory(writeSet); return new InventoryPostingResult { Outcome = InventoryPostingOutcome.Success, WrittenRows = writeSet.TotalWrites, LegacyReturnMsg = "保存成功", }; } /// 据命令 + 上下文计算 W1-W6 写集(纯,无副作用;可单测路由/量变)。 public static InventoryWriteSet BuildWriteSet(IReadOnlyList cmds, InventoryContext ctx) { if (cmds == null) throw new ArgumentNullException(nameof(cmds)); if (ctx == null) throw new ArgumentNullException(nameof(ctx)); var ws = new InventoryWriteSet(); // W1/W2 InvMaster:按 item+loc 聚合 foreach (var g in cmds.GroupBy(c => (c.ItemNum, c.Location))) { var delta = SumDeltas(g); var before = ctx.GetInvMaster(g.Key.ItemNum, g.Key.Location) ?? new InventoryBalance(); var hasInbound = g.Any(c => c.ChangeQty > 0m); ws.InvMasterWrites.Add(new InvMasterWrite { TenantId = g.First().TenantId, DomainCode = g.First().DomainCode, ItemNum = g.Key.ItemNum, Location = g.Key.Location, Delta = delta, After = InventoryBalanceCalculator.Apply(before, delta), InsertIfMissing = !ctx.HasInvMaster(g.Key.ItemNum, g.Key.Location) && hasInbound, User = g.First().User, }); } // W3 ItemMaster.Location → ado_item_location_state:仅当当前默认库位为空、且有非空 Location 的命令(取首个) foreach (var g in cmds.GroupBy(c => c.ItemNum)) { if (!string.IsNullOrEmpty(ctx.GetItemDefaultLocation(g.Key))) continue; var loc = g.Select(c => c.Location).FirstOrDefault(l => !string.IsNullOrEmpty(l)); if (string.IsNullOrEmpty(loc)) continue; ws.ItemLocationWrites.Add(new ItemLocationWrite { TenantId = g.First().TenantId, DomainCode = g.First().DomainCode, ItemNum = g.Key, DefaultLocation = loc, User = g.First().User, }); } // W4/W5 LocationDetail:按 item+loc+lot+refs 聚合(写入维度始终含批次,与 LIVE 一致) foreach (var g in cmds.GroupBy(c => (c.ItemNum, c.Location, Lot: c.LotSerial ?? "", Refs: c.Refs ?? ""))) { var delta = SumDeltas(g); var before = ctx.GetLocationDetail(g.Key.ItemNum, g.Key.Location, g.Key.Lot, g.Key.Refs) ?? new InventoryBalance(); var hasInbound = g.Any(c => c.ChangeQty > 0m); ws.LocationDetailWrites.Add(new LocationDetailWrite { TenantId = g.First().TenantId, DomainCode = g.First().DomainCode, ItemNum = g.Key.ItemNum, Location = g.Key.Location, LotSerial = g.Key.Lot, // 空批空串(Phase 2 契约) Refs = g.Key.Refs, Delta = delta, After = InventoryBalanceCalculator.Apply(before, delta), InsertIfMissing = ctx.GetLocationDetail(g.Key.ItemNum, g.Key.Location, g.Key.Lot, g.Key.Refs) == null && hasInbound, Cost = g.Max(c => c.Price), Curr = g.Select(c => c.Curr).FirstOrDefault(x => !string.IsNullOrEmpty(x)), User = g.First().User, }); } // W6 流水:每命令一行(按 Seq 稳定顺序),BeginBalance = 变动前 LocationDetail 在手 foreach (var c in cmds.OrderBy(c => c.Seq)) { var d = InventoryBalanceCalculator.ComputeRowDeltas(c); var beforeQoh = ctx.GetLocationDetail(c.ItemNum, c.Location, c.LotSerial ?? "", c.Refs ?? "")?.QtyOnHand ?? 0m; var hist = InventoryBalanceCalculator.BuildTransHist(c, d, beforeQoh); ws.TransactionRows.Add(new InventoryTransactionRow { TenantId = c.TenantId, DomainCode = c.DomainCode, PostingId = c.PostingId, TransactionGroupId = c.TransactionGroupId, Seq = c.Seq, ItemNum = c.ItemNum, Location = c.Location, LotSerial = c.LotSerial ?? "", TransType = hist.TransType, QtyChange = hist.QtyChange, BeginBalance = hist.BeginBalance, FreezeQty = hist.FreezeQty, Assay = hist.Assay, Amt = hist.Amt, WorkOrd = c.WorkOrd, ShipperNum = c.ShipperNum, Remark = c.Remark, Fbillno = c.Fbillno, Receiver = c.Receiver, RctQcNbr = c.RctQcNbr, User = c.User, }); } return ws; } private static InventoryDeltas SumDeltas(IEnumerable group) { var acc = new InventoryDeltas(); foreach (var c in group) { var d = InventoryBalanceCalculator.ComputeRowDeltas(c); acc.Conv += d.Conv; acc.DeltaQtyOnHand += d.DeltaQtyOnHand; acc.DeltaAvailStatusQty += d.DeltaAvailStatusQty; acc.DeltaAssay += d.DeltaAssay; acc.DeltaFreezeQty += d.DeltaFreezeQty; acc.DeltaQtyOnOrd += d.DeltaQtyOnOrd; } return acc; } }