extern alias MMDALOwn; using AccountsDAL.DTO.Voucher; using AdminBLL.BIZTransactionType; using AdminDAL.DTO.InventorySetting; using GB5Shared.Draft; using GB5Shared.DTO.Draft; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.Enums; using GB5Shared.GB5CommonFunction; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using Dapr.Client; using Microsoft.AspNetCore.SignalR.Protocol; using Microsoft.Extensions.Logging; using MMBLL.Allocation; using MMBLL.StockLedgerCost; using MMDAL.CustomCode.ConfigVersion; using MMDAL.CustomCode.MMHead; using MMDAL.DTO.CostCenterAllocation; using MMDAL.DTO.ItemReason; using MMDAL.DTO.LotDetail; using MMDAL.DTO.LotType; using MMDAL.DTO.MMHead; using MMDAL.DTO.Pack; using MMDAL.DTO.Production; using MMDALOwn::MMDAL.CustomCode.MMDocument; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Data; using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using static GB5Shared.GB5Constant.Constant; namespace MMBLL.MMHead { public class MMHeadBLL : IMMHeadBLL { private readonly IMMHeadDAL MMHeadDAL; private readonly AutoNumber _AutoNumber; private readonly IGB5CommonFunction _GB5CommonFunction; private readonly IStockLedgerCostBLL _stockLedgerCostBll; private readonly IStockPostingBLL _stockPostingBll; private readonly IAllocationPostingBLL _allocationPostingBll; private readonly IConfigVersionDAL _configVersionDAL; private readonly IBIZTransactionTypeBLL _bizTransactionTypeBll; private readonly DocumentNumberService _documentNumberService; private readonly ILogger _logger; private readonly IDraftRowBLL _draftRowBLL; private readonly DaprClient _dapr; public MMHeadBLL( IMMHeadDAL IMMHeadDAL, AutoNumber AutoNumber, IGB5CommonFunction IGB5CommonFunction, IStockLedgerCostBLL stockLedgerCostBll, IStockPostingBLL stockPostingBll, IAllocationPostingBLL allocationPostingBll, IConfigVersionDAL configVersionDAL, IBIZTransactionTypeBLL bizTransactionTypeBll, DocumentNumberService documentNumberService, ILogger logger, IDraftRowBLL draftRowBLL, DaprClient dapr) { MMHeadDAL = IMMHeadDAL; _AutoNumber = AutoNumber; _GB5CommonFunction = IGB5CommonFunction; _stockLedgerCostBll = stockLedgerCostBll; _stockPostingBll = stockPostingBll; _allocationPostingBll = allocationPostingBll; _configVersionDAL = configVersionDAL; _bizTransactionTypeBll = bizTransactionTypeBll; _documentNumberService = documentNumberService; _logger = logger; _draftRowBLL = draftRowBLL; _dapr = dapr; } // Best-effort warehouse delta event, mirroring AccountsBLL/Voucher/VoucherBLL.cs's own // "accounting.voucher.saved" publish (same pubsub component, same fire-and-forget // try/catch that only logs a warning — a missed publish just means this OU/date's fact // refreshes on the next document save or the periodic full catch-up, not data loss). // A subscriber in AccountsSL (WarehouseChangeSubscriber) picks this up via the same // generic IWarehouseChangeQueueBLL.EnqueueVoucherChangeAsync(...) call the voucher event // already uses — that method loops every MWAREHOUSEFACT with POSTINGMODE=1, not just // Finance's, so no new AccountsBLL code is needed beyond one new subscriber method. private async Task PublishStockPostedEventAsync(int ouId, DateTime activityDate, LoginDTO login) { try { await _dapr.PublishEventAsync("pubsub", "mm.stock.posted", new { OuId = ouId, ActivityDate = activityDate, TenantId = login.ClientId, ConnectionName = login.DatabaseName }).ConfigureAwait(false); } catch (Exception daprEx) { _logger.LogWarning(daprEx, "Warehouse delta event skipped for OuId={OuId} — Dapr unavailable.", ouId); } } // Sales Overview Dashboard (Phase 1, parallel DW-posting track) -- mirrors // PublishStockPostedEventAsync exactly. Fired from both the pending-allocation // branch (covers Sales Enquiry/Quotation/Order, which post to TPENDINGALLOCATION // but don't move stock) and the stock-posting branch (covers Sales Invoice/Return) // below, so every Sales BizTransactionClass gets a delta-post trigger regardless // of which branch its BizTransactionType setting routes through. Harmless when // fired for a non-Sales document too -- WarehouseChangeQueueBLL.EnqueueVoucherChangeAsync // already loops every POSTINGMODE=1 fact unconditionally (same as the existing // "mm.stock.posted" event does today), and FSALE's own posting-rule views filter by // BizTransactionClassId at query time, so a non-Sales OU/date just posts zero // contributions for the Sales measures -- not incorrect, only a redundant enqueue. private async Task PublishSalesPostedEventAsync(int ouId, DateTime activityDate, LoginDTO login) { try { await _dapr.PublishEventAsync("pubsub", "mm.sales.posted", new { OuId = ouId, ActivityDate = activityDate, TenantId = login.ClientId, ConnectionName = login.DatabaseName }).ConfigureAwait(false); } catch (Exception daprEx) { _logger.LogWarning(daprEx, "Warehouse delta event skipped for OuId={OuId} — Dapr unavailable.", ouId); } } public async Task SaveMMHead(MMHeadDTO head, LoginDTO login, CancellationToken ct = default) { // SessionGuid present ⇒ attempt row-level incremental save (only dirty Detail/Charges // rows touched, driven by TDRAFT_ROWS for this session) — including a new document's // first save (the FE's grid marks every row New on every rebuild for a not-yet-saved // document, precisely so Load-from-Document/Item/Allocation gets the same benefit as // typing rows in manually). SaveMMHeadIncrementalAsync itself reconciles the draft set // against what the FE currently holds before committing to this path, and transparently // falls back to the full-bulk path below on any mismatch — see its own comments for why // that reconciliation, not this gate, is the actual safety net against dropped rows. if (!string.IsNullOrEmpty(head.SessionGuid)) return await SaveMMHeadIncrementalAsync(head, login, ct).ConfigureAwait(false); var headsList = new List { head }; var resultList = await SaveBatchAsync(headsList, login, ct); return resultList.FirstOrDefault() ?? "No records saved"; // return first element only } private async Task SaveMMHeadIncrementalAsync(MMHeadDTO head, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-mmhead-incremental", new { head.MMHeadId, head.SessionGuid }); bool isNewDocument = head.MMHeadId == 0; var now = DateTime.UtcNow; var activeConfigVersionId = await _configVersionDAL .GetActiveConfigVersionIdAsync(login, ct) .ConfigureAwait(false); head.ConfigVersionId = activeConfigVersionId ?? -1; head.MMHeadModifiedOn = now; head.MMHeadModifiedById = login.UserId; if (isNewDocument) { head.MMHeadCreatedOn = now; head.MMHeadCreatedById = login.UserId; if (_bizTransactionTypeBll != null) { var config = await _bizTransactionTypeBll.GetBizTransactionTypeSettings(head.BizTransactionTypeId, login, ct).ConfigureAwait(false); if (config != null) { string? contractDocNum = head.MMDetailArray?.FirstOrDefault()?.ContractDocumentNumber; head.MMHeadNumber = await _documentNumberService.GenerateDocumentNumberAsync( head.BizTransactionTypeId, DateOnly.FromDateTime(head.MMHeadDate), head.MMHeadNumber, contractDocNum, head.MMHeadRevisionNumber, config.BIZTransactionTypeNOGenerationType, config.BIZTransactionTypeNumberSize, login, ct).ConfigureAwait(false); } } } var draftRows = (await _draftRowBLL.GetDraftRowsForPromotionAsync(head.SessionGuid!, login, ct) .ConfigureAwait(false)).ToList(); var newDetailRows = DraftRowBLL.DeserializeRows(draftRows, DraftObjectType.MM_DETAIL, RowState.New).ToList(); var modifiedDetailRowsRaw = DraftRowBLL.DeserializeRows(draftRows, DraftObjectType.MM_DETAIL, RowState.Modified).ToList(); var deletedDetailIds = DraftRowBLL.GetServerIds(draftRows, DraftObjectType.MM_DETAIL, RowState.Deleted).ToList(); var newChargeRows = DraftRowBLL.DeserializeRows(draftRows, DraftObjectType.MM_CHARGE, RowState.New).ToList(); var modifiedChargeRowsRaw = DraftRowBLL.DeserializeRows(draftRows, DraftObjectType.MM_CHARGE, RowState.Modified).ToList(); var deletedChargeIds = DraftRowBLL.GetServerIds(draftRows, DraftObjectType.MM_CHARGE, RowState.Deleted).ToList(); var newScheduleRows = DraftRowBLL.DeserializeRows(draftRows, DraftObjectType.MM_SCHEDULE, RowState.New).ToList(); var modifiedScheduleRowsRaw = DraftRowBLL.DeserializeRows(draftRows, DraftObjectType.MM_SCHEDULE, RowState.Modified).ToList(); var deletedScheduleIds = DraftRowBLL.GetServerIds(draftRows, DraftObjectType.MM_SCHEDULE, RowState.Deleted).ToList(); var newLotRows = DraftRowBLL.DeserializeRows(draftRows, DraftObjectType.MM_LOT, RowState.New).ToList(); var modifiedLotRowsRaw = DraftRowBLL.DeserializeRows(draftRows, DraftObjectType.MM_LOT, RowState.Modified).ToList(); var deletedLotIds = DraftRowBLL.GetServerIds(draftRows, DraftObjectType.MM_LOT, RowState.Deleted).ToList(); // Modified rows: the real PK travels on the DraftRowDTO wrapper (ServerRowId), never // trusted from the row's own JSON payload — correlate back to the matching wrapper by // position since DeserializeRows/GetServerIds both filter+project the same source list // in the same order. var modifiedDetailWrappers = draftRows.Where(r => r.ObjectTypeId == DraftObjectType.MM_DETAIL && r.RowState == RowState.Modified).ToList(); for (int i = 0; i < modifiedDetailRowsRaw.Count; i++) { modifiedDetailRowsRaw[i].MMDetailId = modifiedDetailWrappers[i].ServerRowId; modifiedDetailRowsRaw[i].DocumentId = head.MMHeadId; } var modifiedChargeWrappers = draftRows.Where(r => r.ObjectTypeId == DraftObjectType.MM_CHARGE && r.RowState == RowState.Modified).ToList(); for (int i = 0; i < modifiedChargeRowsRaw.Count; i++) { var wrapper = modifiedChargeWrappers[i]; modifiedChargeRowsRaw[i].MMChargesId = wrapper.ServerRowId; modifiedChargeRowsRaw[i].DocumentId = head.MMHeadId; // Must be re-derived the same way New charge rows resolve it below — the row's // own JSON blob never reliably carries DocumentDetailId (header-level charge // objects from the FE's charges lightbox have no such field at all, so it would // otherwise deserialize to the int default 0 and overwrite an existing row's // correct -1 header-level sentinel via the UPDATE's SET list). modifiedChargeRowsRaw[i].DocumentDetailId = wrapper.ParentServerId; // -1 = header-level, matches existing MM convention } var modifiedScheduleWrappers = draftRows.Where(r => r.ObjectTypeId == DraftObjectType.MM_SCHEDULE && r.RowState == RowState.Modified).ToList(); for (int i = 0; i < modifiedScheduleRowsRaw.Count; i++) { modifiedScheduleRowsRaw[i].MMScheduleId = modifiedScheduleWrappers[i].ServerRowId; modifiedScheduleRowsRaw[i].DocumentId = head.MMHeadId; } var modifiedLotWrappers = draftRows.Where(r => r.ObjectTypeId == DraftObjectType.MM_LOT && r.RowState == RowState.Modified).ToList(); for (int i = 0; i < modifiedLotRowsRaw.Count; i++) { modifiedLotRowsRaw[i].LotDetailId = modifiedLotWrappers[i].ServerRowId; modifiedLotRowsRaw[i].LotDetailObjectHeaderId = head.MMHeadId; } var newDetailWrappers = draftRows.Where(r => r.ObjectTypeId == DraftObjectType.MM_DETAIL && r.RowState == RowState.New).ToList(); var newChargeWrappers = draftRows.Where(r => r.ObjectTypeId == DraftObjectType.MM_CHARGE && r.RowState == RowState.New).ToList(); var newScheduleWrappers = draftRows.Where(r => r.ObjectTypeId == DraftObjectType.MM_SCHEDULE && r.RowState == RowState.New).ToList(); var newLotWrappers = draftRows.Where(r => r.ObjectTypeId == DraftObjectType.MM_LOT && r.RowState == RowState.New).ToList(); // Reconciliation safety net — never trust the draft set blindly. // // The FE has two valid ways to carry a new document's details into this call: // (1) leave MMDetailArray empty and rely entirely on TDRAFT_ROWS (the normal // incremental-save flow), or (2) mirror its full grid state into MMDetailArray // the way the legacy bulk path always expected (see gbmaterialmanagementgrid's // bulkRemarkDirtyRows(), which marks every row New on every rebuild for a // not-yet-saved document). Only the second form can be reconciled against the // draft set, so the gate below only fires when the caller actually sent a // non-empty MMDetailArray and it disagrees with what TDRAFT_ROWS has — if it // does, that is a FE gap/race and we fall back rather than silently insert an // incomplete document. Schedule is tracked the same way (its own lightbox uses // gb-formgrid's row machinery); Lot is tracked too, EXCEPT a new Lot-detail row // that references a brand-new Lot master (LotId == 0) — creating a new Lot // master involves lot-number generation this path doesn't replicate, so any such // row forces a fallback rather than silently mis-saving it. // // Everything else (Pack, Parameter/Quality, ItemReason, CostCenterAllocation, // MMDetailProcess, Addon) is not yet row-tracked on the FE at all — TDRAFT_ROWS never // has entries for these object types today, for a new OR an existing document. So ANY // of these present — header-level or nested under a detail row — always falls back, or // an existing document's edits/deletes to them would go completely untouched under the // incremental path. // // Charges: HEADER-level (head.MMChargesArray, the DocumentCharges lightbox) IS // row-tracked (MM_CHARGE, ParentServerId -1) and deliberately excluded below. // DETAIL-level (nested detail.MMChargesArray, the per-item charges lightbox) is NOT // independently tracked — an item-charge edit does mark its parent detail row // Modified (so the row's own scalar columns save correctly), and the row's full blob // — including its nested MMChargesArray — does reach TDRAFT_ROWS riding along inside // that row's RowData JSON, but SaveIncrementalDetailAsync only ever writes the flat // columns DetailMapping lists; nothing extracts and persists the nested array from // that blob. So a detail-level charge edit under the incremental path would silently // never reach TMMCHARGES — stays in the fallback-forcing set until either the FE gets // its own item-charges tracking (MM_CHARGE rows keyed to the parent detail row) or the // backend learns to extract detail.MMChargesArray out of each touched detail row. bool clientSentDetailArray = (head.MMDetailArray?.Count ?? 0) > 0; bool isNewDocumentDetailIncomplete = isNewDocument && clientSentDetailArray && ( newDetailRows.Count != head.MMDetailArray!.Count || newScheduleRows.Count != head.MMDetailArray.Sum(d => d.MMScheduleArray?.Count ?? 0) || newLotRows.Count != head.MMDetailArray.Sum(d => d.MMLotDetailArray?.Count ?? 0)); bool hasNewLotNeedingNewLotMaster = newLotRows.Any(l => l.LotId == 0); int expectedUntrackedSubRowCount = (head.MMAddonArray?.Count ?? 0) + (head.MMDetailArray?.Sum(d => (d.MMChargesArray?.Count ?? 0) + (d.MMAddonArray?.Count ?? 0) + (d.MMPackDetailArray?.Count ?? 0) + (d.MMParameterArray?.Count ?? 0) + (d.ItemReasonDTOArray?.Count ?? 0) + (d.MMCostCenterAllocationArray?.Count ?? 0) + (d.MMDetailProcessArray?.Count ?? 0)) ?? 0); if (isNewDocumentDetailIncomplete || hasNewLotNeedingNewLotMaster || expectedUntrackedSubRowCount > 0) { _logger.LogWarning( "SaveMMHeadIncrementalAsync: falling back to full-bulk save for MMHeadId {MMHeadId} " + "(isNewDocument={IsNewDocument}, detailIncomplete={DetailIncomplete}, " + "hasNewLotNeedingNewLotMaster={HasNewLot}, expectedUntrackedSubRowCount={UntrackedCount})", head.MMHeadId, isNewDocument, isNewDocumentDetailIncomplete, hasNewLotNeedingNewLotMaster, expectedUntrackedSubRowCount); var fallbackHeadsList = new List { head }; var fallbackResultList = await SaveBatchAsync(fallbackHeadsList, login, ct).ConfigureAwait(false); return fallbackResultList.FirstOrDefault() ?? "No records saved"; } int newIdsNeeded = newDetailRows.Count + newChargeRows.Count; int nextId = 0; if (newIdsNeeded > 0) { var autoNum = await _AutoNumber.GetNumberAsync(newIdsNeeded, AUTONUMBERCONSTANT.MMHEAD, login).ConfigureAwait(false); nextId = autoNum.StartNumber; } var newDetailRowGuidToId = new Dictionary(); for (int i = 0; i < newDetailRows.Count; i++) { newDetailRows[i].MMDetailId = nextId; newDetailRows[i].DocumentId = head.MMHeadId; newDetailRows[i].RowGuid = newDetailWrappers[i].RowGuid; newDetailRowGuidToId[newDetailWrappers[i].RowGuid] = nextId; nextId++; } for (int i = 0; i < newChargeRows.Count; i++) { var wrapper = newChargeWrappers[i]; newChargeRows[i].MMChargesId = nextId; newChargeRows[i].DocumentId = head.MMHeadId; newChargeRows[i].RowGuid = wrapper.RowGuid; int parentDetailId = -1; // -1 = header-level charge, matches existing MM convention if (wrapper.ParentServerId != 0) parentDetailId = wrapper.ParentServerId; else if (wrapper.ParentRowGuid.HasValue && newDetailRowGuidToId.TryGetValue(wrapper.ParentRowGuid.Value, out var resolvedParentId)) parentDetailId = resolvedParentId; newChargeRows[i].DocumentDetailId = parentDetailId; nextId++; } // Schedule/Lot each draw from their own dedicated AutoNumber pool (not MMHEAD's) — // matches SaveBatchAsync's legacy allocation for these two entities exactly. int nextScheduleId = 0; if (newScheduleRows.Count > 0) { var scheduleAutoNum = await _AutoNumber.GetNumberAsync(newScheduleRows.Count, AUTONUMBERCONSTANT.MASTERSCHEDULE, login).ConfigureAwait(false); nextScheduleId = scheduleAutoNum.StartNumber; } for (int i = 0; i < newScheduleRows.Count; i++) { var wrapper = newScheduleWrappers[i]; newScheduleRows[i].MMScheduleId = nextScheduleId; newScheduleRows[i].DocumentId = head.MMHeadId; newScheduleRows[i].RowGuid = wrapper.RowGuid; newScheduleRows[i].DocumentDetailId = wrapper.ParentServerId != 0 ? wrapper.ParentServerId : (wrapper.ParentRowGuid.HasValue && newDetailRowGuidToId.TryGetValue(wrapper.ParentRowGuid.Value, out var scheduleParentId) ? scheduleParentId : 0); nextScheduleId++; } int nextLotDetailId = 0; if (newLotRows.Count > 0) { var lotAutoNum = await _AutoNumber.GetNumberAsync(newLotRows.Count, AUTONUMBERCONSTANT.LOTDETAIL, login).ConfigureAwait(false); nextLotDetailId = lotAutoNum.StartNumber; } for (int i = 0; i < newLotRows.Count; i++) { var wrapper = newLotWrappers[i]; newLotRows[i].LotDetailId = nextLotDetailId; newLotRows[i].RowGuid = wrapper.RowGuid; newLotRows[i].ObjectHeaderTypeId = EntityConstant.OBJECTMMHEAD; newLotRows[i].ObjectTypeId = EntityConstant.OBJECTMMDETAIL; newLotRows[i].LotDetailObjectHeaderId = head.MMHeadId; newLotRows[i].LotDetailObjectId = wrapper.ParentServerId != 0 ? wrapper.ParentServerId : (wrapper.ParentRowGuid.HasValue && newDetailRowGuidToId.TryGetValue(wrapper.ParentRowGuid.Value, out var lotParentId) ? lotParentId : 0); nextLotDetailId++; } GB5Trace.Step("save-mmhead-incremental", new { head.MMHeadId, isNewDocument, newDetails = newDetailRows.Count, modifiedDetails = modifiedDetailRowsRaw.Count, deletedDetails = deletedDetailIds.Count, newCharges = newChargeRows.Count, modifiedCharges = modifiedChargeRowsRaw.Count, deletedCharges = deletedChargeIds.Count, newSchedules = newScheduleRows.Count, modifiedSchedules = modifiedScheduleRowsRaw.Count, deletedSchedules = deletedScheduleIds.Count, newLots = newLotRows.Count, modifiedLots = modifiedLotRowsRaw.Count, deletedLots = deletedLotIds.Count }); var tx = await MMHeadDAL.BeginTransactionAsync(login).ConfigureAwait(false); try { if (isNewDocument) { var autoNum = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.MMHEAD, login).ConfigureAwait(false); head.MMHeadId = autoNum.StartNumber; } RecomputeHeaderTotals(head); await MMHeadDAL.SaveHeaderScalarAsync(login, head, isNewDocument, HeadMapping, tx, ct).ConfigureAwait(false); foreach (var d in newDetailRows) d.DocumentId = head.MMHeadId; foreach (var c in newChargeRows) c.DocumentId = head.MMHeadId; foreach (var s in newScheduleRows) s.DocumentId = head.MMHeadId; foreach (var l in newLotRows) l.LotDetailObjectHeaderId = head.MMHeadId; foreach (var d in modifiedDetailRowsRaw) d.DocumentId = head.MMHeadId; foreach (var c in modifiedChargeRowsRaw) c.DocumentId = head.MMHeadId; foreach (var s in modifiedScheduleRowsRaw) s.DocumentId = head.MMHeadId; foreach (var l in modifiedLotRowsRaw) l.LotDetailObjectHeaderId = head.MMHeadId; await MMHeadDAL.SaveIncrementalDetailAsync(login, head.MMHeadId, newDetailRows, modifiedDetailRowsRaw, deletedDetailIds, DetailMapping, tx, ct).ConfigureAwait(false); await MMHeadDAL.SaveIncrementalChargeAsync(login, head.MMHeadId, newChargeRows, modifiedChargeRowsRaw, deletedChargeIds, ChargeMapping, tx, ct).ConfigureAwait(false); await MMHeadDAL.SaveIncrementalScheduleAsync(login, head.MMHeadId, newScheduleRows, modifiedScheduleRowsRaw, deletedScheduleIds, ScheduleMapping, tx, ct).ConfigureAwait(false); await MMHeadDAL.SaveIncrementalLotAsync(login, head.MMHeadId, newLotRows, modifiedLotRowsRaw, deletedLotIds, LotDetailMapping, tx, ct).ConfigureAwait(false); // MMDetailArray is empty on this path (see reconciliation comment above), so the // RecomputeHeaderTotals(head) call above only wrote correct TOTALOTHERAMOUNTPLUS/ // MINUS/ROUNDOFF — TOTALQUANTITY/TOTALNETVALUE/DOCUMENTVALUE need a DB-sourced // aggregate now that the detail rows for this save are actually committed. await MMHeadDAL.RecomputeAndUpdateHeaderTotalsAsync(login, head.MMHeadId, tx, ct).ConfigureAwait(false); await _draftRowBLL.CleanupAfterSaveAsync(head.SessionGuid!, login, tx, ct).ConfigureAwait(false); await MMHeadDAL.CommitAsync(tx).ConfigureAwait(false); } catch { await MMHeadDAL.RollbackAsync(tx).ConfigureAwait(false); throw; } // Best-effort post-save posting — same hooks/semantics as SaveBatchAsync. Prefer the // FE's own MMDetailArray when it mirrored one (legacy behavior, unchanged), but the // incremental path's normal case is an FE that sent none at all (details live only // in TDRAFT_ROWS) — in that case head.MMDetailArray is empty even though the document // now genuinely has details, so re-read the just-committed detail set from the DB // rather than posting against an empty list (which previously blew up requesting 0 // AutoNumber IDs for ALLOCATION). Errors are logged but never fail the save. try { var detailsForPosting = await ResolveDetailsForPostingAsync(head, login, ct).ConfigureAwait(false); var bizTypeSetting = await _bizTransactionTypeBll.GetBizTransactionTypeSettings(head.BizTransactionTypeId, login, ct).ConfigureAwait(false); await PostSaveDocumentPostingAsync(head, detailsForPosting, bizTypeSetting, login).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("incremental-posting-failed", ex); _logger.LogError(ex, "Post-save posting failed for MMHeadId {MMHeadId}", head.MMHeadId); } // Bare document number, matching SaveBatchAsync's convention (results.Add(savedHead.MMHeadNumber // ?? ...), below) — SaveMMHead.cs wraps this in its own "Detail Saved/Updated Successfully With // DocumentNumber : {result}" message, so returning an already-prefixed string here double-wraps it. return head.MMHeadNumber ?? head.MMHeadId.ToString(); } public async Task> SaveBatchAsync(List heads, LoginDTO login, CancellationToken ct = default) { if (heads == null || heads.Count == 0) return new List(); // Resolve active charge config version once per batch — stamped on every head for post-time traceability var activeConfigVersionId = await _configVersionDAL .GetActiveConfigVersionIdAsync(login, ct) .ConfigureAwait(false); // 1. Get counts for all entities var entityCounts = await CountEntities(heads); // 2. Pre-fetch all ID ranges in bulk var generateIds = new GeneratedIds(); // Fetch IDs for all sequences in parallel var mmTask = entityCounts.totalnumber > 0 ? _AutoNumber.GetNumberAsync(entityCounts.totalnumber, AUTONUMBERCONSTANT.MMHEAD, login) : Task.FromResult(null!); var lotDetailTask = entityCounts.numberoflotdetail > 0 ? _AutoNumber.GetNumberAsync(entityCounts.numberoflotdetail, AUTONUMBERCONSTANT.LOTDETAIL, login) : Task.FromResult(null!); var lotTask = entityCounts.numberofnewLots > 0 ? _AutoNumber.GetNumberAsync(entityCounts.numberofnewLots, AUTONUMBERCONSTANT.LOT, login) : Task.FromResult(null!); var packDetailTask = entityCounts.numberofpackdetail > 0 ? _AutoNumber.GetNumberAsync(entityCounts.numberofpackdetail, AUTONUMBERCONSTANT.PACKDETAIL, login) : Task.FromResult(null!); var packNumberTask = entityCounts.numberofnewPacks > 0 ? _AutoNumber.GetNumberAsync(entityCounts.numberofnewPacks, AUTONUMBERCONSTANT.PACKNUMBER, login) : Task.FromResult(null!); var scheduleTask = entityCounts.numberofschedule > 0 ? _AutoNumber.GetNumberAsync(entityCounts.numberofschedule, AUTONUMBERCONSTANT.MASTERSCHEDULE, login) : Task.FromResult(null!); var parameterTask = entityCounts.numberofparameter > 0 ? _AutoNumber.GetNumberAsync(entityCounts.numberofparameter, AUTONUMBERCONSTANT.PARAMETER, login) : Task.FromResult(null!); var ItemReasonTask = entityCounts.numberofitemreason > 0 ? _AutoNumber.GetNumberAsync(entityCounts.numberofitemreason, AUTONUMBERCONSTANT.ITEMREASON, login) : Task.FromResult(null!); // TMMDETAILPROCESS has no autonumber config of its own — it shares the MMHEAD number // pool (see generateIds.mmID) like Charges/Addons do. CountEntities already folds // numberofDetailProcess into totalnumber, so the MMHEAD reservation covers these rows too. var CostCenterAllocationTask = entityCounts.numberofCostCenterAllocation > 0 ? _AutoNumber.GetNumberAsync(entityCounts.numberofCostCenterAllocation, AUTONUMBERCONSTANT.COSTCENTERALLOCATION, login) : Task.FromResult(null!); // Wait for all async operations to complete await Task.WhenAll(mmTask, lotDetailTask, lotTask, packDetailTask, packNumberTask, scheduleTask, parameterTask, ItemReasonTask, CostCenterAllocationTask); // Assign the results generateIds.mmID = mmTask.Result?.StartNumber ?? 0; generateIds.packdetailID = packDetailTask.Result?.StartNumber ?? 0; generateIds.lotdetailID = lotDetailTask.Result?.StartNumber ?? 0; generateIds.packnumberID = packNumberTask.Result?.StartNumber ?? 0; generateIds.lotID = lotTask.Result?.StartNumber ?? 0; generateIds.scheduleID = scheduleTask.Result?.StartNumber ?? 0; generateIds.parameterID = parameterTask.Result?.StartNumber ?? 0; generateIds.ItemReasonID = ItemReasonTask.Result?.StartNumber ?? 0; generateIds.CostCenterAllocationId = CostCenterAllocationTask.Result?.StartNumber ?? 0; // 3. Assign IDs in one pass // Capture new documents before AssignIds stamps a real MMHeadId — needed for number generation below. var headsNeedingNumbers = heads.Where(h => h.MMHeadId == 0).ToList(); var lotDetailsNeedingLotsInsert = new List(entityCounts.numberofnewLots); var lotDetailsNeedingLotsUpdate = new List(); var packDetailsNeedingNumbers = new List(entityCounts.numberofnewPacks); var packNumberDTOsInsert = new List(); var packNumberDTOsUpdate = new List(); AssignIds(heads, generateIds, ref lotDetailsNeedingLotsInsert, ref lotDetailsNeedingLotsUpdate, ref packDetailsNeedingNumbers, ref packNumberDTOsInsert, ref packNumberDTOsUpdate); // MMHeadId == 0 is the SOLE signal for "new document" (never MMHeadId > 0 — // an existing document's id is not guaranteed positive in GB5). headsNeedingNumbers // was captured before AssignIds stamped real ids, so it still identifies (by // reference) which head objects were originally new. var newHeadsSet = new HashSet(headsNeedingNumbers); // 3b. Fetch BizTransactionType config for all unique type IDs — used during post-save posting var bizTypeMap = new Dictionary(); foreach (var typeId in heads.Select(h => h.BizTransactionTypeId).Distinct()) { var settings = await _bizTransactionTypeBll.GetBizTransactionTypeSettings(typeId, login, ct).ConfigureAwait(false); if (settings != null) bizTypeMap[typeId] = settings; } // 3c. Generate / validate document numbers for new heads. // Only new documents (originally MMHeadId == 0) get a number here; updates keep // their existing MMHeadNumber unchanged. foreach (var head in headsNeedingNumbers) { if (!bizTypeMap.TryGetValue(head.BizTransactionTypeId, out var config)) continue; string? contractDocNum = head.MMDetailArray?.FirstOrDefault()?.ContractDocumentNumber; head.MMHeadNumber = await _documentNumberService.GenerateDocumentNumberAsync( head.BizTransactionTypeId, DateOnly.FromDateTime(head.MMHeadDate), head.MMHeadNumber, contractDocNum, head.MMHeadRevisionNumber, config.BIZTransactionTypeNOGenerationType, config.BIZTransactionTypeNumberSize, login, ct).ConfigureAwait(false); } // 4. Batch save const int BatchSize = 100; var results = new List(); var batchTablesInsert = InitBatchTables(); var batchTablesUpdate = InitBatchTables(); var batchHeads = new List(BatchSize); int processed = 0; var now = DateTime.UtcNow; foreach (var head in heads) { head.ConfigVersionId = activeConfigVersionId ?? -1; head.MMHeadCreatedOn = now; head.MMHeadModifiedOn = now; head.MMHeadCreatedById = login.UserId; head.MMHeadModifiedById = login.UserId; bool isNewDocument = newHeadsSet.Contains(head); RecomputeHeaderTotals(head); MapToTables(head, isNewDocument ? batchTablesInsert : batchTablesUpdate); batchHeads.Add(head); processed++; if (processed % BatchSize == 0 || processed == heads.Count) { foreach (var lot in lotDetailsNeedingLotsInsert) batchTablesInsert.Lot = MapLotDetail(lot, batchTablesInsert.Lot); foreach (var lot in lotDetailsNeedingLotsUpdate) batchTablesUpdate.Lot = MapLotDetail(lot, batchTablesUpdate.Lot); foreach (var packNum in packNumberDTOsInsert) batchTablesInsert.PackNumber = MapPackNumber(packNum, batchTablesInsert.PackNumber); foreach (var packNum in packNumberDTOsUpdate) batchTablesUpdate.PackNumber = MapPackNumber(packNum, batchTablesUpdate.PackNumber); if (batchTablesInsert.Header?.Rows.Count > 0) await MMHeadDAL.SaveTVPAsync( login, batchTablesInsert.Header, batchTablesInsert.Detail, batchTablesInsert.Charge, batchTablesInsert.Addon, batchTablesInsert.Pack, batchTablesInsert.Lot, batchTablesInsert.PackNumber, batchTablesInsert.Schedule, batchTablesInsert.Parameter, batchTablesInsert.ItemReason, batchTablesInsert.MMDetailProcess, batchTablesInsert.CostCenterAllocation ).ConfigureAwait(false); if (batchTablesUpdate.Header?.Rows.Count > 0) await MMHeadDAL.UpdateTVPAsync( login, batchTablesUpdate.Header, batchTablesUpdate.Detail, batchTablesUpdate.Charge, batchTablesUpdate.Addon, batchTablesUpdate.Pack, batchTablesUpdate.Lot, batchTablesUpdate.PackNumber, batchTablesUpdate.Schedule, batchTablesUpdate.Parameter, batchTablesUpdate.ItemReason, batchTablesUpdate.MMDetailProcess, batchTablesUpdate.CostCenterAllocation ).ConfigureAwait(false); foreach (var savedHead in batchHeads) results.Add(savedHead.MMHeadNumber ?? savedHead.MMHeadId.ToString()); // Mark GateEntry records as loaded after a successful save (best-effort; does not fail the save). var gateEntryIds = batchHeads .Where(h => h.GateEntryId > 0) .Select(h => h.GateEntryId) .Distinct() .ToArray(); if (gateEntryIds.Length > 0) await MMHeadDAL.MarkGateEntryLoadedAsync(gateEntryIds, login, CancellationToken.None); // A head can reach this path carrying a SessionGuid — most notably the // incremental path's own reconciliation-gate fallback (SaveMMHeadIncrementalAsync // -> SaveBatchAsync when Pack/Parameter/ItemReason/CostCenterAllocation/ // MMDetailProcess/Addon rows are present, or a new Lot needing a new Lot master). // This TVP path has no concept of TDRAFT_ROWS at all, so unlike // SaveMMHeadIncrementalAsync's own commit (which calls CleanupAfterSaveAsync) // those draft rows would otherwise survive forever — confirmed live: a session's // promoted rows still sitting in TDRAFT_ROWS well after the document they // belonged to was fully saved via this path, ready to be wrongly re-promoted by // a later incremental save reusing the same SessionGuid (its ROWGUID already // belongs to the earlier document, so the guarded insert correctly rejects it). // Best-effort — a failed cleanup here must not fail the save that already committed. foreach (var sessionGuid in batchHeads .Select(h => h.SessionGuid) .Where(sg => !string.IsNullOrEmpty(sg)) .Distinct()) { try { await _draftRowBLL.CleanupAfterSaveAsync(sessionGuid!, login, null!, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Draft cleanup failed for SessionGuid {SessionGuid} after batch save", sessionGuid); } } // After document save, post stock ledger and update perpetual average cost. // Best-effort per document: errors are logged but do not fail the overall save. // Same fallback as the incremental path below (ResolveDetailsForPostingAsync) — // an update-only save whose caller didn't mirror the full detail grid into // MMDetailArray must not silently skip allocation/stock posting. foreach (var batchHead in batchHeads) { bizTypeMap.TryGetValue(batchHead.BizTransactionTypeId, out var bizTypeSetting); try { var detailsForPosting = await ResolveDetailsForPostingAsync(batchHead, login, ct).ConfigureAwait(false); await PostSaveDocumentPostingAsync(batchHead, detailsForPosting, bizTypeSetting, login).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("batch-posting-failed", ex); _logger.LogError(ex, "Post-save posting failed for MMHeadId {MMHeadId}", batchHead.MMHeadId); } } batchTablesInsert = InitBatchTables(); batchTablesUpdate = InitBatchTables(); batchHeads.Clear(); } } return results; } private class GeneratedIds { public int scheduleID { get; set; } public int parameterID { get; set; } public int mmID { get; set; } public int packdetailID { get; set; } public int lotdetailID { get; set; } public int packnumberID { get; set; } public int lotID { get; set; } public int ItemReasonID { get; set; } public int CostCenterAllocationId { get; set; } } #region ID ASSIGNMENT & COUNT HELPERS #region CountEntities private async Task<(int totalnumber, int numberofhead, int numberofdetail, int numberoflotdetail, int numberofnewLots, int numberofpackdetail, int numberofnewPacks, int numberofschedule, int numberofparameter, int numberofitemreason, int numberofCostCenterAllocation)> CountEntities(IEnumerable heads) { int totalnumber = 0, numberofhead = 0, numberofdetail = 0, numberoflotdetail = 0, numberofnewLots = 0, numberofpackdetail = 0, numberofnewPacks = 0, numberofschedule = 0, numberofparameter = 0, numberofitemreason = 0, numberofCostCenterAllocation = 0; foreach (var head in heads ?? Enumerable.Empty()) { if (head == null) continue; // Must mirror AssignIds' isNewDocument gate exactly: MMHeadId == 0 is the sole // signal for "this document needs fresh ids". An update (MMHeadId already set) // never draws a number for the head/detail/charges/addon/schedule/parameter/ // itemreason/costcenterallocation/lotdetail/packdetail rows in AssignIds, so // counting them here would just reserve MAUTONUMBER slots that are never consumed. // LotId/PackDetailPackNumberId are the one exception — AssignIds decides those // independently (a document being updated can still reference a brand-new lot/pack // number), so numberofnewLots/numberofnewPacks stay ungated below. bool isNewDocument = head.MMHeadId == 0; if (isNewDocument) { numberofhead++; totalnumber++; totalnumber += await _GB5CommonFunction.CountArrayAsync(head.MMChargesArray) + await _GB5CommonFunction.CountArrayAsync(head.MMAddonArray); } foreach (var detail in head.MMDetailArray ?? Enumerable.Empty()) { if (detail == null) continue; if (isNewDocument) { numberofdetail++; totalnumber++; numberoflotdetail += await _GB5CommonFunction.CountArrayAsync(detail.MMLotDetailArray); numberofpackdetail += await _GB5CommonFunction.CountArrayAsync(detail.MMPackDetailArray); numberofschedule += await _GB5CommonFunction.CountArrayAsync(detail.MMScheduleArray); numberofparameter += await _GB5CommonFunction.CountArrayAsync(detail.MMParameterArray); numberofitemreason += await _GB5CommonFunction.CountArrayAsync(detail.ItemReasonDTOArray); numberofCostCenterAllocation += await _GB5CommonFunction.CountArrayAsync(detail.MMCostCenterAllocationArray); totalnumber += await _GB5CommonFunction.CountArrayAsync(detail.MMChargesArray) + await _GB5CommonFunction.CountArrayAsync(detail.MMAddonArray) + await _GB5CommonFunction.CountArrayAsync(detail.MMDetailProcessArray); } // Independent of isNewDocument — matches AssignIds' standalone LotId/ // PackDetailPackNumberId checks. numberofnewLots += await _GB5CommonFunction.CountConditionalAsync(detail.MMLotDetailArray, lot => lot?.LotId == 0); numberofnewPacks += await _GB5CommonFunction.CountConditionalAsync(detail.MMPackDetailArray, pack => pack?.PackDetailPackNumberId == 0); } } return (totalnumber, numberofhead, numberofdetail, numberoflotdetail, numberofnewLots, numberofpackdetail, numberofnewPacks, numberofschedule, numberofparameter , numberofitemreason, numberofCostCenterAllocation); } #endregion private void AssignIds(List heads, GeneratedIds generateIds, ref List lotDetailsNeedingLotsInsert, ref List lotDetailsNeedingLotsUpdate, ref List packDetailsNeedingNumbers, ref List packNumberDTOsInsert, ref List packNumberDTOsUpdate) { // Initialize if null (safety check) lotDetailsNeedingLotsInsert ??= new List(); lotDetailsNeedingLotsUpdate ??= new List(); packDetailsNeedingNumbers ??= new List(); packNumberDTOsInsert ??= new List(); packNumberDTOsUpdate ??= new List(); if (heads == null) return; foreach (var head in heads) { if (head == null) continue; // MMHeadId is the SOLE signal for "is this a new document?" — a detail/charge/etc. // row's own id is never trusted for that decision, since load/conversion screens // (e.g. PO -> GRN) legitimately populate child rows with the SOURCE document's // already-committed ids for display purposes. If MMHeadId == 0, every child id is // freshly generated regardless of whatever value it already carries; any other // value (including sentinels like -1) means this head is in update mode and no // autonumber is drawn for it or anything under it. bool isNewDocument = head.MMHeadId == 0; if (isNewDocument) head.MMHeadId = generateIds.mmID++; foreach (var detail in head.MMDetailArray ?? Enumerable.Empty()) { if (detail == null) continue; if (isNewDocument) detail.MMDetailId = generateIds.mmID++; detail.DocumentId = head.MMHeadId; // Charges foreach (var charge in detail.MMChargesArray ?? Enumerable.Empty()) { if (charge == null) continue; if (isNewDocument) charge.MMChargesId = generateIds.mmID++; charge.DocumentId = head.MMHeadId; charge.DocumentDetailId = detail.MMDetailId; } // Addons foreach (var addon in detail.MMAddonArray ?? Enumerable.Empty()) { if (addon == null) continue; if (isNewDocument) addon.MMAddonId = generateIds.mmID++; addon.DocumentId = head.MMHeadId; addon.DocumentDetailId = detail.MMDetailId; } // Lot details foreach (var lotdetail in detail.MMLotDetailArray ?? Enumerable.Empty()) { if (lotdetail == null) continue; if (isNewDocument) lotdetail.LotDetailId = generateIds.lotdetailID++; lotdetail.LotDetailObjectId = detail.MMDetailId; lotdetail.LotDetailObjectHeaderId = head.MMHeadId; // LotId==0 means "reuse/create a lot master row" — an independent decision // from document-newness (a new document can still reference an existing lot). if (lotdetail.LotId == 0) { lotdetail.LotId = generateIds.lotID++; // Add to a list for insert — routed by the owning document's // insert/update status so it flushes with the matching batch. (isNewDocument ? lotDetailsNeedingLotsInsert : lotDetailsNeedingLotsUpdate).Add(lotdetail); } } // Pack details with generated numbers if (detail.MMPackDetailArray?.Count > 0) { foreach (var packdetail in detail.MMPackDetailArray ?? Enumerable.Empty()) { if (packdetail == null) continue; if (isNewDocument) packdetail.PackDetailId = generateIds.packdetailID++; packdetail.DocumentDetailId = detail.MMDetailId; packdetail.PackDetailObjectHeaderId = head.MMHeadId; // PackDetailPackNumberId==0 means "create a new pack number" — an // independent decision from document-newness, same reasoning as LotId above. if (packdetail.PackDetailPackNumberId == 0) { // Create a new pack number var packNumber = new PackNumberDTO { PackNumberId = generateIds.packnumberID++, DocumentDetailId = detail.MMDetailId, PackNumberNumber = packdetail.PackNumberNumber, StoreId = packdetail.StoreId, ItemId = packdetail.ItemId, SKUId = packdetail.SKUId, LotId = packdetail.LotId, PackId = packdetail.PackId, PackNumberQuantity = packdetail.PackDetailQuantity, PackNumberGoodQuantity = packdetail.PackDetailGoodQuantity, PackNumberGrossWeight = packdetail.PackDetailGrossWeight, PackNumberNetWeight = packdetail.PackDetailNetWeight, PackNumberRemarks = packdetail.PackDetailRemarks, PackNumberPackType = packdetail.PackType }; (isNewDocument ? packNumberDTOsInsert : packNumberDTOsUpdate).Add(packNumber); packdetail.PackDetailPackNumberId = packNumber.PackNumberId; } } } // Schedules foreach (var schedule in detail.MMScheduleArray ?? Enumerable.Empty()) { if (schedule == null) continue; if (isNewDocument) schedule.MMScheduleId = generateIds.scheduleID++; schedule.DocumentId = head.MMHeadId; schedule.DocumentDetailId = detail.MMDetailId; } // Parameters foreach (var parameter in detail.MMParameterArray ?? Enumerable.Empty()) { if (parameter == null) continue; if (isNewDocument) parameter.MMParameterId = generateIds.parameterID++; parameter.DocumentId = head.MMHeadId; parameter.DocumentDetailId = detail.MMDetailId; } //Item Reason foreach (var ItemReason in detail.ItemReasonDTOArray ?? Enumerable.Empty()) { if (ItemReason == null) continue; if (isNewDocument) ItemReason.ItemReasonId = generateIds.ItemReasonID++; ItemReason.ItemReasonObjectHeaderId = head.MMHeadId; ItemReason.ItemReasonObjectId = detail.MMDetailId; } //DetailProcess — shares the MMHEAD number pool (generateIds.mmID); see CountEntities. foreach (var process in detail.MMDetailProcessArray ?? Enumerable.Empty()) { if (process == null) continue; if (isNewDocument) process.MMDetailProcessId = generateIds.mmID++; process.DocumentDetailId = detail.MMDetailId; } //CostCenterAllocation foreach (var CostCenterAllocation in detail.MMCostCenterAllocationArray ?? Enumerable.Empty()) { if (CostCenterAllocation == null) continue; if (isNewDocument) CostCenterAllocation.CostCenterAllocationId = generateIds.CostCenterAllocationId++; CostCenterAllocation.CostCenterAllocationObjectHeaderId = head.MMHeadId; CostCenterAllocation.CostCenterAllocationObjectId = detail.MMDetailId; CostCenterAllocation.ObjectHeaderTypeId = -1899997952; CostCenterAllocation.ObjectTypeId = -1899997854; } } // End of mmdetail loop // Head charges foreach (var charge in head.MMChargesArray ?? Enumerable.Empty()) { if (charge == null) continue; if (isNewDocument) charge.MMChargesId = generateIds.mmID++; charge.DocumentId = head.MMHeadId; charge.DocumentDetailId = -1; } // Head addons foreach (var addon in head.MMAddonArray ?? Enumerable.Empty()) { if (addon == null) continue; if (isNewDocument) addon.MMAddonId = generateIds.mmID++; addon.DocumentId = head.MMHeadId; addon.DocumentDetailId = -1; } } } #endregion #region Data Map and Batching // ---------------------------------------------------- // BATCHING & MAPPING // ---------------------------------------------------- private class BatchTables { public DataTable Header { get; set; } = null!; public DataTable Detail { get; set; } = null!; public DataTable Charge { get; set; } = null!; public DataTable Addon { get; set; } = null!; public DataTable Pack { get; set; } = null!; public DataTable Lot { get; set; } = null!; public DataTable PackNumber { get; set; } = null!; public DataTable Schedule { get; set; } = null!; public DataTable Parameter { get; set; } = null!; public DataTable ItemReason { get; set; } = null!; public DataTable MMDetailProcess { get; set; } = null!; public DataTable CostCenterAllocation { get; set; } = null!; public DataTable Allocation { get; set; } = null!; public DataTable StockLedger { get; set; } = null!; } private BatchTables InitBatchTables() => new BatchTables { Header = null!, Detail = null!, Charge = null!, Addon = null!, Pack = null!, Lot = null!, PackNumber = null!, Schedule = null!, Parameter = null!, ItemReason = null!, MMDetailProcess = null!, CostCenterAllocation = null!, Allocation = null!, StockLedger = null!, }; // GB4 parity: TMMHEAD.TOTALQUANTITY/TOTALNETVALUE/DOCUMENTVALUE are pure aggregates of // the document's TMMDETAIL rows (confirmed against the GB4 reference DB — e.g. // DOCUMENTID -1499845208: SUM(detail.TRANSACTIONQUANTITY) = 6, SUM(detail.ITEMNETVALUE) // = 22626.5, both matching the header exactly, with DOCUMENTVALUE = TOTALNETVALUE + // TOTALOTHERAMOUNTPLUS - TOTALOTHERAMOUNTMINUS + ROUNDOFF). GB5 never computed these // server-side — POST_MM_DOCUMENT/POST_MM_DOCUMENT_UPDATE just persist whatever the head // TVP row carries — so a save that didn't already have them precomputed left // Quantity/Bill Value at 0 (visible via GetSelectListForDocument). head.MMDetailArray // always holds the document's full, current detail set on every save — the incremental // path's own reconciliation gate (isNewDocumentDetailIncomplete, above) depends on that // being true — so it's safe to source the aggregate from here regardless of which save // path (full-batch or incremental) is in play. // Shared by both save paths (SaveMMHeadIncrementalAsync and SaveBatchAsync) — the // allocation/stock posting steps that follow a save are identical for either path, // only how detailsForPosting and bizTypeSetting get resolved differs (incremental // falls back to a DB re-read since MMDetailArray can be empty there; batch already // has both in hand). Callers keep their own try/catch and GB5Trace tag so a failure // here is still attributed to the save path it came from. // Resolves the detail set to post allocation/stock against for one saved head. Prefers // the caller's own MMDetailArray when it mirrored one, but re-reads the just-committed // rows from TMMDETAIL whenever it's empty — both AllocationPostingBLL.PostDocumentAllocationsAsync // and StockPostingBLL.PostDocumentStockAsync silently no-op on an empty list rather than // throwing, so an update-only save that didn't resend its full detail grid must not be // allowed to skip posting outright. Shared by both save paths — SaveBatchAsync callers are // never a true multi-document bulk import within this BLL (both call sites pass a single // head), so one extra DB round-trip per unposted document here is cheap. private async Task> ResolveDetailsForPostingAsync(MMHeadDTO head, LoginDTO login, CancellationToken ct) { if ((head.MMDetailArray?.Count ?? 0) > 0) { GB5Trace.Step("resolve-details-for-posting", new { head.MMHeadId, source = "MMDetailArray", count = head.MMDetailArray!.Count }); return head.MMDetailArray!; } var refetched = await MMHeadDAL.GetMMHeadForFinalizeAsync(head.MMHeadId, login, ct).ConfigureAwait(false); var details = refetched?.MMDetailArray ?? new List(); // If headFound=true but count=0, TMMDETAIL genuinely has no rows for this DocumentId — // the earlier detail insert/update in this save didn't commit what it should have. // If headFound=false, GetMMHeadForFinalizeAsync's own TenantId/DocumentId filter didn't // match anything — check head.MMHeadId and login.ClientId against TMMHEAD directly. GB5Trace.Step("resolve-details-for-posting", new { head.MMHeadId, source = "GetMMHeadForFinalizeAsync", headFound = refetched != null, count = details.Count }); return details; } private async Task PostSaveDocumentPostingAsync( MMHeadDTO head, IList detailsForPosting, InventorySettingsDTO? bizTypeSetting, LoginDTO login) { // Single decision-point trace up front — every allocation/stock branch below is gated // purely off these four config flags, so seeing them together is what lets a Zipkin // trace answer "why didn't allocation/stock run" without reading this method's code. GB5Trace.Step("post-save-posting-decision", new { head.MMHeadId, head.BizTransactionTypeId, detailCount = detailsForPosting.Count, bizSettingsFound = bizTypeSetting != null, isPendingPost = bizTypeSetting?.BIZTransactionTypeIsPendingPost, pendingForStock = bizTypeSetting?.BIZTransactionTypePendingForStock, pendingForProcess = bizTypeSetting?.BIZTransactionTypePendingForProcess, stockPostType = bizTypeSetting?.BIZTransactionTypeStockPostType }); if (bizTypeSetting?.BIZTransactionTypeIsPendingPost != IsApplicable.NO) { // AllocationNature 0 — "pending for stock" (TALLOCATION + TPENDINGALLOCATION via // SP_SaveAllocations -> SP_SyncPendingAllocation, same transaction). if (bizTypeSetting?.BIZTransactionTypePendingForStock == IsApplicable.YES) await _allocationPostingBll.PostDocumentAllocationsAsync(head, detailsForPosting, 0, login, CancellationToken.None).ConfigureAwait(false); else GB5Trace.Step("allocation-pending-stock-skipped", new { head.MMHeadId, reason = "BIZTransactionTypePendingForStock != YES" }); // AllocationNature 1 — "pending for process" (WIP allocation, same SP path). if (bizTypeSetting?.BIZTransactionTypePendingForProcess == IsApplicable.YES) await _allocationPostingBll.PostDocumentAllocationsAsync(head, detailsForPosting, 1, login, CancellationToken.None).ConfigureAwait(false); else GB5Trace.Step("allocation-pending-process-skipped", new { head.MMHeadId, reason = "BIZTransactionTypePendingForProcess != YES" }); await PublishSalesPostedEventAsync(head.OUId, head.MMHeadDate, login).ConfigureAwait(false); } else { GB5Trace.Step("allocation-skipped", new { head.MMHeadId, reason = "BIZTransactionTypeIsPendingPost == NO" }); } if (bizTypeSetting?.BIZTransactionTypeStockPostType != IsApplicable.NO) { await _stockPostingBll.PostDocumentStockAsync(head, detailsForPosting, login, CancellationToken.None).ConfigureAwait(false); bool isTransfer = head.FromStoreId != -1 && head.ToStoreId != -1; await _stockLedgerCostBll.UpdateCostForDocumentAsync( objectTypeId: head.BizTransactionTypeId, objectId: head.MMHeadId, isTransfer: isTransfer, fromStoreId: isTransfer ? head.FromStoreId : -1, toStoreId: isTransfer ? head.ToStoreId : head.StoreId, transactionDate: DateOnly.FromDateTime(head.MMHeadDate), login: login, ct: CancellationToken.None).ConfigureAwait(false); await PublishStockPostedEventAsync(head.OUId, head.MMHeadDate, login).ConfigureAwait(false); await PublishSalesPostedEventAsync(head.OUId, head.MMHeadDate, login).ConfigureAwait(false); } else { GB5Trace.Step("stock-posting-skipped", new { head.MMHeadId, reason = "BIZTransactionTypeStockPostType == NO" }); } } private static void RecomputeHeaderTotals(MMHeadDTO head) { var details = head.MMDetailArray ?? new List(); head.MMHeadTotalQuantity = details.Sum(d => d.MMDetailTransactionQuantity); head.MMHeadTotalNetValue = details.Sum(d => d.MMDetailItemNetValue); head.MMHeadValue = head.MMHeadTotalNetValue + head.MMHeadTotalOtherAmountPlus - head.MMHeadTotalOtherAmountMinus + head.MMHeadRoundOff; } private void MapToTables(MMHeadDTO head, BatchTables tables) { tables.Header = MapHeader(head, tables.Header); foreach (var detail in head.MMDetailArray ?? []) { tables.Detail = MapDetailList(detail, tables.Detail); foreach (var charge in detail.MMChargesArray ?? []) tables.Charge = MapCharge(charge, tables.Charge); foreach (var addon in detail.MMAddonArray ?? []) tables.Addon = MapAddon(addon, tables.Addon); foreach (var packDetail in detail.MMPackDetailArray ?? []) tables.Pack = MapPackDetail(packDetail, tables.Pack); foreach (var lot in detail.MMLotDetailArray ?? []) tables.Lot = MapLotDetail(lot, tables.Lot); foreach (var schedule in detail.MMScheduleArray ?? []) tables.Schedule = MapSchedule(schedule, tables.Schedule); foreach (var parameter in detail.MMParameterArray ?? []) tables.Parameter = MapParameter(parameter, tables.Parameter); foreach (var item in detail.ItemReasonDTOArray ?? []) tables.ItemReason = MapItemReason(item, tables.ItemReason); foreach (var process in detail.MMDetailProcessArray ?? []) tables.MMDetailProcess = MapMMDetailProcess(process, tables.MMDetailProcess); foreach (var CostCenterAllocation in detail.MMCostCenterAllocationArray ?? []) tables.CostCenterAllocation = MapCostCenterAllocation(CostCenterAllocation, tables.CostCenterAllocation); } foreach (var charge in head.MMChargesArray ?? []) tables.Charge = MapCharge(charge, tables.Charge); foreach (var addon in head.MMAddonArray ?? []) tables.Addon = MapAddon(addon, tables.Addon); foreach (var alloc in head.AllocationArray ?? []) tables.Allocation = MapAllocation(alloc, tables.Allocation); foreach (var sl in head.StockLedgerArray ?? []) tables.StockLedger = MapStockLedger(sl, tables.StockLedger); } // ---------------------------------------------------- // MAPPING USING GENERIC MAPPER // ---------------------------------------------------- private DataTable MapHeader(MMHeadDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(HeadMapping); DataTableMapper.AddRow(dt, dto, HeadMapping); return dt; } private DataTable MapDetailList(MMDetailDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(DetailTvpMapping); DataTableMapper.AddRow(dt, dto, DetailTvpMapping); return dt; } private DataTable MapCharge(MMChargesDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(ChargeTvpMapping); DataTableMapper.AddRow(dt, dto, ChargeTvpMapping); return dt; } private DataTable MapAddon(MMAddonDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(AddonMapping); DataTableMapper.AddRow(dt, dto, AddonMapping); return dt; } private DataTable MapPackDetail(PackDetailDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(PackDetailMapping); DataTableMapper.AddRow(dt, dto, PackDetailMapping); return dt; } private DataTable MapLotDetail(LotDetailDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(LotDetailMapping); DataTableMapper.AddRow(dt, dto, LotDetailMapping); return dt; } private DataTable MapPackNumber(PackNumberDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(PackNumberMapping); DataTableMapper.AddRow(dt, dto, PackNumberMapping); return dt; } private DataTable MapSchedule(MMScheduleDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(ScheduleMapping); DataTableMapper.AddRow(dt, dto, ScheduleMapping); return dt; } private DataTable MapParameter(MMParameterDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(ParameterMapping); DataTableMapper.AddRow(dt, dto, ParameterMapping); return dt; } private DataTable MapItemReason(ItemReasonDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(ItemReasonMapping); DataTableMapper.AddRow(dt, dto, ItemReasonMapping); return dt; } private DataTable MapMMDetailProcess(MMDetailProcessDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(MMDetailProcessMapping); DataTableMapper.AddRow(dt, dto, MMDetailProcessMapping); return dt; } private DataTable MapCostCenterAllocation(MMDALOwn::MMDAL.DTO.CostCenterAllocation.CostCenterAllocationDTO dto, DataTable dt = null!) { dt ??= DataTableMapper.CreateSchema(CostCenterAllocationMapping); DataTableMapper.AddRow(dt, dto, CostCenterAllocationMapping); return dt; } private DataTable MapAllocation(AllocationRowDTO dto, DataTable dt = null!) { dt ??= CreateAllocationSchema(); var row = dt.NewRow(); row["ALLOCATIONID"] = dto.AllocationId; row["BIZTRANSACTIONTYPEID"] = dto.BizTransactionTypeId; row["OBJECTHEADERTYPEID"] = dto.ObjectHeaderTypeId; row["OBJECTHEADERID"] = dto.ObjectHeaderId; row["OBJECTTYPEID"] = dto.ObjectTypeId; row["OBJECTID"] = dto.ObjectId; row["ALLOCATIONTYPE"] = dto.AllocationType; row["ALLOCATIONNATURE"] = dto.AllocationNature; row["QUANTITY"] = dto.Quantity; row["PROCESSID"] = dto.ProcessId < 0 ? (object)DBNull.Value : dto.ProcessId; row["ALLOTEDALLOCATIONID"] = dto.AllotedAllocationId; row["REJECTEDQUANTITY"] = dto.RejectedQuantity; row["REWORKQUANTITY"] = dto.ReworkQuantity; row["OTHERQUANTITY"] = dto.OtherQuantity; row["EXCESSQUANTITY"] = dto.ExcessQuantity; row["MODIFIEDON"] = dto.ModifiedOn.HasValue ? (object)dto.ModifiedOn.Value : DBNull.Value; row["FREEQUANTITY"] = dto.FreeQuantity; dt.Rows.Add(row); return dt; } private static DataTable CreateAllocationSchema() { var dt = new DataTable(); dt.Columns.Add("ALLOCATIONID", typeof(int)); dt.Columns.Add("BIZTRANSACTIONTYPEID", typeof(int)); 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("ALLOCATIONTYPE", typeof(byte)); dt.Columns.Add("ALLOCATIONNATURE", typeof(byte)); dt.Columns.Add("QUANTITY", typeof(decimal)); dt.Columns.Add("PROCESSID", typeof(int)); dt.Columns.Add("ALLOTEDALLOCATIONID", typeof(int)); 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("MODIFIEDON", typeof(DateTime)); dt.Columns.Add("FREEQUANTITY", typeof(decimal)); return dt; } private DataTable MapStockLedger(StockLedgerRowDTO dto, DataTable dt = null!) { dt ??= CreateStockLedgerSchema(); var row = dt.NewRow(); row["STOCKLEDGERNUMBER"] = (object?)dto.StockLedgerNumber ?? DBNull.Value; row["STOCKLEDGERDATE"] = dto.StockLedgerDate == DateTime.MinValue ? DBNull.Value : (object)dto.StockLedgerDate; row["REFERENCENUMBER"] = (object?)dto.ReferenceNumber ?? DBNull.Value; row["REFERENCEDATE"] = dto.ReferenceDate == DateTime.MinValue ? DBNull.Value : (object)dto.ReferenceDate; row["OUID"] = dto.OuId; row["BIZTRANSACTIONTYPEID"] = dto.BizTransactionTypeId; row["OBJECTTYPEID"] = dto.ObjectTypeId; row["OBJECTID"] = dto.ObjectId; row["LOCATIONTYPE"] = dto.LocationType; row["ALLOCATIONID"] = dto.AllocationId; row["STOREID"] = dto.StoreId; row["WORKCENTERID"] = dto.WorkCenterId; row["PARTYBRANCHID"] = dto.PartyBranchId; row["ITEMID"] = dto.ItemId; row["SKUID"] = dto.SkuId; row["STOCKPOSTTYPE"] = dto.StockPostType; row["MATERIALOWNERSHIPTYPE"] = dto.MaterialOwnershipType; row["TRANSACTIONQUANTITY"] = dto.TransactionQuantity; row["UOMID"] = dto.UomId; row["CONVERSIONFACTOR"] = dto.ConversionFactor; row["QUANTITY"] = dto.Quantity; row["PACKQUANTITY"] = dto.PackQuantity; row["PACKID"] = dto.PackId; row["WEIGHT"] = dto.Weight; row["CURRENCYID"] = dto.CurrencyId; row["RATECONVERSIONFACTOR"] = dto.RateConversionFactor; row["RATE"] = dto.Rate; row["RATEPERUOMID"] = dto.RatePerUomId; row["COST"] = dto.Cost; row["POSTEDCOST"] = dto.PostedCost; row["POSTEDVALUE"] = dto.PostedValue; row["GOODQUANTITY"] = dto.GoodQuantity; row["REJECTEDQUANTITY"] = dto.RejectedQuantity; row["REWORKQUANTITY"] = dto.ReworkQuantity; row["OTHERQUANTITY"] = dto.OtherQuantity; row["TRANSACTIONACTUALQUANTITY"] = dto.TransactionActualQuantity; row["ACTUALQUANTITY"] = dto.ActualQuantity; row["TRANSACTIONUOMID"] = dto.TransactionUomId; row["OBJECTDETAILTYPEID"] = dto.ObjectDetailTypeId; row["OBJECTDETAILID"] = dto.ObjectDetailId; row["DELIVERYTYPE"] = dto.DeliveryType; row["MATERIALCOST"] = (object?)dto.MaterialCost ?? DBNull.Value; row["PROCESSCOST"] = (object?)dto.ProcessCost ?? DBNull.Value; row["CHARGESCOST"] = (object?)dto.ChargesCost ?? DBNull.Value; row["REVENUE"] = (object?)dto.Revenue ?? DBNull.Value; row["INDENTDETAILID"] = dto.IndentDetailId; row["GCCURRENCYCONVERSION"] = dto.GcCurrencyConversion; row["POSTEDCOSTGC"] = dto.PostedCostGc; row["POSTEDVALUEGC"] = dto.PostedValueGc; dt.Rows.Add(row); return dt; } private static DataTable CreateStockLedgerSchema() { var dt = new DataTable(); dt.Columns.Add("STOCKLEDGERNUMBER", typeof(string)); dt.Columns.Add("STOCKLEDGERDATE", typeof(DateTime)); dt.Columns.Add("REFERENCENUMBER", typeof(string)); dt.Columns.Add("REFERENCEDATE", typeof(DateTime)); dt.Columns.Add("OUID", typeof(int)); dt.Columns.Add("BIZTRANSACTIONTYPEID", typeof(int)); dt.Columns.Add("OBJECTTYPEID", typeof(int)); dt.Columns.Add("OBJECTID", 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)); return dt; } // ---------------------------------------------------- // GENERIC DATATABLE MAPPER // ---------------------------------------------------- public static class DataTableMapper { private static readonly ConcurrentDictionary> Accessors = new(); private static Func CreateGetter(PropertyInfo prop) { var param = Expression.Parameter(typeof(T), "x"); var body = Expression.Convert(Expression.Property(param, prop), typeof(object)); return Expression.Lambda>(body, param).Compile(); } public static DataTable CreateSchema(Dictionary map) { DataTable dt = new(); foreach (var kv in map) { var prop = typeof(T).GetProperty(kv.Key)!; var type = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; dt.Columns.Add(kv.Value, type); Accessors.GetOrAdd(kv.Key, _ => CreateGetter(prop)); } return dt; } public static void AddRow(DataTable dt, T item, Dictionary map) { var row = dt.NewRow(); foreach (var kv in map) { var value = Accessors[kv.Key](item); row[kv.Value] = value is null || value is DateTime d && d == DateTime.MinValue ? DBNull.Value : value; } dt.Rows.Add(row); } } // ---------------------------------------------------- // MAPPING DICTIONARIES // ---------------------------------------------------- private static readonly Dictionary HeadMapping = new() { { "MMHeadId" ,"DOCUMENTID" }, { "MMHeadVersion","VERSION" }, {"MMHeadPeriodId" ,"PERIODID"}, {"MMHeadDate" ,"DOCUMENTDATE"}, {"MMHeadNumber" ,"DOCUMENTNUMBER"}, {"MMHeadReferenceNumber" ,"REFERENCENUMBER"}, {"MMHeadReferenceDate" ,"REFERENCEDATE"}, {"MMHeadPartyReferenceNumber" ,"PARTYREFERENCENUMBER"}, {"MMHeadPartyReferenceDate" ,"PARTYREFERENCEDATE"}, {"MMHeadTotalQuantity" ,"TOTALQUANTITY"}, {"MMHeadTotalPacks" ,"TOTALPACKS"}, {"MMHeadTotalItemBasicValue" ,"TOTALITEMBASICVALUE"}, {"MMHeadTotalItemAmountPlus" ,"TOTALITEMAMOUNTPLUS"}, {"MMHeadTotalItemAmountMinus" ,"TOTALITEMAMOUNTMINUS"}, {"MMHeadTotalOtherAmountPlus" ,"TOTALOTHERAMOUNTPLUS"}, {"MMHeadTotalOtherAmountMinus" ,"TOTALOTHERAMOUNTMINUS"}, {"MMHeadTotalNetValue" ,"TOTALNETVALUE"}, {"MMHeadRoundOff" ,"ROUNDOFF"}, {"MMHeadValue" ,"DOCUMENTVALUE"}, {"MMHeadCostValue" ,"DOCUMENTCOSTVALUE"}, {"MMHeadRealisationValue" ,"DOCUMENTREALISATIONVALUE"}, {"MMHeadCurrencyConversion" ,"CURRENCYCONVERSION"}, {"MMHeadTotalItemBasicValueFC" ,"TOTALITEMBASICVALUEFC"}, {"MMHeadTotalItemAmountPlusFC" ,"TOTAIITEMAMOUNTPLUSFC"}, {"MMHeadTotalItemAmountMinusFC" ,"TOTALITEMAMOUNTMINUSFC"}, {"MMHeadTotalOtherAmountPlusFC" ,"TOTALOTHERAMOUNTPLUSFC"}, {"MMHeadTotalOtherAmountMinusFC" ,"TOTALOTHERAMOUNTMINUSFC"}, {"MMHeadTotalNetValueFC" ,"TOTALNETVALUEFC"}, {"MMHeadRoundOffFC" ,"ROUNDOFFFC"}, {"MMHeadValueFC" ,"DOCUMENTVALUEFC"}, {"MMHeadTotalGrossWeight" ,"TOTALGROSSWEIGHT"}, {"MMHeadTotalTareWeight" ,"TOTALTAREWEIGHT"}, {"MMHeadTotalNetWeight" ,"TOTALNETWEIGHT"}, {"MMHeadNoOfOrginalCopy" ,"NOOFORIGINALCOPY"}, {"MMHeadNoOfCopyTaken" ,"NOOFCOPYTAKEN"}, {"MMHeadPaymentTermsRemarks" ,"PAYMENTTERMSREMARKS"}, {"MMHeadModeOfPayment" ,"MODEOFPAYMENT"}, {"MMHeadRemarks" ,"REMARKS"}, {"MMHeadSpecialInstruction" ,"SPECIALINSTRUCTION"}, {"MMHeadIsAccountPost" ,"ISACCOUNTPOST"}, {"MMHeadShipmentType" ,"SHIPMENTTYPE"}, {"MMHeadVehicleEmptyWeight" ,"VEHICLEEMPTYWEIGHT"}, {"MMHeadVehicleLoadWeight" ,"VEHICLELOADWEIGHT"}, {"MMHeadVehicleNetWeight" ,"VEHICLENETWEIGHT"}, {"MMHeadModeOfDelivery" ,"MODEOFDELIVERY"}, {"MMHeadLRNumber" ,"LRNUMBER"}, {"MMHeadLRDate" ,"LRDATE"}, {"MMHeadTripId" ,"TRIPID"}, {"MMHeadCounterSessionId" ,"COUNTERSESSIONID"}, {"MMHeadDeliveryTerms" ,"DELIVERYTERMS"}, {"MMHeadDeliveryInstructions" ,"DELIVERYINSTRUCTIONS"}, {"MMHeadIsCapitalUse" ,"ISCAPITALUSE"}, {"MMHeadNature" ,"DOCUMENTNATURE"}, {"MMHeadDocumentChargesId" ,"DOCUMENTCHARGESID"}, {"MMHeadSeason" ,"SEASON"}, {"MMHeadFormsReceivedStatus" ,"FORMSRECEIVEDSTATUS"}, {"MMHeadFormsReceivedType" ,"FORMSRECEIVEDTYPE"}, {"MMHeadFormsReceivedNumber" ,"FORMSRECEIVEDNUMBER"}, {"MMHeadFormsReceivedDate" ,"FORMSRECEIVEDDATE"}, {"MMHeadFormsReceivedValue" ,"FORMSRECEIVEDVALUE"}, {"MMHeadFormsIssuedStatus" ,"FORMSISSUEDSTATUS"}, {"MMHeadFormsIssuedType" ,"FORMSISSUEDTYPE"}, {"MMHeadFormsIssuedNumber" ,"FORMSISSUEDNUMBER"}, {"MMHeadFormsIssuedDate" ,"FORMSISSUEDDATE"}, {"MMHeadFormsIssuedValue" ,"FORMSISSUEDVALUE"}, {"MMHeadDutyPaidBillNumber" ,"DUTYPAIDBILLNUMBER"}, {"MMHeadDutyPaidBillDate" ,"DUTYPAIDBILLDATE"}, {"MMHeadAmendmentSlNo" ,"AMENDMENTSLNO"}, {"MMHeadTotalItemGrossValue" ,"TOTALITEMGROSSVALUE"}, {"MMHeadTotalItemGrossValueFC" ,"TOTALITEMGROSSVALUEFC"}, {"MMHeadDocumentRealisationValueFC" ,"DOCUMENTREALISATIONVALUEFC"}, {"MMHeadDespatchedOn" ,"DESPATCHEDON"}, {"MMHeadExciseRegisterLastNo" ,"EXCISEREGISTERLASTNO"}, {"MMHeadStockPosting" ,"STOCKPOSTING"}, {"MMHeadFromQualityType" ,"FROMQUALITYTYPE"}, {"MMHeadToQualityType" ,"TOQUALITYTYPE"}, {"MMHeadLRPaymentType" ,"LRPAYMENTTYPE"}, {"MMHeadLRAmount" ,"LRAMOUNT"}, {"MMHeadLRBilledNumber" ,"LRBILLEDNUMBER"}, {"MMHeadEffectiveFrom" ,"EFFECTIVEFROM"}, {"MMHeadEffectiveTo" ,"EFFECTIVETO"}, {"MMHeadRevisionNumber" ,"REVISIONNUMBER"}, {"MMHeadRevisionDate" ,"REVISIONDATE"}, {"MMHeadDocumentStatus" ,"DOCUMENTSTATUS"}, {"MMHeadDocumentType" ,"DOCUMENTTYPE"}, {"MMHeadIsStandardBill" ,"ISSTANDARDBILL"}, {"MMHeadIsSeparateBill" ,"ISSEPARATEBILL"}, {"MMHeadMaterialReceiptStatus" ,"MATERIALRECEIPTSTATUS"}, {"MMHeadPartyMaterialNumber" ,"PARTYMATERIALNUMBER"}, {"MMHeadPartyMaterialDate" ,"PARTYMATERIALDATE"}, {"MMHeadPartyAccountedNumber" ,"PARTYACCOUNTEDNUMBER"}, {"MMHeadPartyAccountedDate" ,"PARTYACCOUNTEDDATE"}, {"MMHeadContainerNo" ,"CONTAINERNO"}, {"MMHeadCODAmount" ,"CODAMOUNT"}, {"DeliveryTermType" ,"DELIVERYTERMTYPE"}, {"FreightType" ,"FREIGHTTYPE"}, {"FreightRemarks" ,"FREIGHTREMARKS"}, {"FinalDestination" ,"FINALDESTINATION"}, {"BlNumber" ,"BLNUMBER"}, {"BlDate" ,"BLDATE"}, {"SbNumber" ,"SBNUMBER"}, {"SbDate" ,"SBDATE"}, {"AREType" ,"ARETYPE"}, {"ARENumber" ,"ARENUMBER"}, {"AREDate" ,"AREDATE"}, {"VesselNo" ,"VESSELNO"}, {"PreCarriage" ,"PRECARRIAGE"}, {"CExciseseAlNo" ,"CEXCISESEALNO"}, {"BottleSealNo" ,"BOTTLESEALNO"}, {"CartonDescription" ,"CARTONDESCRIPTION"}, {"PackDescription" ,"PACKDESCRIPTION"}, {"CartonMarks" ,"CARTONMARKS"}, {"ReceiptPlace" ,"RECEIPTPLACE"}, {"InsuranceType" ,"INSURANCETYPE"}, {"LcId" ,"LCID"}, {"LicenseId" ,"LICENSEID"}, {"IsRevision" ,"ISREVISION"}, {"IsSupplementary" ,"ISSUPPLEMENTARY"}, {"EBNumber" ,"EBNUMBER"}, {"GSTDocumentIds" ,"GSTDOCUMENTIDS"}, {"MMHeadBillValue" ,"BILLVALUE"}, {"MMHeadStatus" ,"STATUS"}, {"MMHeadCreatedById" ,"CREATEDBYID"}, {"MMHeadCreatedOn" ,"CREATEDON"}, {"MMHeadModifiedById" ,"MODIFIEDBYID"}, {"MMHeadModifiedOn" ,"MODIFIEDON"}, {"BizTransactionTypeId" ,"BIZTRANSACTIONTYPEID"}, {"OUId" ,"OUID"}, {"PartyId" ,"PARTYID"}, {"TransporterId" ,"TRANSPORTERID"}, {"DriverId" ,"DRIVERID"}, {"VehicleId" ,"VEHICLEID"}, {"PartyBranchId" ,"PARTYBRANCHID"}, {"PartyContactId" ,"PARTYCONTACTPERSONID"}, {"PartyAgentId" ,"PARTYAGENTID"}, {"SubLedgerLinkId" ,"SUBLEDGERLINKID"}, {"BillToAddressId" ,"BILLTOADDRESSID"}, {"ShipToAddressId" ,"SHIPTOADDRESSID"}, {"StoreId" ,"STOREID"}, {"FromStoreId" ,"FROMSTOREID"}, {"ToStoreId" ,"TOSTOREID"}, {"DepartmentId" ,"DEPARTMENTID"}, {"CurrencyId" ,"CURRENCYID"}, {"InchargeId" ,"INCHARGEID"}, {"CountryId" ,"COUNTRYID"}, {"MerchandiserId" ,"MERCHANDISERID"}, {"ParameterSetId" ,"PARAMETERSETID"}, {"FromPortId" ,"FROMPORTID"}, {"ToPortId" ,"TOPORTID"}, {"AllocationId" ,"ALLOCATIONID"}, {"PaymentTermId" ,"PAYMENTTERMSID"}, {"TaxTransactionTypeId" ,"TAXTRANSACTIONTYPEID"}, {"VoucherId" ,"VOUCHERID"}, {"LotId" ,"LOTID"}, {"DutyPaidPartyBranchId" ,"DUTYPAIDPARTYBRANCHID"}, {"DutyTransactionTypeId" ,"DUTYTRANSACTIONTYPEID"}, {"ToCountryId" ,"TOCOUNTRYID"}, {"TermsSetId" ,"TERMSETID"}, {"ReasonId" ,"REASONID"}, {"GateEntryId" ,"GATEENTRYID"}, {"ExciseRegisterId" ,"EXCISEREGISTERID"}, {"GenerateAllocationId" ,"GENERATEALLOCATIONID"}, {"RevisedFromDocumentId" ,"REVISEDFROMDOCUMENTID"}, {"RevisedTodocumentId" ,"REVISEDTODOCUMENTID"}, {"PrimaryDocumentId" ,"PRIMARYDOCUMENTID"}, {"ShipToPartyId" ,"SHIPTOPARTYID"}, {"ShipToPartyBranchId" ,"SHIPTOPARTYBRANCHID"}, {"ContractTypeId" ,"CONTRACTTYPEID"}, {"DocumentTermSetId" ,"DOCUMENTTERMSETID"}, {"InsuranceId" ,"INSURANCEID"}, {"CountryOfFinalId" ,"COUNTRYOFFINALID"}, {"OurBankId" ,"OURBANKID"}, {"PartyBankId" ,"PARTYBANKID"}, {"OriginalDocumentId" ,"ORIGINALDOCUMENTID"}, {"ReferenceDocumentId" ,"REFERENCEDOCUMENTID"}, {"RouteId" ,"ROUTEID"}, {"ExemptionId" ,"EXEMPTIONID"}, {"LeadId" ,"LEADID"}, {"ReservationType" ,"RESERVATIONTYPE"}, {"GCCurrencyConversion" ,"GCCURRENCYCONVERSION"}, {"TotalItemBasicValueGC" ,"TOTALITEMBASICVALUEGC"}, {"TotalItemAmountPlusGC" ,"TOTALITEMAMOUNTPLUSGC"}, {"TotalItemAmountMinusGC" ,"TOTALITEMAMOUNTMINUSGC"}, {"TotalOtherAmountplusGC" ,"TOTALOTHERAMOUNTPLUSGC"}, {"TotalOtherAmountMinusGC" ,"TOTALOTHERAMOUNTMINUSGC"}, {"TotalNetValueGC" ,"TOTALNETVALUEGC"}, {"RoundOffGC" ,"ROUNDOFFGC"}, {"DocumentValueGC" ,"DOCUMENTVALUEGC"}, {"TotalItemGrossValueGC" ,"TOTALITEMGROSSVALUEGC"}, {"DocumentRealisationValueGC" ,"DOCUMENTREALISATIONVALUEGC"}, {"DocumentCostValueGC" ,"DOCUMENTCOSTVALUEGC"}, {"BillValueGC" ,"BILLVALUEGC"}, {"MMHeadOpenName" ,"OPENNAME"}, {"MMHeadOpenPhone" ,"OPENPHONE"}, {"MMHeadOpenMail" ,"OPENMAIL"}, {"ContactId" ,"CONTACTID"}, {"MMHeadAdvanceAmount" ,"ADVANCEAMOUNT"}, {"AdvanceAccountId" ,"ADVANCEACCOUNTID"}, {"AdvanceDocumentId" ,"ADVANCEDOCUMENTID"}, {"MMHeadOpenGSTNo" ,"OPENGSTNO"}, {"MMHeadBillValueFC" ,"BILLVALUEFC"}, {"ConfigVersionId" ,"CONFIGVERSIONID"}, }; private static readonly Dictionary DetailMapping = new() { {"MMDetailId","DOCUMENTDETAILID"}, {"RowGuid","ROWGUID"}, {"MMDetailSlNo","SLNO"}, {"MMDetailProductDescription","PRODUCTDESCRIPTION"}, {"MMDetailTransactionQuantity","TRANSACTIONQUANTITY"}, {"MMDetailItemExcessType","ITEMEXCESSTYPE"}, {"MMDetailItemExcessPercentage","ITEMEXCESSPERCENTAGE"}, {"MMDetailTransactionActualQuantity","TRANSACTIONACTUALQUANTITY"}, {"MMDetailQuantityConversionType","QUANTITYCONVERSIONTYPE"}, {"MMDetailQuantityConversion","QUANTITYCONVERSION"}, {"MMDetailActualQuantity","ACTUALQUANTITY"}, {"MMDetailStockPostType","STOCKPOSTTYPE"}, {"MMDetailItemPostedQuantity","ITEMPOSTEDQUANTITY"}, {"MMDetailPackQuantity","PACKQUANTITY"}, {"MMDetailTransactionRate","TRANSACTIONRATE"}, {"MMDetailRateConversionType","RATECONVERSIONTYPE"}, {"MMDetailRateConversion","RATECONVERSION"}, {"MMDetailRateFC","RATEFC"}, {"MMDetailTransactionCurrencyConversion","TRANSACTIONCURRENCYCONVERSION"}, {"MMDetailRate","RATE"}, {"MMDetailDiscountPercentage","DISCOUNTPERCENTAGE"}, {"MMDetailDiscountAmount","DISCOUNTAMOUNT"}, {"MMDetailItemBasicCost","ITEMBASICCOST"}, {"MMDetailItemMinus","ITEMMINUS"}, {"MMDetailItemPlus","ITEMPLUS"}, {"MMDetailItemMotherMinus","ITEMOTHERMINUS"}, {"MMDetailItemMotherPlus","ITEMOTHERPLUS"}, {"MMDetailItemNetValue","ITEMNETVALUE"}, {"MMDetailItemRealisationValue","ITEMREALISATIONVALUE"}, {"MMDetailItemNetCost","ITEMNETCOST"}, {"MMDetailItemPostedCost","ITEMPOSTEDCOST"}, {"MMDetailRemarks","REMARKS"}, {"MMDetailKitType","KITTYPE"}, {"MMDetailKitId","KITID"}, {"MMDetailIsGeneratedLine","ISGENERATEDLINE"}, {"MMDetailItemGrossWeight","ITEMGROSSWEIGHT"}, {"MMDetailItemBasicValue","ITEMBASICVALUE"}, {"MMDetailLoadingSlipId","LOADINGSLIPID"}, {"MMDetailGraceDays","GRACEDAYS"}, {"MMDetailRequiredDeliveryDate","REQUIREDDELIVERYDATE"}, {"MMDetailPlanDeliveryDate","PLANDELIVERYDATE"}, {"MMDetailRoutingId","ROUTINGID"}, {"MMDetailDetailChargesId","DETAILCHARGESID"}, {"MMDetailGoodQuantity","GOODQUANTITY"}, {"MMDetailRejectedQuantity","REJECTEDQUANTITY"}, {"MMDetailReworkQuantity","REWORKQUANTITY"}, {"MMDetailOtherQuantity","OTHERQUANTITY"}, {"MMDetailVarianceType","VARIANCETYPE"}, {"MMDetailDutyAmount","DUTYAMOUNT"}, {"MMDetailBalanceQuantity","BALANCEQUANTITY"}, {"MMDetailBalanceDuty","BALANCEDUTY"}, {"MMDetailItemGrossValue","ITEMGROSSVALUE"}, {"MMDetailItemGrossValueFC","ITEMGROSSVALUEFC"}, {"MMDetailItemBasicValueFC","ITEMBASICVALUEFC"}, {"MMDetailItemAmountPlusFC","ITEMAMOUNTPLUSFC"}, {"MMDetailItemAmountMinusFC","ITEMAMOUNTMINUSFC"}, {"MMDetailItemOtherAmountPlusFC","ITEMOTHERAMOUNTPLUSFC"}, {"MMDetailItemOtherAmountMinusFC","ITEMOTHERAMOUNTMINUSFC"}, {"MMDetailItemCostValue","ITEMCOSTVALUE"}, {"MMDetailItemNetWeight","ITEMNETWEIGHT"}, {"MMDetailItemTareWeight","ITEMTAREWEIGHT"}, {"MMDetailItemCostValueFC","ITEMCOSTVALUEFC"}, {"MMDetailItemRealisationValueFC","ITEMREALISATIONVALUEFC"}, {"MMDetailItemNetValueFC","ITEMNETVALUEFC"}, {"MMDetailQualityRecordType","QUALITYRECORDTYPE"}, {"MMDetailNumberOfSamples","NUMBEROFSAMPLES"}, {"MMDetailQualityStatus","QUALITYSTATUS"}, {"MMDetailQualityDebitRate","QUALITYDEBITRATE"}, {"MMDetailQualityCreditRate","QUALITYCREDITRATE"}, {"MMDetailQualityRemarks","QUALITYREMARKS"}, {"MMDetailDrCrStatus","DRCRSTATUS"}, {"MMDetailIsForecastConsume","ISFORECASTCONSUME"}, {"MMDetailForecastConsumedQuantity","FORECASTCONSUMEDQUANTITY"}, {"MMDetailExciseRegisterLastNo","EXCISEREGISTERLASTNO"}, {"MMDetailExciseRegisterPageNo","EXCISEREGISTERPAGENO"}, {"MMDetailApplicableFrom","APPLICABLEFROM"}, {"MMDetailApplicableTo","APPLICABLETO"}, {"MMDetailProcessedTill","PROCESSEDTILL"}, {"MMDetailBillingType","BILLINGTYPE"}, {"MMDetailFrequency","FREQUENCY"}, {"MMDetailPriceType","PRICETYPE"}, {"MMDetailPrePostType","PREPOSTTYPE"}, {"MMDetailPartialPeriod","PARTIALPERIOD"}, {"MMDetailPricePlanId","PRICEPLANID"}, {"MMDetailPriceFixType","PRICEFIXTYPE"}, {"MMDetailDeliveryStatus","DELIVERYSTATUS"}, {"MMDetailDeliveryType","DELIVERYTYPE"}, {"DeliveryQty","DELIVERYQTY"}, {"DeliveryTime","DELIVERYTIME"}, {"MMDetailMRPRate","MRPRATE"}, {"MMDetailBatchNumbers","BATCHNUMBERS"}, {"MMDetailIsFree","ISFREE"}, {"FreeAmount","FREEAMOUNT"}, {"MMDetailBillValueDetail","BILLVALUEDETAIL"}, {"MMDetailMaterialCost","MATERIALCOST"}, {"MMDetailProcessCost","PROCESSCOST"}, {"MMDetailChargesCost","CHARGESCOST"}, {"MMDetailRevenue","REVENUE"}, {"MMDetailRecoveryType","RECOVERYTYPE"}, {"MMDetailRecoveryPercentage","RECOVERYPERCENTAGE"}, {"MMDetailRecoveryValue","RECOVERYVALUE"}, {"DocumentId","DOCUMENTID"}, {"SKUId","SKUID"}, {"ItemId","ITEMID"}, {"TransactionUOMId","TRANSACTIONUOMID"}, {"TransactionRateUOMId","TRANSACTIONRATEUOMID"}, {"TransactionCurrencyId","TRANSACTIONCURRENCYID"}, {"AccountId","ACCOUNTID"}, {"LoadFromAllocationId","LOADFROMALLOCATIONID"}, {"PackId","PACKID"}, {"AllotedDocumentDetailId","ALLOTEDDOCUMENTDETAILID"}, {"DrCrNoteId","DRCRNOTEID"}, {"GateEntryId","GATEENTRYID"}, {"ExciseRegisterId","EXCISEREGISTERID"}, {"AllocationId","ALLOCATIONID"}, {"StoreId","STOREID"}, {"FreeForDocumentDetailId","FREEFORDOCUMENTDETAILID"}, {"SchemeFreeId","SCHEMEFREEID"}, {"DeliveryStoreId","DELIVERYSTOREID"}, {"DeliveryOuId","DELIVERYOUID"}, {"InterOUId","INTEROUID"}, {"LotId","LOTID"}, {"ServiceRuleId","SERVICERULEID"}, {"LeadId","LEADID"}, {"RecoveryPartyBranchId","RECOVERYPARTYBRANCHID"}, {"ReservationType","RESERVATIONTYPE"}, {"GeneratedAllocationId","GENERATEDALLOCATIONID"}, {"ReferenceDocumentDetailId","REFERENCEDOCUMENTDETAILID"}, {"ReferenceEmployeeId","REFERENCEEMPLOYEEID"}, {"ClaimId","CLAIMID"}, {"MMDetailDocumentStatus","DOCUMENTSTATUS"}, {"UsedDocumentId","USEDDOCUMENTID"}, {"InspectionNumberId","INSPECTIONNUMBERID"}, {"ReasonId","REASONID"}, {"GCCurrencyConversion","GCCURRENCYCONVERSION"}, {"RateGC","RATEGC"}, {"ItemBasicValueGC","ITEMBASICVALUEGC"}, {"ItemAmountPlusGC","ITEMAMOUNTPLUSGC"}, {"ItemAmountMinusGC","ITEMAMOUNTMINUSGC"}, {"ItemOtherAmountPlusGC","ITEMOTHERAMOUNTPLUSGC"}, {"ItemOtherAmountMinusGC","ITEMOTHERAMOUNTMINUSGC"}, {"ItemGrossValueGC","ITEMGROSSVALUEGC"}, {"ItemCostValueGC","ITEMCOSTVALUEGC"}, {"ItemRealisationValueGC","ITEMREALISATIONVALUEGC"}, {"ItemNetValueGC","ITEMNETVALUEGC"}, {"BillValueDetailGC","BILLVALUEDETAILGC"}, {"BOMId","BOMID"}, {"MMDetailMfgDate","MFGDATE"}, {"MMDetailExpiryDate","EXPIRYDATE"}, {"MMDetailD1","D1"}, {"MMDetailD2","D2"}, {"MMDetailD3","D3"}, {"MMDetailD4","D4"}, {"MMDetailD5","D5"}, {"SizeSetDetailId","SIZESETDETAILID"}, {"MMDetailFreeQuantity","FREEQUANTITY"}, {"MMDetailSequenceNumber","SEQUENCENUMBER"}, {"ItemAliasId","ITEMALIASID"}, {"MMDetailPlanPendingSOQuantity","PLANPENDINGSOQUANTITY"}, {"MMDetailPlanSOH","PLANSOH"}, {"MMDetailPlanScheduledReceipt","PLANSCHEDULEDRECEIPT"}, {"MMDetailPlanCalculatedQty","PLANCALCULATEDQTY"}, {"MMDetailPlanAddorLess","PLANADDORLESS"}, {"CostCenterId","COSTCENTERID"}, {"BinId","BINID"}, {"MMDetailBillValueDetailFC","BILLVALUEDETAILFC"}, {"ItemIsStockPosting","ISSTOCKPOST"}, }; private static readonly Dictionary ChargeMapping = new() { {"MMChargesId","DOCUMENTCHARGESID" }, {"RowGuid","ROWGUID" }, {"MMChargesSlNo","SLNO"}, {"MMChargesInputValue","INPUTVALUE"}, {"MMChargesInputType","INPUTTYPE"}, {"MMChargesCalculatedOn","CALCULATEDON"}, {"MMChargesCalculatedValue","CALCULATEDVALUE"}, {"MMChargesAmountPlus","AMOUNTPLUS"}, {"MMChargesAmountMinus","AMOUNTMINUS"}, {"MMChargesFCCalculatedValue","FCCALCULATEDVALUE"}, {"MMChargesCurrencyConversion","CURRENCYCONVERSION"}, {"MMChargesReason","REASON"}, {"MMChargesRemarks","REMARKS"}, {"DocumentId","DOCUMENTID"}, {"DocumentDetailId","DOCUMENTDETAILID"}, {"FormulaId","FORMULAID"}, {"ChargeDetailId","CHARGEDETAILID"}, {"CurrencyId","CURRENCYID"}, {"PartyAccountId","PARTYACCOUNTID"}, {"ContraAccountId","CONTRAACCOUNTID"}, {"PersonId","PERSONID"}, }; // TVP_MMDETAIL/TVP_MMCHARGES (unisoftgb4, and every tenant DB that predates the // 2026-08-04 draft-row migration) have no ROWGUID column — it exists only on the // TMMDETAIL/TMMCHARGES base tables, for the incremental/draft-save path's raw SQL // (SaveIncrementalDetailAsync/SaveIncrementalChargeAsync). The legacy batch path // (SaveBatchAsync -> SaveTVPAsync -> POST_MM_DOCUMENT) must build its DataTable // schema without RowGuid or the TVP column count no longer matches the SQL Server // table type ("Trying to pass a table-valued parameter with N column(s) where the // corresponding user-defined table type requires N-1 column(s)"). private static readonly Dictionary DetailTvpMapping = DetailMapping.Where(kv => kv.Key != "RowGuid").ToDictionary(kv => kv.Key, kv => kv.Value); private static readonly Dictionary ChargeTvpMapping = ChargeMapping.Where(kv => kv.Key != "RowGuid").ToDictionary(kv => kv.Key, kv => kv.Value); private static readonly Dictionary AddonMapping = new() { {"MMAddonId","DOCUMENTADDONID" }, {"DocumentId","DOCUMENTID"}, { "DocumentDetailId", "DOCUMENTDETAILID" }, { "MMAddonSlNo", "SLNO" }, { "ParameterId", "PARAMETERID" }, { "MMAddonInputDate", "INPUTDATE" }, { "MMAddonInputText", "INPUTTEXT" }, { "MMAddonInputValue", "INPUTVALUE" }, { "MMAddonFinalValue", "FINALVALUE" } }; private static readonly Dictionary PackDetailMapping = new() { {"PackDetailId", "PACKDETAILID"}, {"PackDetailSlNo", "SLNO"}, {"PackDetailPackNumberId", "PACKNUMBERID"}, {"BizTransactionTypeId", "BIZTRANSACTIONTYPEID"}, {"ObjectHeaderTypeId", "OBJECTHEADERTYPEID"}, {"PackDetailObjectHeaderId", "OBJECTHEADERID"}, {"ObjectTypeId", "OBJECTTYPEID"}, {"PackDetailObjectId", "OBJECTID"}, {"ItemId", "ITEMID"}, {"SKUId", "SKUID"}, {"PackDetailQuantity", "QUANTITY"}, {"PackDetailShortage", "SHORTAGE"}, {"PackDetailExcess", "EXCESS"}, {"PackDetailNetQuantity", "NETQUANTITY"}, {"PackDetailGoodQuantity", "GOODQUANTITY"}, {"PackDetailRejectedQuantity", "REJECTEDQUANTITY"}, {"PackDetailReworkQuantity", "REWORKQUANTITY"}, {"PackDetailOtherQuantity", "OTHERQUANTITY"}, {"PackDetailGrossWeight", "GROSSWEIGHT"}, {"PackDetailNetWeight", "NETWEIGHT"}, {"PackDetailRemarks", "REMARKS"}, {"OUId", "OUID"}, {"StoreId", "STOREID"}, {"PackDetailStockLedgerNumber", "STOCKLEDGERNUMBER"}, {"PackDetailStockLedgerDate", "STOCKLEDGERDATE"}, {"PackDetailReferenceNumber", "REFERENCENUMBER"}, {"PackDetailReferenceDate", "REFERENCEDATE"}, {"PackDetailLocationType", "LOCATIONTYPE"}, {"PartyBranchId", "PARTYBRANCHID"}, {"PackDetailStockPostType", "STOCKPOSTTYPE"}, {"PackDetailMaterialOwnerShipType", "MATERIALOWNERSHIPTYPE"}, {"PackDetailExciseSlno", "EXCISESLNO"}, {"PackDetailTareWeight", "TAREWEIGHT"}, {"DocumentDetailId", "DOCUMENTDETAILID"}, {"PlannedQuantity", "PLANNEDQUANTITY"} }; private static readonly Dictionary LotDetailMapping = new() { {"LotDetailId", "LOTDETAILID"}, {"RowGuid", "ROWGUID"}, {"BizTransactionTypeId", "BIZTRANSACTIONTYPEID"}, {"ObjectHeaderTypeId", "OBJECTHEADERTYPEID"}, {"LotDetailObjectHeaderId", "OBJECTHEADERID"}, {"ObjectTypeId", "OBJECTTYPEID"}, {"LotDetailObjectId", "OBJECTID"}, {"LotDetailSlNo", "SLNO"}, {"LotId", "LOTID"}, {"LotDetailQuantity", "QUANTITY"}, {"LotDetailGoodQuantity", "GOODQUANTITY"}, {"LotDetailRejectedQuantity", "REJECTEDQUANTITY"}, {"LotDetailReworkQuantity", "REWORKQUANTITY"}, {"LotDetailOtherQuantity", "OTHERQUANTITY"}, {"PackId", "PACKID"}, {"LotDetailPackQuantity", "PACKQUANTITY"}, {"OUId", "OUID"}, {"StoreId", "STOREID"}, {"LotDetailStockLedgerNumber", "STOCKLEDGERNUMBER"}, {"LotDetailStockLedgerDate", "STOCKLEDGERDATE"}, {"LotDetailReferenceNumber", "REFERENCENUMBER"}, {"LotDetailReferenceDate", "REFERENCEDATE"}, {"LotDetailLocationType", "LOCATIONTYPE"}, {"PartyBranchId", "PARTYBRANCHID"}, {"LotDetailStockPostType", "STOCKPOSTTYPE"}, {"LotDetailMaterialOwnerShipType", "MATERIALOWNERSHIPTYPE"}, {"UsedInLotId", "USEDINLOTID"}, {"ReasonId", "REASONID"}, {"Remarks", "REMARKS"}, {"LotDetailMarkedGoodQuantity", "MARKEDGOODQUANTITY"}, {"LotDetailD3", "D3"}, {"LotDetailD4", "D4"}, {"LotDetailItemPostedCost", "ITEMPOSTEDCOST"}, {"LotDetailPlannedQuantity", "PLANNEDQUANTITY"}, {"BinId", "BINID"}, {"LotDetailD1", "D1"}, {"LotDetailD2", "D2"}, {"LotDetailD5", "D5"} }; private static readonly Dictionary PackNumberMapping = new() { {"PackNumberId", "PACKNUMBERID"}, {"PackNumberNumber", "PACKNUMBER"}, {"OUId", "OUID"}, {"StoreId", "STOREID"}, {"WorkCenterId", "WORKCENTERID"}, {"PartyBranchId", "PARTYBRANCHID"}, {"ItemId", "ITEMID"}, {"SKUId", "SKUID"}, {"LotId", "LOTID"}, {"PackNumberPackType", "PACKTYPE"}, {"PackId", "PACKID"}, {"PackNumberQuantity", "QUANTITY"}, {"PackNumberShortage", "SHORTAGE"}, {"PackNumberExcess", "EXCESS"}, {"PackNumberNetQuantity", "NETQUANTITY"}, {"PackNumberGoodQuantity", "GOODQUANTITY"}, {"PackNumberRejectedQuantity", "REJECTEDQUANTITY"}, {"PackNumberReworkQuantity", "REWORKQUANTITY"}, {"PackNumberOtherQuantity", "OTHERQUANTITY"}, {"PackNumberGrossWeight", "GROSSWEIGHT"}, {"PackNumberNetWeight", "NETWEIGHT"}, {"PackNumberShadowQuantity", "SHADOWQUANTITY"}, {"PackNumberStartDate", "STARTDATE"}, {"PackNumberExpiryDate", "EXPIRYDATE"}, {"PackNumberPoints", "POINTS"}, {"PackNumberShade", "SHADE"}, {"PackNumberStatus", "STATUS"}, {"PackNumberPackStatus", "PACKSTATUS"}, {"PackNumberPick", "PICK"}, {"PackNumberInspectedStatus", "INSPECTEDSTATUS"}, {"ModifiedOn", "MODIFIEDON"}, {"BinId", "BINID"}, {"PackNumberRemarks", "REMARKS"} }; private static readonly Dictionary ScheduleMapping = new() { { "MMScheduleId", "MMSCHEDULEID" }, { "RowGuid", "ROWGUID" }, { "DocumentId", "DOCUMENTID" }, { "DocumentDetailId", "DOCUMENTDETAILID" }, { "MMScheduleSlNo", "SLNO" }, { "PartyId", "PARTYID" }, { "PartyBranchId", "PARTYBRANCHID" }, { "MMScheduleScheduleDate", "SCHEDULEDATE" }, { "MMScheduleScheduleQuantity", "SCHEDULEQUANTITY" }, { "MMScheduleGoodQuantity", "GOODQUANTITY" }, { "MMScheduleRejectedQuantity", "REJECTEDQUANTITY" }, { "MMScheduleReworkQuantity", "REWORKQUANTITY" }, { "MMScheduleOtherQuantity", "OTHERQUANTITY" }, { "MMScheduleRemarks", "REMARKS" } }; private static readonly Dictionary ParameterMapping = new() { { "MMParameterId", "DOCUMENTPARAMETERID" }, { "DocumentId", "DOCUMENTID" }, { "DocumentDetailId", "DOCUMENTDETAILID" }, { "LotId", "LOTID" }, { "PackNumberId", "PACKNUMBERID" }, { "MMParameterSampleNumber", "SAMPLENUMBER" }, { "MMParameterSlNo", "SLNO" }, { "ParameterId", "PARAMETERID" }, { "MMParameterStandard", "STANDARD" }, { "MMParameterActual", "ACTUAL" }, { "MMParameterMinimum", "MINIMUM" }, { "MMParameterMaximum", "MAXIMUM" }, { "MMParameterTollerancePlusPercent", "TOLLERANCEPLUSPERCENT" }, { "MMParameterTolleranceMinusPercent", "TOLLERANCEMINUSPERCENT" }, { "MMParameterStandardText", "STANDARDTEXT" }, { "MMParameterActualText", "ACTUALTEXT" }, { "MMParameterRemarks", "REMARKS" }, { "MMParameterParameterStatus", "PARAMETERSTATUS" }, { "InputId", "INPUTID" }, { "ObjectHeaderTypeId", "OBJECTHEADERTYPEID" }, { "ObjectHeaderId", "OBJECTHEADERID" }, { "ObjectTypeId", "OBJECTTYPEID" }, { "ObjectId", "OBJECTID" }, { "BIZTransactionTypeId", "BIZTRANSACTIONTYPEID" }, { "SupplierInspectionReport", "SUPPLIERINSPECTIONREPORT" }, { "SupplierTestCertificateNo", "SUPPLIERTESTCERTIFICATENO" }, { "ChemicalAnalysisReportNo", "CHEMICALANALYSISREPORTNO" } }; private static readonly Dictionary ItemReasonMapping = new() { { "ItemReasonId", "ITEMREASONID" }, { "ObjectHeaderTypeId", "OBJECTHEADERTYPEID" }, { "ItemReasonObjectHeaderId", "OBJECTHEADERID" }, { "ObjectTypeId", "OBJECTTYPEID" }, { "ItemReasonObjectId", "OBJECTID" }, { "BIZTransactionTypeId", "BIZTRANSACTIONTYPEID" }, { "ItemId", "ITEMID" }, { "SKUId", "SKUID" }, { "ItemReasonDocumentDate", "DOCUMENTDATE" }, { "ItemReasonSlNo", "SLNO" }, { "ReasonId", "REASONID" }, { "ItemReasonGoodQuantity", "GOODQUANTITY" }, { "ItemReasonRejectedQuantity", "REJECTEDQUANTITY" }, { "ItemReasonReworkQuantity", "REWORKQUANTITY" }, { "ItemReasonOtherQuantity", "OTHERQUANTITY" }, { "ItemReasonRemarks", "REMARKS" }, { "LotId", "LOTID" } }; private static readonly Dictionary MMDetailProcessMapping = new() { { "MMDetailProcessId", "DOCUMENTDETAILPROCESSID" }, { "DocumentDetailId", "DOCUMENTDETAILID" }, { "MMDetailProcessSlNo", "SLNO" }, { "ProcessId", "PROCESSID" }, { "ProcessTypeId", "PROCESSTYPEID" }, { "SubProcessId", "SUBPROCESSID" }, { "MMDetailProcessProcessMode", "PROCESSMODE" }, { "MMDetailProcessIsProcessType", "ISPROCESSTYPE" }, { "MMDetailProcessRate", "RATE" } }; private static readonly Dictionary CostCenterAllocationMapping = new() { { "CostCenterAllocationId", "COSTCENTERALLOCATIONID" }, { "ObjectHeaderTypeId", "OBJECTHEADERTYPEID" }, { "CostCenterAllocationObjectHeaderId", "OBJECTHEADERID" }, { "ObjectTypeId", "OBJECTTYPEID" }, { "CostCenterAllocationObjectId", "OBJECTID" }, { "CostCenterAllocationCostCategorySlno", "COSTCATEGORYSLNO" }, { "CostCategoryId", "COSTCATEGORYID" }, { "CostCenterAllocationSlno", "SLNO" }, { "CostCenterId", "COSTCENTERID" }, { "CostCenterAllocationAllocatedQuantity", "ALLOCATEDQUANTITY" } }; #endregion public async Task GetMMHead(int DocumentId, LoginDTO LoginDTO) { try { return await MMHeadDAL.GetMMHead(DocumentId, LoginDTO); } catch (Exception) { throw; } } // GB4 parity: MMHead.svc/SelectList/ForDocument defaulted FirstNumber/MaxResult from // CommonConfig ("FromNumber"/"ToNumber", falling back to 0/10) whenever a caller sent // FirstNumber=0 && MaxResult=0 — its convention for "paging unspecified". GB5 has no // CommonConfig table equivalent, so this defaults straight to a first page of 10, matching // GB4's practical fallback (CommonConfig entries are normally unset). Without this, GB5's // "-1 = no paging" SQL sentinel (see MMheadQB.GET_SELECTLIST_MMHEAD_DOCUMENT) means // FirstNumber=0/MaxResult=0 silently returns zero rows instead of a default page. public async Task GetSelectListForDocument(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { if (FirstNumber == 0 && MaxResult == 0) { FirstNumber = 1; MaxResult = 10; } return await MMHeadDAL.GetSelectListForDocument(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task GetSelectListMMHeadForVoucher(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await MMHeadDAL.GetSelectListMMHeadForVoucher(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task GetLoadMMHead( string documentIds, string? detailIds, int bizTransactionClassId, int nature, int storeId, int? partyId, LoginDTO login, CancellationToken ct) { var ids = ParseIds(documentIds); var lineIds = detailIds is not null ? ParseIds(detailIds) : null; // GB4 parity: a plain document load (no item-wise DetailIds filter) is a full // fetch of the source document(s) — same as GetMMHead(DocumentId) — not a // pending-allocation-scoped projection. Mirrors GB4 MMHeadBLL.GetLoadMMHead's // "DocumentIde != -1 && ItemId == "" && Qty == 0" branch → GetLoadSelectedMMHead, // extended to N documents (GB4's comma-separated DocumentId list). if (lineIds is null || lineIds.Length == 0) { return await MMHeadDAL.GetMMHeadList(ids, login).ConfigureAwait(false); } var result = await MMHeadDAL.GetLoadMMHeadFromAllocation( ids, lineIds, bizTransactionClassId, nature, storeId, partyId, login, ct) .ConfigureAwait(false); return JsonSerializer.Serialize(result); } public async Task GetMMHeadObjectConversion( string ids, string? detailIds, string entityCode, int storeId, LoginDTO login, CancellationToken ct) { var parsedIds = ParseIds(ids); var lineIds = detailIds is not null ? ParseIds(detailIds) : null; MMDAL.DTO.MMHead.MMHeadDTO? result = entityCode.ToUpperInvariant() switch { "INDENT" => await MMHeadDAL.GetLoadMMHeadFromIndent(parsedIds, lineIds, storeId, login, ct) .ConfigureAwait(false), "GATEENTRY" => await MMHeadDAL.GetLoadMMHeadFromGateEntry(parsedIds, storeId, login, ct) .ConfigureAwait(false), _ => throw new ArgumentException($"Unsupported EntityCode: {entityCode}") }; return JsonSerializer.Serialize(result); } // EntityId of TMMHEAD — used as DocumentTypeId for TALLOCATION lookup. private const int MmHeadEntityId = -1899997952; // Document status values. private const short StatusDraft = 0; private const short StatusFinalized = 1; private const short StatusCancelled = 2; private const short StatusPosting = 3; // transient claim — prevents double-finalize private const short StatusCancelling = 4; // transient claim — prevents double-cancel public async Task FinalizeMMHeadAsync(int documentId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("finalize-load", new { documentId }); var head = await MMHeadDAL.GetMMHeadForFinalizeAsync(documentId, login, ct).ConfigureAwait(false); if (head is null) throw new ArgumentException($"Document {documentId} not found."); if (head.MMHeadStatus != StatusDraft) throw new InvalidOperationException( $"Document {documentId} is not in Draft state (status={head.MMHeadStatus})."); // Atomic claim: Draft → Posting. Zero rows affected means another caller beat us. GB5Trace.Step("finalize-claim", new { documentId }); var claimed = await MMHeadDAL.UpdateMMHeadStatusConditionalAsync( documentId, StatusDraft, StatusPosting, login, ct).ConfigureAwait(false); if (claimed == 0) throw new InvalidOperationException( $"Document {documentId} is already being finalized by another request."); IList details = head.MMDetailArray ?? Array.Empty(); try { GB5Trace.Step("finalize-allocations", new { documentId, detailCount = details.Count }); #region vv sir discussion required for nature of allocation posting // await _allocationPostingBll.PostDocumentAllocationsAsync(head, details, login, ct).ConfigureAwait(false); #endregion if (head.MMHeadStockPosting == 1) { GB5Trace.Step("finalize-stock-post", new { documentId }); await _stockPostingBll.PostDocumentStockAsync(head, details, login, ct).ConfigureAwait(false); bool isTransfer = head.FromStoreId > 0 && head.ToStoreId > 0; await _stockLedgerCostBll.UpdateCostForDocumentAsync( objectTypeId: head.BizTransactionTypeId, objectId: head.MMHeadId, isTransfer: isTransfer, fromStoreId: isTransfer ? head.FromStoreId : -1, toStoreId: isTransfer ? head.ToStoreId : head.StoreId, transactionDate: DateOnly.FromDateTime(head.MMHeadDate), login: login, ct: ct).ConfigureAwait(false); } // Promote: Posting → Finalized. await MMHeadDAL.UpdateMMHeadStatusConditionalAsync( documentId, StatusPosting, StatusFinalized, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("finalize-posting-failed", ex); _logger.LogError(ex, "FinalizeMMHeadAsync posting failed for DocumentId {DocumentId}", documentId); // Rollback claim: Posting → Draft so the document can be retried. await MMHeadDAL.UpdateMMHeadStatusConditionalAsync( documentId, StatusPosting, StatusDraft, login, CancellationToken.None).ConfigureAwait(false); throw; } return SuccessResponse.UpdateSuccess; } public async Task CancelMMHeadAsync(int documentId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("cancel-load", new { documentId }); var head = await MMHeadDAL.GetMMHeadForFinalizeAsync(documentId, login, ct).ConfigureAwait(false); if (head is null) throw new ArgumentException($"Document {documentId} not found."); if (head.MMHeadStatus != StatusFinalized) throw new InvalidOperationException( $"Document {documentId} cannot be cancelled — status={head.MMHeadStatus}. Only finalized documents can be cancelled."); // Atomic claim: Finalized → Cancelling. GB5Trace.Step("cancel-claim", new { documentId }); var claimed = await MMHeadDAL.UpdateMMHeadStatusConditionalAsync( documentId, StatusFinalized, StatusCancelling, login, ct).ConfigureAwait(false); if (claimed == 0) throw new InvalidOperationException( $"Document {documentId} is already being cancelled by another request."); try { GB5Trace.Step("cancel-allocations", new { documentId }); await _allocationPostingBll.CancelDocumentAllocationsAsync( documentId, MmHeadEntityId, login, ct).ConfigureAwait(false); if (head.MMHeadStockPosting == 1) { GB5Trace.Step("cancel-stock-reversal", new { documentId }); await _stockPostingBll.ReverseDocumentStockAsync(documentId, login, ct).ConfigureAwait(false); } // Promote: Cancelling → Cancelled. await MMHeadDAL.UpdateMMHeadStatusConditionalAsync( documentId, StatusCancelling, StatusCancelled, login, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("cancel-posting-failed", ex); _logger.LogError(ex, "CancelMMHeadAsync failed for DocumentId {DocumentId}", documentId); // Rollback claim: Cancelling → Finalized so the document can be retried. await MMHeadDAL.UpdateMMHeadStatusConditionalAsync( documentId, StatusCancelling, StatusFinalized, login, CancellationToken.None).ConfigureAwait(false); throw; } return SuccessResponse.UpdateSuccess; } // Ported from GB4's MMHeadBLL.Delete(DocumentId, LoginDTO, CancelApplicable) — // D:\GB4Project\GB4Solution\BLL\MMBLL\MMHead\MMHeadBLL.cs ~4995-5376. GB4's version also // reverses Pack/BOM/CostCenterAllocation/BinLedger/StockToAccount state and removes the // Activiti workflow instance for the document; none of those subsystems exist in GB5 yet // (confirmed via repo search 2026-08-31), so instead of silently skipping their reversal // this blocks delete whenever the document has touched them (see DeleteMMHeadQB's // GET_DELETE_BLOCKERS). Combined with the Draft-only status gate below, this keeps delete // usable only for documents that were never finalized — GB4's own "no TALLOCATION rows" // guard (UNIB0104) makes the same restriction hold for the common real-world case. public async Task DeleteMMHeadAsync(int documentId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("delete-load", new { documentId }); var head = await MMHeadDAL.GetMMHeadForDeleteAsync(documentId, login, ct).ConfigureAwait(false); if (head is null) throw new ArgumentException($"Document {documentId} not found."); if (head.MMHeadStatus != StatusDraft) throw new InvalidOperationException( $"Document {documentId} cannot be deleted — status={head.MMHeadStatus}. Only Draft documents can be deleted; a finalized document must be Cancelled instead."); if (!string.IsNullOrEmpty(head.IRN) || !string.IsNullOrEmpty(head.EBNumber)) throw new InvalidOperationException( $"Document {documentId} cannot be deleted — an E-Invoice/E-way bill has already been generated for it."); if (head.VoucherId != -1) throw new InvalidOperationException( $"Document {documentId} cannot be deleted — it is linked to voucher {head.VoucherId}."); GB5Trace.Step("delete-blockers", new { documentId }); var blockers = await MMHeadDAL.GetMMHeadDeleteBlockersAsync(documentId, login, ct).ConfigureAwait(false); if (blockers.HasAllocations) throw new InvalidOperationException($"Document {documentId} cannot be deleted — it already has stock allocations."); if (blockers.HasStockLedger) throw new InvalidOperationException($"Document {documentId} cannot be deleted — it already has stock ledger postings."); if (blockers.HasBOM) throw new InvalidOperationException($"Document {documentId} cannot be deleted — one or more of its rows are linked to a BOM."); if (blockers.HasPackDetail) throw new InvalidOperationException($"Document {documentId} cannot be deleted — one or more of its rows are linked to pack detail."); if (blockers.HasItemPartyUsage) throw new InvalidOperationException($"Document {documentId} cannot be deleted — it is used in Item-Party (quotation) data; remove that link first."); GB5Trace.Step("delete-cascade", new { documentId }); var tx = await MMHeadDAL.BeginTransactionAsync(login).ConfigureAwait(false); try { await MMHeadDAL.DeleteMMHeadCascadeAsync(documentId, head.ReferenceDocumentId, login, tx, ct).ConfigureAwait(false); await MMHeadDAL.CommitAsync(tx).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("delete-cascade-failed", ex); _logger.LogError(ex, "DeleteMMHeadAsync failed for DocumentId {DocumentId}", documentId); await MMHeadDAL.RollbackAsync(tx).ConfigureAwait(false); throw; } return SuccessResponse.DeleteSuccessMessage; } // Ported from GB4's MMHeadBLL.VoucherFormingFromMMHeadProcedureBased. Forms a complete, // ready-to-save accounting VoucherDTO from an already-finalized MM document — used to // prefill a voucher from a linked purchase/GRN document. This never saves anything; the // caller is expected to POST the returned VoucherDTO to Voucher Save separately, exactly // matching GB4's own contract. // // Confirmed via GB4 source (NHibernate mapping files + grep across BLL/MMBLL): neither // MMHeadDTO.VoucherBillAllocationDTOs/VoucherInstrumentDTOs nor MMHeadDTO.AgreementNumber // are ever populated by the plain document load this operation uses (no backing DB // mapping at all — they're only set by unrelated bulk-import flows). So GB4's "reuse // existing bill-allocation/instrument" branches and its AgreementNumber override are dead // code here and are omitted; only the "synthesize new" paths are implemented. public async Task VoucherFormingFromMMHeadProcedureBased(int documentId, LoginDTO login, CancellationToken ct) { try { if (login.DatabaseType != DBType.SQL) throw new NotSupportedException( "Voucher forming from MMHead (procedure-based) is only supported for SQL Server-backed clients — matches GB4's own restriction."); var mmHead = await MMHeadDAL.GetMMHeadHeaderForVoucherForming(documentId, login, ct).ConfigureAwait(false); if (mmHead is null) throw new ArgumentException($"MMHead document not found: {documentId}"); var bizTypeSettings = await _bizTransactionTypeBll .GetBizTransactionTypeSettings(mmHead.BizTransactionTypeId, login, ct).ConfigureAwait(false); bool isCashCredit = bizTypeSettings != null && bizTypeSettings.BIZTransactionTypeIsCashCredit != 0; var postRows = await MMHeadDAL.GetMM2AccountPostData(documentId, login, ct).ConfigureAwait(false); var voucher = new VoucherDTO { BizTransactionTypeId = mmHead.BizTransactionTypeId, VoucherVoucherNumber = mmHead.MMHeadNumber, OrganizationUnitId = login.WorkOUId, TempTotalNetValue = mmHead.MMHeadTotalNetValue, VoucherVoucherDate = mmHead.MMHeadDate, VoucherReferenceDate = mmHead.MMHeadDate, VoucherValidTillDate = mmHead.MMHeadDate, PeriodId = login.WorkPeriodId, VoucherReferenceNumber = mmHead.MMHeadReferenceNumber, VoucherId = 0, VoucherVersion = 0, VoucherPostedFromType = 1, VoucherPostedFromId = documentId, }; int slno = 1; foreach (var row in postRows) { var account = await MMHeadDAL.GetAccountForPosting(row.AccountId, login, ct).ConfigureAwait(false); var currency = await MMHeadDAL.GetCurrencyForPosting(mmHead.CurrencyId, login, ct).ConfigureAwait(false); var costCenter = await MMHeadDAL.GetCostCenterForPosting(row.CostCenterId, login, ct).ConfigureAwait(false); var detail = new VoucherDetailDTO { VoucherAccountId = row.AccountId, VoucherAccountTDSCategoryId = account?.TDSCategoryId ?? 0, VoucherAccountTDSCategoryName = account?.TDSCategoryName, VoucherAccountIsTDSApplicable = account?.AccountIsTDSApplicable ?? 0, VoucherAccountType = account?.AccountType ?? 0, InterOUId = login.WorkOUId, VoucherAccountCode = row.AccountCode, VoucherAccountName = row.AccountName, VoucherAccountBillAllocationType = (byte)row.BillAllocationType, VoucherAccountAccountGroupId = row.AccountGroupId, VoucherDetailDetailType = (byte)row.DetailType, VoucherDetailAmount = (double)row.Amount, VoucherDetailAmountFC = (double)row.Amount, CurrencyId = mmHead.CurrencyId, CurrencyCode = currency?.CurrencyCode, CurrencyName = currency?.CurrencyName, VoucherDetailCurrencyConversion = 1, VoucherDetailSlno = (short)slno, CostCenterId = row.CostCenterId, CostCenterCode = costCenter?.CostCenterCode, CostCenterName = costCenter?.CostCenterName, L1Id = row.L1Id, L1Code = row.L1Code, L1Name = row.L1Name, L2Id = row.L2Id, L2Code = row.L2Code, L2Name = row.L2Name, L3Id = row.L3Id, L3Code = row.L3Code, L3Name = row.L3Name, L4Id = row.L4Id, L4Code = row.L4Code, L4Name = row.L4Name, L5Id = row.L5Id, L5Code = row.L5Code, L5Name = row.L5Name, VoucherDetailVoucherLineNarration = "", }; bool costCategoryConditionExists = await MMHeadDAL .CostCategoryConditionExistsForAccountGroup(row.AccountGroupId, login, ct).ConfigureAwait(false); detail.VoucherDetailCostCenterApplicable = costCategoryConditionExists ? 0 : 1; // Bill-allocation forming — only for the party/control-account line, matching // GB4's condition exactly. if (detail.VoucherAccountBillAllocationType != 0 && mmHead.PartyId == detail.VoucherAccountId) { string refNo = string.IsNullOrEmpty(voucher.VoucherReferenceNumber) ? mmHead.MMHeadNumber : voucher.VoucherReferenceNumber; DateTime today = DateTime.UtcNow.Date; var billAllocation = new VoucherBillAllocationDTO { VoucherBillAllocationDetailNumber = refNo, VoucherBillAllocationReferenceNumber = string.IsNullOrEmpty(voucher.VoucherReferenceNumber) ? mmHead.MMHeadPartyReferenceNumber : refNo, VoucherBillAllocationFullAmount = detail.VoucherDetailAmountFC, VoucherBillAllocationAllocatedAmount = detail.VoucherDetailAmountFC, VoucherBillAllocationDetailDate = voucher.VoucherVoucherDate, VoucherBillAllocationDueDate = today, VoucherBillAllocationPlanDate = today, VoucherBillAllocationReferenceDate = today, CurrencyId = mmHead.CurrencyId, InChargeId = mmHead.InchargeId, RouteId = mmHead.RouteId, PriceCategoryId = -1, }; detail.VoucherBillAllocationArray.Add(billAllocation); } voucher.VoucherDetailArray.Add(detail); slno++; } decimal totalVoucherAmount = 0; int slvv = 1; foreach (var detail in voucher.VoucherDetailArray) { detail.IsCreditMerge = 0; detail.IsDebitMerge = 0; detail.VoucherDetailSlno = (short)slvv; detail.VoucherDetailAmount = Math.Abs(detail.VoucherDetailAmount); detail.VoucherDetailAmountFC = Math.Abs(detail.VoucherDetailAmountFC); if (detail.VoucherDetailAcCurrencyConversion == 0) detail.VoucherDetailAcCurrencyConversion = 1; if (detail.VoucherDetailCurrencyConversion == 0) detail.VoucherDetailCurrencyConversion = 1; if (detail.VoucherDetailDetailType == 0) totalVoucherAmount += (decimal)detail.VoucherDetailAmount; // FullAmount is never sign-flipped in GB4 — only AllocatedAmount flips for credit lines. foreach (var ba in detail.VoucherBillAllocationArray) { ba.VoucherBillAllocationFullAmount = detail.VoucherDetailAmount; ba.VoucherBillAllocationAllocatedAmount = detail.VoucherDetailDetailType == 0 ? detail.VoucherDetailAmount : detail.VoucherDetailAmount * -1; } slvv++; } // Cash/bank default-instrument synthesis — GB4 quirk preserved: first row of // MINSTRUMENT with INSTRUMENTTYPE=0, no ORDER BY (nondeterministic if >1 exists). if (!isCashCredit && mmHead.MMHeadModeOfPayment == 0 && voucher.VoucherDetailArray.Count > 0) { var firstDetail = voucher.VoucherDetailArray[0]; var instrumentId = await MMHeadDAL.GetDefaultCashInstrumentId(login, ct).ConfigureAwait(false); var instrument = new VoucherInstrumentDTO { AccountId = firstDetail.VoucherAccountId, BankAccountId = firstDetail.VoucherAccountId, VoucherInstrumentSlno = 1, InstrumentId = instrumentId ?? 0, VoucherInstrumentDate = voucher.VoucherVoucherDate, VoucherDate = voucher.VoucherVoucherDate, VoucherInstrumentBouncedDate = voucher.VoucherVoucherDate, VoucherInstrumentCancelDate = voucher.VoucherVoucherDate, VoucherInstrumentDepositDate = voucher.VoucherVoucherDate, VoucherInstrumentRealisedDate = voucher.VoucherVoucherDate, VoucherInstrumentDrawnOnBank = "", VoucherInstrumentDrawnOnBranch = "", VoucherInstrumentAmount = firstDetail.VoucherDetailAmount, VoucherInstrumentNumber = "", VoucherInstrumentFCAmount = firstDetail.VoucherDetailAmountFC, }; firstDetail.VoucherInstrumentArray.Add(instrument); } voucher.VoucherVoucherAmount = (double)totalVoucherAmount; voucher.VoucherVoucherFCAmount = (double)totalVoucherAmount; voucher.CurrencyId = mmHead.CurrencyId; voucher.VoucherConversionFactor = mmHead.MMHeadCurrencyConversion; voucher.VoucherStatus = 1; // Always approve during Import — matches GB4 (bug 22196) ApplyAutoAllocationFormingIfSettingFound(voucher); return JsonSerializer.Serialize(voucher); } catch (Exception ex) { _logger.LogError(ex, "VoucherFormingFromMMHeadProcedureBased failed for DocumentId {DocumentId}", documentId); throw; } } // Ported from GB4's MMHeadLib.AutoAllocationFormaingIfSettingFound — a safety net run after // the main forming pass: any detail line whose account is Manual bill-allocation // (BILLALLOCATIONTYPE=2) but still ended up with zero bill-allocation rows gets one // synthesized "New Allocation" row so the ledger entry isn't left un-allocable. private static void ApplyAutoAllocationFormingIfSettingFound(VoucherDTO voucher) { foreach (var detail in voucher.VoucherDetailArray) { if (detail.VoucherAccountBillAllocationType != 2 || detail.VoucherBillAllocationArray.Count != 0) continue; var newAllocation = new VoucherBillAllocationDTO { VoucherBillAllocationType = 0, // "New Allocation" AllocationLineDetailNumber = voucher.VoucherVoucherNumber, VoucherBillAllocationReferenceNumber = voucher.VoucherVoucherNumber, VoucherBillAllocationDetailNumber = voucher.VoucherVoucherNumber, AllocationLineId = -1, VoucherBillAllocationDetailDate = voucher.VoucherVoucherDate, VoucherBillAllocationReferenceDate = voucher.VoucherReferenceDate, VoucherBillAllocationDueDate = voucher.VoucherVoucherDate, VoucherBillAllocationPlanDate = voucher.VoucherVoucherDate, InChargeId = -1, RouteId = -1, VoucherBillAllocationFullAmount = detail.VoucherDetailAmount, VoucherBillAllocationAllocatedAmount = detail.VoucherDetailDetailType == 0 ? detail.VoucherDetailAmount : detail.VoucherDetailAmount * -1, }; detail.VoucherBillAllocationArray.Add(newAllocation); } } private static int[] ParseIds(string raw) => string.IsNullOrWhiteSpace(raw) ? throw new ArgumentException("IDs are required.") : raw.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(s => int.TryParse(s.Trim(), out var id) ? id : throw new ArgumentException($"Invalid ID: {s}")) .ToArray(); public async Task GetSelectListMMHead(CriteriaDTO crt, LoginDTO loginDTO) => await MMHeadDAL.GetSelectListMMHead(crt, loginDTO); // ── Account posting preview from FE (unsaved document) ────────────────────── // Ported from GB4's MMHeadTunedBLL.AccountPostingFromFromFE. GB4 staged the FE-submitted // document into local #Head/#Detail/#Charges temp tables via per-row string-substituted // SQL, then copied those into TVP table variables before calling the stored procedure. // GB5 builds the three DataTables directly here and Dapper binds them straight to the TVP // parameters — see MMheadQB.GET_MM2_ACCOUNT_POST_PREVIEW_DATA. Returns the same row shape // as VoucherFormingFromMMHeadProcedureBased's GetMM2AccountPostData (MMToAccountPostDTO), // so FE can share one grid for both the pre-save preview and the post-save review. public async Task AccountPostingFromFE(MMHeadDTO head, byte isSummary, LoginDTO login, CancellationToken ct) { try { var headTable = CreateAccountPostHeadSchema(); var headRow = headTable.NewRow(); headRow["DOCUMENTID"] = head.MMHeadId; headRow["DOCUMENTNO"] = (object?)head.MMHeadNumber ?? string.Empty; headRow["DOCUMENTDATE"] = head.MMHeadDate; headRow["PARTYID"] = head.PartyId; headRow["BIZTRANSACTIONTYPEID"] = head.BizTransactionTypeId; headRow["BIZTRANSACTIONCLASSID"] = head.BizTransactionTypeBIZTransactionTypeClassId; headRow["MODEOFPAYMENT"] = head.MMHeadModeOfPayment; headRow["ISCASHCREDIT"] = head.BizTransactionTypeIsCashCredit; headRow["STOREID"] = head.StoreId; headRow["TOTALITEMBASICVALUE"] = (decimal)head.MMHeadTotalItemBasicValue; headRow["TOTALITEMGROSSVALUE"] = (decimal)head.MMHeadTotalItemGrossValue; headRow["DOCUMENTCHARGESID"] = head.MMHeadDocumentChargesId; headTable.Rows.Add(headRow); var detailTable = CreateAccountPostDetailSchema(); var chargesTable = CreateAccountPostChargesSchema(); foreach (var charge in head.MMChargesArray ?? new List()) AddAccountPostChargeRow(chargesTable, head.MMHeadId, -1, charge); foreach (var detail in head.MMDetailArray ?? new List()) { var detailRow = detailTable.NewRow(); detailRow["DOCUMENTID"] = head.MMHeadId; detailRow["DOCUMENTDETAILID"] = detail.MMDetailId; detailRow["ITEMID"] = detail.ItemId; detailRow["ITEMNAME"] = (object?)detail.ItemName ?? string.Empty; detailRow["ACCOUNTID"] = detail.AccountId; detailRow["ITEMBASICVALUE"] = (decimal)detail.MMDetailItemBasicValue; detailRow["ITEMGROSSVALUE"] = (decimal)detail.MMDetailItemGrossValue; detailRow["COSTCENTERID"] = detail.CostCenterId; detailRow["BIZDIMENSIONVALUESETID"] = -1; // GB4 parity: never populated by the FE payload detailTable.Rows.Add(detailRow); foreach (var charge in detail.MMChargesArray ?? new List()) AddAccountPostChargeRow(chargesTable, head.MMHeadId, detail.MMDetailId, charge); } var rows = await MMHeadDAL.GetMM2AccountPostPreviewData( headTable, detailTable, chargesTable, isSummary, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(rows); } catch (Exception ex) { _logger.LogError(ex, "AccountPostingFromFE failed for DocumentId {DocumentId}", head?.MMHeadId); throw; } } private static void AddAccountPostChargeRow(DataTable table, int documentId, int documentDetailId, MMChargesDTO charge) { var row = table.NewRow(); row["DOCUMENTID"] = documentId; row["DOCUMENTDETAILID"] = documentDetailId; row["CHARGEDETAILID"] = charge.ChargeDetailId; row["AMOUNTPLUS"] = (decimal)charge.MMChargesAmountPlus; row["AMOUNTMINUS"] = (decimal)charge.MMChargesAmountMinus; row["PARTYACCOUNTTYPE"] = charge.ChargeDetailPartyAccountType; row["PARTYACCOUNTID"] = charge.PartyAccountId; row["PARTYACCOUNTMERGE"] = charge.ChargeDetailPartyAccountMerge; row["CONTRAACCOUNTTYPE"] = charge.ChargeDetailContraAccountType; row["CONTRAACCOUNTID"] = charge.ContraAccountId; row["CONTRAACCOUNTMERGE"] = charge.ChargeDetailContraAccountMerge; table.Rows.Add(row); } private static DataTable CreateAccountPostHeadSchema() { var dt = new DataTable(); dt.Columns.Add("DOCUMENTID", typeof(int)); dt.Columns.Add("DOCUMENTNO", typeof(string)); dt.Columns.Add("DOCUMENTDATE", typeof(DateTime)); dt.Columns.Add("PARTYID", typeof(int)); dt.Columns.Add("BIZTRANSACTIONTYPEID", typeof(int)); dt.Columns.Add("BIZTRANSACTIONCLASSID", typeof(int)); dt.Columns.Add("MODEOFPAYMENT", typeof(byte)); dt.Columns.Add("ISCASHCREDIT", typeof(byte)); dt.Columns.Add("STOREID", typeof(int)); dt.Columns.Add("TOTALITEMBASICVALUE", typeof(decimal)); dt.Columns.Add("TOTALITEMGROSSVALUE", typeof(decimal)); dt.Columns.Add("DOCUMENTCHARGESID", typeof(int)); return dt; } private static DataTable CreateAccountPostDetailSchema() { var dt = new DataTable(); dt.Columns.Add("DOCUMENTID", typeof(int)); dt.Columns.Add("DOCUMENTDETAILID", typeof(int)); dt.Columns.Add("ITEMID", typeof(int)); dt.Columns.Add("ITEMNAME", typeof(string)); dt.Columns.Add("ACCOUNTID", typeof(int)); dt.Columns.Add("ITEMBASICVALUE", typeof(decimal)); dt.Columns.Add("ITEMGROSSVALUE", typeof(decimal)); dt.Columns.Add("COSTCENTERID", typeof(int)); dt.Columns.Add("BIZDIMENSIONVALUESETID", typeof(int)); return dt; } private static DataTable CreateAccountPostChargesSchema() { var dt = new DataTable(); dt.Columns.Add("DOCUMENTID", typeof(int)); dt.Columns.Add("DOCUMENTDETAILID", typeof(int)); dt.Columns.Add("CHARGEDETAILID", typeof(int)); dt.Columns.Add("AMOUNTPLUS", typeof(decimal)); dt.Columns.Add("AMOUNTMINUS", typeof(decimal)); dt.Columns.Add("PARTYACCOUNTTYPE", typeof(byte)); dt.Columns.Add("PARTYACCOUNTID", typeof(int)); dt.Columns.Add("PARTYACCOUNTMERGE", typeof(byte)); dt.Columns.Add("CONTRAACCOUNTTYPE", typeof(byte)); dt.Columns.Add("CONTRAACCOUNTID", typeof(int)); dt.Columns.Add("CONTRAACCOUNTMERGE", typeof(byte)); return dt; } } }