using System.Data; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using MMDAL.CustomCode.Allocation; using MMDAL.DTO.Allocation; using MMDAL.DTO.MMHead; using Newtonsoft.Json; using static GB5Shared.GB5Constant.Constant; namespace MMBLL.Allocation; public class AllocationPostingBLL : IAllocationPostingBLL { // Entity IDs from MOBJECTTYPE private const int MmHeadEntityId = -1899997952; // TMMHEAD private const int MmDetailEntityId = -1899997854; // TMMDETAIL private readonly IAllocationPostingDAL _dal; private readonly AutoNumber _autoNumber; private readonly ILogger _logger; public AllocationPostingBLL( IAllocationPostingDAL dal, AutoNumber autoNumber, ILogger logger) { _dal = dal; _autoNumber = autoNumber; _logger = logger; } // Empty TVP_SOURCELINK table reused across calls — schema-only, no rows. private static readonly DataTable _emptySourceLinks = BuildEmptySourceLinksTable(); private static DataTable BuildEmptySourceLinksTable() { var dt = new DataTable(); dt.Columns.Add("CONSOLIDATEDALLOCATIONID", typeof(int)); dt.Columns.Add("SOURCEALLOCATIONID", typeof(int)); dt.Columns.Add("SOURCEQUANTITY", typeof(decimal)); dt.Columns.Add("CREATEDBYID", typeof(int)); return dt; } public async Task PostDocumentAllocationsAsync( MMHeadDTO head, IList details,short AllocationNature, LoginDTO login, CancellationToken ct) { GB5Trace.Step("build-allocation-rows", new { head.MMHeadId, AllocationNature, detailCount = details.Count }); if (details.Count == 0) { GB5Trace.Step("allocation-skipped", new { head.MMHeadId, AllocationNature, reason = "no details to post" }); return; } var sourceNatures = await ResolveSourceAllocationNaturesAsync(details, head, login, ct) .ConfigureAwait(false); // Pre-allocate exactly the IDs this call will use: one Type=0 row per detail, plus one // Type=1 row for each detail whose referenced allocation's nature matches this call. // Under-reserving here would let nextId spill past this block into another call's reservation. var rowCount = details.Count; foreach (var detail in details) if (ConsumesAgainstRow(detail, AllocationNature, sourceNatures)) rowCount++; var autoNumberResult = await _autoNumber .GetNumberAsync(rowCount, "ALLOCATION", login) .ConfigureAwait(false); int nextId = (int)autoNumberResult.StartNumber; var rows = new List(capacity: rowCount); var docDate = DateOnly.FromDateTime(head.MMHeadDate); // Mirrors the SP's legacy @IsEnriched=0 CASE on MBIZTRANSACTIONCLASS.RESERVATIONTYPE — // head.ReservationType is that same value denormalized onto the head. short demandSupplyType = (short)(head.ReservationType == 1 ? 1 : 0); // One Type=0 (source) row per detail, plus a Type=1 (against) row for details whose // referenced allocation's nature matches this call. foreach (var detail in details) { // Same nature-match rule as the Type=1 row below: a pre-alloted link is only real // when this call's nature matches the referenced source's actual nature. Otherwise // this detail has no against-row under this nature, so the Type=0 row must not claim // a pre-alloted link either (old design's equivalent: ISNULL(..., -1)). var consumesAgainstRow = ConsumesAgainstRow(detail, AllocationNature, sourceNatures); { var sourceAllocationId = nextId++; rows.Add(new AllocationTVPDTO { AllocationId = sourceAllocationId, AllotedAllocationId = sourceAllocationId, // points back to the Type=0 head row AllocationType = 0, AllocationNature = AllocationNature, ObjectHeaderTypeId = MmHeadEntityId, ObjectHeaderId = head.MMHeadId, ObjectTypeId = MmDetailEntityId, ObjectId = detail.MMDetailId, BizTransactionTypeId = head.BizTransactionTypeId, OuId = head.OUId, Quantity = (decimal)detail.MMDetailTransactionQuantity, RejectedQuantity = (decimal)detail.MMDetailRejectedQuantity, ReworkQuantity = (decimal)detail.MMDetailReworkQuantity, OtherQuantity = (decimal)detail.MMDetailOtherQuantity, PreAllotedAllocationId = consumesAgainstRow ? detail.LoadFromAllocationId : null, DocumentDate = docDate, DocumentNumber = head.MMHeadNumber, ReferenceNumber = head.MMHeadReferenceNumber, ReferenceDate = DateOnly.FromDateTime(head.MMHeadReferenceDate), PartyReferenceNumber = head.MMHeadPartyReferenceNumber ?? string.Empty, PartyReferenceDate = DateOnly.FromDateTime(head.MMHeadPartyReferenceDate), PendingDate = docDate, ItemId = detail.ItemId, SkuId = detail.SKUId, PartyBranchId = head.PartyBranchId, DocStatus = 0, GeneratedAllocationId = detail.GeneratedAllocationId, CreatedById = login.UserId, TenantId = login.ClientId, WorkCenterId = -1, OutputType = 1, DemandSupplyType = demandSupplyType, HeaderAllocationId = -1, DetailAllocationId = -1, InterOuId = -1 }); } if (consumesAgainstRow) { var detailAllocationId = nextId++; rows.Add(new AllocationTVPDTO { AllocationId = detailAllocationId, AllotedAllocationId = detail.LoadFromAllocationId, // points back to the Type=0 head row AllocationType = 1, AllocationNature = AllocationNature, ObjectHeaderTypeId = MmHeadEntityId, ObjectHeaderId = head.MMHeadId, ObjectTypeId = MmDetailEntityId, ObjectId = detail.MMDetailId, BizTransactionTypeId = head.BizTransactionTypeId, OuId = head.OUId, Quantity = (decimal)detail.MMDetailTransactionQuantity * -1, RejectedQuantity = (decimal)detail.MMDetailRejectedQuantity * -1, ReworkQuantity = (decimal)detail.MMDetailReworkQuantity * -1, OtherQuantity = (decimal)detail.MMDetailOtherQuantity * -1, PreAllotedAllocationId = detail.LoadFromAllocationId, DocumentDate = docDate, DocumentNumber = head.MMHeadNumber, ReferenceNumber = head.MMHeadReferenceNumber, ReferenceDate = DateOnly.FromDateTime(head.MMHeadReferenceDate), PartyReferenceNumber = head.MMHeadPartyReferenceNumber ?? string.Empty, PartyReferenceDate = DateOnly.FromDateTime(head.MMHeadPartyReferenceDate), PendingDate = docDate, ItemId = detail.ItemId, SkuId = detail.SKUId, PartyBranchId = head.PartyBranchId, DocStatus = 0, GeneratedAllocationId = detail.GeneratedAllocationId, CreatedById = login.UserId, TenantId = login.ClientId, WorkCenterId = -1, OutputType = 1, DemandSupplyType = demandSupplyType, HeaderAllocationId = -1, DetailAllocationId = -1, InterOuId = -1 }); } } // Nature 0 = pending-for-stock, 1 = pending-for-process (WIP). SP_SaveAllocations inserts // TALLOCATION and, in the same transaction, calls SP_SyncPendingAllocation to insert/update // TPENDINGALLOCATION — one Zipkin step covers both, since they always run together. GB5Trace.Step("save-allocations", new { head.MMHeadId, AllocationNature, rowCount = rows.Count, sp = "SP_SaveAllocations (+ SP_SyncPendingAllocation)" }); try { using var allocationsTvp = ToAllocationTable(rows); await _dal.SaveAllocationsAsync(allocationsTvp, _emptySourceLinks, login, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("save-allocations-failed", ex); _logger.LogError(ex, "PostDocumentAllocationsAsync failed for MMHeadId {MMHeadId}", head.MMHeadId); throw; } } // LoadFromAllocationId can reference an allocation created under either nature (0 or 1). // The Type=1 "against" row that consumes it must only be posted under the call whose // AllocationNature matches the referenced row's own nature — otherwise the same source // allocation gets closed twice, once per nature call. Resolves the actual nature per // referenced ID instead of assuming one. private async Task> ResolveSourceAllocationNaturesAsync( IList details, MMHeadDTO head, LoginDTO login, CancellationToken ct) { var loadFromIds = details .Select(d => d.LoadFromAllocationId) .Where(id => id != -1) .Distinct() .ToArray(); var sourceNatures = loadFromIds.Length > 0 ? await _dal.GetAllocationNaturesAsync(loadFromIds, login, ct).ConfigureAwait(false) : new Dictionary(); foreach (var id in loadFromIds) if (!sourceNatures.ContainsKey(id)) _logger.LogWarning( "LoadFromAllocationId {LoadFromAllocationId} not found in TALLOCATION while posting MMHeadId {MMHeadId} — against-row for it will be skipped.", id, head.MMHeadId); return sourceNatures; } private static bool ConsumesAgainstRow( MMDetailDTO detail, short allocationNature, Dictionary sourceNatures) => sourceNatures.TryGetValue(detail.LoadFromAllocationId, out var sourceNature) && sourceNature == allocationNature; public async Task CancelDocumentAllocationsAsync( int documentId, int documentTypeId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("load-allocations-for-cancel", new { documentId, documentTypeId }); var rows = await _dal.GetAllocationsForDocumentAsync(documentId, documentTypeId, login, ct) .ConfigureAwait(false); if (rows.Count == 0) return; // Stamp the cancelling user's ID via CREATEDBYID — SP derives @CancelledById from this. foreach (var row in rows) row.CreatedById = login.UserId; GB5Trace.Step("cancel-allocations", new { documentId, rowCount = rows.Count }); try { using var cancelTvp = ToAllocationTable(rows); await _dal.CancelAllocationsAsync(cancelTvp, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("cancel-allocations-failed", ex); _logger.LogError(ex, "CancelDocumentAllocationsAsync failed for DocumentId {DocumentId}", documentId); throw; } } private static DataTable ToAllocationTable(List rows) { var dt = new DataTable(); dt.Columns.Add("ALLOCATIONID", typeof(int)); dt.Columns.Add("ALLOTEDALLOCATIONID", typeof(int)); dt.Columns.Add("ALLOCATIONTYPE", typeof(short)); dt.Columns.Add("ALLOCATIONNATURE", typeof(short)); dt.Columns.Add("OBJECTHEADERTYPEID", typeof(int)); dt.Columns.Add("OBJECTHEADERID", typeof(int)); dt.Columns.Add("OBJECTTYPEID", typeof(int)); dt.Columns.Add("OBJECTID", typeof(int)); dt.Columns.Add("BIZTRANSACTIONTYPEID", typeof(int)); dt.Columns.Add("OUID", typeof(int)); dt.Columns.Add("QUANTITY", typeof(decimal)); dt.Columns.Add("REJECTEDQUANTITY", typeof(decimal)); dt.Columns.Add("REWORKQUANTITY", typeof(decimal)); dt.Columns.Add("OTHERQUANTITY", typeof(decimal)); dt.Columns.Add("EXCESSQUANTITY", typeof(decimal)); dt.Columns.Add("PROCESSID", typeof(int)); dt.Columns.Add("ISCONSOLIDATED", typeof(short)); dt.Columns.Add("PREALLOTEDALLOCATIONID", typeof(int)); dt.Columns.Add("DOCUMENTDATE", typeof(DateOnly)); dt.Columns.Add("DOCUMENTNUMBER", typeof(string)); dt.Columns.Add("REFERENCENUMBER", typeof(string)); dt.Columns.Add("REFERENCEDATE", typeof(DateOnly)); dt.Columns.Add("PARTYREFERENCENUMBER", typeof(string)); dt.Columns.Add("PARTYREFERENCEDATE", typeof(DateOnly)); dt.Columns.Add("PENDINGDATE", typeof(DateOnly)); dt.Columns.Add("ITEMID", typeof(int)); dt.Columns.Add("SKUID", typeof(int)); dt.Columns.Add("PARTYBRANCHID", typeof(int)); dt.Columns.Add("WORKCENTERID", typeof(int)); dt.Columns.Add("OUTPUTTYPE", typeof(short)); dt.Columns.Add("DEMANDSUPPLYTYPE", typeof(short)); dt.Columns.Add("DOCSTATUS", typeof(short)); dt.Columns.Add("HEADERALLOCATIONID", typeof(int)); dt.Columns.Add("DETAILALLOCATIONID", typeof(int)); dt.Columns.Add("GENERATEDALLOCATIONID", typeof(int)); dt.Columns.Add("INTEROUID", typeof(int)); dt.Columns.Add("CREATEDBYID", typeof(int)); dt.Columns.Add("TENANTID", typeof(int)); foreach (var r in rows) { dt.Rows.Add( r.AllocationId, r.AllotedAllocationId, r.AllocationType, r.AllocationNature, r.ObjectHeaderTypeId, r.ObjectHeaderId, r.ObjectTypeId, r.ObjectId, r.BizTransactionTypeId, r.OuId, r.Quantity, r.RejectedQuantity, r.ReworkQuantity, r.OtherQuantity, r.ExcessQuantity, r.ProcessId ?? -1, r.IsConsolidated, r.PreAllotedAllocationId ?? -1, (object?)r.DocumentDate ?? DBNull.Value, (object?)r.DocumentNumber ?? DBNull.Value, (object?)r.ReferenceNumber ?? DBNull.Value, (object?)r.ReferenceDate ?? DBNull.Value, (object?)r.PartyReferenceNumber ?? DBNull.Value, (object?)r.PartyReferenceDate ?? DBNull.Value, (object?)r.PendingDate ?? DBNull.Value, r.ItemId ?? -1, r.SkuId ?? -1, r.PartyBranchId ?? -1, r.WorkCenterId ?? -1, (object?)r.OutputType ?? DBNull.Value, (object?)r.DemandSupplyType ?? DBNull.Value, (object?)r.DocStatus ?? DBNull.Value, r.HeaderAllocationId ?? -1, r.DetailAllocationId ?? -1, r.GeneratedAllocationId ?? -1, r.InterOuId ?? -1, r.CreatedById ?? -1, r.TenantId ); } return dt; } }