using System; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using RecruitmentBLL.Integration; using RecruitmentDAL.CustomeCode.Agency; using RecruitmentDAL.DTO.Agency; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.Agency { // Entity CRUD for MRECRUITMENTAGENCY — a placement agency, modeled as a DXP Party (identity/ // legal-entity data) plus this Recruitment-side extension table (recruitment-specific // profile fields). See AgencyDTO's doc comment for the exact DXP-Party-vs-local-table split, // and IDxpPartyIntegrationService's doc comment for how a save reaches DXP's SaveParty // endpoint cross-host despite that endpoint requiring a real DXP-signed Bearer token. // // Same single-entity ExecuteSaveAsync pipeline discipline as every other Phase 1-6 Recruitment // entity (BgVerificationBLL/CandidateBLL/ApplicationBLL) — GB5Trace, ILogger, i18n, tenant // filtering, per CLAUDE.md. public class AgencyBLL : IAgencyBLL { // DXP's PartyTypeCode is a closed vendor/customer taxonomy with no dedicated "Agency" // value (1=Vendor 2=Customer 3=Dealer 4=ServiceFranchisee 5=Consumer — see // DXPDAL.Party.PartyDTO). A placement agency is functionally a services vendor to the // tenant, so this module maps every Agency to PartyTypeCode=1 (Vendor). Adding a // dedicated DXP-side "Agency" code is out of this task's lane (DXPDAL/DXPBLL/DXPSL are // not touched here) — flagged as an open question in this task's report. private const byte DxpAgencyPartyTypeCode = 1; private readonly IAgencyDAL _AgencyDAL; private readonly IDxpPartyIntegrationService _DxpPartyIntegrationService; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly KeyInvalidate _KeyInvalidate; private readonly BaseEntityAppService _BaseEntityAppService; private readonly ILogger _Logger; public AgencyBLL( IAgencyDAL agencyDAL, IDxpPartyIntegrationService dxpPartyIntegrationService, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, BaseEntityAppService baseEntityAppService, ILogger logger) { _AgencyDAL = agencyDAL; _DxpPartyIntegrationService = dxpPartyIntegrationService; _AutoNumber = autoNumber; _Validation = validation; _KeyInvalidate = keyInvalidate; _BaseEntityAppService = baseEntityAppService; _Logger = logger; } public async Task GetAgency(int RecruitmentAgencyId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _AgencyDAL.GetAgency(RecruitmentAgencyId, LoginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-agency-failed", ex); _Logger.LogError(ex, "GetAgency failed for RecruitmentAgencyId {RecruitmentAgencyId}", RecruitmentAgencyId); throw; } } public async Task GetSelectListAgency(string? SearchKeyword, LoginDTO LoginDTO, CancellationToken ct) { try { return await _AgencyDAL.GetSelectListAgency(SearchKeyword, LoginDTO, ct).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-agency-failed", ex); _Logger.LogError(ex, "GetSelectListAgency failed"); throw; } } public async Task SaveAgency(AgencyDTO AgencyDTO, LoginDTO LoginDTO, CancellationToken ct) { if (AgencyDTO == null) throw new ArgumentNullException(nameof(AgencyDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-agency", new { AgencyDTO.RecruitmentAgencyId }); if (string.IsNullOrWhiteSpace(AgencyDTO.AgencyName)) throw new ValidationException("AgencyName is required."); if (AgencyDTO.CommissionPercentage is < 0 or > 100) throw new ValidationException($"CommissionPercentage must be between 0 and 100. Received: {AgencyDTO.CommissionPercentage}."); // Sync DXP Party BEFORE local persist — the Party is the source of truth for the // agency's legal identity (LegalName). A failed sync must never leave a local // Agency row with no valid DxpPartyId, so this throws rather than degrading. GB5Trace.Step("dxp-save-party-agency", new { AgencyDTO.RecruitmentAgencyId, AgencyDTO.DxpPartyId }); var outcome = await _DxpPartyIntegrationService .SavePartyAsync(AgencyDTO.DxpPartyId, AgencyDTO.AgencyName, DxpAgencyPartyTypeCode, ct) .ConfigureAwait(false); if (!outcome.Success) { GB5Trace.MarkFailed("save-agency-dxp-sync-failed", new InvalidOperationException(outcome.Error)); _Logger.LogWarning("Agency DXP Party sync failed for {AgencyName}: {Error}", AgencyDTO.AgencyName, outcome.Error); throw new InvalidOperationException($"Failed to sync agency identity with DXP: {outcome.Error}"); } AgencyDTO.DxpPartyId = outcome.DxpPartyId; AgencyDTO.TenantId = LoginDTO.ClientId; AgencyDTO.ModifiedById = LoginDTO.UserId; AgencyDTO.ModifiedOn = DateTime.UtcNow; bool isNew = AgencyDTO.RecruitmentAgencyId == 0; if (isNew) { autoNumberDTO = await _AutoNumber.GetNumberAsync( 1, "RECRUITMENTAGENCY", LoginDTO).ConfigureAwait(false); AgencyDTO.RecruitmentAgencyId = autoNumberDTO.StartNumber; AgencyDTO.CreatedById = LoginDTO.UserId; AgencyDTO.CreatedOn = DateTime.UtcNow; } GB5Trace.Step("save-agency", new { AgencyDTO.RecruitmentAgencyId, isNew }); await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTRECRUITMENTAGENCY, isNew ? EventTypeConstant.SAVERECRUITMENTAGENCYEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTAGENCYEVENTTYPEID, AgencyDTO, LoginDTO, async tx => { _ = isNew ? await _AgencyDAL.SaveAgency(AgencyDTO, LoginDTO, tx, ct).ConfigureAwait(false) : await _AgencyDAL.UpdateAgency(AgencyDTO, LoginDTO, tx, ct).ConfigureAwait(false); return AgencyDTO.RecruitmentAgencyId; }, null, -1, -1, null, isNew).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTAGENCYEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTAGENCYEVENTTYPEID }); var cacheKey = new CacheKeyGeneration().KeyGeneration( AgencyDTO.RecruitmentAgencyId, EntityConstant.OBJECTRECRUITMENTAGENCY, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation( "Agency {RecruitmentAgencyId} {Action} by user {UserId}", AgencyDTO.RecruitmentAgencyId, isNew ? "saved" : "updated", LoginDTO.UserId); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {AgencyDTO.RecruitmentAgencyId}" : $"{SuccessResponse.UpdateSuccessMessage} {AgencyDTO.RecruitmentAgencyId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-agency-failed", vex); if (autoNumberDTO != null && AgencyDTO.RecruitmentAgencyId > 0) await _AutoNumber.RollbackAutoNumber("RECRUITMENTAGENCY", AgencyDTO.RecruitmentAgencyId, LoginDTO).ConfigureAwait(false); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-agency-failed", ex); _Logger.LogError(ex, "SaveAgency failed for RecruitmentAgencyId {RecruitmentAgencyId}", AgencyDTO.RecruitmentAgencyId); if (autoNumberDTO != null && AgencyDTO.RecruitmentAgencyId > 0) { await _AutoNumber.RollbackAutoNumber( "RECRUITMENTAGENCY", AgencyDTO.RecruitmentAgencyId, LoginDTO).ConfigureAwait(false); } throw; } } public async Task DeleteAgency(int RecruitmentAgencyId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-agency", new { RecruitmentAgencyId }); var result = await _AgencyDAL.DeleteAgency(RecruitmentAgencyId, LoginDTO, ct).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( RecruitmentAgencyId, EntityConstant.OBJECTRECRUITMENTAGENCY, CacheKeyLevel.CLIENT_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("Agency {RecruitmentAgencyId} deleted by user {UserId}", RecruitmentAgencyId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-agency-failed", ex); _Logger.LogError(ex, "DeleteAgency failed for RecruitmentAgencyId {RecruitmentAgencyId}", RecruitmentAgencyId); throw; } } } }