Explorar el Código

工资新版规则

Lyyyi hace 1 día
padre
commit
a7caf7b2db

+ 21 - 16
OASystem/OASystem.Api/Controllers/PersonnelModuleController.cs

@@ -516,28 +516,32 @@ namespace OASystem.API.Controllers
             #endregion
             #endregion
 
 
             string thisYearMonth = dto.yearMonth;
             string thisYearMonth = dto.yearMonth;
-            string preYearMonth = yearMonthDt.AddMonths(-1).ToString("yyyy-MM");
+            //string preYearMonth = yearMonthDt.AddMonths(-1).ToString("yyyy-MM");
 
 
-            // 计算本月工资起止时间 比如是2月的1号-28号,那就是2月1号的零点到3月1号的零点 
-            DateTime thisStartDt = startDt;
-            DateTime thisEndDt = endDt; //
+            //// 计算本月工资起止时间 比如是2月的1号-28号,那就是2月1号的零点到3月1号的零点 
+            //DateTime thisStartDt = startDt;
+            //DateTime thisEndDt = endDt; //
+
+            string preYearMonth ="2024-02";
+            DateTime thisStartDt = new DateTime(2026,3,1);
+            DateTime thisEndDt = new DateTime(2026, 3, 31);
 
 
             // 检查是否存在
             // 检查是否存在
-            var isExists = await _sqlSugar.Queryable<Pm_WageSheet>().AnyAsync(it => it.YearMonth == dto.yearMonth);
+            var isExists = await _sqlSugar.Queryable<Pm_WageSheet>().AnyAsync(it => it.IsDel == 0 && it.YearMonth == dto.yearMonth);
             if (isExists) return Ok(JsonView(false, $"{dto.yearMonth} 工资数据已存在。"));
             if (isExists) return Ok(JsonView(false, $"{dto.yearMonth} 工资数据已存在。"));
 
 
+            // 在职的人员ID
+            var activeEmployeeIds = await _sqlSugar.Queryable<Sys_Users>().Where(x => x.IsDel == 0).Select(x => x.Id).ToListAsync();
+
             var preWageSheetItems = await _sqlSugar.Queryable<Pm_WageSheet>()
             var preWageSheetItems = await _sqlSugar.Queryable<Pm_WageSheet>()
-                .Where(it => it.IsDel == 0 && it.YearMonth == preYearMonth)
+                .Where(it => it.IsDel == 0 && it.YearMonth == preYearMonth && it.Basic != 0m)
+                .WhereIF(activeEmployeeIds.Any(), it => activeEmployeeIds.Contains(it.UserId))
                 .ToListAsync();
                 .ToListAsync();
 
 
             if (!preWageSheetItems.Any()) return Ok(JsonView(false, "上月工资数据不存在。"));
             if (!preWageSheetItems.Any()) return Ok(JsonView(false, "上月工资数据不存在。"));
 
 
-            //处理上个月同月同人 多条数据
-            List<Pm_WageSheet> preWageSheetItems1 =  preWageSheetItems
-                .GroupBy(it => new { it.YearMonth, it.UserId })
-                .Select(it => it.FirstOrDefault(item => item.Basic != 0))
-                .Where(it => it != null)
-                .ToList()!;
+            var preWageSheetItem = preWageSheetItems.GroupBy(x => x.UserId).Select(x => x.First()).ToList();
+
 
 
             ////获取OA系统内所有用户
             ////获取OA系统内所有用户
             //var userNames = _usersRep._sqlSugar.Queryable<Sys_Users>()
             //var userNames = _usersRep._sqlSugar.Queryable<Sys_Users>()
@@ -551,7 +555,7 @@ namespace OASystem.API.Controllers
 
 
             //_result = await PayrollComputation1.SalaryCalculatorAsync(preWageSheetItems1, userNames, dto.UserId, thisYearMonth, thisStartDt, thisEndDt);
             //_result = await PayrollComputation1.SalaryCalculatorAsync(preWageSheetItems1, userNames, dto.UserId, thisYearMonth, thisStartDt, thisEndDt);
 
 
-            _result = await PayrollComputation_v1.SalaryCalculatorAsync(preWageSheetItems1, 208, thisYearMonth, startDt, endDt);
+            _result = await PayrollComputation_v1.SalaryCalculatorAsync(preWageSheetItem, 208, thisYearMonth, startDt, endDt);
 
 
             #region 批量添加
             #region 批量添加
 
 
@@ -612,10 +616,8 @@ namespace OASystem.API.Controllers
             }
             }
 
 
             wageSheets.Add(wageSheet);
             wageSheets.Add(wageSheet);
-            //获取OA系统内所有用户
-            List<UserNameView> userNames = _usersRep._sqlSugar.SqlQueryable<UserNameView>("Select Id,CnName From Sys_Users").ToList();
 
 
-            _result = await PayrollComputation1.SalaryCalculatorAsync(wageSheets, userNames, dto.UserId, dto.YearMonth, startDt, endDt);
+            _result = await PayrollComputation_v1.SalaryCalculatorAsync(wageSheets, dto.UserId, dto.YearMonth, startDt, endDt);
 
 
             if (_result.Code != 0)
             if (_result.Code != 0)
             {
             {
@@ -627,6 +629,9 @@ namespace OASystem.API.Controllers
 
 
             #region 处理返回数据
             #region 处理返回数据
 
 
+            //获取OA系统内所有用户
+            List<UserNameView> userNames = _usersRep._sqlSugar.SqlQueryable<UserNameView>("Select Id,CnName From Sys_Users").ToList();
+
             List<WageSheetInfoView> wageSheetItems = new();
             List<WageSheetInfoView> wageSheetItems = new();
             wageSheetItems = _mapper.Map<List<WageSheetInfoView>>(wageSheets1);
             wageSheetItems = _mapper.Map<List<WageSheetInfoView>>(wageSheets1);
             wageSheetItems = wageSheetItems.Select(it =>
             wageSheetItems = wageSheetItems.Select(it =>

+ 363 - 134
OASystem/OASystem.Api/OAMethodLib/PayrollComputation_v1.cs

@@ -1,9 +1,11 @@
-using OASystem.API.OAMethodLib.QiYeWeChatAPI;
+using MathNet.Numerics.LinearAlgebra.Factorization;
+using Newtonsoft.Json.Serialization;
+using NodaTime;
+using OASystem.API.OAMethodLib.QiYeWeChatAPI;
 using OASystem.Domain.Entities.PersonnelModule;
 using OASystem.Domain.Entities.PersonnelModule;
-using OASystem.Domain.ViewModels.CRM;
 using OASystem.Domain.ViewModels.PersonnelModule;
 using OASystem.Domain.ViewModels.PersonnelModule;
 using OASystem.Domain.ViewModels.QiYeWeChat;
 using OASystem.Domain.ViewModels.QiYeWeChat;
-using System.Diagnostics.Tracing;
+using System.Text.Json.Serialization;
 
 
 namespace OASystem.API.OAMethodLib;
 namespace OASystem.API.OAMethodLib;
 
 
@@ -19,15 +21,31 @@ public static class PayrollConfig
     public const decimal MealSubsidyPerDay = 10m;
     public const decimal MealSubsidyPerDay = 10m;
 
 
     // ========== 迟到早退规则 ==========
     // ========== 迟到早退规则 ==========
-    public const int FreeTotalMinutes = 10;           // 累计免罚分钟(超过此值才罚款)
-    public const decimal FinePerEvent = 50m;          // 每次事件罚款金额
+    /// <summary>
+    /// 累计免罚分钟(超过此值才罚款)
+    /// </summary>
+    public const int FreeTotalMinutes = 10;
+
+    /// <summary>
+    /// 每次事件罚款金额
+    /// </summary>
+    public const decimal FinePerEvent = 50m;         
     public const int WorkStartHour = 9;
     public const int WorkStartHour = 9;
     public const int WorkEndHour = 18;
     public const int WorkEndHour = 18;
-    public const int DefaultAllowSeconds = 300;       // 默认允许迟到/早退秒数(当规则未设置时使用)
+    /// <summary>
+    /// 默认允许迟到/早退秒数(当规则未设置时使用)
+    /// </summary>
+    public const int DefaultAllowSeconds = 300;
 
 
     // ========== 旷工判定 ==========
     // ========== 旷工判定 ==========
-    public const int HalfDayMissMinutes = 60;         // 半日旷工下限(分钟)
-    public const int FullDayMissMinutes = 180;        // 全日旷工阈值(分钟)
+    /// <summary>
+    /// 半日旷工下限(分钟)
+    /// </summary>
+    public const int HalfDayMissMinutes = 60;         
+    /// <summary>
+    /// 全日旷工阈值(分钟)
+    /// </summary>
+    public const int FullDayMissMinutes = 180;        
 
 
     // ========== 补卡规则 ==========
     // ========== 补卡规则 ==========
     public const int ProbationFreeCardCount = 2;
     public const int ProbationFreeCardCount = 2;
@@ -37,125 +55,44 @@ public static class PayrollConfig
 
 
     // ========== 请假餐补规则 ==========
     // ========== 请假餐补规则 ==========
     public const int MealDeductionLeaveHours = 3;
     public const int MealDeductionLeaveHours = 3;
-}
-
-/// <summary>
-/// 工资计算
-/// </summary>
-public static class PayrollComputation_v1
-{
-    private static Result _result = new Result();
-    private static readonly IQiYeWeChatApiService _qiYeWeChatApiService = AutofacIocManager.Instance.GetService<IQiYeWeChatApiService>();
-    private static readonly SqlSugarClient _sqlSugar = AutofacIocManager.Instance.GetService<SqlSugarClient>();
-
-    // =============================== 内部类定义 ===============================
 
 
+    // ========== 加班餐补 ==========
     /// <summary>
     /// <summary>
-    /// 解析后的打卡规则(仅用于当前计算,不缓存
+    /// 加班餐补金额(元/天)
     /// </summary>
     /// </summary>
-    private class ParsedCheckInRule
-    {
-        public uint GroupId { get; set; }
-        public int FlexOnDutyTime { get; set; } = 0;      // 允许迟到秒数
-        public int FlexOffDutyTime { get; set; } = 0;     // 允许早退秒数
-        public HashSet<DateTime> SpecialWorkDays { get; set; } = new HashSet<DateTime>();
-        public HashSet<DateTime> SpecialOffDays { get; set; } = new HashSet<DateTime>();
-    }
-
+    public const decimal OvertimeMealSubsidy = 12m;       
     /// <summary>
     /// <summary>
-    /// 考勤扣款结果
+    /// 加班起始小时(20点)
     /// </summary>
     /// </summary>
-    private class AttendancePenaltyResult
-    {
-        public decimal LateFine { get; set; }
-        public decimal EarlyFine { get; set; }
-        public decimal AbsenteeismDeduction { get; set; }
-        public decimal MealDeduction { get; set; }
-        public decimal TotalDeduction => LateFine + EarlyFine + AbsenteeismDeduction;
-        public List<ExItem> Details { get; set; } = new List<ExItem>();
-    }
-
-    /// <summary>
-    /// 假勤扣款结果
-    /// </summary>
-    private class LeaveDeductionResult
-    {
-        public decimal PersonalLeaveTotal { get; set; }
-        public decimal SickLeaveTotal { get; set; }
-        public decimal MealDeduction { get; set; }
-        public decimal TotalDeduction => PersonalLeaveTotal + SickLeaveTotal;
-        public List<ExItem> Details { get; set; } = new List<ExItem>();
-    }
-
-    /// <summary>
-    /// 补卡扣款结果
-    /// </summary>
-    private class PunchCorrectionResult
-    {
-        public decimal MissPunchFine { get; set; }
-        public decimal AbsenteeismDeduction { get; set; }
-        public decimal MealDeduction { get; set; }
-        public decimal TotalDeduction => MissPunchFine + AbsenteeismDeduction;
-        public List<ExItem> Details { get; set; } = new List<ExItem>();
-    }
-
-    /// <summary>
-    /// 扣款明细项(用于序列化)
-    /// </summary>
-    private class ExItem
-    {
-        public int SubTypeId { get; set; }
-        public string? SubType { get; set; }
-        public decimal Deduction { get; set; } = 0.00M;
-        public decimal MealDeduction { get; set; } = 0.00M;
-        public DateTime StartTimeDt { get; set; }
-        public DateTime EndTimeDt { get; set; }
-        public decimal Duration { get; set; }
-        public string Unit { get; set; } = "小时";
-        public string? Reason { get; set; }
-        public DateTime Apply_time_dt { get; set; }
-        public List<string>? Approval_name { get; set; }
-    }
-
+    public const int OvertimeStartHour = 20;              
     /// <summary>
     /// <summary>
-    /// 扣款分类容器
+    /// 加班起始分钟
     /// </summary>
     /// </summary>
-    private class ExItems
-    {
-        public string? Type { get; set; }
-        public object? ExItemInfo { get; set; }
-    }
+    public const int OvertimeStartMinute = 0;             
 
 
-    /// <summary>
-    /// 请假/调休/出差明细(用于跨月拆分)
-    /// </summary>
-    private class LeaveDetailItem
+    /// <summary>公司打卡地址白名单(根据实际设备ID填写)</summary>
+    public static readonly List<string> CompanyLocations = new()
     {
     {
-        public int TypeId { get; set; }
-        public string TypeName { get; set; } = "";
-        public DateTime Start { get; set; }
-        public DateTime End { get; set; }
-        public decimal Days { get; set; }
-        public decimal Hours { get; set; }
-        public string DtType { get; set; } = "";
-        public decimal NewDuration { get; set; }
-        public Slice_info SliceInfo { get; set; } = new Slice_info();
-        public DateTime ApplyDt { get; set; }
-        public bool IsBusinessTrip { get; set; }
-    }
+        "ZK-T1",
+        "蓝牙考勤设备",
+        "武侯区成都银泰中心in99(名都路西)"
+    };
 
 
-    /// <summary>
-    /// 判断是否为工作日(基于规则中的特殊日期和系统日历)
-    /// </summary>
-    private static bool IsWorkDayForRule( DateTime date, List<Sys_Calendar> sysCalendars)
+    /// <summary>公司WiFi名称白名单</summary>
+    public static readonly List<string> CompanyWifiNames = new()
     {
     {
-        var calendar = sysCalendars.FirstOrDefault(c => c.Dt == date.ToString("yyyy-MM-dd"));
-        if (calendar != null)
-            return calendar.IsWorkDay;
+        "Pan-American",
+    };
+}
 
 
-        // 默认周一到周五为工作日
-        return date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday;
-    }
+/// <summary>
+/// 工资计算
+/// </summary>
+public static class PayrollComputation_v1
+{
+    private static Result _result = new();
+    private static readonly IQiYeWeChatApiService _qiYeWeChatApiService = AutofacIocManager.Instance.GetService<IQiYeWeChatApiService>();
+    private static readonly SqlSugarClient _sqlSugar = AutofacIocManager.Instance.GetService<SqlSugarClient>();
 
 
     /// <summary>
     /// <summary>
     /// 计算工资
     /// 计算工资
@@ -235,11 +172,14 @@ public static class PayrollComputation_v1
                     punchResult = await CalculatePunchCorrectionAsync(acctid, pm_wsInfo.Floats, dailyWage, startDt, endDt);
                     punchResult = await CalculatePunchCorrectionAsync(acctid, pm_wsInfo.Floats, dailyWage, startDt, endDt);
                 }
                 }
 
 
-                // 计算餐补
+                // 计算加班餐补明细(按天)
+                var (overtimeMeal, overtimeDetails) = await CalculateOvertimeMealSubsidyWithDetailsAsync(acctid, startDt, endDt);
+
+                // 原有餐补计算
                 int actualWorkDays = userDailyRecords.Count(r => IsWorkDayForRule(r.Key, sys_Calendars));
                 int actualWorkDays = userDailyRecords.Count(r => IsWorkDayForRule(r.Key, sys_Calendars));
                 decimal mealSubsidy = actualWorkDays * PayrollConfig.MealSubsidyPerDay;
                 decimal mealSubsidy = actualWorkDays * PayrollConfig.MealSubsidyPerDay;
                 decimal mealDeductionTotal = attendanceResult.MealDeduction + leaveResult.MealDeduction + punchResult.MealDeduction;
                 decimal mealDeductionTotal = attendanceResult.MealDeduction + leaveResult.MealDeduction + punchResult.MealDeduction;
-                decimal mealActual = mealSubsidy - mealDeductionTotal;
+                decimal mealActual = mealSubsidy - mealDeductionTotal + overtimeMeal;   // 加班餐补累加到餐补总额
 
 
                 // 汇总扣款
                 // 汇总扣款
                 decimal totalDeduction = attendanceResult.TotalDeduction + leaveResult.TotalDeduction + punchResult.TotalDeduction
                 decimal totalDeduction = attendanceResult.TotalDeduction + leaveResult.TotalDeduction + punchResult.TotalDeduction
@@ -249,21 +189,43 @@ public static class PayrollComputation_v1
                 decimal shouldTotal = amountPayable + mealActual + pm_wsInfo.OtherHandle;
                 decimal shouldTotal = amountPayable + mealActual + pm_wsInfo.OtherHandle;
                 decimal afterTax = Math.Floor((shouldTotal - totalDeduction - pm_wsInfo.WithholdingTax) * 100) / 100;
                 decimal afterTax = Math.Floor((shouldTotal - totalDeduction - pm_wsInfo.WithholdingTax) * 100) / 100;
 
 
-                // 组装扣款明细JSON
-                var exItemsList = new List<ExItems>();
-                if (attendanceResult.Details.Count > 0)
-                    exItemsList.Add(new ExItems { Type = "打卡", ExItemInfo = attendanceResult.Details });
-                if (leaveResult.Details.Count > 0)
-                    exItemsList.Add(new ExItems { Type = "假勤", ExItemInfo = leaveResult.Details });
-                if (punchResult.Details.Count > 0)
-                    exItemsList.Add(new ExItems { Type = "补卡", ExItemInfo = punchResult.Details });
+                var exItemsList = new List<ExItems>()
+                {
+                    new(){ Type = "打卡", ExItemInfo = new List<ExItem>()},
+                    new(){ Type = "假勤", ExItemInfo = new List<ExItem>()},
+                    new(){ Type = "打卡补卡", ExItemInfo = new List<ExItem>()},
+                };
+
+                // 根据类型赋值
+                var punchItem = exItemsList.FirstOrDefault(x => x.Type == "打卡");
+                if (punchItem != null && attendanceResult.Details.Count > 0)
+                    punchItem.ExItemInfo = attendanceResult.Details;
+
+                var leaveItem = exItemsList.FirstOrDefault(x => x.Type == "假勤");
+                if (leaveItem != null)
+                {
+                    var combined = new List<ExItem>();
+                    if (leaveResult.Details.Any()) combined.AddRange(leaveResult.Details);
+                    if (overtimeDetails.Any()) combined.AddRange(overtimeDetails);
+                    leaveItem.ExItemInfo = combined;
+                }
+
+                var punchCardItem = exItemsList.FirstOrDefault(x => x.Type == "打卡补卡");
+                if (punchCardItem != null && punchResult.Details.Count > 0)
+                    punchCardItem.ExItemInfo = punchResult.Details;
 
 
-                string exItemsRemark = exItemsList.Count > 0 ? JsonConvert.SerializeObject(exItemsList) : "";
+                string exItemsRemark = JsonConvert.SerializeObject(exItemsList,
+                    new JsonSerializerSettings
+                    {
+                        DateFormatString = "yyyy-MM-dd HH:mm:ss",
+                        NullValueHandling = NullValueHandling.Ignore
+                    });
 
 
                 // 更新工资表
                 // 更新工资表
                 UpdateWageSheet(pm_wsInfo, thisYearMonth, startDt, endDt, workDays, actualWorkDays,
                 UpdateWageSheet(pm_wsInfo, thisYearMonth, startDt, endDt, workDays, actualWorkDays,
                     mealActual, leaveResult, attendanceResult, punchResult, shouldTotal, totalDeduction, afterTax, userId, exItemsRemark);
                     mealActual, leaveResult, attendanceResult, punchResult, shouldTotal, totalDeduction, afterTax, userId, exItemsRemark);
             }
             }
+
         }
         }
         catch (Exception ex)
         catch (Exception ex)
         {
         {
@@ -276,7 +238,142 @@ public static class PayrollComputation_v1
         return _result;
         return _result;
     }
     }
 
 
-    // =============================== 辅助方法 ===============================
+
+    #region 内部类定义
+
+    /// <summary>
+    /// 解析后的打卡规则(仅用于当前计算,不缓存)
+    /// </summary>
+    private class ParsedCheckInRule
+    {
+        public uint GroupId { get; set; }
+        public int FlexOnDutyTime { get; set; } = 0;      // 允许迟到秒数
+        public int FlexOffDutyTime { get; set; } = 0;     // 允许早退秒数
+        public HashSet<DateTime> SpecialWorkDays { get; set; } = new HashSet<DateTime>();
+        public HashSet<DateTime> SpecialOffDays { get; set; } = new HashSet<DateTime>();
+    }
+
+    /// <summary>
+    /// 考勤扣款结果
+    /// </summary>
+    private class AttendancePenaltyResult
+    {
+        public decimal LateFine { get; set; }
+        public decimal EarlyFine { get; set; }
+        public decimal AbsenteeismDeduction { get; set; }
+        public decimal MealDeduction { get; set; }
+        public decimal TotalDeduction => LateFine + EarlyFine + AbsenteeismDeduction;
+        public List<ExItem> Details { get; set; } = new List<ExItem>();
+    }
+
+    /// <summary>
+    /// 假勤扣款结果
+    /// </summary>
+    private class LeaveDeductionResult
+    {
+        public decimal PersonalLeaveTotal { get; set; }
+        public decimal SickLeaveTotal { get; set; }
+        public decimal MealDeduction { get; set; }
+        public decimal TotalDeduction => PersonalLeaveTotal + SickLeaveTotal;
+        public List<ExItem> Details { get; set; } = new List<ExItem>();
+    }
+
+    /// <summary>
+    /// 补卡扣款结果
+    /// </summary>
+    private class PunchCorrectionResult
+    {
+        public decimal MissPunchFine { get; set; }
+        public decimal AbsenteeismDeduction { get; set; }
+        public decimal MealDeduction { get; set; }
+        public decimal TotalDeduction => MissPunchFine + AbsenteeismDeduction;
+        public List<ExItem> Details { get; set; } = new List<ExItem>();
+    }
+
+    /// <summary>
+    /// 扣款明细项(用于序列化)
+    /// </summary>
+    private class ExItem
+    {
+        [JsonProperty("SubTypeId")]
+        public int SubTypeId { get; set; }
+        [JsonProperty("SubType")]
+        public string? SubType { get; set; }
+        [JsonProperty("Deduction")]
+        public decimal Deduction { get; set; } = 0.00M;
+        [JsonProperty("MealDeduction")]
+        public decimal MealDeduction { get; set; } = 0.00M;
+        [JsonProperty("StartTimeDt")]
+        public DateTime StartTimeDt { get; set; }
+        [JsonProperty("EndTimeDt")]
+        public DateTime EndTimeDt { get; set; }
+        [JsonProperty("Duration")]
+        public decimal Duration { get; set; }
+        [JsonProperty("Unit")]
+        public string Unit { get; set; } = "小时";
+        [JsonProperty("Reason")]
+        public string? Reason { get; set; }
+        [JsonProperty("Apply_time_dt")]
+        public DateTime Apply_time_dt { get; set; }
+        [JsonProperty("Approval_name")]
+        public List<string>? Approval_name { get; set; }
+    }
+
+    /// <summary>
+    /// 扣款分类容器
+    /// </summary>
+    private class ExItems
+    {
+        [JsonProperty("Type")]
+        public string? Type { get; set; }
+
+        [JsonProperty("Ex_ItemInfo")]
+        public object? ExItemInfo { get; set; }
+    }
+
+    /// <summary>
+    /// 请假/调休/出差明细(用于跨月拆分)
+    /// </summary>
+    private class LeaveDetailItem
+    {
+        public int TypeId { get; set; }
+        public string TypeName { get; set; } = "";
+        public DateTime Start { get; set; }
+        public DateTime End { get; set; }
+        public decimal Days { get; set; }
+        public decimal Hours { get; set; }
+        public string DtType { get; set; } = "";
+        public decimal NewDuration { get; set; }
+        public Slice_info SliceInfo { get; set; } = new Slice_info();
+        public DateTime ApplyDt { get; set; }
+        public bool IsBusinessTrip { get; set; }
+    }
+
+    #endregion
+
+    #region 辅助方法
+
+    /// <summary>
+    /// 加班时段(支持跨天,餐补归属开始日期)
+    /// </summary>
+    private class OvertimePeriodDetail
+    {
+        public DateTime StartDate { get; set; }   // 加班开始日期(仅日期)
+        public DateTime EndTime { get; set; }     // 加班结束时间(包含时分秒,可能跨天)
+    }
+
+    /// <summary>
+    /// 判断是否为工作日(基于规则中的特殊日期和系统日历)
+    /// </summary>
+    private static bool IsWorkDayForRule(DateTime date, List<Sys_Calendar> sysCalendars)
+    {
+        var calendar = sysCalendars.FirstOrDefault(c => c.Dt == date.ToString("yyyy-MM-dd"));
+        if (calendar != null)
+            return calendar.IsWorkDay;
+
+        // 默认周一到周五为工作日
+        return date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday;
+    }
 
 
     private static decimal GetTotalSalaryBase(Pm_WageSheet wageSheet)
     private static decimal GetTotalSalaryBase(Pm_WageSheet wageSheet)
     {
     {
@@ -305,8 +402,8 @@ public static class PayrollComputation_v1
         var dict = new Dictionary<DateTime, CheckInDayRoot>();
         var dict = new Dictionary<DateTime, CheckInDayRoot>();
 
 
         var allRecords = view.datas ?? new List<CheckInDayRoot>();
         var allRecords = view.datas ?? new List<CheckInDayRoot>();
-        IEnumerable<CheckInDayRoot> userRecords  = allRecords.Where(r => r.base_info.name == name || r.base_info.name.Contains(name)).OrderBy(x => x.base_info.DateDt);
-        
+        IEnumerable<CheckInDayRoot> userRecords = allRecords.Where(r => r.base_info.name == name || r.base_info.name.Contains(name)).OrderBy(x => x.base_info.DateDt);
+
         foreach (var record in userRecords)
         foreach (var record in userRecords)
         {
         {
             DateTime date = record.base_info.DateDt.Date;
             DateTime date = record.base_info.DateDt.Date;
@@ -387,6 +484,8 @@ public static class PayrollComputation_v1
                                 SubTypeId = 1,
                                 SubTypeId = 1,
                                 SubType = "迟到",
                                 SubType = "迟到",
                                 Deduction = deduction,
                                 Deduction = deduction,
+                                Duration = minutes,
+                                Unit = "分钟",
                                 StartTimeDt = date,
                                 StartTimeDt = date,
                                 Reason = reason
                                 Reason = reason
                             });
                             });
@@ -404,6 +503,8 @@ public static class PayrollComputation_v1
                                     SubTypeId = 4,
                                     SubTypeId = 4,
                                     SubType = "旷工(迟到超时)",
                                     SubType = "旷工(迟到超时)",
                                     Deduction = ded,
                                     Deduction = ded,
+                                    Duration = minutes,
+                                    Unit = "分钟",
                                     StartTimeDt = date,
                                     StartTimeDt = date,
                                     Reason = reason + $"迟到{minutes}分钟,按旷工半天处理,扣除餐补"
                                     Reason = reason + $"迟到{minutes}分钟,按旷工半天处理,扣除餐补"
                                 });
                                 });
@@ -418,6 +519,8 @@ public static class PayrollComputation_v1
                                     SubTypeId = 4,
                                     SubTypeId = 4,
                                     SubType = "旷工(迟到超时)",
                                     SubType = "旷工(迟到超时)",
                                     Deduction = ded,
                                     Deduction = ded,
+                                    Duration = minutes,
+                                    Unit = "分钟",
                                     StartTimeDt = date,
                                     StartTimeDt = date,
                                     Reason = reason + $"迟到{minutes}分钟,按旷工全天处理,扣除餐补"
                                     Reason = reason + $"迟到{minutes}分钟,按旷工全天处理,扣除餐补"
                                 });
                                 });
@@ -462,6 +565,8 @@ public static class PayrollComputation_v1
                                 SubTypeId = 2,
                                 SubTypeId = 2,
                                 SubType = "早退",
                                 SubType = "早退",
                                 Deduction = deduction,
                                 Deduction = deduction,
+                                Duration = minutes,
+                                Unit = "分钟",
                                 StartTimeDt = date,
                                 StartTimeDt = date,
                                 Reason = reason
                                 Reason = reason
                             });
                             });
@@ -477,9 +582,11 @@ public static class PayrollComputation_v1
                                 result.Details.Add(new ExItem
                                 result.Details.Add(new ExItem
                                 {
                                 {
                                     SubTypeId = 4,
                                     SubTypeId = 4,
-                                    SubType = "旷工(早退超时)",
+                                    SubType = "旷工",
                                     Deduction = ded,
                                     Deduction = ded,
                                     StartTimeDt = date,
                                     StartTimeDt = date,
+                                    Duration = minutes,
+                                    Unit = "分钟",
                                     Reason = reason + $"早退{minutes}分钟,按旷工半天处理,扣除餐补"
                                     Reason = reason + $"早退{minutes}分钟,按旷工半天处理,扣除餐补"
                                 });
                                 });
                             }
                             }
@@ -491,9 +598,11 @@ public static class PayrollComputation_v1
                                 result.Details.Add(new ExItem
                                 result.Details.Add(new ExItem
                                 {
                                 {
                                     SubTypeId = 4,
                                     SubTypeId = 4,
-                                    SubType = "旷工(早退超时)",
+                                    SubType = "旷工",
                                     Deduction = ded,
                                     Deduction = ded,
                                     StartTimeDt = date,
                                     StartTimeDt = date,
+                                    Duration = minutes,
+                                    Unit = "分钟",
                                     Reason = reason + $"早退{minutes}分钟,按旷工全天处理,扣除餐补"
                                     Reason = reason + $"早退{minutes}分钟,按旷工全天处理,扣除餐补"
                                 });
                                 });
                             }
                             }
@@ -509,9 +618,11 @@ public static class PayrollComputation_v1
                             result.Details.Add(new ExItem
                             result.Details.Add(new ExItem
                             {
                             {
                                 SubTypeId = 4,
                                 SubTypeId = 4,
-                                SubType = "旷工(缺卡)",
+                                SubType = "旷工",
                                 Deduction = ded,
                                 Deduction = ded,
                                 StartTimeDt = date,
                                 StartTimeDt = date,
+                                Duration = 0.5m,
+                                Unit = "天",
                                 Reason = reason + "缺卡,按半天旷工处理,扣除餐补"
                                 Reason = reason + "缺卡,按半天旷工处理,扣除餐补"
                             });
                             });
                         }
                         }
@@ -528,6 +639,8 @@ public static class PayrollComputation_v1
                                 SubType = "旷工",
                                 SubType = "旷工",
                                 Deduction = ded,
                                 Deduction = ded,
                                 StartTimeDt = date,
                                 StartTimeDt = date,
+                                Duration = 0.5m,
+                                Unit = "天",
                                 Reason = reason + "旷工半天,扣除餐补"
                                 Reason = reason + "旷工半天,扣除餐补"
                             });
                             });
                         }
                         }
@@ -541,6 +654,8 @@ public static class PayrollComputation_v1
                                 SubTypeId = 4,
                                 SubTypeId = 4,
                                 SubType = "旷工",
                                 SubType = "旷工",
                                 Deduction = ded,
                                 Deduction = ded,
+                                Duration = 1m,
+                                Unit = "天",
                                 StartTimeDt = date,
                                 StartTimeDt = date,
                                 Reason = reason + "旷工全天,扣除餐补"
                                 Reason = reason + "旷工全天,扣除餐补"
                             });
                             });
@@ -623,12 +738,12 @@ public static class PayrollComputation_v1
                 // 年假、调休假不扣薪、视情况扣除餐补
                 // 年假、调休假不扣薪、视情况扣除餐补
                 if (isMealDeduct)
                 if (isMealDeduct)
                 {
                 {
-                    if (item.Hours >=0 || item.Days >= 0.5m)
+                    if (item.Hours >= 0 || item.Days >= 0.5m)
                     {
                     {
                         leaveMeal = PayrollConfig.MealSubsidyPerDay;
                         leaveMeal = PayrollConfig.MealSubsidyPerDay;
                     }
                     }
                 }
                 }
-                    
+
                 result.MealDeduction += leaveMeal;
                 result.MealDeduction += leaveMeal;
 
 
                 // 产生扣款时记录
                 // 产生扣款时记录
@@ -850,6 +965,116 @@ public static class PayrollComputation_v1
         return result;
         return result;
     }
     }
 
 
+    /// <summary>
+    /// 获取审核通过的加班时段明细(结束时间 ≥ 20:00,且已裁剪至当月范围内)
+    /// </summary>
+    private static async Task<List<OvertimePeriodDetail>> GetApprovedOvertimePeriodsDetailAsync(string acctid, DateTime monthStart, DateTime monthEnd)
+    {
+        var result = new List<OvertimePeriodDetail>();
+        if (string.IsNullOrEmpty(acctid)) return result;
+
+        // 加班审批的 subType = 5
+        var overtimeApprovals = await _qiYeWeChatApiService.GetApprovalDetailsAsync(monthStart, monthEnd, acctid, 2, 5);
+        foreach (var sp in overtimeApprovals)
+        {
+            if (sp.sp_status != 2) continue;                     // 只取已通过
+            var applyData = sp.apply_data;
+            if (applyData == null) continue;
+
+            var attendanceControl = applyData.contents?.FirstOrDefault(c => c.control == "Attendance");
+            if (attendanceControl?.value?.attendance?.date_range == null) continue;
+
+            var dateRange = attendanceControl.value.attendance.date_range;
+            DateTime start = dateRange.new_begin_dt;
+            DateTime end = dateRange.new_end_dt;
+            if (start > end) (start, end) = (end, start);
+
+            // 只处理结束时间 ≥ 20:00 的加班(否则没有加班餐补)
+            if (end.TimeOfDay < new TimeSpan(PayrollConfig.OvertimeStartHour, PayrollConfig.OvertimeStartMinute, 0))
+                continue;
+
+            // 跨月裁剪:只保留与当前月份重叠的部分(结束时间不能超出当月最后一天)
+            if (end < monthStart || start > monthEnd) continue;
+            DateTime actualEnd = end > monthEnd ? monthEnd.Date.AddDays(1).AddSeconds(-1) : end;
+
+            result.Add(new OvertimePeriodDetail
+            {
+                StartDate = start,
+                EndTime = actualEnd
+            });
+        }
+        return result.OrderBy(x => x.StartDate).ToList();
+    }
+
+    /// <summary>
+    /// 计算加班餐补(基于审批与打卡记录)
+    /// </summary>
+    private static async Task<(decimal total, List<ExItem> details)> CalculateOvertimeMealSubsidyWithDetailsAsync(
+        string acctid, DateTime monthStart, DateTime monthEnd)
+    {
+        // 结束时间 默认加一天 至第二天的凌晨零点
+        monthEnd = monthEnd.AddDays(1);
+        var details = new List<ExItem>();
+        var periods = await GetApprovedOvertimePeriodsDetailAsync(acctid, monthStart, monthEnd);
+        if (periods.Count == 0) return (0, details);
+
+        // 获取该员工当月的所有打卡明细
+        var resp = await _qiYeWeChatApiService.GetCheckinDataAsync(new List<string> { acctid }, 3, monthStart, monthEnd);
+        if (resp.errcode != 0) return (0, details);
+
+        // 筛选符合条件的打卡记录:
+        // 1. 下班打卡(IsEveningCheckIn)
+        // 2. 非外出打卡
+        // 3. 无缺卡异常(IsMissPunch)
+        // 4. 打卡地点在公司白名单内(位置名称或WiFi名称匹配)
+        // 5. 打卡时间 >= 当天 20:00(在后续 valid 判断中已包含,这里先放宽筛选)
+        var validCheckins = resp.checkindata
+            .Where(x => x.IsEveningCheckIn && !x.IsOutCheckIn && !x.IsMissPunch &&
+                        (PayrollConfig.CompanyLocations.Contains(x.location_title) ||
+                         PayrollConfig.CompanyWifiNames.Contains(x.wifiname)))
+            .ToList();
+
+        decimal total = 0;
+        foreach (var period in periods)
+        {
+            // 查找是否存在一条打卡记录满足:
+            // - 打卡时间 >= 加班结束时间(period.EndTime)
+            // - 跨日允许次日凌晨 6:00 之前
+            bool valid = validCheckins.Any(r =>
+            {
+                DateTime checkTime = r.checkin_time_dt;
+                DateTime end = period.EndTime;
+                if (checkTime < end) return false;
+
+                // 如果打卡日期 > 加班结束日期(跨天)
+                if (checkTime.Date > end.Date)
+                {
+                    // 只允许跨一天,且打卡时间不晚于次日 06:00
+                    return checkTime.Date == end.Date.AddDays(1) && checkTime.TimeOfDay <= TimeSpan.FromHours(6);
+                }
+                return true;
+            });
+
+            if (valid)
+            {
+                total += PayrollConfig.OvertimeMealSubsidy;
+                details.Add(new ExItem
+                {
+                    SubTypeId = 6,
+                    SubType = "加班餐补",
+                    Deduction = 0,
+                    MealDeduction = 0,
+                    StartTimeDt = period.StartDate,
+                    EndTimeDt = period.EndTime,
+                    Duration = PayrollConfig.OvertimeMealSubsidy,
+                    Unit = "元",
+                    Reason = $"加班餐补({period.StartDate:yyyy-MM-dd HH:mm} 加班至 {period.EndTime:HH:mm} 后合规打卡)"
+                });
+            }
+        }
+        return (total, details);
+    }
+
     private static string GetLeaveTypeName(int typeId)
     private static string GetLeaveTypeName(int typeId)
     {
     {
         return typeId switch
         return typeId switch
@@ -887,6 +1112,8 @@ public static class PayrollComputation_v1
         ws.Ex_ItemsRemark = exItemsRemark;
         ws.Ex_ItemsRemark = exItemsRemark;
         ws.LastUpdateUserId = userId;
         ws.LastUpdateUserId = userId;
         ws.LastUpdateDt = DateTime.Now;
         ws.LastUpdateDt = DateTime.Now;
+        ws.CreateUserId = userId;
+        ws.CreateTime = DateTime.Now;
     }
     }
 
 
     public static async Task<int> GetWorkDays(string yearMonth)
     public static async Task<int> GetWorkDays(string yearMonth)
@@ -900,4 +1127,6 @@ public static class PayrollComputation_v1
     {
     {
         return Math.Floor(value * 100) / 100;
         return Math.Floor(value * 100) / 100;
     }
     }
+
+    #endregion
 }
 }

+ 1 - 1
OASystem/OASystem.Domain/ViewModels/PersonnelModule/WageSheetView.cs

@@ -509,7 +509,7 @@ namespace OASystem.Domain.ViewModels.PersonnelModule
         /// </summary>
         /// </summary>
         public string? Type { get; set; }
         public string? Type { get; set; }
 
 
-        public Object Ex_ItemInfo { get; set; }
+        public object? Ex_ItemInfo { get; set; }
     }
     }