using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using System.ComponentModel.DataAnnotations; using TMSDAL.CustomCode.TrainingDomain; using TMSDAL.CustomCode.TrainingNeed; using TMSDAL.DTO.TrainingNeed; using TMSDAL.Enums; using static GB5Shared.GB5Constant.Constant; namespace TMSBLL.TrainingNeed { public class TrainingNeedBLL : ITrainingNeedBLL { private readonly ITrainingNeedDAL _TrainingNeedDAL; private readonly ITrainingDomainDAL _TrainingDomainDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly EventLogPublish _EventLog; // State machine: valid transitions for NeedStatus private static readonly Dictionary _needTransitions = new() { [NeedStatus.Draft] = [NeedStatus.Submitted], [NeedStatus.Submitted] = [NeedStatus.UnderReview, NeedStatus.Rejected], [NeedStatus.UnderReview] = [NeedStatus.Approved, NeedStatus.Rejected], [NeedStatus.Approved] = [NeedStatus.Linked, NeedStatus.Closed], [NeedStatus.Linked] = [NeedStatus.Closed], [NeedStatus.Closed] = [], [NeedStatus.Rejected] = [], }; public TrainingNeedBLL( ITrainingNeedDAL trainingNeedDAL, ITrainingDomainDAL trainingDomainDAL, AutoNumber autoNumber, IValidation validation, EventLogPublish eventLog) { _TrainingNeedDAL = trainingNeedDAL; _TrainingDomainDAL = trainingDomainDAL; _AutoNumber = autoNumber; _Validation = validation; _EventLog = eventLog; } public async Task GetTrainingNeed(int trainingNeedId, LoginDTO loginDTO) { try { return await _TrainingNeedDAL.GetTrainingNeed(trainingNeedId, loginDTO); } catch (Exception) { throw; } } public async Task GetTrainingNeedList(byte? needStatus, int? domainId, byte? priority, byte? sourceType, int page, int size, LoginDTO loginDTO) { try { return await _TrainingNeedDAL.GetTrainingNeedList(needStatus, domainId, priority, sourceType, page, size, loginDTO); } catch (Exception) { throw; } } public async Task GetSelectList(CriteriaDTO criteriaDTO, LoginDTO login, CancellationToken ct) => await _TrainingNeedDAL.GetSelectList(criteriaDTO, login, ct); public async Task SaveTrainingNeed(TrainingNeedDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); AutoNumberDTO? autoNumberDTO = null; try { await _Validation.NotEmpty(dto.NeedTitle, nameof(dto.NeedTitle)); if (dto.TrainingDomainId == 0) throw new ValidationException("TrainingDomainId is required."); dto.TenantId = loginDTO.ClientId; dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; // Normalize optional FK fields — only 0 means "not selected", convert to null if (dto.EmployeeId == 0) dto.EmployeeId = null; if (dto.DepartmentId == 0) dto.DepartmentId = null; if (dto.SourceRefId == 0) dto.SourceRefId = null; if (dto.ApprovedById == 0) dto.ApprovedById = null; // RAISEDBYID is NOT NULL FK to MEMPLOYEE — must be provided by FE if (dto.RaisedById == 0) throw new ValidationException("RaisedById is required."); if (dto.TrainingNeedId == 0) { dto.NeedStatus = (byte)NeedStatus.Draft; autoNumberDTO = await _AutoNumber.GetNumberAsync(1, "TRAININGNEED", loginDTO); dto.TrainingNeedId = autoNumberDTO.StartNumber; dto.CreatedById = loginDTO.UserId; dto.CreatedOn = DateTime.UtcNow; await _TrainingNeedDAL.SaveTrainingNeed(dto, loginDTO); return string.Format(SuccessResponse.SaveSuccessWithCodeAndName, dto.NeedCode, dto.NeedTitle); } else { // Only Draft records can be updated via Save byte currentStatus = await _TrainingNeedDAL.GetCurrentNeedStatus(dto.TrainingNeedId, loginDTO); if (currentStatus != (byte)NeedStatus.Draft) throw new ValidationException("Only Draft training needs can be updated. Use status-change endpoints for workflow transitions."); await _TrainingNeedDAL.UpdateTrainingNeed(dto, loginDTO); return string.Format(SuccessResponse.UpdateSuccessWithCodeAndName, dto.NeedCode, dto.NeedTitle); } } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception) { if (autoNumberDTO != null && dto.TrainingNeedId > 0) await _AutoNumber.RollbackAutoNumber("TRAININGNEED", dto.TrainingNeedId, loginDTO); throw; } } public async Task DeleteTrainingNeed(int trainingNeedId, LoginDTO loginDTO) { try { return await _TrainingNeedDAL.DeleteTrainingNeed(trainingNeedId, loginDTO); } catch (Exception) { throw; } } public async Task SubmitTrainingNeed(SubmitTrainingNeedDTO dto, LoginDTO loginDTO) { try { var current = (NeedStatus)await _TrainingNeedDAL.GetCurrentNeedStatus(dto.TrainingNeedId, loginDTO); var requested = (NeedStatus)dto.RequestedStatus; ValidateNeedStatusTransition(current, requested); await _TrainingNeedDAL.UpdateNeedStatus(dto, loginDTO); return $"Training need status updated to {requested}."; } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception) { throw; } } private static void ValidateNeedStatusTransition(NeedStatus current, NeedStatus requested) { if (!_needTransitions.TryGetValue(current, out var allowed) || !allowed.Contains(requested)) throw new ValidationException($"Invalid status transition: {current} → {requested}"); } public async Task ProposeTrainingNeed(ProposeTrainingNeedDTO dto, LoginDTO loginDTO, CancellationToken ct) { if (dto == null) throw new ArgumentNullException(nameof(dto)); try { GB5Trace.Step("validate-propose-training-need", new { dto.SourceRefEntity, dto.SourceRefId, dto.NeedSourceType }); await _Validation.NotEmpty(dto.NeedTitle, nameof(dto.NeedTitle)); await _Validation.NotEmpty(dto.SourceRefEntity, nameof(dto.SourceRefEntity)); if (dto.SourceRefId == 0) throw new ValidationException($"{nameof(dto.SourceRefId)} is required."); // Idempotent: a producer may call this repeatedly as it re-detects the same // underlying condition (e.g. every roster recompute) — never create a second // open Training Need for the same (source, need-type) pair. var existingId = await _TrainingNeedDAL.FindOpenNeedIdBySource( dto.SourceRefEntity, dto.SourceRefId, dto.NeedSourceType, loginDTO, ct).ConfigureAwait(false); if (existingId != 0) { GB5Trace.Step("propose-training-need-deduped", new { TrainingNeedId = existingId, dto.SourceRefEntity, dto.SourceRefId }); return existingId; } var trainingDomainId = dto.TrainingDomainId; if (trainingDomainId == 0) trainingDomainId = await _TrainingDomainDAL.GetDefaultTrainingDomainId(loginDTO, ct).ConfigureAwait(false); if (trainingDomainId == 0) throw new ValidationException("No Training Domain is configured for this tenant — configure at least one before Training Needs can be auto-raised."); var trainingNeedDto = new TrainingNeedDTO { NeedTitle = dto.NeedTitle, Description = dto.Description, TrainingDomainId = trainingDomainId, NeedSourceType = dto.NeedSourceType, SourceRefEntity = dto.SourceRefEntity, SourceRefId = dto.SourceRefId, Priority = dto.Priority, AudienceType = (byte)(dto.EmployeeId is not null ? AudienceType.Individual : dto.DepartmentId is not null ? AudienceType.Department : AudienceType.Custom), EmployeeId = dto.EmployeeId, DepartmentId = dto.DepartmentId, IsMandatory = dto.IsMandatory, ComplianceDeadline = dto.ComplianceDeadline, Remarks = dto.Remarks, RaisedById = -1 // system-raised — no human "raised" this, a detection rule did }; GB5Trace.Step("propose-training-need", new { dto.SourceRefEntity, dto.SourceRefId, dto.NeedSourceType }); await SaveTrainingNeed(trainingNeedDto, loginDTO).ConfigureAwait(false); GB5Trace.Step("event-publish", new { EventTypeId = EventTypeConstant.TMSTRAININGNEEDRAISEDEVENTTYPEID }); await _EventLog.PublishEventLogAsync( "Training Need Auto-Raised", new { trainingNeedDto.TrainingNeedId, dto.NeedSourceType, dto.SourceRefEntity, dto.SourceRefId }, EventTypeConstant.TMSTRAININGNEEDRAISEDEVENTTYPEID, trainingNeedDto.TrainingNeedId, loginDTO, ct: ct).ConfigureAwait(false); return trainingNeedDto.TrainingNeedId; } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("propose-training-need-failed", ex); throw; } } } }