using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using CRMBLL.Task; using CRMDAL.CustomCode.Action; using CRMDAL.DTO.Action; using CRMDAL.DTO.Task; using CRMDAL.Query.Action; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.ResponseStandard; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using Newtonsoft.Json; using GB5Shared.DTO.Framework.Logging; using GB5Shared.Validation; using GB5Shared.Resource.Response; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.EntityHandler; using GB5Shared.GB5Exception; using System.ComponentModel.DataAnnotations; using STask = System.Threading.Tasks.Task; using static GB5Shared.GB5Constant.Constant; namespace CRMBLL.Action { // Internal DTOs for back-date and rights queries file sealed class BackDateSettingsDTO { public int BackDateEntryDay { get; set; } public int BackDateUpdateDay { get; set; } public int BackDateDeleteDay { get; set; } public int BackDateCheckType { get; set; } public int ForwardEntryDay { get; set; } } file sealed class OverLockDateDTO { public DateTime OverLockDate { get; set; } } file sealed class UserRightsDTO { public string CanInsert { get; set; } = "0"; public string CanUpdate { get; set; } = "0"; public string CanDelete { get; set; } = "0"; } public class ActionBLL : IActionBLL { private readonly IActionDAL _ActionDAL; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; private readonly IQueryExecutor _QueryExecutor; private readonly VoucherNumberService _VoucherNumberService; private readonly ITaskBLL _TaskBLL; private readonly BaseEntityAppService _BaseEntityAppService; private readonly GB5Shared.DaprCache.KeyInvalidate _KeyInvalidate; // CRM Action menu constants — MENUID from MMENU (GB5 MROLEVSMENU based auth) private const int CRM_ACTION_SAVE = -1899999863; // MENUNAME: Action, MENUCODE: FM10002 private const int CRM_ACTION_DELETE = -1899999863; private const int CRM_ACTION_GET = -1899999863; public ActionBLL(IActionDAL IActionDAL, AutoNumber AutoNumber, IValidation validation, IQueryExecutor queryExecutor, VoucherNumberService voucherNumberService, ITaskBLL taskBLL, BaseEntityAppService baseEntityAppService, GB5Shared.DaprCache.KeyInvalidate keyInvalidate) { _ActionDAL = IActionDAL; _AutoNumber = AutoNumber; _Validation = validation; _QueryExecutor = queryExecutor; _VoucherNumberService = voucherNumberService; _TaskBLL = taskBLL; _BaseEntityAppService = baseEntityAppService; _KeyInvalidate = keyInvalidate; } // ── Private validation helpers ──────────────────────────────────────── /// /// Replicates GB4 CheckOverAllLockDate: /// Throws if the transaction date is before the OU's overall lock date. /// private async STask CheckOverAllLockDateAsync(DateOnly vDate, LoginDTO loginDTO) { var rows = await _QueryExecutor.QueryAsync( loginDTO, ActionQB.CHECK_OVERALL_LOCK_DATE, new { ouid = loginDTO.WorkOUId }); var lockDate = rows.FirstOrDefault()?.OverLockDate ?? DateTime.MinValue; if (vDate < DateOnly.FromDateTime(lockDate)) throw new InvalidOperationException( $"Transaction date {vDate:dd-MMM-yyyy} is before the organization lock date {lockDate:dd-MMM-yyyy}. Entry not allowed."); } /// /// Replicates GB4 CheckBizTransactionTypeBackDate + CheckForwardDate: /// Throws if the date violates the BIZTransactionType's back-date or forward-date limits. /// isNew=true uses BackDateEntryDay; isNew=false uses BackDateUpdateDay. /// private async STask CheckBackDateSettingsAsync(DateOnly vDate, int bizTransactionTypeId, bool isNew, LoginDTO loginDTO) { var rows = await _QueryExecutor.QueryAsync( loginDTO, ActionQB.CHECK_BACKDATE_SETTINGS, new { biztransactiontypeid = bizTransactionTypeId }); var s = rows.FirstOrDefault(); if (s == null) return; // Back-date check int allowedBackDays = isNew ? s.BackDateEntryDay : s.BackDateUpdateDay; DateOnly compareDt = DateOnly.FromDateTime(DateTime.Today); DateOnly allowableBackDt = compareDt.AddDays(-allowedBackDays); if (vDate < allowableBackDt) { string op = isNew ? "Save" : "Update"; throw new InvalidOperationException( $"Back-dated entry not allowed. Cannot {op} a record more than {allowedBackDays} day(s) before today."); } // Forward-date check DateOnly allowableForwardDt = compareDt.AddDays(s.ForwardEntryDay); if (vDate > allowableForwardDt) throw new InvalidOperationException( $"Forward-dated entry not allowed. Cannot enter a date more than {s.ForwardEntryDay} day(s) ahead of today."); } /// /// Replicates GB4 CheckUserRights: /// Throws if the user's role does not have Insert (new) or Update (edit) permission /// for the CRM Action menu. /// private async STask CheckUserRightsAsync(bool isNew, LoginDTO loginDTO) { var rows = await _QueryExecutor.QueryAsync( loginDTO, ActionQB.CHECK_USER_RIGHTS, new { roleid = loginDTO.RoleId, menuid = CRM_ACTION_SAVE }); var rights = rows.FirstOrDefault(); if (rights == null) throw new UnauthorizedAccessException("User role does not have access to CRM Action."); bool allowed = isNew ? rights.CanInsert == "1" : rights.CanUpdate == "1"; if (!allowed) { string op = isNew ? "create" : "update"; throw new UnauthorizedAccessException($"User does not have permission to {op} a CRM Action."); } } public async Task GetAction(int TActionId, LoginDTO LoginDTO) { try { // Prepare SQL and params string sql = ActionQB.GET_ACTION; var parameters = new { tactionid = TActionId }; // Call DAL var result = await _ActionDAL.GetAction(LoginDTO, sql, parameters); return JsonConvert.SerializeObject(result); } catch (Exception) { throw; } } public async Task> GetSelectListAction(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { // GB4: when no explicit pagination is supplied the service fell back to // FromNumber/ToNumber config values, and the BLL always passed -1,-1 to // NHibernate (return all records). Mirror that behaviour: treat 0,0 as // "all records" by forwarding -1,-1 to the DAL. if (FirstNumber <= 0 && MaxResult <= 0) { FirstNumber = -1; MaxResult = -1; } return await _ActionDAL.GetSelectListAction(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task SaveAction(TActionDTO TActionDTO, LoginDTO LoginDTO) { if (TActionDTO == null) throw new ArgumentNullException(nameof(TActionDTO)); // ── GB4: Input validations ──────────────────────────────────────── if (TActionDTO.ActionDetailArray == null || TActionDTO.ActionDetailArray.Count == 0) throw new ArgumentException("At least one action detail is required."); if (TActionDTO.PersonId == -1) throw new ArgumentException("Please select a Person before saving."); AutoNumberDTO? autoNumberDTO = null; bool isNew = TActionDTO.TActionId == 0; try { // ── GB4: CheckUserRights ────────────────────────────────────── // TODO: Replace CRM_ACTION_SAVE constant with the correct MENUID // from MMENU once confirmed. Run: SELECT MENUID,MENUNAME FROM MMENU WHERE MENUNAME LIKE '%Action%' //await CheckUserRightsAsync(isNew, LoginDTO); // ── GB4: CheckOverAllLockDate ───────────────────────────────── await CheckOverAllLockDateAsync(TActionDTO.TActionTransactionDate, LoginDTO); // ── GB4: CheckBackDateSettings + CheckForwardDate ───────────── await CheckBackDateSettingsAsync( TActionDTO.TActionTransactionDate, TActionDTO.BIZTransactionTypeId, isNew, LoginDTO); // ── GB4: Date standardization (strip time component) ────────── TActionDTO.TActionTransactionDate = DateOnly.FromDateTime(TActionDTO.TActionTransactionDate.ToDateTime(TimeOnly.MinValue)); TActionDTO.TActionReferenceDate = DateOnly.FromDateTime(TActionDTO.TActionReferenceDate.ToDateTime(TimeOnly.MinValue)); // ── Standard fields ─────────────────────────────────────────── TActionDTO.TActionModifiedById = LoginDTO.UserId; TActionDTO.TActionModifiedOn = DateOnly.FromDateTime(DateTime.UtcNow); // ── GB4: Task follow-up creation ─────────────────────────────── // When the caller attaches a TaskDTO, save it first and link the // resulting TaskId onto the first ActionDetail as its follow-up task. if (TActionDTO.TaskDTO != null && TActionDTO.ActionDetailArray.Count > 0) { await _TaskBLL.SaveTask(new List { TActionDTO.TaskDTO }, LoginDTO); TActionDTO.ActionDetailArray[0].FollowUpTaskId = TActionDTO.TaskDTO.TaskId; } // ── GB4: VoucherNumber generation (MBIZTRANSACTIONKEYS) ─────── // Only for new actions. Returns null when NOGenerationType==1 (manual), // in which case the user-supplied TActionTransactionNumber is kept as-is. if (isNew) { string? generatedNumber = await _VoucherNumberService.GetNextVoucherNumberAsync( TActionDTO.BIZTransactionTypeId, TActionDTO.TActionTransactionDate, LoginDTO); if (generatedNumber != null) TActionDTO.TActionTransactionNumber = generatedNumber; } if (isNew) { // ── GB4: AutoNumber count = 1 (header) + details + items ── int detailCount = TActionDTO.ActionDetailArray.Count; int itemCount = TActionDTO.ActionDetailArray.Sum(d => d.ActionItemsArray?.Count ?? 0); int totalCount = 1 + detailCount + itemCount; autoNumberDTO = await _AutoNumber.GetNumberAsync(totalCount, "TACTION", LoginDTO); TActionDTO.TActionId = autoNumberDTO.StartNumber; TActionDTO.TActionCreatedById = LoginDTO.UserId; TActionDTO.TActionCreatedOn = DateOnly.FromDateTime(DateTime.UtcNow); // ── GB4: Assign IDs to each ActionDetail and ActionItems ─── int idOffset = 1; for (int i = 0; i < TActionDTO.ActionDetailArray.Count; i++) { var detail = TActionDTO.ActionDetailArray[i]; detail.TActionDetailId = autoNumberDTO.StartNumber + idOffset++; detail.ActionId = TActionDTO.TActionId; detail.TActionDetailSlno = (short)(i + 1); detail.TActionDetailNetDuration = (short)(detail.TActionDetailDuration - detail.TActionDetailInterval); if (detail.ActionItemsArray != null) { for (int x = 0; x < detail.ActionItemsArray.Count; x++) { detail.ActionItemsArray[x].TActionDetailId = detail.TActionDetailId; detail.ActionItemsArray[x].TActionItemsId = autoNumberDTO.StartNumber + idOffset++; } } } } else { // GB4 always reserves 1 (header) + details + items regardless of // new/update — the header id itself isn't re-assigned on update // (TActionId is already set), but the counter still advances by 1 // to stay in lockstep with GB4's numbering. int detailCount = TActionDTO.ActionDetailArray.Count; int itemCount = TActionDTO.ActionDetailArray.Sum(d => d.ActionItemsArray?.Count ?? 0); int totalCount = 1 + detailCount + itemCount; autoNumberDTO = await _AutoNumber.GetNumberAsync(totalCount, "TACTION", LoginDTO); int idOffset = 1; for (int i = 0; i < TActionDTO.ActionDetailArray.Count; i++) { var detail = TActionDTO.ActionDetailArray[i]; detail.TActionDetailId = autoNumberDTO.StartNumber + idOffset++; detail.ActionId = TActionDTO.TActionId; detail.TActionDetailSlno = (short)(i + 1); detail.TActionDetailNetDuration = (short)(detail.TActionDetailDuration - detail.TActionDetailInterval); if (detail.ActionItemsArray != null) { for (int x = 0; x < detail.ActionItemsArray.Count; x++) { detail.ActionItemsArray[x].TActionDetailId = detail.TActionDetailId; detail.ActionItemsArray[x].TActionItemsId = autoNumberDTO.StartNumber + idOffset++; } } } } // ── GB5: Unified save pipeline ────────────────────────────────── // Replaces GB4's CallWorkflowInCaseofClosed + PostEventTrigger mail-trigger // SQL with GB5's config-driven workflow engine + outbox/event publish // (same mechanism CRMBLL.Task.TaskBLL and AccountsBLL.Account use). // Every DB write that must be atomic with the header save runs inside // persistFunc, using the transaction BaseEntityAppService hands it — // geo-fencing, completed-percentage, task updates, call status, lead // update — matching GB4's single-NHibernate-session atomicity. await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTTACTION, isNew ? EventTypeConstant.SAVEACTIONEVENTTYPEID : EventTypeConstant.UPDATEACTIONEVENTTYPEID, TActionDTO, LoginDTO, async tx => { if (isNew) { await _ActionDAL.SaveAction(TActionDTO, LoginDTO, tx); foreach (var detail in TActionDTO.ActionDetailArray) await _ActionDAL.SaveActionDetail(detail, LoginDTO, tx); } else { // ── GB4: Delete old details then re-insert ──────── await _ActionDAL.DeleteActionDetails(TActionDTO.TActionId, LoginDTO, tx); await _ActionDAL.UpdateAction(TActionDTO, LoginDTO, tx); foreach (var detail in TActionDTO.ActionDetailArray) await _ActionDAL.SaveActionDetail(detail, LoginDTO, tx); } // ── GB4: Geo-fencing / location tracking ─────────────── // Only checked when the BIZTransactionType itself does NOT // already track location (IsLocationTrack==0) — mirrors // GB4's inverted gate. byte isLocationTrack = await _ActionDAL.GetBizTransactionTypeIsLocationTrack(TActionDTO.BIZTransactionTypeId, LoginDTO, tx); if (isLocationTrack == 0) { int geoFencing = await _ActionDAL.GetGeoFencingSetting(LoginDTO, tx); if (geoFencing == 1 || geoFencing == 2) { bool hasNoGeoCode = string.Equals(TActionDTO.MasterGeoCode, "NONE", StringComparison.OrdinalIgnoreCase) || string.Equals(TActionDTO.TActionModifiedAt, "NONE", StringComparison.OrdinalIgnoreCase); if (!hasNoGeoCode) { await _ActionDAL.PostToLocationTrack( TActionDTO.MasterGeoCode, TActionDTO.TActionModifiedAt, TActionDTO.BIZTransactionTypeId, LoginDTO.UserId, LoginDTO, tx); } } } // ── GB4: Task completed-percentage recalculation ─────── if (TActionDTO.FromResourcePunch == 0) { foreach (var detail in TActionDTO.ActionDetailArray) { for (int z = 4; z > 2; z--) await _ActionDAL.UpdateCompletedPercentage(TActionDTO.PersonId, detail.TaskId, z, LoginDTO, tx); } for (int z = 3; z > 0; z--) await _ActionDAL.UpdateCompletedPercentageSecondTime(z, LoginDTO, tx); } else { foreach (var detail in TActionDTO.ActionDetailArray) { int? parentTaskId = await _ActionDAL.GetParentTaskId(detail.TaskId, LoginDTO, tx); if (parentTaskId.HasValue) await _ActionDAL.UpdateCompletedPercentageForResourcePunch(parentTaskId.Value, LoginDTO, tx); } } // ── GB4: Task updates per detail ─────────────────────── foreach (var detail in TActionDTO.ActionDetailArray) { if (detail.TaskId == -1) continue; if (!isNew) { // Reverse previously logged hours before re-adding int oldHours = detail.TActionDetailDuration / 60; await _ActionDAL.ReverseTaskForAction(detail.TaskId, oldHours, LoginDTO, tx); await _ActionDAL.UpdateParentTaskForActionDetail(detail.TaskId, oldHours, isReverse: true, LoginDTO, tx); } int actualHours = detail.TActionDetailDuration / 60; await _ActionDAL.UpdateTaskForAction( detail.TaskId, DateOnly.FromDateTime(detail.TActionDetailPeriodFrom), DateOnly.FromDateTime(detail.TActionDetailPeriodTo), actualHours, detail.TActionDetailActionStatus, detail.TActionDetailNumberOfData, LoginDTO, tx); await _ActionDAL.UpdateParentTaskForActionDetail(detail.TaskId, actualHours, isReverse: false, LoginDTO, tx); } // ── GB4: Call status update ──────────────────────────── if (TActionDTO.TempCallStatus == 0) { foreach (var detail in TActionDTO.ActionDetailArray) { if (detail.CallId == -1) continue; await _ActionDAL.UpdateCallStatus( detail.CallId, TActionDTO.CallUpdateStatus, TActionDTO.CallIsClosed == 1, TActionDTO.CompletedOnDate, TActionDTO.CompletedOnTime, LoginDTO.UserId, LoginDTO, tx); } } // ── GB4: Lead update (first ActionDetail only) ───────── if (TActionDTO.UpdateLead == 1 && TActionDTO.ActionDetailArray.Count > 0) { var firstDetail = TActionDTO.ActionDetailArray[0]; if (firstDetail.LeadId != -1) { await _ActionDAL.UpdateLead( firstDetail.LeadId, firstDetail.PriorityId, firstDetail.TActionDetailSuccessStatus, DateOnly.FromDateTime(firstDetail.TActionDetailExpectedDate), DateOnly.FromDateTime(firstDetail.TActionDetailOrderWonLostDate), firstDetail.TActionDetailExpectedFCRevenue, firstDetail.TActionDetailOrderConfidence, firstDetail.TActionDetailRevenueConfidence, firstDetail.TActionDetailLeadStatus, firstDetail.TActionDetailLostRemarks, firstDetail.LeadCycleDetailId, LoginDTO, tx); } } return TActionDTO.TActionId; }, new Dictionary { { "PersonId", TActionDTO.PersonId } }, bizTransactionClassId: -1, bizTransactionId: -1, isNewEntity: isNew); // GetAction caches its response per (TActionId, USER_LEVEL, user) — must // invalidate the same key here or a stale response survives past this write. var cacheKey = new GB5Shared.DaprCache.CacheKeyGeneration().KeyGeneration( TActionDTO.TActionId, EntityConstant.OBJECTTACTION, CacheKeyLevel.USER_LEVEL, LoginDTO); await _KeyInvalidate.AllInvalidateCache(cacheKey); return TActionDTO.TActionTransactionNumber.ToString(); } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception) { if (autoNumberDTO != null && TActionDTO.TActionId > 0) await _AutoNumber.RollbackAutoNumber("TACTION", TActionDTO.TActionId, LoginDTO); throw; } } public async Task DeleteAction(int TActionId, LoginDTO LoginDTO) { try { // TACTIONDETAIL/TACTIONITEMS reference TACTION via FKTACTIONDETAIL_ACTIONID — // must clear child rows first or the delete fails with SQL Error 547. await _ActionDAL.DeleteActionDetails(TActionId, LoginDTO); return await _ActionDAL.DeleteAction(TActionId, LoginDTO); } catch (Exception) { throw; } } public async Task SaveNewAction(TActionDTO TActionDTO, LoginDTO LoginDTO) { if (TActionDTO == null) throw new ArgumentNullException(nameof(TActionDTO)); if (TActionDTO.ActionDetailArray == null || TActionDTO.ActionDetailArray.Count == 0) throw new ArgumentException("Action detail array is required and cannot be empty."); if (TActionDTO.PersonId == -1) throw new ArgumentException("Please select a Person before saving."); if (TActionDTO.BIZTransactionTypeId == -1) throw new ArgumentException("BIZ Transaction Type is required."); return await SaveAction(TActionDTO, LoginDTO); } public async Task GetActivityRegisterReport(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _ActionDAL.GetActivityRegisterReport(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task GetReadingEntryReport(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO, bool Count = false) { try { return await _ActionDAL.GetReadingEntryReport(FirstNumber, MaxResult, CriteriaDTO, LoginDTO, Count); } catch (Exception) { throw; } } public async Task GetActivityRegisterWithOutDRLeavePresent(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _ActionDAL.GetActivityRegisterWithOutDRLeavePresent(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task UpdateActivity(SelectionActionDTO SelectionActionDTO, LoginDTO LoginDTO) { if (SelectionActionDTO == null) throw new ArgumentNullException(nameof(SelectionActionDTO)); if (SelectionActionDTO.ActionId <= 0) throw new ArgumentException("ActionId is required."); if (SelectionActionDTO.ActionDetailId <= 0) throw new ArgumentException("ActionDetailId is required."); try { return await _ActionDAL.UpdateActivity(SelectionActionDTO, LoginDTO); } catch (Exception) { throw; } } public async Task CustomerAndLeadPicklist(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _ActionDAL.CustomerAndLeadPicklist(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } public async Task ActionSummary(int FirstNumber, int MaxResult, CriteriaDTO CriteriaDTO, LoginDTO LoginDTO) { try { return await _ActionDAL.ActionSummary(FirstNumber, MaxResult, CriteriaDTO, LoginDTO); } catch (Exception) { throw; } } } }