diff --git a/HikvisionAttendanceManager.UserSync.cs b/HikvisionAttendanceManager.UserSync.cs index 7a077c6..60a5783 100644 --- a/HikvisionAttendanceManager.UserSync.cs +++ b/HikvisionAttendanceManager.UserSync.cs @@ -89,51 +89,7 @@ internal sealed partial class HikvisionAttendanceManager return; } - var targets = new List(); - foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty()) - { - var t = ResolveDeviceConfigByIp(targetIp); - if (t == null || string.IsNullOrWhiteSpace(t.Ip)) - { - _logger.Warn("UserSync: target skipped — not found or IP empty. TargetMachineIp=\"" + targetIp + "\"."); - continue; - } - if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase))) - continue; - // Same IP allowed for DB->device restore (user deleted on device, restore from DB). - if (hasSource && - string.Equals((t.Ip ?? "").Trim(), (source!.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) && - !_config.EnableTemplateDbToDeviceSync) - { - _logger.Diag("user_sync", "target skipped — same as source IP and DB->device restore disabled. TargetMachineIp=\"" + targetIp + "\"."); - continue; - } - targets.Add(t); - } - foreach (var tid in _config.TargetDeviceIds ?? Enumerable.Empty()) - { - var t = ResolveDeviceConfig(tid); - if (t == null || string.IsNullOrWhiteSpace(t.Ip)) - { - _logger.Warn("UserSync: target skipped — not found or IP empty. TargetDeviceId=\"" + tid + "\"."); - continue; - } - - if (hasSource && - DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source!.DeviceId)) - { - if (!_config.EnableTemplateDbToDeviceSync) - { - _logger.Warn("UserSync: target skipped — same as source. deviceId=\"" + t.DeviceId + "\"."); - continue; - } - if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase))) - continue; - } - - targets.Add(t); - } - + var targets = CollectTestedSyncTargets(source, hasSource, ct); if (targets.Count == 0) { // Deletion may have already run above; copy/template sync simply has nothing to do. @@ -233,36 +189,6 @@ internal sealed partial class HikvisionAttendanceManager foreach (var target in targets) { ct.ThrowIfCancellationRequested(); - var hasSession = _sessions.Any(s => - string.Equals((s.Device.Ip ?? "").Trim(), (target.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) || - DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == DeviceIdentity.CanonicalLookupKey(target.DeviceId)); - if (!hasSession) - { - _logger.OpsWarn(OpsMarkers.TemplateDbToDevice, - "target=" + target.DeviceId + " ip=" + (target.Ip ?? "") + - " SKIPPED reason=\"target offline\""); - _logger.Biz(BizChannel.UserSync, - "MACHINE " + (target.DeviceId ?? ""), - "", - "Machine is not connected.", - ""); - _logger.BizSeparator(BizChannel.UserSync); - if (_config.EnableTemplateDbToDeviceSync) - { - _logger.Biz(BizChannel.Template, - "Target Device :", - "", - (target.Ip ?? ""), - "", - "Machine is not connected.", - ""); - _logger.BizSeparator(BizChannel.Template); - } - foreach (var u in sourceUsers) - _logger.Ops(OpsMarkers.TemplateDbToDevice, - u.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE SKIPPED reason=\"target offline\""); - continue; - } _logger.Biz(BizChannel.UserSync, "MACHINE " + (target.DeviceId ?? "") + " -> Total Users : " + sourceUsers.Count, @@ -299,6 +225,8 @@ internal sealed partial class HikvisionAttendanceManager int created = 0, faceUp = 0, skipped = 0, failed = 0, updated = 0; var statusCounts = new Dictionary(); var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>(); + var failedEmployees = new List<(string EmployeeNo, string Reason)>(); + var skippedEmployees = new List<(string EmployeeNo, string Reason)>(); void Bump(UserSyncStatus s) { @@ -319,20 +247,54 @@ internal sealed partial class HikvisionAttendanceManager bool createUserLogged = false; bool faceUploadedLogged = false; bool templateFoundLogged = false; + string dbCreateUserResult = "skipped"; + string dbFaceUploadResult = "skipped"; + string dbVerificationResult = "skipped"; + string dbFinalResult = "skipped"; try { if (tgtRow == null) { - if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr)) + if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr, out var alreadyExisted)) { + dbCreateUserResult = "failed"; + dbFinalResult = string.IsNullOrWhiteSpace(createErr) ? "create_user_failed" : createErr; + var createReason = DescribeUserSyncFailure("create", createErr); + failedEmployees.Add((srcUser.EmployeeNo, createReason)); _logger.Warn("UserSync: FailedCreate employeeNo=" + srcUser.EmployeeNo + " target=" + target.DeviceId + " err=" + createErr); + _logger.Biz(BizChannel.UserSync, + "Machine ID : " + (target.DeviceId ?? ""), + "Machine IP : " + (target.Ip ?? ""), + "", + srcUser.EmployeeNo + " user create failed.", + "", + "Reason :", + "", + createReason, + ""); failed++; Bump(UserSyncStatus.FailedCreate); continue; } + if (alreadyExisted) + { + dbCreateUserResult = "exists"; + _logger.Biz(BizChannel.UserSync, srcUser.EmployeeNo + " already present.", ""); + tgtRow = new UserDto + { + EmployeeNo = srcUser.EmployeeNo, + Name = srcUser.Name, + NumOfFace = 0, + NumOfFp = 0 + }; + targetMap[srcUser.EmployeeNo] = tgtRow; + } + else + { + dbCreateUserResult = "ok"; created++; createUserLogged = true; _logger.Totals.UsersAdded++; @@ -372,8 +334,12 @@ internal sealed partial class HikvisionAttendanceManager NumOfFp = 0 }; targetMap[srcUser.EmployeeNo] = tgtRow; + } } - else if (pol.UpdateExistingUserFields && UserFieldsDiffer(srcUser, tgtRow)) + else + { + dbCreateUserResult = "exists"; + if (pol.UpdateExistingUserFields && UserFieldsDiffer(srcUser, tgtRow)) { if (TryModifyUserOnTarget(target, srcUser, maxRetries, ct, out var modErr)) { @@ -397,12 +363,25 @@ internal sealed partial class HikvisionAttendanceManager } else { + var modReason = DescribeUserSyncFailure("modify", modErr); + failedEmployees.Add((srcUser.EmployeeNo, modReason)); _logger.Warn("UserSync: FailedUserModify employeeNo=" + srcUser.EmployeeNo + " target=" + target.DeviceId + " err=" + modErr); + _logger.Biz(BizChannel.UserSync, + "Machine ID : " + (target.DeviceId ?? ""), + "Machine IP : " + (target.Ip ?? ""), + "", + srcUser.EmployeeNo + " user update failed.", + "", + "Reason :", + "", + modReason, + ""); failed++; Bump(UserSyncStatus.FailedUserModify); } } + } bool hasFaceBytes = faceBytesByEmployee.TryGetValue(srcUser.EmployeeNo, out var fb) && fb != null && fb.Length > 0; @@ -426,6 +405,9 @@ internal sealed partial class HikvisionAttendanceManager templateFoundLogged = hasFaceBytes; if (!hasFaceBytes) { + dbFaceUploadResult = "missing"; + dbVerificationResult = "skipped"; + dbFinalResult = "template_missing"; skipped++; _logger.Ops(OpsMarkers.TemplateDbToDevice, srcUser.EmployeeNo + " -> Device " + target.DeviceId + @@ -447,13 +429,28 @@ internal sealed partial class HikvisionAttendanceManager Bump(UserSyncStatus.AlreadySynced); } else + { + skippedEmployees.Add((srcUser.EmployeeNo, + "Face template missing (no face in DB/device source).")); + _logger.Biz(BizChannel.UserSync, + srcUser.EmployeeNo + " face sync skipped.", + "", + "Reason :", + "", + "Face template missing (no face in DB/device source).", + ""); Bump(createdNew ? UserSyncStatus.CreatedNoFaceAvailable : UserSyncStatus.ExistsNoFaceOnSourceSkipped); + } continue; } if (pol.UploadFaceIfMissingOnly && tgtRow.NumOfFace > 0) { + dbFaceUploadResult = "skipped_exists"; + dbVerificationResult = "ok"; + dbFinalResult = "already_synced"; skipped++; + skippedEmployees.Add((srcUser.EmployeeNo, "Face already exists on device and overwrite is disabled.")); _logger.Ops(OpsMarkers.TemplateDbToDevice, srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE SKIPPED reason=\"already exists and overwrite disabled\""); @@ -475,10 +472,22 @@ internal sealed partial class HikvisionAttendanceManager if (!UploadFaceOnTarget(target, srcUser.EmployeeNo, fb!, maxRetries, ct, out var upErr)) { - faceRejectedEmployees.Add((srcUser.EmployeeNo, string.IsNullOrWhiteSpace(upErr) ? "face upload rejected" : upErr)); + dbFaceUploadResult = "failed"; + dbVerificationResult = "failed"; + dbFinalResult = string.IsNullOrWhiteSpace(upErr) ? "face_upload_failed" : upErr; + var faceReason = DescribeUserSyncFailure("face", upErr); + faceRejectedEmployees.Add((srcUser.EmployeeNo, faceReason)); + failedEmployees.Add((srcUser.EmployeeNo, faceReason)); _logger.OpsError(OpsMarkers.TemplateDbToDevice, srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\""); _logger.Totals.TemplatesFailed++; + _logger.Biz(BizChannel.UserSync, + srcUser.EmployeeNo + " face enrollment failed.", + "", + "Reason :", + "", + faceReason, + ""); if (_config.EnableTemplateDbToDeviceSync) { _logger.Biz(BizChannel.Template, @@ -490,7 +499,7 @@ internal sealed partial class HikvisionAttendanceManager "", "Reason :", "", - string.IsNullOrWhiteSpace(upErr) ? "Unknown error." : upErr, + faceReason, ""); _logger.BizSeparator(BizChannel.Template); } @@ -501,6 +510,9 @@ internal sealed partial class HikvisionAttendanceManager faceUp++; faceUploadedLogged = true; + dbFaceUploadResult = "ok"; + dbVerificationResult = "ok"; + dbFinalResult = "success"; _logger.Totals.TemplatesSaved++; tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1); _logger.Ops(OpsMarkers.TemplateDbToDevice, @@ -525,6 +537,15 @@ internal sealed partial class HikvisionAttendanceManager " templateFound=" + (templateFoundLogged ? "true" : "false") + " createUser=" + (createUserLogged ? "true" : "false") + " faceUploaded=" + (faceUploadedLogged ? "true" : "false")); + if (_config.EnableTemplateDbToDeviceSync) + { + LogDbToDeviceResult( + srcUser.EmployeeNo, + dbCreateUserResult, + dbFaceUploadResult, + dbVerificationResult, + dbFinalResult); + } } } @@ -563,6 +584,8 @@ internal sealed partial class HikvisionAttendanceManager var statusLine = string.Join(", ", statusCounts.OrderBy(kv => kv.Key.ToString()) .Select(kv => kv.Key + "=" + kv.Value)); WriteFaceRejectedEmployeeSummary("TEMPLATE_DB_TO_DEVICE", target, faceRejectedEmployees); + WriteUserSyncTargetConclusion(target, created, faceUp, updated, skipped, failed, + failedEmployees, skippedEmployees); _logger.Ops(OpsMarkers.TemplateDbToDevice, "device=" + target.DeviceId + " summary created=" + created + " faceUploaded=" + faceUp + " updated=" + updated + @@ -574,6 +597,80 @@ internal sealed partial class HikvisionAttendanceManager _logger.Ops(OpsMarkers.TemplateDbToDevice, "Cycle completed id=" + cycleId); } + private void WriteUserSyncTargetConclusion( + HikvisionAttendanceWindowsService.DeviceConfig target, + int created, + int faceUploaded, + int updated, + int skipped, + int failed, + IReadOnlyList<(string EmployeeNo, string Reason)> failedEmployees, + IReadOnlyList<(string EmployeeNo, string Reason)> skippedEmployees) + { + var lines = new List + { + "USER SYNC SUMMARY", + "", + "Machine ID : " + (target.DeviceId ?? ""), + "Machine IP : " + (target.Ip ?? ""), + "Created Users : " + created, + "Updated Users : " + updated, + "Face Uploaded : " + faceUploaded, + "Skipped Users : " + skipped, + "Failed Users : " + failed, + "" + }; + + if (failedEmployees != null && failedEmployees.Count > 0) + { + lines.Add("CONCLUSION — FAILED USERS:"); + foreach (var item in failedEmployees + .GroupBy(x => x.EmployeeNo, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)) + { + var reasons = string.Join(" | ", item + .Select(x => ToOneLineSnippet(x.Reason, 240)) + .Distinct(StringComparer.OrdinalIgnoreCase)); + lines.Add(" " + item.Key + " : " + reasons); + } + + lines.Add(""); + } + + if (skippedEmployees != null && skippedEmployees.Count > 0) + { + lines.Add("SKIPPED USERS:"); + foreach (var item in skippedEmployees + .OrderBy(x => x.EmployeeNo, StringComparer.OrdinalIgnoreCase)) + { + lines.Add(" " + item.EmployeeNo + " : " + ToOneLineSnippet(item.Reason, 240)); + } + + lines.Add(""); + } + + if (failed == 0 && (failedEmployees == null || failedEmployees.Count == 0)) + lines.Add("CONCLUSION : All processed users succeeded (or were intentionally skipped)."); + + _logger.Biz(BizChannel.UserSync, lines.ToArray()); + } + + private static string DescribeUserSyncFailure(string stage, string? rawError) + { + var detail = string.IsNullOrWhiteSpace(rawError) ? "Unknown error." : rawError.Trim(); + switch ((stage ?? "").Trim().ToLowerInvariant()) + { + case "create": + return "User create failed on device. " + detail; + case "modify": + return "User update failed on device. " + detail; + case "face": + return "Face enrollment failed on device. " + detail; + default: + return detail; + } + } + private void WriteFaceRejectedEmployeeSummary( string diagTag, HikvisionAttendanceWindowsService.DeviceConfig target, @@ -892,22 +989,48 @@ internal sealed partial class HikvisionAttendanceManager public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user) { - return CreateUserOnTarget(target, user, SyncPol.HttpMaxRetries, CancellationToken.None, out _); + return CreateUserOnTarget(target, user, SyncPol.HttpMaxRetries, CancellationToken.None, out _, out _); } public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user, int maxRetries, CancellationToken ct, out string error) + { + return CreateUserOnTarget(target, user, maxRetries, ct, out error, out _); + } + + public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user, int maxRetries, + CancellationToken ct, out string error, out bool alreadyExisted) { error = ""; + alreadyExisted = false; var ser = new JavaScriptSerializer(); var root = BuildUserInfoRecordPayload(user); string json = ser.Serialize(root); if (!TryIsapiPostJsonWithRetry(target, "/ISAPI/AccessControl/UserInfo/Record?format=json", json, maxRetries, ct, out var body, out var status, out error)) + { + // Only treat as "already present" when the device says so or the user is confirmed on-device. + if (IsUserAlreadyExistsOnDeviceResponse(status, body, error) || + (status == 400 && VerifyUserOnTarget(target, user.EmployeeNo ?? "", out _))) + { + alreadyExisted = true; + error = ""; + return true; + } + return false; + } if (!IsLikelyIsapiSuccess(body, status)) { + if (IsUserAlreadyExistsOnDeviceResponse(status, body, error) || + (status == 400 && VerifyUserOnTarget(target, user.EmployeeNo ?? "", out _))) + { + alreadyExisted = true; + error = ""; + return true; + } + error = "ISAPI error status=" + status + " bodySnip=" + ToOneLineSnippet(body); return false; } @@ -915,6 +1038,31 @@ internal sealed partial class HikvisionAttendanceManager return true; } + /// + /// True only when Hikvision explicitly reports the employeeNo already exists. + /// + private static bool IsUserAlreadyExistsOnDeviceResponse(int httpStatus, string? body, string? transportError) + { + TryParseIsapiResponseFields(body, out _, out var statusString, out var subStatusCode); + var sub = (subStatusCode ?? "").Trim(); + if (sub.Equals("employeeNoAlreadyExist", StringComparison.OrdinalIgnoreCase) || + sub.Equals("employeeNoAlreadyExisted", StringComparison.OrdinalIgnoreCase) || + sub.Equals("employeeAlreadyExist", StringComparison.OrdinalIgnoreCase) || + sub.Equals("userAlreadyExist", StringComparison.OrdinalIgnoreCase)) + return true; + + var statusText = (statusString ?? "").Trim(); + if (statusText.IndexOf("already exist", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + + var blob = ((body ?? "") + " " + (transportError ?? "")).Trim(); + if (blob.IndexOf("employeeNoAlreadyExist", StringComparison.OrdinalIgnoreCase) >= 0 || + blob.IndexOf("employeeNoAlreadyExisted", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + + return false; + } + public bool UploadFaceOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo, byte[] faceImage) { diff --git a/HikvisionAttendanceManager.cs b/HikvisionAttendanceManager.cs index 40cae3d..3dcdbbb 100644 --- a/HikvisionAttendanceManager.cs +++ b/HikvisionAttendanceManager.cs @@ -43,6 +43,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable private readonly IHrmsDbConnectionFactory? _dbConnectionFactory; private readonly IAttendanceMachineRepository? _attendanceMachineRepository; private readonly IAttendanceLogRepository? _attendanceLogRepository; + private readonly IEmployeeLookupRepository? _employeeLookupRepository; private readonly IAttendanceMachineUserRepository? _attendanceMachineUserRepository; private readonly IAttendanceMachineFaceTemplateRepository? _attendanceMachineFaceTemplateRepository; private readonly UnreachableDeviceTracker _unreachableDevices; @@ -86,6 +87,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable _dbConnectionFactory = new MySqlHrmsDbConnectionFactory(_config); _attendanceMachineRepository = new MySqlAttendanceMachineRepository(_dbConnectionFactory); _attendanceLogRepository = new MySqlAttendanceLogRepository(_dbConnectionFactory); + _employeeLookupRepository = new MySqlEmployeeLookupRepository(_dbConnectionFactory); _attendanceMachineUserRepository = new MySqlAttendanceMachineUserRepository(_dbConnectionFactory); _attendanceMachineFaceTemplateRepository = new MySqlAttendanceMachineFaceTemplateRepository(_dbConnectionFactory); _logger.Info("DB integration enabled; connection=" + _dbConnectionFactory.BuildConnectionStringMasked()); @@ -439,10 +441,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable } 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) + - ". Meaning: window was empty, cursor moved forward so we do not re-scan forever."); + _logger.Ops(OpsMarkers.Attendance, + "[ATTENDANCE] No punches found. Keeping last_sync_date unchanged. machine_id=" + deviceId); } else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded) { @@ -1637,18 +1637,53 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable continue; } - var lastSync = ReadLastSyncTimestamp(session.Device.DeviceId, out _); + var lastSync = ReadLastSyncTimestamp(session.Device.DeviceId, out var lastSyncReason); + var serverNow = DateTime.Now; var from = lastSync.HasValue ? lastSync.Value.AddSeconds(1) : fallbackFrom; - _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) + + if (from > serverNow) { - skippedDevices++; - _logger.Biz(BizChannel.Attendance, "No attendance records found.", ""); - _logger.BizSeparator(BizChannel.Attendance); - continue; + var resetFrom = serverNow.AddMinutes(-10); + _logger.OpsWarn(OpsMarkers.Attendance, + "[ATTENDANCE_WINDOW] Invalid future last_sync_date detected" + + " machine_id=" + (session.Device.DeviceId ?? "") + + " machine_ip=" + (session.Device.Ip ?? "") + + " last_sync_date=" + (lastSync.HasValue + ? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + : "(null)") + + " current_time=" + serverNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " reset_from=" + resetFrom.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " reason=\"" + lastSyncReason + "\""); + from = resetFrom; } + if ((serverNow - from).TotalHours > 24) + { + var resetFrom = serverNow.AddHours(-24); + _logger.OpsWarn(OpsMarkers.Attendance, + "[ATTENDANCE_WINDOW] Fetch range exceeded maximum limit. Resetting window." + + " machine_id=" + (session.Device.DeviceId ?? "") + + " machine_ip=" + (session.Device.Ip ?? "") + + " from_time=" + from.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " to_time=" + serverNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " reset_from=" + resetFrom.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + from = resetFrom; + } + + if (from > serverNow) + from = serverNow.AddMinutes(-1); + + _logger.Ops(OpsMarkers.Attendance, + "[ATTENDANCE_WINDOW]" + + " machine_id=" + (session.Device.DeviceId ?? "") + + " machine_ip=" + (session.Device.Ip ?? "") + + " last_sync_date=" + (lastSync.HasValue + ? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + : "(null)") + + " from_time=" + from.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " to_time=" + to.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " server_time=" + serverNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + DateTime? lastEventTimestamp; bool dbInsertAllSucceeded; int n = FetchAttendanceRecordsCore(session.Device.DeviceId, from, to, token, out lastEventTimestamp, out dbInsertAllSucceeded, stats); @@ -1699,10 +1734,14 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable } else if (n == 0 && dbInsertAllSucceeded) { - WriteLastSyncTimestamp(session.Device.DeviceId, to); - stats.LastSyncUpdated = true; + // Do not advance last_sync_date on empty windows — keeps the cursor safe for retries. + stats.LastSyncUpdated = false; stats.CleanupResult = "SKIPPED (no punches)"; successDevices++; + _logger.Ops(OpsMarkers.Attendance, + "[ATTENDANCE] No punches found. Keeping last_sync_date unchanged." + + " machine_id=" + (session.Device.DeviceId ?? "") + + " machine_ip=" + (session.Device.Ip ?? "")); } else { @@ -1901,7 +1940,13 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable dbOk = true; TryUpdateMachineRuntimeState(ev.DeviceIp ?? "", "synced", null, null); break; + case AttendancePersistOutcome.Duplicate: + if (stats != null) stats.Duplicates++; + dbOk = true; + break; case AttendancePersistOutcome.SkippedNoEmployee: + case AttendancePersistOutcome.UnmappedEmployee: + case AttendancePersistOutcome.UnsupportedWorkerType: if (stats != null) stats.SystemEventsSkipped++; dbOk = true; break; @@ -2023,9 +2068,22 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable private void WriteLastSyncTimestamp(string deviceId, DateTime timestampLocal) { + var now = DateTime.Now; + var localValue = NormalizeForDbLocalTime(timestampLocal); + if (localValue > now) + { + _logger.OpsWarn(OpsMarkers.Attendance, + "[ATTENDANCE_WINDOW] Refusing future last_sync_date write" + + " machine_id=" + deviceId + + " proposed=" + localValue.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " clamped_to=" + now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + localValue = now; + } + + localValue = DateTime.SpecifyKind(localValue, DateTimeKind.Unspecified); + if (_config.EnableDbIntegration && _attendanceMachineRepository != null) { - var localValue = NormalizeForDbLocalTime(timestampLocal); var machineIp = ResolveMachineIpForDbLookup(deviceId); if (string.IsNullOrWhiteSpace(machineIp)) { @@ -2039,7 +2097,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable var path = GetLastSyncFilePath(deviceId); Directory.CreateDirectory(Path.GetDirectoryName(path) ?? _config.LogDirectory); - var raw = timestampLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + var raw = localValue.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); File.WriteAllText(path, raw); } @@ -2274,11 +2332,16 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable //var fromUtc = fromLocal.ToUniversalTime(); //var toUtc = toLocal.ToUniversalTime(); - //string startTime = fromUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); - //string endTime = toUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); - - string startTime = fromLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00"; - string endTime = toLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00"; + // Query window in local wall-clock with this host's UTC offset (Pakistan = +05:00). + // Do not convert punches to UTC; attendance_log must match the time shown on the device. + var localOffset = TimeZoneInfo.Local.GetUtcOffset(fromLocal); + var offsetSign = localOffset < TimeSpan.Zero ? "-" : "+"; + var offsetAbs = localOffset.Duration(); + var offsetText = offsetSign + + offsetAbs.Hours.ToString("00", CultureInfo.InvariantCulture) + ":" + + offsetAbs.Minutes.ToString("00", CultureInfo.InvariantCulture); + string startTime = fromLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + offsetText; + string endTime = toLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + offsetText; var searchId = "1"; int searchResultPosition = 0; @@ -2410,9 +2473,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable // even if the date range has no events (avoid redundant SDK remote-config fetch). attemptedStdXml = true; if (maxEventTs.HasValue) - // STDXML timestamps are parsed as UTC (we parse the device time with offset, then convert to universal). - // Convert back to local time so incremental cursor stays consistent with scheduler's local `from`/`to`. - lastEventTimestamp = DateTime.SpecifyKind(maxEventTs.Value, DateTimeKind.Utc).ToLocalTime(); + // Event timestamps are stored as device/local wall-clock for attendance — keep as-is. + lastEventTimestamp = DateTime.SpecifyKind(maxEventTs.Value, DateTimeKind.Local); else lastEventTimestamp = null; return parsedOk; @@ -4924,10 +4986,12 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable if (verifyMode.HasValue) currentVerifyMode = (byte)Math.Max(0, Math.Min(255, verifyMode.Value)); - // Timestamp: best effort across common key names containing "Time". + // Timestamp: preserve Hikvision device/site wall-clock (never store UTC-shifted checktime). DateTime ts; - if (!TryExtractStdAcsDateTime(info, out var parsedTs)) - ts = DateTime.UtcNow; + string deviceTimestampRaw = ""; + string deviceTimestampOffset = ""; + if (!TryExtractStdAcsDateTime(info, out var parsedTs, out deviceTimestampRaw, out deviceTimestampOffset)) + ts = DateTime.Now; else ts = parsedTs; @@ -4964,7 +5028,11 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable isSuccess, rawMajor, rawMinor, - historySerialNo: 0); + historySerialNo: 0) + { + DeviceTimestampRaw = deviceTimestampRaw, + DeviceTimestampOffset = deviceTimestampOffset + }; return true; } @@ -5002,9 +5070,21 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable return null; } - private static bool TryExtractStdAcsDateTime(Dictionary info, out DateTime dt) + private static bool TryExtractStdAcsDateTime(Dictionary info, out DateTime dt) => + TryExtractStdAcsDateTime(info, out dt, out _); + + private static bool TryExtractStdAcsDateTime(Dictionary info, out DateTime dt, out string rawDeviceTimestamp) => + TryExtractStdAcsDateTime(info, out dt, out rawDeviceTimestamp, out _); + + private static bool TryExtractStdAcsDateTime( + Dictionary info, + out DateTime dt, + out string rawDeviceTimestamp, + out string offsetText) { dt = default; + rawDeviceTimestamp = ""; + offsetText = ""; string[] candidateKeys = { @@ -5021,7 +5101,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable { if (info.TryGetValue(key, out var v) && v != null) { - if (TryParseStdAcsDateTimeValue(v, out dt)) + rawDeviceTimestamp = FormatRawDeviceTimestamp(v); + if (TryParseStdAcsDateTimeValue(v, out dt, out offsetText)) return true; } } @@ -5030,23 +5111,43 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable { if (kv.Key != null && kv.Key.IndexOf("time", StringComparison.OrdinalIgnoreCase) >= 0) { - if (TryParseStdAcsDateTimeValue(kv.Value, out dt)) + rawDeviceTimestamp = FormatRawDeviceTimestamp(kv.Value); + if (TryParseStdAcsDateTimeValue(kv.Value, out dt, out offsetText)) return true; } } + rawDeviceTimestamp = ""; + offsetText = ""; return false; } - private static bool TryParseStdAcsDateTimeValue(object? value, out DateTime dt) + private static string FormatRawDeviceTimestamp(object? value) + { + if (value == null) + return ""; + if (value is DateTime d) + return d.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture); + if (value is DateTimeOffset dto) + return dto.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture); + return Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim() ?? ""; + } + + private static bool TryParseStdAcsDateTimeValue(object? value, out DateTime dt) => + TryParseStdAcsDateTimeValue(value, out dt, out _); + + private static bool TryParseStdAcsDateTimeValue(object? value, out DateTime dt, out string offsetText) { dt = default; + offsetText = ""; if (value == null) return false; if (value is DateTime d) { - dt = d; + // Keep wall-clock components only — never ToLocalTime/ToUniversalTime for attendance. + dt = new DateTime(d.Year, d.Month, d.Day, d.Hour, d.Minute, d.Second, DateTimeKind.Unspecified); + offsetText = d.Kind == DateTimeKind.Utc ? "00:00:00" : ""; return true; } @@ -5054,11 +5155,13 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable { try { - // Heuristic: >10 digits => milliseconds. - if (l > 10_000_000_000L) - dt = DateTimeOffset.FromUnixTimeMilliseconds(l).UtcDateTime; - else - dt = DateTimeOffset.FromUnixTimeSeconds(l).UtcDateTime; + // Epoch values are instants; take UTC clock face as wall-clock digits (no host TZ shift). + DateTime utc = l > 10_000_000_000L + ? DateTimeOffset.FromUnixTimeMilliseconds(l).UtcDateTime + : DateTimeOffset.FromUnixTimeSeconds(l).UtcDateTime; + dt = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, utc.Second, + DateTimeKind.Unspecified); + offsetText = "00:00:00"; return true; } catch @@ -5068,7 +5171,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable } if (value is int i) - return TryParseStdAcsDateTimeValue((long)i, out dt); + return TryParseStdAcsDateTimeValue((long)i, out dt, out offsetText); string s = value is string ss ? ss : value.ToString() ?? ""; if (string.IsNullOrWhiteSpace(s)) @@ -5076,12 +5179,24 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable s = s.Trim(); + // Hikvision ISO8601, e.g. 2026-09-10T17:43:02Z or ...+05:00. + // Attendance must keep DEVICE WALL CLOCK digits — never ToLocalTime/ToUniversalTime. + if (DateTimeOffset.TryParse( + s, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var dto)) + { + dt = new DateTime(dto.Year, dto.Month, dto.Day, dto.Hour, dto.Minute, dto.Second, + DateTimeKind.Unspecified); + offsetText = FormatOffset(dto.Offset); + return true; + } + string[] formats = { - "yyyy-MM-dd'T'HH:mm:ss'Z'", - "yyyy-MM-dd'T'HH:mm:ss.FFF'Z'", - "yyyy-MM-dd'T'HH:mm:sszzz", - "yyyy-MM-dd'T'HH:mm:ss.FFFzzz", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss.FFF", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd" }; @@ -5090,20 +5205,39 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable s, formats, CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + DateTimeStyles.None, out dt)) + { + dt = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, + DateTimeKind.Unspecified); + offsetText = ""; return true; + } if (DateTime.TryParse( s, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out dt)) + { + dt = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, + DateTimeKind.Unspecified); + offsetText = ""; return true; + } return false; } + private static string FormatOffset(TimeSpan offset) + { + var sign = offset < TimeSpan.Zero ? "-" : ""; + var abs = offset.Duration(); + return sign + abs.Hours.ToString("00", CultureInfo.InvariantCulture) + ":" + + abs.Minutes.ToString("00", CultureInfo.InvariantCulture) + ":" + + abs.Seconds.ToString("00", CultureInfo.InvariantCulture); + } + private static string TruncateForLog(string s, int maxChars) { if (string.IsNullOrEmpty(s)) @@ -5451,11 +5585,10 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable { // Sdk structs use int; guard against 0/invalid timestamps. if (t.dwYear <= 1900) - { - return DateTime.UtcNow; - } + return DateTime.SpecifyKind(DateTime.Now, DateTimeKind.Unspecified); - return new DateTime(t.dwYear, t.dwMonth, t.dwDay, t.dwHour, t.dwMinute, t.dwSecond); + // Device/SDK wall-clock — do not treat as UTC. + return new DateTime(t.dwYear, t.dwMonth, t.dwDay, t.dwHour, t.dwMinute, t.dwSecond, DateTimeKind.Unspecified); } private static string DecodeCardNo(byte[] bytes) @@ -5662,11 +5795,26 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable return ""; } + private bool IsTerryAttendanceMachineIp(string machineIp) + { + var ip = (machineIp ?? "").Trim(); + if (ip.Length == 0) + return false; + + foreach (var configured in _config.TerryAttendanceMachineIps ?? Enumerable.Empty()) + { + if (string.Equals((configured ?? "").Trim(), ip, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + private static DateTime NormalizeForDbLocalTime(DateTime value) { - if (value.Kind == DateTimeKind.Utc) - return value.ToLocalTime(); - return value; + // Persist as Unspecified wall-clock for MySQL DATETIME (no timezone conversion). + return new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, + DateTimeKind.Unspecified); } private AttendancePersistOutcome WriteAttendanceToDatabase(AttendanceEvent ev, AttendanceDeviceCycleStats? stats = null) @@ -5689,28 +5837,107 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable var acNo = employeeNo.ToString(CultureInfo.InvariantCulture); var inOutTypeId = ev.RawMinor > 0 ? (int)ev.RawMinor : 0; + var machineId = ev.DeviceId ?? ""; + var machineIp = (ev.DeviceIp ?? "").Trim(); + // Preserve device/site wall-clock — never store UTC-shifted checktime. + var checkTimeLocal = NormalizeForDbLocalTime(ev.Timestamp); + checkTimeLocal = DateTime.SpecifyKind(checkTimeLocal, DateTimeKind.Unspecified); + var dateOnly = checkTimeLocal.Date; + + var rawTs = string.IsNullOrWhiteSpace(ev.DeviceTimestampRaw) + ? checkTimeLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + : ev.DeviceTimestampRaw; + var offsetLog = string.IsNullOrWhiteSpace(ev.DeviceTimestampOffset) ? "00:00:00" : ev.DeviceTimestampOffset; + _logger.Ops(OpsMarkers.Attendance, + "[ATTENDANCE_DEBUG]" + + " machine_id=" + machineId + + " machine_ip=" + machineIp + + " employee=" + acNo + + " source=" + (ev.Source ?? "") + + " raw_timestamp=" + rawTs + + " parsed_timestamp=" + checkTimeLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " datetime_kind=" + checkTimeLocal.Kind + + " offset=" + offsetLog); + + // Terry machines (by IP): always terry_attendance_log — no employee/worker_type lookup. + if (IsTerryAttendanceMachineIp(machineIp)) + { + _logger.Ops(OpsMarkers.Attendance, + "[ATTENDANCE_ROUTE] machine=" + machineId + + " ip=" + machineIp + + " employeeNo=" + acNo + + " route=TerryAttendanceMachineIps" + + " destination=terry_attendance_log"); + + var terryOk = _attendanceLogRepository.InsertTerryAttendance( + acNo, + checkTimeLocal, + 0, + machineId, + inOutTypeId, + machineIp, + dateOnly, + dateOnly, + msg => _logger.Diag("attendance", msg), + out var terryDuplicate, + out var terryErr); + + if (terryDuplicate) + { + _logger.Ops(OpsMarkers.Attendance, + "[ATTENDANCE_ROUTE] employeeNo=" + acNo + + " destination=terry_attendance_log result=DUPLICATE"); + return AttendancePersistOutcome.Duplicate; + } + + if (!terryOk) + { + _logger.OpsError(OpsMarkers.Attendance, "DB insert FAILED destination=terry_attendance_log ac_no=" + acNo + + " device=" + machineId + " reason=\"" + terryErr + "\""); + _logger.Totals.AttendanceFailed++; + return AttendancePersistOutcome.DbFailed; + } + + _logger.Ops(OpsMarkers.Attendance, + "[ATTENDANCE_ROUTE] employeeNo=" + acNo + + " destination=terry_attendance_log result=INSERTED"); + _logger.Totals.AttendanceInserted++; + stats?.InsertedPunches.Add((machineIp, acNo, checkTimeLocal)); + return AttendancePersistOutcome.DbInserted; + } + + // Non-Terry machines: existing attendance_log insertion flow (no Terry IP routing). + _logger.Ops(OpsMarkers.Attendance, + "[ATTENDANCE_ROUTE] machine=" + machineId + + " ip=" + machineIp + + " employeeNo=" + acNo + + " destination=attendance_log"); + var ok = _attendanceLogRepository.InsertAttendance( acNo, employeeNo, - ev.Timestamp, + checkTimeLocal, 0, - ev.DeviceId ?? "", + machineId, inOutTypeId, - ev.DeviceIp ?? "", - ev.Timestamp.Date, + machineIp, + dateOnly, msg => _logger.Diag("attendance", msg), out var err); if (!ok) { - _logger.OpsError(OpsMarkers.Attendance, "DB insert FAILED ac_no=" + acNo + - " device=" + (ev.DeviceId ?? "") + " reason=\"" + err + "\""); + _logger.OpsError(OpsMarkers.Attendance, "DB insert FAILED destination=attendance_log ac_no=" + acNo + + " device=" + machineId + " reason=\"" + err + "\""); _logger.Totals.AttendanceFailed++; return AttendancePersistOutcome.DbFailed; } + _logger.Ops(OpsMarkers.Attendance, + "[ATTENDANCE_ROUTE] employeeNo=" + acNo + + " destination=attendance_log result=INSERTED"); _logger.Totals.AttendanceInserted++; - stats?.InsertedPunches.Add((ev.DeviceIp ?? "", acNo, ev.Timestamp)); + stats?.InsertedPunches.Add((machineIp, acNo, checkTimeLocal)); return AttendancePersistOutcome.DbInserted; } @@ -5935,6 +6162,10 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable public string DeviceId { get; } public string DeviceIp { get; } public DateTime Timestamp { get; } + /// Original Hikvision time string (diagnostic only; not persisted as a column). + public string DeviceTimestampRaw { get; set; } = ""; + /// Offset from raw Hikvision timestamp when present (diagnostic only). + public string DeviceTimestampOffset { get; set; } = ""; public int? EmployeeNo { get; } /// Hikvision employeeNoString — sole identity for attendance_log.ac_no. public string? EmployeeNoString { get; }