feat(attendance): add AttendanceTracker with thread-safe duplicate guard and scheduled checkout
This commit is contained in:
parent
c479bb60ca
commit
5bf634278d
1 changed files with 174 additions and 0 deletions
174
apps/KronaStaffTerminal/Services/AttendanceTracker.cs
Normal file
174
apps/KronaStaffTerminal/Services/AttendanceTracker.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue