Giới thiệu
Xây dựng một hệ thống nộp bảng chấm công đáng tin cậy cho các ứng dụng doanh nghiệp không chỉ đơn thuần là lưu trữ giờ làm việc vào cơ sở dữ liệu. Nó đòi hỏi sự phối hợp cẩn thận giữa việc lưu trữ dữ liệu, xác thực đa lớp, quản lý trạng thái phê duyệt và thông báo dựa trên sự kiện. Trong bài viết này, chúng ta sẽ phân tích một kiến trúc nộp bảng chấm công cấp độ sản xuất được xây dựng bằng ASP.NET Core, Entity Framework Core và MediatR, tập trung vào cách các thực thể được chuyển đổi qua từng giai đoạn của quy trình nộp bảng chấm công.
Tổng quan hệ thống
Hệ thống tuân theo mô hình CQRS (Command Query Responsibility Segregation) sử dụng MediatR. Khi người dùng gửi bảng chấm công hàng tuần, yêu cầu sẽ trải qua bốn giai đoạn riêng biệt:
- Lưu - Giữ lại các mục nhập giờ gần nhất
- Xác thực - Áp dụng các quy tắc kinh doanh
- Gửi - Trạng thái phê duyệt chuyển đổi
- Thông báo - Tạo sự kiện miền để gửi email
Mô hình thực thể
Mô hình dữ liệu cốt lõi xoay quanh ba thực thể:
public class WorkItem
{
public Guid Id { get; set; }
public string Title { get; set; }
public Guid ProjectAllocationId { get; set; }
public Guid? ApproverId { get; set; }
public string CategoryCode { get; set; }
public DateTime WeekStartDate { get; set; }
public string RejectionNote { get; set; }
public int SortOrder { get; set; }
public ICollection<TimeRecord> TimeRecords { get; set; }
}
public class TimeRecord
{
public Guid Id { get; set; }
public Guid? WorkItemId { get; set; }
public Guid? AbsenceRequestId { get; set; }
public decimal Hours { get; set; }
public DateTime RecordDate { get; set; }
public byte? ApprovalStatus { get; set; }
public DateTime? LastModifiedAt { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime? ApprovedAt { get; set; }
}
public class AbsenceRequest
{
public Guid Id { get; set; }
public Guid EmployeeId { get; set; }
public string AbsenceTypeCode { get; set; }
public DateTime WeekStartDate { get; set; }
public string Reason { get; set; }
public Guid? ApproverId { get; set; }
public ICollection<TimeRecord> TimeRecords { get; set; }
}
Mỗi WorkItem ô AbsenceRequest chứa chính xác 7 TimeRecord mục, đại diện cho các ngày từ thứ Hai đến Chủ Nhật. Trường ApprovalStatus dữ liệu trên mỗi ô TimeRecord điều khiển toàn bộ quy trình làm việc:
| Giá trị | Trạng thái | Nghĩa |
|---|---|---|
null |
Bản nháp | Người dùng có thể tự do chỉnh sửa |
0 |
Chưa giải quyết | Đang chờ sự phê duyệt của quản lý |
1 |
Tán thành | Quản lý đã xác nhận |
2 |
Vật bị loại bỏ | Quản lý được gửi lại để xem xét lại. |
5 |
Đang chờ làm thêm giờ | Đã được Thủ tướng phê duyệt, đang chờ xác nhận từ Văn phòng Thủ tướng. |
Giai đoạn 1: Lưu trước khi gửi
Một quyết định thiết kế quan trọng là mỗi lần gửi dữ liệu đều phải lưu lại trước khi xác thực hoặc thay đổi trạng thái. Điều này đảm bảo cơ sở dữ liệu phản ánh thông tin nhập mới nhất của người dùng bất kể quá trình xác thực có thành công hay không.
public class SubmitWeeklyTimeCommand : IRequest<Unit>
{
public PersistTimeCommand PersistCommand { get; set; }
public List<DayOfWeek> SelectedDays { get; set; }
}
public class SubmitWeeklyTimeHandler : IRequestHandler<SubmitWeeklyTimeCommand, Unit>
{
public async Task<Unit> Handle(SubmitWeeklyTimeCommand request, CancellationToken ct)
{
// Phase 1: Save current data
var sheet = await _mediator.Send(request.PersistCommand);
// Phase 2: Load rules and validate
var rule = await _dbContext.WorkingHourRules
.FirstOrDefaultAsync(r => r.Region == _context.RegionCode);
sheet.ConfigureRules(requiredHours: rule.DailyRequiredHours);
sheet.RunSubmissionValidation(request.SelectedDays);
var budgetError = await ValidateBudgetLimits(request, sheet);
if (budgetError != null) throw new ValidationException(budgetError);
// Phase 3: Change statuses
sheet.ProcessSubmission(request.SelectedDays);
// Phase 4: Publish events and persist
foreach (var evt in sheet.PendingEvents)
await _mediator.Publish(evt);
await _dbContext.SaveChangesAsync();
return Unit.Value;
}
}
Trình PersistCommand xử lý tải bảng chấm công hiện có từ cơ sở dữ liệu, ánh xạ dữ liệu hàng giao diện người dùng vào các đối tượng miền, cập nhật các thực thể được theo dõi bởi EF Core và gọi phương thức SaveChangesAsync().
Giai đoạn 2: Quy trình xác thực
Giai đoạn xác thực thực thi một chuỗi các quy tắc nghiệp vụ nghiêm ngặt. Mỗi quy tắc được chạy tuần tự, và các vi phạm được thu thập thành một ngoại lệ tổng hợp báo cáo lỗi theo từng ngày trong tuần.
public void RunSubmissionValidation(List<DayOfWeek> days)
{
EnsureNotEmpty();
EnsureNotAlreadySubmitted(days);
EnforceMaxHoursPerDay(days); // 24h ceiling
EnforceAbsenceConflicts(days); // No work on full-day leave
EnforceTimeGranularity(days); // 0.25h increments
EnforceRequiredDailyHours(days); // Work + Leave = 8h on weekdays
EnforceHolidayRestrictions(days); // Overtime only on holidays
if (_validationErrors.Any())
throw new CompositeValidationException(_validationErrors);
}
Quy tắc xác thực chính
Thực thi giờ làm việc hàng ngày: Vào các ngày trong tuần, tổng số giờ làm việc không tính giờ làm thêm và giờ nghỉ phép phải bằng số giờ làm việc quy định hàng ngày của công ty (thường là 8 giờ). Hệ thống phân biệt giữa các nhiệm vụ thông thường và nhiệm vụ làm thêm giờ, áp dụng các quy tắc khác nhau cho từng loại.
Độ chi tiết thời gian: Giờ làm việc phải là bội số của 0,25 (tăng dần 15 phút), trong khi giờ nghỉ phép phải là số nguyên. Điều này được xác thực bằng phép toán modulo: (hour * 100) % 25 == 0.
Kiểm tra ngân sách: Mỗi khoản phân bổ dự án có thể có TotalHours mức trần hoặc giới hạn MonthlyHoursCap. Hệ thống sẽ truy vấn tất cả TimeRecord các mục nhập hiện có cho khoản phân bổ đó và kiểm tra xem việc thêm số giờ đã gửi có vượt quá giới hạn hay không.
private async Task<ValidationError> ValidateBudgetLimits(
SubmitWeeklyTimeCommand request, IWeeklyTimesheet sheet)
{
var allocations = sheet.ActiveAllocations;
foreach (var alloc in allocations.Where(a => a.TotalHoursCap != null))
{
var usedHours = await _dbContext.TimeRecords
.Where(r => r.WorkItem.ProjectAllocationId == alloc.Id
&& r.RecordDate <= sundayDate)
.SumAsync(r => r.Hours);
if (alloc.TotalHoursCap - usedHours < 0)
return new ValidationError("Budget exceeded for " + alloc.Name);
}
return null;
}
Giai đoạn 3: Chuyển đổi trạng thái
Cốt lõi của quy trình nộp bài là logic chuyển đổi trạng thái. Chỉ những bài viết ở trạng thái "đang Draft chờ xử lý" mới Rejected chuyển sang trạng thái " Pendingđang chờ xử lý". Những bài viết đã được nộp hoặc đã được phê duyệt sẽ không thay đổi.
public void ProcessSubmission(List<DayOfWeek> selectedDays)
{
// Remove items with zero hours in submitted days
var activeItems = _workItems
.Where(t => t.TimeSlot.TotalHoursInDays(selectedDays) > 0)
.ToList();
// Change status for each selected day
var allDaySlots = activeItems
.SelectMany(t => t.TimeSlot.GetDayEntries())
.ToList();
foreach (var day in selectedDays)
{
allDaySlots
.Where(d => d.RecordDate.DayOfWeek == day)
.ToList()
.ForEach(TransitionToPending);
}
// Sync domain changes back to EF-tracked entities
SyncWorkItemEntities();
SyncAbsenceEntities();
SyncSupplementaryEntities();
// Raise domain events
RaiseSubmissionEvents(selectedDays);
}
private void TransitionToPending(DayEntry entry)
{
switch (entry.Status)
{
case EntryStatus.Draft:
case EntryStatus.Rejected:
entry.SetStatus(EntryStatus.Pending);
break;
// Pending and Approved entries are unchanged
}
}
Theo dõi thay đổi thực thể
Một mô hình kiến trúc quan trọng ở đây là mô hình thực thể hai lớp . Các đối tượng miền ( WorkTask, LeaveTask) chứa logic nghiệp vụ và được thao tác trong bộ nhớ. Các thực thể được theo dõi của EF Core ( Task, LeavePermission) là lớp lưu trữ dữ liệu. Sau khi các thao tác miền hoàn tất, một phương thức đồng bộ sẽ sao chép các thay đổi từ các đối tượng miền trở lại các thực thể EF:
private void SyncWorkItemEntities()
{
var now = DateTime.Now;
var timestamp = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0);
_trackedEntities.ForEach(entity =>
{
var domainItem = _domainItems.First(d => d.Id == entity.Id);
entity.Title = domainItem.Title;
entity.ApproverId = domainItem.ApproverId;
entity.SortOrder = domainItem.Order;
entity.TimeRecords.ToList().ForEach(record =>
{
var dayEntry = domainItem.TimeSlot.GetDay(record.Id);
record.ApprovalStatus = dayEntry.StatusValue; // Status change persisted here
var hours = Convert.ToDecimal(dayEntry.Hours);
if (record.Hours != hours)
record.LastModifiedAt = timestamp;
record.Hours = hours;
});
});
}
Mô hình này đảm bảo rằng trình theo dõi thay đổi của EF Core chỉ phát hiện các trường thực sự đã thay đổi, tạo ra UPDATE số lượng câu lệnh tối thiểu.
Giai đoạn 4: Thông báo dựa trên sự kiện
Sau khi chuyển đổi trạng thái, hệ thống sẽ tạo ra các sự kiện miền. Các sự kiện này được thu thập trong quá trình hoạt động của miền và được công bố thông qua MediatR sau khi logic chính hoàn tất nhưng trước khi SaveChangesAsync()...
// Domain event raised when tasks are submitted
public class TimeSubmittedEvent : INotification
{
public Guid EmployeeId { get; }
public IEnumerable<string> SubmittedDays { get; }
public DateTime WeekStartDate { get; }
public IEnumerable<Guid> AffectedItemIds { get; }
}
Các trình xử lý sự kiện chuyển đổi các sự kiện miền này thành tải trọng hàng đợi tin nhắn. Hệ thống sử dụng cơ chế đường ống chạy sau trình xử lý chính, đẩy tin nhắn vào hàng đợi tin nhắn để gửi email không đồng bộ:
public class MessageQueuePipeline<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var response = await next();
try
{
foreach (var message in _pendingMessages.EmailMessages)
{
await _producer.SendAsync(_settings.EmailTopic, message);
MarkAsQueued(message.NotificationId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to queue notification");
// Swallow - main operation already succeeded
}
return response;
}
}
Thiết kế này đảm bảo rằng việc gửi thông báo thất bại sẽ không bao giờ làm hủy bỏ việc nộp bảng chấm công thành công.
Quy trình phê duyệt
Sau khi được gửi đi, các bài dự thi sẽ tuân theo một quy trình phê duyệt được xác định rõ ràng:
Draft (null) ──[Submit]──> Pending (0) ──[Approve]──> Approved (1)
──[Reject]──> Rejected (2)
──[Re-submit]──> Pending (0)
Approved (1) ──[Reopen]──> Draft (null)
Đối với các nhiệm vụ làm thêm giờ có yêu cầu phê duyệt hai cấp:
Pending (0) ──[PM Approves]──> OvertimePending (5) ──[PMO Approves]──> Approved (1)
Việc phê duyệt được thực hiện chi tiết theo từng ngày , chứ không phải theo từng nhiệm vụ. Một nhiệm vụ có thể có ngày là thứ Hai Approved trong khi ngày vẫn là thứ Sáu Draft nếu chỉ có các nhiệm vụ từ thứ Hai đến thứ Năm được gửi đi.
Các quyết định thiết kế quan trọng
Mô hình lưu rồi xác thực: Bằng cách lưu trữ dữ liệu trước khi xác thực, hệ thống đảm bảo không có dữ liệu người dùng nhập vào bị mất ngay cả khi quá trình xác thực thất bại. Người dùng sẽ thấy dữ liệu đã lưu của họ ở lần tải trang tiếp theo.
Độ chi tiết trạng thái theo từng ngày: Thay vì một trạng thái duy nhất cho mỗi nhiệm vụ, mỗi ngày sẽ có trạng thái riêng ApprovalStatus. Điều này cho phép nộp bài trong một phần tuần và các trường hợp trạng thái hỗn hợp.
Phân tách miền và thực thể: Logic nghiệp vụ hoạt động trên các đối tượng miền phức tạp, trong khi việc lưu trữ dữ liệu sử dụng các thực thể EF đơn giản. Bước đồng bộ hóa đóng vai trò cầu nối giữa hai phần, giữ cho các vấn đề được tách biệt rõ ràng.
Thông báo dựa trên sự kiện: Việc gửi email hoàn toàn tách biệt khỏi giao dịch gửi dữ liệu. Các thông báo thất bại không ảnh hưởng đến hoạt động cốt lõi và tin nhắn có thể được gửi lại một cách độc lập.
Lỗi xác thực tổng hợp: Thay vì báo lỗi ngay khi gặp lỗi xác thực đầu tiên, hệ thống sẽ thu thập tất cả các lỗi và trả về chúng theo nhóm ngày, cho phép giao diện người dùng làm nổi bật các khu vực có vấn đề cụ thể trong bảng tuần.
Phần kết luận
Một hệ thống nộp bảng chấm công được thiết kế tốt không chỉ đơn thuần là các thao tác CRUD (Tạo, Đọc, Viết và Cập nhật). Bằng cách tách luồng công việc thành các giai đoạn lưu, xác thực, nộp và thông báo, hệ thống vẫn dễ bảo trì và mở rộng. Mô hình tách biệt miền-thực thể đảm bảo các quy tắc nghiệp vụ luôn rõ ràng và có thể kiểm thử, trong khi đường dẫn thông báo dựa trên sự kiện cung cấp độ tin cậy mà không gây phụ thuộc lẫn nhau. Cho dù bạn đang xây dựng một công cụ theo dõi thời gian từ đầu hay tái cấu trúc một công cụ hiện có, các mô hình kiến trúc này đều cung cấp nền tảng vững chắc để xử lý sự phức tạp của quy trình làm việc bảng chấm công trong doanh nghiệp.