feat: wire console diagnostics, attendance sync, and delete orchestration

--test-planning / diagnose / inspect; attendance parse logs; delete sync entrypoints.
main
SYED MUSTUFA AHMED NAQVI 2026-09-04 12:43:38 +05:00
parent d7b8a4057d
commit 6b2d980c8a
1 changed files with 393 additions and 30 deletions

View File

@ -143,11 +143,10 @@ namespace HanvonF710XAttendanceService
/// </summary>
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<Attendance>();
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<string>();
var pendingItems = new List<MachineUserDeleteItem>();
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<string> 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<string>();
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<string>()),
() => new List<string>(job.EmpIds ?? new List<string>()));
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<string>();
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<string> { 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<byte>();
}
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<string> 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);