diff --git a/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs b/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs
index 1deb238..f0fd517 100644
--- a/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs
+++ b/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs
@@ -220,4 +220,168 @@ public class AttendanceTrackerTests
Assert.Empty(checkedOutRecords);
}
+
+ [Fact]
+ public async Task ProcessScan_Locked_RegularEmployee_FirstScan_RecordsAttendanceAndKeepsLocked()
+ {
+ var (state, emp) = CreateTestState();
+ var tracker = new AttendanceTracker();
+ var now = DateTimeOffset.UtcNow;
+
+ var result = await tracker.ProcessScanAsync(
+ state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
+
+ Assert.True(result.Success);
+ Assert.False(result.ShouldUnlock);
+ Assert.NotNull(result.AttendanceResult.Record);
+ Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
+ Assert.Equal("Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным.", result.LockScreenMessage);
+ Assert.Single(state.Attendance);
+ }
+
+ [Fact]
+ public async Task ProcessScan_Locked_Administrator_FirstScan_RecordsAttendanceAndUnlocks()
+ {
+ var (state, emp) = CreateTestState();
+ emp.IsAdministrator = true;
+ var tracker = new AttendanceTracker();
+ var now = DateTimeOffset.UtcNow;
+
+ var result = await tracker.ProcessScanAsync(
+ state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
+
+ Assert.True(result.Success);
+ Assert.True(result.ShouldUnlock);
+ Assert.NotNull(result.AttendanceResult.Record);
+ Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
+ Assert.Null(result.LockScreenMessage);
+ Assert.Contains(emp.DisplayName, result.StatusMessage);
+ Assert.Single(state.Attendance);
+ }
+
+ [Fact]
+ public async Task ProcessScan_Locked_Administrator_SecondScanAfterGuard_RecordsCheckOutAndUnlocks()
+ {
+ var (state, emp) = CreateTestState();
+ emp.IsAdministrator = true;
+ var tracker = new AttendanceTracker();
+ var now = DateTimeOffset.UtcNow;
+
+ // First scan: check-in
+ await tracker.ProcessScanAsync(
+ state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
+
+ // Second scan after 35 seconds (guard is 30s)
+ var result = await tracker.ProcessScanAsync(
+ state, emp, 0, now.AddSeconds(35), isApplicationLocked: true, _ => Task.CompletedTask);
+
+ Assert.True(result.Success);
+ Assert.True(result.ShouldUnlock);
+ Assert.NotNull(result.AttendanceResult.Record);
+ Assert.Equal("Уход", result.AttendanceResult.Record.EventType);
+ Assert.Null(result.LockScreenMessage);
+ Assert.Equal(2, state.Attendance.Count);
+ }
+
+ [Fact]
+ public async Task ProcessScan_Locked_RegularEmployee_DuplicateScan_RejectsDuplicateAndKeepsLocked()
+ {
+ var (state, emp) = CreateTestState();
+ var tracker = new AttendanceTracker();
+ var now = DateTimeOffset.UtcNow;
+
+ // First scan
+ await tracker.ProcessScanAsync(
+ state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
+
+ // Immediate repeat scan 5s later
+ var result = await tracker.ProcessScanAsync(
+ state, emp, 0, now.AddSeconds(5), isApplicationLocked: true, _ => Task.CompletedTask);
+
+ Assert.False(result.Success);
+ Assert.False(result.ShouldUnlock);
+ Assert.True(result.AttendanceResult.IsDuplicateSuppressed);
+ Assert.Equal(25, result.AttendanceResult.RemainingSeconds);
+ Assert.Equal("Повторный скан отклонён. Повторите через 25 сек.", result.LockScreenMessage);
+ Assert.Single(state.Attendance);
+ }
+
+ [Fact]
+ public async Task ProcessScan_Locked_Administrator_DuplicateScan_RejectsDuplicateAndUnlocks()
+ {
+ var (state, emp) = CreateTestState();
+ emp.IsAdministrator = true;
+ var tracker = new AttendanceTracker();
+ var now = DateTimeOffset.UtcNow;
+
+ // First scan
+ await tracker.ProcessScanAsync(
+ state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
+
+ // Immediate repeat scan 5s later
+ var result = await tracker.ProcessScanAsync(
+ state, emp, 0, now.AddSeconds(5), isApplicationLocked: true, _ => Task.CompletedTask);
+
+ Assert.False(result.Success);
+ Assert.True(result.ShouldUnlock);
+ Assert.True(result.AttendanceResult.IsDuplicateSuppressed);
+ Assert.Equal(25, result.AttendanceResult.RemainingSeconds);
+ Assert.Null(result.LockScreenMessage);
+ Assert.Single(state.Attendance);
+ }
+
+ [Fact]
+ public async Task ProcessScan_Locked_BlockedEmployee_DoesNotRecordAttendance()
+ {
+ var (state, emp) = CreateTestState();
+ emp.IsBlocked = true;
+ var tracker = new AttendanceTracker();
+ var now = DateTimeOffset.UtcNow;
+
+ var result = await tracker.ProcessScanAsync(
+ state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask);
+
+ Assert.False(result.Success);
+ Assert.False(result.ShouldUnlock);
+ Assert.False(result.AttendanceResult.Success);
+ Assert.Equal("Этот сотрудник заблокирован.", result.LockScreenMessage);
+ Assert.Empty(state.Attendance);
+ }
+
+ [Fact]
+ public async Task ProcessScan_Unlocked_RegularEmployee_RecordsAttendanceWithoutUnlock()
+ {
+ var (state, emp) = CreateTestState();
+ var tracker = new AttendanceTracker();
+ var now = DateTimeOffset.UtcNow;
+
+ var result = await tracker.ProcessScanAsync(
+ state, emp, 0, now, isApplicationLocked: false, _ => Task.CompletedTask);
+
+ Assert.True(result.Success);
+ Assert.False(result.ShouldUnlock);
+ Assert.NotNull(result.AttendanceResult.Record);
+ Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
+ Assert.Null(result.LockScreenMessage);
+ Assert.Single(state.Attendance);
+ }
+
+ [Fact]
+ public async Task ProcessScan_Unlocked_Administrator_RecordsAttendanceWithoutUnlock()
+ {
+ var (state, emp) = CreateTestState();
+ emp.IsAdministrator = true;
+ var tracker = new AttendanceTracker();
+ var now = DateTimeOffset.UtcNow;
+
+ var result = await tracker.ProcessScanAsync(
+ state, emp, 0, now, isApplicationLocked: false, _ => Task.CompletedTask);
+
+ Assert.True(result.Success);
+ Assert.False(result.ShouldUnlock);
+ Assert.NotNull(result.AttendanceResult.Record);
+ Assert.Equal("Приход", result.AttendanceResult.Record.EventType);
+ Assert.Null(result.LockScreenMessage);
+ Assert.Single(state.Attendance);
+ }
}
diff --git a/apps/KronaStaffTerminal/App.xaml.cs b/apps/KronaStaffTerminal/App.xaml.cs
index 523bbcc..0f75f9d 100644
--- a/apps/KronaStaffTerminal/App.xaml.cs
+++ b/apps/KronaStaffTerminal/App.xaml.cs
@@ -123,6 +123,9 @@ public partial class App : Application
public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon();
+ public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1) =>
+ _trayService?.ShowNotificationBalloon(title, message, infoFlags);
+
public void UpdateTray(TrayState state, int pendingCount, string? statusText = null) =>
_trayService?.UpdateState(state, pendingCount, statusText);
diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
index 20eddb7..00cea39 100644
--- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
+++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
@@ -73,6 +73,7 @@
+