using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using Microsoft.Extensions.Logging; using PayRollDAL.CustomeCode.TLeave; using PayRollDAL.DTO.TLeave; using System.Data.Common; using static GB5Shared.GB5Constant.Constant; namespace PayRollBLL.TLeave { public class TLeaveBLL : ITLeaveBLL { private readonly ITLeaveDAL _TLeaveDAL; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly BaseEntityAppService _baseEntityAppService; private readonly VoucherNumberService _voucherNumberService; private readonly ILogger _Logger; public TLeaveBLL( ITLeaveDAL tLeaveDAL, AutoNumber autoNumber, IQueryExecutor queryExecutor, BaseEntityAppService baseEntityAppService, VoucherNumberService voucherNumberService, ILogger logger) { _TLeaveDAL = tLeaveDAL; _AutoNumber = autoNumber; _QueryExecutor = queryExecutor; _baseEntityAppService = baseEntityAppService; _voucherNumberService = voucherNumberService; _Logger = logger; } public async Task SaveTLeave(TLeaveDTO tLeaveDTO, LoginDTO loginDTO) { try { // ── Validate before opening transaction ─────────────────────────────────── ValidateTLeave(tLeaveDTO); // ── Duplicate date check ────────────────────────────────────────────────── // Mirrors GB4: "Records already found for the selected date with Leave No..." // Checks TLEAVEDETAIL for same employee+date, STATUS IN (0,1), leave-request // class only (-1399999955). For updates, the current TLeaveId is excluded so // re-saving the same record does not self-conflict. foreach (var detail in tLeaveDTO.TLeaveDetailArray) { var duplicates = (await _TLeaveDAL.CheckDuplicateLeaveDates( detail.EmployeeId, detail.TLeaveDetailLeaveDate, tLeaveDTO.TLeaveId, // 0 for new → no self-exclusion needed loginDTO).ConfigureAwait(false)).ToList(); if (duplicates.Count > 0) { string leaveNumbers = string.Join(", ", duplicates .Select(d => d.LeaveNumber) .Where(n => !string.IsNullOrWhiteSpace(n)) .Distinct()); throw new InvalidOperationException( $"Records already found for the selected date with Leave No {leaveNumbers}"); } } bool isNew = tLeaveDTO.TLeaveId == 0; // OUId always comes from the authenticated session — never trusted from client payload tLeaveDTO.OUId = loginDTO.WorkOUId; // ── Pre-transaction: AutoNumber ─────────────────────────────────────────── // Allocate a contiguous ID block outside any transaction so the MAUTONUMBER // counter always advances in auto-commit — never rolled back on error. // isNew → 1 (header) + N (details) → StartNumber = header ID // update → N (details only) → StartNumber = first detail ID int idsNeeded = (isNew ? 1 : 0) + tLeaveDTO.TLeaveDetailArray.Count; var autoNumber = await _AutoNumber.GetNumberAsync(idsNeeded, AUTONUMBERCONSTANT.LEAVE, loginDTO); // ── Pre-transaction: Leave Number (VNO) ─────────────────────────────────── // Generated outside the transaction — mirrors GB4's GetNextVno pattern where // the voucher counter always advances regardless of entity save outcome. if (isNew) { string? leaveNumber = await _voucherNumberService.GetNextVoucherNumberAsync( tLeaveDTO.BizTransactionTypeId, tLeaveDTO.TLeaveLeaveDate, loginDTO, transaction: null); // auto-commit: counter never rolled back if (leaveNumber != null) tLeaveDTO.TLeaveLeaveNumber = leaveNumber; } // ── Transaction ─────────────────────────────────────────────────────────── var Trans = await _QueryExecutor.BeginTransactionAsync(loginDTO); try { await _baseEntityAppService.ExecuteSaveAsync( EntityConstant.TLEAVE, EventTypeConstant.SAVETLEAVEEVENTTYPEID, tLeaveDTO, loginDTO, async tx => { // Assign IDs and audit fields if (isNew) { tLeaveDTO.TLeaveId = autoNumber.StartNumber; tLeaveDTO.TLeaveCreatedById = loginDTO.UserId; tLeaveDTO.TLeaveModifiedById = loginDTO.UserId; tLeaveDTO.TLeaveCreatedOn = DateTime.UtcNow; tLeaveDTO.TLeaveVersion = 0; tLeaveDTO.TLeaveStatus = 0; } tLeaveDTO.TLeaveModifiedById = loginDTO.UserId; tLeaveDTO.TLeaveModifiedOn = DateTime.UtcNow; // Detail IDs: isNew → StartNumber + 1, 2, … // update → StartNumber, + 1, … (StartNumber IS the first detail slot) int detailIdBase = isNew ? autoNumber.StartNumber + 1 : autoNumber.StartNumber; for (short i = 0; i < tLeaveDTO.TLeaveDetailArray.Count; i++) { tLeaveDTO.TLeaveDetailArray[i].TLeaveDetailId = detailIdBase + i; tLeaveDTO.TLeaveDetailArray[i].TLeaveId = tLeaveDTO.TLeaveId; tLeaveDTO.TLeaveDetailArray[i].TLeaveDetailSlNo = (short)(i + 1); } // Recompute total days from the submitted detail lines tLeaveDTO.TLeaveNumberOfDays = tLeaveDTO.TLeaveDetailArray .Sum(d => d.TLeaveDetailNumberOfDays); // Populate date-range header fields from detail lines so // ##TLeaveDetailLeaveDate## / ##TLeaveDetailValidTill## resolve // in the GENERATELEAVEMAIL email template. tLeaveDTO.TLeaveDetailLeaveDate = tLeaveDTO.TLeaveDetailArray .Select(d => d.TLeaveDetailLeaveDate) .Where(d => d != default) .DefaultIfEmpty(tLeaveDTO.TLeaveLeaveDate) .Min() .ToString("dd-MMM-yyyy"); tLeaveDTO.TLeaveDetailValidTill = tLeaveDTO.TLeaveDetailArray .Select(d => d.TLeaveDetailValidTill) .Where(d => d != default) .DefaultIfEmpty(tLeaveDTO.TLeaveLeaveDate) .Max() .ToString("dd-MMM-yyyy"); if (isNew) { await _TLeaveDAL.SaveTLeave(tLeaveDTO, loginDTO, tx); } else { // Delete-then-re-insert detail lines (mirrors GB4 DeleteTLeaveDetails + SaveOrUpdate) await _TLeaveDAL.DeleteTLeaveDetails(tLeaveDTO.TLeaveId, loginDTO, tx); await _TLeaveDAL.UpdateTLeave(tLeaveDTO, loginDTO, tx); } foreach (var detail in tLeaveDTO.TLeaveDetailArray) await _TLeaveDAL.SaveTLeaveDetail(detail, loginDTO, tx); return tLeaveDTO.TLeaveId; }, null, // Facts — BuildFacts auto-reflects header + TLeaveDetailArray[0]; no hardcoding needed BIZTRANSACTIONCLASSCONSTANT.LEAVE, BizTransactionConstant.LEAVEREQUEST, Trans, isNewEntity: isNew); await _QueryExecutor.CommitAsync(Trans); _Logger.LogInformation( "TLeave {Action} — Id: {TLeaveId}, LeaveNo: {LeaveNumber}, Employee: {EmployeeId}", isNew ? "Created" : "Updated", tLeaveDTO.TLeaveId, tLeaveDTO.TLeaveLeaveNumber, tLeaveDTO.TLeaveDetailArray[0].EmployeeId); if (tLeaveDTO.MenuBasedResponce == 1) { int leaveTypeId = tLeaveDTO.TLeaveDetailArray[0].LeaveTypeId; // leaveTypeId == -1 means the client sent a string value that was not parsed // (e.g. "LeaveTypeId": "-1500000000" instead of a JSON number). // This is a bad-request condition — surface it immediately so it is never // silently treated as Onduty. if (leaveTypeId == -1 || leaveTypeId == 0) throw new InvalidOperationException( $"LeaveTypeId is not set correctly (received {leaveTypeId}). " + "Ensure the client sends LeaveTypeId as a JSON number, not a string."); byte? leaveType = await _TLeaveDAL.GetLeaveTypeById(leaveTypeId, loginDTO).ConfigureAwait(false); if (leaveType == null) throw new InvalidOperationException( $"Leave configuration not found for LeaveTypeId {leaveTypeId}. " + "Please verify the leave type is correctly set up in MLEAVE."); bool isLeaveType = leaveType == 1 || leaveType == 2 || leaveType == 3 || leaveType == 6 || leaveType == 8; string requestType = isLeaveType ? "Leave" : "Onduty"; return $"{requestType} Request Saved Successfully with the number: {tLeaveDTO.TLeaveLeaveNumber}"; } return isNew ? $"Details saved successfully with Leave/Onduty Number: {tLeaveDTO.TLeaveLeaveNumber} and Id: {tLeaveDTO.TLeaveId}" : $"Details updated successfully with Leave/Onduty Number: {tLeaveDTO.TLeaveLeaveNumber} and Id: {tLeaveDTO.TLeaveId}"; } catch { await _QueryExecutor.RollbackAsync(Trans); throw; } } catch (Exception) { throw; } } public async Task GetTLeave(int tLeaveId, LoginDTO loginDTO) { try { var header = await _TLeaveDAL.GetTLeave(tLeaveId, loginDTO); if (header == null) throw new KeyNotFoundException($"TLeave with ID {tLeaveId} not found."); header.TLeaveDetailArray = await _TLeaveDAL.GetTLeaveDetailsByLeave(tLeaveId, loginDTO); // TLEAVE has no EMPLOYEEID — populate root-level employee fields from the first detail row if (header.TLeaveDetailArray.Count > 0) { var first = header.TLeaveDetailArray[0]; header.EmployeeId = first.EmployeeId; header.EmployeeCode = first.EmployeeCode; header.EmployeeName = first.EmployeeName; } return header; } catch (Exception) { throw; } } public async Task DeleteTLeave(int tLeaveId, int cancelApplicable, LoginDTO loginDTO, CancellationToken ct) { var tLeave = await _TLeaveDAL.GetTLeave(tLeaveId, loginDTO).ConfigureAwait(false); if (tLeave == null) throw new KeyNotFoundException($"TLeave with ID {tLeaveId} not found."); await _baseEntityAppService.ExecuteDeleteAsync( EntityConstant.TLEAVE, tLeaveId, loginDTO, async tx => { if (cancelApplicable == 0) { // Hard delete — remove detail lines first, then the header. // Only pending leaves (status = 0) should be hard-deleted; // approved leaves must be cancelled (cancelApplicable = 1) instead. await _TLeaveDAL.DeleteTLeaveDetails(tLeaveId, loginDTO, tx).ConfigureAwait(false); await _TLeaveDAL.DeleteTLeave(tLeaveId, loginDTO, tx).ConfigureAwait(false); } else { // Soft cancel: set STATUS = 2. // Balance reverts automatically — the balance query counts only // STATUS IN (0, 1), so a cancelled record drops out of the calculation // and the employee's leave balance is restored to its pre-application value. await _TLeaveDAL.UpdateTLeaveStatus(tLeaveId, 2, loginDTO, tx).ConfigureAwait(false); } }, "Leave request deleted"); _Logger.LogInformation( "TLeave {Action} — Id: {TLeaveId}", cancelApplicable == 0 ? "Deleted" : "Cancelled", tLeaveId); return cancelApplicable == 0 ? SuccessResponse.DeleteSuccessMessage : "Leave cancelled successfully. Balance has been reverted."; } // ── Private helpers ─────────────────────────────────────────────────────────── private static void ValidateTLeave(TLeaveDTO dto) { if (dto == null) throw new ArgumentNullException(nameof(dto)); if (dto.TLeaveDetailArray == null || dto.TLeaveDetailArray.Count == 0) throw new ArgumentException("At least one leave detail line is required."); if (dto.TLeaveLeaveDate == default) throw new ArgumentException("Leave Date is required."); foreach (var detail in dto.TLeaveDetailArray) { if (detail.TLeaveDetailNumberOfDays <= 0) throw new ArgumentException("NumberOfDays must be greater than zero."); } } public async Task GetSelectListTLeave(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _TLeaveDAL.GetSelectListTLeave(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task GetTLeaveDetails(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO, bool Count = false) { try { return await _TLeaveDAL.GetTLeaveDetails(FirstNumber, MaxResult, CriteriaDTO, LoginDTO, Count); } catch (Exception) { throw; } } public async Task> GetTotalLeaveTaken( int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO, bool IsCount = false) { try { return await _TLeaveDAL.GetTotalLeaveTaken(FirstNumber, MaxResult, CriteriaDTO, LoginDTO, IsCount); } catch (Exception) { throw; } } // ── Leave Credit Posting ───────────────────────────────────────────────────── // GB4 parity: TLeaveBLL.TLeavePostingProcess. Bulk composite write — one TLEAVE // header + N TLEAVEDETAIL rows across a dynamically filtered employee set — with // no single owning entity DTO, so it does not route through ExecuteSaveAsync. [GB5Shared.Attributes.ManualTransactionJustified( "Bulk multi-employee leave posting — no single owning entity DTO; composite header+N-detail insert across a dynamically filtered employee set.")] public async Task TLeavePostingProcess(TLeaveDTO tLeaveDTO, LoginDTO loginDTO) { if (tLeaveDTO == null) throw new ArgumentNullException(nameof(tLeaveDTO)); if (tLeaveDTO.TLeaveDetailArray == null || tLeaveDTO.TLeaveDetailArray.Count == 0) throw new ArgumentException("At least one leave detail line is required."); if (tLeaveDTO.RegularCreditType < 0 || tLeaveDTO.RegularCreditType > 2) throw new ArgumentException($"Unsupported RegularCreditType: {tLeaveDTO.RegularCreditType}"); tLeaveDTO.OUId = loginDTO.WorkOUId; tLeaveDTO.PeriodId = loginDTO.WorkPeriodId; string? leaveNumber = await _voucherNumberService.GetNextVoucherNumberAsync( tLeaveDTO.BizTransactionTypeId, tLeaveDTO.TLeaveLeaveDate, loginDTO, transaction: null).ConfigureAwait(false); if (leaveNumber != null) tLeaveDTO.TLeaveLeaveNumber = leaveNumber; int eligibleCount = await _TLeaveDAL.CountEligibleEmployeesForPosting(tLeaveDTO, loginDTO).ConfigureAwait(false); if (eligibleCount == 0) return "No eligible employees found for leave credit posting."; var autoNumber = await _AutoNumber.GetNumberAsync(eligibleCount + 1, AUTONUMBERCONSTANT.LEAVE, loginDTO).ConfigureAwait(false); int tLeaveId = autoNumber.StartNumber; int detailStartId = autoNumber.StartNumber + 1; var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); try { await _TLeaveDAL.InsertTLeaveHeaderForPosting(tLeaveDTO, tLeaveId, loginDTO, trans).ConfigureAwait(false); await _TLeaveDAL.InsertTLeaveDetailForPosting(tLeaveDTO, tLeaveId, detailStartId, loginDTO, trans).ConfigureAwait(false); await _QueryExecutor.CommitAsync(trans).ConfigureAwait(false); _Logger.LogInformation( "TLeave Credit Posting completed — LeaveId: {TLeaveId}, LeaveNo: {LeaveNumber}, EligibleEmployees: {Count}", tLeaveId, tLeaveDTO.TLeaveLeaveNumber, eligibleCount); return "Leave Credit Process Done..."; } catch { await _QueryExecutor.RollbackAsync(trans).ConfigureAwait(false); throw; } } // ── Leave Debit Posting ("Make Zero") ──────────────────────────────────────── // GB4 parity: TLeaveBLL.TleavePostingMakingZero. Same composite-write shape as // TLeavePostingProcess above — see its comment for why this bypasses ExecuteSaveAsync. [GB5Shared.Attributes.ManualTransactionJustified( "Bulk multi-employee leave posting — no single owning entity DTO; composite header+N-detail insert across a dynamically filtered employee set.")] public async Task TleavePostingMakingZero(TLeaveDTO tLeaveDTO, LoginDTO loginDTO) { if (tLeaveDTO == null) throw new ArgumentNullException(nameof(tLeaveDTO)); if (tLeaveDTO.TLeaveDetailArray == null || tLeaveDTO.TLeaveDetailArray.Count == 0) throw new ArgumentException("At least one leave detail line is required."); var balances = await _TLeaveDAL.GetLeaveBalanceForMakeZero(tLeaveDTO, loginDTO).ConfigureAwait(false); // GB4 parity: returns this message even when zero balances need zeroing. if (balances.Count == 0) return "Details Saved Successfully"; tLeaveDTO.OUId = loginDTO.WorkOUId; tLeaveDTO.PeriodId = loginDTO.WorkPeriodId; string? leaveNumber = await _voucherNumberService.GetNextVoucherNumberAsync( tLeaveDTO.BizTransactionTypeId, tLeaveDTO.TLeaveLeaveDate, loginDTO, transaction: null).ConfigureAwait(false); if (leaveNumber != null) tLeaveDTO.TLeaveLeaveNumber = leaveNumber; var autoNumber = await _AutoNumber.GetNumberAsync(balances.Count + 1, AUTONUMBERCONSTANT.LEAVE, loginDTO).ConfigureAwait(false); int tLeaveId = autoNumber.StartNumber; var leaveDate = tLeaveDTO.TLeaveDetailArray[0].TLeaveDetailLeaveDate; var details = new List(balances.Count); for (int i = 0; i < balances.Count; i++) { details.Add(new TLeaveDetailDTO { TLeaveDetailId = autoNumber.StartNumber + 1 + i, TLeaveId = tLeaveId, TLeaveDetailSlNo = (short)(i + 1), EmployeeId = balances[i].EmployeeId, LeaveTypeId = balances[i].LeaveTypeId, TLeaveDetailLeaveDate = leaveDate, TLeaveDetailValidTill = leaveDate, TLeaveDetailNumberOfDays = (double)Math.Abs(balances[i].LeaveBalance) }); } var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); try { await _TLeaveDAL.InsertTLeaveHeaderForPosting(tLeaveDTO, tLeaveId, loginDTO, trans).ConfigureAwait(false); await _TLeaveDAL.BulkInsertTLeaveDetailForMakeZero(details, loginDTO, trans).ConfigureAwait(false); await _QueryExecutor.CommitAsync(trans).ConfigureAwait(false); _Logger.LogInformation( "TLeave Debit Posting (Make Zero) completed — LeaveId: {TLeaveId}, LeaveNo: {LeaveNumber}, Employees: {Count}", tLeaveId, tLeaveDTO.TLeaveLeaveNumber, balances.Count); return "Details Saved Successfully"; } catch { await _QueryExecutor.RollbackAsync(trans).ConfigureAwait(false); throw; } } } }