using KronaStaffTerminal.Models; namespace KronaStaffTerminal.Services; public sealed record AttendanceRecordResult( bool Success, AttendanceRecord? Record, string Message, bool IsDuplicateSuppressed, int RemainingSeconds = 0); public sealed record ScanRecognitionResult( bool Success, AttendanceRecordResult AttendanceResult, bool ShouldUnlock, string? LockScreenMessage, string StatusMessage); 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 ProcessScanAsync( TerminalState state, EmployeeRecord employee, int readerIndex, DateTimeOffset nowUtc, bool isApplicationLocked, Func saveStateCallback) { if (employee.IsBlocked) { return new ScanRecognitionResult( Success: false, AttendanceResult: new AttendanceRecordResult( Success: false, Record: null, Message: "Сотрудник заблокирован. Отметка не создана.", IsDuplicateSuppressed: false), ShouldUnlock: false, LockScreenMessage: isApplicationLocked ? "Этот сотрудник заблокирован." : null, StatusMessage: "Сотрудник заблокирован. Отметка не создана."); } var attendanceResult = await TryRecordAttendanceAsync( state, employee, readerIndex, nowUtc, saveStateCallback); if (isApplicationLocked) { if (!employee.IsAdministrator) { var lockMessage = attendanceResult.Success ? "Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным." : attendanceResult.IsDuplicateSuppressed ? $"Повторный скан отклонён. Повторите через {attendanceResult.RemainingSeconds} сек." : "Отметка не создана. Интерфейс остаётся заблокированным."; return new ScanRecognitionResult( Success: attendanceResult.Success, AttendanceResult: attendanceResult, ShouldUnlock: false, LockScreenMessage: lockMessage, StatusMessage: attendanceResult.Message); } // Administrator scan in locked state: // Attendance is recorded AND the interface should be unlocked return new ScanRecognitionResult( Success: attendanceResult.Success, AttendanceResult: attendanceResult, ShouldUnlock: true, LockScreenMessage: null, StatusMessage: $"Вход выполнен по отпечатку администратора {employee.DisplayName}."); } return new ScanRecognitionResult( Success: attendanceResult.Success, AttendanceResult: attendanceResult, ShouldUnlock: false, LockScreenMessage: null, StatusMessage: attendanceResult.Message); } 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(); } }