using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Mapster; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NCC.Common.Core.Manager; using NCC.Common.Enum; using NCC.Common.Filter; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqAttendanceSummary; using NCC.Extend.Entitys.Enum; using NCC.Extend.Entitys.lq_attendance_group; using NCC.Extend.Entitys.lq_attendance_record; using NCC.Extend.Entitys.lq_attendance_rest_group; using NCC.Extend.Entitys.lq_attendance_summary; using NCC.Extend.Entitys.lq_mdxx; using NCC.Extend.Interfaces.LqAttendanceRecord; using NCC.Extend.Interfaces.LqAttendanceSummary; using NCC.FriendlyException; using NCC.System.Entitys.Permission; using SqlSugar; using Yitter.IdGenerator; namespace NCC.Extend { /// /// 考勤汇总服务 /// [ApiDescriptionSettings(Tag = "绿纤考勤汇总服务", Name = "LqAttendanceSummary", Order = 200)] [Route("api/Extend/[controller]")] public class LqAttendanceSummaryService : ILqAttendanceSummaryService, IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; private readonly IUserManager _userManager; private readonly ILogger _logger; private readonly ILqAttendanceRecordService _attendanceRecordService; /// /// 构造函数 /// /// 数据库客户端 /// 用户管理器 /// 日志记录器 /// 考勤打卡服务(从打卡记录汇总写入汇总表) public LqAttendanceSummaryService( ISqlSugarClient db, IUserManager userManager, ILogger logger, ILqAttendanceRecordService attendanceRecordService) { _db = db; _userManager = userManager; _logger = logger; _attendanceRecordService = attendanceRecordService; } #region 从考勤打卡记录同步汇总 /// /// 从考勤打卡记录按自然月重新汇总到 lq_attendance_summary(覆盖该月已有数据) /// /// /// 人选与月度考勤矩阵、员工花名册「当月在职」一致(见 Skill historical-on-job-inference): /// 有当月有效汇总且员工状态为在职则入选;无汇总时按主档入职/离职边界推断。 /// 出勤/请假/休息天数按每日一条打卡记录(同日取最新一条)的状态统计:正常+迟到→出勤,请假+病假→请假,休息→休息。 /// 未来月不允许调用(仅认已有汇总)。 /// /// 年 /// 月 /// 可选,与月度矩阵相同(姓名/账号/门店/分组) /// 可选考勤分组 ID /// 同步结果 [HttpPost("SyncFromAttendanceRecords/{year}/{month}")] public Task SyncFromAttendanceRecords( int year, int month, [FromQuery] string keyword = null, [FromQuery] string attendanceGroupId = null) { return _attendanceRecordService.SyncMonthSummaryFromAttendanceRecordsAsync(year, month, keyword, attendanceGroupId); } #endregion #region 获取考勤汇总列表 /// /// 获取考勤汇总列表 /// /// 请求参数 /// [HttpGet("")] public async Task GetList([FromQuery] LqAttendanceSummaryListQueryInput input) { var sidx = input.sidx == null ? "id" : input.sidx; var sord = input.sort == null ? "desc" : input.sort; var data = await _db.Queryable() .Where(a => a.IsEffective == StatusEnum.有效.GetHashCode()) .WhereIF(!string.IsNullOrEmpty(input.UserId), a => a.UserId == input.UserId) .WhereIF(input.Year.HasValue, a => a.Year == input.Year) .WhereIF(input.Month.HasValue, a => a.Month == input.Month) .WhereIF(input.EmployeeStatus.HasValue, a => a.EmployeeStatus == input.EmployeeStatus) .Select(a => new LqAttendanceSummaryListOutput { id = a.Id, userId = a.UserId, userName = SqlFunc.Subqueryable().Where(u => u.Id == a.UserId).Select(u => u.RealName), year = a.Year, month = a.Month, employeeStatus = a.EmployeeStatus, workDays = a.WorkDays, leaveDays = a.LeaveDays, restDays = a.RestDays, remark = a.Remark, createUser = a.CreateUser, createUserName = SqlFunc.Subqueryable().Where(u => u.Id == a.CreateUser).Select(u => u.RealName), createTime = a.CreateTime, updateUser = a.UpdateUser, updateUserName = SqlFunc.Subqueryable().Where(u => u.Id == a.UpdateUser).Select(u => u.RealName), updateTime = a.UpdateTime, isEffective = a.IsEffective, }) .MergeTable() .OrderBy(sidx + " " + input.sort) .ToPagedListAsync(input.currentPage, input.pageSize); var pageRows = data.list == null ? new List() : data.list.ToList(); await FillSummaryListDerivedFromRecordsAsync(pageRows); return PageResult.SqlSugarPageResult(data); } /// /// 按分页结果从打卡记录补全:当月考勤组文案 + 应休/到岗/请假/迟到早退/旷工及可算扣款。 /// private async Task FillSummaryListDerivedFromRecordsAsync(List items) { if (items == null || items.Count == 0) { return; } var userIds = items.Select(x => x.userId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); if (userIds.Count == 0) { return; } var monthBounds = items.Select(x => (x.year, x.month)).Distinct().ToList(); var minStart = monthBounds.Min(m => new DateTime(m.year, m.month, 1)); var maxEnd = monthBounds.Max(m => new DateTime(m.year, m.month, 1).AddMonths(1)); var records = await _db.Queryable() .Where(x => x.IsEffective == StatusEnum.有效.GetHashCode()) .Where(x => x.AttendanceDate >= minStart && x.AttendanceDate < maxEnd) .Where(x => userIds.Contains(x.UserId)) .ToListAsync(); var users = await _db.Queryable() .Where(u => userIds.Contains(u.Id) && u.DeleteMark == null) .Select(u => new { u.Id, u.Mdid, u.AttendanceRestGroupId }) .ToListAsync(); var mdids = users.Select(u => u.Mdid).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); var stores = mdids.Count == 0 ? new List() : await _db.Queryable() .Where(s => mdids.Contains(s.Id)) .ToListAsync(); var storeById = stores.Where(x => !string.IsNullOrEmpty(x.Id)).GroupBy(x => x.Id).ToDictionary(x => x.Key, x => x.First()); var groupIds = stores.Select(s => s.AttendanceGroupId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); var groups = groupIds.Count == 0 ? new List() : await _db.Queryable() .Where(g => groupIds.Contains(g.Id) && SqlFunc.IsNull(g.DeleteMark, 0) == 0) .ToListAsync(); var groupById = groups.GroupBy(x => x.Id).ToDictionary(x => x.Key, x => x.First()); var groupNameById = groupById.ToDictionary(x => x.Key, x => x.Value.GroupName ?? string.Empty); var defaultRestIdsFromShift = groups .Select(g => g.DefaultRestGroupId) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct() .ToList(); var defaultRestGroups = defaultRestIdsFromShift.Count == 0 ? new List() : await _db.Queryable() .Where(r => defaultRestIdsFromShift.Contains(r.Id) && SqlFunc.IsNull(r.DeleteMark, 0) == 0) .ToListAsync(); var monthlyRestByDefaultRestId = defaultRestGroups .Where(x => !string.IsNullOrEmpty(x.Id)) .GroupBy(x => x.Id) .ToDictionary(x => x.Key, x => x.First().MonthlyRestDays); var monthlyRestByGroupId = groupById.ToDictionary( x => x.Key, x => { var g = x.Value; if (!string.IsNullOrWhiteSpace(g.DefaultRestGroupId) && monthlyRestByDefaultRestId.TryGetValue(g.DefaultRestGroupId, out var mr)) { return mr; } return 0; }); var restGroupIds = users.Select(u => u.AttendanceRestGroupId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); var restGroups = restGroupIds.Count == 0 ? new List() : await _db.Queryable() .Where(r => restGroupIds.Contains(r.Id) && SqlFunc.IsNull(r.DeleteMark, 0) == 0) .ToListAsync(); var restGroupById = restGroups.GroupBy(x => x.Id).ToDictionary(x => x.Key, x => x.First()); var monthlyRestByRestGroupId = restGroupById.ToDictionary(x => x.Key, x => x.Value.MonthlyRestDays); var profileNameByUserId = users.ToDictionary( u => u.Id, u => { var gid = !string.IsNullOrWhiteSpace(u.Mdid) && storeById.TryGetValue(u.Mdid.Trim(), out var st) ? st.AttendanceGroupId : null; return !string.IsNullOrWhiteSpace(gid) && groupNameById.TryGetValue(gid, out var gn) ? gn : string.Empty; }); var profileMonthlyRestByUserId = users.ToDictionary( u => u.Id, u => { if (!string.IsNullOrWhiteSpace(u.AttendanceRestGroupId) && monthlyRestByRestGroupId.TryGetValue(u.AttendanceRestGroupId, out var mrr)) { return (int)mrr; } var gid = !string.IsNullOrWhiteSpace(u.Mdid) && storeById.TryGetValue(u.Mdid.Trim(), out var st) ? st.AttendanceGroupId : null; if (string.IsNullOrWhiteSpace(gid) || !monthlyRestByGroupId.TryGetValue(gid, out var mr)) { return 0; } return (int)mr; }); var recordsByUserMonth = records .GroupBy(r => (r.UserId, r.AttendanceDate.Year, r.AttendanceDate.Month)) .ToDictionary(g => g.Key, g => g.ToList()); foreach (var item in items) { if (string.IsNullOrWhiteSpace(item.userId)) { item.attendanceGroupDisplay = "无"; continue; } recordsByUserMonth.TryGetValue((item.userId, item.year, item.month), out var monthRecs); profileNameByUserId.TryGetValue(item.userId, out var profileName); item.attendanceGroupDisplay = AttendanceMonthGroupDisplayHelper.BuildDisplay(monthRecs, profileName ?? string.Empty); profileMonthlyRestByUserId.TryGetValue(item.userId, out var fallbackRest); var perDay = AttendanceSummaryMonthMetricsHelper.CollapseToLatestPerDay(monthRecs); var metrics = AttendanceSummaryMonthMetricsHelper.Compute(perDay, fallbackRest, null); item.monthEntitledRestDays = metrics.EntitledRestDays; item.monthOnDutyDays = metrics.OnDutyDays; item.monthCompositeWorkDays = metrics.CompositeWorkDays; item.monthPaidLeaveDays = metrics.PaidLeaveDays; item.monthLeaveDaysTotal = metrics.LeaveDaysTotal; item.monthLateEarlyDays = metrics.LateEarlyDays; item.monthAbsenteeismDays = metrics.AbsenteeismDays; item.monthLateDeductionAmount = metrics.LateDeductionAmount; item.monthLeaveDeductionAmount = metrics.LeaveDeductionAmount; item.monthAbsenteeismDeductionAmount = metrics.AbsenteeismDeductionAmount; } } #endregion #region 清空某一个月的数据 /// /// 清空某一个月的数据 /// /// 年份 /// 月份 /// [HttpDelete("DeleteByMonth/{year}/{month}")] public async Task DeleteByMonth(string year, string month) { int yearInt = int.Parse(year); int monthInt = int.Parse(month); await _db.Deleteable().Where(p => p.Year == yearInt && p.Month == monthInt).ExecuteCommandAsync(); return new { success = true, message = "清空成功" }; } #endregion #region 生成健康师考勤模拟数据 /// /// 生成健康师考勤模拟数据 /// /// /// 为所有健康师生成指定年月的考勤模拟数据 /// /// 规则: /// - 80%以上的健康师出勤天数 >= 21天 /// - 其他健康师出勤天数随机生成(0-20天) /// - 员工状态默认为1(在职) /// /// 示例请求: /// ```json /// { /// "year": 2025, /// "month": 11 /// } /// ``` /// /// 年份 /// 月份 /// 生成结果 /// 成功生成考勤模拟数据 /// 参数错误 /// 生成失败 [HttpPost("GenerateMockData/{year}/{month}")] public async Task GenerateMockData(int year, int month) { try { // 验证参数 if (year < 2000 || year > 3000) { throw NCCException.Oh("年份参数无效"); } if (month < 1 || month > 12) { throw NCCException.Oh("月份参数无效,应在1-12之间"); } // 查询所有健康师 var healthCoaches = await _db.Queryable() .Where(u => u.Gw == "健康师" && u.EnabledMark == 1 && (u.DeleteMark == null || u.DeleteMark == 0)) .Select(u => new { u.Id, u.RealName }) .ToListAsync(); if (!healthCoaches.Any()) { throw NCCException.Oh("未找到健康师用户"); } // 检查是否已存在该年月的考勤数据 var existingRecords = await _db.Queryable() .Where(a => a.Year == year && a.Month == month) .CountAsync(); if (existingRecords > 0) { throw NCCException.Oh($"已存在{year}年{month}月的考勤数据,请先清空后再生成"); } var totalCount = healthCoaches.Count; var highAttendanceCount = (int)Math.Ceiling(totalCount * 0.8); // 80%以上,向上取整 var lowAttendanceCount = totalCount - highAttendanceCount; var random = new Random(); var entitiesToInsert = new List(); var now = DateTime.Now; // 生成考勤数据 for (int i = 0; i < totalCount; i++) { var healthCoach = healthCoaches[i]; decimal workDays; decimal leaveDays; decimal restDays; if (i < highAttendanceCount) { // 80%以上的健康师:出勤天数 >= 21天 workDays = random.Next(21, 31); // 21-30天 var remainingDays = 30 - workDays; // 剩余天数分配给请假和休息 if (remainingDays > 0) { leaveDays = random.Next(0, (int)remainingDays + 1); restDays = remainingDays - leaveDays; } else { leaveDays = 0; restDays = 0; } } else { // 其他20%的健康师:出勤天数 < 21天 workDays = random.Next(0, 21); // 0-20天 var remainingDays = 30 - workDays; // 剩余天数分配给请假和休息 if (remainingDays > 0) { leaveDays = random.Next(0, (int)remainingDays + 1); restDays = remainingDays - leaveDays; } else { leaveDays = 0; restDays = 0; } } var entity = new LqAttendanceSummaryEntity { Id = YitIdHelper.NextId().ToString(), UserId = healthCoach.Id, Year = year, Month = month, EmployeeStatus = 1, // 在职 WorkDays = workDays, LeaveDays = leaveDays, RestDays = restDays, Remark = "模拟数据", CreateUser = _userManager.UserId ?? "system", CreateTime = now, UpdateUser = _userManager.UserId ?? "system", UpdateTime = now, IsEffective = 1 }; entitiesToInsert.Add(entity); } // 批量插入 await _db.Insertable(entitiesToInsert).ExecuteCommandAsync(); // 统计结果 var highAttendanceActual = entitiesToInsert.Count(e => e.WorkDays >= 21); var highAttendancePercentage = (decimal)highAttendanceActual / totalCount * 100; return new { success = true, message = "生成考勤模拟数据成功", data = new { year = year, month = month, totalCount = totalCount, highAttendanceCount = highAttendanceActual, highAttendancePercentage = Math.Round(highAttendancePercentage, 2), generatedCount = entitiesToInsert.Count } }; } catch (Exception ex) { _logger.LogError(ex, $"生成健康师考勤模拟数据失败 - 年份: {year}, 月份: {month}"); throw NCCException.Oh($"生成健康师考勤模拟数据失败:{ex.Message}"); } } #endregion } }