AttendanceSummaryMonthMetricsHelper.cs 12.9 KB
using System;
using System.Collections.Generic;
using System.Linq;
using NCC.Extend.Entitys.Dto.LqAttendanceRecord;
using NCC.Extend.Entitys.Enum;
using NCC.Extend.Entitys.lq_attendance_record;
using Newtonsoft.Json.Linq;

namespace NCC.Extend
{
    /// <summary>
    /// 考勤汇总列表用:按自然月、每日一条有效记录口径统计应休/到岗/请假/迟到早退/旷工及可自快照计算的固定扣款。
    /// </summary>
    public static class AttendanceSummaryMonthMetricsHelper
    {
        private const string PersonalLeaveType = "事假";
        private const string SickLeaveType = "病假";

        /// <summary>
        /// 单月扩展指标
        /// </summary>
        public sealed class MonthMetrics
        {
            /// <summary>应休天数(考勤组月应休额度,优先取当月打卡规则快照中的分组配置)</summary>
            public decimal EntitledRestDays { get; set; }

            /// <summary>到岗天数(正常+迟到)</summary>
            public decimal OnDutyDays { get; set; }

            /// <summary>工作天数(应休+到岗)</summary>
            public decimal CompositeWorkDays { get; set; }

            /// <summary>带薪请假天数(请假状态中假种非事假、非病假;不含病假状态)</summary>
            public decimal PaidLeaveDays { get; set; }

            /// <summary>请假天数(请假+病假状态合计)</summary>
            public decimal LeaveDaysTotal { get; set; }

            /// <summary>迟到或早退分钟大于 0 的天数</summary>
            public decimal LateEarlyDays { get; set; }

            /// <summary>旷工天数</summary>
            public decimal AbsenteeismDays { get; set; }

            /// <summary>迟到/早退规则为「固定金额」时,自 F_RuleSnapshotJson 汇总</summary>
            public decimal LateDeductionAmount { get; set; }

            /// <summary>事假/病假扣款依赖日薪×倍率;未传日薪时为 0</summary>
            public decimal LeaveDeductionAmount { get; set; }

            /// <summary>旷工规则为「固定金额」时,自 F_AllRuleSnapshotJson 的旷工规则表取首条匹配单日区间</summary>
            public decimal AbsenteeismDeductionAmount { get; set; }
        }

        /// <summary>
        /// 按用户当月有效打卡记录计算扩展指标(须已按日折叠为每日一条,与汇总同步口径一致)。
        /// </summary>
        /// <param name="perDayRecords">该用户该月「每日一条」记录</param>
        /// <param name="fallbackMonthlyRestDays">无快照时的月应休兜底(主档分组)</param>
        /// <param name="dailySalaryForLeaveDeduct">日薪;为空则请假扣款金额为 0</param>
        public static MonthMetrics Compute(
            IReadOnlyList<LqAttendanceRecordEntity> perDayRecords,
            int fallbackMonthlyRestDays,
            decimal? dailySalaryForLeaveDeduct = null)
        {
            var metrics = new MonthMetrics();
            if (perDayRecords == null || perDayRecords.Count == 0)
            {
                metrics.EntitledRestDays = fallbackMonthlyRestDays;
                metrics.CompositeWorkDays = metrics.EntitledRestDays + metrics.OnDutyDays;
                return metrics;
            }

            var entitled = TryResolveMonthlyRestDays(perDayRecords, fallbackMonthlyRestDays);
            metrics.EntitledRestDays = entitled;

            decimal? leaveRate = null;
            decimal? sickRate = null;
            foreach (var r in perDayRecords)
            {
                TryReadBaseLeaveRates(r.AllRuleSnapshotJson, ref leaveRate, ref sickRate);
            }

            foreach (var r in perDayRecords)
            {
                var st = r.Status;
                if (st == (int)AttendanceRecordStatusEnum.正常 || st == (int)AttendanceRecordStatusEnum.迟到)
                {
                    metrics.OnDutyDays += 1;
                }

                if (st == (int)AttendanceRecordStatusEnum.请假)
                {
                    metrics.LeaveDaysTotal += 1;
                    var lt = ResolveLeaveType(r);
                    if (IsPaidLeaveCategory(lt))
                    {
                        metrics.PaidLeaveDays += 1;
                    }

                    if (dailySalaryForLeaveDeduct.HasValue && leaveRate.HasValue
                        && string.Equals(lt, PersonalLeaveType, StringComparison.Ordinal))
                    {
                        metrics.LeaveDeductionAmount += dailySalaryForLeaveDeduct.Value * leaveRate.Value;
                    }
                }
                else if (st == (int)AttendanceRecordStatusEnum.病假)
                {
                    metrics.LeaveDaysTotal += 1;
                    if (dailySalaryForLeaveDeduct.HasValue && sickRate.HasValue)
                    {
                        metrics.LeaveDeductionAmount += dailySalaryForLeaveDeduct.Value * sickRate.Value;
                    }
                }

                if (r.LateMinutes > 0 || r.EarlyLeaveMinutes > 0)
                {
                    metrics.LateEarlyDays += 1;
                }

                if (st == (int)AttendanceRecordStatusEnum.旷工)
                {
                    metrics.AbsenteeismDays += 1;
                }

                metrics.LateDeductionAmount += TrySumFixedLateEarlyDeduction(r);
                if (st == (int)AttendanceRecordStatusEnum.旷工)
                {
                    metrics.AbsenteeismDeductionAmount += TryPickFixedAbsenteeismDeduction(r.AllRuleSnapshotJson);
                }
            }

            metrics.CompositeWorkDays = metrics.EntitledRestDays + metrics.OnDutyDays;
            return metrics;
        }

        /// <summary>
        /// 将原始当月记录按日取最新一条(UpdateTime 优先)。
        /// </summary>
        public static List<LqAttendanceRecordEntity> CollapseToLatestPerDay(
            IEnumerable<LqAttendanceRecordEntity> monthRecords)
        {
            var list = monthRecords?.Where(x => x != null).ToList() ?? new List<LqAttendanceRecordEntity>();
            return list
                .GroupBy(x => x.AttendanceDate.Date)
                .Select(g => g.OrderByDescending(x => x.UpdateTime ?? x.CreateTime ?? DateTime.MinValue).First())
                .OrderBy(x => x.AttendanceDate)
                .ToList();
        }

        private static int TryResolveMonthlyRestDays(
            IReadOnlyList<LqAttendanceRecordEntity> perDayRecords,
            int fallback)
        {
            foreach (var r in perDayRecords.OrderByDescending(x => x.AttendanceDate))
            {
                var v = TryGetMonthlyRestFromRuleSnapshot(r.RuleSnapshotJson);
                if (v.HasValue)
                {
                    return Math.Max(0, v.Value);
                }
            }

            return Math.Max(0, fallback);
        }

        private static int? TryGetMonthlyRestFromRuleSnapshot(string json)
        {
            if (string.IsNullOrWhiteSpace(json))
            {
                return null;
            }

            try
            {
                var o = JObject.Parse(json);
                var ag = o["attendanceGroup"];
                return ag?["monthlyRestDays"]?.Value<int?>();
            }
            catch
            {
                return null;
            }
        }

        private static void TryReadBaseLeaveRates(
            string allJson,
            ref decimal? leaveRate,
            ref decimal? sickRate)
        {
            if (string.IsNullOrWhiteSpace(allJson) || (leaveRate.HasValue && sickRate.HasValue))
            {
                return;
            }

            try
            {
                var o = JObject.Parse(allJson);
                var bs = o["baseSetting"];
                if (bs == null)
                {
                    return;
                }

                if (!leaveRate.HasValue)
                {
                    leaveRate = bs["leaveDeductDailySalaryRate"]?.Value<decimal?>();
                }

                if (!sickRate.HasValue)
                {
                    sickRate = bs["sickLeaveDeductDailySalaryRate"]?.Value<decimal?>();
                }
            }
            catch
            {
                // ignore
            }
        }

        private static decimal TrySumFixedLateEarlyDeduction(LqAttendanceRecordEntity r)
        {
            if (string.IsNullOrWhiteSpace(r?.RuleSnapshotJson))
            {
                return 0m;
            }

            try
            {
                var o = JObject.Parse(r.RuleSnapshotJson);
                decimal sum = 0;
                if (r.LateMinutes > 0)
                {
                    sum += ReadFixedDeductAmount(o["lateRule"]);
                }

                if (r.EarlyLeaveMinutes > 0)
                {
                    sum += ReadFixedDeductAmount(o["earlyLeaveRule"]);
                }

                return sum;
            }
            catch
            {
                return 0m;
            }
        }

        private static decimal ReadFixedDeductAmount(JToken rule)
        {
            if (rule == null || rule.Type == JTokenType.Null)
            {
                return 0m;
            }

            var mode = rule["deductMode"]?.Value<int?>();
            if (mode != (int)AttendanceDeductModeEnum.FixedAmount)
            {
                return 0m;
            }

            return rule["deductValue"]?.Value<decimal?>() ?? 0m;
        }

        private static decimal TryPickFixedAbsenteeismDeduction(string allJson)
        {
            if (string.IsNullOrWhiteSpace(allJson))
            {
                return 0m;
            }

            try
            {
                var o = JObject.Parse(allJson);
                var arr = o["allRules"]?["absenteeismRules"] as JArray;
                if (arr == null)
                {
                    return 0m;
                }

                foreach (var token in arr)
                {
                    var minDays = token["minDays"]?.Value<decimal?>() ?? 1m;
                    var maxDays = token["maxDays"]?.Value<decimal?>();
                    if (minDays > 1m)
                    {
                        continue;
                    }

                    if (maxDays.HasValue && maxDays.Value <= 1m)
                    {
                        continue;
                    }

                    var mode = token["deductMode"]?.Value<int?>();
                    if (mode != (int)AttendanceDeductModeEnum.FixedAmount)
                    {
                        continue;
                    }

                    return token["deductValue"]?.Value<decimal?>() ?? 0m;
                }
            }
            catch
            {
                // ignore
            }

            return 0m;
        }

        private static string ResolveLeaveType(LqAttendanceRecordEntity r)
        {
            var workflows = ParseRelatedWorkflows(r.RelatedWorkflowsJson);
            var fromWf = workflows
                .FirstOrDefault(x => x != null && x.type == "请假" && !string.IsNullOrWhiteSpace(x.leaveType));
            if (fromWf != null)
            {
                return fromWf.leaveType.Trim();
            }

            return TryGetLeaveTypeLabelFromSyncRemark(r.Remark);
        }

        private static List<RelatedWorkflowItem> ParseRelatedWorkflows(string json)
        {
            if (string.IsNullOrWhiteSpace(json))
            {
                return new List<RelatedWorkflowItem>();
            }

            try
            {
                return Newtonsoft.Json.JsonConvert.DeserializeObject<List<RelatedWorkflowItem>>(json)
                       ?? new List<RelatedWorkflowItem>();
            }
            catch
            {
                return new List<RelatedWorkflowItem>();
            }
        }

        private static string TryGetLeaveTypeLabelFromSyncRemark(string remark)
        {
            if (string.IsNullOrWhiteSpace(remark))
            {
                return null;
            }

            const string prefix = "请假审批通过:";
            var idx = remark.IndexOf(prefix, StringComparison.Ordinal);
            if (idx < 0)
            {
                return null;
            }

            var tail = remark.Substring(idx + prefix.Length);
            var paren = tail.IndexOf('(');
            if (paren <= 0)
            {
                return null;
            }

            var label = tail.Substring(0, paren).Trim();
            return string.IsNullOrEmpty(label) ? null : label;
        }

        private static bool IsPaidLeaveCategory(string leaveType)
        {
            if (string.IsNullOrWhiteSpace(leaveType))
            {
                return false;
            }

            if (string.Equals(leaveType, PersonalLeaveType, StringComparison.Ordinal)
                || string.Equals(leaveType, SickLeaveType, StringComparison.Ordinal))
            {
                return false;
            }

            return true;
        }
    }
}