fix(terminal): handle locked scans consistently

This commit is contained in:
Ochenstarik 2026-09-24 18:09:32 +07:00
parent 0f530dc633
commit 057ec75b26
5 changed files with 315 additions and 25 deletions

View file

@ -220,4 +220,168 @@ public class AttendanceTrackerTests
Assert.Empty(checkedOutRecords); 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);
}
} }

View file

@ -123,6 +123,9 @@ public partial class App : Application
public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon(); 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) => public void UpdateTray(TrayState state, int pendingCount, string? statusText = null) =>
_trayService?.UpdateState(state, pendingCount, statusText); _trayService?.UpdateState(state, pendingCount, statusText);

View file

@ -73,6 +73,7 @@
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" /> <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" /> <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.6.1" /> <PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.6.1" />
<PackageReference Include="System.Windows.Extensions" Version="10.0.12" />
</ItemGroup> </ItemGroup>
<!-- <!--
@ -97,14 +98,8 @@
<_WinUiAsset Include="$(TargetDir)Assets\**\*" /> <_WinUiAsset Include="$(TargetDir)Assets\**\*" />
<_WinUiStyle Include="$(TargetDir)Styles\**\*" /> <_WinUiStyle Include="$(TargetDir)Styles\**\*" />
</ItemGroup> </ItemGroup>
<Copy SourceFiles="@(_WinUiRootResource)" <Copy SourceFiles="@(_WinUiRootResource)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
DestinationFolder="$(PublishDir)" <Copy SourceFiles="@(_WinUiAsset)" DestinationFiles="@(_WinUiAsset->'$(PublishDir)Assets\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
SkipUnchangedFiles="true" /> <Copy SourceFiles="@(_WinUiStyle)" DestinationFiles="@(_WinUiStyle->'$(PublishDir)Styles\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WinUiAsset)"
DestinationFiles="@(_WinUiAsset->'$(PublishDir)Assets\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_WinUiStyle)"
DestinationFiles="@(_WinUiStyle->'$(PublishDir)Styles\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target> </Target>
</Project> </Project>

View file

@ -963,19 +963,7 @@ public sealed partial class MainPage : Page
else SetStatus("Сотрудник заблокирован. Отметка не создана.", InfoBarSeverity.Warning); else SetStatus("Сотрудник заблокирован. Отметка не создана.", InfoBarSeverity.Warning);
return; return;
} }
if (_applicationLocked)
{
if (!employee.IsAdministrator)
{
await RecordAttendanceAsync(employee, score);
LockScreenMessage.Text = "Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным.";
return;
}
_attendanceSuppressedFingerprintId = fingerprintId;
_attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3);
UnlockApplication($"employee:{employee.Id}", $"Вход выполнен по отпечатку администратора {employee.DisplayName}.");
return;
}
if (_adminAuthCompletion is not null) if (_adminAuthCompletion is not null)
{ {
if (employee.IsAdministrator) if (employee.IsAdministrator)
@ -1004,30 +992,57 @@ public sealed partial class MainPage : Page
} }
_attendanceSuppressedFingerprintId = null; _attendanceSuppressedFingerprintId = null;
_attendanceSuppressedUntilUtc = default; _attendanceSuppressedUntilUtc = default;
if (_applicationLocked)
{
var scanResult = await RecordAttendanceAsync(employee, score);
if (scanResult.ShouldUnlock)
{
_attendanceSuppressedFingerprintId = fingerprintId;
_attendanceSuppressedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(3);
UnlockApplication($"employee:{employee.Id}", scanResult.StatusMessage);
return;
}
LockScreenMessage.Text = scanResult.LockScreenMessage;
return;
}
await RecordAttendanceAsync(employee, score); await RecordAttendanceAsync(employee, score);
} }
private async Task RecordAttendanceAsync(EmployeeRecord employee, int score) private async Task<ScanRecognitionResult> RecordAttendanceAsync(EmployeeRecord employee, int score)
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var result = await _attendanceTracker.TryRecordAttendanceAsync( var scanResult = await _attendanceTracker.ProcessScanAsync(
_state, _state,
employee, employee,
_scanner.ReaderIndex, _scanner.ReaderIndex,
now, now,
_applicationLocked,
state => _store.SaveAsync(state)); state => _store.SaveAsync(state));
var result = scanResult.AttendanceResult;
if (!result.Success) if (!result.Success)
{ {
if (result.IsDuplicateSuppressed) if (result.IsDuplicateSuppressed)
{ {
PlayDuplicateSoundSafe();
ShowTrayNotification(
"Krona Staff",
$"Повторный скан отклонён. Повторите через {result.RemainingSeconds} сек.");
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек."; TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек.";
SetStatus(result.Message, InfoBarSeverity.Informational); SetStatus(result.Message, InfoBarSeverity.Informational);
} }
return; return scanResult;
} }
var record = result.Record!; var record = result.Record!;
PlaySuccessSoundSafe();
ShowTrayNotification(
"Krona Staff",
$"{TerminalDisplayName(employee)}: {record.EventType} в {now.ToLocalTime():HH:mm}");
RefreshLists(); RefreshLists();
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{record.EventType} отмечен в {now.ToLocalTime():HH:mm}"; TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{record.EventType} отмечен в {now.ToLocalTime():HH:mm}";
SetStatus($"{record.EventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); SetStatus($"{record.EventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
@ -1046,6 +1061,47 @@ public sealed partial class MainPage : Page
{ {
SetStatus($"{record.EventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning); SetStatus($"{record.EventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
} }
return scanResult;
}
private static void PlaySuccessSoundSafe()
{
try
{
System.Media.SystemSounds.Asterisk.Play();
}
catch (Exception exception)
{
CrashLogService.Write(exception);
}
}
private static void PlayDuplicateSoundSafe()
{
try
{
System.Media.SystemSounds.Exclamation.Play();
}
catch (Exception exception)
{
CrashLogService.Write(exception);
}
}
private static void ShowTrayNotification(string title, string message)
{
try
{
if (Application.Current is App app)
{
app.ShowNotificationBalloon(title, message);
}
}
catch (Exception exception)
{
CrashLogService.Write(exception);
}
} }
private async Task RunAutomaticCheckoutIfDueAsync() private async Task RunAutomaticCheckoutIfDueAsync()

View file

@ -9,6 +9,13 @@ public sealed record AttendanceRecordResult(
bool IsDuplicateSuppressed, bool IsDuplicateSuppressed,
int RemainingSeconds = 0); int RemainingSeconds = 0);
public sealed record ScanRecognitionResult(
bool Success,
AttendanceRecordResult AttendanceResult,
bool ShouldUnlock,
string? LockScreenMessage,
string StatusMessage);
public sealed class AttendanceTracker public sealed class AttendanceTracker
{ {
private readonly SemaphoreSlim _gate = new(1, 1); private readonly SemaphoreSlim _gate = new(1, 1);
@ -77,6 +84,71 @@ public sealed class AttendanceTracker
} }
} }
public async Task<ScanRecognitionResult> ProcessScanAsync(
TerminalState state,
EmployeeRecord employee,
int readerIndex,
DateTimeOffset nowUtc,
bool isApplicationLocked,
Func<TerminalState, Task> saveStateCallback)
{
if (employee.IsBlocked)
{
return new ScanRecognitionResult(
Success: false,
AttendanceResult: new AttendanceRecordResult(
Success: false,
Record: null,
Message: "Сотрудник заблокирован. Отметка не создана.",
IsDuplicateSuppressed: false),
ShouldUnlock: false,
LockScreenMessage: isApplicationLocked ? "Этот сотрудник заблокирован." : null,
StatusMessage: "Сотрудник заблокирован. Отметка не создана.");
}
var attendanceResult = await TryRecordAttendanceAsync(
state,
employee,
readerIndex,
nowUtc,
saveStateCallback);
if (isApplicationLocked)
{
if (!employee.IsAdministrator)
{
var lockMessage = attendanceResult.Success
? "Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным."
: attendanceResult.IsDuplicateSuppressed
? $"Повторный скан отклонён. Повторите через {attendanceResult.RemainingSeconds} сек."
: "Отметка не создана. Интерфейс остаётся заблокированным.";
return new ScanRecognitionResult(
Success: attendanceResult.Success,
AttendanceResult: attendanceResult,
ShouldUnlock: false,
LockScreenMessage: lockMessage,
StatusMessage: attendanceResult.Message);
}
// Administrator scan in locked state:
// Attendance is recorded AND the interface should be unlocked
return new ScanRecognitionResult(
Success: attendanceResult.Success,
AttendanceResult: attendanceResult,
ShouldUnlock: true,
LockScreenMessage: null,
StatusMessage: $"Вход выполнен по отпечатку администратора {employee.DisplayName}.");
}
return new ScanRecognitionResult(
Success: attendanceResult.Success,
AttendanceResult: attendanceResult,
ShouldUnlock: false,
LockScreenMessage: null,
StatusMessage: attendanceResult.Message);
}
public async Task<List<AttendanceRecord>> RunScheduledCheckoutAsync( public async Task<List<AttendanceRecord>> RunScheduledCheckoutAsync(
TerminalState state, TerminalState state,
DateTimeOffset nowUtc, DateTimeOffset nowUtc,