Files
f9-web-ai-test/backend/F9WebAiTest.Application/Services/Attendance/AttendanceService.cs
T
2026-10-08 10:43:59 +08:00

574 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos;
using F9WebAiTest.Application.Dtos.Attendance;
using F9WebAiTest.Application.Services.Notice;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities;
using F9WebAiTest.Domain.Entities.Attendance;
using F9WebAiTest.Domain.Entities.Notice;
using RBAC = F9WebAiTest.Domain.Entities.SYS.RBAC;
using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments;
using MediatR;
namespace F9WebAiTest.Application.Services.Attendance;
public class AttendanceService : IAttendanceService
{
private readonly IRepository<AttendanceRecord> _attRepo;
private readonly IRepository<LeaveRequest> _leaveRepo;
private readonly IRepository<LeaveApprover> _approverRepo;
private readonly IRepository<RBAC.User> _empRepo;
private readonly IRepository<SysDept.Department> _deptRepo;
private readonly IRepository<RBAC.Role> _roleRepo;
private readonly IRepository<RBAC.UserRole> _empRoleRepo;
private readonly IRepository<NotificationMessage> _msgRepo;
private readonly IRepository<NotificationReceiver> _receiverRepo;
private readonly IRepository<TodoItem> _todoRepo;
private readonly IRepository<NotificationType> _typeRepo;
private readonly IMediator _mediator;
// 上下班时间(可配置,这里简化固定)
private static readonly TimeSpan WorkStart = new(9, 0, 0);
private static readonly TimeSpan WorkEnd = new(18, 0, 0);
public AttendanceService(
IRepository<AttendanceRecord> attRepo,
IRepository<LeaveRequest> leaveRepo,
IRepository<LeaveApprover> approverRepo,
IRepository<RBAC.User> empRepo,
IRepository<SysDept.Department> deptRepo,
IRepository<RBAC.Role> roleRepo,
IRepository<RBAC.UserRole> empRoleRepo,
IRepository<NotificationMessage> msgRepo,
IRepository<NotificationReceiver> receiverRepo,
IRepository<TodoItem> todoRepo,
IRepository<NotificationType> typeRepo,
IMediator mediator)
{
_attRepo = attRepo;
_leaveRepo = leaveRepo;
_approverRepo = approverRepo;
_empRepo = empRepo;
_deptRepo = deptRepo;
_roleRepo = roleRepo;
_empRoleRepo = empRoleRepo;
_msgRepo = msgRepo;
_receiverRepo = receiverRepo;
_todoRepo = todoRepo;
_typeRepo = typeRepo;
_mediator = mediator;
}
public async Task<PunchResultDto> PunchAsync(long employeeId)
{
var now = DateTime.Now;
var today = now.Date;
var record = await _attRepo.FirstOrDefaultAsync(a => a.EmployeeId == employeeId && a.WorkDate == today);
// 签到
if (record == null)
{
var status = now.TimeOfDay <= WorkStart.Add(TimeSpan.FromMinutes(10)) ? "正常" : "迟到";
var newRecord = new AttendanceRecord
{
EmployeeId = employeeId,
WorkDate = today,
SignInTime = now,
SignInStatus = status,
SignOutStatus = "未签退"
};
await _attRepo.AddAsync(newRecord);
await _attRepo.SaveChangesAsync();
return new PunchResultDto
{
Type = "signIn",
Time = now,
Status = status,
Message = status == "正常" ? "签到成功,辛苦了!" : "签到成功(已迟到)"
};
}
// 已签到,进行签退
if (record.SignOutTime == null)
{
var status = now.TimeOfDay >= WorkEnd ? "正常" : "早退";
record.SignOutTime = now;
record.SignOutStatus = status;
await _attRepo.UpdateAsync(record);
await _attRepo.SaveChangesAsync();
return new PunchResultDto
{
Type = "signOut",
Time = now,
Status = status,
Message = status == "正常" ? "签退成功,明天见!" : "签退成功(早退)"
};
}
// 已签退
return new PunchResultDto
{
Type = "done",
Time = now,
Status = "已完成",
Message = "今日已签到和签退,无需重复打卡"
};
}
public async Task<PageResult<AttendanceDto>> GetMyRecordsAsync(long employeeId, int pageIndex, int pageSize)
{
var records = (await _attRepo.FindAsync(a => a.EmployeeId == employeeId))
.OrderByDescending(a => a.WorkDate)
.ToList();
var total = records.Count;
var items = records.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();
var emp = await _empRepo.GetByIdAsync(employeeId);
var dept = emp != null ? await _deptRepo.GetByIdAsync(emp.DepartmentId) : null;
return new PageResult<AttendanceDto>
{
Items = items.Select(a => new AttendanceDto
{
Id = a.Id,
EmployeeId = a.EmployeeId,
EmployeeName = emp?.UserName ?? "",
DepartmentName = dept?.Name ?? "",
WorkDate = a.WorkDate,
SignInTime = a.SignInTime,
SignOutTime = a.SignOutTime,
SignInStatus = a.SignInStatus,
SignOutStatus = a.SignOutStatus,
Remark = a.Remark
}).ToList(),
Total = total,
PageIndex = pageIndex,
PageSize = pageSize
};
}
public async Task<AttendanceSummaryDto> GetMySummaryAsync(long employeeId, string month)
{
// month 格式 yyyy-MM,默认当前月
if (string.IsNullOrWhiteSpace(month))
{
month = DateTime.Now.ToString("yyyy-MM");
}
var start = DateTime.Parse(month + "-01");
var end = start.AddMonths(1);
var records = await _attRepo.FindAsync(a =>
a.EmployeeId == employeeId && a.WorkDate >= start && a.WorkDate < end);
var summary = new AttendanceSummaryDto();
foreach (var r in records)
{
summary.TotalDays++;
if (r.SignInStatus == "迟到") summary.LateDays++;
if (r.SignOutStatus == "早退") summary.EarlyLeaveDays++;
if (r.SignInTime == null) summary.AbsentDays++;
}
summary.NormalDays = summary.TotalDays - summary.LateDays - summary.EarlyLeaveDays - summary.AbsentDays;
// 请假天数
var leaves = await _leaveRepo.FindAsync(l =>
l.EmployeeId == employeeId && l.Status == "已批准" && l.StartTime < end && l.EndTime >= start);
summary.LeaveDays = leaves.Sum(l => (l.EndTime - l.StartTime).Days + 1);
return summary;
}
public async Task<PageResult<AttendanceDto>> GetAllRecordsAsync(AttendanceQueryDto query)
{
var records = await _attRepo.GetAllAsync();
var filtered = records.AsQueryable();
if (!string.IsNullOrWhiteSpace(query.Date))
{
var date = DateTime.Parse(query.Date).Date;
filtered = filtered.Where(a => a.WorkDate == date);
}
var emps = await _empRepo.GetAllAsync();
var depts = await _deptRepo.GetAllAsync();
var empDict = emps.ToDictionary(e => e.Id, e => e);
var deptDict = depts.ToDictionary(d => d.Id, d => d.Name);
if (query.DepartmentId.HasValue)
{
var deptEmpIds = emps.Where(e => e.DepartmentId == query.DepartmentId.Value).Select(e => e.Id).ToHashSet();
filtered = filtered.Where(a => deptEmpIds.Contains(a.EmployeeId));
}
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var kwEmpIds = emps.Where(e => e.UserName.Contains(query.Keyword) || e.LoginName.Contains(query.Keyword))
.Select(e => e.Id).ToHashSet();
filtered = filtered.Where(a => kwEmpIds.Contains(a.EmployeeId));
}
var total = filtered.LongCount();
var items = filtered.OrderByDescending(a => a.WorkDate).ThenByDescending(a => a.SignInTime)
.Skip((query.PageIndex - 1) * query.PageSize).Take(query.PageSize).ToList();
return new PageResult<AttendanceDto>
{
Items = items.Select(a =>
{
var emp = empDict.GetValueOrDefault(a.EmployeeId);
return new AttendanceDto
{
Id = a.Id,
EmployeeId = a.EmployeeId,
EmployeeName = emp?.UserName ?? "",
DepartmentName = emp != null ? deptDict.GetValueOrDefault(emp.DepartmentId, "") : "",
WorkDate = a.WorkDate,
SignInTime = a.SignInTime,
SignOutTime = a.SignOutTime,
SignInStatus = a.SignInStatus,
SignOutStatus = a.SignOutStatus,
Remark = a.Remark
};
}).ToList(),
Total = total,
PageIndex = query.PageIndex,
PageSize = query.PageSize
};
}
public async Task<LeaveRequestDto> ApplyLeaveAsync(long employeeId, CreateLeaveDto dto)
{
if (dto.EndTime < dto.StartTime)
throw new ServiceException("结束时间不能早于开始时间");
// 确定审批人:优先使用前端选择的审批人,为空则回退到管理员
var approverIds = (dto.ApproverIds ?? new List<long>()).Where(id => id > 0).Distinct().ToList();
if (approverIds.Count == 0)
approverIds = await GetApproverIdsAsync();
if (approverIds.Count == 0)
throw new ServiceException("没有可用的审批人,请联系管理员");
var firstApprover = await _empRepo.GetByIdAsync(approverIds[0]);
var leave = new LeaveRequest
{
EmployeeId = employeeId,
Type = dto.Type,
StartTime = dto.StartTime,
EndTime = dto.EndTime,
Reason = dto.Reason ?? "",
CurrentOrderNo = 1,
ApproverId = approverIds[0],
ApproverName = firstApprover?.UserName ?? ""
};
await _leaveRepo.AddAsync(leave);
await _leaveRepo.SaveChangesAsync();
// 创建审批节点(序号 = 审批顺序)
for (var i = 0; i < approverIds.Count; i++)
{
var emp = await _empRepo.GetByIdAsync(approverIds[i]);
await _approverRepo.AddAsync(new LeaveApprover
{
LeaveId = leave.Id,
ApproverId = approverIds[i],
ApproverName = emp?.UserName ?? "",
OrderNo = i + 1
});
}
await _approverRepo.SaveChangesAsync();
// 只给第一个审批人发待办(顺序审批,后续审批人待前一节点通过后才收到)
await SendLeaveApprovalAsync(leave, approverIds[0], 1, approverIds.Count);
// 业务单据已创建,推送事件让各端刷新(多端同步)
await _mediator.Publish(new BusinessChangedEvent("LeaveApproval", leave.Id.ToString(), "Created"));
return await ToLeaveDto(leave);
}
public async Task<List<LeaveRequestDto>> GetMyLeavesAsync(long employeeId)
{
var leaves = await _leaveRepo.FindAsync(l => l.EmployeeId == employeeId);
var result = new List<LeaveRequestDto>();
foreach (var l in leaves.OrderByDescending(l => l.CreatedAt))
{
result.Add(await ToLeaveDto(l));
}
return result;
}
public async Task<List<LeaveRequestDto>> GetAllLeavesAsync(string status)
{
var leaves = await _leaveRepo.GetAllAsync();
if (!string.IsNullOrWhiteSpace(status) && status != "全部")
{
leaves = leaves.Where(l => l.Status == status).ToList();
}
var result = new List<LeaveRequestDto>();
foreach (var l in leaves.OrderByDescending(l => l.CreatedAt))
{
result.Add(await ToLeaveDto(l));
}
return result;
}
public async Task<LeaveRequestDto> ApproveLeaveAsync(long leaveId, long approverId, string approverName, ApproveLeaveDto dto)
{
var leave = await _leaveRepo.GetByIdAsync(leaveId);
if (leave == null) throw new ServiceException("请假申请不存在", 404);
if (leave.Status != "待审批") throw new ServiceException("该申请已处理");
var approvers = (await _approverRepo.FindAsync(a => a.LeaveId == leaveId))
.OrderBy(a => a.OrderNo).ToList();
// 校验当前审批节点
var current = approvers.FirstOrDefault(a => a.OrderNo == leave.CurrentOrderNo);
if (current == null) throw new ServiceException("审批节点不存在", 404);
if (current.ApproverId != approverId)
throw new ServiceException($"当前应由「{current.ApproverName}」审批,您无权操作", 403);
if (current.Status != "待审批") throw new ServiceException("该节点已处理");
// 更新当前节点
var handleResult = dto.Approved ? "已批准" : "已驳回";
current.Status = handleResult;
current.ApproveRemark = dto.Remark ?? "";
current.ApprovedAt = DateTime.Now;
await _approverRepo.UpdateAsync(current);
if (!dto.Approved)
{
// 驳回:整体驳回,结束流程
leave.Status = "已驳回";
leave.ApproverId = approverId;
leave.ApproverName = approverName;
leave.ApproveRemark = dto.Remark ?? "";
leave.ApprovedAt = DateTime.Now;
await _leaveRepo.UpdateAsync(leave);
await _leaveRepo.SaveChangesAsync();
await _approverRepo.SaveChangesAsync();
// 流程终结:完成该单所有待办 + 标记消息已读,避免残留未读红点
await FinalizeLeaveApprovalAsync(leaveId, handleResult);
await NotifyLeaveResultAsync(leave, false, dto.Remark);
await _mediator.Publish(new BusinessChangedEvent("LeaveApproval", leaveId.ToString(), "StatusChanged"));
return await ToLeaveDto(leave);
}
// 批准:判断是否还有下一级审批人
var next = approvers.FirstOrDefault(a => a.OrderNo == leave.CurrentOrderNo + 1);
if (next != null)
{
// 流转到下一级审批人
leave.CurrentOrderNo = next.OrderNo;
leave.ApproverId = next.ApproverId;
leave.ApproverName = next.ApproverName;
await _leaveRepo.UpdateAsync(leave);
await _leaveRepo.SaveChangesAsync();
await _approverRepo.SaveChangesAsync();
// 完成当前审批人待办并标记其消息已读,再把同一条消息流转给下一审批人
await CompleteCurrentApproverAsync(leaveId, approverId, handleResult);
await SendLeaveApprovalAsync(leave, next.ApproverId, next.OrderNo, approvers.Count);
}
else
{
// 最后一级审批人批准:整体批准
leave.Status = "已批准";
leave.ApproverId = approverId;
leave.ApproverName = approverName;
leave.ApproveRemark = dto.Remark ?? "";
leave.ApprovedAt = DateTime.Now;
await _leaveRepo.UpdateAsync(leave);
await _leaveRepo.SaveChangesAsync();
await _approverRepo.SaveChangesAsync();
// 流程终结
await FinalizeLeaveApprovalAsync(leaveId, handleResult);
await NotifyLeaveResultAsync(leave, true, dto.Remark);
}
// 业务状态已变更,推送事件让各端刷新(多端同步)
await _mediator.Publish(new BusinessChangedEvent("LeaveApproval", leaveId.ToString(), "StatusChanged"));
return await ToLeaveDto(leave);
}
/// <summary>查询审批人(admin 角色用户)ID 列表(未指定审批人时的兜底)</summary>
private async Task<List<long>> GetApproverIdsAsync()
{
var adminRole = await _roleRepo.FirstOrDefaultAsync(r => r.Code == "admin");
if (adminRole == null) return new List<long>();
var relations = await _empRoleRepo.FindAsync(ur => ur.RoleId == adminRole.Id);
return relations.Select(ur => ur.UserId).Distinct().ToList();
}
/// <summary>
/// 请假审批待办消息:一张请假单仅保留一条,随审批流转复用。
/// 首次创建,流转时更新内容与当前审批人并重置为待投递(投递服务幂等,只推新审批人)。
/// </summary>
private async Task SendLeaveApprovalAsync(LeaveRequest leave, long targetApproverId, int currentLevel, int totalLevel)
{
var businessType = NotificationTypeEnum.LeaveApproval.ToString();
var businessId = leave.Id.ToString();
var content = $"类型:{leave.Type},时间:{leave.StartTime:MM-dd HH:mm} ~ {leave.EndTime:MM-dd HH:mm},事由:{leave.Reason}(第 {currentLevel}/{totalLevel} 级审批)";
var msg = await _msgRepo.FirstOrDefaultAsync(m =>
m.BusinessType == businessType && m.BusinessId == businessId);
if (msg == null)
{
var type = await _typeRepo.FirstOrDefaultAsync(t => t.Code == businessType);
var applicant = await _empRepo.GetByIdAsync(leave.EmployeeId);
msg = new NotificationMessage
{
NotificationTypeId = type?.Id ?? 0,
MessageTypeCode = businessType,
BusinessType = businessType,
BusinessId = businessId,
Title = $"{applicant?.UserName ?? "员工"} 提交了请假申请",
Content = content,
Priority = NotificationPriority.Important,
Status = NotificationStatus.Ready,
ForceReceivers = JsonSerializer.Serialize(new List<long> { targetApproverId }),
CreatorId = leave.EmployeeId,
CreatorName = applicant?.UserName ?? "",
Category = NotificationCategory.Todo,
RoutePath = !string.IsNullOrWhiteSpace(type?.RouteTemplate)
? type.RouteTemplate.Replace("{businessId}", businessId)
: $"/attendance/approve?leaveId={businessId}"
};
await _msgRepo.AddAsync(msg);
await _msgRepo.SaveChangesAsync();
}
else
{
// 复用:更新内容与当前审批人,重置为待投递,投递服务幂等只会推送给新审批人
msg.Content = content;
msg.ForceReceivers = JsonSerializer.Serialize(new List<long> { targetApproverId });
msg.Status = NotificationStatus.Ready;
await _msgRepo.UpdateAsync(msg);
await _msgRepo.SaveChangesAsync();
}
// 当前审批人的待办(幂等:已存在则跳过)
var existTodo = await _todoRepo.FirstOrDefaultAsync(t => t.MessageId == msg.Id && t.AssigneeId == targetApproverId);
if (existTodo == null)
{
await _todoRepo.AddAsync(new TodoItem
{
MessageId = msg.Id,
AssigneeId = targetApproverId,
TodoStatus = TodoItemStatus.Pending
});
await _todoRepo.SaveChangesAsync();
}
}
/// <summary>完成当前审批人的待办,并标记其消息已读(流转时调用,避免已处理人残留未读红点)</summary>
private async Task CompleteCurrentApproverAsync(long leaveId, long approverId, string handleResult)
{
var msgs = await _msgRepo.FindAsync(m =>
m.BusinessType == NotificationTypeEnum.LeaveApproval.ToString() && m.BusinessId == leaveId.ToString());
var msgIds = msgs.Select(m => m.Id).ToList();
if (msgIds.Count == 0) return;
var todos = await _todoRepo.FindAsync(t =>
msgIds.Contains(t.MessageId) && t.AssigneeId == approverId && t.TodoStatus == TodoItemStatus.Pending);
foreach (var t in todos)
{
t.TodoStatus = TodoItemStatus.Completed;
t.CompletedTime = DateTime.Now;
t.HandleResult = handleResult;
await _todoRepo.UpdateAsync(t);
}
var receivers = await _receiverRepo.FindAsync(r =>
msgIds.Contains(r.MessageId) && r.UserId == approverId && r.ReadStatus == NotificationReadStatus.Unread);
foreach (var r in receivers)
{
r.ReadStatus = NotificationReadStatus.Read;
r.ReadTime = DateTime.Now;
await _receiverRepo.UpdateAsync(r);
}
}
/// <summary>流程终结:完成该单所有待处理待办,并标记所有未读消息为已读</summary>
private async Task FinalizeLeaveApprovalAsync(long leaveId, string handleResult)
{
var msgs = await _msgRepo.FindAsync(m =>
m.BusinessType == NotificationTypeEnum.LeaveApproval.ToString() && m.BusinessId == leaveId.ToString());
var msgIds = msgs.Select(m => m.Id).ToList();
if (msgIds.Count == 0) return;
var todos = await _todoRepo.FindAsync(t => msgIds.Contains(t.MessageId) && t.TodoStatus == TodoItemStatus.Pending);
foreach (var t in todos)
{
t.TodoStatus = TodoItemStatus.Completed;
t.CompletedTime = DateTime.Now;
t.HandleResult = handleResult;
await _todoRepo.UpdateAsync(t);
}
var receivers = await _receiverRepo.FindAsync(r => msgIds.Contains(r.MessageId) && r.ReadStatus == NotificationReadStatus.Unread);
foreach (var r in receivers)
{
r.ReadStatus = NotificationReadStatus.Read;
r.ReadTime = DateTime.Now;
await _receiverRepo.UpdateAsync(r);
}
}
/// <summary>通知申请人审批结果</summary>
private async Task NotifyLeaveResultAsync(LeaveRequest leave, bool approved, string remark)
{
await _mediator.Send(new SendNotificationCommand(
NotificationTypeEnum.LeaveResult,
BusinessId: leave.Id.ToString(),
Title: approved ? "请假申请已批准" : "请假申请已驳回",
Content: $"您的请假({leave.Type},{leave.StartTime:MM-dd HH:mm} ~ {leave.EndTime:MM-dd HH:mm})已{(approved ? "批准" : "驳回")}。审批意见:{remark}",
ForceReceivers: new List<long> { leave.EmployeeId }
));
}
private async Task<LeaveRequestDto> ToLeaveDto(LeaveRequest l)
{
var emp = await _empRepo.GetByIdAsync(l.EmployeeId);
var dept = emp != null ? await _deptRepo.GetByIdAsync(emp.DepartmentId) : null;
var approvers = (await _approverRepo.FindAsync(a => a.LeaveId == l.Id))
.OrderBy(a => a.OrderNo)
.Select(a => new LeaveApproverDto
{
Id = a.Id,
ApproverId = a.ApproverId,
ApproverName = a.ApproverName,
OrderNo = a.OrderNo,
Status = a.Status,
ApproveRemark = a.ApproveRemark,
ApprovedAt = a.ApprovedAt
})
.ToList();
return new LeaveRequestDto
{
Id = l.Id,
EmployeeId = l.EmployeeId,
EmployeeName = emp?.UserName ?? "",
DepartmentName = dept?.Name ?? "",
Type = l.Type,
StartTime = l.StartTime,
EndTime = l.EndTime,
Reason = l.Reason,
Status = l.Status,
CurrentOrderNo = l.CurrentOrderNo,
ApproverName = l.ApproverName,
ApproveRemark = l.ApproveRemark,
ApprovedAt = l.ApprovedAt,
CreatedAt = l.CreatedAt,
Approvers = approvers
};
}
}