From fb6f09188d8464b275bcf035e950f53f0b6ddf5c Mon Sep 17 00:00:00 2001 From: "mustafa.ahmed" Date: Thu, 30 Jul 2026 13:19:46 +0500 Subject: [PATCH] =?UTF-8?q?Make=20attendance=20business=20logs=20an=20acti?= =?UTF-8?q?vity=20story=20and=20gate=20device=E2=86=92DB=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit scope/DB startup once; per-machine CONNECTING/status/fetch/insert (or offline) story lines; keep technical counters in internal logs; require EnableTemplateDeviceToDbSync for device→DB template persistence/logging. --- HikvisionAttendanceManager.cs | 923 ++++++++++++++++++++++++---------- 1 file changed, 656 insertions(+), 267 deletions(-) diff --git a/HikvisionAttendanceManager.cs b/HikvisionAttendanceManager.cs index 7ca230c..0620f20 100644 --- a/HikvisionAttendanceManager.cs +++ b/HikvisionAttendanceManager.cs @@ -45,6 +45,10 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable private readonly IAttendanceLogRepository? _attendanceLogRepository; private readonly IAttendanceMachineUserRepository? _attendanceMachineUserRepository; private readonly IAttendanceMachineFaceTemplateRepository? _attendanceMachineFaceTemplateRepository; + private readonly UnreachableDeviceTracker _unreachableDevices; + + private DateTime _serviceStartedAt = DateTime.MinValue; + private string _stopReason = ""; private CancellationTokenSource _cts; private Task _queueWriterTask; @@ -60,6 +64,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable { _config = config; _logger = logger; + _unreachableDevices = new UnreachableDeviceTracker(_logger); Directory.CreateDirectory(_config.LogDirectory); _csvPath = Path.Combine(_config.LogDirectory, "attendance_events.csv"); @@ -84,6 +89,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable _attendanceMachineUserRepository = new MySqlAttendanceMachineUserRepository(_dbConnectionFactory); _attendanceMachineFaceTemplateRepository = new MySqlAttendanceMachineFaceTemplateRepository(_dbConnectionFactory); _logger.Info("DB integration enabled; connection=" + _dbConnectionFactory.BuildConnectionStringMasked()); + _logger.Diag("attendance", "DB connection configured: " + _dbConnectionFactory.BuildConnectionStringMasked()); } EnsureCsvSchema(); @@ -118,50 +124,136 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable } } - private List ResolveRuntimeDevices() + private List ResolveRuntimeDevices(bool emitBizStartupSummary = false) { _runtimeDevices.Clear(); + var configDevices = _config.Devices ?? new List(); + var credentialFallback = configDevices.FirstOrDefault(d => + !string.IsNullOrWhiteSpace(d.Username) && !string.IsNullOrWhiteSpace(d.Password)); + bool loadedFromDb = false; if (_config.EnableDbIntegration && _config.EnableDbMachineLoading && _attendanceMachineRepository != null) { var dbMachines = _attendanceMachineRepository.GetActiveMachines("HIKVISION", out var dbErr); if (!string.IsNullOrWhiteSpace(dbErr)) + { _logger.Warn("DB machine loading failed; fallback to config. err=" + dbErr); + } else { + var requestedScope = string.Equals(_config.MachineScopeMode, "SITE", StringComparison.OrdinalIgnoreCase) ? "SITE" : "CENTRAL"; + var scopedIps = new HashSet((_config.ScopedMachineIps ?? new List()).Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase); + var dbFetchedCount = dbMachines.Count; + if (requestedScope == "SITE") + { + dbMachines = dbMachines + .Where(m => !string.IsNullOrWhiteSpace(m.MachineIp) && scopedIps.Contains(m.MachineIp.Trim())) + .ToList(); + } + + _logger.Ops(OpsMarkers.Scope, "ScopeMode=" + requestedScope + + (requestedScope == "SITE" ? " scopedIps=[" + string.Join(",", scopedIps) + "]" : " (all active Hikvision)") + + " fetchedFromDb=" + dbFetchedCount + " afterScopeFilter=" + dbMachines.Count); + + int selected = 0; + int skippedNoCreds = 0; foreach (var m in dbMachines) { - var matchingCfg = (_config.Devices ?? new List()) - .FirstOrDefault(x => - string.Equals(x.Ip, m.MachineIp, StringComparison.OrdinalIgnoreCase) || - string.Equals(DeviceIdentity.CanonicalLookupKey(x.DeviceId), DeviceIdentity.CanonicalLookupKey(m.MachineId), StringComparison.Ordinal)); - if (matchingCfg == null) + if (string.IsNullOrWhiteSpace(m.MachineIp)) + { + _logger.OpsWarn(OpsMarkers.Scope, "SKIP machine_id=" + (m.MachineId ?? "") + " reason=\"empty machine_ip\""); continue; + } + + var matchingCfg = configDevices.FirstOrDefault(x => + string.Equals((x.Ip ?? "").Trim(), (m.MachineIp ?? "").Trim(), StringComparison.OrdinalIgnoreCase)); + var creds = matchingCfg ?? credentialFallback; + if (creds == null || string.IsNullOrWhiteSpace(creds.Username) || string.IsNullOrWhiteSpace(creds.Password)) + { + skippedNoCreds++; + _logger.OpsWarn(OpsMarkers.Scope, "SKIP machine_id=" + (m.MachineId ?? "") + " ip=" + m.MachineIp + + " reason=\"no username/password in config\""); + continue; + } + + var deviceId = !string.IsNullOrWhiteSpace(m.MachineId) + ? m.MachineId.Trim() + : (!string.IsNullOrWhiteSpace(m.MachineName) ? m.MachineName.Trim() : m.MachineIp.Trim()); + var port = m.PortNumber > 0 ? m.PortNumber : (creds.Port > 0 ? creds.Port : 8000); + _runtimeDevices.Add(new HikvisionAttendanceWindowsService.DeviceConfig { - DeviceId = string.IsNullOrWhiteSpace(m.MachineId) ? matchingCfg.DeviceId : m.MachineId, - Ip = m.MachineIp, - Port = m.PortNumber > 0 ? m.PortNumber : matchingCfg.Port, - Username = matchingCfg.Username, - Password = matchingCfg.Password, - FingerPrintReaderNo = matchingCfg.FingerPrintReaderNo, - FaceReaderNo = matchingCfg.FaceReaderNo, - GatewayDoorIndex = matchingCfg.GatewayDoorIndex, - Model = matchingCfg.Model, - SerialNumber = matchingCfg.SerialNumber, - FirmwareVersion = matchingCfg.FirmwareVersion, - SubnetMask = matchingCfg.SubnetMask, - DefaultGateway = matchingCfg.DefaultGateway + DeviceId = deviceId, + Ip = m.MachineIp.Trim(), + Port = port, + Username = creds.Username, + Password = creds.Password, + FingerPrintReaderNo = creds.FingerPrintReaderNo, + FaceReaderNo = creds.FaceReaderNo, + GatewayDoorIndex = creds.GatewayDoorIndex, + Model = string.IsNullOrWhiteSpace(m.MachineName) ? (creds.Model ?? "") : m.MachineName, + SerialNumber = creds.SerialNumber, + FirmwareVersion = creds.FirmwareVersion, + SubnetMask = creds.SubnetMask, + DefaultGateway = creds.DefaultGateway }); + selected++; + _logger.Ops(OpsMarkers.Scope, + "SELECTED machine_id=" + deviceId + + " name=\"" + (m.MachineName ?? "") + "\"" + + " ip=" + m.MachineIp.Trim() + + " port=" + port + + " type=" + (m.MachineType ?? "HIKVISION")); } + loadedFromDb = _runtimeDevices.Count > 0; - _logger.Info("DB machine loading: loaded " + _runtimeDevices.Count + " active Hikvision machines."); + _logger.Ops(OpsMarkers.Scope, "SUMMARY fetched=" + dbFetchedCount + + " selected=" + selected + " skippedNoCredentials=" + skippedNoCreds); + + // Business attendance log: scope once at service startup only (not every job cycle). + if (emitBizStartupSummary) + { + _logger.Biz(BizChannel.Attendance, + "Scope : " + requestedScope, + "Machines in DB : " + dbFetchedCount, + "Machines selected : " + selected, + ""); + _logger.BizSeparator(BizChannel.Attendance); + } } } - if (!_config.PreferDbMachinesOverConfig || !loadedFromDb) - return _config.Devices ?? new List(); - return _runtimeDevices; + if (_config.EnableDbMachineLoading && _config.PreferDbMachinesOverConfig && loadedFromDb) + { + _logger.Ops(OpsMarkers.Scope, "RuntimeSource=DB selectedCount=" + _runtimeDevices.Count); + return _runtimeDevices; + } + + if (!loadedFromDb) + { + foreach (var d in configDevices) + { + _logger.Ops(OpsMarkers.Scope, + "SELECTED machine_id=" + (d.DeviceId ?? "") + + " name=\"" + (d.Model ?? "") + "\"" + + " ip=" + (d.Ip ?? "") + + " port=" + d.Port + + " type=HIKVISION"); + } + + if (emitBizStartupSummary) + { + _logger.Biz(BizChannel.Attendance, + "Scope : CONFIG", + "Machines in DB : 0", + "Machines selected : " + configDevices.Count, + ""); + _logger.BizSeparator(BizChannel.Attendance); + } + } + + _logger.Ops(OpsMarkers.Scope, "RuntimeSource=CONFIG selectedCount=" + configDevices.Count); + return configDevices; } /// Main service loop: SDK init, ACS alarm deploy, optional scheduled historical fetch. @@ -176,82 +268,146 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable try { + _serviceStartedAt = DateTime.Now; + _stopReason = ""; + _logger.WriteBizServiceStarted(); + _logger.Ops(OpsMarkers.Service, "START time=" + _serviceStartedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + + if (_config.EnableDbIntegration && _dbConnectionFactory != null) + { + if (_dbConnectionFactory.TryBuildConnectionString(out _, out var dbCsErr)) + { + _logger.Biz(BizChannel.Attendance, "Database connection opened successfully.", ""); + _logger.Diag("attendance", "DB connection OK: " + _dbConnectionFactory.BuildConnectionStringMasked()); + } + else + { + var code = ExtractMysqlErrorCode(dbCsErr); + _logger.WriteBizDatabaseFailed(code, string.IsNullOrWhiteSpace(dbCsErr) + ? "Unable to connect to MySQL server." + : dbCsErr); + _stopReason = "database connection failed"; + _logger.OpsError(OpsMarkers.Service, "DB connection failed: " + dbCsErr); + return; + } + } + if (!Common.CHCNetSDK.NET_DVR_Init()) { var err = Common.CHCNetSDK.NET_DVR_GetLastError(); + _stopReason = "SDK init failed (error " + err + ")"; LogSdkFailure("NET_DVR_Init", err, null, null, "SDK initialization failed; service startup will stop.", isWarning: false); + _logger.OpsError(OpsMarkers.Service, "START FAILED reason=\"" + _stopReason + "\""); return; } - _logger.Info("NET_DVR_Init succeeded."); - - _logger.Info("HCNetSDK routing: all native entry points that must share init state use Common.CHCNetSDK (DllImport HCNetSDK\\HCNetSDK.dll next to exe). " + - "EventByDeploy types are used only for ACS struct layouts and TypeMap (no separate DllImport calls for callback/alarm/remote-config)."); - - _logger.Info("Attendance persistence: plain-text file=\"" + _attendanceTextPath + "\", database INSERTs " + - (_config.EnableDatabasePersistence && !string.IsNullOrWhiteSpace(_config.SqlConnectionString) - ? "ENABLED" - : "DISABLED (set EnableDatabasePersistence true + SqlConnectionString to re-enable)")); + _logger.Ops(OpsMarkers.Service, "SDK ready. Jobs: ATTENDANCE / TEMPLATE(DEVICE->DB) / USER_SYNC(DB->DEVICE + USER_DELETE)"); + _logger.Diag("attendance", "HCNetSDK init OK; attendance text=\"" + _attendanceTextPath + "\""); StartDevices(); - _logger.Info("Startup: active device sessions=" + _sessions.Count + ". Historical ACS query API: NET_DVR_GET_ACS_EVENT is available."); - var attendanceInterval = _config.AttendanceSyncIntervalMinutes > 0 ? _config.AttendanceSyncIntervalMinutes : _config.HistoricalFetchIntervalMinutes; if (_config.EnableAttendanceSync && attendanceInterval > 0 && _sessions.Count > 0) { - _logger.Info("Scheduled historical fetch enabled: every " + attendanceInterval + - " min, lookback " + _config.HistoricalFetchLookbackMinutes + " min."); - _logger.JobInfo("attendance", "Scheduler enabled intervalMinutes=" + attendanceInterval + - " lookbackMinutes=" + _config.HistoricalFetchLookbackMinutes + " activeDevices=" + _sessions.Count); + _logger.Ops(OpsMarkers.Service, "JOB ENABLED [ATTENDANCE] intervalMinutes=" + attendanceInterval + + " lookbackMinutes=" + _config.HistoricalFetchLookbackMinutes + " devices=" + _sessions.Count); _ = Task.Run(() => HistoricalSchedulerLoop(_cts.Token), _cts.Token); } + else + { + _logger.Ops(OpsMarkers.Service, "JOB DISABLED [ATTENDANCE] EnableAttendanceSync=" + _config.EnableAttendanceSync + + " sessions=" + _sessions.Count); + } if (_config.EnableTemplateFetch && + _config.EnableTemplateDeviceToDbSync && _config.TemplateFetchIntervalHours > 0 && _sessions.Count > 0) { - _logger.Info("Template fetch scheduler enabled: every " + _config.TemplateFetchIntervalHours + - " hour(s), active devices=" + _sessions.Count + "."); - _logger.JobInfo("template_fetch", "Scheduler enabled intervalHours=" + _config.TemplateFetchIntervalHours + - " activeDevices=" + _sessions.Count); + _logger.Ops(OpsMarkers.Service, "JOB ENABLED [TEMPLATE_DEVICE_TO_DB] intervalHours=" + _config.TemplateFetchIntervalHours + + " devices=" + _sessions.Count); _ = Task.Run(() => TemplateFetchSchedulerLoop(_cts.Token), _cts.Token); } + else + { + _logger.Ops(OpsMarkers.Service, "JOB DISABLED [TEMPLATE_DEVICE_TO_DB] EnableTemplateFetch=" + _config.EnableTemplateFetch + + " EnableTemplateDeviceToDbSync=" + _config.EnableTemplateDeviceToDbSync + + " sessions=" + _sessions.Count); + } + var hasUserSyncSource = !string.IsNullOrWhiteSpace(_config.SourceDeviceId) || !string.IsNullOrWhiteSpace(_config.SourceMachineIp); + var userSyncTargetCount = (_config.TargetDeviceIds?.Count ?? 0) + (_config.TargetMachineIps?.Count ?? 0); + var hasTemplateSyncRoute = hasUserSyncSource && userSyncTargetCount > 0; + var hasDatabaseDeletionRoute = _config.EnableDbIntegration && _attendanceMachineRepository != null && + _attendanceMachineUserRepository != null; if (_config.EnableUserSync && _config.SyncIntervalMinutes > 0 && - !string.IsNullOrWhiteSpace(_config.SourceDeviceId) && - _config.TargetDeviceIds != null && - _config.TargetDeviceIds.Count > 0) + (hasTemplateSyncRoute || hasDatabaseDeletionRoute)) { - _logger.Info("User sync scheduler enabled: every " + _config.SyncIntervalMinutes + " min, source=\"" + - _config.SourceDeviceId + "\", targetCount=" + _config.TargetDeviceIds.Count + ", isapiHttpPort=" + - _config.IsapiHttpPort + "."); - _logger.JobInfo("user_sync", "Scheduler enabled intervalMinutes=" + _config.SyncIntervalMinutes + - " source=" + _config.SourceDeviceId + " targetCount=" + _config.TargetDeviceIds.Count); + 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 + + " source=" + (hasUserSyncSource ? sourceLabel : "(not configured)") + " targets=" + userSyncTargetCount + + " deletionByMachineId=" + hasDatabaseDeletionRoute); _ = Task.Run(() => UserSyncSchedulerLoop(_cts.Token), _cts.Token); } + else + { + _logger.Ops(OpsMarkers.Service, "JOB DISABLED [TEMPLATE_DB_TO_DEVICE] EnableUserSync=" + _config.EnableUserSync + + " hasSource=" + hasUserSyncSource + " targets=" + userSyncTargetCount + + " deletionByMachineId=" + hasDatabaseDeletionRoute); + } + + _logger.Ops(OpsMarkers.Service, "RUNNING sessions=" + _sessions.Count); await Task.Delay(Timeout.Infinite, _cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { - // shutdown + if (string.IsNullOrWhiteSpace(_stopReason)) + _stopReason = "cancel requested (Ctrl+C / Windows Service stop / debugger stop)"; + _logger.Ops(OpsMarkers.Service, "STOP SIGNAL reason=\"" + _stopReason + "\""); } catch (Exception ex) { + _stopReason = "fatal error: " + ex.Message; _logger.Error("RunAsync main loop crashed", ex); + _logger.OpsError(OpsMarkers.Service, "CRASH reason=\"" + _stopReason + "\""); } finally { StopDevices(); try { Common.CHCNetSDK.NET_DVR_Cleanup(); } catch { /* ignore */ } - _logger.Info("NET_DVR_Cleanup completed."); + var ended = DateTime.Now; + var runtime = _serviceStartedAt == DateTime.MinValue ? TimeSpan.Zero : ended - _serviceStartedAt; + if (string.IsNullOrWhiteSpace(_stopReason)) + _stopReason = "normal shutdown"; + _logger.Ops(OpsMarkers.Service, + "STOP time=" + ended.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " runtime=" + FormatDuration(runtime) + + " reason=\"" + _stopReason + "\""); + _logger.WriteBizServiceStopped(); } } + private static string ExtractMysqlErrorCode(string? err) + { + if (string.IsNullOrWhiteSpace(err)) + return "1042"; + var m = System.Text.RegularExpressions.Regex.Match(err, @"\b(10\d{2})\b"); + return m.Success ? m.Groups[1].Value : "1042"; + } + + private static string FormatDuration(TimeSpan ts) + { + if (ts.TotalHours >= 1) + return ((int)ts.TotalHours) + "h " + ts.Minutes + "m " + ts.Seconds + "s"; + if (ts.TotalMinutes >= 1) + return ts.Minutes + "m " + ts.Seconds + "s"; + return ts.Seconds + "s"; + } + /// Fetches stored ACS events from the device for the given local time range and enqueues them into the same pipeline. public Task FetchAttendanceRecordsAsync(string deviceId, DateTime fromLocal, DateTime toLocal, CancellationToken cancellationToken = default) { @@ -274,20 +430,23 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable { WriteLastSyncTimestamp(deviceId, lastEventTimestamp.Value); _logger.Info("LastSync updated (CLI/manual fetch): device=" + deviceId + - ", lastEventTimestamp=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss")); + ", lastEventTimestamp=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") + + ". Meaning: next fetch will start after this timestamp."); } else if (n == 0 && dbInsertAllSucceeded) { WriteLastSyncTimestamp(deviceId, toLocal); _logger.Info("No records found; advancing last_sync_date to window end. machine=" + deviceId + - " timestamp=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + " timestamp=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + ". Meaning: window was empty, cursor moved forward so we do not re-scan forever."); } else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded) { _logger.Warn("LastSync NOT updated (CLI/manual fetch): DB insert did not fully succeed; will retry same window."); - _logger.JobWarn("attendance", "Cleanup skipped: DB insert not confirmed; delete not executed."); + _logger.JobWarn("attendance", "Cleanup skipped. Meaning: punches were not confirmed in DB, so device storage was NOT deleted."); } - _logger.JobInfo("attendance", "MACHINE " + deviceId + " has " + n + " records !"); + _logger.JobInfo("attendance", "Fetch result (manual/CLI): device=" + deviceId + " punchesFound=" + n + + ". Meaning: number of attendance events pulled for the requested time window."); return n; }, cancellationToken); } @@ -1193,32 +1352,52 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable } } - var devicesToLogin = ResolveRuntimeDevices(); + var devicesToLogin = ResolveRuntimeDevices(emitBizStartupSummary: true); - // Startup diagnostics: prove whether we have devices to login. if (devicesToLogin == null) { - _logger.Warn("StartDevices: config.Devices is NULL; skipping all device logins (active sessions will remain 0)."); + _logger.OpsWarn(OpsMarkers.Connectivity, "No device list (null); skipping login."); return; } - _logger.Info("StartDevices: devicesToLoginCount=" + devicesToLogin.Count); if (devicesToLogin.Count == 0) { - _logger.Warn("StartDevices: Devices is empty; skipping all device logins (active sessions will remain 0)."); + _logger.OpsWarn(OpsMarkers.Connectivity, "No devices selected; skipping login."); return; } - for (int i = 0; i < devicesToLogin.Count; i++) - { - var d = devicesToLogin[i]; - _logger.Info("StartDevices: loadedDevice[" + i + "]: DeviceId=\"" + (d.DeviceId ?? "") + "\" Ip=\"" + (d.Ip ?? "") + "\" Port=" + d.Port + - " Username=\"" + (d.Username ?? "") + "\""); - } + _logger.Ops(OpsMarkers.Connectivity, "Login attempts starting for " + devicesToLogin.Count + " device(s)."); + int onlineCount = 0; + int offlineCount = 0; foreach (var device in devicesToLogin) { - _logger.Info("NET_DVR_Login_V30: connecting " + device.Ip + ":" + device.Port + " user=" + device.Username + " (" + device.DeviceId + ")..."); + var machineName = device.Model ?? ""; + var probe = ConnectivityDiagnostics.ProbeBeforeLogin(device.Ip, device.Port, tcpTimeoutMs: 3000, tryPing: true); + + // Hard-fail before SDK only for invalid IP. Ping/TCP failures are still attempted via SDK + // (some networks block ICMP) but are recorded with accurate stage labels. + if (probe.FailureStage == ConnectivityFailureStage.InvalidIp) + { + offlineCount++; + _logger.Totals.MachinesProcessed++; + _logger.Totals.MachinesFailed++; + _logger.OpsWarn(OpsMarkers.Connectivity, + ConnectivityDiagnostics.FormatReachableLine(device.DeviceId, machineName, device.Ip ?? "", device.Port, probe, loginOk: false)); + _unreachableDevices.ReportFailure(device.DeviceId, machineName, device.Ip ?? "", device.Port, + probe.FailureStage, probe.FriendlyReason); + TryUpdateMachineRuntimeState(device.Ip, "NOT CONNECTED", null, null); + continue; + } + + if (probe.TcpChecked && !probe.TcpOk) + { + // Still try SDK login (device may accept after intermittent block), but label TCP failure if login fails. + _logger.Diag("attendance", "Pre-login TCP closed for " + device.Ip + ":" + device.Port + " — attempting SDK login anyway."); + } + + _logger.Totals.MachinesProcessed++; + _logger.Diag("attendance", "NET_DVR_Login_V30 connecting " + device.Ip + ":" + device.Port + " device=" + device.DeviceId); var deviceInfo = new Common.CHCNetSDK.NET_DVR_DEVICEINFO_V30(); int userId = Common.CHCNetSDK.NET_DVR_Login_V30( @@ -1230,21 +1409,42 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable if (userId < 0) { + offlineCount++; var err = Common.CHCNetSDK.NET_DVR_GetLastError(); - LogSdkFailure("NET_DVR_Login_V30", err, device.DeviceId, device.Ip, - "Device login failed on port " + device.Port + ". Check device IP/network/credentials.", isWarning: false); - // Status update must NOT touch last_sync_date (cursor). + var classified = ConnectivityDiagnostics.ClassifySdkLoginFailure(err, probe); + // Prefer earlier TCP/ping stage when SDK only says "connection failed". + if (err == 7 && probe.TcpChecked && !probe.TcpOk) + { + classified.FailureStage = ConnectivityFailureStage.TcpPortClosed; + classified.FriendlyReason = probe.FriendlyReason; + } + else if (err == 7 && probe.PingChecked && !probe.PingOk && probe.TcpOk) + { + // Ping failed but TCP was open — keep SDK classification (connection failed). + } + else if (probe.PingChecked && !probe.PingOk && !probe.TcpOk) + { + classified.FailureStage = ConnectivityFailureStage.PingFailed; + classified.FriendlyReason = probe.FriendlyReason; + } + + _logger.OpsWarn(OpsMarkers.Connectivity, + ConnectivityDiagnostics.FormatReachableLine(device.DeviceId, machineName, device.Ip ?? "", device.Port, classified, loginOk: false)); + _unreachableDevices.ReportFailure(device.DeviceId, machineName, device.Ip ?? "", device.Port, + classified.FailureStage, classified.FriendlyReason); + _logger.Totals.MachinesFailed++; + _logger.Diag("attendance", "Login failed device=" + device.DeviceId + " sdkErr=" + err + " " + TranslateSdkErrorCode(err)); TryUpdateMachineRuntimeState(device.Ip, "NOT CONNECTED", null, null); continue; } - _logger.Info("NET_DVR_Login_V30 succeeded for " + device.DeviceId + " (" + device.Ip + ":" + device.Port + "), userId=" + userId + ". SDK serial=" + - FormatSdkSerial(deviceInfo.sSerialNumber) + ", wDevType=0x" + deviceInfo.wDevType.ToString("X4") + "."); - - LogConfiguredTerminalProfile(device); + onlineCount++; + _logger.Totals.MachinesConnected++; + _unreachableDevices.ReportRecovered(device.DeviceId, machineName, device.Ip ?? "", device.Port); + _logger.Ops(OpsMarkers.Connectivity, + ConnectivityDiagnostics.FormatReachableLine(device.DeviceId, machineName, device.Ip ?? "", device.Port, probe, loginOk: true)); int alarmHandle = -1; - var alarmParam = new Common.CHCNetSDK.NET_DVR_SETUPALARM_PARAM_V50 { byLevel = 1, @@ -1256,35 +1456,26 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable bySupport = 0, byBrokenNetHttp = 0, wTaskNo = 0, - byDeployType = 1, // real-time deploy + byDeployType = 1, byRes1 = new byte[3], byAlarmTypeURL = 0, byCustomCtrl = 0, byRes4 = new byte[128] }; alarmParam.dwSize = (uint)Marshal.SizeOf(typeof(Common.CHCNetSDK.NET_DVR_SETUPALARM_PARAM_V50)); - - _logger.Info("NET_DVR_SetupAlarmChan_V50: userId=" + userId + " dwSize=" + alarmParam.dwSize + " byLevel=" + alarmParam.byLevel + - " byAlarmInfoType=" + alarmParam.byAlarmInfoType + " byDeployType=" + alarmParam.byDeployType + " (Common)."); - alarmHandle = Common.CHCNetSDK.NET_DVR_SetupAlarmChan_V50(userId, ref alarmParam, IntPtr.Zero, 0); if (alarmHandle < 0) { var err = Common.CHCNetSDK.NET_DVR_GetLastError(); - LogSdkFailure("NET_DVR_SetupAlarmChan_V50", err, device.DeviceId, device.Ip, - "Alarm channel setup failed; session will still be kept for login-only APIs (historical fetch, template APIs).", isWarning: true); - } - else - { - _logger.Info("NET_DVR_SetupAlarmChan_V50 succeeded for " + device.DeviceId + ", alarmHandle=" + alarmHandle + "."); + _logger.Diag("attendance", "Alarm setup failed device=" + device.DeviceId + " err=" + err + " (session kept for fetch/templates)."); } _sessions.Add(new DeviceSession(device, userId, alarmHandle)); - _logger.Info("Session REGISTERED: deviceId=" + device.DeviceId + " userId=" + userId + " alarmHandle=" + alarmHandle + - (alarmHandle < 0 ? " (no live alarm; fetch still allowed)" : "")); - // Status update must NOT touch last_sync_date (cursor). TryUpdateMachineRuntimeState(device.Ip, "IDLE", null, null); } + + _logger.Ops(OpsMarkers.Connectivity, "SUMMARY selected=" + devicesToLogin.Count + + " reachable=" + onlineCount + " unreachable=" + offlineCount + " sessions=" + _sessions.Count); } private void StopDevices() @@ -1360,22 +1551,20 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable private void ExecuteAttendanceFetchCycle(CancellationToken token, bool isImmediateStartupRun) { - LogJobCycleStart("attendance", "ATTENDANCE", "--Attendance job started at "); - if (isImmediateStartupRun) - _logger.JobInfo("attendance", "Attendance immediate startup fetch triggered."); - _logger.JobInfo("attendance", "Entering real attendance fetch loop."); + var cycleStarted = DateTime.Now; + int successDevices = 0, failedDevices = 0, skippedDevices = 0; + _logger.Ops(OpsMarkers.Attendance, "JOB CYCLE START" + (isImmediateStartupRun ? " (startup)" : "")); - var runtimeDevices = ResolveRuntimeDevices() ?? new List(); + // Do not re-emit scope/startup business lines on every cycle. + var runtimeDevices = ResolveRuntimeDevices(emitBizStartupSummary: false) + ?? new List(); var sessionsSnapshot = _sessions.ToArray(); - _logger.JobInfo("attendance", "Device/session count being processed: runtimeDevices=" + runtimeDevices.Count + ", activeSessions=" + sessionsSnapshot.Length); if (runtimeDevices.Count == 0) - _logger.JobWarn("attendance", "Attendance fetch skipped reason=no runtime devices resolved."); - if (sessionsSnapshot.Length == 0) - _logger.JobWarn("attendance", "Attendance fetch skipped reason=no active device sessions."); - if (runtimeDevices.Count == 0 || sessionsSnapshot.Length == 0) { - LogJobCycleEnd("attendance", "--Attendance job finished at "); + _logger.OpsWarn(OpsMarkers.Attendance, "SKIPPED — no runtime devices"); + _logger.Ops(OpsMarkers.Attendance, "JOB CYCLE END success=0 skipped=0 failed=0 duration=" + + FormatDuration(DateTime.Now - cycleStarted)); return; } @@ -1385,108 +1574,180 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable var runtimeIps = new HashSet( runtimeDevices.Select(d => (d.Ip ?? "").Trim()).Where(x => x.Length > 0), StringComparer.OrdinalIgnoreCase); - foreach (var d in runtimeDevices) - { - var key = DeviceIdentity.CanonicalLookupKey(d.DeviceId); - var ip = (d.Ip ?? "").Trim(); - bool hasSessionById = key.Length > 0 && sessionsSnapshot.Any(s => DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == key); - bool hasSessionByIp = ip.Length > 0 && sessionsSnapshot.Any(s => string.Equals((s.Device.Ip ?? "").Trim(), ip, StringComparison.OrdinalIgnoreCase)); - if (!hasSessionById && !hasSessionByIp) - _logger.JobWarn("attendance", "Attendance fetch skipped reason=DB machine loaded but session missing machine=" + d.DeviceId + " ip=" + (d.Ip ?? "")); - } var to = DateTime.Now; var fallbackFrom = to.AddMinutes(-_config.HistoricalFetchLookbackMinutes); if (_config.HistoricalFetchLookbackMinutes <= 0) fallbackFrom = to.AddDays(-1); - int processedSessions = 0; - foreach (var s in sessionsSnapshot) + foreach (var d in runtimeDevices) { + var key = DeviceIdentity.CanonicalLookupKey(d.DeviceId); + var ip = (d.Ip ?? "").Trim(); + var session = sessionsSnapshot.FirstOrDefault(s => + (key.Length > 0 && DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == key) || + (ip.Length > 0 && string.Equals((s.Device.Ip ?? "").Trim(), ip, StringComparison.OrdinalIgnoreCase))); + + WriteBizAttendanceConnecting(d.DeviceId ?? "", ip, d.Port); + + if (session == null) + { + skippedDevices++; + failedDevices++; + _logger.Biz(BizChannel.Attendance, + "Status : Connection failed", + "", + "Reason : Device is offline.", + ""); + _logger.BizSeparator(BizChannel.Attendance); + _logger.OpsWarn(OpsMarkers.Attendance, "device=" + d.DeviceId + " ip=" + ip + + " SKIPPED reason=\"target offline (no SDK session)\""); + _unreachableDevices.ReportFailure(d.DeviceId, d.Model ?? "", ip, d.Port, + ConnectivityFailureStage.Unknown, "Device is offline."); + continue; + } + + _logger.Biz(BizChannel.Attendance, + "Status : Connected successfully", + "", + "Fetching attendance...", + ""); + + var stats = new AttendanceDeviceCycleStats + { + DeviceId = session.Device.DeviceId ?? "", + DeviceIp = session.Device.Ip ?? "" + }; + try { - var sessionKey = DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId); - var sessionIp = (s.Device.Ip ?? "").Trim(); + var sessionKey = DeviceIdentity.CanonicalLookupKey(session.Device.DeviceId); + var sessionIp = (session.Device.Ip ?? "").Trim(); bool eligibleById = sessionKey.Length > 0 && runtimeKeys.Contains(sessionKey); bool eligibleByIp = sessionIp.Length > 0 && runtimeIps.Contains(sessionIp); if ((runtimeKeys.Count > 0 || runtimeIps.Count > 0) && !eligibleById && !eligibleByIp) { - _logger.JobWarn("attendance", "Attendance fetch skipped reason=no matching machine/session found for session device=" + s.Device.DeviceId + - " sessionIp=" + sessionIp); + skippedDevices++; + _logger.Biz(BizChannel.Attendance, "No attendance records found.", ""); + _logger.BizSeparator(BizChannel.Attendance); continue; } - processedSessions++; - var lastSync = ReadLastSyncTimestamp(s.Device.DeviceId, out var lastSyncReadReason); + var lastSync = ReadLastSyncTimestamp(session.Device.DeviceId, out _); var from = lastSync.HasValue ? lastSync.Value.AddSeconds(1) : fallbackFrom; - _logger.JobInfo("attendance", "Attendance fetch window: device=" + s.Device.DeviceId + - " from=" + from.ToString("yyyy-MM-dd HH:mm:ss") + - " to=" + to.ToString("yyyy-MM-dd HH:mm:ss") + - " lastSync=" + (lastSync.HasValue ? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(none)") + - (string.IsNullOrEmpty(lastSyncReadReason) ? "" : " lastSyncReason=" + lastSyncReadReason)); + _logger.Diag("attendance", "window device=" + session.Device.DeviceId + " from=" + from.ToString("yyyy-MM-dd HH:mm:ss") + + " to=" + to.ToString("yyyy-MM-dd HH:mm:ss")); if (from > to) { - _logger.Info("Scheduled historical ACS fetch skipped (from > to): device=" + s.Device.DeviceId + - ", from=" + from.ToString("yyyy-MM-dd HH:mm:ss") + ", to=" + to.ToString("yyyy-MM-dd HH:mm:ss") + - (string.IsNullOrEmpty(lastSyncReadReason) ? "" : ", lastSyncReason=" + lastSyncReadReason)); + skippedDevices++; + _logger.Biz(BizChannel.Attendance, "No attendance records found.", ""); + _logger.BizSeparator(BizChannel.Attendance); continue; } DateTime? lastEventTimestamp; bool dbInsertAllSucceeded; - int n = FetchAttendanceRecordsCore(s.Device.DeviceId, from, to, token, out lastEventTimestamp, out dbInsertAllSucceeded); - _logger.Info("Scheduled historical ACS fetch completed: device=" + s.Device.DeviceId + ", records=" + n + - ", window=" + from.ToString("yyyy-MM-dd HH:mm:ss") + " .. " + to.ToString("yyyy-MM-dd HH:mm:ss") + - ", lastSync=" + (lastSync.HasValue ? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(none)") + - (string.IsNullOrEmpty(lastSyncReadReason) ? "" : ", lastSyncReason=" + lastSyncReadReason) + - ", lastEventTimestamp=" + (lastEventTimestamp.HasValue ? lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(null)")); - _logger.JobInfo("attendance", "Scheduled fetch device=" + s.Device.DeviceId + " records=" + n + - " window=" + from.ToString("yyyy-MM-dd HH:mm:ss") + ".." + to.ToString("yyyy-MM-dd HH:mm:ss")); - _logger.JobInfo("attendance", "MACHINE " + s.Device.DeviceId + " has " + n + " records !"); + int n = FetchAttendanceRecordsCore(session.Device.DeviceId, from, to, token, out lastEventTimestamp, out dbInsertAllSucceeded, stats); + stats.EventsReceived = Math.Max(stats.EventsReceived, n + stats.SystemEventsSkipped + stats.Duplicates); + + if (stats.DbInserted > 0) + { + _logger.Biz(BizChannel.Attendance, + "Successfully inserted " + stats.DbInserted + " attendance records.", + ""); + foreach (var punch in stats.InsertedPunches) + { + _logger.Biz(BizChannel.Attendance, + "Employee " + punch.EmpNo + " -> " + BizFriendlyReasons.FormatTime(punch.Time)); + } + _logger.BizBlank(BizChannel.Attendance); + } + else if (stats.Failed > 0) + { + _logger.Biz(BizChannel.Attendance, + "Attendance insert failed.", + "", + "Reason : One or more records could not be saved to the database.", + ""); + } + else + { + _logger.Biz(BizChannel.Attendance, "No attendance records found.", ""); + } if (n > 0 && lastEventTimestamp.HasValue && dbInsertAllSucceeded) { - WriteLastSyncTimestamp(s.Device.DeviceId, lastEventTimestamp.Value); - if (_config.EnableDatabasePersistence && dbInsertAllSucceeded) + WriteLastSyncTimestamp(session.Device.DeviceId, lastEventTimestamp.Value); + stats.LastSyncUpdated = true; + if (_config.EnableAttendanceDbPersistence || _config.EnableDatabasePersistence) { - if (TryCleanupDeviceAttendanceStorage(s, lastEventTimestamp.Value, out var cleanupErr)) - { - _logger.JobInfo("attendance", "Device cleanup OK: machine=" + s.Device.DeviceId + - " ip=" + (s.Device.Ip ?? "") + - " checkTime=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); - } + if (TryCleanupDeviceAttendanceStorage(session, lastEventTimestamp.Value, out var cleanupErr)) + stats.CleanupResult = "OK"; else { - _logger.JobWarn("attendance", "Device cleanup FAILED: machine=" + s.Device.DeviceId + - " ip=" + (s.Device.Ip ?? "") + - " checkTime=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + - " err=" + cleanupErr); + stats.CleanupResult = "FAILED: " + cleanupErr; + _logger.Diag("attendance", "cleanup failed device=" + session.Device.DeviceId + " err=" + cleanupErr); } } + else + stats.CleanupResult = "SKIPPED (DB persistence off)"; + successDevices++; } else if (n == 0 && dbInsertAllSucceeded) { - WriteLastSyncTimestamp(s.Device.DeviceId, to); - _logger.JobInfo("attendance", "No records found; advancing last_sync_date to window end. machine=" + s.Device.DeviceId + - " timestamp=" + to.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + WriteLastSyncTimestamp(session.Device.DeviceId, to); + stats.LastSyncUpdated = true; + stats.CleanupResult = "SKIPPED (no punches)"; + successDevices++; } - else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded) + else { - _logger.JobWarn("attendance", "LastSync NOT updated for machine=" + s.Device.DeviceId + - " because DB insert failed; same data window will be retried."); - _logger.JobWarn("attendance", "Cleanup skipped: DB insert not confirmed; delete not executed. machine=" + s.Device.DeviceId); + stats.LastSyncUpdated = false; + stats.CleanupResult = "SKIPPED (DB insert failed — safety lock)"; + failedDevices++; + _logger.OpsWarn(OpsMarkers.Attendance, "device=" + stats.DeviceId + + " last_sync=NOT_UPDATED cleanup=SKIPPED reason=\"DB insert failed\""); } + + _logger.Ops(OpsMarkers.Attendance, + "device=" + stats.DeviceId + " ip=" + stats.DeviceIp + + " eventsReceived=" + stats.EventsReceived + + " validPunches=" + stats.ValidPunches + + " systemSkipped=" + stats.SystemEventsSkipped + + " dbInserted=" + stats.DbInserted + + " duplicates=" + stats.Duplicates + + " failed=" + stats.Failed + + " last_sync=" + (stats.LastSyncUpdated ? "UPDATED" : "NOT_UPDATED") + + " cleanup=" + stats.CleanupResult); + _logger.BizSeparator(BizChannel.Attendance); } catch (Exception ex) { - _logger.Error("Scheduled historical fetch failed for " + s.Device.DeviceId, ex); - _logger.JobError("attendance", "Scheduled fetch FAILED device=" + s.Device.DeviceId + " err=" + ex.Message); + failedDevices++; + _logger.Error("Scheduled historical fetch failed for " + session.Device.DeviceId, ex); + _logger.OpsError(OpsMarkers.Attendance, "device=" + session.Device.DeviceId + " FAILED reason=\"" + ex.Message + "\""); + _logger.Biz(BizChannel.Attendance, + "Attendance fetch failed.", + "", + "Reason : " + ex.Message, + ""); + _logger.BizSeparator(BizChannel.Attendance); } } - if (processedSessions == 0) - _logger.JobWarn("attendance", "Attendance fetch method returned early due to guard condition: no eligible sessions to process."); - LogJobCycleEnd("attendance", "--Attendance job finished at "); + _logger.Ops(OpsMarkers.Attendance, "JOB CYCLE END success=" + successDevices + + " skipped=" + skippedDevices + " failed=" + failedDevices + + " duration=" + FormatDuration(DateTime.Now - cycleStarted)); + } + + private void WriteBizAttendanceConnecting(string machineId, string ip, int port) + { + _logger.Biz(BizChannel.Attendance, + "CONNECTING", + "Machine ID : " + machineId, + "Machine IP : " + ip, + "Port : " + port, + ""); } private async Task TemplateFetchSchedulerLoop(CancellationToken token) @@ -1507,7 +1768,17 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable } firstRun = false; - LogJobCycleStart("template_fetch", "TEMPLATE", "--Template job started at "); + var cycleStarted = DateTime.Now; + int okCount = 0, failCount = 0; + _logger.Ops(OpsMarkers.TemplateDeviceToDb, "JOB CYCLE START direction=DEVICE -> DB"); + _logger.Biz(BizChannel.Template, + "TEMPLATE SYNC", + "", + "Direction :", + "", + "DEVICE -> DATABASE", + ""); + _logger.BizSeparator(BizChannel.Template); foreach (var s in _sessions.ToArray()) { if (token.IsCancellationRequested) @@ -1515,8 +1786,14 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable try { + _logger.Biz(BizChannel.Template, + "Device :", + "", + (s.Device.Ip ?? ""), + ""); + _logger.BizSeparator(BizChannel.Template); var outDir = Path.Combine(_config.LogDirectory, "template_fetch", DateTime.Now.ToString("yyyy-MM-dd"), s.Device.DeviceId); - _logger.JobInfo("template_fetch", "Start device=" + s.Device.DeviceId + " outDir=\"" + outDir + "\""); + _logger.Diag("template_fetch", "Start device=" + s.Device.DeviceId + " outDir=\"" + outDir + "\""); string jsonPath; int discoveredUsers; @@ -1533,51 +1810,70 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable if (ok) { - _logger.JobInfo("template_fetch", "DONE device=" + s.Device.DeviceId + " jsonPath=\"" + jsonPath + "\""); + okCount++; TryUpdateMachineRuntimeState(s.Device.Ip, "IDLE", null, discoveredUsers); - _logger.JobInfo("template_fetch", "DB attendance_machine.total_users updated: machine_ip=" + (s.Device.Ip ?? "") + - " total_users=" + discoveredUsers); + _logger.Ops(OpsMarkers.TemplateDeviceToDb, + "device=" + s.Device.DeviceId + " ip=" + (s.Device.Ip ?? "") + + " users=" + discoveredUsers + " status=OK"); } else - _logger.JobError("template_fetch", "FAILED device=" + s.Device.DeviceId + " err=" + err); + { + failCount++; + _logger.OpsError(OpsMarkers.TemplateDeviceToDb, + "device=" + s.Device.DeviceId + " FAILED reason=\"" + err + "\""); + _logger.Biz(BizChannel.Template, + "Template sync failed for this device.", + "", + "Reason :", + "", + string.IsNullOrWhiteSpace(err) ? "Unknown error." : err, + ""); + _logger.BizSeparator(BizChannel.Template); + } } catch (Exception ex) { - _logger.JobError("template_fetch", "EXCEPTION device=" + s.Device.DeviceId + " err=" + ex.Message); + failCount++; + _logger.OpsError(OpsMarkers.TemplateDeviceToDb, + "device=" + s.Device.DeviceId + " FAILED reason=\"" + ex.Message + "\""); } } - LogJobCycleEnd("template_fetch", "--Template job finished at "); + _logger.Ops(OpsMarkers.TemplateDeviceToDb, "JOB CYCLE END success=" + okCount + + " skipped=0 failed=" + failCount + " duration=" + FormatDuration(DateTime.Now - cycleStarted)); } } - private void LogJobCycleStart(string jobKey, string jobTitle, string startedLinePrefix) + private void LogJobCycleStart(string jobKey, string jobTitle, string meaning) { - var now = DateTime.Now; - _logger.JobInfo(jobKey, "========================================================"); - _logger.JobInfo(jobKey, "SERVICE STARTED"); - _logger.JobInfo(jobKey, "Time: " + now.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture)); - _logger.JobInfo(jobKey, "JOB: " + jobTitle); - _logger.JobInfo(jobKey, "========================="); - _logger.JobInfo(jobKey, startedLinePrefix + now.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture)); + var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + _logger.JobInfo(jobKey, "------------------------------------------------------------"); + _logger.JobInfo(jobKey, "JOB CYCLE START: " + jobTitle); + _logger.JobInfo(jobKey, "Meaning: " + meaning); + _logger.JobInfo(jobKey, "Started at: " + now); + _logger.JobInfo(jobKey, "------------------------------------------------------------"); } - private void LogJobCycleEnd(string jobKey, string finishedLinePrefix) + private void LogJobCycleEnd(string jobKey, string jobTitle, string meaning) { - var now = DateTime.Now; - _logger.JobInfo(jobKey, finishedLinePrefix + now.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture)); - _logger.JobInfo(jobKey, "SERVICE ENDED"); - _logger.JobInfo(jobKey, "Time: " + now.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.InvariantCulture)); - _logger.JobInfo(jobKey, "========================================================"); + var now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + _logger.JobInfo(jobKey, "------------------------------------------------------------"); + _logger.JobInfo(jobKey, "JOB CYCLE END: " + jobTitle); + _logger.JobInfo(jobKey, "Meaning: " + meaning); + _logger.JobInfo(jobKey, "Finished at: " + now); + _logger.JobInfo(jobKey, "------------------------------------------------------------"); } - private bool PersistHistoricalAttendanceEvent(AttendanceEvent ev, out bool dbInserted) + private bool PersistHistoricalAttendanceEvent(AttendanceEvent ev, out bool dbOk, AttendanceDeviceCycleStats? stats = null) { - dbInserted = false; + dbOk = true; if (ev == null) return false; if (!_dedupeKeys.TryAdd(ev.DedupeKey, 1)) + { + if (stats != null) stats.Duplicates++; return false; + } if (_dedupeKeys.Count > DedupeMaxEntries) { @@ -1592,12 +1888,32 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable if (_config.KeepAttendanceFileExport || !_config.EnableAttendanceDbPersistence) AppendAttendanceToTextFileSafely(ev); - dbInserted = WriteAttendanceToDatabase(ev); - if (dbInserted) + + var outcome = WriteAttendanceToDatabase(ev, stats); + switch (outcome) { - // Keep status updated but do not overwrite last_sync_date cursor (that is handled by WriteLastSyncTimestamp). - TryUpdateMachineRuntimeState(ev.DeviceIp ?? "", "synced", null, null); + case AttendancePersistOutcome.DbInserted: + if (stats != null) { stats.ValidPunches++; stats.DbInserted++; } + dbOk = true; + TryUpdateMachineRuntimeState(ev.DeviceIp ?? "", "synced", null, null); + break; + case AttendancePersistOutcome.SkippedNoEmployee: + if (stats != null) stats.SystemEventsSkipped++; + dbOk = true; + break; + case AttendancePersistOutcome.DbFailed: + if (stats != null) { stats.ValidPunches++; stats.Failed++; } + dbOk = false; + break; + case AttendancePersistOutcome.PersistedWithoutDb: + if (stats != null) stats.ValidPunches++; + dbOk = true; + break; + default: + dbOk = true; + break; } + return true; } @@ -1729,36 +2045,26 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable DateTime toLocal, CancellationToken cancellationToken, out DateTime? lastEventTimestamp, - out bool dbInsertAllSucceeded) + out bool dbInsertAllSucceeded, + AttendanceDeviceCycleStats? stats = null) { lastEventTimestamp = null; dbInsertAllSucceeded = true; var canonicalKey = DeviceIdentity.CanonicalLookupKey(deviceId); - _logger.Info("FetchAttendanceRecordsCore: enter historical fetch; requestedDeviceId=" + deviceId + - " canonicalKey=" + (canonicalKey.Length == 0 ? "(empty)" : canonicalKey)); + _logger.Diag("attendance", "FetchAttendanceRecordsCore device=" + deviceId + " key=" + canonicalKey); var session = FindSession(deviceId); if (session == null) { - _logger.Error("FetchAttendanceRecordsCore: session lookup FAILED (device not logged in). requestedDeviceId=" + deviceId + - " canonicalKey=" + (canonicalKey.Length == 0 ? "(empty)" : canonicalKey) + - "; activeSessionIds=" + string.Join(", ", _sessions.Select(s => s.Device.DeviceId))); + _logger.OpsError(OpsMarkers.Attendance, "device=" + deviceId + " FAILED reason=\"not logged in\""); return 0; } - _logger.Info("FetchAttendanceRecordsCore: session lookup OK userId=" + session.UserId + " sessionDeviceId=" + session.Device.DeviceId); - if (toLocal < fromLocal) - { - _logger.Warn("FetchAttendanceRecordsCore: toLocal < fromLocal; swapping."); (fromLocal, toLocal) = (toLocal, fromLocal); - } - _logger.Info("Historical ACS fetch START device=" + deviceId + " userId=" + session.UserId + - " (session from login only; alarm channel not required) from=" + fromLocal.ToString("yyyy-MM-dd HH:mm:ss") + + _logger.Diag("attendance", "Fetch START device=" + deviceId + " from=" + fromLocal.ToString("yyyy-MM-dd HH:mm:ss") + " to=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss")); - _logger.JobInfo("attendance", "Fetch START device=" + deviceId + " from=" + - fromLocal.ToString("yyyy-MM-dd HH:mm:ss") + " to=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss")); // Prioritize the officially documented STDXMLConfig + ISAPI AccessControl path for DS-K1T642MFW. // If anything fails (SDK call error, parse error, unexpected response), we fall back to NET_DVR_GET_ACS_EVENT. @@ -1773,7 +2079,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable cancellationToken, out attemptedStdXml, out stdLastEventTs, - out dbInsertAllSucceeded); + out dbInsertAllSucceeded, + stats); if (attemptedStdXml) { lastEventTimestamp = stdLastEventTs; @@ -1836,10 +2143,10 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable total++; if (TryBuildAttendanceFromAcsCfg(session, ref cfg, out var ev)) { - if (PersistHistoricalAttendanceEvent(ev, out var dbInserted)) + if (PersistHistoricalAttendanceEvent(ev, out var dbInserted, stats)) { parsedOk++; - if (_config.EnableDatabasePersistence && !dbInserted) + if ((_config.EnableAttendanceDbPersistence || _config.EnableDatabasePersistence) && !dbInserted) dbInsertAllSucceeded = false; } @@ -1852,6 +2159,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable else { parseFail++; + if (stats != null) stats.SystemEventsSkipped++; } continue; @@ -1943,7 +2251,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable CancellationToken cancellationToken, out bool attemptedStdXml, out DateTime? lastEventTimestamp, - out bool dbInsertAllSucceeded) + out bool dbInsertAllSucceeded, + AttendanceDeviceCycleStats? stats = null) { attemptedStdXml = false; lastEventTimestamp = null; @@ -2053,14 +2362,15 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable isSuccessByMinorRule, out var ev)) { parseFail++; + if (stats != null) stats.SystemEventsSkipped++; continue; } - if (PersistHistoricalAttendanceEvent(ev, out var dbInserted)) + if (PersistHistoricalAttendanceEvent(ev, out var dbInserted, stats)) { parsedOk++; pageParsedOk++; - if (_config.EnableDatabasePersistence && !dbInserted) + if ((_config.EnableAttendanceDbPersistence || _config.EnableDatabasePersistence) && !dbInserted) dbInsertAllSucceeded = false; } @@ -4182,6 +4492,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable " template=" + (userPayload.face.present ? "fetched" : "not_fetched")); if (_config.EnableDbIntegration && _config.EnableTemplateDbPersistence && + _config.EnableTemplateDeviceToDbSync && userPayload.face.present && !string.IsNullOrWhiteSpace(userPayload.face.dataBase64) && _attendanceMachineFaceTemplateRepository != null) @@ -4189,14 +4500,51 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable try { var bytes = Convert.FromBase64String(userPayload.face.dataBase64); - if (!_attendanceMachineFaceTemplateRepository.UpsertFaceTemplate(cardNo.Trim(), bytes, DateTime.UtcNow, true, out var dbErr)) - _logger.Warn("Template DB upsert failed emp_no=" + cardNo.Trim() + " err=" + dbErr); + if (_attendanceMachineFaceTemplateRepository.UpsertFaceTemplate(cardNo.Trim(), bytes, DateTime.UtcNow, true, deviceId.Trim(), session.Device.Ip, out var createdNew, out var dbErr)) + { + _logger.Ops(OpsMarkers.TemplateDeviceToDb, + cardNo.Trim() + " -> DB = FACE TEMPLATE " + (createdNew ? "INSERTED" : "UPDATED") + " SUCCESSFULLY"); + _logger.Totals.TemplatesSaved++; + _logger.Biz(BizChannel.Template, + "Employee " + cardNo.Trim(), + "", + "Face Template", + "", + "Saved successfully.", + ""); + _logger.BizSeparator(BizChannel.Template); + } + else + { + _logger.OpsError(OpsMarkers.TemplateDeviceToDb, + cardNo.Trim() + " -> DB = FACE TEMPLATE FAILED reason=\"" + dbErr + "\""); + _logger.Totals.TemplatesFailed++; + _logger.Biz(BizChannel.Template, + "Employee " + cardNo.Trim(), + "", + "Face Template", + "", + "Save failed.", + "", + "Reason :", + "", + dbErr ?? "Unknown error.", + ""); + _logger.BizSeparator(BizChannel.Template); + } } catch (Exception ex) { - _logger.Warn("Template DB upsert decode failed emp_no=" + cardNo.Trim() + " err=" + ex.Message); + _logger.OpsError(OpsMarkers.TemplateDeviceToDb, + cardNo.Trim() + " -> DB = FACE TEMPLATE FAILED reason=\"" + ex.Message + "\""); + _logger.Totals.TemplatesFailed++; } } + else if (!userPayload.face.present) + { + _logger.Ops(OpsMarkers.TemplateDeviceToDb, + cardNo.Trim() + " -> DB = FACE TEMPLATE SKIPPED reason=\"no face on device\""); + } if (userPayload.face.present) faceFetched.Add(cardNo.Trim()); else faceNotFetched.Add(cardNo.Trim()); // Fingerprint flow: capability-first, then FingerPrintDownload family. @@ -4243,6 +4591,19 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable } bool anyFingerprintFetched = userPayload.fingerprints.Any(x => x.present); + if (anyFingerprintFetched) + { + foreach (var fp in userPayload.fingerprints.Where(x => x.present)) + { + _logger.Ops(OpsMarkers.TemplateDeviceToDb, + cardNo.Trim() + " -> FILE = FINGERPRINT TEMPLATE INSERTED SUCCESSFULLY fingerId=" + fp.fingerId); + } + } + else + { + _logger.Ops(OpsMarkers.TemplateDeviceToDb, + cardNo.Trim() + " -> FILE = FINGERPRINT TEMPLATE SKIPPED reason=\"no fingerprint on device\""); + } templateFingerStatusSw.WriteLine(DateTime.UtcNow.ToString("o") + " machine_name=" + deviceId + " machine_ip=" + (session.Device.Ip ?? "") + @@ -4416,7 +4777,12 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable value = (int)dd; return true; } - if (kv.Value is string s && int.TryParse(s, out var p)) + if (kv.Value is decimal dec) + { + value = (int)dec; + return true; + } + if (kv.Value is string s && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) { value = p; return true; @@ -4519,20 +4885,20 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable { attendanceEvent = null!; - // Required-ish fields mentioned in your guide excerpt: - // - employeeNoString - // - currentVerifyMode - // - attendanceStatus - // - statusValue - + // Required identity for attendance_log: employeeNoString only (never cardNo). string? empStr = TryGetString(info, "employeeNoString", "employeeNo", "employeeNoStr"); - int? employeeNo = null; - if (!string.IsNullOrWhiteSpace(empStr) && int.TryParse(empStr.Trim(), out var parsedEmp)) - employeeNo = parsedEmp; + if (string.IsNullOrWhiteSpace(empStr)) + return false; // system / non-attendance event (major/minor only) - string? userIdentifier = string.IsNullOrWhiteSpace(empStr) ? null : empStr.Trim(); + empStr = empStr.Trim(); + if (!int.TryParse(empStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedEmp)) + return false; - // Optional fields: card/door/reader vary by device & config. + int? employeeNo = parsedEmp; + string? userIdentifier = empStr; + string? employeeNoString = empStr; + + // Optional fields: card/door/reader vary by device & config (card is logged in files only, not attendance_log). string? cardNo = TryGetString(info, "cardNoString", "cardNo"); int doorNo = TryGetInt(info, "doorNo", 0); int readerNo = TryGetInt(info, "readerNo", 0); @@ -4570,6 +4936,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable device.Ip ?? "", ts, employeeNo, + employeeNoString, userIdentifier, string.IsNullOrWhiteSpace(cardNo) ? null : cardNo, doorNo, @@ -4869,11 +5236,13 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable uint empNo = info.dwEmployeeNo; string cardNo = DecodeCardNo(info.byCardNo); + // attendance_log identity = employee number only (not card). bool hasEmployee = empNo != 0; - bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0"; - if (!hasEmployee && !hasCard) + if (!hasEmployee) return; + string employeeNoString = empNo.ToString(CultureInfo.InvariantCulture); + DateTime ts = FromSdkTime(acsAlarm.struTime); bool isSuccess = AcsAttendanceParser.ResolveIsSuccess(rawMajor, rawMinor, eventName); string method = AcsAttendanceParser.InferMethodFromAcsDetail(rawMinor, info.byCardReaderKind, 0, eventName); @@ -4891,13 +5260,15 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable } string eventType = AcsAttendanceParser.MapMajorCategory(rawMajor) + "/" + rawMinor.ToString("X"); + bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0"; var attendanceEvent = new AttendanceEvent( deviceId, deviceIp, ts, - hasEmployee ? (int?)((int)empNo) : null, - null, + (int)empNo, + employeeNoString, + employeeNoString, hasCard ? cardNo : null, (int)info.dwDoorNo, (int)info.dwCardReaderNo, @@ -4954,17 +5325,25 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable uint empNum = detail.dwEmployeeNo; string cardNo = DecodeCardNo(detail.byCardNo); - bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0"; bool hasEmpNum = empNum != 0; bool hasEmpStr = !string.IsNullOrWhiteSpace(userIdStr); - if (!hasCard && !hasEmpNum && !hasEmpStr) + // Require a parseable employee number — card-only / system events are not attendance_log rows. + string? employeeNoString = null; + int? employeeNo = null; + if (hasEmpStr && int.TryParse(userIdStr.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedFromStr)) + { + employeeNoString = parsedFromStr.ToString(CultureInfo.InvariantCulture); + employeeNo = parsedFromStr; + } + else if (hasEmpNum) + { + employeeNo = (int)empNum; + employeeNoString = empNum.ToString(CultureInfo.InvariantCulture); + } + else return false; - int? employeeNo = null; - if (hasEmpNum) - employeeNo = (int)empNum; - else if (hasEmpStr && int.TryParse(userIdStr, out var parsed)) - employeeNo = parsed; + bool hasCard = !string.IsNullOrWhiteSpace(cardNo) && cardNo != "0"; DateTime ts = FromSdkTime(cfg.struTime); bool isSuccess = AcsAttendanceParser.ResolveIsSuccess(cfg.dwMajor, cfg.dwMinor, eventName); @@ -4981,7 +5360,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable session.Device.Ip ?? "", ts, employeeNo, - hasEmpStr ? userIdStr : null, + employeeNoString, + employeeNoString, hasCard ? cardNo : null, (int)detail.dwDoorNo, (int)detail.dwCardReaderNo, @@ -5273,60 +5653,71 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable return value; } - private bool WriteAttendanceToDatabase(AttendanceEvent ev) + private AttendancePersistOutcome WriteAttendanceToDatabase(AttendanceEvent ev, AttendanceDeviceCycleStats? stats = null) { if (_config.EnableDbIntegration && _config.EnableAttendanceDbPersistence && _attendanceLogRepository != null) { - var acNo = ev.EmployeeNo.HasValue - ? ev.EmployeeNo.Value.ToString(CultureInfo.InvariantCulture) - : (ev.UserIdentifier ?? ""); + var employeeNoString = (ev.EmployeeNoString ?? ev.UserIdentifier ?? "").Trim(); + if (string.IsNullOrWhiteSpace(employeeNoString)) + { + _logger.Diag("attendance", "skip no employeeNoString major=" + ev.RawMajor + " minor=" + ev.RawMinor + + " event=\"" + (ev.EventName ?? "") + "\""); + return AttendancePersistOutcome.SkippedNoEmployee; + } + + if (!int.TryParse(employeeNoString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var employeeNo)) + { + _logger.Diag("attendance", "skip invalid employeeNoString=\"" + employeeNoString + "\""); + return AttendancePersistOutcome.SkippedNoEmployee; + } + + var acNo = employeeNo.ToString(CultureInfo.InvariantCulture); var inOutTypeId = ev.RawMinor > 0 ? (int)ev.RawMinor : 0; - var ok = _attendanceLogRepository.UpsertAttendance( + var ok = _attendanceLogRepository.InsertAttendance( acNo, + employeeNo, ev.Timestamp, 0, ev.DeviceId ?? "", inOutTypeId, ev.DeviceIp ?? "", ev.Timestamp.Date, + msg => _logger.Diag("attendance", msg), out var err); + if (!ok) { - _logger.Error("WriteAttendanceToDatabase MySQL(REPLACE) failed: " + err); - return false; + _logger.OpsError(OpsMarkers.Attendance, "DB insert FAILED ac_no=" + acNo + + " device=" + (ev.DeviceId ?? "") + " reason=\"" + err + "\""); + _logger.Totals.AttendanceFailed++; + return AttendancePersistOutcome.DbFailed; } - _logger.Info( - "[AttendanceInsert] machine_id=" + (ev.DeviceId ?? "") + - " ip=" + (ev.DeviceIp ?? "") + - " emp_no=" + (ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString(CultureInfo.InvariantCulture) : "") + - " checktime=" + ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + - " rows=1"); - return true; + _logger.Totals.AttendanceInserted++; + stats?.InsertedPunches.Add((ev.DeviceIp ?? "", acNo, ev.Timestamp)); + return AttendancePersistOutcome.DbInserted; } if (!_config.EnableDatabasePersistence) - return true; + return AttendancePersistOutcome.PersistedWithoutDb; if (string.IsNullOrWhiteSpace(_config.SqlConnectionString)) - return false; + return AttendancePersistOutcome.DbFailed; try { using (var conn = new SqlConnection(_config.SqlConnectionString)) { conn.Open(); - // EventType column = SDK descriptive name (same as pre-change behavior). Add optional columns in DB as needed. var sql = "INSERT INTO " + _config.AttendanceTableName + " " + - "(DeviceId, DeviceIp, EmployeeId, UserIdentifier, CardNo, EventType, AttendanceMethod, EventTimestamp, DoorNo, ReaderNo, IsSuccess, RawMajor, RawMinor, EventSource) " + - "VALUES (@DeviceId,@DeviceIp,@EmployeeId,@UserIdentifier,@CardNo,@EventType,@AttendanceMethod,@EventTimestamp,@DoorNo,@ReaderNo,@IsSuccess,@RawMajor,@RawMinor,@EventSource)"; + "(DeviceId, DeviceIp, EmployeeId, UserIdentifier, EventType, AttendanceMethod, EventTimestamp, DoorNo, ReaderNo, IsSuccess, RawMajor, RawMinor, EventSource) " + + "VALUES (@DeviceId,@DeviceIp,@EmployeeId,@UserIdentifier,@EventType,@AttendanceMethod,@EventTimestamp,@DoorNo,@ReaderNo,@IsSuccess,@RawMajor,@RawMinor,@EventSource)"; using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@DeviceId", (object)ev.DeviceId ?? DBNull.Value); cmd.Parameters.AddWithValue("@DeviceIp", (object)ev.DeviceIp ?? DBNull.Value); cmd.Parameters.AddWithValue("@EmployeeId", (object)ev.EmployeeNo ?? DBNull.Value); - cmd.Parameters.AddWithValue("@UserIdentifier", (object)ev.UserIdentifier ?? DBNull.Value); - cmd.Parameters.AddWithValue("@CardNo", (object)ev.CardNo ?? DBNull.Value); + cmd.Parameters.AddWithValue("@UserIdentifier", (object)(ev.EmployeeNoString ?? ev.UserIdentifier) ?? DBNull.Value); cmd.Parameters.AddWithValue("@EventType", (object)ev.EventName ?? DBNull.Value); cmd.Parameters.AddWithValue("@AttendanceMethod", (object)ev.AttendanceMethod ?? DBNull.Value); cmd.Parameters.AddWithValue("@EventTimestamp", ev.Timestamp); @@ -5336,21 +5727,15 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable cmd.Parameters.AddWithValue("@RawMajor", ev.RawMajor); cmd.Parameters.AddWithValue("@RawMinor", ev.RawMinor); cmd.Parameters.AddWithValue("@EventSource", (object)ev.Source ?? DBNull.Value); - int rows = cmd.ExecuteNonQuery(); - _logger.Info( - "[AttendanceInsert] machine_id=" + (ev.DeviceId ?? "") + - " ip=" + (ev.DeviceIp ?? "") + - " emp_no=" + (ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString(CultureInfo.InvariantCulture) : "") + - " checktime=" + ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + - " rows=" + rows); + cmd.ExecuteNonQuery(); } } - return true; + return AttendancePersistOutcome.DbInserted; } catch (Exception ex) { - _logger.Error("WriteAttendanceToDatabase failed (extend table: UserIdentifier NVARCHAR, ReaderNo INT, EventSource NVARCHAR)", ex); - return false; + _logger.Error("WriteAttendanceToDatabase failed (SQL Server path)", ex); + return AttendancePersistOutcome.DbFailed; } } @@ -5498,6 +5883,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable string deviceIp, DateTime timestamp, int? employeeNo, + string? employeeNoString, string? userIdentifier, string? cardNo, int doorNo, @@ -5515,6 +5901,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable DeviceIp = deviceIp; Timestamp = timestamp; EmployeeNo = employeeNo; + EmployeeNoString = employeeNoString; UserIdentifier = userIdentifier; CardNo = cardNo; DoorNo = doorNo; @@ -5533,6 +5920,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable public string DeviceIp { get; } public DateTime Timestamp { get; } public int? EmployeeNo { get; } + /// Hikvision employeeNoString — sole identity for attendance_log.ac_no. + public string? EmployeeNoString { get; } public string? UserIdentifier { get; } public string? CardNo { get; } public int DoorNo { get; } @@ -5550,7 +5939,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable Source + "|" + DeviceId + "|" + (HistorySerialNo != 0 ? HistorySerialNo.ToString() : Timestamp.ToString("yyyyMMddHHmmss") + "|" + RawMajor + "|" + RawMinor) + "|" + - (EmployeeNo?.ToString() ?? "") + "|" + (UserIdentifier ?? "") + "|" + (CardNo ?? "") + "|" + + (EmployeeNo?.ToString() ?? "") + "|" + (EmployeeNoString ?? UserIdentifier ?? "") + "|" + DoorNo + "|" + ReaderNo; } }