using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Mapster;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NCC.Common.Core.Manager;
using NCC.Common.Enum;
using NCC.Common.Helper;
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 上传Excel文件导入考勤汇总数据
///
/// 上传Excel文件导入考勤汇总数据
///
///
/// 上传Excel文件批量导入考勤汇总数据
///
/// Excel文件格式要求:
/// - 第一行必须是标题行
/// - 列顺序:员工姓名、员工电话、年份、月份、出勤天数、请假天数、休息天数、备注
/// - 支持.xlsx和.xls格式
///
/// 示例请求:
/// POST /api/Extend/LqAttendanceSummary/ImportAttendanceDataFromExcel
/// Content-Type: multipart/form-data
///
/// 参数说明:
/// - file: Excel文件(必填)
///
/// Excel文件
/// 导入结果
/// 导入成功
/// 文件格式错误
/// 服务器错误
[HttpPost("ImportAttendanceDataFromExcel")]
public async Task ImportAttendanceDataFromExcel(IFormFile file)
{
try
{
if (file == null || file.Length == 0)
{
throw NCCException.Oh("请选择要上传的Excel文件");
}
// 检查文件格式
var allowedExtensions = new[] { ".xlsx", ".xls" };
var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(fileExtension))
{
throw NCCException.Oh("只支持.xlsx和.xls格式的Excel文件");
}
var importData = new List();
// 保存临时文件
var tempFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + Path.GetExtension(file.FileName));
try
{
using (var stream = new FileStream(tempFilePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
// 使用ExcelImportHelper读取Excel文件
var dataTable = ExcelImportHelper.ToDataTable(tempFilePath, 0, 0);
if (dataTable.Rows.Count == 0)
{
throw NCCException.Oh("Excel文件中没有数据行");
}
// 根据列名查找列索引(支持多种可能的列名)
int GetColumnIndex(string[] possibleNames)
{
foreach (var name in possibleNames)
{
for (int col = 0; col < dataTable.Columns.Count; col++)
{
var columnName = dataTable.Columns[col].ColumnName?.Trim();
if (columnName == name || columnName?.Contains(name) == true)
{
return col;
}
}
}
return -1;
}
var idColIndex = GetColumnIndex(new[] { "员工ID", "id", "用户ID", "员工id", "用户id" });
var nameColIndex = GetColumnIndex(new[] { "员工姓名", "姓名", "员工", "姓名" });
var phoneColIndex = GetColumnIndex(new[] { "员工电话", "电话", "手机", "手机号", "联系电话" });
var yearColIndex = GetColumnIndex(new[] { "年份", "年" });
var monthColIndex = GetColumnIndex(new[] { "月份", "月" });
var workDaysColIndex = GetColumnIndex(new[] { "出勤天数(算薪在店天数)", "出勤天数", "出勤", "上班天数" });
var leaveDaysColIndex = GetColumnIndex(new[] { "请假天数", "请假", "事假天数" });
var restDaysColIndex = GetColumnIndex(new[] { "休息天数", "休息", "休假天数" });
var remarkColIndex = GetColumnIndex(new[] { "备注", "说明", "备注信息" });
// 验证必需的列是否存在
if (nameColIndex == -1) throw NCCException.Oh("Excel文件中未找到'员工姓名'列");
if (phoneColIndex == -1) throw NCCException.Oh("Excel文件中未找到'员工电话'列");
if (yearColIndex == -1) throw NCCException.Oh("Excel文件中未找到'年份'列");
if (monthColIndex == -1) throw NCCException.Oh("Excel文件中未找到'月份'列");
// DataTable 首行已是 Excel 第 2 行数据(标题行由 ExcelImportHelper 解析为列名)
for (int i = 0; i < dataTable.Rows.Count; i++)
{
try
{
var row = dataTable.Rows[i];
var userId = idColIndex >= 0 ? row[idColIndex]?.ToString()?.Trim() : null;
var employeeName = row[nameColIndex]?.ToString()?.Trim();
var employeePhone = row[phoneColIndex]?.ToString()?.Trim();
if (employeePhone == "无")
{
employeePhone = null;
}
// 处理年份:可能是数字、日期或字符串
string yearText = null;
if (yearColIndex >= 0 && row[yearColIndex] != null && row[yearColIndex] != DBNull.Value)
{
if (row[yearColIndex] is DateTime dt)
{
yearText = dt.Year.ToString();
}
else if (row[yearColIndex] is double d)
{
yearText = ((int)d).ToString();
}
else
{
yearText = row[yearColIndex].ToString()?.Trim();
}
}
// 处理月份:可能是数字、日期或字符串
string monthText = null;
if (monthColIndex >= 0 && row[monthColIndex] != null && row[monthColIndex] != DBNull.Value)
{
if (row[monthColIndex] is DateTime dt)
{
monthText = dt.Month.ToString();
}
else if (row[monthColIndex] is double d)
{
monthText = ((int)d).ToString();
}
else
{
monthText = row[monthColIndex].ToString()?.Trim();
}
}
var workDaysText = workDaysColIndex >= 0 ? row[workDaysColIndex]?.ToString()?.Trim() : null;
var leaveDaysText = leaveDaysColIndex >= 0 ? row[leaveDaysColIndex]?.ToString()?.Trim() : null;
var restDaysText = restDaysColIndex >= 0 ? row[restDaysColIndex]?.ToString()?.Trim() : null;
var remark = remarkColIndex >= 0 ? row[remarkColIndex]?.ToString()?.Trim() : null;
// 跳过空行
if (string.IsNullOrEmpty(employeeName) && string.IsNullOrEmpty(employeePhone))
{
continue;
}
// 验证必填字段
if (string.IsNullOrEmpty(employeeName))
{
throw new Exception($"第{i + 2}行:员工姓名不能为空");
}
if (string.IsNullOrEmpty(employeePhone) && string.IsNullOrEmpty(userId))
{
throw new Exception($"第{i + 2}行:员工电话不能为空(无员工ID时)");
}
// 解析数值字段
int year = 0;
int month = 0;
// 解析年份:支持纯数字、日期格式、中文格式
if (string.IsNullOrEmpty(yearText))
{
throw new Exception($"第{i + 2}行:年份不能为空");
}
// 尝试直接解析为整数
if (int.TryParse(yearText, out year))
{
// 成功解析
}
// 尝试解析日期格式(如:2025-11-01 或 2025/11/01)
else if (DateTime.TryParse(yearText, out DateTime yearDate))
{
year = yearDate.Year;
}
// 尝试解析中文格式(如:2025年)
else if (yearText.Contains("年"))
{
var yearMatch = Regex.Match(yearText, @"(\d{4})年");
if (yearMatch.Success && int.TryParse(yearMatch.Groups[1].Value, out year))
{
// 成功解析
}
else
{
throw new Exception($"第{i + 2}行:年份格式错误,无法解析:{yearText}");
}
}
else
{
throw new Exception($"第{i + 2}行:年份格式错误,无法解析。实际值:\"{yearText}\"(类型:{yearText?.GetType().Name})");
}
// 验证年份范围
if (year < 2020 || year > 2030)
{
throw new Exception($"第{i + 2}行:年份必须在2020-2030之间");
}
// 解析月份:支持纯数字、日期格式、中文格式
if (string.IsNullOrEmpty(monthText))
{
throw new Exception($"第{i + 2}行:月份不能为空");
}
// 尝试直接解析为整数
if (int.TryParse(monthText, out month))
{
// 成功解析
}
// 尝试解析日期格式(如:2025-11-01 或 2025/11/01)
else if (DateTime.TryParse(monthText, out DateTime monthDate))
{
month = monthDate.Month;
}
// 尝试解析中文格式(如:11月)
else if (monthText.Contains("月"))
{
var monthMatch = Regex.Match(monthText, @"(\d{1,2})月");
if (monthMatch.Success && int.TryParse(monthMatch.Groups[1].Value, out month))
{
// 成功解析
}
else
{
throw new Exception($"第{i + 2}行:月份格式错误,无法解析:{monthText}");
}
}
// 尝试解析"年-月"格式(如:2025-11)
else if (monthText.Contains("-") || monthText.Contains("/"))
{
var parts = monthText.Split(new[] { "-", "/" }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && int.TryParse(parts[1], out month))
{
// 成功解析
}
else
{
throw new Exception($"第{i + 2}行:月份格式错误,无法解析:{monthText}");
}
}
else
{
throw new Exception($"第{i + 2}行:月份格式错误,无法解析。实际值:\"{monthText}\"(类型:{monthText?.GetType().Name})");
}
// 验证月份范围
if (month < 1 || month > 12)
{
throw new Exception($"第{i + 2}行:月份必须在1-12之间");
}
var daysInMonth = DateTime.DaysInMonth(year, month);
decimal.TryParse(workDaysText, out decimal workDays);
if (workDays > daysInMonth)
{
throw new Exception($"第{i + 2}行:出勤天数不能超过当月自然日天数({daysInMonth}天)");
}
decimal.TryParse(leaveDaysText, out decimal leaveDays);
decimal.TryParse(restDaysText, out decimal restDays);
var item = new LqAttendanceSummaryImportInput
{
UserId = userId,
EmployeeName = employeeName,
EmployeePhone = employeePhone,
Year = year,
Month = month,
WorkDays = workDays,
LeaveDays = leaveDays,
RestDays = restDays,
Remark = remark
};
importData.Add(item);
}
catch (Exception ex)
{
throw new Exception($"第{i + 2}行数据解析失败: {ex.Message}");
}
}
}
finally
{
// 清理临时文件
if (File.Exists(tempFilePath))
{
File.Delete(tempFilePath);
}
}
if (!importData.Any())
{
throw NCCException.Oh("Excel文件中没有有效的数据行");
}
// 处理导入数据
return await ProcessImportData(importData);
}
catch (Exception ex)
{
_logger.LogError(ex, "上传Excel文件导入考勤汇总数据失败");
throw NCCException.Oh($"上传Excel文件导入考勤汇总数据失败: {ex.Message}");
}
}
#endregion
#region 处理导入数据
///
/// 处理导入数据
///
/// 导入数据列表
/// 导入结果
private async Task ProcessImportData(List importData)
{
var successCount = 0;
var failCount = 0;
var errorMessages = new List();
var entitiesToInsert = new List();
var entitiesToUpdate = new List();
var importedUserKeys = new List<(int Year, int Month, string UserId)>();
foreach (var item in importData)
{
try
{
// 1. 优先按模板 id 列匹配,其次姓名+电话,无电话时仅按姓名
UserEntity user = null;
if (!string.IsNullOrEmpty(item.UserId))
{
user = await _db.Queryable()
.Where(u => u.Id == item.UserId && u.DeleteMark == null)
.FirstAsync();
}
if (user == null && !string.IsNullOrEmpty(item.EmployeePhone))
{
user = await _db.Queryable()
.Where(u => u.RealName == item.EmployeeName && u.MobilePhone == item.EmployeePhone && u.DeleteMark == null)
.FirstAsync();
}
if (user == null && !string.IsNullOrEmpty(item.EmployeeName))
{
user = await _db.Queryable()
.Where(u => u.RealName == item.EmployeeName && u.DeleteMark == null)
.FirstAsync();
}
if (user == null)
{
var phoneLabel = string.IsNullOrEmpty(item.EmployeePhone) ? "无电话" : item.EmployeePhone;
errorMessages.Add($"员工 {item.EmployeeName}({phoneLabel}) 不存在");
failCount++;
continue;
}
// 2. 检查是否已存在相同记录
var existingRecord = await _db.Queryable()
.Where(a => a.UserId == user.Id && a.Year == item.Year && a.Month == item.Month)
.FirstAsync();
var monthStartForStatus = new DateTime(item.Year, item.Month, 1);
var monthLastForStatus = new DateTime(
item.Year, item.Month, DateTime.DaysInMonth(item.Year, item.Month), 23, 59, 59);
var employeeStatus = AttendanceMonthOnJobUserQuery.ResolveEmployeeStatusForMonth(
user.LeaveDate, monthStartForStatus, monthLastForStatus);
if (existingRecord != null)
{
// 更新现有记录
existingRecord.EmployeeStatus = employeeStatus;
existingRecord.WorkDays = item.WorkDays;
existingRecord.LeaveDays = item.LeaveDays;
existingRecord.RestDays = item.RestDays;
existingRecord.Remark = AttendanceSummarySourceHelper.BuildImportRemark(item.Remark);
existingRecord.UpdateUser = _userManager.UserId;
existingRecord.UpdateTime = DateTime.Now;
entitiesToUpdate.Add(existingRecord);
}
else
{
// 创建新记录
var entity = new LqAttendanceSummaryEntity
{
Id = YitIdHelper.NextId().ToString(),
UserId = user.Id,
Year = item.Year,
Month = item.Month,
EmployeeStatus = employeeStatus,
WorkDays = item.WorkDays,
LeaveDays = item.LeaveDays,
RestDays = item.RestDays,
Remark = AttendanceSummarySourceHelper.BuildImportRemark(item.Remark),
CreateUser = _userManager.UserId,
CreateTime = DateTime.Now,
UpdateUser = _userManager.UserId,
UpdateTime = DateTime.Now,
IsEffective = 1
};
entitiesToInsert.Add(entity);
}
importedUserKeys.Add((item.Year, item.Month, user.Id));
successCount++;
}
catch (Exception ex)
{
errorMessages.Add($"处理员工 {item.EmployeeName} 数据失败: {ex.Message}");
failCount++;
_logger.LogError(ex, $"导入考勤数据失败 - 员工: {item.EmployeeName}");
}
}
// 批量插入和更新
if (entitiesToInsert.Any())
{
await _db.Insertable(entitiesToInsert).ExecuteCommandAsync();
}
if (entitiesToUpdate.Any())
{
await _db.Updateable(entitiesToUpdate).ExecuteCommandAsync();
}
// Excel 导入:仅移除同月未出现在本次导入清单中的「Excel导入」汇总行,不影响打卡同步数据
foreach (var monthGroup in importedUserKeys.GroupBy(x => (x.Year, x.Month)))
{
var importedUserIds = monthGroup.Select(x => x.UserId).Distinct().ToHashSet(StringComparer.Ordinal);
var summariesInMonth = await _db.Queryable()
.Where(a => a.Year == monthGroup.Key.Year && a.Month == monthGroup.Key.Month)
.ToListAsync();
var excelIdsToDelete = summariesInMonth
.Where(s => AttendanceSummarySourceHelper.IsExcelImport(s) && !importedUserIds.Contains(s.UserId))
.Select(s => s.Id)
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToList();
if (excelIdsToDelete.Count > 0)
{
await _db.Deleteable()
.Where(a => excelIdsToDelete.Contains(a.Id))
.ExecuteCommandAsync();
}
}
if (successCount == 0)
{
var preview = errorMessages.Any()
? string.Join(";", errorMessages.Take(5))
: "请检查员工姓名、电话或 id 是否与系统一致";
throw NCCException.Oh($"导入失败:{preview}");
}
return new
{
success = true,
message = failCount > 0 ? $"导入完成,成功 {successCount} 条,失败 {failCount} 条" : "导入完成",
data = new
{
totalCount = importData.Count,
successCount = successCount,
failCount = failCount,
insertCount = entitiesToInsert.Count,
updateCount = entitiesToUpdate.Count,
errorMessages = errorMessages
}
};
}
#endregion
#region 导入模板人员清单
///
/// 获取指定年月考勤汇总 Excel 导入模板人员清单
///
///
/// 口径与员工花名册、从打卡同步汇总一致(historical-on-job-inference):
/// - 入职不晚于该月末,且(无离职日或离职日≥该月1日)→ 含当月中途离职,不含更早离职
/// - 该月有有效汇总且员工状态为在职时亦入选
///
/// 示例请求:
/// `GET /api/Extend/LqAttendanceSummary/ImportTemplateUsers?year=2026&month=5`
///
/// 年份
/// 月份(1-12)
/// 人员清单
/// 成功返回人员列表
[HttpGet("ImportTemplateUsers")]
public async Task GetImportTemplateUsers([FromQuery] int year, [FromQuery] int month)
{
if (year < 1990 || year > 2100 || month < 1 || month > 12)
{
throw NCCException.Oh("请先选择有效的年份和月份");
}
var now = DateTime.Now;
var isFutureMonth = year > now.Year || (year == now.Year && month > now.Month);
var monthStart = new DateTime(year, month, 1);
var monthLast = new DateTime(year, month, DateTime.DaysInMonth(year, month), 23, 59, 59);
var eligibleLines = await AttendanceMonthOnJobUserQuery.QuerySalaryMonthUsersAsync(
_db, year, month, monthStart, monthLast, isFutureMonth, null, null);
var userIds = eligibleLines
.Select(x => x.UserId)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct()
.ToList();
var users = userIds.Count == 0
? new List()
: await _db.Queryable()
.Where(u => userIds.Contains(u.Id) && u.DeleteMark == null)
.ToListAsync();
var userById = users.GroupBy(u => u.Id).ToDictionary(g => g.Key, g => g.First());
var summaries = userIds.Count == 0
? new List()
: await _db.Queryable()
.Where(s => s.Year == year && s.Month == month && s.IsEffective == StatusEnum.有效.GetHashCode())
.Where(s => userIds.Contains(s.UserId))
.ToListAsync();
var summaryByUserId = summaries
.Where(s => !string.IsNullOrWhiteSpace(s.UserId))
.GroupBy(s => s.UserId)
.ToDictionary(g => g.Key, g => g.First());
var monthEndDate = monthLast.Date;
var list = eligibleLines
.Where(line => !string.IsNullOrWhiteSpace(line.UserId) && userById.ContainsKey(line.UserId))
.Select(line =>
{
var u = userById[line.UserId];
var leftInMonth = u.LeaveDate.HasValue
&& u.LeaveDate.Value.Date >= monthStart.Date
&& u.LeaveDate.Value.Date <= monthEndDate;
summaryByUserId.TryGetValue(line.UserId, out var summary);
return new LqAttendanceSummaryImportTemplateUserOutput
{
id = u.Id,
realName = u.RealName,
mobilePhone = u.MobilePhone,
leftInMonth = leftInMonth,
workDays = summary?.WorkDays,
leaveDays = summary?.LeaveDays,
restDays = summary?.RestDays
};
})
.ToList();
return new
{
year,
month,
total = list.Count,
list
};
}
#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;
}
profileNameByUserId.TryGetValue(item.userId, out var profileName);
item.dataSource = AttendanceSummarySourceHelper.ResolveDataSource(item.remark, item.dataSource);
// Excel 导入:仅展示导入字段,不计算打卡扩展指标
if (AttendanceSummarySourceHelper.IsExcelImport(item.remark, item.dataSource))
{
item.attendanceGroupDisplay = string.IsNullOrWhiteSpace(profileName) ? "无" : profileName;
continue;
}
recordsByUserMonth.TryGetValue((item.userId, item.year, item.month), out var monthRecs);
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
}
}