From 6b2d980c8a8c802e6cb94d0d3c153651e57f3e18 Mon Sep 17 00:00:00 2001 From: Syed Mustafa Ahmed Naqvi Date: Fri, 4 Sep 2026 12:43:38 +0500 Subject: [PATCH] feat: wire console diagnostics, attendance sync, and delete orchestration --test-planning / diagnose / inspect; attendance parse logs; delete sync entrypoints. --- Program.cs | 423 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 393 insertions(+), 30 deletions(-) diff --git a/Program.cs b/Program.cs index 92875ac..3bc4cae 100644 --- a/Program.cs +++ b/Program.cs @@ -143,11 +143,10 @@ namespace HanvonF710XAttendanceService /// static void Main(string[] args) { - string baseDir = AppDomain.CurrentDomain.BaseDirectory; + ApplicationPaths.Initialize(); try { - SetDllDirectory(baseDir); - Environment.CurrentDirectory = baseDir; + SetDllDirectory(ApplicationPaths.BaseDirectory); } catch { } @@ -200,8 +199,58 @@ namespace HanvonF710XAttendanceService return false; } + private static string GetArgValue(string[] args, params string[] keys) + { + if (args == null || keys == null) return null; + foreach (var arg in args) + { + if (string.IsNullOrWhiteSpace(arg)) continue; + foreach (var key in keys) + { + if (arg.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase)) + { + return arg.Substring(key.Length + 1); + } + } + } + return null; + } + private static void RunAsConsole(string[] args) { + if (HasArg(args, "--test-planning", "/test-planning")) + { + int failed = DbToDevicePlanningTests.RunAll(); + failed += NedoTemplateConverterTests.RunAll(); + failed += HrmsEmployeePhotoClientTests.RunAll(); + failed += HrmsEmployeeMappingTests.RunAll(); + failed += DbToDeviceSummaryTests.RunAll(); + failed += DbToDeviceFaceErrorsTests.RunAll(); + failed += MachineUserDeleteSyncTests.RunAll(); + Environment.ExitCode = failed == 0 ? 0 : 1; + return; + } + + if (HasArg(args, "--test-template-load", "/test-template-load")) + { + RunTemplateLoadTest(); + return; + } + + string diagnoseEmp = GetArgValue(args, "--diagnose-emp", "/diagnose-emp"); + if (!string.IsNullOrWhiteSpace(diagnoseEmp)) + { + RunDiagnoseEmployee(diagnoseEmp.Trim()); + return; + } + + string inspectPortalPhoto = GetArgValue(args, "--inspect-portal-photo", "/inspect-portal-photo"); + if (!string.IsNullOrWhiteSpace(inspectPortalPhoto)) + { + RunInspectPortalPhoto(inspectPortalPhoto.Trim()); + return; + } + IsConsoleMode = true; try { AllocConsole(); } catch { } @@ -264,12 +313,7 @@ namespace HanvonF710XAttendanceService public static void WriteToFile(string Message) { - string path = AppDomain.CurrentDomain.BaseDirectory + "\\TemplateRawLogs"; - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - } - string filepath = AppDomain.CurrentDomain.BaseDirectory + "\\TemplateRawLogs\\ServiceLog_" + DateTime.Now.Date.ToShortDateString().Replace('/', '_') + ".txt"; + string filepath = ApplicationPaths.DatedFile(ApplicationPaths.TemplateRawLogs, "ServiceLog"); LogService.EnqueueLine(filepath, Message); } @@ -277,7 +321,7 @@ namespace HanvonF710XAttendanceService { try { - string filepath = AppDomain.CurrentDomain.BaseDirectory + "\\InternalLogs\\InternalLog_" + DateTime.Now.Date.ToShortDateString().Replace('/', '_') + ".txt"; + string filepath = ApplicationPaths.DatedFile(ApplicationPaths.InternalLogs, "InternalLog"); LogService.EnqueueLine(filepath, message); } catch @@ -290,7 +334,7 @@ namespace HanvonF710XAttendanceService { try { - string filepath = AppDomain.CurrentDomain.BaseDirectory + "\\InternalLogs\\UnreachableMachines_" + DateTime.Now.Date.ToShortDateString().Replace('/', '_') + ".txt"; + string filepath = ApplicationPaths.DatedFile(ApplicationPaths.InternalLogs, "UnreachableMachines"); LogService.EnqueueLine(filepath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\tmachine_id={machine.MachineId}\tip={machine.MachineIp}\tsite_id={machine.SiteId}\treason={reason}"); lock (_unreachableLock) @@ -326,7 +370,7 @@ namespace HanvonF710XAttendanceService try { - string filepath = AppDomain.CurrentDomain.BaseDirectory + "\\InternalLogs\\UnreachableMachines_" + DateTime.Now.Date.ToShortDateString().Replace('/', '_') + ".txt"; + string filepath = ApplicationPaths.DatedFile(ApplicationPaths.InternalLogs, "UnreachableMachines"); LogService.EnqueueLine(filepath, ""); LogService.EnqueueLine(filepath, "---"); LogService.EnqueueLine(filepath, ""); @@ -420,7 +464,15 @@ namespace HanvonF710XAttendanceService var parsedRows = new List(); foreach (var log in logs) { - parsedRows.Add(new Attendance(log.AcNo, log.CheckTime, false, machine.MachineId, "1", machine.MachineIp, DateTime.Now)); + string acNo = !string.IsNullOrWhiteSpace(log.EnrollId) ? log.EnrollId.Trim() : (log.AcNo ?? "").Trim(); + if (string.IsNullOrWhiteSpace(acNo)) + { + WriteInternalLog($"[AttendanceSkip] machine_id={machine.MachineId} ip={machine.MachineIp} reason=missing_enrollid name={log.Name ?? ""} time={log.CheckTime:yyyy-MM-dd HH:mm:ss}"); + continue; + } + + WriteInternalLog($"[AttendanceParsed] machine_id={machine.MachineId} ip={machine.MachineIp} enrollid={acNo} name={log.Name ?? ""} checktime={log.CheckTime:yyyy-MM-dd HH:mm:ss}"); + parsedRows.Add(new Attendance(acNo, log.CheckTime, false, machine.MachineId, "1", machine.MachineIp, DateTime.Now)); } int totalFetched = parsedRows.Count; @@ -619,31 +671,42 @@ namespace HanvonF710XAttendanceService var toDelete = attendanceMachineUsersDAO.GetDeletionRequestedSerials(conn, machine.MachineId); if (toDelete.Count > 0) { - // Map serials to device enroll ids when possible. - var deleteIds = new List(); + var pendingItems = new List(); foreach (var empId in toDelete) { - if (string.IsNullOrWhiteSpace(empId)) continue; + if (string.IsNullOrWhiteSpace(empId)) + { + continue; + } + + string trimmed = empId.Trim(); var match = users.FirstOrDefault(u => - string.Equals(u.SerialNumber, empId.Trim(), StringComparison.OrdinalIgnoreCase) - || string.Equals(u.DeviceUserId, empId.Trim(), StringComparison.OrdinalIgnoreCase) - || string.Equals(u.Name, empId.Trim(), StringComparison.OrdinalIgnoreCase)); - deleteIds.Add(match != null ? match.DeviceUserId : empId.Trim()); + string.Equals(u.SerialNumber, trimmed, StringComparison.OrdinalIgnoreCase) + || string.Equals(u.DeviceUserId, trimmed, StringComparison.OrdinalIgnoreCase) + || string.Equals(u.Name, trimmed, StringComparison.OrdinalIgnoreCase)); + string deviceEnrollId = match != null ? match.DeviceUserId : trimmed; + pendingItems.Add(new MachineUserDeleteItem(trimmed, deviceEnrollId)); } - if (client.DeleteUsers(deleteIds, out string delErr)) - { - foreach (var empId in toDelete) + int batchSize = MachineUserDeleteSettings.GetBatchSize(); + var deleteResult = MachineUserDeleteSync.Execute( + machine.MachineId, + machine.MachineIp, + pendingItems, + batchSize, + (IReadOnlyList deviceIds, out string delErr) => client.DeleteUsers(deviceIds, out delErr), + serial => { - attendanceMachineUsersDAO.MarkUserDeleted(conn, machine.MachineId, empId); - string line = empId + " removed from " + machine.MachineId; + attendanceMachineUsersDAO.MarkUserDeleted(conn, machine.MachineId, serial); + string line = serial + " removed from " + machine.MachineId; responses.Add(line); WriteInternalLog(line); - } - } - else + }, + WriteInternalLog); + + if (deleteResult.FailedCount > 0) { - WriteInternalLog("syncMachineUsers HTTP deleteusers failed: " + delErr); + WriteInternalLog($"syncMachineUsers HTTP deleteusers completed with failures. machine_id={machine.MachineId} success={deleteResult.SuccessCount} failed={deleteResult.FailedCount}"); } } } @@ -839,6 +902,304 @@ namespace HanvonF710XAttendanceService return logs; } + private static void RunTemplateLoadTest() + { + var logs = new List(); + try + { + Initialize(); + if (!TryLoadDeviceSettings(out var settings, logs)) + { + foreach (var line in logs) Console.WriteLine(line); + Environment.ExitCode = 1; + return; + } + + var job = settings?.TemplateTransfer?.Jobs?.FirstOrDefault(j => j.Enabled); + if (job == null) + { + Console.WriteLine("No enabled template transfer job found."); + Environment.ExitCode = 1; + return; + } + + using (var conn = OpenDbConnectionWithRetry("testTemplateLoad")) + { + var employeeIds = DbToDevicePlanning.ResolveEmployeeIds( + job, + departmentIds => new AttendanceMachineFaceTemplateDAO().GetActiveSerialNumbersByDepartmentIds(conn, departmentIds, new List()), + () => new List(job.EmpIds ?? new List())); + + Console.WriteLine($"[DB_TO_DEVICE] Template load test employees={employeeIds.Count}"); + var runner = new TemplateTransferRunner(new AttendanceMachineDAO(), new AttendanceMachineFaceTemplateDAO()); + var templates = runner.LoadTemplatesForDbToDeviceTest(conn, employeeIds, logs); + int missing = employeeIds.Count - templates.Count; + logs.Add($"[DB_TO_DEVICE] Template loading complete requested={employeeIds.Count} loaded={templates.Count} missing={missing}"); + } + } + catch (Exception ex) + { + logs.Add("Template load test failed: " + ex); + } + + foreach (var line in logs) + { + Console.WriteLine(line); + } + + Environment.ExitCode = logs.Any(l => l.IndexOf("failed", StringComparison.OrdinalIgnoreCase) >= 0) ? 1 : 0; + } + + private static void RunInspectPortalPhoto(string deviceSerial) + { + try + { + Initialize(); + string photoEmployeeId = deviceSerial; + string mappedName = null; + using (var conn = OpenDbConnectionWithRetry("inspectPortalPhoto")) + { + var employeeDao = new HrmsEmployeeDAO(); + if (employeeDao.TryGetByDeviceSerial(conn, deviceSerial, out HrmsEmployeeInfo employee)) + { + photoEmployeeId = employee.EmployeeId; + mappedName = employee.ConcatenatedName; + Console.WriteLine($"[PORTAL_PHOTO] device_serial={deviceSerial} hrms_id={employee.EmployeeId} name={employee.ConcatenatedName}"); + } + else + { + Console.WriteLine($"[PORTAL_PHOTO] device_serial={deviceSerial} hrms_lookup=MISS (treating argument as employees.id for photo URL)"); + } + } + + string url = HrmsEmployeePhotoClient.BuildPhotoUrl(EmployeePhotoSourceSettings.GetBaseUrl(), photoEmployeeId); + Console.WriteLine($"[PORTAL_PHOTO] emp={deviceSerial} hrms_id={photoEmployeeId} enabled={EmployeePhotoSourceSettings.IsEnabled()} url={url}"); + + if (!HrmsEmployeePhotoClient.TryDownloadEmployeePhoto(photoEmployeeId, out HrmsEmployeePhotoDownloadResult result)) + { + Console.WriteLine($"[PORTAL_PHOTO] emp={deviceSerial} hrms_id={photoEmployeeId} download=FAILED http={result?.StatusCode ?? 0} content_type={result?.ContentType ?? ""} err={result?.Error ?? "unknown"}"); + Environment.ExitCode = 1; + return; + } + + Console.WriteLine($"[PORTAL_PHOTO] emp={deviceSerial} hrms_id={photoEmployeeId} name={mappedName ?? ""} download=OK http={result.StatusCode} content_type={result.ContentType ?? ""}"); + Console.WriteLine($"[PORTAL_PHOTO] emp={deviceSerial} photo_size={result.ImageBytes?.Length ?? 0} width={result.Width} height={result.Height} base64_len={result.Base64Length}"); + } + catch (Exception ex) + { + Console.WriteLine("Inspect portal photo failed: " + ex); + Environment.ExitCode = 1; + } + } + + private static void RunDiagnoseEmployee(string empId) + { + var logs = new List(); + var writer = new StringWriter(); + try + { + Initialize(); + if (!TryLoadDeviceSettings(out var settings, logs)) + { + foreach (var line in logs) Console.WriteLine(line); + Environment.ExitCode = 1; + return; + } + + var job = settings?.TemplateTransfer?.Jobs?.FirstOrDefault(j => j.Enabled); + if (job == null || job.TargetIps == null || job.TargetIps.Count == 0) + { + Console.WriteLine("No enabled template job with targets found."); + Environment.ExitCode = 1; + return; + } + + string targetIp = job.TargetIps[0]; + using (var conn = OpenDbConnectionWithRetry("diagnoseEmp")) + { + var machineDao = new AttendanceMachineDAO(); + if (!machineDao.TryGetAttendanceMachineByIp(conn, targetIp, out var machine, out _)) + { + Console.WriteLine("Target machine not found for ip=" + targetIp); + Environment.ExitCode = 1; + return; + } + + var templateDao = new AttendanceMachineFaceTemplateDAO(); + var templates = templateDao.GetActiveTemplatesBySerialNos(conn, new List { empId }); + byte[] blob = null; + if (!templates.TryGetValue(empId, out blob)) + { + if (!EmployeePhotoSourceSettings.IsEnabled()) + { + Console.WriteLine("No active template in DB for emp=" + empId); + Environment.ExitCode = 1; + return; + } + + blob = Array.Empty(); + } + + var uploadPlan = DbToDeviceUploadPlan.FromBlob(blob); + if (uploadPlan.Format == DbToDeviceTemplateFormat.Empty && EmployeePhotoSourceSettings.IsEnabled()) + { + uploadPlan.Format = DbToDeviceTemplateFormat.NedoXml; + uploadPlan.PushMode = "face"; + uploadPlan.UseProfileVerification = true; + } + bool portalPhotoEnabled = uploadPlan.Format == DbToDeviceTemplateFormat.NedoXml + && EmployeePhotoSourceSettings.IsEnabled(); + if (!uploadPlan.HasUploadPayload && !portalPhotoEnabled) + { + Console.WriteLine("No uploadable template payload for emp=" + empId + " format=" + uploadPlan.FormatLabel); + Environment.ExitCode = 1; + return; + } + + var machineUserDao = new AttendanceMachineUserDAO(); + var employeeDao = new HrmsEmployeeDAO(); + HrmsEmployeeInfo hrmsEmployee = null; + try + { + employeeDao.TryGetByDeviceSerial(conn, empId, out hrmsEmployee); + } + catch (Exception exEmp) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} hrms_lookup=FAILED err={exEmp.Message}"); + } + + string machineUserName = machineUserDao.GetEmployeeNameForMachine(conn, machine.MachineId, empId); + string displayName = !string.IsNullOrWhiteSpace(hrmsEmployee?.ConcatenatedName) + ? hrmsEmployee.ConcatenatedName + : (!string.IsNullOrWhiteSpace(machineUserName) ? machineUserName : empId); + + if (hrmsEmployee != null) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} hrms_id={hrmsEmployee.EmployeeId} name={displayName}"); + } + else + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} hrms_lookup=MISS name={displayName}"); + } + + if (!int.TryParse(empId, out int enrollId)) + { + Console.WriteLine("empId must be numeric enrollid for Hanvon HTTP API. emp=" + empId); + Environment.ExitCode = 1; + return; + } + + var client = HanvonHttpApiClient.ForMachine(machine); + if (!client.TryLogin(out string loginErr)) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} login=FAILED err={loginErr}"); + Environment.ExitCode = 1; + return; + } + + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} template=FOUND format={uploadPlan.FormatLabel}"); + if (uploadPlan.Format == DbToDeviceTemplateFormat.NedoXml) + { + bool hadNedoPayload = uploadPlan.HasUploadPayload; + if (portalPhotoEnabled) + { + string photoEmployeeId = hrmsEmployee?.EmployeeId; + if (uploadPlan.TryResolveEmployeePhoto(photoEmployeeId, out string resolveErr)) + { + if (string.Equals(uploadPlan.PhotoSourceLabel, "HRMS_PORTAL", StringComparison.OrdinalIgnoreCase)) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} hrms_id={photoEmployeeId} photo_source=HRMS_PORTAL photo_url={uploadPlan.PhotoSourceUrl} photo_size={uploadPlan.PhotoByteLength} width={uploadPlan.PreparedWidth} height={uploadPlan.PreparedHeight}"); + } + else if (hadNedoPayload) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} photo_source=HRMS_PORTAL FAILED err={resolveErr ?? "unknown"}"); + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} photo_source=NEDO_XML FALLBACK"); + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} nedo_photo=FOUND base64_len={uploadPlan.PhotoBase64Length} size={uploadPlan.PhotoByteLength} blob_len={uploadPlan.SourceBlobLength}"); + } + } + else + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} photo_source=HRMS_PORTAL FAILED err={resolveErr ?? "unknown"}"); + } + } + else if (uploadPlan.HasUploadPayload) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} nedo_photo=FOUND base64_len={uploadPlan.PhotoBase64Length} size={uploadPlan.PhotoByteLength} blob_len={uploadPlan.SourceBlobLength}"); + } + } + + if (!uploadPlan.HasUploadPayload) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} face_upload=FAILED response=no_photo_available"); + Environment.ExitCode = 1; + return; + } + + if (!uploadPlan.TryPrepareFacePayload(out string prepareErr)) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} format={uploadPlan.FormatLabel} photo=INVALID err={prepareErr}"); + Environment.ExitCode = 1; + return; + } + + if (!string.IsNullOrWhiteSpace(uploadPlan.PrepareNote)) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} nedo_photo=PREPARED note={uploadPlan.PrepareNote} width={uploadPlan.PreparedWidth} height={uploadPlan.PreparedHeight} size={uploadPlan.PhotoByteLength}"); + } + + if (!client.TryUploadDbToDeviceTemplate(enrollId, displayName, uploadPlan, out string uploadErr)) + { + if (DbToDeviceFaceErrors.TryParseDuplicateFace(uploadErr, out string existingDeviceId)) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} face=DUPLICATE existing_device_id={existingDeviceId}"); + } + else + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} face_upload=FAILED response={uploadErr}"); + Environment.ExitCode = 1; + } + } + else if (client.VerifyDbToDeviceTemplate(enrollId, uploadPlan, out string faceflag, out string photourl, out string verifyErr)) + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} target={machine.MachineIp} face_upload=OK photo_source={uploadPlan.PhotoSourceLabel ?? uploadPlan.FormatLabel} push_mode={uploadPlan.PushMode} payload_len={uploadPlan.Payload?.Length ?? 0}"); + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} verify faceflag={faceflag ?? ""} photourl={photourl ?? ""} face=OK"); + } + else + { + writer.WriteLine($"[DB_TO_DEVICE] employee={empId} verify=FAILED faceflag={faceflag ?? ""} photourl={photourl ?? ""} err={verifyErr}"); + Environment.ExitCode = 1; + } + + var users = client.GetUserList(out string listErr); + var match = users.FirstOrDefault(u => + string.Equals(u.DeviceUserId, empId, StringComparison.OrdinalIgnoreCase) + || string.Equals(u.SerialNumber, empId, StringComparison.OrdinalIgnoreCase)); + writer.WriteLine($"[DB_TO_DEVICE] emp={empId} getuserlist user_exists={(match != null ? "true" : "false")} list_id={match?.DeviceUserId ?? ""} list_name={match?.Name ?? ""}"); + if (!string.IsNullOrWhiteSpace(listErr)) + { + writer.WriteLine($"[DB_TO_DEVICE] getuserlist_err={listErr}"); + } + } + } + catch (Exception ex) + { + writer.WriteLine("Diagnose failed: " + ex); + } + + string output = writer.ToString(); + Console.WriteLine(output); + try + { + string logPath = Path.Combine(ApplicationPaths.InternalLogs, "DbToDeviceDiag_" + empId + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".txt"); + File.WriteAllText(logPath, output); + Console.WriteLine("Diagnostic log written: " + logPath); + } + catch + { + // ignore file write failures + } + } + // ApplyDeviceSelectionRulesIfConfigured removed: selection is now config-driven (IP allowlist/blocklist) without SITE_ID env var. private static bool TryLoadDeviceSettings(out DeviceSettingsConfig settings, List logs) @@ -848,9 +1209,11 @@ namespace HanvonF710XAttendanceService string path = ConfigurationManager.AppSettings["DEVICE_SETTINGS_PATH"]; if (string.IsNullOrWhiteSpace(path)) { - path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "DeviceSettings.xml"); + path = "DeviceSettings.xml"; } + path = ApplicationPaths.Resolve(path); + if (!DeviceSettingsConfig.TryLoad(path, out settings, out var error)) { logs?.Add(error);