feat(attendance): add AttendanceTracker with thread-safe duplicate guard and scheduled checkout

This commit is contained in:
Ochenstarik 2026-09-24 16:21:07 +07:00
parent c479bb60ca
commit 5bf634278d

View file

@ -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<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<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();
}
}