fix(terminal): handle locked scans consistently
This commit is contained in:
parent
0f530dc633
commit
057ec75b26
5 changed files with 315 additions and 25 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.6.1" />
|
||||
<PackageReference Include="System.Windows.Extensions" Version="10.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
|
|
@ -97,14 +98,8 @@
|
|||
<_WinUiAsset Include="$(TargetDir)Assets\**\*" />
|
||||
<_WinUiStyle Include="$(TargetDir)Styles\**\*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_WinUiRootResource)"
|
||||
DestinationFolder="$(PublishDir)"
|
||||
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" />
|
||||
<Copy SourceFiles="@(_WinUiRootResource)" DestinationFolder="$(PublishDir)" 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>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -963,19 +963,7 @@ public sealed partial class MainPage : Page
|
|||
else SetStatus("Сотрудник заблокирован. Отметка не создана.", InfoBarSeverity.Warning);
|
||||
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 (employee.IsAdministrator)
|
||||
|
|
@ -1004,30 +992,57 @@ public sealed partial class MainPage : Page
|
|||
}
|
||||
_attendanceSuppressedFingerprintId = null;
|
||||
_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);
|
||||
}
|
||||
|
||||
private async Task RecordAttendanceAsync(EmployeeRecord employee, int score)
|
||||
private async Task<ScanRecognitionResult> RecordAttendanceAsync(EmployeeRecord employee, int score)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var result = await _attendanceTracker.TryRecordAttendanceAsync(
|
||||
var scanResult = await _attendanceTracker.ProcessScanAsync(
|
||||
_state,
|
||||
employee,
|
||||
_scanner.ReaderIndex,
|
||||
now,
|
||||
_applicationLocked,
|
||||
state => _store.SaveAsync(state));
|
||||
|
||||
var result = scanResult.AttendanceResult;
|
||||
if (!result.Success)
|
||||
{
|
||||
if (result.IsDuplicateSuppressed)
|
||||
{
|
||||
PlayDuplicateSoundSafe();
|
||||
ShowTrayNotification(
|
||||
"Krona Staff",
|
||||
$"Повторный скан отклонён. Повторите через {result.RemainingSeconds} сек.");
|
||||
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек.";
|
||||
SetStatus(result.Message, InfoBarSeverity.Informational);
|
||||
}
|
||||
return;
|
||||
return scanResult;
|
||||
}
|
||||
|
||||
var record = result.Record!;
|
||||
PlaySuccessSoundSafe();
|
||||
ShowTrayNotification(
|
||||
"Krona Staff",
|
||||
$"{TerminalDisplayName(employee)}: {record.EventType} в {now.ToLocalTime():HH:mm}");
|
||||
|
||||
RefreshLists();
|
||||
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{record.EventType} отмечен в {now.ToLocalTime():HH:mm}";
|
||||
SetStatus($"{record.EventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
|
||||
|
|
@ -1046,6 +1061,47 @@ public sealed partial class MainPage : Page
|
|||
{
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ public sealed record AttendanceRecordResult(
|
|||
bool IsDuplicateSuppressed,
|
||||
int RemainingSeconds = 0);
|
||||
|
||||
public sealed record ScanRecognitionResult(
|
||||
bool Success,
|
||||
AttendanceRecordResult AttendanceResult,
|
||||
bool ShouldUnlock,
|
||||
string? LockScreenMessage,
|
||||
string StatusMessage);
|
||||
|
||||
public sealed class AttendanceTracker
|
||||
{
|
||||
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(
|
||||
TerminalState state,
|
||||
DateTimeOffset nowUtc,
|
||||
|
|
|
|||
Loading…
Reference in a new issue