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;
|
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++;
|
||||||
|
|
@ -372,8 +334,12 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
NumOfFp = 0
|
NumOfFp = 0
|
||||||
};
|
};
|
||||||
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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue