diff --git a/DeviceSettings.config b/DeviceSettings.config
index b8e0688..49268e1 100644
--- a/DeviceSettings.config
+++ b/DeviceSettings.config
@@ -1,20 +1,24 @@
{
"MachineScopeMode": "SITE",
"ScopedMachineIps": [
- "192.168.90.226"
+ "192.168.91.80"
],
"EnableTemplateDeviceToDbSync": false,
"EnableTemplateDbToDeviceSync": false,
- "SourceMachineIp": "192.168.90.226",
+ "SourceMachineIp": "",
"TargetMachineIps": [
- "192.168.90.226"
+ "192.168.91.80"
],
- "SyncEmployeeIds": [
- "15399",
- "17003",
- "15111",
- ]
+ "SyncEmployeeIds": [],
+ "SyncDepartmentIds": [],
+
+ "EnableInitialDepartmentSync": true,
+ "InitialSyncDepartmentIds": [
+ "357"
+ ],
+ "EnableEmployeePhotoSource": true,
+ "EmployeePhotoBaseUrl": "https://portal.utopiaindustries.pk/uind/employee-photo/"
}
diff --git a/HikvisionAttendanceManager.InitialSync.cs b/HikvisionAttendanceManager.InitialSync.cs
new file mode 100644
index 0000000..9d81d43
--- /dev/null
+++ b/HikvisionAttendanceManager.InitialSync.cs
@@ -0,0 +1,339 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using HikvisionAttendanceService.Data;
+using MySql.Data.MySqlClient;
+
+namespace HikvisionAttendanceService;
+
+///
+/// Separate onboarding path: HRMS department employees + portal JPEG photos → new Hikvision device.
+/// Does not read or write attendance_machine_face_templates (existing DB↔device flows stay unchanged).
+///
+internal sealed partial class HikvisionAttendanceManager
+{
+ ///
+ /// Provisions users/faces onto TargetMachineIps from InitialSyncDepartmentIds + employee-photo URL.
+ /// Gated by EnableInitialDepartmentSync only.
+ ///
+ public void RunInitialDepartmentSyncCycle(CancellationToken ct)
+ {
+ if (!_config.EnableInitialDepartmentSync)
+ return;
+
+ ct.ThrowIfCancellationRequested();
+
+ var deptIds = (_config.InitialSyncDepartmentIds ?? new List())
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ if (deptIds.Count == 0)
+ {
+ _logger.OpsWarn("INITIAL_SYNC", "skipped — InitialSyncDepartmentIds is empty");
+ return;
+ }
+
+ var targets = ResolveInitialSyncTargets();
+ if (targets.Count == 0)
+ {
+ _logger.OpsWarn("INITIAL_SYNC", "skipped — no valid TargetMachineIps");
+ return;
+ }
+
+ var employees = LoadEmployeesForInitialDepartmentSync(deptIds, out var loadErr);
+ if (!string.IsNullOrWhiteSpace(loadErr))
+ _logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr);
+
+ _logger.Ops("INITIAL_SYNC", "departmentIds=" + string.Join(",", deptIds) +
+ " employeesFound=" + employees.Count);
+
+ if (employees.Count == 0)
+ return;
+
+ var maxRetries = Math.Max(1, SyncPol.HttpMaxRetries);
+ var photoBase = (_config.EmployeePhotoBaseUrl ?? "").Trim();
+ if (!string.IsNullOrWhiteSpace(photoBase) && !photoBase.EndsWith("/", StringComparison.Ordinal))
+ photoBase += "/";
+
+ 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("INITIAL_SYNC",
+ "target=" + (target.DeviceId ?? "") + " ip=" + (target.Ip ?? "") +
+ " SKIPPED reason=\"target offline\"");
+ continue;
+ }
+
+ var existing = FetchAllUsersIsapiForSync(target, maxRetries, ct);
+ var existingNos = new HashSet(existing.Select(u => u.EmployeeNo), StringComparer.OrdinalIgnoreCase);
+ var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>();
+
+ foreach (var emp in employees)
+ {
+ ct.ThrowIfCancellationRequested();
+ var employeeNo = emp.SerialNumber;
+ var employeeId = emp.Id;
+ var photoUrl = "";
+ var photoDownloaded = false;
+ var userCreated = false;
+ var faceUploaded = false;
+ var reason = "";
+
+ try
+ {
+ if (!existingNos.Contains(employeeNo))
+ {
+ var dto = new UserDto
+ {
+ EmployeeNo = employeeNo,
+ Name = emp.Name ?? "",
+ NumOfFace = 0,
+ NumOfFp = 0
+ };
+ if (!CreateUserOnTarget(target, dto, maxRetries, ct, out var createErr))
+ {
+ reason = string.IsNullOrWhiteSpace(createErr) ? "user_create_failed" : createErr;
+ continue;
+ }
+
+ userCreated = true;
+ existingNos.Add(employeeNo);
+ if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
+ {
+ _attendanceMachineUserRepository.UpsertMachineUser(
+ target.DeviceId ?? "",
+ employeeNo,
+ emp.Name ?? "",
+ "hikvision-service-initial",
+ out _);
+ }
+ }
+
+ if (!_config.EnableEmployeePhotoSource || string.IsNullOrWhiteSpace(photoBase))
+ {
+ reason = "photo_source_disabled";
+ continue;
+ }
+
+ if (string.IsNullOrWhiteSpace(employeeId))
+ {
+ reason = "missing_employee_id";
+ continue;
+ }
+
+ photoUrl = photoBase + employeeId.Trim() + ".jpeg";
+ if (!TryDownloadEmployeePortalPhoto(photoUrl, out var jpegBytes, out var photoErr))
+ {
+ reason = string.IsNullOrWhiteSpace(photoErr) ? "photo_not_found" : photoErr;
+ continue;
+ }
+
+ photoDownloaded = true;
+ if (!IsJpegMagic(jpegBytes))
+ {
+ reason = "invalid_jpeg";
+ continue;
+ }
+
+ if (!UploadFaceOnTarget(target, employeeNo, jpegBytes, maxRetries, ct, out var upErr))
+ {
+ reason = string.IsNullOrWhiteSpace(upErr) ? "face_upload_failed" : upErr;
+ faceRejectedEmployees.Add((employeeNo, reason));
+ continue;
+ }
+
+ faceUploaded = true;
+ }
+ finally
+ {
+ if (photoDownloaded && faceUploaded)
+ {
+ _logger.Ops("INITIAL_SYNC",
+ "employeeNo=" + employeeNo +
+ " employeeId=" + employeeId +
+ " photoUrl=" + photoUrl +
+ " photoDownloaded=true" +
+ " userCreated=" + (userCreated ? "true" : "false") +
+ " faceUploaded=true");
+ }
+ else if (!photoDownloaded)
+ {
+ _logger.Ops("INITIAL_SYNC",
+ "employeeNo=" + employeeNo +
+ (string.IsNullOrWhiteSpace(employeeId) ? "" : (" employeeId=" + employeeId)) +
+ (string.IsNullOrWhiteSpace(photoUrl) ? "" : (" photoUrl=" + photoUrl)) +
+ " photoDownloaded=false" +
+ " reason=" + (string.IsNullOrWhiteSpace(reason) ? "photo_not_found" : reason));
+ }
+ else
+ {
+ _logger.Ops("INITIAL_SYNC",
+ "employeeNo=" + employeeNo +
+ " employeeId=" + employeeId +
+ " photoUrl=" + photoUrl +
+ " photoDownloaded=true" +
+ " userCreated=" + (userCreated ? "true" : "false") +
+ " faceUploaded=false" +
+ " reason=" + (string.IsNullOrWhiteSpace(reason) ? "face_upload_failed" : reason));
+ }
+ }
+ }
+
+ WriteFaceRejectedEmployeeSummary("INITIAL_SYNC", target, faceRejectedEmployees);
+ }
+ }
+
+ private List ResolveInitialSyncTargets()
+ {
+ var targets = new List();
+ foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty())
+ {
+ var t = ResolveDeviceConfigByIp(targetIp);
+ if (t == null || string.IsNullOrWhiteSpace(t.Ip))
+ {
+ _logger.OpsWarn("INITIAL_SYNC", "target skipped — not found or IP empty. TargetMachineIp=\"" + targetIp + "\"");
+ continue;
+ }
+ if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase)))
+ continue;
+ targets.Add(t);
+ }
+
+ foreach (var tid in _config.TargetDeviceIds ?? Enumerable.Empty())
+ {
+ var t = ResolveDeviceConfig(tid);
+ if (t == null || string.IsNullOrWhiteSpace(t.Ip))
+ continue;
+ if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase)))
+ continue;
+ targets.Add(t);
+ }
+
+ return targets;
+ }
+
+ private List LoadEmployeesForInitialDepartmentSync(IReadOnlyList departmentIds, out string error)
+ {
+ error = "";
+ var result = new List();
+ if (departmentIds == null || departmentIds.Count == 0)
+ return result;
+ if (!_config.EnableDbIntegration || _dbConnectionFactory == null)
+ {
+ error = "DB integration not available for initial department sync.";
+ return result;
+ }
+
+ if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error))
+ return result;
+
+ try
+ {
+ using var conn = new MySqlConnection(cs);
+ conn.Open();
+ var sql = new StringBuilder("SELECT id, serial_number FROM employee WHERE department_id IN (");
+ for (int i = 0; i < departmentIds.Count; i++)
+ {
+ if (i > 0) sql.Append(',');
+ sql.Append("@d").Append(i);
+ }
+ sql.Append(')');
+
+ using var cmd = new MySqlCommand(sql.ToString(), conn);
+ for (int i = 0; i < departmentIds.Count; i++)
+ cmd.Parameters.AddWithValue("@d" + i, departmentIds[i]);
+
+ using var rd = cmd.ExecuteReader();
+ var seenSerial = new HashSet(StringComparer.OrdinalIgnoreCase);
+ while (rd.Read())
+ {
+ var serial = rd["serial_number"]?.ToString()?.Trim() ?? "";
+ if (serial.Length == 0 || !seenSerial.Add(serial))
+ continue;
+ result.Add(new InitialSyncEmployee
+ {
+ Id = rd["id"]?.ToString()?.Trim() ?? "",
+ SerialNumber = serial,
+ Name = ""
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ }
+
+ return result;
+ }
+
+ private static bool TryDownloadEmployeePortalPhoto(string photoUrl, out byte[] bytes, out string error)
+ {
+ bytes = Array.Empty();
+ error = "";
+ if (string.IsNullOrWhiteSpace(photoUrl))
+ {
+ error = "photo_not_found";
+ return false;
+ }
+
+ try
+ {
+ using var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = true,
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
+ };
+ using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) };
+ using var response = client.GetAsync(photoUrl).GetAwaiter().GetResult();
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ error = "photo_not_found";
+ return false;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ error = "photo_http_" + (int)response.StatusCode;
+ return false;
+ }
+
+ bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() ?? Array.Empty();
+ if (bytes.Length == 0)
+ {
+ error = "photo_not_found";
+ return false;
+ }
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ error = "photo_download_failed:" + ex.Message;
+ return false;
+ }
+ }
+
+ private static bool IsJpegMagic(byte[] bytes) =>
+ bytes != null &&
+ bytes.Length >= 3 &&
+ bytes[0] == 0xFF &&
+ bytes[1] == 0xD8 &&
+ bytes[2] == 0xFF;
+
+ private sealed class InitialSyncEmployee
+ {
+ public string Id { get; set; } = "";
+ public string SerialNumber { get; set; } = "";
+ public string Name { get; set; } = "";
+ }
+}
diff --git a/HikvisionAttendanceManager.UserSync.cs b/HikvisionAttendanceManager.UserSync.cs
index a792b1d..7a077c6 100644
--- a/HikvisionAttendanceManager.UserSync.cs
+++ b/HikvisionAttendanceManager.UserSync.cs
@@ -11,12 +11,16 @@ using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using HikvisionAttendanceService.Data;
+using MySql.Data.MySqlClient;
namespace HikvisionAttendanceService;
/// Multi-device user + face sync via ISAPI HTTP (Digest). Orchestration and focused helpers.
internal sealed partial class HikvisionAttendanceManager
{
+ private readonly Dictionary> _faceLibCacheByDeviceIp =
+ new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
private HikvisionAttendanceWindowsService.UserSyncPoliciesConfig SyncPol =>
_config.SyncPolicies ?? new HikvisionAttendanceWindowsService.UserSyncPoliciesConfig();
@@ -62,10 +66,23 @@ internal sealed partial class HikvisionAttendanceManager
var pol = SyncPol;
int maxRetries = pol.HttpMaxRetries < 1 ? 1 : pol.HttpMaxRetries;
+ // Merge SyncEmployeeIds + serial_numbers from SyncDepartmentIds (deduped).
+ var requestedEmployeeIds = BuildMergedSyncEmployeeIds(out var departmentIdsUsed, out var departmentEmployeesFound);
+ if (departmentIdsUsed.Count > 0)
+ {
+ _logger.Ops("USER_SYNC", "Department filter: departmentIds=" + string.Join(",", departmentIdsUsed) +
+ " employeesFound=" + departmentEmployeesFound);
+ }
+
+ var hasExplicitEmployeeFilter = requestedEmployeeIds.Count > 0;
+ // DB templates already in attendance_machine_face_templates do not need a source device.
+ var dbOnlyRestore = _config.EnableTemplateDbToDeviceSync && hasExplicitEmployeeFilter;
+
var source = !string.IsNullOrWhiteSpace(_config.SourceMachineIp)
? ResolveDeviceConfigByIp(_config.SourceMachineIp)
: ResolveDeviceConfig(_config.SourceDeviceId ?? "");
- if (source == null || string.IsNullOrWhiteSpace(source.Ip))
+ var hasSource = source != null && !string.IsNullOrWhiteSpace(source.Ip);
+ if (!hasSource && !dbOnlyRestore)
{
_logger.Warn("UserSync: skipped — source device not found or IP empty. SourceDeviceId=\"" +
(_config.SourceDeviceId ?? "") + "\" SourceMachineIp=\"" + (_config.SourceMachineIp ?? "") + "\".");
@@ -84,7 +101,8 @@ internal sealed partial class HikvisionAttendanceManager
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 (string.Equals((t.Ip ?? "").Trim(), (source.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) &&
+ 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 + "\".");
@@ -101,7 +119,8 @@ internal sealed partial class HikvisionAttendanceManager
continue;
}
- if (DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source.DeviceId))
+ if (hasSource &&
+ DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source!.DeviceId))
{
if (!_config.EnableTemplateDbToDeviceSync)
{
@@ -123,21 +142,24 @@ internal sealed partial class HikvisionAttendanceManager
}
var cycleId = DateTime.UtcNow.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture);
+ var sourceDeviceLabel = hasSource ? (source!.DeviceId ?? "") : "(none-db-restore)";
+ var sourceIpLabel = hasSource ? (source!.Ip ?? "") : "";
_logger.Info("USER_SYNC cycle begin id=" + cycleId +
". Meaning: one full restore/sync pass from source to target(s)." +
- " sourceDevice=" + source.DeviceId + " sourceIp=" + (source.Ip ?? "") +
+ " sourceDevice=" + sourceDeviceLabel + " sourceIp=" + sourceIpLabel +
" targetCount=" + targets.Count +
" UpdateExistingUserFields=" + pol.UpdateExistingUserFields +
" UploadFaceIfMissingOnly=" + pol.UploadFaceIfMissingOnly +
" DeleteOnTargetIfMissingInSource=" + pol.DeleteOnTargetIfMissingInSource);
_logger.JobInfo("user_sync", "Cycle begin id=" + cycleId +
- ". Meaning: push users/faces to targets. source=" + source.DeviceId +
- " sourceIp=" + (source.Ip ?? "") + " targets=" + targets.Count);
+ ". Meaning: push users/faces to targets. source=" + sourceDeviceLabel +
+ " sourceIp=" + sourceIpLabel + " targets=" + targets.Count);
- var sourceUsers = LoadSourceUsersForSync(source, maxRetries, ct);
- if ((_config.SyncEmployeeIds ?? new List()).Count > 0)
+ var sourceUsers = hasSource
+ ? LoadSourceUsersForSync(source!, maxRetries, ct)
+ : new List();
+ if (hasExplicitEmployeeFilter)
{
- var requestedEmployeeIds = new HashSet(_config.SyncEmployeeIds.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
sourceUsers = sourceUsers.Where(x => requestedEmployeeIds.Contains(x.EmployeeNo)).ToList();
// Same-machine restore: employee may already be gone from device/user list;
// synthesize missing IDs and restore from DB face templates.
@@ -157,13 +179,14 @@ internal sealed partial class HikvisionAttendanceManager
});
}
}
+ _logger.Ops("USER_SYNC", "Final employee sync count=" + sourceUsers.Count);
_logger.JobInfo("user_sync", "Employee filter: requested=" + requestedEmployeeIds.Count +
" willSync=" + sourceUsers.Count +
- ". Meaning: only these employee IDs from SyncEmployeeIds are restored/synced this cycle.");
+ ". Meaning: SyncEmployeeIds + SyncDepartmentIds (deduped) drive this cycle.");
}
- _logger.Info("USER_SYNC source users ready count=" + sourceUsers.Count + " device=" + source.DeviceId +
+ _logger.Info("USER_SYNC source users ready count=" + sourceUsers.Count + " device=" + sourceDeviceLabel +
". Meaning: users we will try to create/update on the target.");
- _logger.JobInfo("user_sync", "Source users ready count=" + sourceUsers.Count + " device=" + source.DeviceId);
+ _logger.JobInfo("user_sync", "Source users ready count=" + sourceUsers.Count + " device=" + sourceDeviceLabel);
var faceBytesByEmployee = new Dictionary(StringComparer.OrdinalIgnoreCase);
int faceDlOk = 0, faceDlFail = 0;
@@ -183,13 +206,13 @@ internal sealed partial class HikvisionAttendanceManager
_logger.Diag("user_sync", "face loaded from DB employeeNo=" + u.EmployeeNo);
continue;
}
- if (string.IsNullOrWhiteSpace(u.FaceUrl))
+ if (!hasSource || string.IsNullOrWhiteSpace(u.FaceUrl))
{
faceBytesByEmployee[u.EmployeeNo] = null;
continue;
}
- if (DownloadFaceByUrl(source, u.FaceUrl, maxRetries, ct, out var bytes) && bytes.Length > 0)
+ if (DownloadFaceByUrl(source!, u.FaceUrl, maxRetries, ct, out var bytes) && bytes.Length > 0)
{
faceBytesByEmployee[u.EmployeeNo] = bytes;
faceDlOk++;
@@ -275,6 +298,7 @@ internal sealed partial class HikvisionAttendanceManager
int created = 0, faceUp = 0, skipped = 0, failed = 0, updated = 0;
var statusCounts = new Dictionary();
+ var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>();
void Bump(UserSyncStatus s)
{
@@ -282,8 +306,8 @@ internal sealed partial class HikvisionAttendanceManager
statusCounts[s] = n + 1;
}
- // Explicit SyncEmployeeIds = restore/push list; do not filter by DB assignment rows.
- var explicitEmployeeFilter = (_config.SyncEmployeeIds ?? new List()).Count > 0;
+ // Explicit SyncEmployeeIds / SyncDepartmentIds = restore/push list; do not filter by DB assignment rows.
+ var explicitEmployeeFilter = hasExplicitEmployeeFilter;
foreach (var srcUser in sourceUsers)
{
@@ -292,7 +316,12 @@ internal sealed partial class HikvisionAttendanceManager
continue;
targetMap.TryGetValue(srcUser.EmployeeNo, out var tgtRow);
bool createdNew = false;
+ bool createUserLogged = false;
+ bool faceUploadedLogged = false;
+ bool templateFoundLogged = false;
+ try
+ {
if (tgtRow == null)
{
if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr))
@@ -305,6 +334,7 @@ internal sealed partial class HikvisionAttendanceManager
}
created++;
+ createUserLogged = true;
_logger.Totals.UsersAdded++;
_logger.Biz(BizChannel.UserSync,
"Machine ID : " + (target.DeviceId ?? ""),
@@ -393,6 +423,7 @@ internal sealed partial class HikvisionAttendanceManager
{
_logger.Warn("UserSync template lookup failed employeeNo=" + srcUser.EmployeeNo + " err=" + tplErr);
}
+ templateFoundLogged = hasFaceBytes;
if (!hasFaceBytes)
{
skipped++;
@@ -444,6 +475,7 @@ 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));
_logger.OpsError(OpsMarkers.TemplateDbToDevice,
srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\"");
_logger.Totals.TemplatesFailed++;
@@ -468,6 +500,7 @@ internal sealed partial class HikvisionAttendanceManager
}
faceUp++;
+ faceUploadedLogged = true;
_logger.Totals.TemplatesSaved++;
tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1);
_logger.Ops(OpsMarkers.TemplateDbToDevice,
@@ -484,6 +517,15 @@ internal sealed partial class HikvisionAttendanceManager
_logger.BizSeparator(BizChannel.Template);
}
Bump(createdNew ? UserSyncStatus.CreatedAndFaceUploaded : UserSyncStatus.ExistsFaceUploaded);
+ }
+ finally
+ {
+ _logger.Ops("USER_SYNC",
+ "employeeNo=" + srcUser.EmployeeNo +
+ " templateFound=" + (templateFoundLogged ? "true" : "false") +
+ " createUser=" + (createUserLogged ? "true" : "false") +
+ " faceUploaded=" + (faceUploadedLogged ? "true" : "false"));
+ }
}
if (pol.DeleteOnTargetIfMissingInSource)
@@ -520,6 +562,7 @@ 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);
_logger.Ops(OpsMarkers.TemplateDbToDevice,
"device=" + target.DeviceId + " summary created=" + created +
" faceUploaded=" + faceUp + " updated=" + updated +
@@ -531,6 +574,114 @@ internal sealed partial class HikvisionAttendanceManager
_logger.Ops(OpsMarkers.TemplateDbToDevice, "Cycle completed id=" + cycleId);
}
+ private void WriteFaceRejectedEmployeeSummary(
+ string diagTag,
+ HikvisionAttendanceWindowsService.DeviceConfig target,
+ IReadOnlyList<(string EmployeeNo, string Reason)> rejected)
+ {
+ if (rejected == null || rejected.Count == 0)
+ return;
+
+ _logger.Diag(diagTag,
+ "FACE REJECTED SUMMARY device=" + (target.DeviceId ?? "") +
+ " ip=" + (target.Ip ?? "") +
+ " count=" + rejected.Count);
+ foreach (var item in rejected.OrderBy(x => x.EmployeeNo, StringComparer.OrdinalIgnoreCase))
+ {
+ _logger.Diag(diagTag,
+ "employeeNo=" + item.EmployeeNo +
+ " reason=\"" + ToOneLineSnippet(item.Reason, 240) + "\"");
+ }
+ }
+
+ ///
+ /// Merges SyncEmployeeIds with serial_numbers from hrms.employee for SyncDepartmentIds.
+ ///
+ private HashSet BuildMergedSyncEmployeeIds(out List departmentIdsUsed, out int departmentEmployeesFound)
+ {
+ departmentIdsUsed = new List();
+ departmentEmployeesFound = 0;
+ var merged = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var id in _config.SyncEmployeeIds ?? Enumerable.Empty())
+ {
+ var t = (id ?? "").Trim();
+ if (t.Length > 0)
+ merged.Add(t);
+ }
+
+ var deptIds = (_config.SyncDepartmentIds ?? new List())
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ if (deptIds.Count == 0)
+ return merged;
+
+ departmentIdsUsed = deptIds;
+ var fromDept = ResolveSerialNumbersByDepartmentIds(deptIds, out var err);
+ if (!string.IsNullOrWhiteSpace(err))
+ _logger.Warn("UserSync: department employee lookup failed err=" + err);
+
+ departmentEmployeesFound = fromDept.Count;
+ foreach (var sn in fromDept)
+ merged.Add(sn);
+
+ return merged;
+ }
+
+ ///
+ /// SELECT serial_number FROM employee WHERE department_id IN (...). Uses existing DB factory only.
+ ///
+ private List ResolveSerialNumbersByDepartmentIds(IReadOnlyList departmentIds, out string error)
+ {
+ error = "";
+ var result = new List();
+ if (departmentIds == null || departmentIds.Count == 0)
+ return result;
+ if (!_config.EnableDbIntegration || _dbConnectionFactory == null)
+ {
+ error = "DB integration not available for department filter.";
+ return result;
+ }
+
+ if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error))
+ return result;
+
+ try
+ {
+ using var conn = new MySqlConnection(cs);
+ conn.Open();
+ var sql = new StringBuilder("SELECT serial_number FROM employee WHERE department_id IN (");
+ for (int i = 0; i < departmentIds.Count; i++)
+ {
+ if (i > 0) sql.Append(',');
+ sql.Append("@d").Append(i);
+ }
+ sql.Append(')');
+
+ using var cmd = new MySqlCommand(sql.ToString(), conn);
+ for (int i = 0; i < departmentIds.Count; i++)
+ cmd.Parameters.AddWithValue("@d" + i, departmentIds[i]);
+
+ using var rd = cmd.ExecuteReader();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ while (rd.Read())
+ {
+ var sn = rd["serial_number"]?.ToString()?.Trim() ?? "";
+ if (sn.Length == 0 || !seen.Add(sn))
+ continue;
+ result.Add(sn);
+ }
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ }
+
+ return result;
+ }
+
private List LoadSourceUsersForSync(HikvisionAttendanceWindowsService.DeviceConfig source, int maxRetries, CancellationToken ct)
{
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
@@ -686,6 +837,13 @@ internal sealed partial class HikvisionAttendanceManager
RunUserFaceSyncCycle(token);
if (_config.EnableTemplateDbToDeviceSync)
_logger.Ops(OpsMarkers.TemplateDbToDevice, "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - cycleStarted));
+ if (_config.EnableInitialDepartmentSync)
+ {
+ var initialStarted = DateTime.Now;
+ _logger.Ops("INITIAL_SYNC", "JOB CYCLE START");
+ RunInitialDepartmentSyncCycle(token);
+ _logger.Ops("INITIAL_SYNC", "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - initialStarted));
+ }
_logger.Ops(OpsMarkers.UserDelete, "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - cycleStarted));
}
catch (OperationCanceledException)
@@ -767,7 +925,6 @@ internal sealed partial class HikvisionAttendanceManager
byte[] faceImage, int maxRetries, CancellationToken ct, out string error)
{
error = "";
- var pol = SyncPol;
if (faceImage == null || faceImage.Length == 0)
{
error = "empty faceImage";
@@ -775,8 +932,11 @@ internal sealed partial class HikvisionAttendanceManager
}
var emp = (employeeNo ?? "").Trim();
- var faceLibType = string.IsNullOrWhiteSpace(pol.FaceLibType) ? "blackFD" : pol.FaceLibType.Trim();
- var fdId = string.IsNullOrWhiteSpace(pol.FaceLibraryFdId) ? "1" : pol.FaceLibraryFdId.Trim();
+ if (emp.Length == 0)
+ {
+ error = "empty employeeNo";
+ return false;
+ }
if (!TryNormalizeFaceImageForUpload(faceImage, out var jpegBytes, out var imageFormat, out var normalizeNote))
{
@@ -790,50 +950,242 @@ internal sealed partial class HikvisionAttendanceManager
return false;
}
- string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(faceLibType) +
- "\",\"FDID\":\"" + EscapeJsonStatic(fdId) +
- "\",\"FPID\":\"" + EscapeJsonStatic(emp) + "\"}";
+ if (!TryDiscoverFaceLibCandidates(target, maxRetries, ct, out var faceLibs, out var discoverErr))
+ {
+ error = "FDLib discovery failed: " + (string.IsNullOrWhiteSpace(discoverErr) ? "no libraries returned" : discoverErr);
+ _logger.Diag("TEMPLATE_DB_TO_DEVICE",
+ "face upload aborted employee=" + emp + " reason=\"" + error + "\"");
+ return false;
+ }
const string relativeUri = "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json";
string url = "http://" + target.Ip + ":" + IsapiPort + relativeUri;
+ string lastError = "";
+ string lastFaceDataRecordResponse = "";
- _logger.Diag("TEMPLATE_DB_TO_DEVICE",
- "face upload prepare employee=" + emp +
- " device=" + (target.DeviceId ?? "") +
- " ip=" + (target.Ip ?? "") +
- " url=" + url +
- " faceLibType=" + faceLibType +
- " FDID=" + fdId +
- " FPID=" + emp +
- " blobSize=" + faceImage.Length +
- " jpegSize=" + jpegBytes.Length +
- " sourceFormat=" + imageFormat +
- " base64Length=" + (4 * ((jpegBytes.Length + 2) / 3)) +
- " meta=" + metaJson +
- (string.IsNullOrWhiteSpace(normalizeNote) ? "" : (" note=\"" + normalizeNote + "\"")));
-
- if (!TryIsapiPostMultipartFaceWithRetry(target, relativeUri, metaJson, jpegBytes, emp, maxRetries, ct,
- out var body, out var status, out error))
+ foreach (var lib in faceLibs)
{
- // Keep Ops/summary concise; full HTTP body already written to Diag inside the sender.
- error = ConciseFaceUploadFailureReason(status, body, error);
- return false;
- }
+ ct.ThrowIfCancellationRequested();
+ var faceLibType = (lib.faceLibType ?? "").Trim();
+ var fdId = lib.fdId.ToString(CultureInfo.InvariantCulture);
+ if (faceLibType.Length == 0)
+ continue;
+
+ string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(faceLibType) +
+ "\",\"FDID\":\"" + EscapeJsonStatic(fdId) +
+ "\",\"FPID\":\"" + EscapeJsonStatic(emp) + "\"}";
- if (!IsLikelyIsapiSuccess(body, status))
- {
- TryParseIsapiResponseFields(body, out var sc, out var ss, out var sub);
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
- "face upload rejected by device employee=" + emp +
- " httpStatus=" + status +
- " statusCode=" + sc +
- " statusString=\"" + ss + "\"" +
- " subStatusCode=\"" + sub + "\"" +
- " response=" + (body ?? ""));
- error = ConciseFaceUploadFailureReason(status, body, "device rejected face upload");
+ "face upload prepare employeeNo=" + emp +
+ " device=" + (target.DeviceId ?? "") +
+ " ip=" + (target.Ip ?? "") +
+ " url=" + url +
+ " faceLibType=" + faceLibType +
+ " FDID=" + fdId +
+ " FPID=" + emp +
+ " blobSize=" + faceImage.Length +
+ " jpegSize=" + jpegBytes.Length +
+ " sourceFormat=" + imageFormat +
+ " meta=" + metaJson +
+ (string.IsNullOrWhiteSpace(normalizeNote) ? "" : (" note=\"" + normalizeNote + "\"")));
+
+ if (!TryIsapiPostMultipartFaceWithRetry(target, relativeUri, metaJson, jpegBytes, emp, maxRetries, ct,
+ out var body, out var status, out var uploadErr))
+ {
+ lastError = ConciseFaceUploadFailureReason(status, body, uploadErr);
+ lastFaceDataRecordResponse = body ?? "";
+ _logger.Diag("TEMPLATE_DB_TO_DEVICE",
+ "FaceDataRecord failed employeeNo=" + emp +
+ " faceLibType=" + faceLibType +
+ " FDID=" + fdId +
+ " response=" + lastFaceDataRecordResponse +
+ " err=\"" + lastError + "\"");
+ continue;
+ }
+
+ if (!IsLikelyIsapiSuccess(body, status))
+ {
+ TryParseIsapiResponseFields(body, out var sc, out var ss, out var sub);
+ lastError = ConciseFaceUploadFailureReason(status, body, "device rejected face upload");
+ lastFaceDataRecordResponse = body ?? "";
+ _logger.Diag("TEMPLATE_DB_TO_DEVICE",
+ "face upload rejected by device employeeNo=" + emp +
+ " faceLibType=" + faceLibType +
+ " FDID=" + fdId +
+ " httpStatus=" + status +
+ " statusCode=" + sc +
+ " statusString=\"" + ss + "\"" +
+ " subStatusCode=\"" + sub + "\"" +
+ " response=" + lastFaceDataRecordResponse);
+ continue;
+ }
+
+ lastFaceDataRecordResponse = body ?? "";
+ _logger.Diag("TEMPLATE_DB_TO_DEVICE",
+ "FaceDataRecord accepted employeeNo=" + emp +
+ " faceLibType=" + faceLibType +
+ " FDID=" + fdId +
+ " response=" + lastFaceDataRecordResponse);
+
+ if (TryVerifyPersonFaceEnrolled(target, emp, maxRetries, ct, out var numOfFace, out var verifyErr) &&
+ numOfFace >= 1)
+ {
+ _logger.Diag("TEMPLATE_DB_TO_DEVICE",
+ "face enrollment verified employeeNo=" + emp +
+ " selectedFaceLibType=" + faceLibType +
+ " selectedFDID=" + fdId +
+ " FaceDataRecordResponse=" + lastFaceDataRecordResponse +
+ " UserInfoNumOfFace=" + numOfFace);
+ return true;
+ }
+
+ lastError = string.IsNullOrWhiteSpace(verifyErr)
+ ? "FaceDataRecord OK but UserInfo numOfFace=" + numOfFace
+ : verifyErr;
+ _logger.Diag("TEMPLATE_DB_TO_DEVICE",
+ "face enrollment not visible employeeNo=" + emp +
+ " faceLibType=" + faceLibType +
+ " FDID=" + fdId +
+ " FaceDataRecordResponse=" + lastFaceDataRecordResponse +
+ " UserInfoNumOfFace=" + numOfFace +
+ " note=\"" + lastError + "\"");
+ }
+
+ error = string.IsNullOrWhiteSpace(lastError)
+ ? "FaceDataRecord did not produce enrolled face (numOfFace=0) on any discovered FDLib"
+ : lastError;
+ if (!string.IsNullOrWhiteSpace(lastFaceDataRecordResponse))
+ error += " lastFaceDataRecordResponse=" + ToOneLineSnippet(lastFaceDataRecordResponse, 240);
+ return false;
+ }
+
+ ///
+ /// GET /ISAPI/Intelligent/FDLib and cache per device IP for the process lifetime.
+ ///
+ private bool TryDiscoverFaceLibCandidates(
+ HikvisionAttendanceWindowsService.DeviceConfig device,
+ int maxRetries,
+ CancellationToken ct,
+ out List candidates,
+ out string error)
+ {
+ candidates = new List();
+ error = "";
+ var ip = (device.Ip ?? "").Trim();
+ if (ip.Length == 0)
+ {
+ error = "target IP empty";
return false;
}
+ if (_faceLibCacheByDeviceIp.TryGetValue(ip, out var cached) && cached.Count > 0)
+ {
+ candidates = cached;
+ return true;
+ }
+
+ string fdLibUrl = "http://" + ip + ":" + IsapiPort + "/ISAPI/Intelligent/FDLib?format=json";
+ if (!TryHttpGetBytesWithRetry(device, fdLibUrl, maxRetries, ct, out var bytes, out error))
+ return false;
+
+ if (!TryParseFaceLibCandidatesFromFdLibResponse(bytes, out candidates, out var parseErr))
+ {
+ error = string.IsNullOrWhiteSpace(parseErr) ? "FDLib response parse failed" : parseErr;
+ candidates = new List();
+ return false;
+ }
+
+ candidates = OrderFaceLibCandidates(DedupeFaceLibCandidates(candidates));
+ if (candidates.Count == 0)
+ {
+ error = "FDLib discovery returned no faceLibType/FDID pairs";
+ return false;
+ }
+
+ _faceLibCacheByDeviceIp[ip] = candidates;
+ _logger.Diag("TEMPLATE_DB_TO_DEVICE",
+ "FDLib discovered device=" + (device.DeviceId ?? "") +
+ " ip=" + ip +
+ " count=" + candidates.Count +
+ " libs=" + string.Join(",", candidates.Select(c => c.faceLibType + ":" + c.fdId)));
+ return true;
+ }
+
+ private static List DedupeFaceLibCandidates(List input)
+ {
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var result = new List();
+ foreach (var c in input)
+ {
+ var key = (c.faceLibType ?? "").Trim() + "|" + c.fdId;
+ if (!seen.Add(key))
+ continue;
+ result.Add(c);
+ }
+
+ return result;
+ }
+
+ private static List OrderFaceLibCandidates(List input) =>
+ input
+ .OrderBy(c => string.Equals(c.faceLibType, "blackFD", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
+ .ThenBy(c => c.faceLibType, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(c => c.fdId)
+ .ToList();
+
+ ///
+ /// Confirms Person Management enrollment via UserInfo/Search numOfFace.
+ /// Retries once briefly because some firmware updates numOfFace asynchronously.
+ ///
+ private bool TryVerifyPersonFaceEnrolled(
+ HikvisionAttendanceWindowsService.DeviceConfig target,
+ string employeeNo,
+ int maxRetries,
+ CancellationToken ct,
+ out int numOfFace,
+ out string error)
+ {
+ numOfFace = 0;
+ error = "";
+ for (int attempt = 0; attempt < 2; attempt++)
+ {
+ if (attempt > 0)
+ Thread.Sleep(300);
+
+ if (!TryGetUserNumOfFaceOnTarget(target, employeeNo, maxRetries, ct, out numOfFace, out error))
+ continue;
+
+ if (numOfFace >= 1)
+ return true;
+ }
+
+ if (string.IsNullOrWhiteSpace(error))
+ error = "UserInfo numOfFace=" + numOfFace;
+ return false;
+ }
+
+ private bool TryGetUserNumOfFaceOnTarget(
+ HikvisionAttendanceWindowsService.DeviceConfig target,
+ string employeeNo,
+ int maxRetries,
+ CancellationToken ct,
+ out int numOfFace,
+ out string error)
+ {
+ numOfFace = 0;
+ error = "";
+ if (!TrySearchUsersIsapiPage(target, 0, 10, employeeNo.Trim(), maxRetries, ct, out var users, out error))
+ return false;
+
+ var user = users.FirstOrDefault(u =>
+ string.Equals(u.EmployeeNo, employeeNo.Trim(), StringComparison.OrdinalIgnoreCase));
+ if (user == null)
+ {
+ error = "employee not found in UserInfo/Search";
+ return false;
+ }
+
+ numOfFace = user.NumOfFace;
return true;
}
diff --git a/HikvisionAttendanceManager.cs b/HikvisionAttendanceManager.cs
index 5cd82af..40cae3d 100644
--- a/HikvisionAttendanceManager.cs
+++ b/HikvisionAttendanceManager.cs
@@ -342,19 +342,23 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
var hasTemplateSyncRoute = hasUserSyncSource && userSyncTargetCount > 0;
var hasDatabaseDeletionRoute = _config.EnableDbIntegration && _attendanceMachineRepository != null &&
_attendanceMachineUserRepository != null;
- if (_config.EnableUserSync &&
+ var hasInitialDepartmentSyncRoute = _config.EnableInitialDepartmentSync && userSyncTargetCount > 0 &&
+ (_config.InitialSyncDepartmentIds?.Count ?? 0) > 0;
+ if ((_config.EnableUserSync || _config.EnableInitialDepartmentSync) &&
_config.SyncIntervalMinutes > 0 &&
- (hasTemplateSyncRoute || hasDatabaseDeletionRoute))
+ (hasTemplateSyncRoute || hasDatabaseDeletionRoute || hasInitialDepartmentSyncRoute))
{
var sourceLabel = !string.IsNullOrWhiteSpace(_config.SourceMachineIp) ? _config.SourceMachineIp : _config.SourceDeviceId;
- _logger.Ops(OpsMarkers.Service, "JOB ENABLED [TEMPLATE_DB_TO_DEVICE]/[USER_DELETE] intervalMinutes=" + _config.SyncIntervalMinutes +
+ _logger.Ops(OpsMarkers.Service, "JOB ENABLED [TEMPLATE_DB_TO_DEVICE]/[USER_DELETE]/[INITIAL_SYNC] intervalMinutes=" + _config.SyncIntervalMinutes +
" source=" + (hasUserSyncSource ? sourceLabel : "(not configured)") + " targets=" + userSyncTargetCount +
- " deletionByMachineId=" + hasDatabaseDeletionRoute);
+ " deletionByMachineId=" + hasDatabaseDeletionRoute +
+ " initialDepartmentSync=" + hasInitialDepartmentSyncRoute);
_ = Task.Run(() => UserSyncSchedulerLoop(_cts.Token), _cts.Token);
}
else
{
_logger.Ops(OpsMarkers.Service, "JOB DISABLED [TEMPLATE_DB_TO_DEVICE] EnableUserSync=" + _config.EnableUserSync +
+ " EnableInitialDepartmentSync=" + _config.EnableInitialDepartmentSync +
" hasSource=" + hasUserSyncSource + " targets=" + userSyncTargetCount +
" deletionByMachineId=" + hasDatabaseDeletionRoute);
}
diff --git a/HikvisionAttendanceWindowsService.cs b/HikvisionAttendanceWindowsService.cs
index dc4efb5..0c12e60 100644
--- a/HikvisionAttendanceWindowsService.cs
+++ b/HikvisionAttendanceWindowsService.cs
@@ -103,6 +103,24 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
public bool EnableTemplateDbToDeviceSync { get; set; }
+ ///
+ /// Separate onboarding flow: provision users+faces to a new device from HRMS department
+ /// + employee portal photos. Does not use attendance_machine_face_templates.
+ ///
+ [DataMember]
+ public bool EnableInitialDepartmentSync { get; set; }
+
+ [DataMember]
+ public List InitialSyncDepartmentIds { get; set; } = new List();
+
+ /// When true, initial sync downloads JPEG photos from .
+ [DataMember]
+ public bool EnableEmployeePhotoSource { get; set; }
+
+ /// Base URL ending with /; photo path is {base}{employee.id}.jpeg
+ [DataMember]
+ public string EmployeePhotoBaseUrl { get; set; } = "";
+
[DataMember]
public bool KeepAttendanceFileExport { get; set; } = true;
@@ -223,6 +241,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
public List SyncEmployeeIds { get; set; } = new List();
+ /// Optional HRMS department_id values; serial_numbers from hrms.employee are merged with SyncEmployeeIds.
+ [DataMember]
+ public List SyncDepartmentIds { get; set; } = new List();
+
/// Optional directory to persist downloaded source face images for auditing or re-upload. Empty = skip file save.
[DataMember]
public string UserSyncFaceCacheDirectory { get; set; } = "";
@@ -248,6 +270,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
EnableTemplateDbPersistence = false,
EnableTemplateDeviceToDbSync = false,
EnableTemplateDbToDeviceSync = false,
+ EnableInitialDepartmentSync = false,
+ InitialSyncDepartmentIds = new List(),
+ EnableEmployeePhotoSource = false,
+ EmployeePhotoBaseUrl = "",
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
DbHost = "",
@@ -281,6 +307,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
TargetDeviceIds = new List(),
TargetMachineIps = new List(),
SyncEmployeeIds = new List(),
+ SyncDepartmentIds = new List(),
UserSyncFaceCacheDirectory = "",
SyncPolicies = new UserSyncPoliciesConfig()
};
@@ -331,6 +358,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
EnableTemplateDbPersistence = false,
EnableTemplateDeviceToDbSync = false,
EnableTemplateDbToDeviceSync = false,
+ EnableInitialDepartmentSync = false,
+ InitialSyncDepartmentIds = new List(),
+ EnableEmployeePhotoSource = false,
+ EmployeePhotoBaseUrl = "",
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
DbHost = "",
@@ -364,6 +395,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
TargetDeviceIds = new List(),
TargetMachineIps = new List(),
SyncEmployeeIds = new List(),
+ SyncDepartmentIds = new List(),
UserSyncFaceCacheDirectory = "",
SyncPolicies = new UserSyncPoliciesConfig()
};
@@ -536,6 +568,14 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
cfg.TargetMachineIps = new List(overlay.TargetMachineIps);
if (overlay.SyncEmployeeIds != null)
cfg.SyncEmployeeIds = new List(overlay.SyncEmployeeIds);
+ if (overlay.SyncDepartmentIds != null)
+ cfg.SyncDepartmentIds = new List(overlay.SyncDepartmentIds);
+ cfg.EnableInitialDepartmentSync = overlay.EnableInitialDepartmentSync;
+ if (overlay.InitialSyncDepartmentIds != null)
+ cfg.InitialSyncDepartmentIds = new List(overlay.InitialSyncDepartmentIds);
+ cfg.EnableEmployeePhotoSource = overlay.EnableEmployeePhotoSource;
+ if (overlay.EmployeePhotoBaseUrl != null)
+ cfg.EmployeePhotoBaseUrl = overlay.EmployeePhotoBaseUrl;
}
catch
{
@@ -573,6 +613,15 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
cfg.SyncEmployeeIds ??= new List();
for (int i = 0; i < cfg.SyncEmployeeIds.Count; i++)
cfg.SyncEmployeeIds[i] = (cfg.SyncEmployeeIds[i] ?? "").Trim();
+ cfg.SyncDepartmentIds ??= new List();
+ for (int i = 0; i < cfg.SyncDepartmentIds.Count; i++)
+ cfg.SyncDepartmentIds[i] = (cfg.SyncDepartmentIds[i] ?? "").Trim();
+ cfg.InitialSyncDepartmentIds ??= new List();
+ for (int i = 0; i < cfg.InitialSyncDepartmentIds.Count; i++)
+ cfg.InitialSyncDepartmentIds[i] = (cfg.InitialSyncDepartmentIds[i] ?? "").Trim();
+ cfg.EmployeePhotoBaseUrl = string.IsNullOrWhiteSpace(cfg.EmployeePhotoBaseUrl)
+ ? ""
+ : cfg.EmployeePhotoBaseUrl.Trim();
if (cfg.Devices == null)
return;
@@ -610,6 +659,21 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
public List SyncEmployeeIds { get; set; } = new List();
+
+ [DataMember]
+ public List SyncDepartmentIds { get; set; } = new List();
+
+ [DataMember]
+ public bool EnableInitialDepartmentSync { get; set; }
+
+ [DataMember]
+ public List InitialSyncDepartmentIds { get; set; } = new List();
+
+ [DataMember]
+ public bool EnableEmployeePhotoSource { get; set; }
+
+ [DataMember]
+ public string EmployeePhotoBaseUrl { get; set; } = "";
}
/// Policies for multi-device user/face sync (ISAPI). Loaded from service JSON only.