fix: improve user sync already-present detection and failure summaries

Treat users as already present only when the device confirms it, clarify HTTP 401 as Digest auth failure, and write clearer per-target user-sync conclusions with failed-employee reasons.
main
SYED MUSTUFA AHMED NAQVI 2026-09-11 15:05:19 +05:00
parent 9605c370ae
commit 312a8815d0
2 changed files with 517 additions and 138 deletions

View File

@ -89,51 +89,7 @@ internal sealed partial class HikvisionAttendanceManager
return; return;
} }
var targets = new List<HikvisionAttendanceWindowsService.DeviceConfig>(); var targets = CollectTestedSyncTargets(source, hasSource, ct);
foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty<string>())
{
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<string>())
{
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);
}
if (targets.Count == 0) if (targets.Count == 0)
{ {
// Deletion may have already run above; copy/template sync simply has nothing to do. // 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) foreach (var target in targets)
{ {
ct.ThrowIfCancellationRequested(); 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, _logger.Biz(BizChannel.UserSync,
"MACHINE " + (target.DeviceId ?? "") + " -> Total Users : " + sourceUsers.Count, "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; int created = 0, faceUp = 0, skipped = 0, failed = 0, updated = 0;
var statusCounts = new Dictionary<UserSyncStatus, int>(); var statusCounts = new Dictionary<UserSyncStatus, int>();
var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>(); 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) void Bump(UserSyncStatus s)
{ {
@ -319,20 +247,54 @@ internal sealed partial class HikvisionAttendanceManager
bool createUserLogged = false; bool createUserLogged = false;
bool faceUploadedLogged = false; bool faceUploadedLogged = false;
bool templateFoundLogged = false; bool templateFoundLogged = false;
string dbCreateUserResult = "skipped";
string dbFaceUploadResult = "skipped";
string dbVerificationResult = "skipped";
string dbFinalResult = "skipped";
try try
{ {
if (tgtRow == null) 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 + _logger.Warn("UserSync: FailedCreate employeeNo=" + srcUser.EmployeeNo + " target=" + target.DeviceId +
" err=" + createErr); " err=" + createErr);
_logger.Biz(BizChannel.UserSync,
"Machine ID : " + (target.DeviceId ?? ""),
"Machine IP : " + (target.Ip ?? ""),
"",
srcUser.EmployeeNo + " user create failed.",
"",
"Reason :",
"",
createReason,
"");
failed++; failed++;
Bump(UserSyncStatus.FailedCreate); Bump(UserSyncStatus.FailedCreate);
continue; 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++; created++;
createUserLogged = true; createUserLogged = true;
_logger.Totals.UsersAdded++; _logger.Totals.UsersAdded++;
@ -373,7 +335,11 @@ internal sealed partial class HikvisionAttendanceManager
}; };
targetMap[srcUser.EmployeeNo] = tgtRow; 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)) if (TryModifyUserOnTarget(target, srcUser, maxRetries, ct, out var modErr))
{ {
@ -397,12 +363,25 @@ internal sealed partial class HikvisionAttendanceManager
} }
else else
{ {
var modReason = DescribeUserSyncFailure("modify", modErr);
failedEmployees.Add((srcUser.EmployeeNo, modReason));
_logger.Warn("UserSync: FailedUserModify employeeNo=" + srcUser.EmployeeNo + " target=" + _logger.Warn("UserSync: FailedUserModify employeeNo=" + srcUser.EmployeeNo + " target=" +
target.DeviceId + " err=" + modErr); target.DeviceId + " err=" + modErr);
_logger.Biz(BizChannel.UserSync,
"Machine ID : " + (target.DeviceId ?? ""),
"Machine IP : " + (target.Ip ?? ""),
"",
srcUser.EmployeeNo + " user update failed.",
"",
"Reason :",
"",
modReason,
"");
failed++; failed++;
Bump(UserSyncStatus.FailedUserModify); Bump(UserSyncStatus.FailedUserModify);
} }
} }
}
bool hasFaceBytes = faceBytesByEmployee.TryGetValue(srcUser.EmployeeNo, out var fb) && fb != null && bool hasFaceBytes = faceBytesByEmployee.TryGetValue(srcUser.EmployeeNo, out var fb) && fb != null &&
fb.Length > 0; fb.Length > 0;
@ -426,6 +405,9 @@ internal sealed partial class HikvisionAttendanceManager
templateFoundLogged = hasFaceBytes; templateFoundLogged = hasFaceBytes;
if (!hasFaceBytes) if (!hasFaceBytes)
{ {
dbFaceUploadResult = "missing";
dbVerificationResult = "skipped";
dbFinalResult = "template_missing";
skipped++; skipped++;
_logger.Ops(OpsMarkers.TemplateDbToDevice, _logger.Ops(OpsMarkers.TemplateDbToDevice,
srcUser.EmployeeNo + " -> Device " + target.DeviceId + srcUser.EmployeeNo + " -> Device " + target.DeviceId +
@ -447,13 +429,28 @@ internal sealed partial class HikvisionAttendanceManager
Bump(UserSyncStatus.AlreadySynced); Bump(UserSyncStatus.AlreadySynced);
} }
else 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); Bump(createdNew ? UserSyncStatus.CreatedNoFaceAvailable : UserSyncStatus.ExistsNoFaceOnSourceSkipped);
}
continue; continue;
} }
if (pol.UploadFaceIfMissingOnly && tgtRow.NumOfFace > 0) if (pol.UploadFaceIfMissingOnly && tgtRow.NumOfFace > 0)
{ {
dbFaceUploadResult = "skipped_exists";
dbVerificationResult = "ok";
dbFinalResult = "already_synced";
skipped++; skipped++;
skippedEmployees.Add((srcUser.EmployeeNo, "Face already exists on device and overwrite is disabled."));
_logger.Ops(OpsMarkers.TemplateDbToDevice, _logger.Ops(OpsMarkers.TemplateDbToDevice,
srcUser.EmployeeNo + " -> Device " + target.DeviceId + srcUser.EmployeeNo + " -> Device " + target.DeviceId +
" = FACE TEMPLATE SKIPPED reason=\"already exists and overwrite disabled\""); " = 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)) 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, _logger.OpsError(OpsMarkers.TemplateDbToDevice,
srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\""); srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\"");
_logger.Totals.TemplatesFailed++; _logger.Totals.TemplatesFailed++;
_logger.Biz(BizChannel.UserSync,
srcUser.EmployeeNo + " face enrollment failed.",
"",
"Reason :",
"",
faceReason,
"");
if (_config.EnableTemplateDbToDeviceSync) if (_config.EnableTemplateDbToDeviceSync)
{ {
_logger.Biz(BizChannel.Template, _logger.Biz(BizChannel.Template,
@ -490,7 +499,7 @@ internal sealed partial class HikvisionAttendanceManager
"", "",
"Reason :", "Reason :",
"", "",
string.IsNullOrWhiteSpace(upErr) ? "Unknown error." : upErr, faceReason,
""); "");
_logger.BizSeparator(BizChannel.Template); _logger.BizSeparator(BizChannel.Template);
} }
@ -501,6 +510,9 @@ internal sealed partial class HikvisionAttendanceManager
faceUp++; faceUp++;
faceUploadedLogged = true; faceUploadedLogged = true;
dbFaceUploadResult = "ok";
dbVerificationResult = "ok";
dbFinalResult = "success";
_logger.Totals.TemplatesSaved++; _logger.Totals.TemplatesSaved++;
tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1); tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1);
_logger.Ops(OpsMarkers.TemplateDbToDevice, _logger.Ops(OpsMarkers.TemplateDbToDevice,
@ -525,6 +537,15 @@ internal sealed partial class HikvisionAttendanceManager
" templateFound=" + (templateFoundLogged ? "true" : "false") + " templateFound=" + (templateFoundLogged ? "true" : "false") +
" createUser=" + (createUserLogged ? "true" : "false") + " createUser=" + (createUserLogged ? "true" : "false") +
" faceUploaded=" + (faceUploadedLogged ? "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()) var statusLine = string.Join(", ", statusCounts.OrderBy(kv => kv.Key.ToString())
.Select(kv => kv.Key + "=" + kv.Value)); .Select(kv => kv.Key + "=" + kv.Value));
WriteFaceRejectedEmployeeSummary("TEMPLATE_DB_TO_DEVICE", target, faceRejectedEmployees); WriteFaceRejectedEmployeeSummary("TEMPLATE_DB_TO_DEVICE", target, faceRejectedEmployees);
WriteUserSyncTargetConclusion(target, created, faceUp, updated, skipped, failed,
failedEmployees, skippedEmployees);
_logger.Ops(OpsMarkers.TemplateDbToDevice, _logger.Ops(OpsMarkers.TemplateDbToDevice,
"device=" + target.DeviceId + " summary created=" + created + "device=" + target.DeviceId + " summary created=" + created +
" faceUploaded=" + faceUp + " updated=" + updated + " faceUploaded=" + faceUp + " updated=" + updated +
@ -574,6 +597,80 @@ internal sealed partial class HikvisionAttendanceManager
_logger.Ops(OpsMarkers.TemplateDbToDevice, "Cycle completed id=" + cycleId); _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<string>
{
"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( private void WriteFaceRejectedEmployeeSummary(
string diagTag, string diagTag,
HikvisionAttendanceWindowsService.DeviceConfig target, HikvisionAttendanceWindowsService.DeviceConfig target,
@ -892,22 +989,48 @@ internal sealed partial class HikvisionAttendanceManager
public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user) 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, public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user, int maxRetries,
CancellationToken ct, out string error) 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 = ""; error = "";
alreadyExisted = false;
var ser = new JavaScriptSerializer(); var ser = new JavaScriptSerializer();
var root = BuildUserInfoRecordPayload(user); var root = BuildUserInfoRecordPayload(user);
string json = ser.Serialize(root); string json = ser.Serialize(root);
if (!TryIsapiPostJsonWithRetry(target, "/ISAPI/AccessControl/UserInfo/Record?format=json", json, maxRetries, ct, if (!TryIsapiPostJsonWithRetry(target, "/ISAPI/AccessControl/UserInfo/Record?format=json", json, maxRetries, ct,
out var body, out var status, out error)) 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; return false;
}
if (!IsLikelyIsapiSuccess(body, status)) 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); error = "ISAPI error status=" + status + " bodySnip=" + ToOneLineSnippet(body);
return false; return false;
} }
@ -915,6 +1038,31 @@ internal sealed partial class HikvisionAttendanceManager
return true; return true;
} }
/// <summary>
/// True only when Hikvision explicitly reports the employeeNo already exists.
/// </summary>
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, public bool UploadFaceOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo,
byte[] faceImage) byte[] faceImage)
{ {

View File

@ -43,6 +43,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
private readonly IHrmsDbConnectionFactory? _dbConnectionFactory; private readonly IHrmsDbConnectionFactory? _dbConnectionFactory;
private readonly IAttendanceMachineRepository? _attendanceMachineRepository; private readonly IAttendanceMachineRepository? _attendanceMachineRepository;
private readonly IAttendanceLogRepository? _attendanceLogRepository; private readonly IAttendanceLogRepository? _attendanceLogRepository;
private readonly IEmployeeLookupRepository? _employeeLookupRepository;
private readonly IAttendanceMachineUserRepository? _attendanceMachineUserRepository; private readonly IAttendanceMachineUserRepository? _attendanceMachineUserRepository;
private readonly IAttendanceMachineFaceTemplateRepository? _attendanceMachineFaceTemplateRepository; private readonly IAttendanceMachineFaceTemplateRepository? _attendanceMachineFaceTemplateRepository;
private readonly UnreachableDeviceTracker _unreachableDevices; private readonly UnreachableDeviceTracker _unreachableDevices;
@ -86,6 +87,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
_dbConnectionFactory = new MySqlHrmsDbConnectionFactory(_config); _dbConnectionFactory = new MySqlHrmsDbConnectionFactory(_config);
_attendanceMachineRepository = new MySqlAttendanceMachineRepository(_dbConnectionFactory); _attendanceMachineRepository = new MySqlAttendanceMachineRepository(_dbConnectionFactory);
_attendanceLogRepository = new MySqlAttendanceLogRepository(_dbConnectionFactory); _attendanceLogRepository = new MySqlAttendanceLogRepository(_dbConnectionFactory);
_employeeLookupRepository = new MySqlEmployeeLookupRepository(_dbConnectionFactory);
_attendanceMachineUserRepository = new MySqlAttendanceMachineUserRepository(_dbConnectionFactory); _attendanceMachineUserRepository = new MySqlAttendanceMachineUserRepository(_dbConnectionFactory);
_attendanceMachineFaceTemplateRepository = new MySqlAttendanceMachineFaceTemplateRepository(_dbConnectionFactory); _attendanceMachineFaceTemplateRepository = new MySqlAttendanceMachineFaceTemplateRepository(_dbConnectionFactory);
_logger.Info("DB integration enabled; connection=" + _dbConnectionFactory.BuildConnectionStringMasked()); _logger.Info("DB integration enabled; connection=" + _dbConnectionFactory.BuildConnectionStringMasked());
@ -439,10 +441,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
} }
else if (n == 0 && dbInsertAllSucceeded) else if (n == 0 && dbInsertAllSucceeded)
{ {
WriteLastSyncTimestamp(deviceId, toLocal); _logger.Ops(OpsMarkers.Attendance,
_logger.Info("No records found; advancing last_sync_date to window end. machine=" + deviceId + "[ATTENDANCE] No punches found. Keeping last_sync_date unchanged. machine_id=" + 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.");
} }
else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded) else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded)
{ {
@ -1637,18 +1637,53 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
continue; 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; 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 > serverNow)
if (from > to)
{ {
skippedDevices++; var resetFrom = serverNow.AddMinutes(-10);
_logger.Biz(BizChannel.Attendance, "No attendance records found.", ""); _logger.OpsWarn(OpsMarkers.Attendance,
_logger.BizSeparator(BizChannel.Attendance); "[ATTENDANCE_WINDOW] Invalid future last_sync_date detected" +
continue; " 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; DateTime? lastEventTimestamp;
bool dbInsertAllSucceeded; bool dbInsertAllSucceeded;
int n = FetchAttendanceRecordsCore(session.Device.DeviceId, from, to, token, out lastEventTimestamp, out dbInsertAllSucceeded, stats); 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) else if (n == 0 && dbInsertAllSucceeded)
{ {
WriteLastSyncTimestamp(session.Device.DeviceId, to); // Do not advance last_sync_date on empty windows — keeps the cursor safe for retries.
stats.LastSyncUpdated = true; stats.LastSyncUpdated = false;
stats.CleanupResult = "SKIPPED (no punches)"; stats.CleanupResult = "SKIPPED (no punches)";
successDevices++; 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 else
{ {
@ -1901,7 +1940,13 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
dbOk = true; dbOk = true;
TryUpdateMachineRuntimeState(ev.DeviceIp ?? "", "synced", null, null); TryUpdateMachineRuntimeState(ev.DeviceIp ?? "", "synced", null, null);
break; break;
case AttendancePersistOutcome.Duplicate:
if (stats != null) stats.Duplicates++;
dbOk = true;
break;
case AttendancePersistOutcome.SkippedNoEmployee: case AttendancePersistOutcome.SkippedNoEmployee:
case AttendancePersistOutcome.UnmappedEmployee:
case AttendancePersistOutcome.UnsupportedWorkerType:
if (stats != null) stats.SystemEventsSkipped++; if (stats != null) stats.SystemEventsSkipped++;
dbOk = true; dbOk = true;
break; break;
@ -2023,9 +2068,22 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
private void WriteLastSyncTimestamp(string deviceId, DateTime timestampLocal) 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) if (_config.EnableDbIntegration && _attendanceMachineRepository != null)
{ {
var localValue = NormalizeForDbLocalTime(timestampLocal);
var machineIp = ResolveMachineIpForDbLookup(deviceId); var machineIp = ResolveMachineIpForDbLookup(deviceId);
if (string.IsNullOrWhiteSpace(machineIp)) if (string.IsNullOrWhiteSpace(machineIp))
{ {
@ -2039,7 +2097,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
var path = GetLastSyncFilePath(deviceId); var path = GetLastSyncFilePath(deviceId);
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? _config.LogDirectory); 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); File.WriteAllText(path, raw);
} }
@ -2274,11 +2332,16 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
//var fromUtc = fromLocal.ToUniversalTime(); //var fromUtc = fromLocal.ToUniversalTime();
//var toUtc = toLocal.ToUniversalTime(); //var toUtc = toLocal.ToUniversalTime();
//string startTime = fromUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); // Query window in local wall-clock with this host's UTC offset (Pakistan = +05:00).
//string endTime = toUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); // Do not convert punches to UTC; attendance_log must match the time shown on the device.
var localOffset = TimeZoneInfo.Local.GetUtcOffset(fromLocal);
string startTime = fromLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00"; var offsetSign = localOffset < TimeSpan.Zero ? "-" : "+";
string endTime = toLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) + "+05:00"; 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"; var searchId = "1";
int searchResultPosition = 0; 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). // even if the date range has no events (avoid redundant SDK remote-config fetch).
attemptedStdXml = true; attemptedStdXml = true;
if (maxEventTs.HasValue) if (maxEventTs.HasValue)
// STDXML timestamps are parsed as UTC (we parse the device time with offset, then convert to universal). // Event timestamps are stored as device/local wall-clock for attendance — keep as-is.
// Convert back to local time so incremental cursor stays consistent with scheduler's local `from`/`to`. lastEventTimestamp = DateTime.SpecifyKind(maxEventTs.Value, DateTimeKind.Local);
lastEventTimestamp = DateTime.SpecifyKind(maxEventTs.Value, DateTimeKind.Utc).ToLocalTime();
else else
lastEventTimestamp = null; lastEventTimestamp = null;
return parsedOk; return parsedOk;
@ -4924,10 +4986,12 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
if (verifyMode.HasValue) if (verifyMode.HasValue)
currentVerifyMode = (byte)Math.Max(0, Math.Min(255, verifyMode.Value)); 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; DateTime ts;
if (!TryExtractStdAcsDateTime(info, out var parsedTs)) string deviceTimestampRaw = "";
ts = DateTime.UtcNow; string deviceTimestampOffset = "";
if (!TryExtractStdAcsDateTime(info, out var parsedTs, out deviceTimestampRaw, out deviceTimestampOffset))
ts = DateTime.Now;
else else
ts = parsedTs; ts = parsedTs;
@ -4964,7 +5028,11 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
isSuccess, isSuccess,
rawMajor, rawMajor,
rawMinor, rawMinor,
historySerialNo: 0); historySerialNo: 0)
{
DeviceTimestampRaw = deviceTimestampRaw,
DeviceTimestampOffset = deviceTimestampOffset
};
return true; return true;
} }
@ -5002,9 +5070,21 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
return null; return null;
} }
private static bool TryExtractStdAcsDateTime(Dictionary<string, object> info, out DateTime dt) private static bool TryExtractStdAcsDateTime(Dictionary<string, object> info, out DateTime dt) =>
TryExtractStdAcsDateTime(info, out dt, out _);
private static bool TryExtractStdAcsDateTime(Dictionary<string, object> info, out DateTime dt, out string rawDeviceTimestamp) =>
TryExtractStdAcsDateTime(info, out dt, out rawDeviceTimestamp, out _);
private static bool TryExtractStdAcsDateTime(
Dictionary<string, object> info,
out DateTime dt,
out string rawDeviceTimestamp,
out string offsetText)
{ {
dt = default; dt = default;
rawDeviceTimestamp = "";
offsetText = "";
string[] candidateKeys = string[] candidateKeys =
{ {
@ -5021,7 +5101,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
{ {
if (info.TryGetValue(key, out var v) && v != null) 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; return true;
} }
} }
@ -5030,23 +5111,43 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
{ {
if (kv.Key != null && kv.Key.IndexOf("time", StringComparison.OrdinalIgnoreCase) >= 0) 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; return true;
} }
} }
rawDeviceTimestamp = "";
offsetText = "";
return false; 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; dt = default;
offsetText = "";
if (value == null) if (value == null)
return false; return false;
if (value is DateTime d) 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; return true;
} }
@ -5054,11 +5155,13 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
{ {
try try
{ {
// Heuristic: >10 digits => milliseconds. // Epoch values are instants; take UTC clock face as wall-clock digits (no host TZ shift).
if (l > 10_000_000_000L) DateTime utc = l > 10_000_000_000L
dt = DateTimeOffset.FromUnixTimeMilliseconds(l).UtcDateTime; ? DateTimeOffset.FromUnixTimeMilliseconds(l).UtcDateTime
else : DateTimeOffset.FromUnixTimeSeconds(l).UtcDateTime;
dt = 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; return true;
} }
catch catch
@ -5068,7 +5171,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
} }
if (value is int i) 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() ?? ""; string s = value is string ss ? ss : value.ToString() ?? "";
if (string.IsNullOrWhiteSpace(s)) if (string.IsNullOrWhiteSpace(s))
@ -5076,12 +5179,24 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
s = s.Trim(); 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 = string[] formats =
{ {
"yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss.FFF'Z'", "yyyy-MM-dd'T'HH:mm:ss.FFF",
"yyyy-MM-dd'T'HH:mm:sszzz",
"yyyy-MM-dd'T'HH:mm:ss.FFFzzz",
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd" "yyyy-MM-dd"
}; };
@ -5090,20 +5205,39 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
s, s,
formats, formats,
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, DateTimeStyles.None,
out dt)) out dt))
{
dt = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second,
DateTimeKind.Unspecified);
offsetText = "";
return true; return true;
}
if (DateTime.TryParse( if (DateTime.TryParse(
s, s,
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal, DateTimeStyles.AssumeLocal,
out dt)) out dt))
{
dt = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second,
DateTimeKind.Unspecified);
offsetText = "";
return true; return true;
}
return false; 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) private static string TruncateForLog(string s, int maxChars)
{ {
if (string.IsNullOrEmpty(s)) if (string.IsNullOrEmpty(s))
@ -5451,11 +5585,10 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
{ {
// Sdk structs use int; guard against 0/invalid timestamps. // Sdk structs use int; guard against 0/invalid timestamps.
if (t.dwYear <= 1900) if (t.dwYear <= 1900)
{ return DateTime.SpecifyKind(DateTime.Now, DateTimeKind.Unspecified);
return DateTime.UtcNow;
}
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) private static string DecodeCardNo(byte[] bytes)
@ -5662,11 +5795,26 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
return ""; return "";
} }
private bool IsTerryAttendanceMachineIp(string machineIp)
{
var ip = (machineIp ?? "").Trim();
if (ip.Length == 0)
return false;
foreach (var configured in _config.TerryAttendanceMachineIps ?? Enumerable.Empty<string>())
{
if (string.Equals((configured ?? "").Trim(), ip, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static DateTime NormalizeForDbLocalTime(DateTime value) private static DateTime NormalizeForDbLocalTime(DateTime value)
{ {
if (value.Kind == DateTimeKind.Utc) // Persist as Unspecified wall-clock for MySQL DATETIME (no timezone conversion).
return value.ToLocalTime(); return new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second,
return value; DateTimeKind.Unspecified);
} }
private AttendancePersistOutcome WriteAttendanceToDatabase(AttendanceEvent ev, AttendanceDeviceCycleStats? stats = null) 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 acNo = employeeNo.ToString(CultureInfo.InvariantCulture);
var inOutTypeId = ev.RawMinor > 0 ? (int)ev.RawMinor : 0; 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( var ok = _attendanceLogRepository.InsertAttendance(
acNo, acNo,
employeeNo, employeeNo,
ev.Timestamp, checkTimeLocal,
0, 0,
ev.DeviceId ?? "", machineId,
inOutTypeId, inOutTypeId,
ev.DeviceIp ?? "", machineIp,
ev.Timestamp.Date, dateOnly,
msg => _logger.Diag("attendance", msg), msg => _logger.Diag("attendance", msg),
out var err); out var err);
if (!ok) if (!ok)
{ {
_logger.OpsError(OpsMarkers.Attendance, "DB insert FAILED ac_no=" + acNo + _logger.OpsError(OpsMarkers.Attendance, "DB insert FAILED destination=attendance_log ac_no=" + acNo +
" device=" + (ev.DeviceId ?? "") + " reason=\"" + err + "\""); " device=" + machineId + " reason=\"" + err + "\"");
_logger.Totals.AttendanceFailed++; _logger.Totals.AttendanceFailed++;
return AttendancePersistOutcome.DbFailed; return AttendancePersistOutcome.DbFailed;
} }
_logger.Ops(OpsMarkers.Attendance,
"[ATTENDANCE_ROUTE] employeeNo=" + acNo +
" destination=attendance_log result=INSERTED");
_logger.Totals.AttendanceInserted++; _logger.Totals.AttendanceInserted++;
stats?.InsertedPunches.Add((ev.DeviceIp ?? "", acNo, ev.Timestamp)); stats?.InsertedPunches.Add((machineIp, acNo, checkTimeLocal));
return AttendancePersistOutcome.DbInserted; return AttendancePersistOutcome.DbInserted;
} }
@ -5935,6 +6162,10 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
public string DeviceId { get; } public string DeviceId { get; }
public string DeviceIp { get; } public string DeviceIp { get; }
public DateTime Timestamp { get; } public DateTime Timestamp { get; }
/// <summary>Original Hikvision time string (diagnostic only; not persisted as a column).</summary>
public string DeviceTimestampRaw { get; set; } = "";
/// <summary>Offset from raw Hikvision timestamp when present (diagnostic only).</summary>
public string DeviceTimestampOffset { get; set; } = "";
public int? EmployeeNo { get; } public int? EmployeeNo { get; }
/// <summary>Hikvision employeeNoString — sole identity for attendance_log.ac_no.</summary> /// <summary>Hikvision employeeNoString — sole identity for attendance_log.ac_no.</summary>
public string? EmployeeNoString { get; } public string? EmployeeNoString { get; }