PayrollComputation_v1.cs 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903
  1. using OASystem.API.OAMethodLib.QiYeWeChatAPI;
  2. using OASystem.Domain.Entities.PersonnelModule;
  3. using OASystem.Domain.ViewModels.CRM;
  4. using OASystem.Domain.ViewModels.PersonnelModule;
  5. using OASystem.Domain.ViewModels.QiYeWeChat;
  6. using System.Diagnostics.Tracing;
  7. namespace OASystem.API.OAMethodLib;
  8. /// <summary>
  9. /// 工资计算公共参数配置
  10. /// </summary>
  11. public static class PayrollConfig
  12. {
  13. // ========== 基础参数 ==========
  14. public const decimal ChengduMinimumWage = 2100m;
  15. public const decimal SickLeaveWageRatio = 0.8m;
  16. public const decimal WorkHoursPerDay = 7.5m;
  17. public const decimal MealSubsidyPerDay = 10m;
  18. // ========== 迟到早退规则 ==========
  19. public const int FreeTotalMinutes = 10; // 累计免罚分钟(超过此值才罚款)
  20. public const decimal FinePerEvent = 50m; // 每次事件罚款金额
  21. public const int WorkStartHour = 9;
  22. public const int WorkEndHour = 18;
  23. public const int DefaultAllowSeconds = 300; // 默认允许迟到/早退秒数(当规则未设置时使用)
  24. // ========== 旷工判定 ==========
  25. public const int HalfDayMissMinutes = 60; // 半日旷工下限(分钟)
  26. public const int FullDayMissMinutes = 180; // 全日旷工阈值(分钟)
  27. // ========== 补卡规则 ==========
  28. public const int ProbationFreeCardCount = 2;
  29. public const int RegularFreeCardCount = 3;
  30. public const decimal PunchCorrectionLowFine = 10m;
  31. public const bool PunchCorrectionToAbsentAfterFourth = true;
  32. // ========== 请假餐补规则 ==========
  33. public const int MealDeductionLeaveHours = 3;
  34. }
  35. /// <summary>
  36. /// 工资计算
  37. /// </summary>
  38. public static class PayrollComputation_v1
  39. {
  40. private static Result _result = new Result();
  41. private static readonly IQiYeWeChatApiService _qiYeWeChatApiService = AutofacIocManager.Instance.GetService<IQiYeWeChatApiService>();
  42. private static readonly SqlSugarClient _sqlSugar = AutofacIocManager.Instance.GetService<SqlSugarClient>();
  43. // =============================== 内部类定义 ===============================
  44. /// <summary>
  45. /// 解析后的打卡规则(仅用于当前计算,不缓存)
  46. /// </summary>
  47. private class ParsedCheckInRule
  48. {
  49. public uint GroupId { get; set; }
  50. public int FlexOnDutyTime { get; set; } = 0; // 允许迟到秒数
  51. public int FlexOffDutyTime { get; set; } = 0; // 允许早退秒数
  52. public HashSet<DateTime> SpecialWorkDays { get; set; } = new HashSet<DateTime>();
  53. public HashSet<DateTime> SpecialOffDays { get; set; } = new HashSet<DateTime>();
  54. }
  55. /// <summary>
  56. /// 考勤扣款结果
  57. /// </summary>
  58. private class AttendancePenaltyResult
  59. {
  60. public decimal LateFine { get; set; }
  61. public decimal EarlyFine { get; set; }
  62. public decimal AbsenteeismDeduction { get; set; }
  63. public decimal MealDeduction { get; set; }
  64. public decimal TotalDeduction => LateFine + EarlyFine + AbsenteeismDeduction;
  65. public List<ExItem> Details { get; set; } = new List<ExItem>();
  66. }
  67. /// <summary>
  68. /// 假勤扣款结果
  69. /// </summary>
  70. private class LeaveDeductionResult
  71. {
  72. public decimal PersonalLeaveTotal { get; set; }
  73. public decimal SickLeaveTotal { get; set; }
  74. public decimal MealDeduction { get; set; }
  75. public decimal TotalDeduction => PersonalLeaveTotal + SickLeaveTotal;
  76. public List<ExItem> Details { get; set; } = new List<ExItem>();
  77. }
  78. /// <summary>
  79. /// 补卡扣款结果
  80. /// </summary>
  81. private class PunchCorrectionResult
  82. {
  83. public decimal MissPunchFine { get; set; }
  84. public decimal AbsenteeismDeduction { get; set; }
  85. public decimal MealDeduction { get; set; }
  86. public decimal TotalDeduction => MissPunchFine + AbsenteeismDeduction;
  87. public List<ExItem> Details { get; set; } = new List<ExItem>();
  88. }
  89. /// <summary>
  90. /// 扣款明细项(用于序列化)
  91. /// </summary>
  92. private class ExItem
  93. {
  94. public int SubTypeId { get; set; }
  95. public string? SubType { get; set; }
  96. public decimal Deduction { get; set; } = 0.00M;
  97. public decimal MealDeduction { get; set; } = 0.00M;
  98. public DateTime StartTimeDt { get; set; }
  99. public DateTime EndTimeDt { get; set; }
  100. public decimal Duration { get; set; }
  101. public string Unit { get; set; } = "小时";
  102. public string? Reason { get; set; }
  103. public DateTime Apply_time_dt { get; set; }
  104. public List<string>? Approval_name { get; set; }
  105. }
  106. /// <summary>
  107. /// 扣款分类容器
  108. /// </summary>
  109. private class ExItems
  110. {
  111. public string? Type { get; set; }
  112. public object? ExItemInfo { get; set; }
  113. }
  114. /// <summary>
  115. /// 请假/调休/出差明细(用于跨月拆分)
  116. /// </summary>
  117. private class LeaveDetailItem
  118. {
  119. public int TypeId { get; set; }
  120. public string TypeName { get; set; } = "";
  121. public DateTime Start { get; set; }
  122. public DateTime End { get; set; }
  123. public decimal Days { get; set; }
  124. public decimal Hours { get; set; }
  125. public string DtType { get; set; } = "";
  126. public decimal NewDuration { get; set; }
  127. public Slice_info SliceInfo { get; set; } = new Slice_info();
  128. public DateTime ApplyDt { get; set; }
  129. public bool IsBusinessTrip { get; set; }
  130. }
  131. /// <summary>
  132. /// 判断是否为工作日(基于规则中的特殊日期和系统日历)
  133. /// </summary>
  134. private static bool IsWorkDayForRule( DateTime date, List<Sys_Calendar> sysCalendars)
  135. {
  136. var calendar = sysCalendars.FirstOrDefault(c => c.Dt == date.ToString("yyyy-MM-dd"));
  137. if (calendar != null)
  138. return calendar.IsWorkDay;
  139. // 默认周一到周五为工作日
  140. return date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday;
  141. }
  142. /// <summary>
  143. /// 计算工资
  144. /// </summary>
  145. public static async Task<Result> SalaryCalculatorAsync(
  146. List<Pm_WageSheet> pm_WageSheetDattaSources, int userId, string thisYearMonth, DateTime startDt, DateTime endDt)
  147. {
  148. if (pm_WageSheetDattaSources.Count <= 0)
  149. {
  150. _result.Msg = "计算工资传入数据为空!";
  151. return _result;
  152. }
  153. // 获取当月应出勤天数
  154. int workDays = await GetWorkDays(thisYearMonth);
  155. if (workDays <= 0)
  156. {
  157. _result.Msg = $"{thisYearMonth} 工作日未设置,请前往《工作日管理页面》设置!";
  158. return _result;
  159. }
  160. var userIds = pm_WageSheetDattaSources.Select(ws => ws.UserId).Distinct().ToList();
  161. var userIdInfos = await GetSysUserWeChatIds(userIds);
  162. if (!userIdInfos.Any())
  163. {
  164. _result.Msg = $"企业微信Id获取失败,请联系管理员!";
  165. return _result;
  166. }
  167. List<string> qyWhchatIdList = userIdInfos.Select(it => it.WeChatId ?? "").Distinct().ToList();
  168. // 获取打卡日报数据(包含 exception_infos)
  169. CheckInDayDataView checkInDayDataView = await _qiYeWeChatApiService.GetCheckInDayDataAsync(qyWhchatIdList, startDt, endDt);
  170. if (checkInDayDataView.errcode != 0)
  171. {
  172. _result.Msg = $"【企业微信】【打卡】【获取时间段内所有日打卡】【Msg】{checkInDayDataView.errmsg}";
  173. return _result;
  174. }
  175. // 获取系统日历(用于判断工作日)
  176. List<Sys_Calendar> sys_Calendars = await GetSysCalendars(startDt, endDt);
  177. string _name = "";
  178. try
  179. {
  180. foreach (var pm_wsInfo in pm_WageSheetDattaSources)
  181. {
  182. _name = userIdInfos.Find(it => it.Id == pm_wsInfo.UserId)?.CnName ?? "Unknown";
  183. // 获取员工当月的打卡日报数据(按日期分组)
  184. var userDailyRecords = GetUserDailyRecords(checkInDayDataView, _name);
  185. // 计算日薪
  186. decimal amountPayable = GetTotalSalaryBase(pm_wsInfo);
  187. decimal dailyWage = ConvertToDecimal(amountPayable / workDays);
  188. decimal sickLeaveDailyWage = ConvertToDecimal((PayrollConfig.ChengduMinimumWage * PayrollConfig.SickLeaveWageRatio) / workDays);
  189. // 获取员工企业微信ID(用于获取审批数据)
  190. string acctid = userDailyRecords.Count > 0 ? userDailyRecords.First().Value.base_info.acctid : "";
  191. // 计算考勤扣款(使用日报中的 exception_infos)
  192. var attendanceResult = CalculateAttendanceFromDailyRecords(userDailyRecords, dailyWage, sys_Calendars);
  193. // 计算假勤扣款(请假、出差等)
  194. var leaveResult = new LeaveDeductionResult();
  195. // 计算补卡扣款
  196. var punchResult = new PunchCorrectionResult();
  197. // 当月假勤次数
  198. var spCount = userDailyRecords.Sum(x => x.Value.sp_items?.Sum(y => y.count) ?? 0);
  199. //当月存在假勤的情况,执行假勤扣除
  200. if (spCount > 0)
  201. {
  202. leaveResult = await CalculateLeaveDeductionAsync(acctid, dailyWage, sickLeaveDailyWage, amountPayable, workDays, startDt, endDt, sys_Calendars);
  203. punchResult = await CalculatePunchCorrectionAsync(acctid, pm_wsInfo.Floats, dailyWage, startDt, endDt);
  204. }
  205. // 计算餐补
  206. int actualWorkDays = userDailyRecords.Count(r => IsWorkDayForRule(r.Key, sys_Calendars));
  207. decimal mealSubsidy = actualWorkDays * PayrollConfig.MealSubsidyPerDay;
  208. decimal mealDeductionTotal = attendanceResult.MealDeduction + leaveResult.MealDeduction + punchResult.MealDeduction;
  209. decimal mealActual = mealSubsidy - mealDeductionTotal;
  210. // 汇总扣款
  211. decimal totalDeduction = attendanceResult.TotalDeduction + leaveResult.TotalDeduction + punchResult.TotalDeduction
  212. + pm_wsInfo.WithholdingInsurance + pm_wsInfo.ReservedFunds + pm_wsInfo.OtherDeductions;
  213. // 应发合计
  214. decimal shouldTotal = amountPayable + mealActual + pm_wsInfo.OtherHandle;
  215. decimal afterTax = Math.Floor((shouldTotal - totalDeduction - pm_wsInfo.WithholdingTax) * 100) / 100;
  216. // 组装扣款明细JSON
  217. var exItemsList = new List<ExItems>();
  218. if (attendanceResult.Details.Count > 0)
  219. exItemsList.Add(new ExItems { Type = "打卡", ExItemInfo = attendanceResult.Details });
  220. if (leaveResult.Details.Count > 0)
  221. exItemsList.Add(new ExItems { Type = "假勤", ExItemInfo = leaveResult.Details });
  222. if (punchResult.Details.Count > 0)
  223. exItemsList.Add(new ExItems { Type = "补卡", ExItemInfo = punchResult.Details });
  224. string exItemsRemark = exItemsList.Count > 0 ? JsonConvert.SerializeObject(exItemsList) : "";
  225. // 更新工资表
  226. UpdateWageSheet(pm_wsInfo, thisYearMonth, startDt, endDt, workDays, actualWorkDays,
  227. mealActual, leaveResult, attendanceResult, punchResult, shouldTotal, totalDeduction, afterTax, userId, exItemsRemark);
  228. }
  229. }
  230. catch (Exception ex)
  231. {
  232. _result.Msg = $"【{_name}】【Msg:{ex.Message}】";
  233. return _result;
  234. }
  235. _result.Code = 0;
  236. _result.Data = pm_WageSheetDattaSources;
  237. return _result;
  238. }
  239. // =============================== 辅助方法 ===============================
  240. private static decimal GetTotalSalaryBase(Pm_WageSheet wageSheet)
  241. {
  242. return wageSheet.Basic + wageSheet.Floats + wageSheet.PostAllowance + wageSheet.InformationSecurityFee + wageSheet.OtherSubsidies;
  243. }
  244. private static async Task<List<Sys_Calendar>> GetSysCalendars(DateTime startDt, DateTime endDt)
  245. {
  246. string sql = string.Format("Select * From Sys_Calendar Where Isdel = 0 And Dt between '{0}' And '{1}'",
  247. startDt.ToString("yyyy-MM-dd"), endDt.ToString("yyyy-MM-dd"));
  248. return await _sqlSugar.SqlQueryable<Sys_Calendar>(sql).ToListAsync();
  249. }
  250. private static async Task<List<UserWeChatIdView>> GetSysUserWeChatIds(List<int> ints)
  251. {
  252. return await _sqlSugar.Queryable<Sys_Users>().Where(u => ints.Contains(u.Id) && u.IsDel == 0)
  253. .Select(u => new UserWeChatIdView { Id = u.Id, CnName = u.CnName, WeChatId = u.QiyeChatUserId })
  254. .ToListAsync();
  255. }
  256. /// <summary>
  257. /// 获取员工每日的打卡日报数据(按日期索引)
  258. /// </summary>
  259. private static Dictionary<DateTime, CheckInDayRoot> GetUserDailyRecords(CheckInDayDataView view, string name)
  260. {
  261. var dict = new Dictionary<DateTime, CheckInDayRoot>();
  262. var allRecords = view.datas ?? new List<CheckInDayRoot>();
  263. IEnumerable<CheckInDayRoot> userRecords = allRecords.Where(r => r.base_info.name == name || r.base_info.name.Contains(name)).OrderBy(x => x.base_info.DateDt);
  264. foreach (var record in userRecords)
  265. {
  266. DateTime date = record.base_info.DateDt.Date;
  267. dict[date] = record;
  268. }
  269. return dict;
  270. }
  271. /// <summary>
  272. /// 基于日报数据中的 exception_infos 计算考勤扣款(次数累计 + 分时段罚款)
  273. /// </summary>
  274. private static AttendancePenaltyResult CalculateAttendanceFromDailyRecords(
  275. Dictionary<DateTime, CheckInDayRoot> dailyRecords,
  276. decimal dailyWage,
  277. List<Sys_Calendar> sysCalendars)
  278. {
  279. var result = new AttendancePenaltyResult();
  280. // 统计本月内迟到/早退次数(仅统计时长<60分钟的事件)
  281. int lateEarlyCount = 0; // 总次数(用于判定是否超过2次)
  282. int lateCount = 0; // 迟到次数(保留,暂未独立使用)
  283. int earlyCount = 0; // 早退次数
  284. foreach (var kvp in dailyRecords)
  285. {
  286. DateTime date = kvp.Key;
  287. var record = kvp.Value;
  288. // 判断是否为工作日(若不是工作日,不计算考勤扣款)
  289. bool isWorkDay = IsWorkDayForRule(date, sysCalendars);
  290. if (!isWorkDay) continue;
  291. var exceptions = record.exception_infos ?? new List<ExceptionInfo>();
  292. foreach (var ex in exceptions)
  293. {
  294. int exceptionType = ex.exception; // 1迟到 2早退 3缺卡 4旷工
  295. int minutes = ex.duration / 60; // 时长(分钟)
  296. string reason = $"{date:yyyy-MM-dd} ";
  297. decimal deduction = 0m;
  298. switch (exceptionType)
  299. {
  300. case 1: // 迟到
  301. if (minutes < 60)
  302. {
  303. // 迟到时长小于60分钟,计入次数,并根据分钟数确定罚款
  304. lateEarlyCount++;
  305. lateCount++;
  306. if (minutes < 10) // 不足10分钟:第3次起罚50
  307. {
  308. if (lateEarlyCount >= 3)
  309. {
  310. deduction = PayrollConfig.FinePerEvent; // 50元
  311. result.LateFine += deduction;
  312. reason += $"迟到{minutes}分钟(第{lateEarlyCount}次),按次数罚款{deduction}元";
  313. }
  314. else
  315. {
  316. reason += $"迟到{minutes}分钟(第{lateEarlyCount}次),前2次不处罚";
  317. }
  318. }
  319. else if (minutes >= 10 && minutes < 30) // 9:10~9:30 => 罚款20
  320. {
  321. deduction = 20m;
  322. result.LateFine += deduction;
  323. reason += $"迟到{minutes}分钟,罚款{deduction}元";
  324. }
  325. else if (minutes >= 30 && minutes < 60) // 9:30~9:59 => 罚款50
  326. {
  327. deduction = PayrollConfig.FinePerEvent; // 50元
  328. result.LateFine += deduction;
  329. reason += $"迟到{minutes}分钟,罚款{deduction}元";
  330. }
  331. result.Details.Add(new ExItem
  332. {
  333. SubTypeId = 1,
  334. SubType = "迟到",
  335. Deduction = deduction,
  336. StartTimeDt = date,
  337. Reason = reason
  338. });
  339. }
  340. else
  341. {
  342. // 迟到≥60分钟,按旷工处理
  343. if (minutes >= PayrollConfig.HalfDayMissMinutes && minutes <= PayrollConfig.FullDayMissMinutes)
  344. {
  345. decimal ded = ConvertToDecimal(dailyWage / 2);
  346. result.AbsenteeismDeduction += ded;
  347. result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
  348. result.Details.Add(new ExItem
  349. {
  350. SubTypeId = 4,
  351. SubType = "旷工(迟到超时)",
  352. Deduction = ded,
  353. StartTimeDt = date,
  354. Reason = reason + $"迟到{minutes}分钟,按旷工半天处理,扣除餐补"
  355. });
  356. }
  357. else // minutes > 180
  358. {
  359. decimal ded = ConvertToDecimal(dailyWage);
  360. result.AbsenteeismDeduction += ded;
  361. result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
  362. result.Details.Add(new ExItem
  363. {
  364. SubTypeId = 4,
  365. SubType = "旷工(迟到超时)",
  366. Deduction = ded,
  367. StartTimeDt = date,
  368. Reason = reason + $"迟到{minutes}分钟,按旷工全天处理,扣除餐补"
  369. });
  370. }
  371. }
  372. break;
  373. case 2: // 早退
  374. if (minutes < 60)
  375. {
  376. lateEarlyCount++;
  377. earlyCount++;
  378. if (minutes < 10)
  379. {
  380. if (lateEarlyCount >= 3)
  381. {
  382. deduction = PayrollConfig.FinePerEvent;
  383. result.EarlyFine += deduction;
  384. reason += $"早退{minutes}分钟(第{lateEarlyCount}次),按次数罚款{deduction}元";
  385. }
  386. else
  387. {
  388. reason += $"早退{minutes}分钟(第{lateEarlyCount}次),前2次不处罚";
  389. }
  390. }
  391. else if (minutes >= 10 && minutes < 30)
  392. {
  393. deduction = 20m;
  394. result.EarlyFine += deduction;
  395. reason += $"早退{minutes}分钟,罚款{deduction}元";
  396. }
  397. else if (minutes >= 30 && minutes < 60)
  398. {
  399. deduction = PayrollConfig.FinePerEvent; // 50元
  400. result.EarlyFine += deduction;
  401. reason += $"早退{minutes}分钟,罚款{deduction}元";
  402. }
  403. result.Details.Add(new ExItem
  404. {
  405. SubTypeId = 2,
  406. SubType = "早退",
  407. Deduction = deduction,
  408. StartTimeDt = date,
  409. Reason = reason
  410. });
  411. }
  412. else
  413. {
  414. // 早退≥60分钟,按旷工处理
  415. if (minutes >= PayrollConfig.HalfDayMissMinutes && minutes <= PayrollConfig.FullDayMissMinutes)
  416. {
  417. decimal ded = ConvertToDecimal(dailyWage / 2);
  418. result.AbsenteeismDeduction += ded;
  419. result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
  420. result.Details.Add(new ExItem
  421. {
  422. SubTypeId = 4,
  423. SubType = "旷工(早退超时)",
  424. Deduction = ded,
  425. StartTimeDt = date,
  426. Reason = reason + $"早退{minutes}分钟,按旷工半天处理,扣除餐补"
  427. });
  428. }
  429. else
  430. {
  431. decimal ded = ConvertToDecimal(dailyWage);
  432. result.AbsenteeismDeduction += ded;
  433. result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
  434. result.Details.Add(new ExItem
  435. {
  436. SubTypeId = 4,
  437. SubType = "旷工(早退超时)",
  438. Deduction = ded,
  439. StartTimeDt = date,
  440. Reason = reason + $"早退{minutes}分钟,按旷工全天处理,扣除餐补"
  441. });
  442. }
  443. }
  444. break;
  445. // 缺卡、旷工处理保持不变
  446. case 3:
  447. {
  448. decimal ded = ConvertToDecimal(dailyWage / 2);
  449. result.AbsenteeismDeduction += ded;
  450. result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
  451. result.Details.Add(new ExItem
  452. {
  453. SubTypeId = 4,
  454. SubType = "旷工(缺卡)",
  455. Deduction = ded,
  456. StartTimeDt = date,
  457. Reason = reason + "缺卡,按半天旷工处理,扣除餐补"
  458. });
  459. }
  460. break;
  461. case 4:
  462. if (minutes >= PayrollConfig.HalfDayMissMinutes && minutes <= PayrollConfig.FullDayMissMinutes)
  463. {
  464. decimal ded = ConvertToDecimal(dailyWage / 2);
  465. result.AbsenteeismDeduction += ded;
  466. result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
  467. result.Details.Add(new ExItem
  468. {
  469. SubTypeId = 4,
  470. SubType = "旷工",
  471. Deduction = ded,
  472. StartTimeDt = date,
  473. Reason = reason + "旷工半天,扣除餐补"
  474. });
  475. }
  476. else
  477. {
  478. decimal ded = ConvertToDecimal(dailyWage);
  479. result.AbsenteeismDeduction += ded;
  480. result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
  481. result.Details.Add(new ExItem
  482. {
  483. SubTypeId = 4,
  484. SubType = "旷工",
  485. Deduction = ded,
  486. StartTimeDt = date,
  487. Reason = reason + "旷工全天,扣除餐补"
  488. });
  489. }
  490. break;
  491. }
  492. }
  493. }
  494. return result;
  495. }
  496. /// <summary>
  497. /// 计算假勤扣款(请假、出差等)
  498. /// </summary>
  499. private static async Task<LeaveDeductionResult> CalculateLeaveDeductionAsync(string acctid, decimal dailyWage, decimal sickLeaveDailyWage,
  500. decimal amountPayable, int workDays, DateTime startDt, DateTime endDt, List<Sys_Calendar> sysCalendars)
  501. {
  502. var result = new LeaveDeductionResult();
  503. if (string.IsNullOrEmpty(acctid))
  504. return result;
  505. // 获取请假审批
  506. List<Sp_Detail> spLeaveDetails = await _qiYeWeChatApiService.GetApprovalDetailsAsync(startDt, endDt, acctid, 2, 1);
  507. if (spLeaveDetails.Count > 0)
  508. {
  509. var leaveDetails = new List<LeaveDetailItem>();
  510. foreach (var sp in spLeaveDetails)
  511. {
  512. var applyData = sp.apply_data;
  513. if (applyData == null) continue;
  514. var vacationControl = applyData.contents?.FirstOrDefault(c => c.control == "Vacation");
  515. if (vacationControl?.value?.vacation == null) continue;
  516. var vac = vacationControl.value.vacation;
  517. var attendance = vac.attendance;
  518. var selector = vac.selector;
  519. if (selector?.options == null || selector.options.Count == 0) continue;
  520. int leaveType = int.Parse(selector.options[0].key);
  521. var dateRange = attendance.date_range;
  522. // 只处理当月的假勤
  523. if (dateRange.new_begin_dt < startDt || dateRange.new_begin_dt > endDt) continue;
  524. var sliceInfo = (leaveType == 2 || leaveType == 3) ? attendance.slice_info : new Slice_info();
  525. var splitItems = SplitLeaveByMonth(dateRange, startDt, endDt, sysCalendars, leaveType, sliceInfo);
  526. leaveDetails.AddRange(splitItems);
  527. }
  528. if (leaveDetails.Any()) leaveDetails = leaveDetails.OrderBy(x => x.Start).ToList();
  529. foreach (var item in leaveDetails)
  530. {
  531. decimal leaveMeal = 0;
  532. decimal deduction = 0;
  533. bool isMealDeduct = (item.Hours >= PayrollConfig.MealDeductionLeaveHours || item.Days >= 0.5m);
  534. if (item.TypeId == 2) // 事假
  535. {
  536. deduction = ConvertToDecimal(dailyWage * item.Days);
  537. if (deduction == 0 && item.Hours > 0)
  538. deduction = ConvertToDecimal((dailyWage / PayrollConfig.WorkHoursPerDay) * item.Hours);
  539. result.PersonalLeaveTotal += deduction;
  540. }
  541. else if (item.TypeId == 3) // 病假
  542. {
  543. decimal deductPerDay = ConvertToDecimal(dailyWage - sickLeaveDailyWage);
  544. if (deductPerDay > 0)
  545. {
  546. deduction = ConvertToDecimal(deductPerDay * item.Days);
  547. if (deduction == 0 && item.Hours > 0)
  548. deduction = ConvertToDecimal((deductPerDay / PayrollConfig.WorkHoursPerDay) * item.Hours);
  549. }
  550. result.SickLeaveTotal += deduction;
  551. }
  552. // 年假、调休假不扣薪、视情况扣除餐补
  553. if (isMealDeduct)
  554. {
  555. if (item.Hours >=0 || item.Days >= 0.5m)
  556. {
  557. leaveMeal = PayrollConfig.MealSubsidyPerDay;
  558. }
  559. }
  560. result.MealDeduction += leaveMeal;
  561. // 产生扣款时记录
  562. if ((deduction + leaveMeal) > 0m)
  563. {
  564. result.Details.Add(new ExItem
  565. {
  566. SubTypeId = item.TypeId,
  567. SubType = GetLeaveTypeName(item.TypeId),
  568. Deduction = deduction,
  569. MealDeduction = leaveMeal,
  570. StartTimeDt = item.Start,
  571. EndTimeDt = item.End,
  572. Duration = item.Days > 0 ? item.Days : item.Hours,
  573. Unit = item.Days > 0 ? "天" : "小时",
  574. Reason = $"{GetLeaveTypeName(item.TypeId)} {(item.Days > 0 ? item.Days.ToString() + "天" : item.Hours.ToString() + "小时")}"
  575. });
  576. }
  577. }
  578. }
  579. #region 处理出差
  580. // 处理出差
  581. List<Sp_Detail> spTripDetails = await _qiYeWeChatApiService.GetApprovalDetailsAsync(startDt, endDt, acctid, 2, 3);
  582. if (spTripDetails.Count > 0)
  583. {
  584. foreach (var sp in spTripDetails)
  585. {
  586. var applyData = sp.apply_data;
  587. if (applyData == null) continue;
  588. var attendanceControl = applyData.contents?.FirstOrDefault(c => c.control == "Attendance");
  589. if (attendanceControl?.value?.attendance == null) continue;
  590. var dateRange = attendanceControl.value.attendance.date_range;
  591. var splitTrips = SplitTripByMonth(dateRange, startDt, endDt);
  592. foreach (var trip in splitTrips)
  593. {
  594. decimal tripMeal = trip.Days * PayrollConfig.MealSubsidyPerDay;
  595. result.MealDeduction += tripMeal;
  596. result.Details.Add(new ExItem
  597. {
  598. SubTypeId = 5,
  599. SubType = "出差",
  600. Deduction = 0,
  601. MealDeduction = tripMeal,
  602. StartTimeDt = trip.Start,
  603. EndTimeDt = trip.End,
  604. Duration = trip.Days,
  605. Unit = "天",
  606. Reason = $"出差 {trip.Days}天,无餐补"
  607. });
  608. }
  609. }
  610. }
  611. #endregion
  612. return result;
  613. }
  614. /// <summary>
  615. /// 计算补卡扣款
  616. /// </summary>
  617. private static async Task<PunchCorrectionResult> CalculatePunchCorrectionAsync(string acctid, decimal floats, decimal dailyWage,
  618. DateTime startDt, DateTime endDt)
  619. {
  620. var result = new PunchCorrectionResult();
  621. if (string.IsNullOrEmpty(acctid)) return result;
  622. var bukaDetails = await _qiYeWeChatApiService.GetApprovalDetailsAsync(startDt, endDt, acctid, 2, 2);
  623. if (bukaDetails.Count == 0) return result;
  624. bool isProbation = floats == 0;
  625. int freeCount = isProbation ? PayrollConfig.ProbationFreeCardCount : PayrollConfig.RegularFreeCardCount;
  626. int bukaNum = 1;
  627. foreach (var sp in bukaDetails)
  628. {
  629. var applyData = sp.apply_data;
  630. if (applyData == null) continue;
  631. var punchCorrControl = applyData.contents?.FirstOrDefault(c => c.control == "PunchCorrection");
  632. if (punchCorrControl?.value?.punch_correction == null) continue;
  633. DateTime missTime = punchCorrControl.value.punch_correction.time_dt;
  634. DateTime missDate = missTime.Date;
  635. if (missDate < startDt || missDate > endDt) continue;
  636. if (bukaNum <= freeCount + 1)
  637. {
  638. result.MissPunchFine += PayrollConfig.PunchCorrectionLowFine;
  639. result.Details.Add(new ExItem
  640. {
  641. SubTypeId = 7,
  642. SubType = "打卡补卡",
  643. Deduction = PayrollConfig.PunchCorrectionLowFine,
  644. StartTimeDt = missTime,
  645. Reason = $"第{bukaNum}次补卡,罚款{PayrollConfig.PunchCorrectionLowFine}元"
  646. });
  647. }
  648. else if (PayrollConfig.PunchCorrectionToAbsentAfterFourth)
  649. {
  650. // 第4次起按旷工半天处理
  651. decimal ded = ConvertToDecimal(dailyWage / 2);
  652. result.AbsenteeismDeduction += ded;
  653. result.MealDeduction += PayrollConfig.MealSubsidyPerDay;
  654. result.Details.Add(new ExItem
  655. {
  656. SubTypeId = 4,
  657. SubType = "旷工(补卡超限)",
  658. Deduction = ded,
  659. StartTimeDt = missDate,
  660. Reason = $"第{bukaNum}次补卡超限,按旷工半天处理"
  661. });
  662. }
  663. bukaNum++;
  664. }
  665. return result;
  666. }
  667. /// <summary>
  668. /// 出差按月份拆分(支持 halfday / hour 类型,按自然日扣除餐补)
  669. /// </summary>
  670. private static List<LeaveDetailItem> SplitTripByMonth(Date_range dateRange, DateTime monthStart, DateTime monthEnd)
  671. {
  672. var result = new List<LeaveDetailItem>();
  673. if (dateRange == null) return result;
  674. // 验证起止时间有效性
  675. DateTime start = dateRange.new_begin_dt;
  676. DateTime end = dateRange.new_end_dt;
  677. if (start > end) (start, end) = (end, start);
  678. // 跨月范围检查
  679. if (end < monthStart || start > monthEnd) return result;
  680. DateTime actualStart = start < monthStart ? monthStart : start;
  681. DateTime actualEnd = end > monthEnd ? monthEnd : end;
  682. // 计算该月内的出差天数(按自然日,包含首尾)
  683. int days = (actualEnd.Date - actualStart.Date).Days + 1;
  684. // 如果出差跨月,且开始/结束是半天,仍按整天扣除餐补(业务规则:出差即无餐补)
  685. result.Add(new LeaveDetailItem
  686. {
  687. TypeId = 5,
  688. TypeName = "出差",
  689. Start = actualStart,
  690. End = actualEnd,
  691. Days = days,
  692. Hours = days * PayrollConfig.WorkHoursPerDay, // 仅用于记录,不影响扣款
  693. DtType = "halfday",
  694. IsBusinessTrip = true
  695. });
  696. return result;
  697. }
  698. /// <summary>
  699. /// 请假按月份拆分(支持 halfday / hour,区分工作日)
  700. /// </summary>
  701. private static List<LeaveDetailItem> SplitLeaveByMonth(Date_range dateRange, DateTime monthStart, DateTime monthEnd,
  702. List<Sys_Calendar> sysCalendars, int leaveType, Slice_info? sliceInfo)
  703. {
  704. var result = new List<LeaveDetailItem>();
  705. if (dateRange == null) return result;
  706. DateTime start = dateRange.new_begin_dt;
  707. DateTime end = dateRange.new_end_dt;
  708. if (start > end) (start, end) = (end, start);
  709. if (end < monthStart || start > monthEnd) return result;
  710. DateTime actualStart = start < monthStart ? monthStart : start;
  711. DateTime actualEnd = end > monthEnd ? monthEnd : end;
  712. decimal days = 0, hours = 0;
  713. for (var dt = actualStart.Date; dt <= actualEnd.Date; dt = dt.AddDays(1))
  714. {
  715. bool isWorkDay = sysCalendars.Any(c => c.Dt == dt.ToString("yyyy-MM-dd") && c.IsWorkDay);
  716. if (!isWorkDay) continue;
  717. if (dt == actualStart.Date && dt == actualEnd.Date)
  718. {
  719. if (dateRange.type.Equals("halfday"))
  720. {
  721. days = dateRange.new_duration / 86400m;
  722. }
  723. else if (dateRange.type.Equals("hour"))
  724. {
  725. hours = dateRange.new_duration / 3600m;
  726. }
  727. }
  728. else if (dt == actualStart.Date || dt == actualEnd.Date)
  729. {
  730. days += 0.5M;
  731. hours += PayrollConfig.WorkHoursPerDay / 2;
  732. }
  733. else
  734. {
  735. days += 1;
  736. hours += PayrollConfig.WorkHoursPerDay;
  737. }
  738. }
  739. result.Add(new LeaveDetailItem
  740. {
  741. TypeId = leaveType,
  742. TypeName = GetLeaveTypeName(leaveType),
  743. Start = actualStart,
  744. End = actualEnd,
  745. Days = days,
  746. Hours = hours,
  747. DtType = dateRange.type
  748. });
  749. return result;
  750. }
  751. private static string GetLeaveTypeName(int typeId)
  752. {
  753. return typeId switch
  754. {
  755. 1 => "年假",
  756. 2 => "事假",
  757. 3 => "病假",
  758. 4 => "调休假",
  759. 5 => "出差",
  760. _ => "其他"
  761. };
  762. }
  763. private static void UpdateWageSheet(Pm_WageSheet ws, string yearMonth, DateTime start, DateTime end,
  764. int workDays, int actualWorkDays, decimal mealActual, LeaveDeductionResult leave,
  765. AttendancePenaltyResult attendance, PunchCorrectionResult punch,
  766. decimal shouldTotal, decimal totalDeduction, decimal afterTax, int userId, string exItemsRemark)
  767. {
  768. ws.YearMonth = yearMonth;
  769. ws.StartDate = start.ToString("yyyy-MM-dd");
  770. ws.EndDate = end.ToString("yyyy-MM-dd");
  771. ws.WorkDays = workDays;
  772. ws.RegularDays = actualWorkDays;
  773. ws.SickLeave = leave.SickLeaveTotal;
  774. ws.SomethingFalse = leave.PersonalLeaveTotal;
  775. ws.LateTo = attendance.LateFine;
  776. ws.LeaveEarly = attendance.EarlyFine;
  777. ws.Absenteeism = attendance.AbsenteeismDeduction + punch.AbsenteeismDeduction;
  778. ws.NotPunch = punch.MissPunchFine;
  779. ws.Mealsupplement = mealActual;
  780. ws.Should = ConvertToDecimal(shouldTotal);
  781. ws.TotalDeductions = ConvertToDecimal(totalDeduction);
  782. ws.TotalRealHair = ConvertToDecimal(afterTax);
  783. ws.AfterTax = ConvertToDecimal(afterTax);
  784. ws.Ex_ItemsRemark = exItemsRemark;
  785. ws.LastUpdateUserId = userId;
  786. ws.LastUpdateDt = DateTime.Now;
  787. }
  788. public static async Task<int> GetWorkDays(string yearMonth)
  789. {
  790. string sql = $"Select * From Pm_WageIssueWorkingDay Where Isdel = 0 And YearMonth = '{yearMonth}'";
  791. var data = await _sqlSugar.SqlQueryable<WageYearMonthView>(sql).FirstAsync();
  792. return data?.Workdays ?? 0;
  793. }
  794. public static decimal ConvertToDecimal(decimal value)
  795. {
  796. return Math.Floor(value * 100) / 100;
  797. }
  798. }