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.main
parent
c4bd561811
commit
01ef651965
|
|
@ -6,6 +6,7 @@ public sealed class UserManagementService(
|
||||||
HikvisionIsapiClient hikvision,
|
HikvisionIsapiClient hikvision,
|
||||||
EmployeePhotoService photos,
|
EmployeePhotoService photos,
|
||||||
AttendanceMachineUserRepository machineUsers,
|
AttendanceMachineUserRepository machineUsers,
|
||||||
|
HrmsEmployeeService hrms,
|
||||||
OperationHistoryService history)
|
OperationHistoryService history)
|
||||||
{
|
{
|
||||||
public static EmployeeSyncResult ValidateEmployee(HrmsEmployee employee, long siteId)
|
public static EmployeeSyncResult ValidateEmployee(HrmsEmployee employee, long siteId)
|
||||||
|
|
@ -28,8 +29,15 @@ public sealed class UserManagementService(
|
||||||
public async Task<EmployeeSyncResult> CreateUserWithFaceAsync(Device device, HrmsEmployee employee, long siteId,
|
public async Task<EmployeeSyncResult> CreateUserWithFaceAsync(Device device, HrmsEmployee employee, long siteId,
|
||||||
IProgress<string>? progress, CancellationToken cancellationToken)
|
IProgress<string>? progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
AppLogger.TraceEnter("USER_CREATE", nameof(CreateUserWithFaceAsync),
|
||||||
|
$"device={device.IpAddress} employee={employee.SerialNumber} siteId={siteId}");
|
||||||
var validation = ValidateEmployee(employee, 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
|
var entry = new SyncHistoryEntry
|
||||||
{
|
{
|
||||||
|
|
@ -49,69 +57,118 @@ public sealed class UserManagementService(
|
||||||
Department = employee.DepartmentName
|
Department = employee.DepartmentName
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var userOnDevice = false;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
progress?.Report("Checking if user already exists…");
|
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.UserStatus = "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.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
|
else
|
||||||
{
|
{
|
||||||
progress?.Report("Creating Hikvision UserInfo…");
|
progress?.Report("Creating Hikvision UserInfo…");
|
||||||
|
AppLogger.TraceEnter("USER_CREATE", "HikvisionIsapiClient.CreateUserAsync", $"employee={employee.SerialNumber}");
|
||||||
var create = await hikvision.CreateUserAsync(device, employee, cancellationToken);
|
var create = await hikvision.CreateUserAsync(device, employee, cancellationToken);
|
||||||
|
AppLogger.TraceExit("USER_CREATE", "HikvisionIsapiClient.CreateUserAsync",
|
||||||
|
$"employee={employee.SerialNumber} success={create.Success}");
|
||||||
if (!create.Success)
|
if (!create.Success)
|
||||||
{
|
{
|
||||||
result.UserStatus = "Failed";
|
result.UserStatus = "Failed";
|
||||||
|
result.UserCreated = false;
|
||||||
result.OverallResult = "FAILED";
|
result.OverallResult = "FAILED";
|
||||||
result.Reason = create.Reason;
|
result.Reason = create.Reason;
|
||||||
await CompleteHistory(entry, result, cancellationToken);
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync),
|
||||||
|
$"employee={employee.SerialNumber} result=FAILED reason={result.Reason}");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userOnDevice = true;
|
||||||
|
result.UserCreated = true;
|
||||||
result.UserStatus = "Created";
|
result.UserStatus = "Created";
|
||||||
}
|
}
|
||||||
|
|
||||||
progress?.Report("Downloading employee photo…");
|
progress?.Report("Downloading employee photo…");
|
||||||
|
AppLogger.TraceEnter("USER_CREATE", "EmployeePhotoService.DownloadAndNormalizeAsync", $"employee={employee.SerialNumber}");
|
||||||
var photo = await photos.DownloadAndNormalizeAsync(employee, cancellationToken);
|
var photo = await photos.DownloadAndNormalizeAsync(employee, cancellationToken);
|
||||||
|
AppLogger.TraceExit("USER_CREATE", "EmployeePhotoService.DownloadAndNormalizeAsync",
|
||||||
|
$"employee={employee.SerialNumber} success={photo.Success}");
|
||||||
if (!photo.Success)
|
if (!photo.Success)
|
||||||
{
|
{
|
||||||
result.FaceStatus = "Failed";
|
ApplyEnrollmentOutcome(result, userOnDevice, faceEnrolled: false, faceStatus: "Failed", verificationStatus: "Not requested",
|
||||||
result.OverallResult = "FAILED";
|
failureReason: photo.Reason);
|
||||||
result.Reason = photo.Reason;
|
|
||||||
await CompleteHistory(entry, result, cancellationToken);
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync),
|
||||||
|
$"employee={employee.SerialNumber} result={result.OverallResult} reason={result.Reason}");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
progress?.Report("Uploading face…");
|
progress?.Report("Uploading face…");
|
||||||
|
AppLogger.TraceEnter("USER_CREATE", "HikvisionIsapiClient.UploadFaceAsync", $"employee={employee.SerialNumber}");
|
||||||
var face = await hikvision.UploadFaceAsync(device, employee.SerialNumber, photo.Bytes, cancellationToken);
|
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)
|
if (!face.Success)
|
||||||
{
|
{
|
||||||
result.FaceStatus = "Failed";
|
ApplyEnrollmentOutcome(result, userOnDevice, faceEnrolled: false, faceStatus: "Failed", verificationStatus: "Not requested",
|
||||||
result.OverallResult = "FAILED";
|
failureReason: face.Reason);
|
||||||
result.Reason = face.Reason;
|
|
||||||
await CompleteHistory(entry, result, cancellationToken);
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync),
|
||||||
|
$"employee={employee.SerialNumber} result={result.OverallResult} reason={result.Reason}");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
result.FaceStatus = "Uploaded";
|
result.FaceStatus = "Uploaded";
|
||||||
|
|
||||||
progress?.Report("Verifying face enrollment…");
|
progress?.Report("Verifying face enrollment…");
|
||||||
|
AppLogger.TraceEnter("USER_CREATE", "HikvisionIsapiClient.VerifyFaceAsync", $"employee={employee.SerialNumber}");
|
||||||
var verify = await hikvision.VerifyFaceAsync(device, employee.SerialNumber, cancellationToken);
|
var verify = await hikvision.VerifyFaceAsync(device, employee.SerialNumber, cancellationToken);
|
||||||
result.VerificationStatus = verify.Success ? "Verified" : "Failed";
|
AppLogger.TraceExit("USER_CREATE", "HikvisionIsapiClient.VerifyFaceAsync",
|
||||||
result.OverallResult = verify.Success ? "SUCCESS" : "FAILED";
|
$"employee={employee.SerialNumber} success={verify.Success}");
|
||||||
result.Reason = verify.Reason;
|
ApplyEnrollmentOutcome(result, userOnDevice, verify.Success, faceStatus: result.FaceStatus,
|
||||||
|
verificationStatus: verify.Success ? "Verified" : "Failed", failureReason: verify.Reason);
|
||||||
|
|
||||||
if (verify.Success)
|
if (verify.Success)
|
||||||
await machineUsers.UpsertAsync(device.MachineId, employee.SerialNumber, employee.Name, cancellationToken);
|
await machineUsers.UpsertAsync(device.MachineId, employee.SerialNumber, employee.Name, cancellationToken);
|
||||||
|
|
||||||
await CompleteHistory(entry, result, cancellationToken);
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync),
|
||||||
|
$"employee={employee.SerialNumber} result={result.OverallResult} faceEnrolled={result.FaceEnrolled}");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
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";
|
result.OverallResult = "FAILED";
|
||||||
result.Reason = ex is InvalidOperationException ? ex.Message : "Device unavailable or operation failed.";
|
|
||||||
await CompleteHistory(entry, result, cancellationToken);
|
await CompleteHistory(entry, result, cancellationToken);
|
||||||
|
AppLogger.TraceExit("USER_CREATE", nameof(CreateUserWithFaceAsync),
|
||||||
|
$"employee={employee.SerialNumber} result={result.OverallResult} reason={result.Reason}");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +177,8 @@ public sealed class UserManagementService(
|
||||||
IProgress<string>? progress, CancellationToken cancellationToken)
|
IProgress<string>? progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var normalized = employeeNumbers.Where(e => !string.IsNullOrWhiteSpace(e)).Select(e => e.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
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
|
var entry = new SyncHistoryEntry
|
||||||
{
|
{
|
||||||
Operation = SyncOperationKind.UserDeletion,
|
Operation = SyncOperationKind.UserDeletion,
|
||||||
|
|
@ -140,27 +199,43 @@ public sealed class UserManagementService(
|
||||||
}
|
}
|
||||||
|
|
||||||
progress?.Report("Deleting users on device…");
|
progress?.Report("Deleting users on device…");
|
||||||
var batchResult = await hikvision.DeleteUsersAsync(device, normalized, cancellationToken);
|
var employeeNames = await LoadEmployeeNamesAsync(device.MachineId, normalized, cancellationToken);
|
||||||
foreach (var employeeNo in normalized)
|
|
||||||
|
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
|
results.Add(new EmployeeSyncResult
|
||||||
{
|
{
|
||||||
EmployeeNumber = employeeNo,
|
EmployeeNumber = deleteResult.EmployeeNumber,
|
||||||
|
EmployeeName = GetEmployeeName(employeeNames, deleteResult.EmployeeNumber),
|
||||||
UserStatus = "Deleted",
|
UserStatus = "Deleted",
|
||||||
OverallResult = "SUCCESS"
|
FaceStatus = "Not applicable",
|
||||||
|
VerificationStatus = "Not requested",
|
||||||
|
OverallResult = "SUCCESS",
|
||||||
|
Reason = "User deleted successfully"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
AppLogger.Warning($"[USER_DELETE] employee={deleteResult.EmployeeNumber} result=FAILED reason={deleteResult.Result.Reason}");
|
||||||
results.Add(new EmployeeSyncResult
|
results.Add(new EmployeeSyncResult
|
||||||
{
|
{
|
||||||
EmployeeNumber = employeeNo,
|
EmployeeNumber = deleteResult.EmployeeNumber,
|
||||||
|
EmployeeName = GetEmployeeName(employeeNames, deleteResult.EmployeeNumber),
|
||||||
UserStatus = "Failed",
|
UserStatus = "Failed",
|
||||||
|
FaceStatus = "Not applicable",
|
||||||
|
VerificationStatus = "Not requested",
|
||||||
OverallResult = "FAILED",
|
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.Failed = results.Count(r => r.OverallResult == "FAILED");
|
||||||
entry.Status = entry.Failed > 0 ? "Completed with errors" : "Completed";
|
entry.Status = entry.Failed > 0 ? "Completed with errors" : "Completed";
|
||||||
await history.UpdateAsync(entry, cancellationToken);
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
|
AppLogger.TraceExit("USER_DELETE", nameof(DeleteUsersAsync),
|
||||||
|
$"success={entry.Success} failed={entry.Failed}");
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<string, string>> LoadEmployeeNamesAsync(string machineId, IReadOnlyList<string> employeeNumbers, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var names = new Dictionary<string, string>(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<string, string> 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)
|
private async Task CompleteHistory(SyncHistoryEntry entry, EmployeeSyncResult result, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
entry.CompletedAt = DateTime.Now;
|
entry.CompletedAt = DateTime.Now;
|
||||||
entry.Success = result.OverallResult is "SUCCESS" or "ALREADY_EXISTS" ? 1 : 0;
|
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.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);
|
await history.UpdateAsync(entry, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue