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(); if (!_config.InitialSyncLocationSiteId.HasValue || _config.InitialSyncLocationSiteId.Value <= 0) { const string reason = "InitialSyncLocationSiteId is not configured; Initial Department Sync requires an explicit location-site filter."; _logger.OpsWarn("INITIAL_SYNC", "skipped — " + reason); _logger.Biz(BizChannel.DepartmentalSync, "DEPARTMENTAL SYNC FAILED", "", "Reason : " + reason, ""); _logger.BizSeparator(BizChannel.DepartmentalSync); return; } var locationSiteId = _config.InitialSyncLocationSiteId.Value; var deptIds = (_config.InitialSyncDepartmentIds ?? new List()) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var allActiveDepartmentsMode = deptIds.Count == 0; var targets = ResolveInitialSyncTargets(); if (targets.Count == 0) { _logger.OpsWarn("INITIAL_SYNC", "skipped — no valid TargetMachineIps"); _logger.Biz(BizChannel.DepartmentalSync, "DEPARTMENTAL SYNC FAILED", "", "Reason : No valid target machines are configured.", ""); _logger.BizSeparator(BizChannel.DepartmentalSync); return; } var employeeIdFilter = (_config.InitialSyncEmployeeIds ?? new List()) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (allActiveDepartmentsMode && employeeIdFilter.Count > 0) { _logger.OpsWarn("INITIAL_SYNC", "InitialSyncEmployeeIds ignored because InitialSyncDepartmentIds is empty; " + "mode=ALL_ACTIVE_DEPARTMENTS_AND_EMPLOYEES"); employeeIdFilter.Clear(); } var selection = LoadEmployeesForInitialDepartmentSync( deptIds, employeeIdFilter, locationSiteId, out var loadErr); if (!string.IsNullOrWhiteSpace(loadErr)) { _logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr); _logger.Biz(BizChannel.DepartmentalSync, "DEPARTMENTAL SYNC FAILED", "", "Reason : " + loadErr, ""); _logger.BizSeparator(BizChannel.DepartmentalSync); return; } LogInitialDepartmentSelection(deptIds, employeeIdFilter, selection); var employees = selection.Employees; 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\""); _logger.Biz(BizChannel.UserSync, "MACHINE " + (target.DeviceId ?? ""), "", "Machine is not connected.", ""); _logger.BizSeparator(BizChannel.UserSync); WriteDepartmentalSyncSummary( target, selection, employeeIdFilter, false, Array.Empty()); continue; } _logger.Biz(BizChannel.UserSync, "MACHINE " + (target.DeviceId ?? "") + " -> Total Users : " + employees.Count, ""); if (employees.Count == 0) { WriteDepartmentalSyncSummary( target, selection, employeeIdFilter, true, Array.Empty()); _logger.BizSeparator(BizChannel.UserSync); 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)>(); var employeeOutcomes = new List(); 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 (!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)); continue; } faceUploaded = true; } finally { employeeOutcomes.Add(new InitialSyncEmployeeOutcome { DepartmentId = emp.DepartmentId, EmployeeNo = employeeNo, Synced = faceUploaded, Reason = faceUploaded ? "" : (string.IsNullOrWhiteSpace(reason) ? "face_upload_failed" : reason) }); WriteInitialSyncEmployeeBizLog(target, employeeNo, userCreated, faceUploaded, reason); 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); WriteDepartmentalSyncSummary(target, selection, employeeIdFilter, true, employeeOutcomes); _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(); 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 InitialSyncSelection LoadEmployeesForInitialDepartmentSync( IReadOnlyList departmentIds, IReadOnlyList employeeIdAllowList, int locationSiteId, out string error) { error = ""; var result = new InitialSyncSelection { LocationSiteId = locationSiteId }; 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; var hasDepartmentFilter = departmentIds != null && departmentIds.Count > 0; var hasAllowList = hasDepartmentFilter && employeeIdAllowList != null && employeeIdAllowList.Count > 0; try { using var conn = new MySqlConnection(cs); conn.Open(); if (hasDepartmentFilter) { var departmentSql = new StringBuilder( "SELECT id, is_active FROM department WHERE id IN ("); AppendSqlParameters(departmentSql, "@d", departmentIds.Count); departmentSql.Append(')'); using var departmentCmd = new MySqlCommand(departmentSql.ToString(), conn); AddSqlParameters(departmentCmd, "@d", departmentIds); var foundDepartmentIds = new HashSet(StringComparer.OrdinalIgnoreCase); using (var departmentReader = departmentCmd.ExecuteReader()) { while (departmentReader.Read()) { var departmentId = departmentReader["id"]?.ToString()?.Trim() ?? ""; if (departmentId.Length == 0) continue; foundDepartmentIds.Add(departmentId); if (IsDatabaseActive(departmentReader["is_active"])) result.ActiveDepartmentIds.Add(departmentId); else result.InactiveDepartmentIds.Add(departmentId); } } foreach (var requestedId in departmentIds) { if (!foundDepartmentIds.Contains(requestedId)) result.MissingDepartmentIds.Add(requestedId); } } else { using var departmentCmd = new MySqlCommand( "SELECT id FROM department WHERE is_active = 1", conn); using (var departmentReader = departmentCmd.ExecuteReader()) { while (departmentReader.Read()) { var departmentId = departmentReader["id"]?.ToString()?.Trim() ?? ""; if (departmentId.Length > 0) result.ActiveDepartmentIds.Add(departmentId); } } using var inactiveDepartmentCmd = new MySqlCommand( "SELECT COUNT(*) FROM department WHERE is_active IS NULL OR is_active <> 1", conn); result.InactiveDepartmentCount = Convert.ToInt32(inactiveDepartmentCmd.ExecuteScalar() ?? 0); } if (result.ActiveDepartmentIds.Count == 0) return result; var employeeSql = new StringBuilder( "SELECT e.id, e.serial_number, e.department_id " + "FROM employee e " + "INNER JOIN department d ON d.id = e.department_id " + "WHERE d.is_active = 1 AND e.is_active = 1 " + "AND e.location_site_id = @locationSiteId AND e.department_id IN ("); AppendSqlParameters(employeeSql, "@activeDept", result.ActiveDepartmentIds.Count); employeeSql.Append(')'); if (hasAllowList) { employeeSql.Append(" AND e.serial_number IN ("); AppendSqlParameters(employeeSql, "@e", employeeIdAllowList.Count); employeeSql.Append(')'); } using var cmd = new MySqlCommand(employeeSql.ToString(), conn); cmd.Parameters.AddWithValue("@locationSiteId", locationSiteId); AddSqlParameters(cmd, "@activeDept", result.ActiveDepartmentIds); if (hasAllowList) AddSqlParameters(cmd, "@e", employeeIdAllowList); var seenSerial = new HashSet(StringComparer.OrdinalIgnoreCase); using (var rd = cmd.ExecuteReader()) { while (rd.Read()) { var serial = rd["serial_number"]?.ToString()?.Trim() ?? ""; if (serial.Length == 0 || !seenSerial.Add(serial)) continue; result.Employees.Add(new InitialSyncEmployee { Id = rd["id"]?.ToString()?.Trim() ?? "", SerialNumber = serial, DepartmentId = rd["department_id"]?.ToString()?.Trim() ?? "", Name = "" }); } } var inactiveEmployeeSql = new StringBuilder( "SELECT COUNT(*) " + "FROM employee e " + "INNER JOIN department d ON d.id = e.department_id " + "WHERE d.is_active = 1 AND (e.is_active IS NULL OR e.is_active <> 1) " + "AND e.location_site_id = @inactiveLocationSiteId AND e.department_id IN ("); AppendSqlParameters(inactiveEmployeeSql, "@inactiveDept", result.ActiveDepartmentIds.Count); inactiveEmployeeSql.Append(')'); if (hasAllowList) { inactiveEmployeeSql.Append(" AND e.serial_number IN ("); AppendSqlParameters(inactiveEmployeeSql, "@inactiveEmp", employeeIdAllowList.Count); inactiveEmployeeSql.Append(')'); } using var inactiveEmployeeCmd = new MySqlCommand(inactiveEmployeeSql.ToString(), conn); inactiveEmployeeCmd.Parameters.AddWithValue("@inactiveLocationSiteId", locationSiteId); AddSqlParameters(inactiveEmployeeCmd, "@inactiveDept", result.ActiveDepartmentIds); if (hasAllowList) AddSqlParameters(inactiveEmployeeCmd, "@inactiveEmp", employeeIdAllowList); result.SkippedInactiveEmployeeCount = Convert.ToInt32(inactiveEmployeeCmd.ExecuteScalar() ?? 0); } catch (Exception ex) { error = ex.Message; } return result; } private void LogInitialDepartmentSelection( IReadOnlyList requestedDepartmentIds, IReadOnlyList employeeIdAllowList, InitialSyncSelection selection) { var allDepartments = requestedDepartmentIds == null || requestedDepartmentIds.Count == 0; var selectiveEmployees = !allDepartments && employeeIdAllowList != null && employeeIdAllowList.Count > 0; var inactiveCount = allDepartments ? selection.InactiveDepartmentCount : selection.InactiveDepartmentIds.Count; _logger.Ops("INITIAL_SYNC", "InitialSyncLocationSiteId=" + selection.LocationSiteId + " departmentMode=" + (allDepartments ? "ALL_ACTIVE" : "SELECTED") + " selectedDepartmentCount=" + (allDepartments ? selection.ActiveDepartmentIds.Count : requestedDepartmentIds.Count) + " activeDepartmentCount=" + selection.ActiveDepartmentIds.Count + " inactiveDepartmentCount=" + inactiveCount + " activeDepartments=" + FormatLogValues(selection.ActiveDepartmentIds) + " inactiveDepartments=" + (allDepartments ? "(all inactive departments excluded)" : FormatLogValues(selection.InactiveDepartmentIds)) + " missingDepartments=" + FormatLogValues(selection.MissingDepartmentIds)); _logger.Ops("INITIAL_SYNC", "employeeFilterMode=" + (selectiveEmployees ? "SELECTIVE" : "ALL") + (selectiveEmployees ? " allowListCount=" + employeeIdAllowList.Count + " allowList=" + FormatLogValues(employeeIdAllowList) : "") + " finalEmployeeCount=" + selection.Employees.Count + " skippedInactiveEmployees=" + selection.SkippedInactiveEmployeeCount); } private void WriteDepartmentalSyncSummary( HikvisionAttendanceWindowsService.DeviceConfig target, InitialSyncSelection selection, IReadOnlyList employeeIdAllowList, bool targetOnline, IReadOnlyList outcomes) { var lines = new List { "DEPARTMENTAL SYNC SUMMARY", "", "Machine ID : " + (target.DeviceId ?? ""), "Machine IP : " + (target.Ip ?? ""), "InitialSyncLocationSiteId : " + selection.LocationSiteId, "Employee Filter : " + (employeeIdAllowList.Count > 0 ? "SELECTIVE" : "ALL"), "Active Departments : " + selection.ActiveDepartmentIds.Count, "Inactive Departments Skipped : " + (selection.InactiveDepartmentIds.Count + selection.InactiveDepartmentCount), "Eligible Active Employees : " + selection.Employees.Count, "Skipped Inactive Employees : " + selection.SkippedInactiveEmployeeCount, "" }; foreach (var departmentId in selection.ActiveDepartmentIds .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) { var eligibleCount = selection.Employees.Count(x => string.Equals(x.DepartmentId, departmentId, StringComparison.OrdinalIgnoreCase)); var departmentOutcomes = outcomes.Where(x => string.Equals(x.DepartmentId, departmentId, StringComparison.OrdinalIgnoreCase)).ToList(); var syncedCount = departmentOutcomes.Count(x => x.Synced); var failedCount = Math.Max(0, eligibleCount - syncedCount); string status; string reason = ""; if (!targetOnline) { status = "NOT SYNCED"; reason = "Target machine is offline."; } else if (eligibleCount == 0) { status = "NOT SYNCED"; reason = employeeIdAllowList.Count > 0 ? "No selected active employees belong to this active department." : "Department has no active employees."; } else if (syncedCount == eligibleCount) { status = "SYNCED"; } else if (syncedCount > 0) { status = "PARTIALLY SYNCED"; } else { status = "NOT SYNCED"; } lines.Add("Department " + departmentId + " : " + status); lines.Add("Eligible Users : " + eligibleCount); lines.Add("Synced Users : " + syncedCount); lines.Add("Failed Users : " + failedCount); if (failedCount > 0) lines.Add("Failed Employee Numbers : " + string.Join(",", targetOnline ? departmentOutcomes.Where(x => !x.Synced).Select(x => x.EmployeeNo) : selection.Employees .Where(x => string.Equals( x.DepartmentId, departmentId, StringComparison.OrdinalIgnoreCase)) .Select(x => x.SerialNumber))); if (!string.IsNullOrWhiteSpace(reason)) lines.Add("Reason : " + reason); foreach (var failureGroup in departmentOutcomes .Where(x => !x.Synced) .GroupBy(x => DescribeInitialSyncFailure(x.Reason)) .OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)) { lines.Add("Failure Reason : " + failureGroup.Key + " (" + failureGroup.Count() + " user(s))"); } lines.Add(""); } foreach (var departmentId in selection.InactiveDepartmentIds .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) { lines.Add("Department " + departmentId + " : NOT SYNCED"); lines.Add("Eligible Users : 0"); lines.Add("Synced Users : 0"); lines.Add("Failed Users : 0"); lines.Add("Reason : Department is inactive."); lines.Add(""); } foreach (var departmentId in selection.MissingDepartmentIds .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) { lines.Add("Department " + departmentId + " : NOT SYNCED"); lines.Add("Eligible Users : 0"); lines.Add("Synced Users : 0"); lines.Add("Failed Users : 0"); lines.Add("Reason : Department was not found."); lines.Add(""); } if (selection.ActiveDepartmentIds.Count == 0 && selection.InactiveDepartmentIds.Count == 0 && selection.MissingDepartmentIds.Count == 0) { lines.Add("No active departments were available for synchronization."); lines.Add(""); } var totalSynced = outcomes.Count(x => x.Synced); lines.Add("TOTAL SYNCED USERS : " + totalSynced); lines.Add("TOTAL FAILED USERS : " + Math.Max(0, selection.Employees.Count - totalSynced)); lines.Add(""); _logger.Biz(BizChannel.DepartmentalSync, lines.ToArray()); _logger.BizSeparator(BizChannel.DepartmentalSync); } private static void AppendSqlParameters(StringBuilder sql, string prefix, int count) { for (int i = 0; i < count; i++) { if (i > 0) sql.Append(','); sql.Append(prefix).Append(i); } } private static void AddSqlParameters( MySqlCommand command, string prefix, IReadOnlyList values) { for (int i = 0; i < values.Count; i++) command.Parameters.AddWithValue(prefix + i, values[i]); } private static bool IsDatabaseActive(object value) { if (value == null || value == DBNull.Value) return false; if (value is bool boolValue) return boolValue; return string.Equals( Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim(), "1", StringComparison.Ordinal); } private static string FormatLogValues(IEnumerable values) { var items = (values ?? Enumerable.Empty()) .Where(x => !string.IsNullOrWhiteSpace(x)) .ToList(); return items.Count == 0 ? "(none)" : string.Join(",", items); } 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 DepartmentId { get; set; } = ""; public string Name { get; set; } = ""; } private sealed class InitialSyncEmployeeOutcome { public string DepartmentId { get; set; } = ""; public string EmployeeNo { get; set; } = ""; public bool Synced { get; set; } public string Reason { get; set; } = ""; } private sealed class InitialSyncSelection { public int LocationSiteId { get; set; } public List Employees { get; } = new List(); public List ActiveDepartmentIds { get; } = new List(); public List InactiveDepartmentIds { get; } = new List(); public List MissingDepartmentIds { get; } = new List(); public int InactiveDepartmentCount { get; set; } public int SkippedInactiveEmployeeCount { get; set; } } }