From 5bf634278d2538f13c6fc56fd4403c8be82644cb Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 16:21:07 +0700 Subject: [PATCH] feat(attendance): add AttendanceTracker with thread-safe duplicate guard and scheduled checkout --- .../Services/AttendanceTracker.cs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 apps/KronaStaffTerminal/Services/AttendanceTracker.cs diff --git a/apps/KronaStaffTerminal/Services/AttendanceTracker.cs b/apps/KronaStaffTerminal/Services/AttendanceTracker.cs new file mode 100644 index 0000000..1217ad9 --- /dev/null +++ b/apps/KronaStaffTerminal/Services/AttendanceTracker.cs @@ -0,0 +1,174 @@ +using KronaStaffTerminal.Models; + +namespace KronaStaffTerminal.Services; + +public sealed record AttendanceRecordResult( + bool Success, + AttendanceRecord? Record, + string Message, + bool IsDuplicateSuppressed, + int RemainingSeconds = 0); + +public sealed class AttendanceTracker +{ + private readonly SemaphoreSlim _gate = new(1, 1); + + public async Task TryRecordAttendanceAsync( + TerminalState state, + EmployeeRecord employee, + int readerIndex, + DateTimeOffset nowUtc, + Func saveStateCallback) + { + await _gate.WaitAsync(); + try + { + var last = state.Attendance + .Where(item => item.EmployeeId == employee.Id) + .OrderByDescending(item => item.OccurredAtUtc) + .FirstOrDefault(); + + var guardSeconds = Math.Clamp(state.Settings.DuplicateScanGuardSeconds, 10, 3600); + var guard = TimeSpan.FromSeconds(guardSeconds); + + if (last is not null && nowUtc - last.OccurredAtUtc < guard) + { + var remaining = Math.Max(1, (int)Math.Ceiling((guard - (nowUtc - last.OccurredAtUtc)).TotalSeconds)); + return new AttendanceRecordResult( + Success: false, + Record: null, + Message: $"Повторный скан отклонён. Повторите через {remaining} сек.", + IsDuplicateSuppressed: true, + RemainingSeconds: remaining); + } + + var maximumShift = TimeSpan.FromHours(Math.Clamp(state.Settings.MaximumShiftHours, 1, 48)); + var isCheckIn = last is null || last.EventType != "Приход" || (nowUtc - last.OccurredAtUtc > maximumShift); + var eventType = isCheckIn ? "Приход" : "Уход"; + + var record = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = employee.Id, + EmployeeName = employee.DisplayName, + OccurredAtUtc = nowUtc, + ReaderIndex = readerIndex, + EventType = eventType, + IsAutomatic = false + }; + + state.Attendance.Insert(0, record); + if (state.Attendance.Count > 100_000) + { + state.Attendance.RemoveRange(100_000, state.Attendance.Count - 100_000); + } + + await saveStateCallback(state); + + return new AttendanceRecordResult( + Success: true, + Record: record, + Message: $"{eventType} отмечен в {nowUtc.ToLocalTime():HH:mm}", + IsDuplicateSuppressed: false); + } + finally + { + _gate.Release(); + } + } + + public async Task> RunScheduledCheckoutAsync( + TerminalState state, + DateTimeOffset nowUtc, + Func saveStateCallback) + { + await _gate.WaitAsync(); + try + { + if (!state.Settings.AutomaticCheckoutEnabled) + { + return []; + } + + var hour = Math.Clamp(state.Settings.AutomaticCheckoutHour, 0, 23); + var minute = Math.Clamp(state.Settings.AutomaticCheckoutMinute, 0, 59); + var latestScheduleUtc = LatestAutomaticCheckoutScheduleUtc(nowUtc, hour, minute); + + if (state.Settings.LastAutomaticCheckoutUtc >= latestScheduleUtc) + { + return []; + } + + var createdRecords = new List(); + foreach (var employee in state.Employees) + { + if (employee.IsBlocked) continue; + + var last = state.Attendance + .Where(item => item.EmployeeId == employee.Id) + .OrderByDescending(item => item.OccurredAtUtc) + .FirstOrDefault(); + + if (last is null || last.EventType != "Приход") continue; + + var checkoutUtc = NextAutomaticCheckoutAfter(last.OccurredAtUtc, hour, minute); + if (checkoutUtc > nowUtc) continue; + + var record = new AttendanceRecord + { + Id = UlidGenerator.New(), + EmployeeId = employee.Id, + EmployeeName = employee.DisplayName, + OccurredAtUtc = checkoutUtc, + ReaderIndex = -1, + EventType = "Уход автоматически", + IsAutomatic = true + }; + + createdRecords.Add(record); + } + + if (createdRecords.Count > 0) + { + state.Attendance.InsertRange(0, createdRecords); + if (state.Attendance.Count > 100_000) + { + state.Attendance.RemoveRange(100_000, state.Attendance.Count - 100_000); + } + } + + state.Settings.LastAutomaticCheckoutUtc = latestScheduleUtc; + await saveStateCallback(state); + + return createdRecords; + } + finally + { + _gate.Release(); + } + } + + public static DateTimeOffset LatestAutomaticCheckoutScheduleUtc(DateTimeOffset nowUtc, int hour, int minute) + { + var localNow = nowUtc.ToLocalTime(); + var scheduledToday = new DateTimeOffset( + localNow.Year, localNow.Month, localNow.Day, + hour, minute, 0, + localNow.Offset); + + var targetLocal = localNow >= scheduledToday ? scheduledToday : scheduledToday.AddDays(-1); + return targetLocal.ToUniversalTime(); + } + + public static DateTimeOffset NextAutomaticCheckoutAfter(DateTimeOffset startUtc, int hour, int minute) + { + var localStart = startUtc.ToLocalTime(); + var scheduledSameDay = new DateTimeOffset( + localStart.Year, localStart.Month, localStart.Day, + hour, minute, 0, + localStart.Offset); + + var targetLocal = scheduledSameDay > localStart ? scheduledSameDay : scheduledSameDay.AddDays(1); + return targetLocal.ToUniversalTime(); + } +}