using AccountsBLL.Voucher; using AccountsDAL.DTO.Voucher; using AdminBLL.BIZTransactionType; using GB5Shared.Attachment; using GB5Shared.DTO.ECM; using GB5Shared.DaprCache; 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.Telemetry; using MaintenanceBLL.Activity; using MaintenanceDAL.CustomCode.Claim; using MaintenanceDAL.DTO.Activity; using MaintenanceDAL.DTO.Claim; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using PayRollBLL.Employee; using PayRollDAL.DTO.Employee; using System; using System.Collections.Generic; using System.Data.Common; using System.IO; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using static GB5Shared.GB5Constant.Constant; namespace MaintenanceBLL.Claim { public class ClaimBLL : IClaimBLL { private readonly IClaimDAL _ClaimDAL; private readonly AutoNumber _AutoNumber; private readonly IQueryExecutor _QueryExecutor; private readonly BaseEntityAppService _baseEntityAppService; private readonly KeyInvalidate _keyInvalidate; private readonly ILogger _logger; private readonly IActivityBLL _activityBll; private readonly IEmployeeBLL _employeeBll; private readonly IBIZTransactionTypeBLL _bizTransactionTypeBll; private readonly IVoucherBLL _voucherBll; private readonly IAttachmentUploadService _attachmentUploadService; // Legacy MBIZTRANSACTIONCLASS row ids (pre-existing business data carried over from GB4, // not GB5-assigned EntityConstant/BIZTRANSACTIONCLASSCONSTANT values — those are per top-level // entity, these are Claim's own finer-grained sub-classifications). Named locally rather than // left as bare magic numbers, per the migration cookbook's anti-pattern guidance — but not // added to the shared Constant.cs registry since they aren't new GB5 object-model constants. private const int TRAVEL_EXPENSE_SUBMISSION_CLASS = -1399999796; private const int LOCAL_CONVEYANCE_CLASS = -1399999776; private const int TRAVEL_EXPENSE_REQUEST_CLASS = -1399999802; public ClaimBLL( IClaimDAL IClaimDAL, AutoNumber autoNumber, IQueryExecutor IQueryExecutor, BaseEntityAppService baseEntityAppService, KeyInvalidate keyInvalidate, ILogger logger, IActivityBLL activityBll, IEmployeeBLL employeeBll, IBIZTransactionTypeBLL bizTransactionTypeBll, IVoucherBLL voucherBll, IAttachmentUploadService attachmentUploadService) { _ClaimDAL = IClaimDAL; _AutoNumber = autoNumber; _QueryExecutor = IQueryExecutor; _baseEntityAppService = baseEntityAppService; _keyInvalidate = keyInvalidate; _logger = logger; _activityBll = activityBll; _employeeBll = employeeBll; _bizTransactionTypeBll = bizTransactionTypeBll; _voucherBll = voucherBll; _attachmentUploadService = attachmentUploadService; } public async Task GetClaim(int ClaimId, LoginDTO LoginDTO) { try { return await _ClaimDAL.GetClaim(ClaimId, LoginDTO); } catch (Exception) { throw; } } public async Task GetSelectListClaim(CriteriaDTO criteriaDTO, LoginDTO loginDTO) { try { return await _ClaimDAL.GetSelectListClaim(criteriaDTO, loginDTO); } catch (Exception) { throw; } } public async Task SaveClaim(ClaimDTO claimDTO, LoginDTO loginDTO, CancellationToken ct) { if (claimDTO == null) throw new ArgumentNullException(nameof(claimDTO)); try { bool isCreate = claimDTO.ClaimId == 0; GB5Trace.Step("validate-claim", new { claimDTO.ClaimId, isCreate }); // Guard: cannot edit a claim that has already been posted to a pay period. if (!isCreate && await _ClaimDAL.IsClaimAlreadyProcessed(claimDTO.ClaimId, loginDTO, ct).ConfigureAwait(false)) throw new ArgumentException("You cannot update this claim since it is already processed."); // Period-validation: the claim date must fall inside the selected period's window. var period = await _ClaimDAL.GetPeriodWindow(claimDTO.PeriodId, loginDTO, ct).ConfigureAwait(false) ?? throw new ArgumentException($"Period {claimDTO.PeriodId} not found"); if (claimDTO.ClaimClaimDate is null || claimDTO.ClaimClaimDate < period.FromDate || claimDTO.ClaimClaimDate > period.ToDate) throw new ArgumentException("Claim date is not within the selected financial period."); var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); try { // Iterative (not recursive) Travel-Expense auto-claim generation. // Legacy behaviour: a new Travel/Expense Submission or Local Conveyance claim with no // ReferenceClaimId yet must first auto-create a linked "Travel/Expense Request" claim — // legacy did this by having SaveClaim call itself with IsRecursiveCalling=0. Replaced // here with a single flat pass: build the linked claim, save it via the DAL directly // (no self-invocation) inside the SAME transaction, then point ReferenceClaimId at it. if (isCreate && (claimDTO.BiztransactionClassId == TRAVEL_EXPENSE_SUBMISSION_CLASS || claimDTO.BiztransactionClassId == LOCAL_CONVEYANCE_CLASS) && claimDTO.ReferenceClaimId <= 0) { GB5Trace.Step("generate-linked-travel-request-claim", new { claimDTO.PeriodId, loginDTO.WorkOUId }); var travelRequestBizTypeId = await _ClaimDAL.GetBizTransactionTypeForClass( TRAVEL_EXPENSE_REQUEST_CLASS, loginDTO.WorkPeriodId, loginDTO.WorkOUId, loginDTO, ct) .ConfigureAwait(false) ?? throw new ArgumentException("Unable to save submission — no BizTransactionType configured for Travel/Expense Request."); var travelRequestAuto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.CLAIM, loginDTO).ConfigureAwait(false); var travelRequestClaim = new ClaimDTO { ClaimId = travelRequestAuto.StartNumber, OUId = claimDTO.OUId, PeriodId = claimDTO.PeriodId, BizTransactionTypeId = travelRequestBizTypeId, ClaimClaimNumber = claimDTO.ClaimClaimNumber, ClaimClaimDate = claimDTO.ClaimClaimDate, EmployeeId = claimDTO.EmployeeId, DepartmentId = claimDTO.DepartmentId, ClaimApprovedTotal = claimDTO.ClaimApprovedTotal, ClaimCreatedById = loginDTO.UserId, ClaimCreatedOn = DateTime.UtcNow, ClaimModifiedById = loginDTO.UserId, ClaimModifiedOn = DateTime.UtcNow, ClaimDetailArray = new List() }; await _ClaimDAL.SaveClaim(travelRequestClaim, loginDTO, trans, ct).ConfigureAwait(false); claimDTO.ReferenceClaimId = travelRequestClaim.ClaimId; } // Detail-row id allocation — the prior version requested a fixed count of 2 auto-numbers // regardless of actual detail count and assigned the SAME id to every detail row (a real // bug: multiple details would collide on one ClaimDetailId). Fixed: allocate exactly one // id per detail row that doesn't already have one. var newDetailCount = claimDTO.ClaimDetailArray?.Count(d => d.ClaimDetailId == 0) ?? 0; int nextDetailId = 0; if (newDetailCount > 0) { var detailAuto = await _AutoNumber.GetNumberAsync(newDetailCount, AUTONUMBERCONSTANT.CLAIM, loginDTO).ConfigureAwait(false); nextDetailId = detailAuto.StartNumber; } await _baseEntityAppService.ExecuteSaveAsync( EntityConstant.CLAIM, EventTypeConstant.SAVECLAIMEVENTTYPEID, claimDTO, loginDTO, async tx => { if (isCreate) { var auto = await _AutoNumber.GetNumberAsync(1, AUTONUMBERCONSTANT.CLAIM, loginDTO).ConfigureAwait(false); claimDTO.ClaimId = auto.StartNumber; claimDTO.ClaimCreatedById = loginDTO.UserId; claimDTO.ClaimCreatedOn = DateTime.UtcNow; } else { claimDTO.ClaimModifiedById = loginDTO.UserId; claimDTO.ClaimModifiedOn = DateTime.UtcNow; } if (claimDTO.ClaimDetailArray != null && claimDTO.ClaimDetailArray.Any()) { short slNo = 1; foreach (var detail in claimDTO.ClaimDetailArray) { detail.ClaimId = claimDTO.ClaimId; detail.ClaimDetailSlNo = slNo++; if (detail.ClaimDetailId == 0) detail.ClaimDetailId = nextDetailId++; } } GB5Trace.Step("save-claim", new { claimDTO.ClaimId, isCreate }); return isCreate ? await _ClaimDAL.SaveClaim(claimDTO, loginDTO, tx, ct).ConfigureAwait(false) : await _ClaimDAL.UpdateClaim(claimDTO, loginDTO, tx, ct).ConfigureAwait(false); }, null, BIZTRANSACTIONCLASSCONSTANT.CLAIM, BizTransactionConstant.CLAIM, trans, isCreate).ConfigureAwait(false); await _QueryExecutor.CommitAsync(trans).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration(claimDTO.ClaimId, EntityConstant.CLAIM, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _keyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); // Voucher posting is a separate BLL call (Accounts manages its own transaction) — // deliberately outside this claim's own transaction, run only after it commits. // Guarded by VoucherId <= 0 so an already-posted claim is never re-posted on edit. if (claimDTO.VoucherId <= 0) await TryPostClaimVoucherAsync(claimDTO, loginDTO, ct).ConfigureAwait(false); return isCreate ? $"{SuccessResponse.SaveSuccessMessage} {claimDTO.ClaimClaimNumber}" : $"{SuccessResponse.UpdateSuccessMessage} {claimDTO.ClaimClaimNumber}"; } catch { await _QueryExecutor.RollbackAsync(trans).ConfigureAwait(false); throw; } } catch (Exception ex) { GB5Trace.MarkFailed("save-claim-failed", ex); _logger.LogError(ex, "SaveClaim failed for ClaimId {ClaimId}", claimDTO.ClaimId); throw; } } // Resolves this claim's own GL accounts (Activity.ExpenseAccountId per claim-head group, // Employee.AdvanceAccountId) and hands Accounts an already-resolved posting request — // Accounts owns voucher construction/posting mechanics, this method never builds a // VoucherDTO itself. No-op unless the claim's BizTransactionType is configured for // Settlement posting (ClaimPostType==2, AccountPostType!=1); alerts (throws) rather than // silently posting to a wrong/zero account when a required master-data account is missing. private async Task TryPostClaimVoucherAsync(ClaimDTO claimDTO, LoginDTO loginDTO, CancellationToken ct) { var bizTypes = await _bizTransactionTypeBll.GetBIZTransactionType(claimDTO.BizTransactionTypeId, loginDTO).ConfigureAwait(false); var bizType = bizTypes?.FirstOrDefault(); if (bizType is null || bizType.BIZTransactionTypeClaimPostType != 2 || bizType.BIZTransactionTypeAccountPostType == 1) return; if (claimDTO.ClaimDetailArray is null || claimDTO.ClaimDetailArray.Count == 0) throw new ArgumentException("Cannot post a settlement voucher for a claim with no detail lines."); if (bizType.ClaimPaymentBIZTransactionId <= 0) throw new ArgumentException($"No ClaimPaymentBIZTransaction configured for BizTransactionType {claimDTO.BizTransactionTypeId} — cannot post claim voucher."); GB5Trace.Step("resolve-claim-voucher-accounts", new { claimDTO.ClaimId }); var expenseLines = new List(); foreach (var group in claimDTO.ClaimDetailArray.GroupBy(d => d.ClaimHeadId)) { var activityJson = await _activityBll.GetActivity(group.Key, loginDTO).ConfigureAwait(false); var activity = string.IsNullOrWhiteSpace(activityJson) ? null : JsonConvert.DeserializeObject(activityJson); if (activity is null || activity.ExpenseAccountId <= 0) throw new ArgumentException($"Activity {group.Key} has no ExpenseAccountId configured — cannot post claim voucher."); expenseLines.Add(new ClaimVoucherExpenseLineDTO { AccountId = activity.ExpenseAccountId, Amount = group.Sum(d => d.ClaimDetailApprovedAmount) }); } var employeeJson = await _employeeBll.GetEmployee(claimDTO.EmployeeId, string.Empty, loginDTO).ConfigureAwait(false); var employee = string.IsNullOrWhiteSpace(employeeJson) ? null : JsonConvert.DeserializeObject(employeeJson); if (employee is null || employee.AdvanceAccountId <= 0) throw new ArgumentException($"Employee {claimDTO.EmployeeId} has no AdvanceAccountId configured — cannot post claim voucher."); var request = new ClaimVoucherPostingRequestDTO { ClaimId = claimDTO.ClaimId, ClaimNumber = claimDTO.ClaimClaimNumber, ClaimDate = claimDTO.ClaimClaimDate ?? DateTime.UtcNow, OUId = claimDTO.OUId, PeriodId = claimDTO.PeriodId, BizTransactionTypeId = bizType.ClaimPaymentBIZTransactionId, Narration = string.IsNullOrWhiteSpace(claimDTO.ClaimRemarks) ? $"Claim {claimDTO.ClaimClaimNumber}" : claimDTO.ClaimRemarks, ExpenseLines = expenseLines, CreditAccountId = employee.AdvanceAccountId, CreditAmount = expenseLines.Sum(l => l.Amount) }; GB5Trace.Step("post-claim-voucher", new { claimDTO.ClaimId }); var voucherId = await _voucherBll.PostClaimVoucherAsync(request, loginDTO, ct).ConfigureAwait(false); var tx = await _QueryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); try { await _ClaimDAL.UpdateClaimVoucherPosting(claimDTO.ClaimId, voucherId, 2, loginDTO, tx, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(tx).ConfigureAwait(false); claimDTO.VoucherId = voucherId; claimDTO.ClaimClaimOStatus = 2; } catch (Exception ex) { await _QueryExecutor.RollbackAsync(tx).ConfigureAwait(false); // The voucher itself already posted (Accounts manages its own transaction) — only // this claim-side status update failed. Same eventual-consistency tradeoff already // accepted elsewhere in this codebase (CRMBLL.TaskBLL's pre-transaction AutoNumber // fetch): rare edge case, logged loudly rather than silently swallowed. GB5Trace.MarkFailed("update-claim-voucher-posting-failed", ex); _logger.LogError(ex, "Voucher {VoucherId} posted for ClaimId {ClaimId} but claim status update failed", voucherId, claimDTO.ClaimId); throw; } } public async Task UploadClaimDocumentAsync( int claimId, AttachmentUploadRequest request, Stream fileStream, string originalFileName, string contentType, LoginDTO loginDTO, CancellationToken ct) { if (claimId <= 0) throw new ArgumentException("A valid ClaimId is required to attach a document."); request.ObjectTypeId = EntityConstant.OBJECTCLAIM; request.ObjectId = claimId; // Thin pass-through — the attachment service owns storage/versioning entirely. return await _attachmentUploadService .UploadAsync(request, fileStream, originalFileName, contentType, loginDTO, ct) .ConfigureAwait(false); } public async Task DeleteClaim(int claimId, LoginDTO loginDTO, CancellationToken ct) { try { if (claimId <= 0) throw new ArgumentException("Invalid Claim Id."); GB5Trace.Step("delete-claim", new { claimId }); if (await _ClaimDAL.IsClaimAlreadyProcessed(claimId, loginDTO, ct).ConfigureAwait(false)) throw new ArgumentException("You cannot delete this claim since it is already processed."); var trans = await _QueryExecutor.BeginTransactionAsync(loginDTO).ConfigureAwait(false); string result; try { result = await _ClaimDAL.DeleteClaim(claimId, loginDTO, trans, ct).ConfigureAwait(false); await _QueryExecutor.CommitAsync(trans).ConfigureAwait(false); } catch { await _QueryExecutor.RollbackAsync(trans).ConfigureAwait(false); throw; } var cacheKey = new CacheKeyGeneration().KeyGeneration(claimId, EntityConstant.CLAIM, CacheKeyLevel.CLIENT_LEVEL, loginDTO); await _keyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); return result; } catch (Exception ex) { GB5Trace.MarkFailed("delete-claim-failed", ex); _logger.LogError(ex, "DeleteClaim failed for ClaimId {ClaimId}", claimId); throw; } } } }