diff --git a/DeviceSettings.config b/DeviceSettings.config index b8e0688..49268e1 100644 --- a/DeviceSettings.config +++ b/DeviceSettings.config @@ -1,20 +1,24 @@ { "MachineScopeMode": "SITE", "ScopedMachineIps": [ - "192.168.90.226" + "192.168.91.80" ], "EnableTemplateDeviceToDbSync": false, "EnableTemplateDbToDeviceSync": false, - "SourceMachineIp": "192.168.90.226", + "SourceMachineIp": "", "TargetMachineIps": [ - "192.168.90.226" + "192.168.91.80" ], - "SyncEmployeeIds": [ - "15399", - "17003", - "15111", - ] + "SyncEmployeeIds": [], + "SyncDepartmentIds": [], + + "EnableInitialDepartmentSync": true, + "InitialSyncDepartmentIds": [ + "357" + ], + "EnableEmployeePhotoSource": true, + "EmployeePhotoBaseUrl": "https://portal.utopiaindustries.pk/uind/employee-photo/" } diff --git a/HikvisionAttendanceManager.InitialSync.cs b/HikvisionAttendanceManager.InitialSync.cs new file mode 100644 index 0000000..9d81d43 --- /dev/null +++ b/HikvisionAttendanceManager.InitialSync.cs @@ -0,0 +1,339 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using HikvisionAttendanceService.Data; +using MySql.Data.MySqlClient; + +namespace HikvisionAttendanceService; + +/// +/// Separate onboarding path: HRMS department employees + portal JPEG photos → new Hikvision device. +/// Does not read or write attendance_machine_face_templates (existing DB↔device flows stay unchanged). +/// +internal sealed partial class HikvisionAttendanceManager +{ + /// + /// Provisions users/faces onto TargetMachineIps from InitialSyncDepartmentIds + employee-photo URL. + /// Gated by EnableInitialDepartmentSync only. + /// + public void RunInitialDepartmentSyncCycle(CancellationToken ct) + { + if (!_config.EnableInitialDepartmentSync) + return; + + ct.ThrowIfCancellationRequested(); + + var deptIds = (_config.InitialSyncDepartmentIds ?? new List()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (deptIds.Count == 0) + { + _logger.OpsWarn("INITIAL_SYNC", "skipped — InitialSyncDepartmentIds is empty"); + return; + } + + var targets = ResolveInitialSyncTargets(); + if (targets.Count == 0) + { + _logger.OpsWarn("INITIAL_SYNC", "skipped — no valid TargetMachineIps"); + return; + } + + var employees = LoadEmployeesForInitialDepartmentSync(deptIds, out var loadErr); + if (!string.IsNullOrWhiteSpace(loadErr)) + _logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr); + + _logger.Ops("INITIAL_SYNC", "departmentIds=" + string.Join(",", deptIds) + + " employeesFound=" + employees.Count); + + if (employees.Count == 0) + return; + + var maxRetries = Math.Max(1, SyncPol.HttpMaxRetries); + var photoBase = (_config.EmployeePhotoBaseUrl ?? "").Trim(); + if (!string.IsNullOrWhiteSpace(photoBase) && !photoBase.EndsWith("/", StringComparison.Ordinal)) + photoBase += "/"; + + foreach (var target in targets) + { + ct.ThrowIfCancellationRequested(); + var hasSession = _sessions.Any(s => + string.Equals((s.Device.Ip ?? "").Trim(), (target.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) || + DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == DeviceIdentity.CanonicalLookupKey(target.DeviceId)); + if (!hasSession) + { + _logger.OpsWarn("INITIAL_SYNC", + "target=" + (target.DeviceId ?? "") + " ip=" + (target.Ip ?? "") + + " SKIPPED reason=\"target offline\""); + continue; + } + + 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)>(); + + foreach (var emp in employees) + { + ct.ThrowIfCancellationRequested(); + var employeeNo = emp.SerialNumber; + var employeeId = emp.Id; + var photoUrl = ""; + var photoDownloaded = false; + var userCreated = false; + var faceUploaded = false; + var reason = ""; + + try + { + if (!existingNos.Contains(employeeNo)) + { + var dto = new UserDto + { + EmployeeNo = employeeNo, + Name = emp.Name ?? "", + NumOfFace = 0, + NumOfFp = 0 + }; + if (!CreateUserOnTarget(target, dto, maxRetries, ct, out var createErr)) + { + reason = string.IsNullOrWhiteSpace(createErr) ? "user_create_failed" : createErr; + continue; + } + + userCreated = true; + existingNos.Add(employeeNo); + if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null) + { + _attendanceMachineUserRepository.UpsertMachineUser( + target.DeviceId ?? "", + employeeNo, + emp.Name ?? "", + "hikvision-service-initial", + out _); + } + } + + if (!_config.EnableEmployeePhotoSource || string.IsNullOrWhiteSpace(photoBase)) + { + reason = "photo_source_disabled"; + continue; + } + + if (string.IsNullOrWhiteSpace(employeeId)) + { + reason = "missing_employee_id"; + continue; + } + + photoUrl = photoBase + employeeId.Trim() + ".jpeg"; + if (!TryDownloadEmployeePortalPhoto(photoUrl, out var jpegBytes, out var photoErr)) + { + reason = string.IsNullOrWhiteSpace(photoErr) ? "photo_not_found" : photoErr; + continue; + } + + photoDownloaded = true; + if (!IsJpegMagic(jpegBytes)) + { + reason = "invalid_jpeg"; + continue; + } + + if (!UploadFaceOnTarget(target, employeeNo, jpegBytes, maxRetries, ct, out var upErr)) + { + reason = string.IsNullOrWhiteSpace(upErr) ? "face_upload_failed" : upErr; + faceRejectedEmployees.Add((employeeNo, reason)); + continue; + } + + faceUploaded = true; + } + finally + { + if (photoDownloaded && faceUploaded) + { + _logger.Ops("INITIAL_SYNC", + "employeeNo=" + employeeNo + + " employeeId=" + employeeId + + " photoUrl=" + photoUrl + + " photoDownloaded=true" + + " userCreated=" + (userCreated ? "true" : "false") + + " faceUploaded=true"); + } + else if (!photoDownloaded) + { + _logger.Ops("INITIAL_SYNC", + "employeeNo=" + employeeNo + + (string.IsNullOrWhiteSpace(employeeId) ? "" : (" employeeId=" + employeeId)) + + (string.IsNullOrWhiteSpace(photoUrl) ? "" : (" photoUrl=" + photoUrl)) + + " photoDownloaded=false" + + " reason=" + (string.IsNullOrWhiteSpace(reason) ? "photo_not_found" : reason)); + } + else + { + _logger.Ops("INITIAL_SYNC", + "employeeNo=" + employeeNo + + " employeeId=" + employeeId + + " photoUrl=" + photoUrl + + " photoDownloaded=true" + + " userCreated=" + (userCreated ? "true" : "false") + + " faceUploaded=false" + + " reason=" + (string.IsNullOrWhiteSpace(reason) ? "face_upload_failed" : reason)); + } + } + } + + WriteFaceRejectedEmployeeSummary("INITIAL_SYNC", target, faceRejectedEmployees); + } + } + + private List ResolveInitialSyncTargets() + { + var targets = new List(); + foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty()) + { + var t = ResolveDeviceConfigByIp(targetIp); + if (t == null || string.IsNullOrWhiteSpace(t.Ip)) + { + _logger.OpsWarn("INITIAL_SYNC", "target skipped — not found or IP empty. TargetMachineIp=\"" + targetIp + "\""); + continue; + } + if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase))) + continue; + targets.Add(t); + } + + foreach (var tid in _config.TargetDeviceIds ?? Enumerable.Empty()) + { + var t = ResolveDeviceConfig(tid); + if (t == null || string.IsNullOrWhiteSpace(t.Ip)) + continue; + if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase))) + continue; + targets.Add(t); + } + + return targets; + } + + private List LoadEmployeesForInitialDepartmentSync(IReadOnlyList departmentIds, out string error) + { + error = ""; + var result = new List(); + if (departmentIds == null || departmentIds.Count == 0) + return result; + if (!_config.EnableDbIntegration || _dbConnectionFactory == null) + { + error = "DB integration not available for initial department sync."; + return result; + } + + if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error)) + return result; + + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + var sql = new StringBuilder("SELECT id, serial_number FROM employee WHERE department_id IN ("); + for (int i = 0; i < departmentIds.Count; i++) + { + if (i > 0) sql.Append(','); + sql.Append("@d").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]); + + using var rd = cmd.ExecuteReader(); + var seenSerial = new HashSet(StringComparer.OrdinalIgnoreCase); + while (rd.Read()) + { + var serial = rd["serial_number"]?.ToString()?.Trim() ?? ""; + if (serial.Length == 0 || !seenSerial.Add(serial)) + continue; + result.Add(new InitialSyncEmployee + { + Id = rd["id"]?.ToString()?.Trim() ?? "", + SerialNumber = serial, + Name = "" + }); + } + } + catch (Exception ex) + { + error = ex.Message; + } + + return result; + } + + private static bool TryDownloadEmployeePortalPhoto(string photoUrl, out byte[] bytes, out string error) + { + bytes = Array.Empty(); + error = ""; + if (string.IsNullOrWhiteSpace(photoUrl)) + { + error = "photo_not_found"; + return false; + } + + try + { + using var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }; + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) }; + using var response = client.GetAsync(photoUrl).GetAwaiter().GetResult(); + if (response.StatusCode == HttpStatusCode.NotFound) + { + error = "photo_not_found"; + return false; + } + + if (!response.IsSuccessStatusCode) + { + error = "photo_http_" + (int)response.StatusCode; + return false; + } + + bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() ?? Array.Empty(); + if (bytes.Length == 0) + { + error = "photo_not_found"; + return false; + } + + return true; + } + catch (Exception ex) + { + error = "photo_download_failed:" + ex.Message; + return false; + } + } + + private static bool IsJpegMagic(byte[] bytes) => + bytes != null && + bytes.Length >= 3 && + bytes[0] == 0xFF && + bytes[1] == 0xD8 && + bytes[2] == 0xFF; + + private sealed class InitialSyncEmployee + { + public string Id { get; set; } = ""; + public string SerialNumber { get; set; } = ""; + public string Name { get; set; } = ""; + } +} diff --git a/HikvisionAttendanceManager.UserSync.cs b/HikvisionAttendanceManager.UserSync.cs index a792b1d..7a077c6 100644 --- a/HikvisionAttendanceManager.UserSync.cs +++ b/HikvisionAttendanceManager.UserSync.cs @@ -11,12 +11,16 @@ using System.Threading; using System.Threading.Tasks; using System.Web.Script.Serialization; using HikvisionAttendanceService.Data; +using MySql.Data.MySqlClient; namespace HikvisionAttendanceService; /// Multi-device user + face sync via ISAPI HTTP (Digest). Orchestration and focused helpers. internal sealed partial class HikvisionAttendanceManager { + private readonly Dictionary> _faceLibCacheByDeviceIp = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + private HikvisionAttendanceWindowsService.UserSyncPoliciesConfig SyncPol => _config.SyncPolicies ?? new HikvisionAttendanceWindowsService.UserSyncPoliciesConfig(); @@ -62,10 +66,23 @@ internal sealed partial class HikvisionAttendanceManager var pol = SyncPol; int maxRetries = pol.HttpMaxRetries < 1 ? 1 : pol.HttpMaxRetries; + // Merge SyncEmployeeIds + serial_numbers from SyncDepartmentIds (deduped). + var requestedEmployeeIds = BuildMergedSyncEmployeeIds(out var departmentIdsUsed, out var departmentEmployeesFound); + if (departmentIdsUsed.Count > 0) + { + _logger.Ops("USER_SYNC", "Department filter: departmentIds=" + string.Join(",", departmentIdsUsed) + + " employeesFound=" + departmentEmployeesFound); + } + + var hasExplicitEmployeeFilter = requestedEmployeeIds.Count > 0; + // DB templates already in attendance_machine_face_templates do not need a source device. + var dbOnlyRestore = _config.EnableTemplateDbToDeviceSync && hasExplicitEmployeeFilter; + var source = !string.IsNullOrWhiteSpace(_config.SourceMachineIp) ? ResolveDeviceConfigByIp(_config.SourceMachineIp) : ResolveDeviceConfig(_config.SourceDeviceId ?? ""); - if (source == null || string.IsNullOrWhiteSpace(source.Ip)) + var hasSource = source != null && !string.IsNullOrWhiteSpace(source.Ip); + if (!hasSource && !dbOnlyRestore) { _logger.Warn("UserSync: skipped — source device not found or IP empty. SourceDeviceId=\"" + (_config.SourceDeviceId ?? "") + "\" SourceMachineIp=\"" + (_config.SourceMachineIp ?? "") + "\"."); @@ -84,7 +101,8 @@ internal sealed partial class HikvisionAttendanceManager if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase))) continue; // Same IP allowed for DB->device restore (user deleted on device, restore from DB). - if (string.Equals((t.Ip ?? "").Trim(), (source.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) && + if (hasSource && + string.Equals((t.Ip ?? "").Trim(), (source!.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) && !_config.EnableTemplateDbToDeviceSync) { _logger.Diag("user_sync", "target skipped — same as source IP and DB->device restore disabled. TargetMachineIp=\"" + targetIp + "\"."); @@ -101,7 +119,8 @@ internal sealed partial class HikvisionAttendanceManager continue; } - if (DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source.DeviceId)) + if (hasSource && + DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source!.DeviceId)) { if (!_config.EnableTemplateDbToDeviceSync) { @@ -123,21 +142,24 @@ internal sealed partial class HikvisionAttendanceManager } var cycleId = DateTime.UtcNow.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture); + var sourceDeviceLabel = hasSource ? (source!.DeviceId ?? "") : "(none-db-restore)"; + var sourceIpLabel = hasSource ? (source!.Ip ?? "") : ""; _logger.Info("USER_SYNC cycle begin id=" + cycleId + ". Meaning: one full restore/sync pass from source to target(s)." + - " sourceDevice=" + source.DeviceId + " sourceIp=" + (source.Ip ?? "") + + " sourceDevice=" + sourceDeviceLabel + " sourceIp=" + sourceIpLabel + " targetCount=" + targets.Count + " UpdateExistingUserFields=" + pol.UpdateExistingUserFields + " UploadFaceIfMissingOnly=" + pol.UploadFaceIfMissingOnly + " DeleteOnTargetIfMissingInSource=" + pol.DeleteOnTargetIfMissingInSource); _logger.JobInfo("user_sync", "Cycle begin id=" + cycleId + - ". Meaning: push users/faces to targets. source=" + source.DeviceId + - " sourceIp=" + (source.Ip ?? "") + " targets=" + targets.Count); + ". Meaning: push users/faces to targets. source=" + sourceDeviceLabel + + " sourceIp=" + sourceIpLabel + " targets=" + targets.Count); - var sourceUsers = LoadSourceUsersForSync(source, maxRetries, ct); - if ((_config.SyncEmployeeIds ?? new List()).Count > 0) + var sourceUsers = hasSource + ? LoadSourceUsersForSync(source!, maxRetries, ct) + : new List(); + if (hasExplicitEmployeeFilter) { - var requestedEmployeeIds = new HashSet(_config.SyncEmployeeIds.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase); sourceUsers = sourceUsers.Where(x => requestedEmployeeIds.Contains(x.EmployeeNo)).ToList(); // Same-machine restore: employee may already be gone from device/user list; // synthesize missing IDs and restore from DB face templates. @@ -157,13 +179,14 @@ internal sealed partial class HikvisionAttendanceManager }); } } + _logger.Ops("USER_SYNC", "Final employee sync count=" + sourceUsers.Count); _logger.JobInfo("user_sync", "Employee filter: requested=" + requestedEmployeeIds.Count + " willSync=" + sourceUsers.Count + - ". Meaning: only these employee IDs from SyncEmployeeIds are restored/synced this cycle."); + ". Meaning: SyncEmployeeIds + SyncDepartmentIds (deduped) drive this cycle."); } - _logger.Info("USER_SYNC source users ready count=" + sourceUsers.Count + " device=" + source.DeviceId + + _logger.Info("USER_SYNC source users ready count=" + sourceUsers.Count + " device=" + sourceDeviceLabel + ". Meaning: users we will try to create/update on the target."); - _logger.JobInfo("user_sync", "Source users ready count=" + sourceUsers.Count + " device=" + source.DeviceId); + _logger.JobInfo("user_sync", "Source users ready count=" + sourceUsers.Count + " device=" + sourceDeviceLabel); var faceBytesByEmployee = new Dictionary(StringComparer.OrdinalIgnoreCase); int faceDlOk = 0, faceDlFail = 0; @@ -183,13 +206,13 @@ internal sealed partial class HikvisionAttendanceManager _logger.Diag("user_sync", "face loaded from DB employeeNo=" + u.EmployeeNo); continue; } - if (string.IsNullOrWhiteSpace(u.FaceUrl)) + if (!hasSource || string.IsNullOrWhiteSpace(u.FaceUrl)) { faceBytesByEmployee[u.EmployeeNo] = null; continue; } - if (DownloadFaceByUrl(source, u.FaceUrl, maxRetries, ct, out var bytes) && bytes.Length > 0) + if (DownloadFaceByUrl(source!, u.FaceUrl, maxRetries, ct, out var bytes) && bytes.Length > 0) { faceBytesByEmployee[u.EmployeeNo] = bytes; faceDlOk++; @@ -275,6 +298,7 @@ internal sealed partial class HikvisionAttendanceManager int created = 0, faceUp = 0, skipped = 0, failed = 0, updated = 0; var statusCounts = new Dictionary(); + var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>(); void Bump(UserSyncStatus s) { @@ -282,8 +306,8 @@ internal sealed partial class HikvisionAttendanceManager statusCounts[s] = n + 1; } - // Explicit SyncEmployeeIds = restore/push list; do not filter by DB assignment rows. - var explicitEmployeeFilter = (_config.SyncEmployeeIds ?? new List()).Count > 0; + // Explicit SyncEmployeeIds / SyncDepartmentIds = restore/push list; do not filter by DB assignment rows. + var explicitEmployeeFilter = hasExplicitEmployeeFilter; foreach (var srcUser in sourceUsers) { @@ -292,7 +316,12 @@ internal sealed partial class HikvisionAttendanceManager continue; targetMap.TryGetValue(srcUser.EmployeeNo, out var tgtRow); bool createdNew = false; + bool createUserLogged = false; + bool faceUploadedLogged = false; + bool templateFoundLogged = false; + try + { if (tgtRow == null) { if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr)) @@ -305,6 +334,7 @@ internal sealed partial class HikvisionAttendanceManager } created++; + createUserLogged = true; _logger.Totals.UsersAdded++; _logger.Biz(BizChannel.UserSync, "Machine ID : " + (target.DeviceId ?? ""), @@ -393,6 +423,7 @@ internal sealed partial class HikvisionAttendanceManager { _logger.Warn("UserSync template lookup failed employeeNo=" + srcUser.EmployeeNo + " err=" + tplErr); } + templateFoundLogged = hasFaceBytes; if (!hasFaceBytes) { skipped++; @@ -444,6 +475,7 @@ internal sealed partial class HikvisionAttendanceManager if (!UploadFaceOnTarget(target, srcUser.EmployeeNo, fb!, maxRetries, ct, out var upErr)) { + faceRejectedEmployees.Add((srcUser.EmployeeNo, string.IsNullOrWhiteSpace(upErr) ? "face upload rejected" : upErr)); _logger.OpsError(OpsMarkers.TemplateDbToDevice, srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\""); _logger.Totals.TemplatesFailed++; @@ -468,6 +500,7 @@ internal sealed partial class HikvisionAttendanceManager } faceUp++; + faceUploadedLogged = true; _logger.Totals.TemplatesSaved++; tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1); _logger.Ops(OpsMarkers.TemplateDbToDevice, @@ -484,6 +517,15 @@ internal sealed partial class HikvisionAttendanceManager _logger.BizSeparator(BizChannel.Template); } Bump(createdNew ? UserSyncStatus.CreatedAndFaceUploaded : UserSyncStatus.ExistsFaceUploaded); + } + finally + { + _logger.Ops("USER_SYNC", + "employeeNo=" + srcUser.EmployeeNo + + " templateFound=" + (templateFoundLogged ? "true" : "false") + + " createUser=" + (createUserLogged ? "true" : "false") + + " faceUploaded=" + (faceUploadedLogged ? "true" : "false")); + } } if (pol.DeleteOnTargetIfMissingInSource) @@ -520,6 +562,7 @@ internal sealed partial class HikvisionAttendanceManager var statusLine = string.Join(", ", statusCounts.OrderBy(kv => kv.Key.ToString()) .Select(kv => kv.Key + "=" + kv.Value)); + WriteFaceRejectedEmployeeSummary("TEMPLATE_DB_TO_DEVICE", target, faceRejectedEmployees); _logger.Ops(OpsMarkers.TemplateDbToDevice, "device=" + target.DeviceId + " summary created=" + created + " faceUploaded=" + faceUp + " updated=" + updated + @@ -531,6 +574,114 @@ internal sealed partial class HikvisionAttendanceManager _logger.Ops(OpsMarkers.TemplateDbToDevice, "Cycle completed id=" + cycleId); } + private void WriteFaceRejectedEmployeeSummary( + string diagTag, + HikvisionAttendanceWindowsService.DeviceConfig target, + IReadOnlyList<(string EmployeeNo, string Reason)> rejected) + { + if (rejected == null || rejected.Count == 0) + return; + + _logger.Diag(diagTag, + "FACE REJECTED SUMMARY device=" + (target.DeviceId ?? "") + + " ip=" + (target.Ip ?? "") + + " count=" + rejected.Count); + foreach (var item in rejected.OrderBy(x => x.EmployeeNo, StringComparer.OrdinalIgnoreCase)) + { + _logger.Diag(diagTag, + "employeeNo=" + item.EmployeeNo + + " reason=\"" + ToOneLineSnippet(item.Reason, 240) + "\""); + } + } + + /// + /// Merges SyncEmployeeIds with serial_numbers from hrms.employee for SyncDepartmentIds. + /// + private HashSet BuildMergedSyncEmployeeIds(out List departmentIdsUsed, out int departmentEmployeesFound) + { + departmentIdsUsed = new List(); + departmentEmployeesFound = 0; + var merged = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var id in _config.SyncEmployeeIds ?? Enumerable.Empty()) + { + var t = (id ?? "").Trim(); + if (t.Length > 0) + merged.Add(t); + } + + var deptIds = (_config.SyncDepartmentIds ?? new List()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (deptIds.Count == 0) + return merged; + + departmentIdsUsed = deptIds; + var fromDept = ResolveSerialNumbersByDepartmentIds(deptIds, out var err); + if (!string.IsNullOrWhiteSpace(err)) + _logger.Warn("UserSync: department employee lookup failed err=" + err); + + departmentEmployeesFound = fromDept.Count; + foreach (var sn in fromDept) + merged.Add(sn); + + return merged; + } + + /// + /// SELECT serial_number FROM employee WHERE department_id IN (...). Uses existing DB factory only. + /// + private List ResolveSerialNumbersByDepartmentIds(IReadOnlyList departmentIds, out string error) + { + error = ""; + var result = new List(); + if (departmentIds == null || departmentIds.Count == 0) + return result; + if (!_config.EnableDbIntegration || _dbConnectionFactory == null) + { + error = "DB integration not available for department filter."; + return result; + } + + if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error)) + return result; + + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + var sql = new StringBuilder("SELECT serial_number FROM employee WHERE department_id IN ("); + for (int i = 0; i < departmentIds.Count; i++) + { + if (i > 0) sql.Append(','); + sql.Append("@d").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]); + + using var rd = cmd.ExecuteReader(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + while (rd.Read()) + { + var sn = rd["serial_number"]?.ToString()?.Trim() ?? ""; + if (sn.Length == 0 || !seen.Add(sn)) + continue; + result.Add(sn); + } + } + catch (Exception ex) + { + error = ex.Message; + } + + return result; + } + private List LoadSourceUsersForSync(HikvisionAttendanceWindowsService.DeviceConfig source, int maxRetries, CancellationToken ct) { if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null) @@ -686,6 +837,13 @@ internal sealed partial class HikvisionAttendanceManager RunUserFaceSyncCycle(token); if (_config.EnableTemplateDbToDeviceSync) _logger.Ops(OpsMarkers.TemplateDbToDevice, "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - cycleStarted)); + if (_config.EnableInitialDepartmentSync) + { + var initialStarted = DateTime.Now; + _logger.Ops("INITIAL_SYNC", "JOB CYCLE START"); + RunInitialDepartmentSyncCycle(token); + _logger.Ops("INITIAL_SYNC", "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - initialStarted)); + } _logger.Ops(OpsMarkers.UserDelete, "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - cycleStarted)); } catch (OperationCanceledException) @@ -767,7 +925,6 @@ internal sealed partial class HikvisionAttendanceManager byte[] faceImage, int maxRetries, CancellationToken ct, out string error) { error = ""; - var pol = SyncPol; if (faceImage == null || faceImage.Length == 0) { error = "empty faceImage"; @@ -775,8 +932,11 @@ internal sealed partial class HikvisionAttendanceManager } var emp = (employeeNo ?? "").Trim(); - var faceLibType = string.IsNullOrWhiteSpace(pol.FaceLibType) ? "blackFD" : pol.FaceLibType.Trim(); - var fdId = string.IsNullOrWhiteSpace(pol.FaceLibraryFdId) ? "1" : pol.FaceLibraryFdId.Trim(); + if (emp.Length == 0) + { + error = "empty employeeNo"; + return false; + } if (!TryNormalizeFaceImageForUpload(faceImage, out var jpegBytes, out var imageFormat, out var normalizeNote)) { @@ -790,50 +950,242 @@ internal sealed partial class HikvisionAttendanceManager return false; } - string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(faceLibType) + - "\",\"FDID\":\"" + EscapeJsonStatic(fdId) + - "\",\"FPID\":\"" + EscapeJsonStatic(emp) + "\"}"; + if (!TryDiscoverFaceLibCandidates(target, maxRetries, ct, out var faceLibs, out var discoverErr)) + { + error = "FDLib discovery failed: " + (string.IsNullOrWhiteSpace(discoverErr) ? "no libraries returned" : discoverErr); + _logger.Diag("TEMPLATE_DB_TO_DEVICE", + "face upload aborted employee=" + emp + " reason=\"" + error + "\""); + return false; + } const string relativeUri = "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json"; string url = "http://" + target.Ip + ":" + IsapiPort + relativeUri; + string lastError = ""; + string lastFaceDataRecordResponse = ""; - _logger.Diag("TEMPLATE_DB_TO_DEVICE", - "face upload prepare employee=" + emp + - " device=" + (target.DeviceId ?? "") + - " ip=" + (target.Ip ?? "") + - " url=" + url + - " faceLibType=" + faceLibType + - " FDID=" + fdId + - " FPID=" + emp + - " blobSize=" + faceImage.Length + - " jpegSize=" + jpegBytes.Length + - " sourceFormat=" + imageFormat + - " base64Length=" + (4 * ((jpegBytes.Length + 2) / 3)) + - " meta=" + metaJson + - (string.IsNullOrWhiteSpace(normalizeNote) ? "" : (" note=\"" + normalizeNote + "\""))); - - if (!TryIsapiPostMultipartFaceWithRetry(target, relativeUri, metaJson, jpegBytes, emp, maxRetries, ct, - out var body, out var status, out error)) + foreach (var lib in faceLibs) { - // Keep Ops/summary concise; full HTTP body already written to Diag inside the sender. - error = ConciseFaceUploadFailureReason(status, body, error); - return false; - } + ct.ThrowIfCancellationRequested(); + var faceLibType = (lib.faceLibType ?? "").Trim(); + var fdId = lib.fdId.ToString(CultureInfo.InvariantCulture); + if (faceLibType.Length == 0) + continue; + + string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(faceLibType) + + "\",\"FDID\":\"" + EscapeJsonStatic(fdId) + + "\",\"FPID\":\"" + EscapeJsonStatic(emp) + "\"}"; - if (!IsLikelyIsapiSuccess(body, status)) - { - TryParseIsapiResponseFields(body, out var sc, out var ss, out var sub); _logger.Diag("TEMPLATE_DB_TO_DEVICE", - "face upload rejected by device employee=" + emp + - " httpStatus=" + status + - " statusCode=" + sc + - " statusString=\"" + ss + "\"" + - " subStatusCode=\"" + sub + "\"" + - " response=" + (body ?? "")); - error = ConciseFaceUploadFailureReason(status, body, "device rejected face upload"); + "face upload prepare employeeNo=" + emp + + " device=" + (target.DeviceId ?? "") + + " ip=" + (target.Ip ?? "") + + " url=" + url + + " faceLibType=" + faceLibType + + " FDID=" + fdId + + " FPID=" + emp + + " blobSize=" + faceImage.Length + + " jpegSize=" + jpegBytes.Length + + " sourceFormat=" + imageFormat + + " meta=" + metaJson + + (string.IsNullOrWhiteSpace(normalizeNote) ? "" : (" note=\"" + normalizeNote + "\""))); + + if (!TryIsapiPostMultipartFaceWithRetry(target, relativeUri, metaJson, jpegBytes, emp, maxRetries, ct, + out var body, out var status, out var uploadErr)) + { + lastError = ConciseFaceUploadFailureReason(status, body, uploadErr); + lastFaceDataRecordResponse = body ?? ""; + _logger.Diag("TEMPLATE_DB_TO_DEVICE", + "FaceDataRecord failed employeeNo=" + emp + + " faceLibType=" + faceLibType + + " FDID=" + fdId + + " response=" + lastFaceDataRecordResponse + + " err=\"" + lastError + "\""); + continue; + } + + if (!IsLikelyIsapiSuccess(body, status)) + { + TryParseIsapiResponseFields(body, out var sc, out var ss, out var sub); + lastError = ConciseFaceUploadFailureReason(status, body, "device rejected face upload"); + lastFaceDataRecordResponse = body ?? ""; + _logger.Diag("TEMPLATE_DB_TO_DEVICE", + "face upload rejected by device employeeNo=" + emp + + " faceLibType=" + faceLibType + + " FDID=" + fdId + + " httpStatus=" + status + + " statusCode=" + sc + + " statusString=\"" + ss + "\"" + + " subStatusCode=\"" + sub + "\"" + + " response=" + lastFaceDataRecordResponse); + continue; + } + + lastFaceDataRecordResponse = body ?? ""; + _logger.Diag("TEMPLATE_DB_TO_DEVICE", + "FaceDataRecord accepted employeeNo=" + emp + + " faceLibType=" + faceLibType + + " FDID=" + fdId + + " response=" + lastFaceDataRecordResponse); + + if (TryVerifyPersonFaceEnrolled(target, emp, maxRetries, ct, out var numOfFace, out var verifyErr) && + numOfFace >= 1) + { + _logger.Diag("TEMPLATE_DB_TO_DEVICE", + "face enrollment verified employeeNo=" + emp + + " selectedFaceLibType=" + faceLibType + + " selectedFDID=" + fdId + + " FaceDataRecordResponse=" + lastFaceDataRecordResponse + + " UserInfoNumOfFace=" + numOfFace); + return true; + } + + lastError = string.IsNullOrWhiteSpace(verifyErr) + ? "FaceDataRecord OK but UserInfo numOfFace=" + numOfFace + : verifyErr; + _logger.Diag("TEMPLATE_DB_TO_DEVICE", + "face enrollment not visible employeeNo=" + emp + + " faceLibType=" + faceLibType + + " FDID=" + fdId + + " FaceDataRecordResponse=" + lastFaceDataRecordResponse + + " UserInfoNumOfFace=" + numOfFace + + " note=\"" + lastError + "\""); + } + + error = string.IsNullOrWhiteSpace(lastError) + ? "FaceDataRecord did not produce enrolled face (numOfFace=0) on any discovered FDLib" + : lastError; + if (!string.IsNullOrWhiteSpace(lastFaceDataRecordResponse)) + error += " lastFaceDataRecordResponse=" + ToOneLineSnippet(lastFaceDataRecordResponse, 240); + return false; + } + + /// + /// GET /ISAPI/Intelligent/FDLib and cache per device IP for the process lifetime. + /// + private bool TryDiscoverFaceLibCandidates( + HikvisionAttendanceWindowsService.DeviceConfig device, + int maxRetries, + CancellationToken ct, + out List candidates, + out string error) + { + candidates = new List(); + error = ""; + var ip = (device.Ip ?? "").Trim(); + if (ip.Length == 0) + { + error = "target IP empty"; return false; } + if (_faceLibCacheByDeviceIp.TryGetValue(ip, out var cached) && cached.Count > 0) + { + candidates = cached; + return true; + } + + string fdLibUrl = "http://" + ip + ":" + IsapiPort + "/ISAPI/Intelligent/FDLib?format=json"; + if (!TryHttpGetBytesWithRetry(device, fdLibUrl, maxRetries, ct, out var bytes, out error)) + return false; + + if (!TryParseFaceLibCandidatesFromFdLibResponse(bytes, out candidates, out var parseErr)) + { + error = string.IsNullOrWhiteSpace(parseErr) ? "FDLib response parse failed" : parseErr; + candidates = new List(); + return false; + } + + candidates = OrderFaceLibCandidates(DedupeFaceLibCandidates(candidates)); + if (candidates.Count == 0) + { + error = "FDLib discovery returned no faceLibType/FDID pairs"; + return false; + } + + _faceLibCacheByDeviceIp[ip] = candidates; + _logger.Diag("TEMPLATE_DB_TO_DEVICE", + "FDLib discovered device=" + (device.DeviceId ?? "") + + " ip=" + ip + + " count=" + candidates.Count + + " libs=" + string.Join(",", candidates.Select(c => c.faceLibType + ":" + c.fdId))); + return true; + } + + private static List DedupeFaceLibCandidates(List input) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var c in input) + { + var key = (c.faceLibType ?? "").Trim() + "|" + c.fdId; + if (!seen.Add(key)) + continue; + result.Add(c); + } + + return result; + } + + private static List OrderFaceLibCandidates(List input) => + input + .OrderBy(c => string.Equals(c.faceLibType, "blackFD", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(c => c.faceLibType, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.fdId) + .ToList(); + + /// + /// Confirms Person Management enrollment via UserInfo/Search numOfFace. + /// Retries once briefly because some firmware updates numOfFace asynchronously. + /// + private bool TryVerifyPersonFaceEnrolled( + HikvisionAttendanceWindowsService.DeviceConfig target, + string employeeNo, + int maxRetries, + CancellationToken ct, + out int numOfFace, + out string error) + { + numOfFace = 0; + error = ""; + for (int attempt = 0; attempt < 2; attempt++) + { + if (attempt > 0) + Thread.Sleep(300); + + if (!TryGetUserNumOfFaceOnTarget(target, employeeNo, maxRetries, ct, out numOfFace, out error)) + continue; + + if (numOfFace >= 1) + return true; + } + + if (string.IsNullOrWhiteSpace(error)) + error = "UserInfo numOfFace=" + numOfFace; + return false; + } + + private bool TryGetUserNumOfFaceOnTarget( + HikvisionAttendanceWindowsService.DeviceConfig target, + string employeeNo, + int maxRetries, + CancellationToken ct, + out int numOfFace, + out string error) + { + numOfFace = 0; + error = ""; + if (!TrySearchUsersIsapiPage(target, 0, 10, employeeNo.Trim(), maxRetries, ct, out var users, out error)) + return false; + + var user = users.FirstOrDefault(u => + string.Equals(u.EmployeeNo, employeeNo.Trim(), StringComparison.OrdinalIgnoreCase)); + if (user == null) + { + error = "employee not found in UserInfo/Search"; + return false; + } + + numOfFace = user.NumOfFace; return true; } diff --git a/HikvisionAttendanceManager.cs b/HikvisionAttendanceManager.cs index 5cd82af..40cae3d 100644 --- a/HikvisionAttendanceManager.cs +++ b/HikvisionAttendanceManager.cs @@ -342,19 +342,23 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable var hasTemplateSyncRoute = hasUserSyncSource && userSyncTargetCount > 0; var hasDatabaseDeletionRoute = _config.EnableDbIntegration && _attendanceMachineRepository != null && _attendanceMachineUserRepository != null; - if (_config.EnableUserSync && + var hasInitialDepartmentSyncRoute = _config.EnableInitialDepartmentSync && userSyncTargetCount > 0 && + (_config.InitialSyncDepartmentIds?.Count ?? 0) > 0; + if ((_config.EnableUserSync || _config.EnableInitialDepartmentSync) && _config.SyncIntervalMinutes > 0 && - (hasTemplateSyncRoute || hasDatabaseDeletionRoute)) + (hasTemplateSyncRoute || hasDatabaseDeletionRoute || hasInitialDepartmentSyncRoute)) { var sourceLabel = !string.IsNullOrWhiteSpace(_config.SourceMachineIp) ? _config.SourceMachineIp : _config.SourceDeviceId; - _logger.Ops(OpsMarkers.Service, "JOB ENABLED [TEMPLATE_DB_TO_DEVICE]/[USER_DELETE] intervalMinutes=" + _config.SyncIntervalMinutes + + _logger.Ops(OpsMarkers.Service, "JOB ENABLED [TEMPLATE_DB_TO_DEVICE]/[USER_DELETE]/[INITIAL_SYNC] intervalMinutes=" + _config.SyncIntervalMinutes + " source=" + (hasUserSyncSource ? sourceLabel : "(not configured)") + " targets=" + userSyncTargetCount + - " deletionByMachineId=" + hasDatabaseDeletionRoute); + " deletionByMachineId=" + hasDatabaseDeletionRoute + + " initialDepartmentSync=" + hasInitialDepartmentSyncRoute); _ = Task.Run(() => UserSyncSchedulerLoop(_cts.Token), _cts.Token); } else { _logger.Ops(OpsMarkers.Service, "JOB DISABLED [TEMPLATE_DB_TO_DEVICE] EnableUserSync=" + _config.EnableUserSync + + " EnableInitialDepartmentSync=" + _config.EnableInitialDepartmentSync + " hasSource=" + hasUserSyncSource + " targets=" + userSyncTargetCount + " deletionByMachineId=" + hasDatabaseDeletionRoute); } diff --git a/HikvisionAttendanceWindowsService.cs b/HikvisionAttendanceWindowsService.cs index dc4efb5..0c12e60 100644 --- a/HikvisionAttendanceWindowsService.cs +++ b/HikvisionAttendanceWindowsService.cs @@ -103,6 +103,24 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase [DataMember] public bool EnableTemplateDbToDeviceSync { get; set; } + /// + /// Separate onboarding flow: provision users+faces to a new device from HRMS department + /// + employee portal photos. Does not use attendance_machine_face_templates. + /// + [DataMember] + public bool EnableInitialDepartmentSync { get; set; } + + [DataMember] + public List InitialSyncDepartmentIds { get; set; } = new List(); + + /// When true, initial sync downloads JPEG photos from . + [DataMember] + public bool EnableEmployeePhotoSource { get; set; } + + /// Base URL ending with /; photo path is {base}{employee.id}.jpeg + [DataMember] + public string EmployeePhotoBaseUrl { get; set; } = ""; + [DataMember] public bool KeepAttendanceFileExport { get; set; } = true; @@ -223,6 +241,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase [DataMember] public List SyncEmployeeIds { get; set; } = new List(); + /// Optional HRMS department_id values; serial_numbers from hrms.employee are merged with SyncEmployeeIds. + [DataMember] + public List SyncDepartmentIds { get; set; } = new List(); + /// Optional directory to persist downloaded source face images for auditing or re-upload. Empty = skip file save. [DataMember] public string UserSyncFaceCacheDirectory { get; set; } = ""; @@ -248,6 +270,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase EnableTemplateDbPersistence = false, EnableTemplateDeviceToDbSync = false, EnableTemplateDbToDeviceSync = false, + EnableInitialDepartmentSync = false, + InitialSyncDepartmentIds = new List(), + EnableEmployeePhotoSource = false, + EmployeePhotoBaseUrl = "", KeepAttendanceFileExport = true, KeepTemplateFiles = true, DbHost = "", @@ -281,6 +307,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase TargetDeviceIds = new List(), TargetMachineIps = new List(), SyncEmployeeIds = new List(), + SyncDepartmentIds = new List(), UserSyncFaceCacheDirectory = "", SyncPolicies = new UserSyncPoliciesConfig() }; @@ -331,6 +358,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase EnableTemplateDbPersistence = false, EnableTemplateDeviceToDbSync = false, EnableTemplateDbToDeviceSync = false, + EnableInitialDepartmentSync = false, + InitialSyncDepartmentIds = new List(), + EnableEmployeePhotoSource = false, + EmployeePhotoBaseUrl = "", KeepAttendanceFileExport = true, KeepTemplateFiles = true, DbHost = "", @@ -364,6 +395,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase TargetDeviceIds = new List(), TargetMachineIps = new List(), SyncEmployeeIds = new List(), + SyncDepartmentIds = new List(), UserSyncFaceCacheDirectory = "", SyncPolicies = new UserSyncPoliciesConfig() }; @@ -536,6 +568,14 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase cfg.TargetMachineIps = new List(overlay.TargetMachineIps); if (overlay.SyncEmployeeIds != null) cfg.SyncEmployeeIds = new List(overlay.SyncEmployeeIds); + if (overlay.SyncDepartmentIds != null) + cfg.SyncDepartmentIds = new List(overlay.SyncDepartmentIds); + cfg.EnableInitialDepartmentSync = overlay.EnableInitialDepartmentSync; + if (overlay.InitialSyncDepartmentIds != null) + cfg.InitialSyncDepartmentIds = new List(overlay.InitialSyncDepartmentIds); + cfg.EnableEmployeePhotoSource = overlay.EnableEmployeePhotoSource; + if (overlay.EmployeePhotoBaseUrl != null) + cfg.EmployeePhotoBaseUrl = overlay.EmployeePhotoBaseUrl; } catch { @@ -573,6 +613,15 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase cfg.SyncEmployeeIds ??= new List(); for (int i = 0; i < cfg.SyncEmployeeIds.Count; i++) cfg.SyncEmployeeIds[i] = (cfg.SyncEmployeeIds[i] ?? "").Trim(); + cfg.SyncDepartmentIds ??= new List(); + for (int i = 0; i < cfg.SyncDepartmentIds.Count; i++) + cfg.SyncDepartmentIds[i] = (cfg.SyncDepartmentIds[i] ?? "").Trim(); + cfg.InitialSyncDepartmentIds ??= new List(); + for (int i = 0; i < cfg.InitialSyncDepartmentIds.Count; i++) + cfg.InitialSyncDepartmentIds[i] = (cfg.InitialSyncDepartmentIds[i] ?? "").Trim(); + cfg.EmployeePhotoBaseUrl = string.IsNullOrWhiteSpace(cfg.EmployeePhotoBaseUrl) + ? "" + : cfg.EmployeePhotoBaseUrl.Trim(); if (cfg.Devices == null) return; @@ -610,6 +659,21 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase [DataMember] public List SyncEmployeeIds { get; set; } = new List(); + + [DataMember] + public List SyncDepartmentIds { get; set; } = new List(); + + [DataMember] + public bool EnableInitialDepartmentSync { get; set; } + + [DataMember] + public List InitialSyncDepartmentIds { get; set; } = new List(); + + [DataMember] + public bool EnableEmployeePhotoSource { get; set; } + + [DataMember] + public string EmployeePhotoBaseUrl { get; set; } = ""; } /// Policies for multi-device user/face sync (ISAPI). Loaded from service JSON only.