using System.Globalization; using System.Text.Json; using System.Runtime.CompilerServices; using Dapper; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.GB5Exception; using GB5Shared.QueryExecutor; using Microsoft.Extensions.Logging; using PayRollDAL.CustomeCode.PayConfiguration; using PayRollDAL.DTO.Employee; using PayRollDAL.DTO.PayConfiguration; using PayRollDAL.DTO.PayProcess; using PayRollDAL.Query.PayProcess; using static GB5Shared.GB5Constant.Constant; namespace PayRollDAL.CustomeCode.PayProcess; /// /// Ported from GB4 PayProcessDAL.SalaryAbstractSummary /// (D:\GB4Project\GB4Solution\DAL\PayrollDAL\CustomeCode\PayProcess\PayProcessDAL.cs:797-1533). /// Covers SalaryAbstractType 0-7 ("summary" mode, GbPeriodId == 0). Types 8/9 and the /// period-comparison mode (GbPeriodId != 0) depend on MGBPERIOD/MGBPERIODDETAIL, which no other /// GB5 code touches today, and are rejected rather than silently mis-reported. The BRFL /// client-specific bank-details merge (GB4's SalaryAbstractType==2 && OUType==0 branch, /// hardcoded to one client's MGCM/bank surrogate IDs) is intentionally not ported. /// public sealed class SalaryAbstractSummaryDAL( IQueryExecutor queryExecutor, IPayConfigurationDAL payConfigurationDAL, ILogger logger) : ISalaryAbstractSummaryDAL { public async Task GetSalaryAbstractSummary( int firstNumber, int maxResult, CriteriaDTO criteriaDTO, LoginDTO loginDTO, CancellationToken ct = default) { var rows = await QueryRows(criteriaDTO, loginDTO, ct); return ApplyPaging(rows, firstNumber, maxResult); } public async IAsyncEnumerable GetSalaryAbstractSummaryStream( int firstNumber, int maxResult, CriteriaDTO criteriaDTO, LoginDTO loginDTO, [EnumeratorCancellation] CancellationToken ct = default) { var rows = await QueryRows(criteriaDTO, loginDTO, ct); foreach (var row in ApplyPaging(rows, firstNumber, maxResult)) { ct.ThrowIfCancellationRequested(); yield return row; } } private async Task> QueryRows( CriteriaDTO criteriaDTO, LoginDTO loginDTO, CancellationToken ct) { var criteria = SalaryAbstractCriteria.From(criteriaDTO); var ouFilterIds = await ResolveOuFilterIds(criteria, loginDTO, ct); var pivot = await BuildPivotExpression(criteria.PayConfigurationId, loginDTO); var grouping = GroupingByType[criteria.SalaryAbstractType]; var parameters = new DynamicParameters(); parameters.Add("payconfigid", criteria.PayConfigurationId); string sql; if (criteria.SalaryAbstractType == 7) { sql = loginDTO.DatabaseType switch { DBType.SQL => SalaryAbstractSummaryQB.COSTCENTER_SQL, DBType.PostGre => SalaryAbstractSummaryQB.COSTCENTER_POSTGRESQL, _ => throw new NotSupportedException($"Database type '{loginDTO.DatabaseType}' is not supported.") }; } else { sql = loginDTO.DatabaseType switch { DBType.SQL => SalaryAbstractSummaryQB.SUMMARY_SQL, DBType.PostGre => SalaryAbstractSummaryQB.SUMMARY_POSTGRESQL, _ => throw new NotSupportedException($"Database type '{loginDTO.DatabaseType}' is not supported.") }; } var ouColumn = loginDTO.DatabaseType == DBType.PostGre ? "ou.ouid" : "OU.OUID"; var ouFilter = ouFilterIds.Count > 0 ? $"AND {ouColumn} IN @ouids" : string.Empty; if (ouFilterIds.Count > 0) parameters.Add("ouids", ouFilterIds); // GB4 validates PeriodFromDate/PeriodToDate/PayPeriodId as required inputs but resolves // the period range and pay-period id to a filter via its generic ApplyCriteria mapping // (PayProcessDAL.cs:1544-1547: PeriodFromDate/PeriodToDate -> payperiod.FromDate/ToDate, // PayPeriodId -> payperiod.PayPeriodId). The exact operator used by that generic helper // isn't visible in the GB4 source available here; a from/to overlap range (matching the // interpretation the prior GB5 port already used for this same field pair) is applied // when the dates are supplied, and an equality filter when PayPeriodId is supplied. var extraFilter = new List(); var periodCol = loginDTO.DatabaseType == DBType.PostGre ? "ppy" : "PPY"; if (criteria.PeriodFromDate is not null && criteria.PeriodToDate is not null) { extraFilter.Add($"AND {periodCol}.FROMDATE <= @periodtodate AND {periodCol}.TODATE >= @periodfromdate"); parameters.Add("periodfromdate", criteria.PeriodFromDate.Value.Date); parameters.Add("periodtodate", criteria.PeriodToDate.Value.Date); } if (criteria.PayPeriodId is > 0) { extraFilter.Add($"AND {periodCol}.PAYPERIODID = @payperiodid"); parameters.Add("payperiodid", criteria.PayPeriodId.Value); } sql = sql .Replace("{HeaderReplacePart}", grouping.HeaderReplacePart(loginDTO.DatabaseType)) .Replace("{GroupByReplacePart}", grouping.GroupByReplacePart(loginDTO.DatabaseType)) .Replace("{OrderByReplacePart}", grouping.OrderByReplacePart(loginDTO.DatabaseType)) .Replace("{AddOnValuesReplacePart}", pivot) .Replace("{OuFilter}", ouFilter) .Replace("{ExtraFilter}", string.Join(" ", extraFilter)); var rows = (await queryExecutor.QueryAsync(loginDTO, sql, parameters)).ToList(); foreach (var row in rows) row.SalaryAbstractType = criteria.SalaryAbstractType; logger.LogInformation( "SalaryAbstractSummary returned {RowCount} row(s). PayConfigurationId={PayConfigurationId}, " + "SalaryAbstractType={SalaryAbstractType}, OuIds={OuIds}", rows.Count, criteria.PayConfigurationId, criteria.SalaryAbstractType, string.Join(',', ouFilterIds)); return rows; } private async Task> ResolveOuFilterIds(SalaryAbstractCriteria criteria, LoginDTO loginDTO, CancellationToken ct) { // Two-layer OU filter, matching GB4's PayProcessDAL.SalaryAbstractSummary (source // lines 990-1022) and the equivalent already-ported GB5 GetPaySlipReport // (PayProcessDAL.cs:348-418): restrict to OUs the user has access rights to, further // narrowed to the criteria's OuIds when supplied, else the caller's WorkOUId. var accessSql = loginDTO.DatabaseType switch { DBType.SQL => PayProcessQB.USER_OU_ACCESS_RIGHTS_REPORT, DBType.PostGre => PayProcessQB.USER_OU_ACCESS_RIGHTS_REPORT_PG, _ => throw new NotSupportedException($"Database type '{loginDTO.DatabaseType}' is not supported.") }; var userAccessOus = (await queryExecutor.QueryAsync( loginDTO, accessSql, new { userid = loginDTO.UserId })).Select(r => r.OUId).ToHashSet(); var requested = criteria.OuIds.Count > 0 ? criteria.OuIds.ToHashSet() : new HashSet { loginDTO.WorkOUId }; if (userAccessOus.Count == 0) return requested.ToList(); return requested.Intersect(userAccessOus).ToList(); } private async Task BuildPivotExpression(int payConfigurationId, LoginDTO loginDTO) { // Same technique as the already-ported PayProcessDAL.GetPaySlipReport's AddonReplaceMent // (PayRollDAL/CustomeCode/PayProcess/PayProcessDAL.cs:466-493), wrapped in SUM(...) to // match GB4's SALARY_ABSTRACT_SUMMARY/SALARY_ABSTRACT_COST_CENTER pivot column. var payConfiguration = await payConfigurationDAL.GetPayConfiguration(payConfigurationId, loginDTO); var isPg = loginDTO.DatabaseType == DBType.PostGre; var ppaCol = isPg ? "ppa" : "PPA"; var pivot = new System.Text.StringBuilder(isPg ? "SUM(CASE pcd.fieldcode " : "SUM(CASE PCD.FIELDCODE "); foreach (var detail in payConfiguration.PayConfigurationDetailArray) { var column = detail.PayConfigurationDetailFieldType switch { 0 => $"{ppaCol}.{detail.PayConfigurationDetailFieldCode}", 1 => $"{ppaCol}.F_{detail.PayConfigurationDetailFieldCode}", 3 => $"{ppaCol}.A_{detail.PayConfigurationDetailFieldCode}", _ => null }; if (column is null) continue; pivot.Append(" WHEN '").Append(detail.PayConfigurationDetailFieldCode).Append("' THEN ").Append(column); } pivot.Append(" ELSE 0 END) AS "); pivot.Append(isPg ? "\"FieldValue\"" : "FieldValue"); return pivot.ToString(); } private static IEnumerable ApplyPaging( IReadOnlyList rows, int firstNumber, int maxResult) => firstNumber > 0 && maxResult > 0 ? rows.Skip(firstNumber - 1).Take(maxResult) : rows; // Per-SalaryAbstractType grouping SELECT/GROUP BY/ORDER BY columns, ported from the // corresponding GB4 branch (PayProcessDAL.cs line ranges noted per type). private sealed record Grouping( Func HeaderReplacePart, Func GroupByReplacePart, Func OrderByReplacePart); // TotalCount is a per-group employee headcount, computed the same way GB4 does (a // COUNT(*)-grouped MEMPLOYEE subquery joined back on the grouping key, wrapped in MAX() // since it's constant per group) but as a correlated subquery rather than GB4's separately // joined derived table - simpler, same result. private static string TotalCountSql(bool isPg, string employeeColumn) => isPg ? $"MAX((SELECT COUNT(*) FROM memployee me2 WHERE me2.{employeeColumn} = e.{employeeColumn})) AS \"TotalCount\"," : $"MAX((SELECT COUNT(*) FROM MEMPLOYEE ME2 WHERE ME2.{employeeColumn} = E.{employeeColumn})) AS TotalCount,"; private static readonly Dictionary GroupingByType = new() { // 0: Department - PayProcessDAL.cs:1037-1064 (TotalCount joined on employee.DEPARTMENTID) [0] = new Grouping( db => (db == DBType.PostGre ? "d.departmentcode AS \"Code\", d.departmentname AS \"Name\", " : "D.DEPARTMENTCODE AS Code, D.DEPARTMENTNAME AS Name, ") + TotalCountSql(db == DBType.PostGre, "DEPARTMENTID"), db => db == DBType.PostGre ? "d.departmentcode, d.departmentname," : "D.DEPARTMENTCODE, D.DEPARTMENTNAME,", db => db == DBType.PostGre ? "d.departmentcode," : "D.DEPARTMENTCODE,"), // 1: EmployeeType - PayProcessDAL.cs:1083-1112 (TotalCount joined on employee.EmployeeTypeID) [1] = new Grouping( db => (db == DBType.PostGre ? "gcm.gcmcode AS \"Code\", gcm.gcmname AS \"Name\", " : "GCM.GCMCODE AS Code, GCM.GCMNAME AS Name, ") + TotalCountSql(db == DBType.PostGre, "EMPLOYEETYPEID") + (db == DBType.PostGre ? " gcm.sortorder AS \"SortOrder\"," : " GCM.SORTORDER AS SortOrder,"), db => db == DBType.PostGre ? "gcm.sortorder, gcm.gcmcode, gcm.gcmname," : "GCM.SORTORDER, GCM.GCMCODE, GCM.GCMNAME,", db => db == DBType.PostGre ? "gcm.sortorder," : "GCM.SORTORDER,"), // 2: OU - PayProcessDAL.cs:1135-1163 (TotalCount joined on employee.WORKOUID, not // OU.OUID - GB4 counts employees whose *home* OU matches, not the pay-processed OU; // BRFL bank-details merge intentionally not ported) [2] = new Grouping( db => (db == DBType.PostGre ? "ou.organizationunitcode AS \"Code\", ou.organizationunitname AS \"Name\", " : "OU.ORGANIZATIONUNITCODE AS Code, OU.ORGANIZATIONUNITNAME AS Name, ") + TotalCountSql(db == DBType.PostGre, "WORKOUID"), db => db == DBType.PostGre ? "ou.organizationunitcode, ou.organizationunitname," : "OU.ORGANIZATIONUNITCODE, OU.ORGANIZATIONUNITNAME,", db => db == DBType.PostGre ? "ou.organizationunitcode," : "OU.ORGANIZATIONUNITCODE,"), // 3: Overall - PayProcessDAL.cs:1183-1199 (no grouping columns, single bucket) [3] = new Grouping(_ => string.Empty, _ => string.Empty, _ => string.Empty), // 4: StaffType - PayProcessDAL.cs:1215-1242 (TotalCount joined on employee.stafftypeid) [4] = new Grouping( db => (db == DBType.PostGre ? "stgcm.gcmcode AS \"Code\", stgcm.gcmname AS \"Name\", " : "STGCM.GCMCODE AS Code, STGCM.GCMNAME AS Name, ") + TotalCountSql(db == DBType.PostGre, "STAFFTYPEID"), db => db == DBType.PostGre ? "stgcm.gcmcode, stgcm.gcmname," : "STGCM.GCMCODE, STGCM.GCMNAME,", db => db == DBType.PostGre ? "stgcm.gcmcode," : "STGCM.GCMCODE,"), // 5: WorkType - PayProcessDAL.cs:1262-1289 (TotalCount joined on employee.worktypeid) [5] = new Grouping( db => (db == DBType.PostGre ? "wtgcm.gcmcode AS \"Code\", wtgcm.gcmname AS \"Name\", " : "WTGCM.GCMCODE AS Code, WTGCM.GCMNAME AS Name, ") + TotalCountSql(db == DBType.PostGre, "WORKTYPEID"), db => db == DBType.PostGre ? "wtgcm.gcmcode, wtgcm.gcmname," : "WTGCM.GCMCODE, WTGCM.GCMNAME,", db => db == DBType.PostGre ? "wtgcm.gcmcode," : "WTGCM.GCMCODE,"), // 6: PayGroup - PayProcessDAL.cs:1308-1340. GB4's TotalCount subquery for this branch is // joined into the FROM list with no correlating predicate (DownReplacePart is left // empty), an unintentional cross join that would multiply every SUM() in the row by the // pay-group count - deliberately not reproduced; TotalCount here is instead correctly // correlated on employee.PAYGROUPID, matching GB4's evident intent without its bug. [6] = new Grouping( db => (db == DBType.PostGre ? "mpg.paygroupcode AS \"Code\", mpg.paygroupname AS \"Name\", " : "MPG.PAYGROUPCODE AS Code, MPG.PAYGROUPNAME AS Name, ") + TotalCountSql(db == DBType.PostGre, "PAYGROUPID"), db => db == DBType.PostGre ? "mpg.paygroupcode, mpg.paygroupname," : "MPG.PAYGROUPCODE, MPG.PAYGROUPNAME,", db => db == DBType.PostGre ? "mpg.paygroupcode," : "MPG.PAYGROUPCODE,"), // 7: CostCenter - PayProcessDAL.cs:1365-1392 (TotalCount not selected in GB4 either) [7] = new Grouping( db => db == DBType.PostGre ? "cc.costcentercode AS \"Code\", cc.costcentername AS \"Name\"," : "CC.COSTCENTERCODE AS Code, CC.COSTCENTERNAME AS Name,", db => db == DBType.PostGre ? "cc.costcentercode, cc.costcentername," : "CC.COSTCENTERCODE, CC.COSTCENTERNAME,", db => db == DBType.PostGre ? "cc.costcentercode," : "CC.COSTCENTERCODE,"), }; private sealed class SalaryAbstractCriteria { public int PayConfigurationId { get; private init; } public int SalaryAbstractType { get; private init; } public int? PayPeriodId { get; private init; } public DateTime? PeriodFromDate { get; private init; } public DateTime? PeriodToDate { get; private init; } public List OuIds { get; } = []; public static SalaryAbstractCriteria From(CriteriaDTO? dto) { int payConfigurationId = 0, salaryAbstractType = 0, gbPeriodId = 0, monthlyWage = -1; int? payPeriodId = null; DateTime? periodFrom = null, periodTo = null; var ouIds = new List(); foreach (var attribute in dto?.SectionCriteriaList? .SelectMany(s => s.AttributesCriteriaList ?? []) ?? []) { var field = attribute.FieldName?.Trim().ToLowerInvariant(); switch (field) { case "payconfigurationid": payConfigurationId = ParseInt(attribute.FieldValue) ?? 0; break; case "salaryabstracttype": salaryAbstractType = ParseInt(attribute.FieldValue) ?? 0; break; case "gbperiodid": gbPeriodId = ParseInt(attribute.FieldValue) ?? 0; break; case "payperiodid": payPeriodId = ParseInt(attribute.FieldValue); break; case "periodfromdate": periodFrom = ParseDate(attribute.FieldValue); break; case "periodtodate": periodTo = ParseDate(attribute.FieldValue); break; case "monthlywage": monthlyWage = ParseInt(attribute.FieldValue) ?? -1; break; case "ouid": ouIds.AddRange(ParseIntList(attribute.FieldValue)); break; } } if (payConfigurationId == 0) throw new NotFoundException("Supply Criteria does not have PayConfigurationId filter or value is 0, please check."); if (salaryAbstractType is 8 or 9) throw new MethodNotAllowedException( $"SalaryAbstractType {salaryAbstractType} (period-wise comparison) is not supported - it requires MGBPERIOD/MGBPERIODDETAIL, which this GB5 build does not populate."); if (salaryAbstractType is < 0 or > 7) throw new NotFoundException($"Unknown SalaryAbstractType '{salaryAbstractType}'."); if (gbPeriodId != 0) throw new MethodNotAllowedException( "SalaryAbstractSummary's period-wise comparison mode (GbPeriodId != 0) is not supported - it requires MGBPERIOD/MGBPERIODDETAIL, which this GB5 build does not populate."); // GB4 validates these as required (PayProcessDAL.cs:883-902) but does not apply them // as a query filter for the "summary" branches ported here beyond what's noted above. if (monthlyWage == 0) { if (periodFrom is null) throw new NotFoundException("Supply Criteria does not have PeriodFromDate filter or value is empty, please check."); if (periodTo is null) throw new NotFoundException("Supply Criteria does not have PeriodToDate filter or value is empty, please check."); } else if (payPeriodId is null or 0 && periodFrom is null && periodTo is null) { throw new NotFoundException("Supply Criteria does not have PayPeriodId filter or value is 0, please check."); } return new SalaryAbstractCriteria { PayConfigurationId = payConfigurationId, SalaryAbstractType = salaryAbstractType, PayPeriodId = payPeriodId, PeriodFromDate = periodFrom, PeriodToDate = periodTo }.WithOuIds(ouIds); } private SalaryAbstractCriteria WithOuIds(IEnumerable ouIds) { OuIds.AddRange(ouIds.Distinct()); return this; } private static int? ParseInt(object? value) => int.TryParse(Raw(value), out var number) ? number : null; private static IEnumerable ParseIntList(object? value) => Raw(value) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(value => int.TryParse(value, out var id) ? id : 0) .Where(id => id != 0); private static DateTime? ParseDate(object? value) { var raw = Raw(value); if (long.TryParse(raw, out var epoch)) { try { return epoch > 100_000_000_000 ? DateTimeOffset.FromUnixTimeMilliseconds(epoch).LocalDateTime : DateTimeOffset.FromUnixTimeSeconds(epoch).LocalDateTime; } catch (ArgumentOutOfRangeException) { return null; } } return DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null; } private static string Raw(object? value) => value is JsonElement element ? element.ValueKind == JsonValueKind.String ? element.GetString() ?? string.Empty : element.GetRawText() : value?.ToString() ?? string.Empty; } }