using System; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using RecruitmentDAL.DTO.JobAdSpend; 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 RecruitmentDAL.CustomeCode.JobAdSpend; using static GB5Shared.GB5Constant.Constant; namespace RecruitmentBLL.JobAdSpend { // Entity CRUD for TJOBADSPEND — programmatic job-ad spend tracking per JobRequisition x // job-board channel (Phase 6 slice of /Users/venkatv/.claude/plans/can-you-check-the-precious- // sunbeam.md, extending Phase 1's multi-channel publish with cost/performance-per-channel // reporting; feeds the new JobAdSpendPerformance report). BgVerificationBLL-style // single-entity save pipeline (ExecuteSaveAsync, GB5Trace, ILogger, i18n, tenant filtering), // per CLAUDE.md. Recorded as a standalone save — see the Phase 6 JobAdSpend migration // header for why no existing GOP publish transaction exists yet to hook into. public class JobAdSpendBLL : IJobAdSpendBLL { private readonly IJobAdSpendDAL _JobAdSpendDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly KeyInvalidate _KeyInvalidate; private readonly BaseEntityAppService _BaseEntityAppService; private readonly ILogger _Logger; public JobAdSpendBLL( IJobAdSpendDAL jobAdSpendDAL, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, BaseEntityAppService baseEntityAppService, ILogger logger) { _JobAdSpendDAL = jobAdSpendDAL; _AutoNumber = autoNumber; _Validation = validation; _KeyInvalidate = keyInvalidate; _BaseEntityAppService = baseEntityAppService; _Logger = logger; } public async Task GetJobAdSpend(int JobAdSpendId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _JobAdSpendDAL.GetJobAdSpend(JobAdSpendId, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-jobadspend-failed", ex); _Logger.LogError(ex, "GetJobAdSpend failed for JobAdSpendId {JobAdSpendId}", JobAdSpendId); throw; } } public async Task GetSelectListJobAdSpend(int JobRequisitionId, LoginDTO LoginDTO, CancellationToken ct) { try { return await _JobAdSpendDAL.GetSelectListJobAdSpend(JobRequisitionId, LoginDTO, ct) .ConfigureAwait(false); } catch (Exception ex) { GB5Trace.MarkFailed("get-selectlist-jobadspend-failed", ex); _Logger.LogError(ex, "GetSelectListJobAdSpend failed for JobRequisitionId {JobRequisitionId}", JobRequisitionId); throw; } } public async Task SaveJobAdSpend(JobAdSpendDTO JobAdSpendDTO, LoginDTO LoginDTO, CancellationToken ct) { if (JobAdSpendDTO == null) throw new ArgumentNullException(nameof(JobAdSpendDTO)); AutoNumberDTO? autoNumberDTO = null; try { GB5Trace.Step("validate-jobadspend", new { JobAdSpendDTO.JobAdSpendId }); if (JobAdSpendDTO.JobRequisitionId <= 0) throw new ValidationException("JobRequisitionId is required for a job-ad spend record."); if (JobAdSpendDTO.ChannelType < JobAdSpendChannelType.Website || JobAdSpendDTO.ChannelType > JobAdSpendChannelType.DirectManual) throw new ValidationException($"ChannelType must be 1-6. Received: {JobAdSpendDTO.ChannelType}."); if (string.IsNullOrWhiteSpace(JobAdSpendDTO.JobBoardName)) throw new ValidationException("JobBoardName is required for a job-ad spend record."); if (JobAdSpendDTO.SpendAmount < 0) throw new ValidationException($"SpendAmount cannot be negative. Received: {JobAdSpendDTO.SpendAmount}."); if (string.IsNullOrWhiteSpace(JobAdSpendDTO.CurrencyCode)) throw new ValidationException("CurrencyCode is required for a job-ad spend record."); JobAdSpendDTO.TenantId = LoginDTO.ClientId; JobAdSpendDTO.ModifiedById = LoginDTO.UserId; JobAdSpendDTO.ModifiedOn = DateTime.UtcNow; bool isNew = JobAdSpendDTO.JobAdSpendId == 0; if (isNew) { autoNumberDTO = await _AutoNumber.GetNumberAsync( 1, "JOBADSPEND", LoginDTO).ConfigureAwait(false); JobAdSpendDTO.JobAdSpendId = autoNumberDTO.StartNumber; JobAdSpendDTO.CreatedById = LoginDTO.UserId; JobAdSpendDTO.CreatedOn = DateTime.UtcNow; } GB5Trace.Step("save-jobadspend", new { JobAdSpendDTO.JobAdSpendId, isNew }); await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTRECRUITMENTJOBADSPEND, isNew ? EventTypeConstant.SAVERECRUITMENTJOBADSPENDEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTJOBADSPENDEVENTTYPEID, JobAdSpendDTO, LoginDTO, async tx => { _ = isNew ? await _JobAdSpendDAL.SaveJobAdSpend(JobAdSpendDTO, LoginDTO, tx, ct).ConfigureAwait(false) : await _JobAdSpendDAL.UpdateJobAdSpend(JobAdSpendDTO, LoginDTO, tx, ct).ConfigureAwait(false); return JobAdSpendDTO.JobAdSpendId; }, null, -1, -1, null, isNew).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = isNew ? EventTypeConstant.SAVERECRUITMENTJOBADSPENDEVENTTYPEID : EventTypeConstant.UPDATERECRUITMENTJOBADSPENDEVENTTYPEID }); // Financial/spend data — NOT_REQUIRED per CLAUDE.md's caching rules, never cached // at CLIENT_LEVEL like this module's other master/reference entities. var cacheKey = new CacheKeyGeneration().KeyGeneration( JobAdSpendDTO.JobAdSpendId, EntityConstant.OBJECTRECRUITMENTJOBADSPEND, CacheKeyLevel.NOT_REQUIRED, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation( "JobAdSpend {JobAdSpendId} {Action} by user {UserId}", JobAdSpendDTO.JobAdSpendId, isNew ? "saved" : "updated", LoginDTO.UserId); return isNew ? $"{SuccessResponse.SaveSuccessMessage} {JobAdSpendDTO.JobAdSpendId}" : $"{SuccessResponse.UpdateSuccessMessage} {JobAdSpendDTO.JobAdSpendId}"; } catch (ValidationException vex) { GB5Trace.MarkFailed("save-jobadspend-failed", vex); if (autoNumberDTO != null && JobAdSpendDTO.JobAdSpendId > 0) await _AutoNumber.RollbackAutoNumber("JOBADSPEND", JobAdSpendDTO.JobAdSpendId, LoginDTO).ConfigureAwait(false); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("save-jobadspend-failed", ex); _Logger.LogError(ex, "SaveJobAdSpend failed for JobAdSpendId {JobAdSpendId}", JobAdSpendDTO.JobAdSpendId); if (autoNumberDTO != null && JobAdSpendDTO.JobAdSpendId > 0) { await _AutoNumber.RollbackAutoNumber( "JOBADSPEND", JobAdSpendDTO.JobAdSpendId, LoginDTO).ConfigureAwait(false); } throw; } } public async Task DeleteJobAdSpend(int JobAdSpendId, LoginDTO LoginDTO, CancellationToken ct) { try { GB5Trace.Step("delete-jobadspend", new { JobAdSpendId }); var result = await _JobAdSpendDAL.DeleteJobAdSpend(JobAdSpendId, LoginDTO, ct) .ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( JobAdSpendId, EntityConstant.OBJECTRECRUITMENTJOBADSPEND, CacheKeyLevel.NOT_REQUIRED, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); _Logger.LogInformation("JobAdSpend {JobAdSpendId} deleted by user {UserId}", JobAdSpendId, LoginDTO.UserId); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-jobadspend-failed", ex); _Logger.LogError(ex, "DeleteJobAdSpend failed for JobAdSpendId {JobAdSpendId}", JobAdSpendId); throw; } } } }