diff --git a/README.md b/README.md
index a2fe992..abd79d9 100644
--- a/README.md
+++ b/README.md
@@ -23,3 +23,7 @@ docker compose up --build app postgres redis
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
+
+## Биометрический терминал
+
+Windows-приложение для SLK20R находится в [apps/KronaStaffTerminal](apps/KronaStaffTerminal). Оно добавляет сотрудников, регистрирует отпечаток по трём сканам, отмечает первый приход за день и отправляет уведомление в Telegram и/или MAX. Инструкция по запуску и настройке находится в [README терминала](apps/KronaStaffTerminal/README.md).
diff --git a/apps/KronaStaffTerminal/.gitignore b/apps/KronaStaffTerminal/.gitignore
new file mode 100644
index 0000000..dd39356
--- /dev/null
+++ b/apps/KronaStaffTerminal/.gitignore
@@ -0,0 +1,54 @@
+## .NET / Visual Studio
+[Bb]in/
+[Oo]bj/
+[Dd]ebug/
+[Rr]elease/
+.vs/
+*.user
+*.suo
+*.userosscache
+*.sln.docstates
+artifacts/
+
+# Build logs
+[Ll]og/
+[Ll]ogs/
+*.log
+*.binlog
+
+# Test results
+[Tt]est[Rr]esult*/
+*.trx
+*.coverage
+*.coveragexml
+
+# NuGet
+*.nupkg
+*.snupkg
+*.nuget.props
+*.nuget.targets
+project.lock.json
+
+# MSIX packaging output
+AppPackages/
+BundleArtifacts/
+*.msix
+*.msixupload
+*.appx
+*.appxbundle
+*.appxupload
+
+# Publish output
+publish/
+*.pubxml
+PublishScripts/
+
+# Code analysis and tooling
+_ReSharper*/
+*.DotSettings.user
+*.dotCover
+.idea/
+
+# Generated files
+Generated\ Files/
+*_wpftmp.csproj
diff --git a/apps/KronaStaffTerminal/App.xaml b/apps/KronaStaffTerminal/App.xaml
new file mode 100644
index 0000000..0410b9e
--- /dev/null
+++ b/apps/KronaStaffTerminal/App.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/KronaStaffTerminal/App.xaml.cs b/apps/KronaStaffTerminal/App.xaml.cs
new file mode 100644
index 0000000..a6ceb25
--- /dev/null
+++ b/apps/KronaStaffTerminal/App.xaml.cs
@@ -0,0 +1,57 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace KronaStaffTerminal;
+
+///
+/// Provides application-specific behavior to supplement the default Application class.
+///
+public partial class App : Application
+{
+ ///
+ /// The main application window. Use App.Window from any class that needs
+ /// the window reference (for dialogs, pickers, interop, etc.).
+ ///
+ public static Window Window { get; private set; } = null!;
+
+ ///
+ /// The UI thread dispatcher. Use App.DispatcherQueue to marshal calls
+ /// to the UI thread. Fully qualified to avoid CS0104 ambiguity with
+ /// .
+ ///
+ public static Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; private set; } = null!;
+
+ ///
+ /// The native window handle (HWND). Use for file pickers,
+ /// DataTransferManager, and any WinRT interop that requires
+ /// InitializeWithWindow.
+ ///
+ public static nint WindowHandle =>
+ WinRT.Interop.WindowNative.GetWindowHandle(Window);
+
+ ///
+ /// Initializes the singleton application object.
+ ///
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ /// Invoked when the application is launched.
+ ///
+ /// Details about the launch request and process.
+ protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
+ {
+ Window = new MainWindow();
+ DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
+ Window.Activate();
+ }
+}
diff --git a/apps/KronaStaffTerminal/Assets/AppIcon.ico b/apps/KronaStaffTerminal/Assets/AppIcon.ico
new file mode 100644
index 0000000..235dad2
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/AppIcon.ico differ
diff --git a/apps/KronaStaffTerminal/Assets/LockScreenLogo.scale-200.png b/apps/KronaStaffTerminal/Assets/LockScreenLogo.scale-200.png
new file mode 100644
index 0000000..33f889e
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/LockScreenLogo.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/Assets/SplashScreen.scale-200.png b/apps/KronaStaffTerminal/Assets/SplashScreen.scale-200.png
new file mode 100644
index 0000000..802c79d
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/SplashScreen.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Square150x150Logo.scale-200.png b/apps/KronaStaffTerminal/Assets/Square150x150Logo.scale-200.png
new file mode 100644
index 0000000..ddba42a
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/Square150x150Logo.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Square44x44Logo.scale-200.png b/apps/KronaStaffTerminal/Assets/Square44x44Logo.scale-200.png
new file mode 100644
index 0000000..9327dd7
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/Square44x44Logo.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-24_altform-unplated.png
new file mode 100644
index 0000000..e51416a
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png b/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png
new file mode 100644
index 0000000..bf063eb
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png differ
diff --git a/apps/KronaStaffTerminal/Assets/StoreLogo.png b/apps/KronaStaffTerminal/Assets/StoreLogo.png
new file mode 100644
index 0000000..be865f0
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/StoreLogo.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Wide310x150Logo.scale-200.png b/apps/KronaStaffTerminal/Assets/Wide310x150Logo.scale-200.png
new file mode 100644
index 0000000..314d297
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/Wide310x150Logo.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
new file mode 100644
index 0000000..ee682ea
--- /dev/null
+++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
@@ -0,0 +1,82 @@
+
+
+ WinExe
+ net10.0-windows10.0.26100.0
+ 10.0.17763.0
+ KronaStaffTerminal
+ app.manifest
+ x86;x64;ARM64
+ win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())
+ win-$(Platform).pubxml
+ true
+ false
+ true
+ enable
+ enable
+ x64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Vendor\ZKFinger\x64\libzkfpcsharp.dll
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+ False
+ True
+ False
+
+
diff --git a/apps/KronaStaffTerminal/MainPage.xaml b/apps/KronaStaffTerminal/MainPage.xaml
new file mode 100644
index 0000000..7d1f7c5
--- /dev/null
+++ b/apps/KronaStaffTerminal/MainPage.xaml
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs
new file mode 100644
index 0000000..d8258ad
--- /dev/null
+++ b/apps/KronaStaffTerminal/MainPage.xaml.cs
@@ -0,0 +1,233 @@
+using KronaStaffTerminal.Models;
+using KronaStaffTerminal.Services;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace KronaStaffTerminal;
+
+public sealed partial class MainPage : Page
+{
+ private readonly TerminalStore _store = new();
+ private readonly NotificationService _notifications = new();
+ private readonly FingerprintScanner _scanner = new();
+ private TerminalState _state = new();
+ private bool _loaded;
+
+ public MainPage()
+ {
+ InitializeComponent();
+ _scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
+ _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3", InfoBarSeverity.Informational));
+ _scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template));
+ _scanner.FingerRecognized += (id, score) => Enqueue(() => _ = RecordArrivalAsync(id, score));
+ }
+
+ private async void Page_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (_loaded) return;
+ _loaded = true;
+ _state = await _store.LoadAsync();
+ LoadSettings();
+ RefreshLists();
+ ConnectScanner();
+ }
+
+ private void Page_Unloaded(object sender, RoutedEventArgs e)
+ {
+ _scanner.Dispose();
+ }
+
+ private void ConnectScanner()
+ {
+ try
+ {
+ _scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
+ .Where(employee => employee.HasFingerprint)
+ .Select(employee => (employee.FingerprintId, employee.FingerprintTemplate!)));
+ ReaderCombo.Items.Clear();
+ for (var index = 0; index < _scanner.DeviceCount; index++)
+ {
+ ReaderCombo.Items.Add($"SLK20R №{index + 1}");
+ }
+ ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
+ SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
+ }
+ catch (Exception exception)
+ {
+ SetStatus(exception.Message, InfoBarSeverity.Error);
+ }
+ }
+
+ private async void AddEmployee_Click(object sender, RoutedEventArgs e)
+ {
+ var name = EmployeeNameBox.Text.Trim();
+ var number = PersonnelNumberBox.Text.Trim();
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number))
+ {
+ SetStatus("Заполните имя и табельный номер сотрудника.", InfoBarSeverity.Warning);
+ return;
+ }
+ if (_state.Employees.Any(employee => employee.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase)))
+ {
+ SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning);
+ return;
+ }
+
+ var employee = new EmployeeRecord { DisplayName = name, PersonnelNumber = number };
+ _state.Employees.Add(employee);
+ await _store.SaveAsync(_state);
+ EmployeeNameBox.Text = "";
+ PersonnelNumberBox.Text = "";
+ RefreshLists();
+ EmployeesList.SelectedItem = employee;
+ SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success);
+ }
+
+ private void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
+ {
+ if (EmployeesList.SelectedItem is not EmployeeRecord employee)
+ {
+ SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
+ return;
+ }
+ if (!_scanner.IsConnected)
+ {
+ SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
+ return;
+ }
+
+ if (employee.FingerprintId <= 0)
+ {
+ employee.FingerprintId = _state.Employees.Select(item => item.FingerprintId).DefaultIfEmpty(0).Max() + 1;
+ }
+ _scanner.BeginRegistration(employee.FingerprintId);
+ }
+
+ private async Task CompleteRegistrationAsync(int fingerprintId, string template)
+ {
+ var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId);
+ if (employee is null) return;
+ employee.FingerprintTemplate = template;
+ await _store.SaveAsync(_state);
+ RefreshLists();
+ TerminalMessage.Text = $"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
+ SetStatus($"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success);
+ }
+
+ private async void DeleteEmployee_Click(object sender, RoutedEventArgs e)
+ {
+ if (EmployeesList.SelectedItem is not EmployeeRecord employee)
+ {
+ SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
+ return;
+ }
+
+ var dialog = new ContentDialog
+ {
+ XamlRoot = XamlRoot,
+ Title = "Удалить сотрудника?",
+ Content = $"{employee.DisplayName} и зарегистрированный отпечаток будут удалены из терминала.",
+ PrimaryButtonText = "Удалить",
+ CloseButtonText = "Отмена",
+ DefaultButton = ContentDialogButton.Close
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+
+ _state.Employees.Remove(employee);
+ await _store.SaveAsync(_state);
+ RefreshLists();
+ ConnectScanner();
+ SetStatus("Сотрудник удалён.", InfoBarSeverity.Success);
+ }
+
+ private async Task RecordArrivalAsync(int fingerprintId, int score)
+ {
+ var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId);
+ if (employee is null) return;
+ var now = DateTimeOffset.UtcNow;
+ var alreadyRecorded = _state.Attendance.Any(item =>
+ item.EmployeeId == employee.Id && item.OccurredAtUtc.ToLocalTime().Date == now.ToLocalTime().Date);
+ if (alreadyRecorded)
+ {
+ TerminalMessage.Text = $"{employee.DisplayName}: приход сегодня уже отмечен";
+ SetStatus($"Повторное сканирование распознано, новая отметка не создана. Точность: {score}.", InfoBarSeverity.Informational);
+ return;
+ }
+
+ var record = new AttendanceRecord
+ {
+ EmployeeId = employee.Id,
+ EmployeeName = employee.DisplayName,
+ OccurredAtUtc = now,
+ ReaderIndex = _scanner.ReaderIndex
+ };
+ _state.Attendance.Insert(0, record);
+ await _store.SaveAsync(_state);
+ RefreshLists();
+ TerminalMessage.Text = $"Здравствуйте, {employee.DisplayName}!\nПриход отмечен в {now.ToLocalTime():HH:mm}";
+ SetStatus($"Приход отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
+
+ var delivery = await _notifications.SendArrivalAsync(_state.Settings, employee.DisplayName, now.ToLocalTime());
+ record.NotificationDelivered = delivery.Delivered;
+ record.NotificationError = delivery.Error;
+ await _store.SaveAsync(_state);
+ RefreshLists();
+ if (delivery.Error is not null)
+ {
+ SetStatus($"Приход сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
+ }
+ }
+
+ private async void SaveSettings_Click(object sender, RoutedEventArgs e)
+ {
+ CopySettingsFromControls();
+ await _store.SaveAsync(_state);
+ ConnectScanner();
+ SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
+ }
+
+ private async void TestNotification_Click(object sender, RoutedEventArgs e)
+ {
+ CopySettingsFromControls();
+ var result = await _notifications.SendTestAsync(_state.Settings);
+ SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
+ }
+
+ private void CopySettingsFromControls()
+ {
+ _state.Settings.ReaderIndex = Math.Max(0, ReaderCombo.SelectedIndex);
+ _state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn;
+ _state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim();
+ _state.Settings.TelegramChatId = TelegramChatBox.Text.Trim();
+ _state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn;
+ _state.Settings.MaxBotToken = MaxTokenBox.Password.Trim();
+ _state.Settings.MaxChatId = MaxChatBox.Text.Trim();
+ }
+
+ private void LoadSettings()
+ {
+ TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled;
+ TelegramTokenBox.Password = _state.Settings.TelegramBotToken;
+ TelegramChatBox.Text = _state.Settings.TelegramChatId;
+ MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled;
+ MaxTokenBox.Password = _state.Settings.MaxBotToken;
+ MaxChatBox.Text = _state.Settings.MaxChatId;
+ }
+
+ private void RefreshLists()
+ {
+ EmployeesList.ItemsSource = null;
+ EmployeesList.ItemsSource = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
+ AttendanceList.ItemsSource = null;
+ AttendanceList.ItemsSource = _state.Attendance.OrderByDescending(item => item.OccurredAtUtc).Take(100).ToList();
+ }
+
+ private void SetStatus(string message, InfoBarSeverity severity = InfoBarSeverity.Informational)
+ {
+ StatusBar.Message = message;
+ StatusBar.Severity = severity;
+ StatusBar.IsOpen = true;
+ }
+
+ private static void Enqueue(Action action) => App.DispatcherQueue.TryEnqueue(() => action());
+}
diff --git a/apps/KronaStaffTerminal/MainWindow.xaml b/apps/KronaStaffTerminal/MainWindow.xaml
new file mode 100644
index 0000000..fb48042
--- /dev/null
+++ b/apps/KronaStaffTerminal/MainWindow.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/KronaStaffTerminal/MainWindow.xaml.cs b/apps/KronaStaffTerminal/MainWindow.xaml.cs
new file mode 100644
index 0000000..163d19d
--- /dev/null
+++ b/apps/KronaStaffTerminal/MainWindow.xaml.cs
@@ -0,0 +1,28 @@
+using Microsoft.UI.Xaml;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace KronaStaffTerminal;
+
+///
+/// The application window. This hosts a Frame that displays pages. Add your
+/// UI and logic to MainPage.xaml / MainPage.xaml.cs instead of here so you
+/// can use Page features such as navigation events and the Loaded lifecycle.
+///
+public sealed partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ ExtendsContentIntoTitleBar = true;
+ SetTitleBar(AppTitleBar);
+
+ AppWindow.SetIcon("Assets/AppIcon.ico");
+ AppWindow.Resize(new Windows.Graphics.SizeInt32(1120, 760));
+
+ // Navigate the root frame to the main page on startup.
+ RootFrame.Navigate(typeof(MainPage));
+ }
+}
diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs
new file mode 100644
index 0000000..a3d529d
--- /dev/null
+++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs
@@ -0,0 +1,53 @@
+using System.Text.Json.Serialization;
+
+namespace KronaStaffTerminal.Models;
+
+public sealed class EmployeeRecord
+{
+ public string Id { get; set; } = UlidGenerator.New();
+ public string PersonnelNumber { get; set; } = "";
+ public string DisplayName { get; set; } = "";
+ public int FingerprintId { get; set; }
+ public string? FingerprintTemplate { get; set; }
+
+ [JsonIgnore]
+ public bool HasFingerprint => FingerprintId > 0 && !string.IsNullOrWhiteSpace(FingerprintTemplate);
+
+ [JsonIgnore]
+ public string FingerprintStatus => HasFingerprint ? "Отпечаток зарегистрирован" : "Отпечатка нет";
+}
+
+public sealed class AttendanceRecord
+{
+ public string Id { get; set; } = UlidGenerator.New();
+ public string EmployeeId { get; set; } = "";
+ public string EmployeeName { get; set; } = "";
+ public DateTimeOffset OccurredAtUtc { get; set; }
+ public int ReaderIndex { get; set; }
+ public bool NotificationDelivered { get; set; }
+ public string? NotificationError { get; set; }
+
+ [JsonIgnore]
+ public string LocalTime => OccurredAtUtc.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss");
+
+ [JsonIgnore]
+ public string DeliveryStatus => NotificationDelivered ? "Отправлено" : string.IsNullOrWhiteSpace(NotificationError) ? "Не настроено" : "Ошибка отправки";
+}
+
+public sealed class TerminalSettings
+{
+ public int ReaderIndex { get; set; }
+ public bool TelegramEnabled { get; set; }
+ public string TelegramBotToken { get; set; } = "";
+ public string TelegramChatId { get; set; } = "";
+ public bool MaxEnabled { get; set; }
+ public string MaxBotToken { get; set; } = "";
+ public string MaxChatId { get; set; } = "";
+}
+
+public sealed class TerminalState
+{
+ public List Employees { get; set; } = [];
+ public List Attendance { get; set; } = [];
+ public TerminalSettings Settings { get; set; } = new();
+}
diff --git a/apps/KronaStaffTerminal/Models/UlidGenerator.cs b/apps/KronaStaffTerminal/Models/UlidGenerator.cs
new file mode 100644
index 0000000..c64be8d
--- /dev/null
+++ b/apps/KronaStaffTerminal/Models/UlidGenerator.cs
@@ -0,0 +1,31 @@
+using System.Numerics;
+using System.Security.Cryptography;
+
+namespace KronaStaffTerminal.Models;
+
+internal static class UlidGenerator
+{
+ private const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+
+ public static string New()
+ {
+ Span value = stackalloc byte[16];
+ var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ for (var index = 5; index >= 0; index--)
+ {
+ value[index] = (byte)(timestamp & 0xff);
+ timestamp >>= 8;
+ }
+
+ RandomNumberGenerator.Fill(value[6..]);
+ var number = new BigInteger(value, isUnsigned: true, isBigEndian: true);
+ Span result = stackalloc char[26];
+ for (var index = 25; index >= 0; index--)
+ {
+ result[index] = Alphabet[(int)(number & 31)];
+ number >>= 5;
+ }
+
+ return new string(result);
+ }
+}
diff --git a/apps/KronaStaffTerminal/Package.appxmanifest b/apps/KronaStaffTerminal/Package.appxmanifest
new file mode 100644
index 0000000..fad2afe
--- /dev/null
+++ b/apps/KronaStaffTerminal/Package.appxmanifest
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+ KronaStaffTerminal
+ AppPublisher
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/KronaStaffTerminal/Properties/launchSettings.json b/apps/KronaStaffTerminal/Properties/launchSettings.json
new file mode 100644
index 0000000..3bd9c3c
--- /dev/null
+++ b/apps/KronaStaffTerminal/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "KronaStaffTerminal (Package)": {
+ "commandName": "MsixPackage"
+ },
+ "KronaStaffTerminal (Unpackaged)": {
+ "commandName": "Project"
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/KronaStaffTerminal/README.md b/apps/KronaStaffTerminal/README.md
new file mode 100644
index 0000000..5512cc3
--- /dev/null
+++ b/apps/KronaStaffTerminal/README.md
@@ -0,0 +1,36 @@
+# Krona Staff Terminal
+
+Локальный терминал учёта прихода для USB-сканеров ZKTeco/BioLink SLK20R и ZKFinger SDK 5.3.
+
+## Возможности
+
+- выбор одного из подключённых сканеров;
+- добавление и удаление сотрудников;
+- регистрация отпечатка по трём последовательным сканам;
+- распознавание сотрудника и одна отметка прихода в день;
+- локальный журнал последних отметок;
+- уведомления через Telegram Bot API и MAX Bot API;
+- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
+
+Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows.
+
+## Запуск
+
+Требуются Windows 10/11 x64, драйвер SLK20R и ZKFinger SDK 5.3. Сборка и запуск из корня репозитория:
+
+```powershell
+dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug -p:Platform=x64 --launch-profile "KronaStaffTerminal (Unpackaged)"
+```
+
+## Первый сотрудник
+
+1. Откройте вкладку «Сотрудники».
+2. Введите табельный номер и имя, затем нажмите «Добавить».
+3. Выберите сотрудника в списке и нажмите «Зарегистрировать отпечаток».
+4. Трижды приложите один и тот же палец, убирая его со стекла после каждого принятого скана.
+
+После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Повторный скан в тот же день распознаётся, но вторую отметку прихода не создаёт.
+
+## Уведомления
+
+На вкладке «Уведомления и сканер» включите нужный канал, укажите токен бота и идентификатор чата, сохраните настройки и нажмите «Отправить тест». При успешном распознавании сообщение содержит имя сотрудника и местное время прихода.
diff --git a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
new file mode 100644
index 0000000..ad62538
--- /dev/null
+++ b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
@@ -0,0 +1,196 @@
+using libzkfpcsharp;
+
+namespace KronaStaffTerminal.Services;
+
+public sealed class FingerprintScanner : IDisposable
+{
+ private const int TemplateSize = 2048;
+ private readonly object _gate = new();
+ private readonly byte[][] _registrationSamples = [new byte[TemplateSize], new byte[TemplateSize], new byte[TemplateSize]];
+ private IntPtr _device = IntPtr.Zero;
+ private IntPtr _database = IntPtr.Zero;
+ private CancellationTokenSource? _captureCancellation;
+ private int _registrationCount;
+ private int? _registrationFingerprintId;
+ private DateTimeOffset _lastCapture = DateTimeOffset.MinValue;
+
+ public event Action? FingerRecognized;
+ public event Action? RegistrationProgress;
+ public event Action? RegistrationCompleted;
+ public event Action? StatusChanged;
+
+ public int DeviceCount { get; private set; }
+ public int ReaderIndex { get; private set; }
+ public bool IsConnected => _device != IntPtr.Zero;
+
+ public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
+ {
+ Disconnect();
+ var result = zkfp2.Init();
+ if (result != 0)
+ {
+ throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}).");
+ }
+
+ DeviceCount = zkfp2.GetDeviceCount();
+ if (DeviceCount <= 0)
+ {
+ zkfp2.Terminate();
+ throw new InvalidOperationException("Подключённые сканеры не найдены.");
+ }
+
+ ReaderIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
+ _device = zkfp2.OpenDevice(ReaderIndex);
+ if (_device == IntPtr.Zero)
+ {
+ zkfp2.Terminate();
+ throw new InvalidOperationException($"Не удалось открыть сканер №{ReaderIndex + 1}.");
+ }
+
+ _database = zkfp2.DBInit();
+ if (_database == IntPtr.Zero)
+ {
+ zkfp2.CloseDevice(_device);
+ _device = IntPtr.Zero;
+ zkfp2.Terminate();
+ throw new InvalidOperationException("Не удалось создать базу распознавания.");
+ }
+
+ foreach (var item in templates)
+ {
+ var template = zkfp2.Base64ToBlob(item.Template);
+ zkfp2.DBAdd(_database, item.FingerprintId, template);
+ }
+
+ _captureCancellation = new CancellationTokenSource();
+ _ = Task.Run(() => CaptureLoop(_captureCancellation.Token));
+ StatusChanged?.Invoke($"Сканер №{ReaderIndex + 1} подключён. Приложите палец.");
+ }
+
+ public void BeginRegistration(int fingerprintId)
+ {
+ lock (_gate)
+ {
+ _registrationFingerprintId = fingerprintId;
+ _registrationCount = 0;
+ }
+
+ StatusChanged?.Invoke("Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
+ }
+
+ public void CancelRegistration()
+ {
+ lock (_gate)
+ {
+ _registrationFingerprintId = null;
+ _registrationCount = 0;
+ }
+ }
+
+ private void CaptureLoop(CancellationToken cancellationToken)
+ {
+ var parameter = new byte[4];
+ var size = 4;
+ var width = 0;
+ var height = 0;
+ zkfp2.GetParameters(_device, 1, parameter, ref size);
+ zkfp2.ByteArray2Int(parameter, ref width);
+ size = 4;
+ zkfp2.GetParameters(_device, 2, parameter, ref size);
+ zkfp2.ByteArray2Int(parameter, ref height);
+ var image = new byte[Math.Max(1, width * height)];
+ var captured = new byte[TemplateSize];
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var capturedLength = TemplateSize;
+ if (zkfp2.AcquireFingerprint(_device, image, captured, ref capturedLength) == 0)
+ {
+ if (DateTimeOffset.UtcNow - _lastCapture > TimeSpan.FromMilliseconds(900))
+ {
+ _lastCapture = DateTimeOffset.UtcNow;
+ ProcessCapture(captured, capturedLength);
+ }
+ }
+ Thread.Sleep(150);
+ }
+ }
+
+ private void ProcessCapture(byte[] captured, int capturedLength)
+ {
+ lock (_gate)
+ {
+ if (_registrationFingerprintId is int fingerprintId)
+ {
+ if (_registrationCount > 0 && zkfp2.DBMatch(_database, captured, _registrationSamples[_registrationCount - 1]) <= 0)
+ {
+ StatusChanged?.Invoke("Это другой палец. Приложите тот же палец ещё раз.");
+ return;
+ }
+
+ Array.Clear(_registrationSamples[_registrationCount]);
+ Array.Copy(captured, _registrationSamples[_registrationCount], capturedLength);
+ _registrationCount++;
+ RegistrationProgress?.Invoke(_registrationCount);
+ if (_registrationCount < 3)
+ {
+ StatusChanged?.Invoke($"Скан принято: {_registrationCount}/3. Уберите палец и приложите снова.");
+ return;
+ }
+
+ var merged = new byte[TemplateSize];
+ var mergedLength = TemplateSize;
+ var result = zkfp2.DBMerge(_database, _registrationSamples[0], _registrationSamples[1], _registrationSamples[2], merged, ref mergedLength);
+ if (result == 0)
+ {
+ zkfp2.DBDel(_database, fingerprintId);
+ result = zkfp2.DBAdd(_database, fingerprintId, merged);
+ }
+
+ _registrationFingerprintId = null;
+ _registrationCount = 0;
+ if (result == 0)
+ {
+ RegistrationCompleted?.Invoke(fingerprintId, zkfp2.BlobToBase64(merged, mergedLength));
+ StatusChanged?.Invoke("Отпечаток успешно зарегистрирован.");
+ }
+ else
+ {
+ StatusChanged?.Invoke($"Не удалось сохранить отпечаток (код {result}). Повторите регистрацию.");
+ }
+ return;
+ }
+
+ var foundId = 0;
+ var score = 0;
+ if (zkfp2.DBIdentify(_database, captured, ref foundId, ref score) == 0)
+ {
+ FingerRecognized?.Invoke(foundId, score);
+ }
+ else
+ {
+ StatusChanged?.Invoke("Отпечаток не распознан.");
+ }
+ }
+ }
+
+ public void Disconnect()
+ {
+ _captureCancellation?.Cancel();
+ _captureCancellation = null;
+ Thread.Sleep(200);
+ if (_database != IntPtr.Zero)
+ {
+ zkfp2.DBFree(_database);
+ _database = IntPtr.Zero;
+ }
+ if (_device != IntPtr.Zero)
+ {
+ zkfp2.CloseDevice(_device);
+ _device = IntPtr.Zero;
+ }
+ zkfp2.Terminate();
+ }
+
+ public void Dispose() => Disconnect();
+}
diff --git a/apps/KronaStaffTerminal/Services/NotificationService.cs b/apps/KronaStaffTerminal/Services/NotificationService.cs
new file mode 100644
index 0000000..b2dbd25
--- /dev/null
+++ b/apps/KronaStaffTerminal/Services/NotificationService.cs
@@ -0,0 +1,79 @@
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using KronaStaffTerminal.Models;
+
+namespace KronaStaffTerminal.Services;
+
+public sealed class NotificationService
+{
+ private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(15) };
+
+ public async Task<(bool Delivered, string? Error)> SendArrivalAsync(TerminalSettings settings, string employeeName, DateTimeOffset localTime)
+ {
+ return await SendAsync(settings, $"{employeeName} пришёл(а) в {localTime:HH:mm}");
+ }
+
+ public async Task<(bool Delivered, string? Error)> SendTestAsync(TerminalSettings settings)
+ {
+ return await SendAsync(settings, "Krona Staff: тестовое сообщение доставлено.");
+ }
+
+ private async Task<(bool Delivered, string? Error)> SendAsync(TerminalSettings settings, string text)
+ {
+ var errors = new List();
+ var sent = false;
+
+ if (settings.TelegramEnabled)
+ {
+ try
+ {
+ var url = $"https://api.telegram.org/bot{settings.TelegramBotToken}/sendMessage";
+ using var response = await _httpClient.PostAsJsonAsync(url, new { chat_id = settings.TelegramChatId, text });
+ if (!response.IsSuccessStatusCode)
+ {
+ errors.Add($"Telegram: HTTP {(int)response.StatusCode}");
+ }
+ else
+ {
+ sent = true;
+ }
+ }
+ catch (Exception exception)
+ {
+ errors.Add($"Telegram: {exception.Message}");
+ }
+ }
+
+ if (settings.MaxEnabled)
+ {
+ try
+ {
+ var url = $"https://platform-api.max.ru/messages?chat_id={Uri.EscapeDataString(settings.MaxChatId)}";
+ using var request = new HttpRequestMessage(HttpMethod.Post, url);
+ request.Headers.TryAddWithoutValidation("Authorization", settings.MaxBotToken);
+ request.Content = new StringContent(JsonSerializer.Serialize(new { text }), Encoding.UTF8, "application/json");
+ using var response = await _httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode)
+ {
+ errors.Add($"MAX: HTTP {(int)response.StatusCode}");
+ }
+ else
+ {
+ sent = true;
+ }
+ }
+ catch (Exception exception)
+ {
+ errors.Add($"MAX: {exception.Message}");
+ }
+ }
+
+ if (!settings.TelegramEnabled && !settings.MaxEnabled)
+ {
+ return (false, null);
+ }
+
+ return (sent && errors.Count == 0, errors.Count == 0 ? null : string.Join("; ", errors));
+ }
+}
diff --git a/apps/KronaStaffTerminal/Services/TerminalStore.cs b/apps/KronaStaffTerminal/Services/TerminalStore.cs
new file mode 100644
index 0000000..bed25ec
--- /dev/null
+++ b/apps/KronaStaffTerminal/Services/TerminalStore.cs
@@ -0,0 +1,42 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+using KronaStaffTerminal.Models;
+
+namespace KronaStaffTerminal.Services;
+
+public sealed class TerminalStore
+{
+ private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+ private readonly string _filePath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Krona",
+ "StaffTerminal",
+ "terminal-state.dat");
+
+ public async Task LoadAsync()
+ {
+ try
+ {
+ if (!File.Exists(_filePath)) return new TerminalState();
+ var encrypted = await File.ReadAllBytesAsync(_filePath);
+ var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
+ return JsonSerializer.Deserialize(clear, JsonOptions) ?? new TerminalState();
+ }
+ catch
+ {
+ return new TerminalState();
+ }
+ }
+
+ public async Task SaveAsync(TerminalState state)
+ {
+ var directory = Path.GetDirectoryName(_filePath)!;
+ Directory.CreateDirectory(directory);
+ var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions);
+ var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser);
+ var temporaryPath = _filePath + ".tmp";
+ await File.WriteAllBytesAsync(temporaryPath, encrypted);
+ File.Move(temporaryPath, _filePath, true);
+ }
+}
diff --git a/apps/KronaStaffTerminal/Vendor/ZKFinger/x64/libzkfpcsharp.dll b/apps/KronaStaffTerminal/Vendor/ZKFinger/x64/libzkfpcsharp.dll
new file mode 100644
index 0000000..9a78479
Binary files /dev/null and b/apps/KronaStaffTerminal/Vendor/ZKFinger/x64/libzkfpcsharp.dll differ
diff --git a/apps/KronaStaffTerminal/app.manifest b/apps/KronaStaffTerminal/app.manifest
new file mode 100644
index 0000000..52092f8
--- /dev/null
+++ b/apps/KronaStaffTerminal/app.manifest
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+
+
+
\ No newline at end of file