feat(terminal): add scheduled automatic checkout
This commit is contained in:
parent
4d37350757
commit
2cefb25bf6
12 changed files with 147 additions and 12 deletions
|
|
@ -1,7 +1,7 @@
|
|||
openapi: 3.1.0
|
||||
info:
|
||||
title: Krona Staff API
|
||||
version: 0.2.4
|
||||
version: 0.2.5
|
||||
description: Operational API for the Krona Staff foundation. HR domain endpoints are intentionally absent until owner requirements are approved.
|
||||
license:
|
||||
name: Proprietary
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@
|
|||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Version>0.2.4</Version>
|
||||
<AssemblyVersion>0.2.4.0</AssemblyVersion>
|
||||
<FileVersion>0.2.4.0</FileVersion>
|
||||
<InformationalVersion>0.2.4</InformationalVersion>
|
||||
<Version>0.2.5</Version>
|
||||
<AssemblyVersion>0.2.5.0</AssemblyVersion>
|
||||
<FileVersion>0.2.5.0</FileVersion>
|
||||
<InformationalVersion>0.2.5</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -225,6 +225,17 @@
|
|||
<ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
|
||||
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
|
||||
<NumberBox x:Name="MaximumShiftHoursBox" Header="Максимальная длительность смены, часов" Minimum="1" Maximum="48" SpinButtonPlacementMode="Compact"/>
|
||||
<ToggleSwitch x:Name="AutomaticCheckoutSwitch"
|
||||
Header="Автоматический уход"
|
||||
OffContent="Выключен"
|
||||
OnContent="Закрывать незавершённые смены ежедневно"/>
|
||||
<TimePicker x:Name="AutomaticCheckoutTimePicker"
|
||||
Header="Время автоматического ухода"
|
||||
ClockIdentifier="24HourClock"
|
||||
MinuteIncrement="5"/>
|
||||
<TextBlock Text="Если сотрудник отметил приход, но не отметил уход, программа автоматически создаст уход в указанное время."
|
||||
TextWrapping="Wrap"
|
||||
Opacity="0.7"/>
|
||||
<ToggleSwitch x:Name="ShowEmployeeNamesSwitch" Header="Имена в публичном журнале терминала" OffContent="Скрыты" OnContent="Показывать"/>
|
||||
<PasswordBox x:Name="NewAdminPinBox" Header="Новый PIN администратора" PasswordRevealMode="Peek"/>
|
||||
<Button Content="Установить новый PIN" HorizontalAlignment="Left" Click="SetAdminPin_Click"/>
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ public sealed partial class MainPage : Page
|
|||
private ContentDialog? _adminAuthDialog;
|
||||
private readonly DispatcherQueueTimer _adminSessionTimer;
|
||||
private readonly DispatcherQueueTimer _updateTimer;
|
||||
private readonly DispatcherQueueTimer _automaticCheckoutTimer;
|
||||
private readonly CancellationTokenSource _updateCancellation = new();
|
||||
private AvailableUpdate? _availableUpdate;
|
||||
private bool _updateCheckInProgress;
|
||||
private bool _automaticCheckoutInProgress;
|
||||
private bool _adminSessionActive;
|
||||
private bool _adminNavigationInProgress;
|
||||
private bool _stateLoadFailed;
|
||||
|
|
@ -71,6 +73,10 @@ public sealed partial class MainPage : Page
|
|||
_updateTimer.Interval = TimeSpan.FromHours(6);
|
||||
_updateTimer.IsRepeating = true;
|
||||
_updateTimer.Tick += (_, _) => EnqueueTask(() => CheckForUpdatesAsync(false));
|
||||
_automaticCheckoutTimer = DispatcherQueue.CreateTimer();
|
||||
_automaticCheckoutTimer.Interval = TimeSpan.FromMinutes(1);
|
||||
_automaticCheckoutTimer.IsRepeating = true;
|
||||
_automaticCheckoutTimer.Tick += (_, _) => EnqueueTask(RunAutomaticCheckoutIfDueAsync);
|
||||
}
|
||||
|
||||
private async void Page_Loaded(object sender, RoutedEventArgs e)
|
||||
|
|
@ -95,6 +101,7 @@ public sealed partial class MainPage : Page
|
|||
RefreshLists();
|
||||
ConnectScanner();
|
||||
_updateTimer.Start();
|
||||
_automaticCheckoutTimer.Start();
|
||||
MainTabs.SelectedItem = TerminalTab;
|
||||
LockScreenMessage.Text = SecurityConfigured
|
||||
? "Введите PIN администратора или приложите зарегистрированный палец."
|
||||
|
|
@ -103,6 +110,7 @@ public sealed partial class MainPage : Page
|
|||
{
|
||||
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
|
||||
}
|
||||
await RunAutomaticCheckoutIfDueAsync();
|
||||
await CheckForUpdatesAsync(false);
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +119,7 @@ public sealed partial class MainPage : Page
|
|||
_loaded = false;
|
||||
_adminSessionTimer.Stop();
|
||||
_updateTimer.Stop();
|
||||
_automaticCheckoutTimer.Stop();
|
||||
_updateCancellation.Cancel();
|
||||
_scanner.Dispose();
|
||||
}
|
||||
|
|
@ -908,6 +917,107 @@ public sealed partial class MainPage : Page
|
|||
}
|
||||
}
|
||||
|
||||
private async Task RunAutomaticCheckoutIfDueAsync()
|
||||
{
|
||||
if (!_loaded || _stateLoadFailed || _automaticCheckoutInProgress ||
|
||||
!_state.Settings.AutomaticCheckoutEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
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;
|
||||
|
||||
_automaticCheckoutInProgress = true;
|
||||
try
|
||||
{
|
||||
var automaticRecords = new List<(AttendanceRecord Record, EmployeeRecord Employee)>();
|
||||
foreach (var employee in _state.Employees)
|
||||
{
|
||||
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;
|
||||
automaticRecords.Add((new AttendanceRecord
|
||||
{
|
||||
EmployeeId = employee.Id,
|
||||
EmployeeName = employee.DisplayName,
|
||||
OccurredAtUtc = checkoutUtc,
|
||||
ReaderIndex = -1,
|
||||
EventType = "Уход автоматически"
|
||||
}, employee));
|
||||
}
|
||||
|
||||
_state.Attendance.InsertRange(0, automaticRecords.Select(item => item.Record));
|
||||
if (_state.Attendance.Count > 100_000)
|
||||
{
|
||||
_state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000);
|
||||
}
|
||||
_state.Settings.LastAutomaticCheckoutUtc = latestScheduleUtc;
|
||||
AppendSecurityAudit(
|
||||
"attendance.automatic_checkout",
|
||||
"succeeded",
|
||||
$"scheduled={latestScheduleUtc:O}; records={automaticRecords.Count}");
|
||||
await _store.SaveAsync(_state);
|
||||
RefreshLists();
|
||||
|
||||
if (automaticRecords.Count == 0) return;
|
||||
TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {automaticRecords.Count}";
|
||||
SetStatus($"Автоматически закрыто смен: {automaticRecords.Count}.", InfoBarSeverity.Informational);
|
||||
|
||||
await Task.WhenAll(automaticRecords.Select(async item =>
|
||||
{
|
||||
var delivery = await _notifications.SendAttendanceAsync(
|
||||
_state.Settings,
|
||||
item.Employee,
|
||||
item.Record.EventType,
|
||||
item.Record.OccurredAtUtc.ToLocalTime());
|
||||
item.Record.NotificationDelivered = delivery.Delivered;
|
||||
item.Record.NotificationError = delivery.Error;
|
||||
}));
|
||||
await _store.SaveAsync(_state);
|
||||
RefreshLists();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
CrashLogService.Write(exception);
|
||||
SetStatus($"Автоматический уход не выполнен: {exception.Message}", InfoBarSeverity.Warning);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_automaticCheckoutInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset LatestAutomaticCheckoutScheduleUtc(DateTimeOffset nowUtc, int hour, int minute)
|
||||
{
|
||||
var localNow = nowUtc.ToLocalTime();
|
||||
var localSchedule = localNow.Date.AddHours(hour).AddMinutes(minute);
|
||||
if (localSchedule > localNow.DateTime) localSchedule = localSchedule.AddDays(-1);
|
||||
return LocalScheduleToUtc(localSchedule);
|
||||
}
|
||||
|
||||
private static DateTimeOffset NextAutomaticCheckoutAfter(DateTimeOffset arrivalUtc, int hour, int minute)
|
||||
{
|
||||
var localArrival = arrivalUtc.ToLocalTime();
|
||||
var localSchedule = localArrival.Date.AddHours(hour).AddMinutes(minute);
|
||||
if (localSchedule <= localArrival.DateTime) localSchedule = localSchedule.AddDays(1);
|
||||
return LocalScheduleToUtc(localSchedule);
|
||||
}
|
||||
|
||||
private static DateTimeOffset LocalScheduleToUtc(DateTime localSchedule)
|
||||
{
|
||||
localSchedule = DateTime.SpecifyKind(localSchedule, DateTimeKind.Unspecified);
|
||||
if (TimeZoneInfo.Local.IsInvalidTime(localSchedule)) localSchedule = localSchedule.AddHours(1);
|
||||
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(localSchedule, TimeZoneInfo.Local));
|
||||
}
|
||||
private async void ClearAttendance_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("очистка журнала")) return;
|
||||
|
|
@ -1157,6 +1267,7 @@ public sealed partial class MainPage : Page
|
|||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
ApplyStartupSetting();
|
||||
ConnectScanner();
|
||||
await RunAutomaticCheckoutIfDueAsync();
|
||||
SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
|
|
@ -1293,6 +1404,9 @@ public sealed partial class MainPage : Page
|
|||
_state.Settings.StartWithWindows = StartWithWindowsSwitch.IsOn;
|
||||
_state.Settings.DuplicateScanGuardSeconds = double.IsNaN(DuplicateGuardBox.Value) ? 120 : Math.Clamp((int)DuplicateGuardBox.Value, 10, 3600);
|
||||
_state.Settings.MaximumShiftHours = double.IsNaN(MaximumShiftHoursBox.Value) ? 20 : Math.Clamp((int)MaximumShiftHoursBox.Value, 1, 48);
|
||||
_state.Settings.AutomaticCheckoutEnabled = AutomaticCheckoutSwitch.IsOn;
|
||||
_state.Settings.AutomaticCheckoutHour = Math.Clamp(AutomaticCheckoutTimePicker.Time.Hours, 0, 23);
|
||||
_state.Settings.AutomaticCheckoutMinute = Math.Clamp(AutomaticCheckoutTimePicker.Time.Minutes, 0, 59);
|
||||
_state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn;
|
||||
_state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn;
|
||||
_state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim();
|
||||
|
|
@ -1315,6 +1429,11 @@ public sealed partial class MainPage : Page
|
|||
StartWithWindowsSwitch.IsOn = _state.Settings.StartWithWindows;
|
||||
DuplicateGuardBox.Value = Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600);
|
||||
MaximumShiftHoursBox.Value = Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48);
|
||||
AutomaticCheckoutSwitch.IsOn = _state.Settings.AutomaticCheckoutEnabled;
|
||||
AutomaticCheckoutTimePicker.Time = new TimeSpan(
|
||||
Math.Clamp(_state.Settings.AutomaticCheckoutHour, 0, 23),
|
||||
Math.Clamp(_state.Settings.AutomaticCheckoutMinute, 0, 59),
|
||||
0);
|
||||
ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal;
|
||||
TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled;
|
||||
TelegramTokenBox.Password = _state.Settings.TelegramBotToken;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,10 @@ public sealed class TerminalSettings
|
|||
public DateTimeOffset AdminPinBlockedUntilUtc { get; set; }
|
||||
public int DuplicateScanGuardSeconds { get; set; } = 120;
|
||||
public int MaximumShiftHours { get; set; } = 20;
|
||||
public bool AutomaticCheckoutEnabled { get; set; }
|
||||
public int AutomaticCheckoutHour { get; set; } = 2;
|
||||
public int AutomaticCheckoutMinute { get; set; }
|
||||
public DateTimeOffset LastAutomaticCheckoutUtc { get; set; }
|
||||
public bool ShowEmployeeNamesOnTerminal { get; set; }
|
||||
public bool TelegramEnabled { get; set; }
|
||||
public string TelegramBotToken { get; set; } = "";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
|
||||
- чередование событий «Приход» и «Уход»;
|
||||
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
|
||||
- ежедневный автоматический уход в заданное время для сотрудников, забывших отметить завершение смены;
|
||||
- локальный журнал последних отметок;
|
||||
- экспорт журнала в CSV и резервные копии зашифрованного состояния;
|
||||
- защищённая очистка журнала без удаления сотрудников и отпечатков;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public sealed class NotificationService
|
|||
|
||||
public async Task<(bool Delivered, string? Error)> SendAttendanceAsync(TerminalSettings settings, EmployeeRecord employee, string eventType, DateTimeOffset localTime)
|
||||
{
|
||||
var action = eventType == "Уход" ? "уход" : "приход";
|
||||
var action = eventType.StartsWith("Уход", StringComparison.Ordinal) ? "уход" : "приход";
|
||||
return await SendAsync(settings, employee,
|
||||
$"Krona Staff: ваш {action} зарегистрирован {localTime:dd.MM.yyyy} в {localTime:HH:mm}.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SetCompressor /SOLID lzma
|
|||
!include "MUI2.nsh"
|
||||
|
||||
!ifndef APP_VERSION
|
||||
!define APP_VERSION "0.2.4"
|
||||
!define APP_VERSION "0.2.5"
|
||||
!endif
|
||||
!ifndef PUBLISH_DIR
|
||||
!error "PUBLISH_DIR is required"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "./schemas/module.schema.json",
|
||||
"id": "ru.krona.staff",
|
||||
"name": "Krona Staff",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"min_platform_version": "3.2.0",
|
||||
"dependencies": ["ru.krona.core"],
|
||||
"optional_dependencies": [],
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@krona/staff",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@krona/staff",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"dependencies": {
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krona/staff",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export const SERVICE_NAME = 'krona-staff';
|
||||
export const SERVICE_VERSION = '0.2.4';
|
||||
export const SERVICE_VERSION = '0.2.5';
|
||||
|
|
|
|||
Loading…
Reference in a new issue