diff --git a/src/HikvisionAttendanceManager.App/Services/DepartmentalUserSyncService.cs b/src/HikvisionAttendanceManager.App/Services/DepartmentalUserSyncService.cs index 9a209ca..29c3846 100644 --- a/src/HikvisionAttendanceManager.App/Services/DepartmentalUserSyncService.cs +++ b/src/HikvisionAttendanceManager.App/Services/DepartmentalUserSyncService.cs @@ -11,6 +11,8 @@ public sealed class DepartmentalUserSyncService( IProgress? progress, CancellationToken cancellationToken) { var list = employees.ToList(); + AppLogger.TraceEnter("USER_SYNC", nameof(SyncAsync), + $"device={device.IpAddress} count={list.Count} context={contextSummary}"); var entry = new SyncHistoryEntry { Operation = SyncOperationKind.DepartmentalUserSync, @@ -24,10 +26,12 @@ public sealed class DepartmentalUserSyncService( var results = await engine.RunAsync(device, list, initialEnrollment: true, progress, cancellationToken); entry.CompletedAt = DateTime.Now; entry.Success = results.Count(r => r.OverallResult is "SUCCESS" or "ALREADY_EXISTS"); - entry.Failed = results.Count(r => r.OverallResult == "FAILED"); + entry.Failed = results.Count(r => r.OverallResult is "FAILED" or "PARTIAL"); entry.Skipped = results.Count(r => r.OverallResult == "SKIPPED"); entry.Status = entry.Failed > 0 ? "Completed with errors" : "Completed"; await history.UpdateAsync(entry, cancellationToken); + AppLogger.TraceExit("USER_SYNC", nameof(SyncAsync), + $"success={entry.Success} failed={entry.Failed} skipped={entry.Skipped}"); return (entry, results); } } diff --git a/src/HikvisionAttendanceManager.App/Services/UserSyncEngine.cs b/src/HikvisionAttendanceManager.App/Services/UserSyncEngine.cs index cb9c988..7e70230 100644 --- a/src/HikvisionAttendanceManager.App/Services/UserSyncEngine.cs +++ b/src/HikvisionAttendanceManager.App/Services/UserSyncEngine.cs @@ -7,47 +7,103 @@ public sealed class UserSyncEngine(HikvisionIsapiClient hikvision, EmployeePhoto public async Task> RunAsync(Device device, IEnumerable employees, bool initialEnrollment, IProgress? progress, CancellationToken cancellationToken) { - var results = new List(); var source = employees.ToList(); + AppLogger.TraceEnter("USER_SYNC", nameof(RunAsync), + $"device={device.IpAddress} count={source.Count} initialEnrollment={initialEnrollment}"); + var results = new List(); for (var index = 0; index < source.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); var employee = source[index]; + AppLogger.TraceEnter("USER_SYNC", "ProcessEmployee", $"employee={employee.SerialNumber} index={index + 1}/{source.Count}"); progress?.Report(CreateProgress(index, source.Count, employee.SerialNumber, "Creating user…", results)); var result = new EmployeeSyncResult { Employee = employee, EmployeeNumber = employee.SerialNumber, EmployeeName = employee.Name, Department = employee.DepartmentName }; + var userOnDevice = false; try { - if (await hikvision.UserExistsAsync(device, employee.SerialNumber, cancellationToken)) + var exists = await hikvision.UserExistsAsync(device, employee.SerialNumber, cancellationToken); + if (exists) + { + userOnDevice = true; + result.UserCreated = true; result.UserStatus = "Already exists"; + if (initialEnrollment) + { + var existingFaces = await hikvision.TryGetUserFaceCountAsync(device, employee.SerialNumber, cancellationToken); + if (existingFaces is >= 1) + { + result.FaceStatus = "Already enrolled"; + result.VerificationStatus = "Verified"; + result.FaceEnrolled = true; + result.OverallResult = "ALREADY_EXISTS"; + results.Add(result); + continue; + } + } + } else { var user = await hikvision.CreateUserAsync(device, employee, cancellationToken); - if (!user.Success) { result.UserStatus = "Failed"; result.OverallResult = "FAILED"; result.Reason = user.Reason; results.Add(result); continue; } + if (!user.Success) + { + result.UserStatus = "Failed"; + result.OverallResult = "FAILED"; + result.Reason = user.Reason; + results.Add(result); + continue; + } + + userOnDevice = true; + result.UserCreated = true; result.UserStatus = "Created"; } - if (!initialEnrollment) { result.OverallResult = result.UserStatus == "Already exists" ? "ALREADY_EXISTS" : "SUCCESS"; results.Add(result); continue; } + if (!initialEnrollment) + { + result.OverallResult = result.UserStatus == "Already exists" ? "ALREADY_EXISTS" : "SUCCESS"; + results.Add(result); + continue; + } + progress?.Report(CreateProgress(index, source.Count, employee.SerialNumber, "Downloading employee photo…", results)); var photo = await photos.DownloadAndNormalizeAsync(employee, cancellationToken); - if (!photo.Success) { result.FaceStatus = "Failed"; result.OverallResult = "FAILED"; result.Reason = photo.Reason; results.Add(result); continue; } + if (!photo.Success) + { + FaceEnrollmentResults.Apply(result, userOnDevice, false, "Failed", "Not requested", photo.Reason); + results.Add(result); + continue; + } + progress?.Report(CreateProgress(index, source.Count, employee.SerialNumber, "Uploading face…", results)); var face = await hikvision.UploadFaceAsync(device, employee.SerialNumber, photo.Bytes, cancellationToken); - if (!face.Success) { result.FaceStatus = "Failed"; result.OverallResult = "FAILED"; result.Reason = face.Reason; results.Add(result); continue; } + if (!face.Success) + { + FaceEnrollmentResults.Apply(result, userOnDevice, false, "Failed", "Not requested", face.Reason); + results.Add(result); + continue; + } result.FaceStatus = "Uploaded"; + progress?.Report(CreateProgress(index, source.Count, employee.SerialNumber, "Verifying face enrollment…", results)); var verification = await hikvision.VerifyFaceAsync(device, employee.SerialNumber, cancellationToken); - result.VerificationStatus = verification.Success ? "Verified" : "Failed"; - result.OverallResult = verification.Success ? "SUCCESS" : "FAILED"; - result.Reason = verification.Reason; + FaceEnrollmentResults.Apply(result, userOnDevice, verification.Success, result.FaceStatus, + verification.Success ? "Verified" : "Failed", verification.Reason); } catch (Exception ex) { - result.OverallResult = "FAILED"; - result.Reason = ex is InvalidOperationException ? ex.Message : "Device unavailable or operation failed."; + FaceEnrollmentResults.Apply(result, userOnDevice, false, result.FaceStatus, result.VerificationStatus, + ex is InvalidOperationException ? ex.Message : "Device unavailable or operation failed."); + if (!userOnDevice) + result.OverallResult = "FAILED"; } + + AppLogger.TraceExit("USER_SYNC", "ProcessEmployee", + $"employee={employee.SerialNumber} result={result.OverallResult}"); results.Add(result); } progress?.Report(CreateProgress(source.Count, source.Count, "", "Completed", results)); + AppLogger.TraceExit("USER_SYNC", nameof(RunAsync), + $"success={results.Count(r => r.OverallResult is "SUCCESS" or "ALREADY_EXISTS")} partial={results.Count(r => r.OverallResult == "PARTIAL")} failed={results.Count(r => r.OverallResult == "FAILED")}"); return results; } @@ -55,5 +111,39 @@ public sealed class UserSyncEngine(HikvisionIsapiClient hikvision, EmployeePhoto new(completed, total, employee, operation, results.Count(r => r.UserStatus == "Created"), results.Count(r => r.UserStatus == "Already exists"), results.Count(r => r.FaceStatus == "Uploaded"), results.Count(r => r.FaceStatus == "Failed"), - results.Count(r => r.OverallResult == "SKIPPED"), results.Count(r => r.OverallResult == "FAILED")); + results.Count(r => r.OverallResult == "SKIPPED"), results.Count(r => r.OverallResult is "FAILED" or "PARTIAL")); +} + +internal static class FaceEnrollmentResults +{ + internal static void Apply( + EmployeeSyncResult result, + bool userOnDevice, + bool faceEnrolled, + string faceStatus, + string verificationStatus, + string? failureReason) + { + result.UserCreated = userOnDevice; + result.FaceEnrolled = faceEnrolled; + result.FaceStatus = faceStatus; + result.VerificationStatus = verificationStatus; + + if (faceEnrolled) + { + result.OverallResult = "SUCCESS"; + result.Reason = ""; + return; + } + + if (userOnDevice) + { + result.OverallResult = "PARTIAL"; + result.Reason = string.IsNullOrWhiteSpace(failureReason) ? "Face enrollment failed." : failureReason; + return; + } + + result.OverallResult = "FAILED"; + result.Reason = string.IsNullOrWhiteSpace(failureReason) ? "User creation failed." : failureReason; + } }