From 3ce91f4d76cef189816b3f2b5b83dff19a03fecf Mon Sep 17 00:00:00 2001 From: "mustafa.ahmed" Date: Sat, 15 Aug 2026 15:58:30 +0500 Subject: [PATCH] feat: apply photo preprocessing, employee allow-list, and user-sync logging to initial sync Wire the initial department sync to the new photo processor: downloaded JPEGs are face-cropped/normalized before FaceDataRecord, and photos with no detectable face are skipped and logged as FACE_NOT_DETECTED. Add an optional InitialSyncEmployeeIds allow-list so only selected employees within the chosen departments are enrolled (empty = whole department), filtered directly in the SQL query with parameters. Emit business entries to logs/user_sync_logs for provisioned users ("added successfully", "face enrolled successfully", readable failure reasons) so onboarded users appear alongside the existing deletion entries instead of only in internal_logs. --- HikvisionAttendanceManager.InitialSync.cs | 161 +++++++++++++++++++++- 1 file changed, 158 insertions(+), 3 deletions(-) diff --git a/HikvisionAttendanceManager.InitialSync.cs b/HikvisionAttendanceManager.InitialSync.cs index 9d81d43..ded0f14 100644 --- a/HikvisionAttendanceManager.InitialSync.cs +++ b/HikvisionAttendanceManager.InitialSync.cs @@ -45,11 +45,24 @@ internal sealed partial class HikvisionAttendanceManager return; } - var employees = LoadEmployeesForInitialDepartmentSync(deptIds, out var loadErr); + var employeeIdFilter = (_config.InitialSyncEmployeeIds ?? new List()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var employees = LoadEmployeesForInitialDepartmentSync(deptIds, employeeIdFilter, out var loadErr); if (!string.IsNullOrWhiteSpace(loadErr)) _logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr); + if (employeeIdFilter.Count > 0) + _logger.Ops("INITIAL_SYNC", "Employee filter: departmentIds=" + string.Join(",", deptIds) + + " allowListCount=" + employeeIdFilter.Count + + " allowList=" + string.Join(",", employeeIdFilter) + + " matched=" + employees.Count); + _logger.Ops("INITIAL_SYNC", "departmentIds=" + string.Join(",", deptIds) + + " employeeFilter=" + (employeeIdFilter.Count > 0 ? "selective(" + employeeIdFilter.Count + ")" : "whole-department") + " employeesFound=" + employees.Count); if (employees.Count == 0) @@ -71,9 +84,19 @@ internal sealed partial class HikvisionAttendanceManager _logger.OpsWarn("INITIAL_SYNC", "target=" + (target.DeviceId ?? "") + " ip=" + (target.Ip ?? "") + " SKIPPED reason=\"target offline\""); + _logger.Biz(BizChannel.UserSync, + "MACHINE " + (target.DeviceId ?? ""), + "", + "Machine is not connected.", + ""); + _logger.BizSeparator(BizChannel.UserSync); continue; } + _logger.Biz(BizChannel.UserSync, + "MACHINE " + (target.DeviceId ?? "") + " -> Total Users : " + employees.Count, + ""); + var existing = FetchAllUsersIsapiForSync(target, maxRetries, ct); var existingNos = new HashSet(existing.Select(u => u.EmployeeNo), StringComparer.OrdinalIgnoreCase); var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>(); @@ -145,7 +168,14 @@ internal sealed partial class HikvisionAttendanceManager continue; } - if (!UploadFaceOnTarget(target, employeeNo, jpegBytes, maxRetries, ct, out var upErr)) + if (!TryPrepareInitialSyncFaceJpeg(employeeNo, jpegBytes, out var uploadBytes)) + { + reason = "FACE_NOT_DETECTED"; + faceRejectedEmployees.Add((employeeNo, reason)); + continue; + } + + if (!UploadFaceOnTarget(target, employeeNo, uploadBytes, maxRetries, ct, out var upErr)) { reason = string.IsNullOrWhiteSpace(upErr) ? "face_upload_failed" : upErr; faceRejectedEmployees.Add((employeeNo, reason)); @@ -156,6 +186,8 @@ internal sealed partial class HikvisionAttendanceManager } finally { + WriteInitialSyncEmployeeBizLog(target, employeeNo, userCreated, faceUploaded, reason); + if (photoDownloaded && faceUploaded) { _logger.Ops("INITIAL_SYNC", @@ -190,9 +222,111 @@ internal sealed partial class HikvisionAttendanceManager } WriteFaceRejectedEmployeeSummary("INITIAL_SYNC", target, faceRejectedEmployees); + _logger.BizSeparator(BizChannel.UserSync); } } + /// + /// Mirrors the DB→device sync wording in logs/user_sync_logs so provisioned users are visible + /// alongside the "removed successfully" entries, not only in internal_logs. + /// + private void WriteInitialSyncEmployeeBizLog( + HikvisionAttendanceWindowsService.DeviceConfig target, + string employeeNo, + bool userCreated, + bool faceUploaded, + string reason) + { + if (userCreated) + { + _logger.Totals.UsersAdded++; + _logger.Biz(BizChannel.UserSync, + "Machine ID : " + (target.DeviceId ?? ""), + "Machine IP : " + (target.Ip ?? ""), + "", + employeeNo + " added successfully.", + ""); + } + + if (faceUploaded) + { + _logger.Totals.TemplatesSaved++; + _logger.Biz(BizChannel.UserSync, employeeNo + " face enrolled successfully.", ""); + return; + } + + _logger.Totals.TemplatesFailed++; + _logger.Biz(BizChannel.UserSync, + employeeNo + " face enrollment failed.", + "", + "Reason :", + "", + DescribeInitialSyncFailure(reason), + ""); + } + + private static string DescribeInitialSyncFailure(string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + return "Unknown error."; + + switch (reason) + { + case "FACE_NOT_DETECTED": + return "No face detected in the employee photo."; + case "photo_not_found": + return "Employee photo not found on the HRMS portal."; + case "invalid_jpeg": + return "Employee photo is not a valid JPEG."; + case "photo_source_disabled": + return "Employee photo source is disabled."; + case "missing_employee_id": + return "Employee has no HRMS id, photo URL cannot be built."; + case "user_create_failed": + return "User could not be created on the device."; + default: + return reason; + } + } + + /// + /// Initial-sync only: crops/normalizes the HRMS photo around the detected face before FaceDataRecord. + /// Returns false only when no face is found; decode/encode problems fall back to the raw portal JPEG. + /// + private bool TryPrepareInitialSyncFaceJpeg(string employeeNo, byte[] downloadedJpeg, out byte[] uploadBytes) + { + uploadBytes = downloadedJpeg; + var processed = EmployeePhotoFaceProcessor.Process(downloadedJpeg); + + if (processed.Status == EmployeePhotoFaceProcessor.PhotoStatus.NoFace) + { + _logger.OpsWarn("INITIAL_SYNC", + "FACE_NOT_DETECTED employeeNo=" + employeeNo + + " original=" + processed.Original.Width + "x" + processed.Original.Height + + " uploadSkipped=true"); + return false; + } + + if (processed.Status != EmployeePhotoFaceProcessor.PhotoStatus.Ok) + { + _logger.OpsWarn("INITIAL_SYNC", + "PHOTO_PROCESS_FAILED employeeNo=" + employeeNo + + " err=" + processed.Error + + " fallback=original-jpeg bytes=" + downloadedJpeg.Length); + return true; + } + + _logger.Ops("INITIAL_SYNC", "PHOTO_PROCESSED employeeNo=" + employeeNo + " " + processed.Describe()); + _logger.Diag("INITIAL_SYNC", + "PHOTO_PROCESSED employeeNo=" + employeeNo + " " + processed.Describe() + + " jpegQuality=" + processed.JpegQuality + + " downloadedBytes=" + downloadedJpeg.Length + + " enhance[" + processed.Enhancement + "]"); + + uploadBytes = processed.JpegBytes; + return true; + } + private List ResolveInitialSyncTargets() { var targets = new List(); @@ -222,7 +356,10 @@ internal sealed partial class HikvisionAttendanceManager return targets; } - private List LoadEmployeesForInitialDepartmentSync(IReadOnlyList departmentIds, out string error) + private List LoadEmployeesForInitialDepartmentSync( + IReadOnlyList departmentIds, + IReadOnlyList employeeIdAllowList, + out string error) { error = ""; var result = new List(); @@ -237,6 +374,8 @@ internal sealed partial class HikvisionAttendanceManager if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error)) return result; + var hasAllowList = employeeIdAllowList != null && employeeIdAllowList.Count > 0; + try { using var conn = new MySqlConnection(cs); @@ -249,9 +388,25 @@ internal sealed partial class HikvisionAttendanceManager } sql.Append(')'); + if (hasAllowList) + { + sql.Append(" AND serial_number IN ("); + for (int i = 0; i < employeeIdAllowList.Count; i++) + { + if (i > 0) sql.Append(','); + sql.Append("@e").Append(i); + } + sql.Append(')'); + } + using var cmd = new MySqlCommand(sql.ToString(), conn); for (int i = 0; i < departmentIds.Count; i++) cmd.Parameters.AddWithValue("@d" + i, departmentIds[i]); + if (hasAllowList) + { + for (int i = 0; i < employeeIdAllowList.Count; i++) + cmd.Parameters.AddWithValue("@e" + i, employeeIdAllowList[i]); + } using var rd = cmd.ExecuteReader(); var seenSerial = new HashSet(StringComparer.OrdinalIgnoreCase);