diff --git a/api/openapi.yaml b/api/openapi.yaml
index 33b8fea..655efc8 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -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
diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
index 35d9cbb..9852ddd 100644
--- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
+++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
@@ -17,10 +17,10 @@
enable
enable
x64
- 0.2.4
- 0.2.4.0
- 0.2.4.0
- 0.2.4
+ 0.2.5
+ 0.2.5.0
+ 0.2.5.0
+ 0.2.5
diff --git a/apps/KronaStaffTerminal/MainPage.xaml b/apps/KronaStaffTerminal/MainPage.xaml
index c0457d6..e581853 100644
--- a/apps/KronaStaffTerminal/MainPage.xaml
+++ b/apps/KronaStaffTerminal/MainPage.xaml
@@ -225,6 +225,17 @@
+
+
+
diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs
index 576d3af..6db21d2 100644
--- a/apps/KronaStaffTerminal/MainPage.xaml.cs
+++ b/apps/KronaStaffTerminal/MainPage.xaml.cs
@@ -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;
diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs
index cbf2333..464f70c 100644
--- a/apps/KronaStaffTerminal/Models/TerminalModels.cs
+++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs
@@ -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; } = "";
diff --git a/apps/KronaStaffTerminal/README.md b/apps/KronaStaffTerminal/README.md
index 5fdff84..16b210c 100644
--- a/apps/KronaStaffTerminal/README.md
+++ b/apps/KronaStaffTerminal/README.md
@@ -10,6 +10,7 @@
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
- чередование событий «Приход» и «Уход»;
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
+- ежедневный автоматический уход в заданное время для сотрудников, забывших отметить завершение смены;
- локальный журнал последних отметок;
- экспорт журнала в CSV и резервные копии зашифрованного состояния;
- защищённая очистка журнала без удаления сотрудников и отпечатков;
diff --git a/apps/KronaStaffTerminal/Services/NotificationService.cs b/apps/KronaStaffTerminal/Services/NotificationService.cs
index 7e60cb4..5e4520c 100644
--- a/apps/KronaStaffTerminal/Services/NotificationService.cs
+++ b/apps/KronaStaffTerminal/Services/NotificationService.cs
@@ -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}.");
}
diff --git a/installer/KronaStaffTerminal.nsi b/installer/KronaStaffTerminal.nsi
index f48050f..bf6fe11 100644
--- a/installer/KronaStaffTerminal.nsi
+++ b/installer/KronaStaffTerminal.nsi
@@ -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"
diff --git a/module.json b/module.json
index fd71fd4..faa7da2 100644
--- a/module.json
+++ b/module.json
@@ -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": [],
diff --git a/package-lock.json b/package-lock.json
index 9ec533d..94451ab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index a67192a..b2a7d1a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@krona/staff",
- "version": "0.2.4",
+ "version": "0.2.5",
"private": true,
"type": "module",
"engines": {
diff --git a/src/version.ts b/src/version.ts
index a25d2a4..1bffbe6 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
export const SERVICE_NAME = 'krona-staff';
-export const SERVICE_VERSION = '0.2.4';
+export const SERVICE_VERSION = '0.2.5';