using System.Text; using DXPBLL.Common; using DXPDAL.Common; using DXPDAL.VendorPo; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.ListQuery; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using static GB5Shared.GB5Constant.Constant; namespace DXPBLL.VendorPo; public class VendorPoBLL : IVendorPoBLL { private readonly IVendorPoDAL _VendorPoDAL; private readonly IPoAcknowledgementDAL _AckDAL; private readonly IMmIntegrationService _MmIntegration; private readonly IListHandler _ListHandler; private readonly IDXPSystemContext _SystemContext; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly BaseEntityAppService _BaseEntityAppService; private readonly IDXPHubNotifier _HubNotifier; public VendorPoBLL( IVendorPoDAL vendorPoDAL, IPoAcknowledgementDAL ackDAL, IMmIntegrationService mmIntegration, IListHandler listHandler, IDXPSystemContext systemContext, AutoNumber autoNumber, IQueryExecutor queryExecutor, BaseEntityAppService baseEntityAppService, IDXPHubNotifier hubNotifier) { _VendorPoDAL = vendorPoDAL; _AckDAL = ackDAL; _MmIntegration = mmIntegration; _ListHandler = listHandler; _SystemContext = systemContext; _AutoNumber = autoNumber; _QueryExecutor = queryExecutor; _BaseEntityAppService = baseEntityAppService; _HubNotifier = hubNotifier; } // ── OU-membership guard — every method below calls this first ────────── private async Task EnsureOuAccessAsync(int localPartyId, int ouId, LoginDTO tenantLogin, CancellationToken ct) { var links = await _VendorPoDAL.GetPartyOuLinksAsync(localPartyId, tenantLogin, ct).ConfigureAwait(false); if (!links.Any(l => l.OUId == ouId)) throw new InvalidOperationException("This party is not linked to the requested Organization Unit."); } public async Task> GetPendingPoListAsync( int dxpPartyId, int localPartyId, int ouId, int poBizTransactionTypeId, string? searchText, int pageOffset, int pageSize, int tenantId, string databaseName, byte databaseType, CancellationToken ct) { var tenantLogin = _SystemContext.GetTenantLogin(tenantId, databaseName, databaseType, localPartyId, -1); await EnsureOuAccessAsync(localPartyId, ouId, tenantLogin, ct).ConfigureAwait(false); var criteria = new VendorPoCriteria { PartyId = localPartyId, BizTransactionTypeId = poBizTransactionTypeId, OUId = ouId, SearchText = searchText, PageOffset = pageOffset, PageSize = pageSize }; var result = await _ListHandler.HandleAsync(new VendorPoListQuery(criteria), tenantLogin, ct) .ConfigureAwait(false); // Merge in DXP-native acknowledgement status — a second query against DXPDb, since // TMMHEAD and TDXPPOACKNOWLEDGEMENT are different physical databases (see VendorPoQB.cs). var items = result.Items?.ToList() ?? new List(); if (items.Count > 0) { var systemLogin = _SystemContext.GetSystemLogin(); var acks = await _AckDAL.GetByTenantMMHeadsAsync(tenantId, items.Select(i => i.MMHeadId), systemLogin, ct) .ConfigureAwait(false); var ackByHead = acks.ToDictionary(a => a.MMHeadId); foreach (var item in items) { if (ackByHead.TryGetValue(item.MMHeadId, out var ack)) { item.IsAcknowledged = true; item.AcknowledgedOn = ack.AcknowledgedOn; } } } return new PagedResult { Items = items, TotalCount = result.TotalCount }; } public async Task GetPoDetailAsync( int mmHeadId, int localPartyId, int tenantId, string databaseName, byte databaseType, CancellationToken ct) { var tenantLogin = _SystemContext.GetTenantLogin(tenantId, databaseName, databaseType, localPartyId, -1); var header = await _VendorPoDAL.GetPoHeaderAsync(mmHeadId, localPartyId, tenantLogin, ct).ConfigureAwait(false); if (header is null) return null; var details = await _VendorPoDAL.GetPoDetailsAsync(mmHeadId, tenantLogin, ct).ConfigureAwait(false); header.Details = details.ToList(); var systemLogin = _SystemContext.GetSystemLogin(); var ack = await _AckDAL.GetByTenantMMHeadAsync(tenantId, mmHeadId, systemLogin, ct).ConfigureAwait(false); if (ack != null) { header.IsAcknowledged = true; header.AcknowledgedOn = ack.AcknowledgedOn; } return header; } public async Task AcknowledgePoAsync( int dxpPartyId, int mmHeadId, int localPartyId, int ouId, int poBizTransactionTypeId, string? remarks, int tenantId, string databaseName, byte databaseType, int actingDxpUserId, CancellationToken ct) { var tenantLogin = _SystemContext.GetTenantLogin(tenantId, databaseName, databaseType, localPartyId, actingDxpUserId); await EnsureOuAccessAsync(localPartyId, ouId, tenantLogin, ct).ConfigureAwait(false); var header = await _VendorPoDAL.GetPoHeaderAsync(mmHeadId, localPartyId, tenantLogin, ct).ConfigureAwait(false) ?? throw new InvalidOperationException("Purchase Order not found for this party."); var details = (await _VendorPoDAL.GetPoDetailsAsync(mmHeadId, tenantLogin, ct).ConfigureAwait(false)).ToList(); var systemLogin = _SystemContext.GetSystemLogin(actingDxpUserId); var dto = new PoAcknowledgementDTO { DxpPartyId = dxpPartyId, TenantId = tenantId, DatabaseName = databaseName, MMHeadId = mmHeadId, BizTransactionTypeId = poBizTransactionTypeId, OUId = ouId, AcknowledgedOn = DateTime.UtcNow, AcknowledgedById = actingDxpUserId, Remarks = remarks, Status = 1 }; var Trans = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { GB5Trace.Step("acknowledge-dxp-po", new { mmHeadId, tenantId }); // Supersede any existing active acknowledgement for this PO — kept for audit. var existing = await _AckDAL.GetByTenantMMHeadAsync(tenantId, mmHeadId, systemLogin, ct).ConfigureAwait(false); if (existing != null) await _AckDAL.SupersedeAsync(existing.DxpPoAcknowledgementId, systemLogin, Trans, ct).ConfigureAwait(false); var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.DXPPOACKNOWLEDGEMENT, systemLogin) .ConfigureAwait(false); dto.DxpPoAcknowledgementId = auto.StartNumber; await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTDXPPOACKNOWLEDGEMENT, EventTypeConstant.SAVEDXPPOACKNOWLEDGEMENTEVENTTYPEID, dto, systemLogin, async tx => { await _AckDAL.SaveAsync(dto, systemLogin, tx, ct).ConfigureAwait(false); return dto.DxpPoAcknowledgementId; }, null, -1, -1, Trans).ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); GB5Trace.MarkFailed("acknowledge-dxp-po-failed", ex); throw; } await _HubNotifier.NotifyAsync(new DXPNotificationDTO { EventType = DXPNotificationEventTypes.PoAcknowledged, DxpPartyId = dxpPartyId, MMHeadId = mmHeadId, ReferenceNumber = header.MMHeadNumber, Message = $"PO {header.MMHeadNumber} acknowledged.", OccurredOn = dto.AcknowledgedOn }, ct).ConfigureAwait(false); // Auto-reserve stock, one call per PO line — a reservation failure does NOT roll back // the acknowledgement itself (recorded on the ack row for follow-up instead). await TriggerReservationsAsync(dto, header, details, tenantLogin, systemLogin, ct).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } private async Task TriggerReservationsAsync( PoAcknowledgementDTO ack, VendorPoDTO header, List details, LoginDTO tenantLogin, LoginDTO systemLogin, CancellationToken ct) { var failures = new StringBuilder(); int? firstSuccessId = null; var allSucceeded = details.Count > 0; foreach (var line in details) { // NOTE: CreatedById/ModifiedById on the MM-side reservation use a negative sentinel // (-100 = "DXP Vendor Portal") since DxpUserId is a different ID space than MM's // internal MUSER — a real deployment may want a dedicated MUSER row per tenant // representing the portal for cleaner audit attribution; flagged here, not solved. var request = new StockReservationRequestDTO { TenantId = ack.TenantId, OUId = ack.OUId, ForObjectHeaderId = header.MMHeadId, ForBizTransactionTypeId = header.BizTransactionTypeId, ItemId = line.ItemId, ReservedQuantity = line.MMDetailTransactionQuantity, IsAutoLinked = true, LinkedPoId = header.MMHeadId, LinkedPoTypeId = header.BizTransactionTypeId, CreatedById = -100, ModifiedById = -100 }; var outcome = await _MmIntegration.TriggerAutoReservationAsync(request, tenantLogin, ct) .ConfigureAwait(false); if (outcome.Success) firstSuccessId ??= outcome.ReservationId; else { allSucceeded = false; failures.Append($"Line {line.MMDetailSlNo} (Item {line.ItemId}): {outcome.Error}; "); } } var Trans = await _QueryExecutor.BeginTransactionAsync(systemLogin).ConfigureAwait(false); try { ack.ReservationTriggered = allSucceeded; ack.ReservationId = firstSuccessId; ack.ReservationError = failures.Length > 0 ? failures.ToString() : null; ack.ModifiedById = systemLogin.UserId; ack.ModifiedOn = DateTime.UtcNow; await _AckDAL.UpdateReservationOutcomeAsync(ack, systemLogin, Trans, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); } catch (Exception ex) { await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); GB5Trace.MarkFailed("update-dxp-po-reservation-outcome-failed", ex); // Do not rethrow — the acknowledgement itself already committed successfully; // losing the reservation-outcome update is a follow-up/reconciliation concern. } } }