| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132 |
- using MathNet.Numerics.LinearAlgebra.Factorization;
- using Newtonsoft.Json.Serialization;
- using NodaTime;
- using OASystem.API.OAMethodLib.QiYeWeChatAPI;
- using OASystem.Domain.Entities.PersonnelModule;
- using OASystem.Domain.ViewModels.PersonnelModule;
- using OASystem.Domain.ViewModels.QiYeWeChat;
- using System.Text.Json.Serialization;
- namespace OASystem.API.OAMethodLib;
- /// <summary>
- /// 工资计算公共参数配置
- /// </summary>
- public static class PayrollConfig
- {
- // ========== 基础参数 ==========
- public const decimal ChengduMinimumWage = 2100m;
- public const decimal SickLeaveWageRatio = 0.8m;
- public const decimal WorkHoursPerDay = 7.5m;
- public const decimal MealSubsidyPerDay = 10m;
- // ========== 迟到早退规则 ==========
- /// <summary>
- /// 累计免罚分钟(超过此值才罚款)
- /// </summary>
- public const int FreeTotalMinutes = 10;
- /// <summary>
- /// 每次事件罚款金额
- /// </summary>
- public const decimal FinePerEvent = 50m;
- public const int WorkStartHour = 9;
- public const int WorkEndHour = 18;
- /// <summary>
- /// 默认允许迟到/早退秒数(当规则未设置时使用)
- /// </summary>
- public const int DefaultAllowSeconds = 300;
- // ========== 旷工判定 ==========
- /// <summary>
- /// 半日旷工下限(分钟)
- /// </summary>
- public const int HalfDayMissMinutes = 60;
- /// <summary>
- /// 全日旷工阈值(分钟)
- /// </summary>
- public const int FullDayMissMinutes = 180;
- // ========== 补卡规则 ==========
- public const int ProbationFreeCardCount = 2;
- public const int RegularFreeCardCount = 3;
- public const decimal PunchCorrectionLowFine = 10m;
- public const bool PunchCorrectionToAbsentAfterFourth = true;
- // ========== 请假餐补规则 ==========
- public const int MealDeductionLeaveHours = 3;
- // ========== 加班餐补 ==========
- /// <summary>
- /// 加班餐补金额(元/天)
- /// </summary>
- public const decimal OvertimeMealSubsidy = 12m;
- /// <summary>
- /// 加班起始小时(20点)
- /// </summary>
- public const int OvertimeStartHour = 20;
- /// <summary>
- /// 加班起始分钟
- /// </summary>
- public const int OvertimeStartMinute = 0;
- /// <summary>公司打卡地址白名单(根据实际设备ID填写)</summary>
- public static readonly List<string> CompanyLocations = new()
- {
- "ZK-T1",
- "蓝牙考勤设备",
- "武侯区成都银泰中心in99(名都路西)"
- };
- /// <summary>公司WiFi名称白名单</summary>
- public static readonly List<string> CompanyWifiNames = new()
- {
- "Pan-American",
- };
- }
- /// <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>
- public static async Task<Result> SalaryCalculatorAsync(
- List<Pm_WageSheet> pm_WageSheetDattaSources, int userId, string thisYearMonth, DateTime startDt, DateTime endDt)
- {
- if (pm_WageSheetDattaSources.Count <= 0)
- {
- _result.Msg = "计算工资传入数据为空!";
- return _result;
- }
- // 获取当月应出勤天数
- int workDays = await GetWorkDays(thisYearMonth);
- if (workDays <= 0)
- {
- _result.Msg = $"{thisYearMonth} 工作日未设置,请前往《工作日管理页面》设置!";
- return _result;
- }
- var userIds = pm_WageSheetDattaSources.Select(ws => ws.UserId).Distinct().ToList();
- var userIdInfos = await GetSysUserWeChatIds(userIds);
- if (!userIdInfos.Any())
- {
- _result.Msg = $"企业微信Id获取失败,请联系管理员!";
- return _result;
- }
- List<string> qyWhchatIdList = userIdInfos.Select(it => it.WeChatId ?? "").Distinct().ToList();
- // 获取打卡日报数据(包含 exception_infos)
- CheckInDayDataView checkInDayDataView = await _qiYeWeChatApiService.GetCheckInDayDataAsync(qyWhchatIdList, startDt, endDt);
- if (checkInDayDataView.errcode != 0)
- {
- _result.Msg = $"【企业微信】【打卡】【获取时间段内所有日打卡】【Msg】{checkInDayDataView.errmsg}";
- return _result;
- }
- // 获取系统日历(用于判断工作日)
- List<Sys_Calendar> sys_Calendars = await GetSysCalendars(startDt, endDt);
- string _name = "";
- try
- {
- foreach (var pm_wsInfo in pm_WageSheetDattaSources)
- {
- _name = userIdInfos.Find(it => it.Id == pm_wsInfo.UserId)?.CnName ?? "Unknown";
- // 获取员工当月的打卡日报数据(按日期分组)
- var userDailyRecords = GetUserDailyRecords(checkInDayDataView, _name);
- // 计算日薪
- decimal amountPayable = GetTotalSalaryBase(pm_wsInfo);
- decimal dailyWage = ConvertToDecimal(amountPayable / workDays);
- decimal sickLeaveDailyWage = ConvertToDecimal((PayrollConfig.ChengduMinimumWage * PayrollConfig.SickLeaveWageRatio) / workDays);
- // 获取员工企业微信ID(用于获取审批数据)
- string acctid = userDailyRecords.Count > 0 ? userDailyRecords.First().Value.base_info.acctid : "";
- // 计算考勤扣款(使用日报中的 exception_infos)
- var attendanceResult = CalculateAttendanceFromDailyRecords(userDailyRecords, dailyWage, sys_Calendars);
- // 计算假勤扣款(请假、出差等)
- var leaveResult = new LeaveDeductionResult();
- // 计算补卡扣款
- var punchResult = new PunchCorrectionResult();
- // 当月假勤次数
- var spCount = userDailyRecords.Sum(x => x.Value.sp_items?.Sum(y => y.count) ?? 0);
- //当月存在假勤的情况,执行假勤扣除
- if (spCount > 0)
- {
- leaveResult = await CalculateLeaveDeductionAsync(acctid, dailyWage, sickLeaveDailyWage, amountPayable, workDays, startDt, endDt, sys_Calendars);
- 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));
- decimal mealSubsidy = actualWorkDays * PayrollConfig.MealSubsidyPerDay;
- decimal mealDeductionTotal = attendanceResult.MealDeduction + leaveResult.MealDeduction + punchResult.MealDeduction;
- decimal mealActual = mealSubsidy - mealDeductionTotal + overtimeMeal; // 加班餐补累加到餐补总额
- // 汇总扣款
- decimal totalDeduction = attendanceResult.TotalDeduction + leaveResult.TotalDeduction + punchResult.TotalDeduction
- + pm_wsInfo.WithholdingInsurance + pm_wsInfo.ReservedFunds + pm_wsInfo.OtherDeductions;
- // 应发合计
- decimal shouldTotal = amountPayable + mealActual + pm_wsInfo.OtherHandle;
- decimal afterTax = Math.Floor((shouldTotal - totalDeduction - pm_wsInfo.WithholdingTax) * 100) / 100;
- 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 = JsonConvert.SerializeObject(exItemsList,
- new JsonSerializerSettings
- {
- DateFormatString = "yyyy-MM-dd HH:mm:ss",
- NullValueHandling = NullValueHandling.Ignore
- });
- // 更新工资表
- UpdateWageSheet(pm_wsInfo, thisYearMonth, startDt, endDt, workDays, actualWorkDays,
- mealActual, leaveResult, attendanceResult, punchResult, shouldTotal, totalDeduction, afterTax, userId, exItemsRemark);
- }
- }
- catch (Exception ex)
- {
- _result.Msg = $"【{_name}】【Msg:{ex.Message}】";
- return _result;
- }
- _result.Code = 0;
- _result.Data = pm_WageSheetDattaSources;
- 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)
- {
- return wageSheet.Basic + wageSheet.Floats + wageSheet.PostAllowance + wageSheet.InformationSecurityFee + wageSheet.OtherSubsidies;
- }
- private static async Task<List<Sys_Calendar>> GetSysCalendars(DateTime startDt, DateTime endDt)
- {
- string sql = string.Format("Select * From Sys_Calendar Where Isdel = 0 And Dt between '{0}' And '{1}'",
- startDt.ToString("yyyy-MM-dd"), endDt.ToString("yyyy-MM-dd"));
- return await _sqlSugar.SqlQueryable<Sys_Calendar>(sql).ToListAsync();
- }
- private static async Task<List<UserWeChatIdView>> GetSysUserWeChatIds(List<int> ints)
- {
- return await _sqlSugar.Queryable<Sys_Users>().Where(u => ints.Contains(u.Id) && u.IsDel == 0)
- .Select(u => new UserWeChatIdView { Id = u.Id, CnName = u.CnName, WeChatId = u.QiyeChatUserId })
- .ToListAsync();
- }
- /// <summary>
- /// 获取员工每日的打卡日报数据(按日期索引)
- /// </summary>
- private static Dictionary<DateTime, CheckInDayRoot> GetUserDailyRecords(CheckInDayDataView view, string name)
- {
- var dict = new Dictionary<DateTime, 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);
- foreach (var record in userRecords)
- {
- DateTime date = record.base_info.DateDt.Date;
- dict[date] = record;
- }
- return dict;
- }
- /// <summary>
- /// 基于日报数据中的 exception_infos 计算考勤扣款(次数累计 + 分时段罚款)
- /// </summary>
- private static AttendancePenaltyResult CalculateAttendanceFromDailyRecords(
- Dictionary<DateTime, CheckInDayRoot> dailyRecords,
- decimal dailyWage,
- List<Sys_Calendar> sysCalendars)
- {
- var result = new AttendancePenaltyResult();
- // 统计本月内迟到/早退次数(仅统计时长<60分钟的事件)
- int lateEarlyCount = 0; // 总次数(用于判定是否超过2次)
- int lateCount = 0; // 迟到次数(保留,暂未独立使用)
- int earlyCount = 0; // 早退次数
- foreach (var kvp in dailyRecords)
- {
- DateTime date = kvp.Key;
- var record = kvp.Value;
- // 判断是否为工作日(若不是工作日,不计算考勤扣款)
- bool isWorkDay = IsWorkDayForRule(date, sysCalendars);
- if (!isWorkDay) continue;
- var exceptions = record.exception_infos ?? new List<ExceptionInfo>();
- foreach (var ex in exceptions)
- {
- int exceptionType = ex.exception; // 1迟到 2早退 3缺卡 4旷工
- int minutes = ex.duration / 60; // 时长(分钟)
- string reason = $"{date:yyyy-MM-dd} ";
- decimal deduction = 0m;
- switch (exceptionType)
- {
- case 1: // 迟到
- if (minutes < 60)
- {
- // 迟到时长小于60分钟,计入次数,并根据分钟数确定罚款
- lateEarlyCount++;
- lateCount++;
- if (minutes < 10) // 不足10分钟:第3次起罚50
- {
- if (lateEarlyCount >= 3)
- {
- deduction = PayrollConfig.FinePerEvent; // 50元
- result.LateFine += deduction;
- reason += $"迟到{minutes}分钟(第{lateEarlyCount}次),按次数罚款{deduction}元";
- }
- else
- {
- reason += $"迟到{minutes}分钟(第{lateEarlyCount}次),前2次不处罚";
- }
- }
- else if (minutes >= 10 && minutes < 30) // 9:10~9:30 => 罚款20
- {
- deduction = 20m;
- result.LateFine += deduction;
- reason += $"迟到{minutes}分钟,罚款{deduction}元";
- }
- else if (minutes >= 30 && minutes < 60) // 9:30~9:59 => 罚款50
- {
- deduction = PayrollConfig.FinePerEvent; // 50元
- result.LateFine += deduction;
- reason += $"迟到{minutes}分钟,罚款{deduction}元";
- }
- result.Details.Add(new ExItem
- {
- SubTypeId = 1,
- SubType = "迟到",
- Deduction = deduction,
- Duration = minutes,
- Unit = "分钟",
- StartTimeDt = date,
- Reason = reason
- });
- }
- else
- {
- // 迟到≥60分钟,按旷工处理
- if (minutes >= PayrollConfig.HalfDayMissMinutes && minutes <= PayrollConfig.FullDayMissMinutes)
- {
- decimal ded = ConvertToDecimal(dailyWage / 2);
- result.AbsenteeismDeduction += ded;
- result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
- result.Details.Add(new ExItem
- {
- SubTypeId = 4,
- SubType = "旷工(迟到超时)",
- Deduction = ded,
- Duration = minutes,
- Unit = "分钟",
- StartTimeDt = date,
- Reason = reason + $"迟到{minutes}分钟,按旷工半天处理,扣除餐补"
- });
- }
- else // minutes > 180
- {
- decimal ded = ConvertToDecimal(dailyWage);
- result.AbsenteeismDeduction += ded;
- result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
- result.Details.Add(new ExItem
- {
- SubTypeId = 4,
- SubType = "旷工(迟到超时)",
- Deduction = ded,
- Duration = minutes,
- Unit = "分钟",
- StartTimeDt = date,
- Reason = reason + $"迟到{minutes}分钟,按旷工全天处理,扣除餐补"
- });
- }
- }
- break;
- case 2: // 早退
- if (minutes < 60)
- {
- lateEarlyCount++;
- earlyCount++;
- if (minutes < 10)
- {
- if (lateEarlyCount >= 3)
- {
- deduction = PayrollConfig.FinePerEvent;
- result.EarlyFine += deduction;
- reason += $"早退{minutes}分钟(第{lateEarlyCount}次),按次数罚款{deduction}元";
- }
- else
- {
- reason += $"早退{minutes}分钟(第{lateEarlyCount}次),前2次不处罚";
- }
- }
- else if (minutes >= 10 && minutes < 30)
- {
- deduction = 20m;
- result.EarlyFine += deduction;
- reason += $"早退{minutes}分钟,罚款{deduction}元";
- }
- else if (minutes >= 30 && minutes < 60)
- {
- deduction = PayrollConfig.FinePerEvent; // 50元
- result.EarlyFine += deduction;
- reason += $"早退{minutes}分钟,罚款{deduction}元";
- }
- result.Details.Add(new ExItem
- {
- SubTypeId = 2,
- SubType = "早退",
- Deduction = deduction,
- Duration = minutes,
- Unit = "分钟",
- StartTimeDt = date,
- Reason = reason
- });
- }
- else
- {
- // 早退≥60分钟,按旷工处理
- if (minutes >= PayrollConfig.HalfDayMissMinutes && minutes <= PayrollConfig.FullDayMissMinutes)
- {
- decimal ded = ConvertToDecimal(dailyWage / 2);
- result.AbsenteeismDeduction += ded;
- result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
- result.Details.Add(new ExItem
- {
- SubTypeId = 4,
- SubType = "旷工",
- Deduction = ded,
- StartTimeDt = date,
- Duration = minutes,
- Unit = "分钟",
- Reason = reason + $"早退{minutes}分钟,按旷工半天处理,扣除餐补"
- });
- }
- else
- {
- decimal ded = ConvertToDecimal(dailyWage);
- result.AbsenteeismDeduction += ded;
- result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
- result.Details.Add(new ExItem
- {
- SubTypeId = 4,
- SubType = "旷工",
- Deduction = ded,
- StartTimeDt = date,
- Duration = minutes,
- Unit = "分钟",
- Reason = reason + $"早退{minutes}分钟,按旷工全天处理,扣除餐补"
- });
- }
- }
- break;
- // 缺卡、旷工处理保持不变
- case 3:
- {
- decimal ded = ConvertToDecimal(dailyWage / 2);
- result.AbsenteeismDeduction += ded;
- result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
- result.Details.Add(new ExItem
- {
- SubTypeId = 4,
- SubType = "旷工",
- Deduction = ded,
- StartTimeDt = date,
- Duration = 0.5m,
- Unit = "天",
- Reason = reason + "缺卡,按半天旷工处理,扣除餐补"
- });
- }
- break;
- case 4:
- if (minutes >= PayrollConfig.HalfDayMissMinutes && minutes <= PayrollConfig.FullDayMissMinutes)
- {
- decimal ded = ConvertToDecimal(dailyWage / 2);
- result.AbsenteeismDeduction += ded;
- result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
- result.Details.Add(new ExItem
- {
- SubTypeId = 4,
- SubType = "旷工",
- Deduction = ded,
- StartTimeDt = date,
- Duration = 0.5m,
- Unit = "天",
- Reason = reason + "旷工半天,扣除餐补"
- });
- }
- else
- {
- decimal ded = ConvertToDecimal(dailyWage);
- result.AbsenteeismDeduction += ded;
- result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
- result.Details.Add(new ExItem
- {
- SubTypeId = 4,
- SubType = "旷工",
- Deduction = ded,
- Duration = 1m,
- Unit = "天",
- StartTimeDt = date,
- Reason = reason + "旷工全天,扣除餐补"
- });
- }
- break;
- }
- }
- }
- return result;
- }
- /// <summary>
- /// 计算假勤扣款(请假、出差等)
- /// </summary>
- private static async Task<LeaveDeductionResult> CalculateLeaveDeductionAsync(string acctid, decimal dailyWage, decimal sickLeaveDailyWage,
- decimal amountPayable, int workDays, DateTime startDt, DateTime endDt, List<Sys_Calendar> sysCalendars)
- {
- var result = new LeaveDeductionResult();
- if (string.IsNullOrEmpty(acctid))
- return result;
- // 获取请假审批
- List<Sp_Detail> spLeaveDetails = await _qiYeWeChatApiService.GetApprovalDetailsAsync(startDt, endDt, acctid, 2, 1);
- if (spLeaveDetails.Count > 0)
- {
- var leaveDetails = new List<LeaveDetailItem>();
- foreach (var sp in spLeaveDetails)
- {
- var applyData = sp.apply_data;
- if (applyData == null) continue;
- var vacationControl = applyData.contents?.FirstOrDefault(c => c.control == "Vacation");
- if (vacationControl?.value?.vacation == null) continue;
- var vac = vacationControl.value.vacation;
- var attendance = vac.attendance;
- var selector = vac.selector;
- if (selector?.options == null || selector.options.Count == 0) continue;
- int leaveType = int.Parse(selector.options[0].key);
- var dateRange = attendance.date_range;
- // 只处理当月的假勤
- if (dateRange.new_begin_dt < startDt || dateRange.new_begin_dt > endDt) continue;
- var sliceInfo = (leaveType == 2 || leaveType == 3) ? attendance.slice_info : new Slice_info();
- var splitItems = SplitLeaveByMonth(dateRange, startDt, endDt, sysCalendars, leaveType, sliceInfo);
- leaveDetails.AddRange(splitItems);
- }
- if (leaveDetails.Any()) leaveDetails = leaveDetails.OrderBy(x => x.Start).ToList();
- foreach (var item in leaveDetails)
- {
- decimal leaveMeal = 0;
- decimal deduction = 0;
- bool isMealDeduct = (item.Hours >= PayrollConfig.MealDeductionLeaveHours || item.Days >= 0.5m);
- if (item.TypeId == 2) // 事假
- {
- deduction = ConvertToDecimal(dailyWage * item.Days);
- if (deduction == 0 && item.Hours > 0)
- deduction = ConvertToDecimal((dailyWage / PayrollConfig.WorkHoursPerDay) * item.Hours);
- result.PersonalLeaveTotal += deduction;
- }
- else if (item.TypeId == 3) // 病假
- {
- decimal deductPerDay = ConvertToDecimal(dailyWage - sickLeaveDailyWage);
- if (deductPerDay > 0)
- {
- deduction = ConvertToDecimal(deductPerDay * item.Days);
- if (deduction == 0 && item.Hours > 0)
- deduction = ConvertToDecimal((deductPerDay / PayrollConfig.WorkHoursPerDay) * item.Hours);
- }
- result.SickLeaveTotal += deduction;
- }
- // 年假、调休假不扣薪、视情况扣除餐补
- if (isMealDeduct)
- {
- if (item.Hours >= 0 || item.Days >= 0.5m)
- {
- leaveMeal = PayrollConfig.MealSubsidyPerDay;
- }
- }
- result.MealDeduction += leaveMeal;
- // 产生扣款时记录
- if ((deduction + leaveMeal) > 0m)
- {
- result.Details.Add(new ExItem
- {
- SubTypeId = item.TypeId,
- SubType = GetLeaveTypeName(item.TypeId),
- Deduction = deduction,
- MealDeduction = leaveMeal,
- StartTimeDt = item.Start,
- EndTimeDt = item.End,
- Duration = item.Days > 0 ? item.Days : item.Hours,
- Unit = item.Days > 0 ? "天" : "小时",
- Reason = $"{GetLeaveTypeName(item.TypeId)} {(item.Days > 0 ? item.Days.ToString() + "天" : item.Hours.ToString() + "小时")}"
- });
- }
- }
- }
- #region 处理出差
- // 处理出差
- List<Sp_Detail> spTripDetails = await _qiYeWeChatApiService.GetApprovalDetailsAsync(startDt, endDt, acctid, 2, 3);
- if (spTripDetails.Count > 0)
- {
- foreach (var sp in spTripDetails)
- {
- var applyData = sp.apply_data;
- if (applyData == null) continue;
- var attendanceControl = applyData.contents?.FirstOrDefault(c => c.control == "Attendance");
- if (attendanceControl?.value?.attendance == null) continue;
- var dateRange = attendanceControl.value.attendance.date_range;
- var splitTrips = SplitTripByMonth(dateRange, startDt, endDt);
- foreach (var trip in splitTrips)
- {
- decimal tripMeal = trip.Days * PayrollConfig.MealSubsidyPerDay;
- result.MealDeduction += tripMeal;
- result.Details.Add(new ExItem
- {
- SubTypeId = 5,
- SubType = "出差",
- Deduction = 0,
- MealDeduction = tripMeal,
- StartTimeDt = trip.Start,
- EndTimeDt = trip.End,
- Duration = trip.Days,
- Unit = "天",
- Reason = $"出差 {trip.Days}天,无餐补"
- });
- }
- }
- }
- #endregion
- return result;
- }
- /// <summary>
- /// 计算补卡扣款
- /// </summary>
- private static async Task<PunchCorrectionResult> CalculatePunchCorrectionAsync(string acctid, decimal floats, decimal dailyWage,
- DateTime startDt, DateTime endDt)
- {
- var result = new PunchCorrectionResult();
- if (string.IsNullOrEmpty(acctid)) return result;
- var bukaDetails = await _qiYeWeChatApiService.GetApprovalDetailsAsync(startDt, endDt, acctid, 2, 2);
- if (bukaDetails.Count == 0) return result;
- bool isProbation = floats == 0;
- int freeCount = isProbation ? PayrollConfig.ProbationFreeCardCount : PayrollConfig.RegularFreeCardCount;
- int bukaNum = 1;
- foreach (var sp in bukaDetails)
- {
- var applyData = sp.apply_data;
- if (applyData == null) continue;
- var punchCorrControl = applyData.contents?.FirstOrDefault(c => c.control == "PunchCorrection");
- if (punchCorrControl?.value?.punch_correction == null) continue;
- DateTime missTime = punchCorrControl.value.punch_correction.time_dt;
- DateTime missDate = missTime.Date;
- if (missDate < startDt || missDate > endDt) continue;
- if (bukaNum <= freeCount + 1)
- {
- result.MissPunchFine += PayrollConfig.PunchCorrectionLowFine;
- result.Details.Add(new ExItem
- {
- SubTypeId = 7,
- SubType = "打卡补卡",
- Deduction = PayrollConfig.PunchCorrectionLowFine,
- StartTimeDt = missTime,
- Reason = $"第{bukaNum}次补卡,罚款{PayrollConfig.PunchCorrectionLowFine}元"
- });
- }
- else if (PayrollConfig.PunchCorrectionToAbsentAfterFourth)
- {
- // 第4次起按旷工半天处理
- decimal ded = ConvertToDecimal(dailyWage / 2);
- result.AbsenteeismDeduction += ded;
- result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
- result.Details.Add(new ExItem
- {
- SubTypeId = 4,
- SubType = "旷工(补卡超限)",
- Deduction = ded,
- StartTimeDt = missDate,
- Reason = $"第{bukaNum}次补卡超限,按旷工半天处理"
- });
- }
- bukaNum++;
- }
- return result;
- }
- /// <summary>
- /// 出差按月份拆分(支持 halfday / hour 类型,按自然日扣除餐补)
- /// </summary>
- private static List<LeaveDetailItem> SplitTripByMonth(Date_range dateRange, DateTime monthStart, DateTime monthEnd)
- {
- var result = new List<LeaveDetailItem>();
- if (dateRange == null) return result;
- // 验证起止时间有效性
- DateTime start = dateRange.new_begin_dt;
- DateTime end = dateRange.new_end_dt;
- if (start > end) (start, end) = (end, start);
- // 跨月范围检查
- if (end < monthStart || start > monthEnd) return result;
- DateTime actualStart = start < monthStart ? monthStart : start;
- DateTime actualEnd = end > monthEnd ? monthEnd : end;
- // 计算该月内的出差天数(按自然日,包含首尾)
- int days = (actualEnd.Date - actualStart.Date).Days + 1;
- // 如果出差跨月,且开始/结束是半天,仍按整天扣除餐补(业务规则:出差即无餐补)
- result.Add(new LeaveDetailItem
- {
- TypeId = 5,
- TypeName = "出差",
- Start = actualStart,
- End = actualEnd,
- Days = days,
- Hours = days * PayrollConfig.WorkHoursPerDay, // 仅用于记录,不影响扣款
- DtType = "halfday",
- IsBusinessTrip = true
- });
- return result;
- }
- /// <summary>
- /// 请假按月份拆分(支持 halfday / hour,区分工作日)
- /// </summary>
- private static List<LeaveDetailItem> SplitLeaveByMonth(Date_range dateRange, DateTime monthStart, DateTime monthEnd,
- List<Sys_Calendar> sysCalendars, int leaveType, Slice_info? sliceInfo)
- {
- var result = new List<LeaveDetailItem>();
- if (dateRange == null) return result;
- DateTime start = dateRange.new_begin_dt;
- DateTime end = dateRange.new_end_dt;
- if (start > end) (start, end) = (end, start);
- if (end < monthStart || start > monthEnd) return result;
- DateTime actualStart = start < monthStart ? monthStart : start;
- DateTime actualEnd = end > monthEnd ? monthEnd : end;
- decimal days = 0, hours = 0;
- for (var dt = actualStart.Date; dt <= actualEnd.Date; dt = dt.AddDays(1))
- {
- bool isWorkDay = sysCalendars.Any(c => c.Dt == dt.ToString("yyyy-MM-dd") && c.IsWorkDay);
- if (!isWorkDay) continue;
- if (dt == actualStart.Date && dt == actualEnd.Date)
- {
- if (dateRange.type.Equals("halfday"))
- {
- days = dateRange.new_duration / 86400m;
- }
- else if (dateRange.type.Equals("hour"))
- {
- hours = dateRange.new_duration / 3600m;
- }
- }
- else if (dt == actualStart.Date || dt == actualEnd.Date)
- {
- days += 0.5M;
- hours += PayrollConfig.WorkHoursPerDay / 2;
- }
- else
- {
- days += 1;
- hours += PayrollConfig.WorkHoursPerDay;
- }
- }
- result.Add(new LeaveDetailItem
- {
- TypeId = leaveType,
- TypeName = GetLeaveTypeName(leaveType),
- Start = actualStart,
- End = actualEnd,
- Days = days,
- Hours = hours,
- DtType = dateRange.type
- });
- 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)
- {
- return typeId switch
- {
- 1 => "年假",
- 2 => "事假",
- 3 => "病假",
- 4 => "调休假",
- 5 => "出差",
- _ => "其他"
- };
- }
- private static void UpdateWageSheet(Pm_WageSheet ws, string yearMonth, DateTime start, DateTime end,
- int workDays, int actualWorkDays, decimal mealActual, LeaveDeductionResult leave,
- AttendancePenaltyResult attendance, PunchCorrectionResult punch,
- decimal shouldTotal, decimal totalDeduction, decimal afterTax, int userId, string exItemsRemark)
- {
- ws.YearMonth = yearMonth;
- ws.StartDate = start.ToString("yyyy-MM-dd");
- ws.EndDate = end.ToString("yyyy-MM-dd");
- ws.WorkDays = workDays;
- ws.RegularDays = actualWorkDays;
- ws.SickLeave = leave.SickLeaveTotal;
- ws.SomethingFalse = leave.PersonalLeaveTotal;
- ws.LateTo = attendance.LateFine;
- ws.LeaveEarly = attendance.EarlyFine;
- ws.Absenteeism = attendance.AbsenteeismDeduction + punch.AbsenteeismDeduction;
- ws.NotPunch = punch.MissPunchFine;
- ws.Mealsupplement = mealActual;
- ws.Should = ConvertToDecimal(shouldTotal);
- ws.TotalDeductions = ConvertToDecimal(totalDeduction);
- ws.TotalRealHair = ConvertToDecimal(afterTax);
- ws.AfterTax = ConvertToDecimal(afterTax);
- ws.Ex_ItemsRemark = exItemsRemark;
- ws.LastUpdateUserId = userId;
- ws.LastUpdateDt = DateTime.Now;
- ws.CreateUserId = userId;
- ws.CreateTime = DateTime.Now;
- }
- public static async Task<int> GetWorkDays(string yearMonth)
- {
- string sql = $"Select * From Pm_WageIssueWorkingDay Where Isdel = 0 And YearMonth = '{yearMonth}'";
- var data = await _sqlSugar.SqlQueryable<WageYearMonthView>(sql).FirstAsync();
- return data?.Workdays ?? 0;
- }
- public static decimal ConvertToDecimal(decimal value)
- {
- return Math.Floor(value * 100) / 100;
- }
- #endregion
- }
|