using System; using System.Collections.Generic; using System.Data.Common; using System.Threading.Tasks; using CRMDAL.CustomCode.Task; using CRMDAL.DTO.Task; using CRMDAL.Query.Task; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using static GB5Shared.GB5Constant.Constant; namespace CRMBLL.Task { public class TaskBLL : ITaskBLL { private readonly ITaskDAL _TaskDAL; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly BaseEntityAppService _baseEntityAppService; private readonly DocumentNumberService _documentNumberService; public TaskBLL( ITaskDAL TaskDAL, AutoNumber AutoNumber, IQueryExecutor IQueryExecutor, BaseEntityAppService baseEntityAppService, DocumentNumberService documentNumberService) { _TaskDAL = TaskDAL; _AutoNumber = AutoNumber; _QueryExecutor = IQueryExecutor; _baseEntityAppService = baseEntityAppService; _documentNumberService = documentNumberService; } public async Task GetSelectListTask(int firstNumber, int maxResult, CriteriaDTO criteriaDTO, LoginDTO LoginDTO, bool isCount = false) { try { if (!isCount && firstNumber <= 0 && maxResult <= 0) { firstNumber = 0; maxResult = 10; } return await _TaskDAL.GetSelectListTask(firstNumber, maxResult, criteriaDTO, LoginDTO, isCount); } catch (Exception) { throw; } } public async Task GetTask(int TaskId, LoginDTO LoginDTO) { try { return await _TaskDAL.GetTask(TaskId, LoginDTO); } catch (Exception) { throw; } } public async Task SaveTask(List TaskDTOs, LoginDTO LoginDTO) { if (TaskDTOs == null || TaskDTOs.Count == 0) throw new ArgumentException("At least one Task is required."); var results = new List(TaskDTOs.Count); foreach (var dto in TaskDTOs) results.Add(await SaveSingleTask(dto, LoginDTO)); return string.Join(" | ", results); } private async Task SaveSingleTask(TaskDTO TaskDTO, LoginDTO LoginDTO) { // ── Validate before opening transaction ──────────────────────────────────── ValidateTask(TaskDTO); // ── Pre-save business logic ──────────────────────────────────────────────── // Default IsSimpleTask when neither workflow flag is explicitly set if (TaskDTO.IsSimpleTask == 0 && TaskDTO.TaskWorkFlowRequired == 0) { TaskDTO.IsSimpleTask = 1; TaskDTO.TaskWorkFlowRequired = 0; } // Build notification email list from all involved parties TaskDTO.TaskUserMail = LoginDTO.UserPrimaryMailId ?? string.Empty; if (!string.IsNullOrEmpty(TaskDTO.InChargePrimaryEMailId) && TaskDTO.InChargePrimaryEMailId != "NONE") TaskDTO.TaskUserMail += ";" + TaskDTO.InChargePrimaryEMailId; if (!string.IsNullOrEmpty(TaskDTO.AssignedToEmployeePrimaryEMailId)) TaskDTO.TaskUserMail += ";" + TaskDTO.AssignedToEmployeePrimaryEMailId; bool isNew = TaskDTO.TaskId == 0; // ── Pre-transaction: AutoNumber + Task Number ────────────────────────────── // Both are fetched BEFORE BeginTransactionAsync so their counters commit // immediately in auto-commit and are never rolled back if the outer // transaction later fails. This matches GB4's GetNextVnoWithSameSession // pattern where the VNO is always advanced regardless of entity save outcome. var autoNumber = isNew ? await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.TASK, LoginDTO) : null; // Generate a task number that does not already exist in TTASK. // The loop self-heals orphaned rows (left by a prior code bug where TTASK // committed in auto-commit but MBIZTRANSACTIONKEYS rolled back, locking the // counter at the same value forever). In normal operation only one iteration // executes. Mirrors GB4: Task.Number = 'T' + GetNextVnoWithSameSession(...) if (isNew && LoginDTO.WipApprovalId == 0) { string? taskNumber = await _documentNumberService.GenerateUniqueTaskNumberAsync( TaskDTO.BizTransactionTypeId, TaskDTO.TaskDate, TaskDTO.TaskDetailType, TaskQB.CHECK_TASK_NUMBER_EXISTS, LoginDTO); if (taskNumber != null) TaskDTO.TaskNumber = taskNumber; } if (isNew) { // Assign the pre-fetched TaskId TaskDTO.TaskId = autoNumber!.StartNumber; // Capture baseline planning values at creation time // (used later for variance tracking — mirrors GB4 OriginalPlannedHours/Date) TaskDTO.TaskOriginalPlannedHours = TaskDTO.TaskPlannedHours; TaskDTO.TaskOriginalPlannedDate = TaskDTO.TaskPlanStartOn; // TaskNumber already set above via GenerateUniqueTaskNumberAsync } // ── Transaction ──────────────────────────────────────────────────────────── var Trans = await _QueryExecutor.BeginTransactionAsync(LoginDTO); try { // WIP final approval: entity is being persisted for the first time as approved. // Force STATUS = 1 (Active) — the record was held in WIP, not the live table. if (LoginDTO.WipApprovalId > 0) TaskDTO.TaskStatus = 1; if (TaskDTO.TaskWorkFlowRequired == 1) { // Workflow approval not required — persist directly, no workflow pipeline. _ = isNew ? await _TaskDAL.SaveTask(TaskDTO, LoginDTO, Trans) : await _TaskDAL.UpdateTask(TaskDTO, LoginDTO, Trans); } else { await _baseEntityAppService.ExecuteSaveAsync( EntityConstant.TASK, EventTypeConstant.SAVETASKEVENTTYPEID, TaskDTO, LoginDTO, async tx => { _ = isNew ? await _TaskDAL.SaveTask(TaskDTO, LoginDTO, tx) : await _TaskDAL.UpdateTask(TaskDTO, LoginDTO, tx); return TaskDTO.TaskId; }, new Dictionary { { "ParentTaskId", TaskDTO.ParentTaskId } }, BIZTRANSACTIONCLASSCONSTANT.TASK, BizTransactionConstant.TASK, Trans, isNewEntity: isNew); } await _QueryExecutor.CommitAsync(Trans); return isNew ? $"Details saved successfully. Task Number: {TaskDTO.TaskNumber}" : $"Details updated successfully. Task Number: {TaskDTO.TaskNumber}"; } catch (Exception) { await _QueryExecutor.RollbackAsync(Trans); throw; } } /// /// Validates task input before the transaction opens. /// Per GB5 architecture, all validation must occur before BeginTransactionAsync. /// Mirrors GB4's ValidationCheckForTask. /// private static void ValidateTask(TaskDTO TaskDTO) { if (string.IsNullOrEmpty(TaskDTO.TaskDescription) || TaskDTO.TaskDescription == "NONE") throw new ArgumentException("Task Description cannot be empty."); if (TaskDTO.TaskPlanStartOn > TaskDTO.TaskPlanEndOn) throw new ArgumentException("Task Start Date should be less than End Date."); if (TaskDTO.TaskPlanStartOn > TaskDTO.TaskDueOn) throw new ArgumentException("Task Start Date should be less than Due Date."); if (TaskDTO.TaskPlanStartOn > TaskDTO.TaskExpireOn) throw new ArgumentException("Task Start Date should be less than Expire Date."); if (TaskDTO.TaskDueOn > TaskDTO.TaskExpireOn) throw new ArgumentException("Task Due Date should be less than Expiry Date."); // RollDown type (2) requires planned hours to be set if (TaskDTO.TaskRollType == 2 && TaskDTO.TaskPlannedHours == 0) throw new ArgumentException("Planned Hours cannot be zero."); if (TaskDTO.BizTransactionTypeId == -1) throw new ArgumentException("Please assign a Biztransaction Type."); if (TaskDTO.BizTransactionTypeId == 0) throw new ArgumentException("Please check Biztransaction Type — BizTransactionTypeId is Zero."); } public async Task DeleteTask(int TaskId, LoginDTO LoginDTO) { try { return await _baseEntityAppService.ExecuteDeleteAsync( EntityConstant.TASK, TaskId, LoginDTO, tx => _TaskDAL.DeleteTask(TaskId, LoginDTO, tx), "Task deleted"); } catch (Exception) { throw; } } public async Task> GetParentTaskPlannedHours(int ParentTaskId, LoginDTO LoginDTO) { try { return await _TaskDAL.GetParentTaskPlannedHours(ParentTaskId, LoginDTO); } catch (Exception) { throw; } } public async Task> GetTaskListOnActivity( CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { int essValidation = 0; bool hasActionDate = false; if (CriteriaDTO?.SectionCriteriaList?.Count > 0) { foreach (var attr in CriteriaDTO.SectionCriteriaList[0].AttributesCriteriaList) { string field = attr.FieldName?.Trim().ToLower() ?? string.Empty; if (field == "essvalidation") { if (attr.FieldValue != null) { int.TryParse(attr.FieldValue.ToString(), out essValidation); } } if (field == "actiondate") { string? actionDateValue = attr.FieldValue?.ToString(); if (!string.IsNullOrWhiteSpace(actionDateValue)) { // Supports yyyy-MM-dd (DateOnly format) if (DateOnly.TryParse(actionDateValue, out _)) { hasActionDate = true; } else if (DateTime.TryParse(actionDateValue, out _)) { hasActionDate = true; } else { throw new ArgumentException( $"Invalid ActionDate format: {actionDateValue}. Expected format is yyyy-MM-dd."); } } } } } if (!hasActionDate && essValidation == 0) { throw new ArgumentException("Please Select The Activity Date"); } try { return await _TaskDAL.GetTaskListOnActivity(CriteriaDTO, LoginDTO); } catch { throw; } } public async Task GetTaskReport(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO, bool IsCount) { try { return await _TaskDAL.GetTaskReport(FirstNumber, MaxResult, CriteriaDTO, LoginDTO, IsCount); } catch (Exception ex) { throw new Exception("Error in GetTaskReport", ex); } } public async Task GetWorkBench(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO, bool IsCount = false) { try { return await _TaskDAL.GetWorkBench(FirstNumber, MaxResult, CriteriaDTO, LoginDTO, IsCount); } catch (Exception ex) { throw new Exception("Error in GetWorkBench", ex); } } // ── UpdateTaskData ─────────────────────────────────────────────────────────── // // Mirrors GB4 TaskDAL.UpdateTaskData: per-task field/status update, parent-date // cascade, child-status cascade (Milestone/Heading/Work), workflow/approval via // ExecuteSaveAsync (replaces GB4's manual CallWorkFlowSetting + reading-fail-count // approval-text building — ExecuteSaveAsync's own audit trail supersedes GB4's // hand-built EventLogDTO text), and TCOMMENT posting from TaskCommandRemarks. // // GB4's mail-triggering region (CommonFunctionDAL.PostEventTrigger after commit) // is superseded by ExecuteSaveAsync's outbox publish — see SaveTask's identical // rationale (MigrationClaude.md §24: no direct Dapr publish in BLL, use the outbox). public async Task UpdateTaskData(List TaskDTOs, LoginDTO loginDTO) { if (TaskDTOs == null || TaskDTOs.Count == 0) throw new ArgumentException("At least one Task is required."); var Trans = await _QueryExecutor.BeginTransactionAsync(loginDTO); try { foreach (var taskDTO in TaskDTOs) { await _baseEntityAppService.ExecuteSaveAsync( EntityConstant.TASK, EventTypeConstant.UPDATETASKEVENTTYPEID, taskDTO, loginDTO, async tx => { await _TaskDAL.UpdateTaskDataSingle(taskDTO, loginDTO, tx); // ── Parent date cascade ───────────────────────────────────────── if (taskDTO.ParentTaskId > 0) { await _TaskDAL.UpdateParentTaskDates( taskDTO.ParentTaskId, taskDTO.TaskPlanEndOn, taskDTO.TaskDueOn, taskDTO.TaskExpireOn, loginDTO, tx); } // ── Child status cascade ──────────────────────────────────────── // Only fires when this task's own status is Closed (4) or // ForceClosed (5) — mirrors GB4's ToUpdateChilds-TaskStatus region. if (taskDTO.TaskOriginalStatus == 4 || taskDTO.TaskOriginalStatus == 5) { await _TaskDAL.CascadeChildTaskStatus( taskDTO.TaskId, taskDTO.TaskDetailType, taskDTO.TaskOriginalStatus, loginDTO, tx); } return taskDTO.TaskId; }, new Dictionary { { "ParentTaskId", taskDTO.ParentTaskId } }, BIZTRANSACTIONCLASSCONSTANT.TASK, BizTransactionConstant.TASK, Trans, isNewEntity: false); // ── Comment posting ───────────────────────────────────────────────── if (!string.IsNullOrWhiteSpace(taskDTO.TaskCommandRemarks)) { var commentAutoNumber = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.TCOMMENT, loginDTO); await _TaskDAL.PostTaskComment( commentAutoNumber.StartNumber, taskDTO.TaskId, taskDTO.TaskCommandRemarks, loginDTO, Trans); } } await _QueryExecutor.CommitAsync(Trans); return "Details Updated Successfully"; } catch (Exception) { await _QueryExecutor.RollbackAsync(Trans); throw; } } public async Task> GetMilestonePicklist(CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _TaskDAL.GetMilestonePicklist(CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task> GetHeadingPicklist(CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _TaskDAL.GetHeadingPicklist(CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task> GetRollupPicklist(CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _TaskDAL.GetRollupPicklist(CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task> GetWorkPicklist(CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _TaskDAL.GetWorkPicklist(CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task> GetParentPicklist(CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _TaskDAL.GetParentPicklist(CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } // GB4 parity: Task.svc/GenerateTask -> TaskBLL.SaveTaskBasedOnQuery/SaveTaskNew. // Walks each eligible recurring asset-activity's CRON expression forward to ToDate, // builds one TaskDTO per occurrence, and persists them through the existing SaveTask // pipeline (which already owns TaskId/TaskNumber generation, validation, and the // workflow-vs-direct-save branching) instead of GB4's bespoke temp-table SQL. // // Deviation from GB4: runs synchronously and returns a summary instead of firing an // async job with an EventLog + email notification — GB5 has no mail service yet, and // this keeps the operation's result visible to the caller instead of silent. public async Task GenerateTask(DateOnly ToDate, byte TaskNature, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { byte taskType = ExtractTaskType(CriteriaDTO); var eligibleActivities = (await _TaskDAL.GetTaskQuery(taskType, CriteriaDTO, LoginDTO)).ToList(); int? bizTransactionTypeId = await _TaskDAL.GetBizTransactionTypeForTaskGeneration( BIZTRANSACTIONCLASSCONSTANT.TASK, LoginDTO); if (bizTransactionTypeId == null) throw new ArgumentException( "No active BizTransactionType is configured for Task generation in the current OU/Period."); DateTime toDateTime = ToDate.ToDateTime(TimeOnly.MaxValue); var generatedTasks = new List(); var lastGeneratedTillByActivity = new Dictionary(); foreach (var activity in eligibleActivities) { DateTime startDate = activity.LastGeneratedTill.Year <= 1900 ? DateTime.UtcNow : activity.LastGeneratedTill; List occurrences; try { occurrences = CronOccurrenceCalculator.GetNextOccurrences(activity.CronExpression, startDate, toDateTime); } catch (Exception) { // Matches GB4: an unparsable CRON expression is skipped, not fatal — // it is simply excluded from this run's LastGeneratedTill advance. continue; } if (occurrences.Count == 0) continue; foreach (var occurrence in occurrences) generatedTasks.Add(BuildGeneratedTaskDTO( activity, occurrence, TaskNature, bizTransactionTypeId.Value, LoginDTO)); DateTime maxOccurrence = occurrences[^1]; lastGeneratedTillByActivity[activity.AssetActivityId] = lastGeneratedTillByActivity.TryGetValue(activity.AssetActivityId, out var existing) && existing > maxOccurrence ? existing : maxOccurrence; } if (generatedTasks.Count == 0) return "No tasks were due for generation."; await SaveTask(generatedTasks, LoginDTO); foreach (var (assetActivityId, lastGeneratedTill) in lastGeneratedTillByActivity) await _TaskDAL.UpdateAssetActivityLastGeneratedTill( assetActivityId, DateOnly.FromDateTime(lastGeneratedTill), LoginDTO); return $"{generatedTasks.Count} task(s) generated across {lastGeneratedTillByActivity.Count} asset-activity(ies)."; } private static byte ExtractTaskType(CriteriaDTO CriteriaDTO) { int taskType = -1; if (CriteriaDTO?.SectionCriteriaList != null) { foreach (var section in CriteriaDTO.SectionCriteriaList) { foreach (var attr in section.AttributesCriteriaList) { if (attr.FieldName.Equals("tasktype", StringComparison.OrdinalIgnoreCase) && int.TryParse(attr.FieldValue?.ToString(), out int parsedTaskType)) taskType = parsedTaskType; } } } if (taskType is < 0 or > 3) throw new ArgumentException( "Please select a Type. Supported Types are Asset, Machine, Resource, Work."); return (byte)taskType; } private static TaskDTO BuildGeneratedTaskDTO( TaskQueryDTO activity, DateTime occurrence, byte taskNature, int bizTransactionTypeId, LoginDTO LoginDTO) { DateTime planEndOnDateTime = occurrence.AddMinutes(activity.Duration); string endLabel = planEndOnDateTime.ToString("dd MMM yyyy HH:mm:ss").Replace(" ", "-"); string natureName = taskNature switch { 1 => activity.AssetName, 2 => activity.MachineName, 3 => activity.ResourceName, _ => "*" }; string natureNameWithCode = taskNature switch { 1 => $"{activity.AssetName} {activity.AssetCode}", 2 => activity.MachineName, 3 => activity.ResourceName, _ => "*" }; (DateOnly planEndOn, DateOnly dueOn, DateOnly expireOn) = ComputeScheduleDates(activity, occurrence); byte assignedToType = activity.TaskAssignedType switch { 0 => 3, 1 => 2, 2 => 1, 3 => 4, _ => 0 }; var taskDto = new TaskDTO { BizTransactionTypeId = bizTransactionTypeId, OuId = LoginDTO.WorkOUId, PeriodId = LoginDTO.WorkPeriodId, TaskDate = DateOnly.FromDateTime(occurrence), TaskTaskName = $"{natureName}{activity.ActivityName} {endLabel}", TaskDescription = $"{natureNameWithCode} {activity.PartyBranchLocationName} {activity.ActivityName} {endLabel}", TaskDetailType = 4, TaskTaskNature = taskNature, PartyBranchId = activity.PartyBranchId, AssetId = activity.AssetId, ResourceId = activity.ResourceId, MachineId = activity.MachineId, ActivityId = activity.ActivityId, TaskWorkType = -1, TaskSlNo = 1, TaskAssignedToType = assignedToType, AssignedToRoleId = activity.TaskAssignedType == 2 ? activity.TaskUserId : -1, AssignedToUserId = activity.TaskAssignedType == 0 ? activity.TaskUserId : -1, AssignedToUserGroupId = activity.TaskAssignedType == 1 ? activity.TaskUserId : -1, AssignedToEmployeeId = activity.TaskAssignedType == 3 ? activity.EmployeeId : -1, TaskOriginalStatus = 1, TaskStatus = 1, TaskPlannedDays = 1, TaskPlannedHours = activity.Duration, TaskPlanStartOn = DateOnly.FromDateTime(occurrence), TaskPlanEndOn = planEndOn, TaskDueOn = dueOn, TaskExpiryTime = (short)activity.AutoCloseTime, TaskExpireOn = expireOn, TaskGraceTime = (short)activity.GraceTime, TaskCriticality = 1, TaskPriority = 1, TaskActualStartOn = DateOnly.FromDateTime(occurrence), TaskActualEndOn = DateOnly.FromDateTime(planEndOnDateTime), TaskActualDays = 1, TaskActualHours = 1, ParameterSetId = activity.ParameterSetId, TaskCreatedById = LoginDTO.UserId, TaskModifiedById = LoginDTO.UserId, TaskWorkFlowRequired = 1 // system-generated: skip approval workflow, save directly }; return taskDto; } // GB4 parity: TEMP_TASK_INSERT_1_NEW's Period-based CASE for PLANENDON/DUEON/EXPIREON. // Period: 0=Hourly,1=Daily,2=Weekly,3=Fortnightly,4=Monthly,5=Quarterly,6=Halfyearly,7=Yearly. private static (DateOnly PlanEndOn, DateOnly DueOn, DateOnly ExpireOn) ComputeScheduleDates( TaskQueryDTO activity, DateTime occurrence) { if (activity.Period is 0 or 1) { var planEndOn = DateOnly.FromDateTime(occurrence.AddMinutes(activity.Duration)); var dueOn = DateOnly.FromDateTime(occurrence.AddMinutes(activity.Duration + activity.GraceTime)); var expireOn = DateOnly.FromDateTime(occurrence.AddMinutes(activity.AutoCloseTime)); return (planEndOn, dueOn, expireOn); } var periodEnd = ComputePeriodEnd(DateOnly.FromDateTime(occurrence), activity.Period); return (periodEnd, periodEnd, periodEnd); } // NOTE: GB4's Weekly formula relies on a SQL Server date-arithmetic idiom // (DATEDIFF/DATEADD against day-offset literals) that could not be verified // bit-for-bit without a live database. This reproduces its evident intent — // round up to the end of the current week — as the next Saturday. private static DateOnly ComputePeriodEnd(DateOnly start, int period) => period switch { 2 => start.AddDays(DaysUntilNextSaturday(start)), // Weekly 3 => start.AddDays(15), // Fortnightly 4 => LastDayOfMonth(start, 0), // Monthly 5 => LastDayOfMonth(start, 3), // Quarterly 6 => LastDayOfMonth(start, 6), // Half-yearly 7 => new DateOnly(start.Year, 12, 31), // Yearly _ => start }; private static int DaysUntilNextSaturday(DateOnly start) { int days = ((int)DayOfWeek.Saturday - (int)start.DayOfWeek + 7) % 7; return days == 0 ? 7 : days; } private static DateOnly LastDayOfMonth(DateOnly start, int monthsAhead) => new DateOnly(start.Year, start.Month, 1).AddMonths(monthsAhead + 1).AddDays(-1); } }