using AdminBLL.BIZTransactionType; using AdminDAL.DTO.InventorySetting; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using MMDAL.CustomCode.StockLedgerCost; using MMDAL.DTO.MMHead; using MMDAL.DTO.StockValuation; using System.Data; using static GB5Shared.GB5Constant.Constant; namespace MMBLL.StockLedgerCost; public class StockPostingBLL : IStockPostingBLL { // EntityId for TMMDETAIL in MOBJECTTYPE — identifies source table in TSTOCKLEDGER. // Confirmed from StockAccountPostQB.cs: AND biz.OBJECTTYPEID = -1899997854 private const int MmDetailEntityId = -1899997854; private const int MmHeadEntityId = -1899997952; // TMMHEAD // BIZTransactionClassStockPostType (0-Inward,1-Outward,2-No Post,3-In/Out,4-In/Out/Waste). private const byte ClassStockPostTypeNoPost = 2; // BIZTransactionTypeTransferStockPostType (0-Inward,1-Outward,2-Both,3-None). private const byte TransferPostTypeInward = 0; private const byte TransferPostTypeOutward = 1; private const byte TransferPostTypeBoth = 2; private const byte TransferPostTypeNone = 3; // BIZTransactionClassInputPostType/OutputPostType/WastePostType (0-Add,1-Less,else-don't post). private const byte ClassPostTypeAdd = 0; private const byte ClassPostTypeLess = 1; private readonly IStockPostingDAL _dal; private readonly IBIZTransactionTypeBLL _bizTransactionTypeBll; private readonly ILogger _logger; public StockPostingBLL( IStockPostingDAL dal, IBIZTransactionTypeBLL bizTransactionTypeBll, ILogger logger) { _dal = dal; _bizTransactionTypeBll = bizTransactionTypeBll; _logger = logger; } public async Task PostDocumentStockAsync( MMHeadDTO head, IList details, LoginDTO login, CancellationToken ct) { GB5Trace.Step("filter-stock-eligible", new { head.MMHeadId, detailCount = details.Count }); var eligible = details.Where(d => d.ItemIsStockPosting == 0).ToList(); if (eligible.Count == 0) { GB5Trace.Step("stock-posting-skipped", new { head.MMHeadId, reason = "no ItemIsStockPosting-eligible details" }); return; } var bizSettings = await _bizTransactionTypeBll .GetBizTransactionTypeSettings(head.BizTransactionTypeId, login, ct) .ConfigureAwait(false); if (bizSettings is null) { GB5Trace.Step("stock-posting-skipped", new { head.MMHeadId, head.BizTransactionTypeId, reason = "no BIZTransactionType settings found" }); return; } if (bizSettings.BIZTransactionTypeStockPostType == IsApplicable.NO) { GB5Trace.Step("stock-posting-skipped", new { head.MMHeadId, reason = "BIZTransactionTypeStockPostType == NO" }); return; } //if (bizSettings.BIZTransactionClassStockPostType == ClassStockPostTypeNoPost) //{ // // in gb5 we have to implement Variance/Value-Adjustment stock posting for this class // // (GB4: pure-value ledger row keyed off MMDetailVarianceType/VarianceExcessOrLess). // return; //} var isTransfer = head.FromStoreId != -1 && head.ToStoreId != -1; var transferPostType = isTransfer ? bizSettings.BIZTransactionTypeTransferStockPostType : (byte)0; if (isTransfer && transferPostType == TransferPostTypeNone) { GB5Trace.Step("stock-posting-skipped", new { head.MMHeadId, reason = "isTransfer && BIZTransactionTypeTransferStockPostType == None" }); return; } var rowCount = isTransfer && transferPostType == TransferPostTypeBoth ? eligible.Count * 2 : eligible.Count; var movements = new List(capacity: rowCount); byte locationType = head.PartyBranchId != -1 && head.StoreId != -1 ? (byte)2 : head.PartyBranchId != -1 ? (byte)1 : (byte)0; // in gb5 we have to implement the combine-indent/sub-contract merge suppression filter // (GB4: IsMultipleDocumentForSameCombineIndentId / MinGeneratedAllocationId / MaxGeneratedAllocationId) — // every eligible row below is posted unconditionally. foreach (var detail in eligible) { if (isTransfer) { // in gb5 we have to implement the quality-type (Good/Rejected/Rework/Other) routing and the // conversion-factor-skip rule GB4 applied only for specific Stock-Transfer/Material-Movement // classes — those class ids aren't mapped in GB5 yet, so every transfer uses a plain qty split. if (transferPostType == TransferPostTypeBoth) { // Inter-store transfer: emit one OUT row from source store and one IN row to destination. var outRow = BuildRow(head, detail, login, storeId: head.FromStoreId, stockPostType: 1, // OUT locationType: 0, partyBranchId: -1); movements.Add(outRow); var inRow = BuildRow(head, detail, login, storeId: head.ToStoreId, stockPostType: 0, // IN locationType: 0, partyBranchId: -1); movements.Add(inRow); } else if (transferPostType == TransferPostTypeInward) { var row = BuildRow(head, detail, login, storeId: head.ToStoreId, stockPostType: 0, // IN locationType: 0, partyBranchId: -1); movements.Add(row); } else // TransferPostTypeOutward { var row = BuildRow(head, detail, login, storeId: head.FromStoreId, stockPostType: 1, // OUT locationType: 0, partyBranchId: -1); movements.Add(row); } } else { var resolvedPostType = ResolveStockPostType(detail.MMDetailStockPostType, bizSettings); if (resolvedPostType is null) continue; // None, or class configured to not post this movement kind. var row = BuildRow(head, detail, login, storeId: head.StoreId, stockPostType: resolvedPostType.Value, locationType: locationType, partyBranchId: head.PartyBranchId); movements.Add(row); } } if (movements.Count == 0) { GB5Trace.Step("stock-posting-skipped", new { head.MMHeadId, reason = "no movement rows built (all details resolved to non-posting stock-post types)" }); return; } GB5Trace.Step("apply-stock-batch", new { head.MMHeadId, rows = movements.Count, sp = "sp_ApplyStockBatch" }); try { using var tvp = ToDataTable(movements); await _dal.ApplyStockBatchAsync(tvp, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("apply-stock-batch-failed", ex); _logger.LogError(ex, "PostDocumentStockAsync failed for MMHeadId {MMHeadId}", head.MMHeadId); throw; } } // Maps TMMDETAIL.STOCKPOSTTYPE (0=None,1=Inward,2=Outward,3=Waste) to the ledger's // Add/Less code via the class-level Input/Output/WastePostType settings // (each 0=Add,1=Less,else=don't post). Returns null when this detail row should not post. private static byte? ResolveStockPostType(byte mmDetailStockPostType, InventorySettingsDTO bizSettings) { byte classPostType = mmDetailStockPostType switch { 1 => bizSettings.BIZTransactionClassInputPostType, 2 => bizSettings.BIZTransactionClassOutputPostType, 3 => bizSettings.BIZTransactionClassWastePostType, _ => byte.MaxValue, // 0=None }; return classPostType switch { ClassPostTypeAdd => (byte)0, ClassPostTypeLess => (byte)1, _ => null, }; } public async Task ReverseDocumentStockAsync(int documentId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("load-stock-for-reversal", new { documentId }); var movements = await _dal.GetStockMovementsForDocumentAsync(documentId, MmDetailEntityId, login, ct) .ConfigureAwait(false); if (movements.Count == 0) return; // Each reversal is a new TSTOCKLEDGER row (STOCKLEDGERID is a DB IDENTITY column) — // the engine assigns a fresh id on insert, so the row here just carries the flipped data. // Flip StockPostType to reverse each ledger entry: // IN(0)↔OUT(1), WIP IN(2)↔WIP OUT(3) foreach (var row in movements) { row.StockPostType = row.StockPostType switch { 0 => 1, 1 => 0, 2 => 3, 3 => 2, _ => row.StockPostType }; } GB5Trace.Step("apply-stock-reversal", new { documentId, rows = movements.Count }); try { using var tvp = ToDataTable(movements); await _dal.ApplyStockBatchAsync(tvp, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("stock-reversal-failed", ex); _logger.LogError(ex, "ReverseDocumentStockAsync failed for DocumentId {DocumentId}", documentId); throw; } } private StockMovementDTO BuildRow( MMHeadDTO head, MMDetailDTO detail, LoginDTO login, int storeId, byte stockPostType, byte locationType, int partyBranchId) { return new StockMovementDTO { StockLedgerNumber = head.MMHeadNumber, ReferenceNumber = head.MMHeadReferenceNumber, OuId = head.OUId, ObjectTypeId = MmHeadEntityId, ObjectId = head.MMHeadId, BizTransactionTypeId = head.BizTransactionTypeId, LocationType = locationType, AllocationId = detail.AllocationId != -1 ? detail.AllocationId : head.AllocationId != -1 ? head.AllocationId : -1, StoreId = storeId, WorkCenterId = -1, // in gb5 we have to implement WIP posting (GB4: BIZTransactionClass.IsWipPosting/WipPostType) once the Production module carries those fields PartyBranchId = partyBranchId, ItemId = detail.ItemId, SkuId = detail.SKUId, StockPostType = stockPostType, MaterialOwnershipType = 0, // 0=Own; party-material path resolved by BizTxType at higher layer TransactionQuantity = (decimal)detail.MMDetailTransactionQuantity, UomId = detail.TransactionUOMId, ConversionFactor = (decimal)detail.MMDetailQuantityConversion, Quantity = (decimal)detail.MMDetailTransactionQuantity, PackQuantity = (decimal)detail.MMDetailPackQuantity, PackId = detail.PackId, Weight = (decimal)detail.ItemWeight, CurrencyId = head.CurrencyId, RateConversionFactor = (decimal)head.MMHeadCurrencyConversion, Rate = (decimal)detail.MMDetailRate, RatePerUomId = detail.TransactionRateUOMId, Cost = 0, // deferred — filled by UpdateCostForDocumentAsync PostedCost = 0, PostedValue = 0, GoodQuantity = (decimal)detail.MMDetailGoodQuantity, RejectedQuantity = (decimal)detail.MMDetailRejectedQuantity, ReworkQuantity = (decimal)detail.MMDetailReworkQuantity, OtherQuantity = (decimal)detail.MMDetailOtherQuantity, TransactionActualQuantity = (decimal)detail.MMDetailTransactionActualQuantity, ActualQuantity = (decimal)detail.MMDetailActualQuantity, TransactionUomId = detail.TransactionUOMId, ObjectDetailTypeId = MmDetailEntityId, ObjectDetailId = detail.MMDetailId, DeliveryType = detail.MMDetailDeliveryType, MaterialCost = (decimal)detail.MMDetailMaterialCost, // deferred ProcessCost = (decimal)detail.MMDetailProcessCost, ChargesCost = (decimal)detail.MMDetailChargesCost, Revenue = (decimal)detail.MMDetailRevenue, IndentDetailId = detail.CombineIndentId, GcCurrencyConversion = 0, // deferred GC step PostedCostGc = 0, PostedValueGc = 0, TenantId = login.ClientId, }; } private static DataTable ToDataTable(List rows) { var dt = new DataTable(); dt.Columns.Add("STOCKLEDGERNUMBER", typeof(string)); dt.Columns.Add("REFERENCENUMBER", typeof(string)); dt.Columns.Add("OUID", typeof(int)); dt.Columns.Add("OBJECTTYPEID", typeof(int)); dt.Columns.Add("OBJECTID", typeof(int)); dt.Columns.Add("BIZTRANSACTIONTYPEID", typeof(int)); dt.Columns.Add("LOCATIONTYPE", typeof(byte)); dt.Columns.Add("ALLOCATIONID", typeof(int)); dt.Columns.Add("STOREID", typeof(int)); dt.Columns.Add("WORKCENTERID", typeof(int)); dt.Columns.Add("PARTYBRANCHID", typeof(int)); dt.Columns.Add("ITEMID", typeof(int)); dt.Columns.Add("SKUID", typeof(int)); dt.Columns.Add("STOCKPOSTTYPE", typeof(byte)); dt.Columns.Add("MATERIALOWNERSHIPTYPE", typeof(byte)); dt.Columns.Add("TRANSACTIONQUANTITY", typeof(decimal)); dt.Columns.Add("UOMID", typeof(int)); dt.Columns.Add("CONVERSIONFACTOR", typeof(decimal)); dt.Columns.Add("QUANTITY", typeof(decimal)); dt.Columns.Add("PACKQUANTITY", typeof(decimal)); dt.Columns.Add("PACKID", typeof(int)); dt.Columns.Add("WEIGHT", typeof(decimal)); dt.Columns.Add("CURRENCYID", typeof(int)); dt.Columns.Add("RATECONVERSIONFACTOR", typeof(decimal)); dt.Columns.Add("RATE", typeof(decimal)); dt.Columns.Add("RATEPERUOMID", typeof(int)); dt.Columns.Add("COST", typeof(decimal)); dt.Columns.Add("POSTEDCOST", typeof(decimal)); dt.Columns.Add("POSTEDVALUE", typeof(decimal)); dt.Columns.Add("GOODQUANTITY", typeof(decimal)); dt.Columns.Add("REJECTEDQUANTITY", typeof(decimal)); dt.Columns.Add("REWORKQUANTITY", typeof(decimal)); dt.Columns.Add("OTHERQUANTITY", typeof(decimal)); dt.Columns.Add("TRANSACTIONACTUALQUANTITY", typeof(decimal)); dt.Columns.Add("ACTUALQUANTITY", typeof(decimal)); dt.Columns.Add("TRANSACTIONUOMID", typeof(int)); dt.Columns.Add("OBJECTDETAILTYPEID", typeof(int)); dt.Columns.Add("OBJECTDETAILID", typeof(int)); dt.Columns.Add("DELIVERYTYPE", typeof(byte)); dt.Columns.Add("MATERIALCOST", typeof(decimal)); dt.Columns.Add("PROCESSCOST", typeof(decimal)); dt.Columns.Add("CHARGESCOST", typeof(decimal)); dt.Columns.Add("REVENUE", typeof(decimal)); dt.Columns.Add("INDENTDETAILID", typeof(int)); dt.Columns.Add("GCCURRENCYCONVERSION", typeof(decimal)); dt.Columns.Add("POSTEDCOSTGC", typeof(decimal)); dt.Columns.Add("POSTEDVALUEGC", typeof(decimal)); dt.Columns.Add("TENANTID", typeof(int)); foreach (var r in rows) { dt.Rows.Add( r.StockLedgerNumber, (object?)r.ReferenceNumber ?? DBNull.Value, r.OuId, r.ObjectTypeId, r.ObjectId, r.BizTransactionTypeId, r.LocationType, r.AllocationId, r.StoreId, r.WorkCenterId, r.PartyBranchId, r.ItemId, r.SkuId, r.StockPostType, r.MaterialOwnershipType, r.TransactionQuantity, r.UomId, r.ConversionFactor, r.Quantity, r.PackQuantity, r.PackId, r.Weight, r.CurrencyId, r.RateConversionFactor, r.Rate, r.RatePerUomId, r.Cost, r.PostedCost, r.PostedValue, r.GoodQuantity, r.RejectedQuantity, r.ReworkQuantity, r.OtherQuantity, r.TransactionActualQuantity, r.ActualQuantity, r.TransactionUomId, r.ObjectDetailTypeId, r.ObjectDetailId, r.DeliveryType, (object?)r.MaterialCost ?? DBNull.Value, (object?)r.ProcessCost ?? DBNull.Value, (object?)r.ChargesCost ?? DBNull.Value, (object?)r.Revenue ?? DBNull.Value, r.IndentDetailId, r.GcCurrencyConversion, r.PostedCostGc, r.PostedValueGc, r.TenantId ); } return dt; } }