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
parent
9605c370ae
commit
312a8815d0
|
|
@ -89,51 +89,7 @@ internal sealed partial class HikvisionAttendanceManager
|
|||
return;
|
||||
}
|
||||
|
||||
var targets = new List<HikvisionAttendanceWindowsService.DeviceConfig>();
|
||||
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);
|
||||
}
|
||||
|
||||
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<UserSyncStatus, int>();
|
||||
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<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(
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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,
|
||||
byte[] faceImage)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
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<string>())
|
||||
{
|
||||
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; }
|
||||
/// <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; }
|
||||
/// <summary>Hikvision employeeNoString — sole identity for attendance_log.ac_no.</summary>
|
||||
public string? EmployeeNoString { get; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue