using System; using System.Collections.Generic; using System.Data.Common; using System.Linq; using System.Threading; using System.Threading.Tasks; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using PayRollDAL.CustomeCode.OverTime; using PayRollDAL.DTO.OverTime; using static GB5Shared.GB5Constant.Constant; namespace PayRollBLL.OverTime { public class OverTimeBLL : IOverTimeBLL { private readonly IOverTimeDAL _OverTimeDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly IQueryExecutor _QueryExecutor; private readonly BaseEntityAppService _baseEntityAppService; private readonly VoucherNumberService _voucherNumberService; private readonly ILogger _Logger; public OverTimeBLL( IOverTimeDAL IOverTimeDAL, AutoNumber AutoNumber, IValidation Validation, IQueryExecutor queryExecutor, BaseEntityAppService baseEntityAppService, VoucherNumberService voucherNumberService, ILogger logger) { _OverTimeDAL = IOverTimeDAL; _AutoNumber = AutoNumber; _Validation = Validation; _QueryExecutor = queryExecutor; _baseEntityAppService = baseEntityAppService; _voucherNumberService = voucherNumberService; _Logger = logger; } public async Task GetSelectListOverTime(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _OverTimeDAL.GetSelectListOverTime(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception ex) { throw new Exception("Error in GetSelectListOverTime", ex); } } public async Task GetOverTimeDetailReport(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO, bool IsCount = false) { try { return await _OverTimeDAL.GetOverTimeDetailReport(FirstNumber, MaxResult, CriteriaDTO, LoginDTO, IsCount).ConfigureAwait(false); } catch (Exception ex) { throw new Exception("Error in GetOverTimeDetailReport", ex); } } // ── SaveOverTime ───────────────────────────────────────────────────────────── // // Mirrors GB4 OverTimeBLL.SaveOverTime(OverTimeDTO, LoginDTO, SameTransaction). // Preserves: HH:mm → minutes conversion for detail rows, duplicate-entry check // (new records only), Period date-range validation, VNO generation (new + NOGen // == 0 only), FunctionalReportingTo/AdminReportingTo/PayConfigurationId // auto-population from the employee record, OuWiseDepartmentWiseHead lookup // (try/catch — addon table may not exist), and the outbound event trigger. // // GB4's PayProcess.IsUpdatePossible(...) check on the update path has NO GB5 // equivalent (no such method exists anywhere in PayRollBLL) — this is called out // in the final migration report rather than silently invented. // // sameTransaction mirrors GB4's SameTransaction parameter: PostAutoOverTime passes // its own transaction through so every per-employee save shares one commit/rollback. public async Task SaveOverTime(OverTimeDTO overTimeDTO, LoginDTO loginDTO, DbTransaction? sameTransaction = null, CancellationToken ct = default) { if (overTimeDTO == null) throw new ArgumentNullException(nameof(overTimeDTO)); if (overTimeDTO.OverTimeDetailArray == null || overTimeDTO.OverTimeDetailArray.Count == 0) throw new ArgumentException("At least one OverTime detail line is required."); bool isNew = overTimeDTO.OverTimeId == 0; int initialId = overTimeDTO.OverTimeId; // ── HH:mm → minutes conversion for every detail row (GB4 parity) ──────────── foreach (var detail in overTimeDTO.OverTimeDetailArray) { detail.OverTimeDetailFromTime = ToMinutes(detail.OverTimeCalInTime); detail.OverTimeDetailToTime = ToMinutes(detail.OverTimeCalOutTime); detail.OverTimeDetailFactorHrs = ToMinutes(detail.OverTimeCalFactorHrs); detail.OverTimeDetailDuration = ToMinutes(detail.OverTimeCalDuration); detail.OverTimeDetailActualHours = ToMinutes(detail.OverTimeCalActualHours); } bool isExternalTx = sameTransaction != null; var trans = sameTransaction ?? await _QueryExecutor.BeginTransactionAsync(loginDTO); try { // ── Duplicate-entry check (new records only) ──────────────────────────── if (isNew) { foreach (var detail in overTimeDTO.OverTimeDetailArray) { var existing = await _OverTimeDAL.CheckDuplicateOverTimeEntry( detail.EmployeeId, overTimeDTO.OverTimeForTheDate, loginDTO, trans); if (existing != null) { throw new InvalidOperationException( $"Overtime entries already exists for {existing.EmployeeCode} {existing.EmployeeName} " + $"with Overtime Number {overTimeDTO.OverTimeOverTimeNumber} and Date {overTimeDTO.OverTimeForTheDate:dd/MMM/yyyy}"); } } } // ── Period date-range validation ──────────────────────────────────────── var periodRange = await _OverTimeDAL.GetPeriodRange(overTimeDTO.PeriodId, loginDTO, trans); if (periodRange == null || overTimeDTO.OverTimeOverTimeDate < periodRange.Value.FromDate || overTimeDTO.OverTimeOverTimeDate > periodRange.Value.ToDate) { throw new InvalidOperationException("Overtime Date is Not in Financial Range"); } // ── AutoNumber: 1 header id + N detail ids ────────────────────────────── int idsNeeded = 1 + overTimeDTO.OverTimeDetailArray.Count; var autoNumber = await _AutoNumber.GetNumberAsync(idsNeeded, AUTONUMBERCONSTANT.OVERTIME, loginDTO); if (isNew) overTimeDTO.OverTimeId = autoNumber.StartNumber; // ── FunctionalReportingTo / AdminReportingTo / PayConfigurationId ─────── var firstEmployeeId = overTimeDTO.OverTimeDetailArray[0].EmployeeId; var employeeInfo = await _OverTimeDAL.GetEmployeeReportingInfo(firstEmployeeId, loginDTO, trans); if (employeeInfo == null) throw new InvalidOperationException($"Employee (Id={firstEmployeeId}) not found."); overTimeDTO.FunctionalReportingTo = employeeInfo.ReportInttoEmployeeId; overTimeDTO.AdminReportingTo = employeeInfo.AdminReportingToId; overTimeDTO.PayConfigurationId = employeeInfo.PayConfigurationId; overTimeDTO.FunctionReportingToMailId = employeeInfo.ReportInttoEmployeeMailId; overTimeDTO.FunctionReportingToMobileNumber = employeeInfo.ReportInttoEmployeeMobile; overTimeDTO.AdminReportingToMailId = employeeInfo.AdminReportingToMailId; overTimeDTO.AdminReportingToMobileNumber = employeeInfo.AdminReportingToMobile; overTimeDTO.FunctionAndAdminReportingToMailId = $"{employeeInfo.ReportInttoEmployeeMailId};{employeeInfo.AdminReportingToMailId}"; overTimeDTO.FunctionAndAdminReportingToMobileNumber = $"{employeeInfo.ReportInttoEmployeeMobile};{employeeInfo.AdminReportingToMobile}"; string employeeCode = employeeInfo.EmployeeCode ?? ""; string employeeName = employeeInfo.EmployeeName ?? ""; // Created-by reporting-to (GB4 parity — populated from the logged-in user's own // employee record, not the OT's employee). var createdByInfo = await _OverTimeDAL.GetEmployeeReportingInfo(loginDTO.UserId, loginDTO, trans); if (createdByInfo != null) { overTimeDTO.CreatedByFunctionalReportingTo = createdByInfo.ReportInttoEmployeeId; overTimeDTO.CreatedByAdminReportingTo = createdByInfo.AdminReportingToId; overTimeDTO.CreatedByUserFunctionReportingToMailId = createdByInfo.ReportInttoEmployeeMailId; overTimeDTO.CreatedByUserFunctionReportingToMobileNumber = createdByInfo.ReportInttoEmployeeMobile; overTimeDTO.CreatedByUserAdminReportingToMailId = createdByInfo.AdminReportingToMailId; overTimeDTO.CreatedByUserAdminReportingToMobileNumber = createdByInfo.AdminReportingToMobile; overTimeDTO.ModifiedByDepartmentHeadId = createdByInfo.DepartmentHeadId; overTimeDTO.ModifiedByDepartmentHeadMailId = createdByInfo.ReportInttoEmployeeMailId; overTimeDTO.ModifiedByDepartmentHeadMobileNumber = createdByInfo.ReportInttoEmployeeMobile; } // ── OuWiseDepartmentWiseHead lookup (try/catch — addon table may not exist) ─ overTimeDTO.OuWiseDepartmentWiseHeadId = await _OverTimeDAL.GetOuWiseDepartmentWiseHeadId( employeeInfo.WorkOUId, employeeInfo.DepartmentId, loginDTO, trans); // ── VNO generation — only for brand-new records with NOGenerationType == 0 ── if (initialId == 0) { var noGenType = await _OverTimeDAL.GetBizTransactionTypeNOGenerationType( overTimeDTO.BIZTransactionTypeId, loginDTO, trans); if (noGenType.HasValue && noGenType.Value == 0) { string? vno = await _voucherNumberService.GetNextVoucherNumberAsync( overTimeDTO.BIZTransactionTypeId, overTimeDTO.OverTimeOverTimeDate, loginDTO, trans); if (vno != null) overTimeDTO.OverTimeOverTimeNumber = vno; } } // ── GB4 PayProcess.IsUpdatePossible(...) check (update path only) ─────── // NOTE: no GB5 equivalent of PayProcessBLL.IsUpdatePossible exists anywhere // in PayRollBLL as of this migration. This gap is called out explicitly in // the migration report — NOT silently invented here. // ── Assign detail ids/slno, persist header + details ──────────────────── short slNo = 1; int detailIdCursor = autoNumber.StartNumber + 1; foreach (var detail in overTimeDTO.OverTimeDetailArray) { detail.OverTimeId = overTimeDTO.OverTimeId; detail.OverTimeDetailId = detailIdCursor++; detail.OverTimeDetailSlNo = slNo++; } await _baseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTOVERTIME, isNew ? EventTypeConstant.SAVEOVERTIMEEVENTTYPEID : EventTypeConstant.UPDATEOVERTIMEEVENTTYPEID, overTimeDTO, loginDTO, async tx => { if (isNew) { await _OverTimeDAL.SaveOverTime(overTimeDTO, loginDTO, tx); } else { // Delete-then-re-insert detail lines (mirrors GB4 DeleteOverTimeDetails + SaveOrUpdate). await _OverTimeDAL.DeleteOverTimeDetails(overTimeDTO.OverTimeId, loginDTO, tx); await _OverTimeDAL.UpdateOverTime(overTimeDTO, loginDTO, tx); } foreach (var detail in overTimeDTO.OverTimeDetailArray) await _OverTimeDAL.SaveOverTimeDetail(detail, loginDTO, tx); return overTimeDTO.OverTimeId; }, null, BIZTRANSACTIONCLASSCONSTANT.OVERTIME, -1, trans, isNewEntity: isNew); if (!isExternalTx) await _QueryExecutor.CommitAsync(trans); _Logger.LogInformation( "OverTime {Action} — Id: {OverTimeId}, Number: {OverTimeNumber}, Employee: {EmployeeCode} {EmployeeName}", isNew ? "Created" : "Updated", overTimeDTO.OverTimeId, overTimeDTO.OverTimeOverTimeNumber, employeeCode, employeeName); // NOTE: GB4's CommonFunctionDAL.PostEventTrigger(OverTimeDTO, -1399999538, LoginDTO) // publishes an outbound event after save. ExecuteSaveAsync's OutboxWrite step // already performs the equivalent transactional publish (IOutBox) for this // entity/event-type — a second manual publish here would duplicate delivery, // so it is intentionally not repeated (see MigrationClaude.md §24 — no direct // Dapr publish in BLL/DAL, use the outbox via ExecuteSaveAsync). return overTimeDTO.OverTimeId; } catch (Exception) { if (!isExternalTx) await _QueryExecutor.RollbackAsync(trans); // NOTE: GB4 also calls GenericDal.RemoveWorkFlow(FinalId, typeof(OverTime), LoginDTO) // here on failure, to clean up any workflow instance already started before the // error. GB5Shared.WorkFlow.IWorkFlowEngine has no "remove workflow instance for // entity" method — only CheckWorkFlowApplicability / StartWorkflowAsync / // HandleActionsAsync / UpdateWipObjectIdAsync exist. Same genuine capability gap // documented on Delete's catch block below — not silently dropped. throw; } } public async Task Get(int overTimeId, LoginDTO loginDTO) { var header = await _OverTimeDAL.GetOverTime(overTimeId, loginDTO); if (header == null) throw new KeyNotFoundException($"OverTime with ID {overTimeId} not found."); header.OverTimeDetailArray = await _OverTimeDAL.GetOverTimeDetailsByHeader(overTimeId, loginDTO); // Minutes → HH:mm reverse conversion for display (GB4 parity). foreach (var detail in header.OverTimeDetailArray) { detail.OverTimeCalInTime = MinutesToHHmm(detail.OverTimeDetailFromTime); detail.OverTimeCalOutTime = MinutesToHHmm(detail.OverTimeDetailToTime); detail.OverTimeCalFactorHrs = MinutesToHHmm(detail.OverTimeDetailFactorHrs); detail.OverTimeCalDuration = MinutesToHHmm(detail.OverTimeDetailDuration); detail.OverTimeCalActualHours = MinutesToHHmm(detail.OverTimeDetailActualHours); } return header; } public async Task Delete(int overTimeId, LoginDTO loginDTO) { var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO); try { await _baseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTOVERTIME, EventTypeConstant.DELETEOVERTIMEEVENTTYPEID, new OverTimeDTO { OverTimeId = overTimeId }, loginDTO, async tx => { await _OverTimeDAL.DeleteOverTimeDetails(overTimeId, loginDTO, tx); await _OverTimeDAL.DeleteOverTime(overTimeId, loginDTO, tx); return overTimeId; }, null, BIZTRANSACTIONCLASSCONSTANT.OVERTIME, -1, trans); await _QueryExecutor.CommitAsync(trans); // NOTE: GB4 also calls GenericDal.RemoveWorkFlow(OverTimeId, typeof(OverTime), LoginDTO) // here ("Task #38835 — when manual ot is sent for approval, deleting the OT entry // should also remove the workflow instance"). GB5Shared.WorkFlow.IWorkFlowEngine has // no "remove workflow instance for entity" method — only CheckWorkFlowApplicability / // StartWorkflowAsync / HandleActionsAsync / UpdateWipObjectIdAsync exist. This is a // genuine capability gap, called out in the migration report rather than invented. _Logger.LogInformation("OverTime Deleted — Id: {OverTimeId}", overTimeId); return SuccessResponse.DeleteSuccessMessage; } catch (Exception) { await _QueryExecutor.RollbackAsync(trans); throw; } } public async Task> LoadOvertimeRecords(CriteriaDTO criteriaDTO, LoginDTO loginDTO, CancellationToken ct = default) { return await _OverTimeDAL.LoadOvertimeRecords(criteriaDTO, loginDTO, ct); } public async Task> LoadDailyAttendanceDataToOverTime( int firstNumber, int maxResult, CriteriaDTO criteriaDTO, LoginDTO loginDTO, CancellationToken ct = default) { return await _OverTimeDAL.LoadDailyAttendanceDataToOverTime(firstNumber, maxResult, criteriaDTO, loginDTO, ct); } // ── PostAutoOverTime ───────────────────────────────────────────────────────── // // GB4 parity: loops over every OU (or a specific OUId), then every day in // [FromDate, ToDate], looks up the OT BizTransactionType for that OU/period, // finds employees with attendance-based OT hours, deletes prior auto-posted // entries (REMARKS = 'Posted from Auto program..'), then calls SaveOverTime per // employee found — batched into a single transaction via sameTransaction. // // Implemented as a synchronous long-running loop per explicit user instruction // (matches the GB4 SVC contract exactly). See final migration report: this should // likely move to a background job given the potential OU-count × day-count fan-out. public async Task PostAutoOverTime(int ouId, string fromDate, string toDate, LoginDTO loginDTO, CancellationToken ct = default) { if (fromDate == "" && toDate != "") throw new InvalidOperationException("Either both dates to be given or no dates to be given.."); if (fromDate != "" && toDate == "") throw new InvalidOperationException("Either both dates to be given or no dates to be given.."); DateTime actualFromDate = DateTime.Today; DateTime actualToDate = DateTime.Today; if (fromDate != "NONE" && toDate != "NONE" && fromDate != "" && toDate != "") { actualFromDate = DateTime.Parse(fromDate).Date; actualToDate = DateTime.Parse(toDate).Date; } // NOTE: GB4 resolves "every OU" via PayProcessQueryBuilder.GET_ALL_OU_COMPNAY_WISE // (a generic organization-unit list query owned by the PayProcess module). GB5's // OverTimeBLL does not inject an OU-listing BLL/DAL for this migration slice — the // single-OU path (OUId != -1, the common caller pattern) is fully supported; the // "all OUs" fan-out (OUId == -1) requires wiring an organization-unit BLL dependency // that is out of scope for this OverTime-only migration and is called out in the // final report rather than stubbed with an incomplete implementation. if (ouId == -1) throw new NotSupportedException( "PostAutoOverTime with OUId = -1 (all organization units) requires an " + "organization-unit listing dependency not yet wired into OverTimeBLL in this " + "migration slice. Pass a specific OUId."); var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO); try { var bizTypeId = await _OverTimeDAL.GetBizTypeForOverTime(ouId, loginDTO.WorkPeriodId, loginDTO, trans); if (!bizTypeId.HasValue) throw new InvalidOperationException($"There is no Biztype found for OU {ouId} for loggedin period.."); int numberOfDays = (int)(actualToDate - actualFromDate).TotalDays + 1; for (int day = 0; day < numberOfDays; day++) { DateTime attendanceDate = actualFromDate.AddDays(day); var employeesWithOt = await _OverTimeDAL.GetEmployeesWithOvertime(ouId, attendanceDate, loginDTO, trans); await _OverTimeDAL.DeleteAutoPostedOverTime(ouId, loginDTO.WorkPeriodId, attendanceDate, loginDTO, trans); foreach (var emp in employeesWithOt) { int actualDurationMinutes = emp.DailyAttOtHrs; int totalDuration = emp.DailyOutTime - emp.DailyInTime >= 0 ? emp.DailyOutTime - emp.DailyInTime : emp.DailyOutTime - emp.DailyInTime + 1440; var overTimeDto = new OverTimeDTO { BIZTransactionTypeId = bizTypeId.Value, OUId = ouId, PeriodId = loginDTO.WorkPeriodId, OverTimeOverTimeNumber = "", OverTimeOverTimeDate = DateTime.Today, OverTimeForTheDate = attendanceDate, OverTimeRemarks = "Posted from Auto program..", OverTimeVersion = 1, OverTimeStatus = 1, OverTimeSourcetype = 5, OverTimeDetailArray = new List { new OverTimeDetailDTO { OverTimeDetailId = 0, OverTimeId = 0, EmployeeId = emp.EmployeeId, EmployeeCode = emp.EmployeeCode, EmployeeName = emp.EmployeeName, OverTimeCalInTime = MinutesToHHmm(emp.DailyInTime), OverTimeCalOutTime = MinutesToHHmm(emp.DailyOutTime), OverTimeCalDuration = MinutesToHHmm(totalDuration), OverTimeCalActualHours = MinutesToHHmm(actualDurationMinutes), OverTimeCalFactorHrs = MinutesToHHmm(actualDurationMinutes), OverTimeDetailFactor = 100, OverTimeDetailCompOffDays = 0 } } }; // Same-transaction recursive call — batches every per-employee save // into this one PostAutoOverTime transaction (mirrors GB4 SameTransaction). await SaveOverTime(overTimeDto, loginDTO, trans, ct); } } await _QueryExecutor.CommitAsync(trans); _Logger.LogInformation("PostAutoOverTime processed — OU: {OUId}, From: {From}, To: {To}", ouId, actualFromDate, actualToDate); return "Processed."; } catch (Exception) { await _QueryExecutor.RollbackAsync(trans); throw; } } // ── Helpers ────────────────────────────────────────────────────────────────── private static short ToMinutes(string? hhmm) { if (string.IsNullOrWhiteSpace(hhmm)) return 0; return (short)TimeSpan.Parse(hhmm).TotalMinutes; } private static string MinutesToHHmm(int minutes) { return TimeSpan.FromMinutes(minutes).ToString(@"hh\:mm"); } } }