feat(terminal): add scheduled automatic checkout
Some checks failed
ci / verify (push) Has been cancelled

This commit is contained in:
Ochenstarik 2026-09-21 10:00:11 +07:00
parent 4d37350757
commit b4eb3432ae
12 changed files with 147 additions and 12 deletions

View file

@ -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

View file

@ -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>

View file

@ -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"/>

View file

@ -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;

View file

@ -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; } = "";

View file

@ -10,6 +10,7 @@
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
- чередование событий «Приход» и «Уход»;
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
- ежедневный автоматический уход в заданное время для сотрудников, забывших отметить завершение смены;
- локальный журнал последних отметок;
- экспорт журнала в CSV и резервные копии зашифрованного состояния;
- защищённая очистка журнала без удаления сотрудников и отпечатков;

View file

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

View file

@ -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"

View file

@ -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
View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@krona/staff",
"version": "0.2.4",
"version": "0.2.5",
"private": true,
"type": "module",
"engines": {

View file

@ -1,2 +1,2 @@
export const SERVICE_NAME = 'krona-staff';
export const SERVICE_VERSION = '0.2.4';
export const SERVICE_VERSION = '0.2.5';