krona-staff/apps/KronaStaffTerminal/Services/AttendanceTracker.cs
2026-09-24 18:09:42 +07:00

246 lines
9.1 KiB
C#

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<AttendanceRecordResult> TryRecordAttendanceAsync(
TerminalState state,
EmployeeRecord employee,
int readerIndex,
DateTimeOffset nowUtc,
Func<TerminalState, Task> 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<ScanRecognitionResult> ProcessScanAsync(
TerminalState state,
EmployeeRecord employee,
int readerIndex,
DateTimeOffset nowUtc,
bool isApplicationLocked,
Func<TerminalState, Task> 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<List<AttendanceRecord>> RunScheduledCheckoutAsync(
TerminalState state,
DateTimeOffset nowUtc,
Func<TerminalState, Task> 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<AttendanceRecord>();
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();
}
}