246 lines
9.1 KiB
C#
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();
|
|
}
|
|
}
|