From 01ef65196528ead0c3a5a3f4f291088172657f60 Mon Sep 17 00:00:00 2001 From: Syed Mustafa Ahmed Naqvi Date: Thu, 27 Aug 2026 10:00:42 +0500 Subject: [PATCH] feat(services): implement user create/delete with PARTIAL face results User create/delete with per-employee batch results, correct SUCCESS/FAILED rows, and PARTIAL when the user is created but face enrollment fails. --- .../Services/UserManagementService.cs | 177 +++++++++++++++--- 1 file changed, 153 insertions(+), 24 deletions(-) diff --git a/src/HikvisionAttendanceManager.App/Services/UserManagementService.cs b/src/HikvisionAttendanceManager.App/Services/UserManagementService.cs index b48f4b5..47dfc1a 100644 --- a/src/HikvisionAttendanceManager.App/Services/UserManagementService.cs +++ b/src/HikvisionAttendanceManager.App/Services/UserManagementService.cs @@ -6,6 +6,7 @@ public sealed class UserManagementService( HikvisionIsapiClient hikvision, EmployeePhotoService photos, AttendanceMachineUserRepository machineUsers, + HrmsEmployeeService hrms, OperationHistoryService history) { public static EmployeeSyncResult ValidateEmployee(HrmsEmployee employee, long siteId) @@ -28,8 +29,15 @@ public sealed class UserManagementService( public async Task CreateUserWithFaceAsync(Device device, HrmsEmployee employee, long siteId, IProgress? progress, CancellationToken cancellationToken) { + AppLogger.TraceEnter("USER_CREATE", nameof(CreateUserWithFaceAsync), + $"device={device.IpAddress} employee={employee.SerialNumber} siteId={siteId}"); var validation = ValidateEmployee(employee, siteId); - if (validation.OverallResult == "SKIPPED") return validation; + if (validation.OverallResult == "SKIPPED") + { + AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync), + $"employee={employee.SerialNumber} result=SKIPPED reason={validation.Reason}"); + return validation; + } var entry = new SyncHistoryEntry { @@ -49,69 +57,118 @@ public sealed class UserManagementService( Department = employee.DepartmentName }; + var userOnDevice = false; + try { progress?.Report("Checking if user already exists…"); - if (await hikvision.UserExistsAsync(device, employee.SerialNumber, cancellationToken)) + AppLogger.TraceEnter("USER_CREATE", "HikvisionIsapiClient.UserExistsAsync", $"employee={employee.SerialNumber}"); + var userExists = await hikvision.UserExistsAsync(device, employee.SerialNumber, cancellationToken); + AppLogger.TraceExit("USER_CREATE", "HikvisionIsapiClient.UserExistsAsync", + $"employee={employee.SerialNumber} exists={userExists}"); + + if (userExists) { + userOnDevice = true; result.UserStatus = "Already exists"; - result.OverallResult = "ALREADY_EXISTS"; + result.UserCreated = true; + + 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"; + result.Reason = "User and face already exist on device."; + await machineUsers.UpsertAsync(device.MachineId, employee.SerialNumber, employee.Name, cancellationToken); + await CompleteHistory(entry, result, cancellationToken); + AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync), + $"employee={employee.SerialNumber} result=ALREADY_EXISTS numOfFace={existingFaces}"); + return result; + } } else { progress?.Report("Creating Hikvision UserInfo…"); + AppLogger.TraceEnter("USER_CREATE", "HikvisionIsapiClient.CreateUserAsync", $"employee={employee.SerialNumber}"); var create = await hikvision.CreateUserAsync(device, employee, cancellationToken); + AppLogger.TraceExit("USER_CREATE", "HikvisionIsapiClient.CreateUserAsync", + $"employee={employee.SerialNumber} success={create.Success}"); if (!create.Success) { result.UserStatus = "Failed"; + result.UserCreated = false; result.OverallResult = "FAILED"; result.Reason = create.Reason; await CompleteHistory(entry, result, cancellationToken); + AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync), + $"employee={employee.SerialNumber} result=FAILED reason={result.Reason}"); return result; } + + userOnDevice = true; + result.UserCreated = true; result.UserStatus = "Created"; } progress?.Report("Downloading employee photo…"); + AppLogger.TraceEnter("USER_CREATE", "EmployeePhotoService.DownloadAndNormalizeAsync", $"employee={employee.SerialNumber}"); var photo = await photos.DownloadAndNormalizeAsync(employee, cancellationToken); + AppLogger.TraceExit("USER_CREATE", "EmployeePhotoService.DownloadAndNormalizeAsync", + $"employee={employee.SerialNumber} success={photo.Success}"); if (!photo.Success) { - result.FaceStatus = "Failed"; - result.OverallResult = "FAILED"; - result.Reason = photo.Reason; + ApplyEnrollmentOutcome(result, userOnDevice, faceEnrolled: false, faceStatus: "Failed", verificationStatus: "Not requested", + failureReason: photo.Reason); await CompleteHistory(entry, result, cancellationToken); + AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync), + $"employee={employee.SerialNumber} result={result.OverallResult} reason={result.Reason}"); return result; } progress?.Report("Uploading face…"); + AppLogger.TraceEnter("USER_CREATE", "HikvisionIsapiClient.UploadFaceAsync", $"employee={employee.SerialNumber}"); var face = await hikvision.UploadFaceAsync(device, employee.SerialNumber, photo.Bytes, cancellationToken); + AppLogger.TraceExit("USER_CREATE", "HikvisionIsapiClient.UploadFaceAsync", + $"employee={employee.SerialNumber} success={face.Success}"); if (!face.Success) { - result.FaceStatus = "Failed"; - result.OverallResult = "FAILED"; - result.Reason = face.Reason; + ApplyEnrollmentOutcome(result, userOnDevice, faceEnrolled: false, faceStatus: "Failed", verificationStatus: "Not requested", + failureReason: face.Reason); await CompleteHistory(entry, result, cancellationToken); + AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync), + $"employee={employee.SerialNumber} result={result.OverallResult} reason={result.Reason}"); return result; } result.FaceStatus = "Uploaded"; progress?.Report("Verifying face enrollment…"); + AppLogger.TraceEnter("USER_CREATE", "HikvisionIsapiClient.VerifyFaceAsync", $"employee={employee.SerialNumber}"); var verify = await hikvision.VerifyFaceAsync(device, employee.SerialNumber, cancellationToken); - result.VerificationStatus = verify.Success ? "Verified" : "Failed"; - result.OverallResult = verify.Success ? "SUCCESS" : "FAILED"; - result.Reason = verify.Reason; + AppLogger.TraceExit("USER_CREATE", "HikvisionIsapiClient.VerifyFaceAsync", + $"employee={employee.SerialNumber} success={verify.Success}"); + ApplyEnrollmentOutcome(result, userOnDevice, verify.Success, faceStatus: result.FaceStatus, + verificationStatus: verify.Success ? "Verified" : "Failed", failureReason: verify.Reason); if (verify.Success) await machineUsers.UpsertAsync(device.MachineId, employee.SerialNumber, employee.Name, cancellationToken); await CompleteHistory(entry, result, cancellationToken); + AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync), + $"employee={employee.SerialNumber} result={result.OverallResult} faceEnrolled={result.FaceEnrolled}"); return result; } catch (Exception ex) { - result.OverallResult = "FAILED"; - result.Reason = ex is InvalidOperationException ? ex.Message : "Device unavailable or operation failed."; + ApplyEnrollmentOutcome(result, userOnDevice, faceEnrolled: false, faceStatus: result.FaceStatus, + verificationStatus: result.VerificationStatus, + failureReason: ex is InvalidOperationException ? ex.Message : "Device unavailable or operation failed."); + if (!userOnDevice) + result.OverallResult = "FAILED"; await CompleteHistory(entry, result, cancellationToken); + AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync), + $"employee={employee.SerialNumber} result={result.OverallResult} reason={result.Reason}"); return result; } } @@ -120,6 +177,8 @@ public sealed class UserManagementService( IProgress? progress, CancellationToken cancellationToken) { var normalized = employeeNumbers.Where(e => !string.IsNullOrWhiteSpace(e)).Select(e => e.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + AppLogger.TraceEnter("USER_DELETE", nameof(DeleteUsersAsync), + $"device={device.IpAddress} count={normalized.Count} employees={string.Join(",", normalized)}"); var entry = new SyncHistoryEntry { Operation = SyncOperationKind.UserDeletion, @@ -140,27 +199,43 @@ public sealed class UserManagementService( } progress?.Report("Deleting users on device…"); - var batchResult = await hikvision.DeleteUsersAsync(device, normalized, cancellationToken); - foreach (var employeeNo in normalized) + var employeeNames = await LoadEmployeeNamesAsync(device.MachineId, normalized, cancellationToken); + + AppLogger.TraceEnter("USER_DELETE", "HikvisionIsapiClient.DeleteUsersAsync", $"device={device.IpAddress} count={normalized.Count}"); + var deleteResults = await hikvision.DeleteUsersAsync(device, normalized, cancellationToken); + AppLogger.TraceExit("USER_DELETE", "HikvisionIsapiClient.DeleteUsersAsync", + $"success={deleteResults.Count(r => r.Result.Success)} failed={deleteResults.Count(r => !r.Result.Success)}"); + foreach (var deleteResult in deleteResults) { - if (batchResult.Success) + if (deleteResult.Result.Success) { - await machineUsers.MarkDeletedAsync(device.MachineId, employeeNo, cancellationToken); + AppLogger.Info($"[USER_DELETE] employee={deleteResult.EmployeeNumber} result=SUCCESS"); + await machineUsers.MarkDeletedAsync(device.MachineId, deleteResult.EmployeeNumber, cancellationToken); results.Add(new EmployeeSyncResult { - EmployeeNumber = employeeNo, + EmployeeNumber = deleteResult.EmployeeNumber, + EmployeeName = GetEmployeeName(employeeNames, deleteResult.EmployeeNumber), UserStatus = "Deleted", - OverallResult = "SUCCESS" + FaceStatus = "Not applicable", + VerificationStatus = "Not requested", + OverallResult = "SUCCESS", + Reason = "User deleted successfully" }); } else { + AppLogger.Warning($"[USER_DELETE] employee={deleteResult.EmployeeNumber} result=FAILED reason={deleteResult.Result.Reason}"); results.Add(new EmployeeSyncResult { - EmployeeNumber = employeeNo, + EmployeeNumber = deleteResult.EmployeeNumber, + EmployeeName = GetEmployeeName(employeeNames, deleteResult.EmployeeNumber), UserStatus = "Failed", + FaceStatus = "Not applicable", + VerificationStatus = "Not requested", OverallResult = "FAILED", - Reason = batchResult.Reason + Reason = string.IsNullOrWhiteSpace(deleteResult.Result.Reason) + ? "Device rejected the delete request." + : deleteResult.Result.Reason }); } } @@ -170,16 +245,70 @@ public sealed class UserManagementService( entry.Failed = results.Count(r => r.OverallResult == "FAILED"); entry.Status = entry.Failed > 0 ? "Completed with errors" : "Completed"; await history.UpdateAsync(entry, cancellationToken); + AppLogger.TraceExit("USER_DELETE", nameof(DeleteUsersAsync), + $"success={entry.Success} failed={entry.Failed}"); return results; } + private async Task> LoadEmployeeNamesAsync(string machineId, IReadOnlyList employeeNumbers, CancellationToken cancellationToken) + { + var names = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var machineRows = await machineUsers.GetActiveByMachineAsync(machineId, cancellationToken); + foreach (var row in machineRows) + { + if (employeeNumbers.Contains(row.SerialNumber, StringComparer.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(row.EmployeeName)) + names[row.SerialNumber] = row.EmployeeName; + } + } + catch (Exception ex) + { + AppLogger.Warning("User delete: unable to load local machine-user names. " + ex.Message); + } + + foreach (var employeeNumber in employeeNumbers.Where(employeeNumber => !names.ContainsKey(employeeNumber))) + { + try + { + var employee = await hrms.FindBySerialNumberAsync(employeeNumber, cancellationToken); + if (employee is not null && !string.IsNullOrWhiteSpace(employee.Name)) + names[employeeNumber] = employee.Name; + } + catch (Exception ex) + { + AppLogger.Warning($"User delete: unable to load HRMS employee name for {employeeNumber}. {ex.Message}"); + } + } + + return names; + } + + private static string GetEmployeeName(IReadOnlyDictionary employeeNames, string employeeNumber) => + employeeNames.TryGetValue(employeeNumber, out var employeeName) ? employeeName : ""; + + private static void ApplyEnrollmentOutcome( + EmployeeSyncResult result, + bool userOnDevice, + bool faceEnrolled, + string faceStatus, + string verificationStatus, + string? failureReason) => + FaceEnrollmentResults.Apply(result, userOnDevice, faceEnrolled, faceStatus, verificationStatus, failureReason); + private async Task CompleteHistory(SyncHistoryEntry entry, EmployeeSyncResult result, CancellationToken cancellationToken) { entry.CompletedAt = DateTime.Now; entry.Success = result.OverallResult is "SUCCESS" or "ALREADY_EXISTS" ? 1 : 0; - entry.Failed = result.OverallResult == "FAILED" ? 1 : 0; + entry.Failed = result.OverallResult is "FAILED" or "PARTIAL" ? 1 : 0; entry.Skipped = result.OverallResult == "SKIPPED" ? 1 : 0; - entry.Status = result.OverallResult == "FAILED" ? "Failed" : "Completed"; + entry.Status = result.OverallResult switch + { + "FAILED" => "Failed", + "PARTIAL" => "Completed with errors", + _ => "Completed" + }; await history.UpdateAsync(entry, cancellationToken); } }