feat: require location-site filter and add departmental sync summaries
Initial department sync now requires InitialSyncLocationSiteId and can run across all active departments when department IDs are empty. Employee selection filters by location site, reports inactive/missing departments, and writes per-department outcomes to a dedicated DepartmentalSync biz log.main
parent
e202923a69
commit
ec6aaac0fa
|
|
@ -54,6 +54,7 @@ internal enum BizChannel
|
||||||
Attendance,
|
Attendance,
|
||||||
UserSync,
|
UserSync,
|
||||||
Template,
|
Template,
|
||||||
|
DepartmentalSync,
|
||||||
Unreachable
|
Unreachable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"EnableTemplateDeviceToDbSync": false,
|
"EnableTemplateDeviceToDbSync": false,
|
||||||
"EnableTemplateDbToDeviceSync": false,
|
"EnableTemplateDbToDeviceSync": false,
|
||||||
|
|
||||||
"SourceMachineIp": "",
|
"SourceMachineIp": "192.168.91.80",
|
||||||
"TargetMachineIps": [
|
"TargetMachineIps": [
|
||||||
"192.168.91.80"
|
"192.168.91.80"
|
||||||
],
|
],
|
||||||
|
|
@ -15,11 +15,10 @@
|
||||||
"SyncEmployeeIds": [],
|
"SyncEmployeeIds": [],
|
||||||
"SyncDepartmentIds": [],
|
"SyncDepartmentIds": [],
|
||||||
|
|
||||||
"EnableInitialDepartmentSync": true,
|
"EnableInitialDepartmentSync": false,
|
||||||
"InitialSyncDepartmentIds": [
|
"InitialSyncDepartmentIds": [],
|
||||||
"357"
|
|
||||||
],
|
|
||||||
"InitialSyncEmployeeIds": [],
|
"InitialSyncEmployeeIds": [],
|
||||||
|
"InitialSyncLocationSiteId": 2,
|
||||||
"EnableEmployeePhotoSource": true,
|
"EnableEmployeePhotoSource": true,
|
||||||
"EmployeePhotoBaseUrl": "https://portal.utopiaindustries.pk/uind/employee-photo/"
|
"EmployeePhotoBaseUrl": "https://portal.utopiaindustries.pk/uind/employee-photo/"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,21 +27,39 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!_config.InitialSyncLocationSiteId.HasValue ||
|
||||||
|
_config.InitialSyncLocationSiteId.Value <= 0)
|
||||||
|
{
|
||||||
|
const string reason =
|
||||||
|
"InitialSyncLocationSiteId is not configured; Initial Department Sync requires an explicit location-site filter.";
|
||||||
|
_logger.OpsWarn("INITIAL_SYNC", "skipped — " + reason);
|
||||||
|
_logger.Biz(BizChannel.DepartmentalSync,
|
||||||
|
"DEPARTMENTAL SYNC FAILED",
|
||||||
|
"",
|
||||||
|
"Reason : " + reason,
|
||||||
|
"");
|
||||||
|
_logger.BizSeparator(BizChannel.DepartmentalSync);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var locationSiteId = _config.InitialSyncLocationSiteId.Value;
|
||||||
|
|
||||||
var deptIds = (_config.InitialSyncDepartmentIds ?? new List<string>())
|
var deptIds = (_config.InitialSyncDepartmentIds ?? new List<string>())
|
||||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
.Select(x => x.Trim())
|
.Select(x => x.Trim())
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
if (deptIds.Count == 0)
|
var allActiveDepartmentsMode = deptIds.Count == 0;
|
||||||
{
|
|
||||||
_logger.OpsWarn("INITIAL_SYNC", "skipped — InitialSyncDepartmentIds is empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var targets = ResolveInitialSyncTargets();
|
var targets = ResolveInitialSyncTargets();
|
||||||
if (targets.Count == 0)
|
if (targets.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.OpsWarn("INITIAL_SYNC", "skipped — no valid TargetMachineIps");
|
_logger.OpsWarn("INITIAL_SYNC", "skipped — no valid TargetMachineIps");
|
||||||
|
_logger.Biz(BizChannel.DepartmentalSync,
|
||||||
|
"DEPARTMENTAL SYNC FAILED",
|
||||||
|
"",
|
||||||
|
"Reason : No valid target machines are configured.",
|
||||||
|
"");
|
||||||
|
_logger.BizSeparator(BizChannel.DepartmentalSync);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,22 +69,33 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var employees = LoadEmployeesForInitialDepartmentSync(deptIds, employeeIdFilter, out var loadErr);
|
if (allActiveDepartmentsMode && employeeIdFilter.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.OpsWarn("INITIAL_SYNC",
|
||||||
|
"InitialSyncEmployeeIds ignored because InitialSyncDepartmentIds is empty; " +
|
||||||
|
"mode=ALL_ACTIVE_DEPARTMENTS_AND_EMPLOYEES");
|
||||||
|
employeeIdFilter.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
var selection = LoadEmployeesForInitialDepartmentSync(
|
||||||
|
deptIds,
|
||||||
|
employeeIdFilter,
|
||||||
|
locationSiteId,
|
||||||
|
out var loadErr);
|
||||||
if (!string.IsNullOrWhiteSpace(loadErr))
|
if (!string.IsNullOrWhiteSpace(loadErr))
|
||||||
|
{
|
||||||
_logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr);
|
_logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr);
|
||||||
|
_logger.Biz(BizChannel.DepartmentalSync,
|
||||||
if (employeeIdFilter.Count > 0)
|
"DEPARTMENTAL SYNC FAILED",
|
||||||
_logger.Ops("INITIAL_SYNC", "Employee filter: departmentIds=" + string.Join(",", deptIds) +
|
"",
|
||||||
" allowListCount=" + employeeIdFilter.Count +
|
"Reason : " + loadErr,
|
||||||
" allowList=" + string.Join(",", employeeIdFilter) +
|
"");
|
||||||
" matched=" + employees.Count);
|
_logger.BizSeparator(BizChannel.DepartmentalSync);
|
||||||
|
|
||||||
_logger.Ops("INITIAL_SYNC", "departmentIds=" + string.Join(",", deptIds) +
|
|
||||||
" employeeFilter=" + (employeeIdFilter.Count > 0 ? "selective(" + employeeIdFilter.Count + ")" : "whole-department") +
|
|
||||||
" employeesFound=" + employees.Count);
|
|
||||||
|
|
||||||
if (employees.Count == 0)
|
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInitialDepartmentSelection(deptIds, employeeIdFilter, selection);
|
||||||
|
var employees = selection.Employees;
|
||||||
|
|
||||||
var maxRetries = Math.Max(1, SyncPol.HttpMaxRetries);
|
var maxRetries = Math.Max(1, SyncPol.HttpMaxRetries);
|
||||||
var photoBase = (_config.EmployeePhotoBaseUrl ?? "").Trim();
|
var photoBase = (_config.EmployeePhotoBaseUrl ?? "").Trim();
|
||||||
|
|
@ -90,6 +119,12 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
"Machine is not connected.",
|
"Machine is not connected.",
|
||||||
"");
|
"");
|
||||||
_logger.BizSeparator(BizChannel.UserSync);
|
_logger.BizSeparator(BizChannel.UserSync);
|
||||||
|
WriteDepartmentalSyncSummary(
|
||||||
|
target,
|
||||||
|
selection,
|
||||||
|
employeeIdFilter,
|
||||||
|
false,
|
||||||
|
Array.Empty<InitialSyncEmployeeOutcome>());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,9 +132,22 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
"MACHINE " + (target.DeviceId ?? "") + " -> Total Users : " + employees.Count,
|
"MACHINE " + (target.DeviceId ?? "") + " -> Total Users : " + employees.Count,
|
||||||
"");
|
"");
|
||||||
|
|
||||||
|
if (employees.Count == 0)
|
||||||
|
{
|
||||||
|
WriteDepartmentalSyncSummary(
|
||||||
|
target,
|
||||||
|
selection,
|
||||||
|
employeeIdFilter,
|
||||||
|
true,
|
||||||
|
Array.Empty<InitialSyncEmployeeOutcome>());
|
||||||
|
_logger.BizSeparator(BizChannel.UserSync);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var existing = FetchAllUsersIsapiForSync(target, maxRetries, ct);
|
var existing = FetchAllUsersIsapiForSync(target, maxRetries, ct);
|
||||||
var existingNos = new HashSet<string>(existing.Select(u => u.EmployeeNo), StringComparer.OrdinalIgnoreCase);
|
var existingNos = new HashSet<string>(existing.Select(u => u.EmployeeNo), StringComparer.OrdinalIgnoreCase);
|
||||||
var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>();
|
var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>();
|
||||||
|
var employeeOutcomes = new List<InitialSyncEmployeeOutcome>();
|
||||||
|
|
||||||
foreach (var emp in employees)
|
foreach (var emp in employees)
|
||||||
{
|
{
|
||||||
|
|
@ -186,6 +234,15 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
employeeOutcomes.Add(new InitialSyncEmployeeOutcome
|
||||||
|
{
|
||||||
|
DepartmentId = emp.DepartmentId,
|
||||||
|
EmployeeNo = employeeNo,
|
||||||
|
Synced = faceUploaded,
|
||||||
|
Reason = faceUploaded
|
||||||
|
? ""
|
||||||
|
: (string.IsNullOrWhiteSpace(reason) ? "face_upload_failed" : reason)
|
||||||
|
});
|
||||||
WriteInitialSyncEmployeeBizLog(target, employeeNo, userCreated, faceUploaded, reason);
|
WriteInitialSyncEmployeeBizLog(target, employeeNo, userCreated, faceUploaded, reason);
|
||||||
|
|
||||||
if (photoDownloaded && faceUploaded)
|
if (photoDownloaded && faceUploaded)
|
||||||
|
|
@ -222,6 +279,7 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
}
|
}
|
||||||
|
|
||||||
WriteFaceRejectedEmployeeSummary("INITIAL_SYNC", target, faceRejectedEmployees);
|
WriteFaceRejectedEmployeeSummary("INITIAL_SYNC", target, faceRejectedEmployees);
|
||||||
|
WriteDepartmentalSyncSummary(target, selection, employeeIdFilter, true, employeeOutcomes);
|
||||||
_logger.BizSeparator(BizChannel.UserSync);
|
_logger.BizSeparator(BizChannel.UserSync);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -356,15 +414,14 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<InitialSyncEmployee> LoadEmployeesForInitialDepartmentSync(
|
private InitialSyncSelection LoadEmployeesForInitialDepartmentSync(
|
||||||
IReadOnlyList<string> departmentIds,
|
IReadOnlyList<string> departmentIds,
|
||||||
IReadOnlyList<string> employeeIdAllowList,
|
IReadOnlyList<string> employeeIdAllowList,
|
||||||
|
int locationSiteId,
|
||||||
out string error)
|
out string error)
|
||||||
{
|
{
|
||||||
error = "";
|
error = "";
|
||||||
var result = new List<InitialSyncEmployee>();
|
var result = new InitialSyncSelection { LocationSiteId = locationSiteId };
|
||||||
if (departmentIds == null || departmentIds.Count == 0)
|
|
||||||
return result;
|
|
||||||
if (!_config.EnableDbIntegration || _dbConnectionFactory == null)
|
if (!_config.EnableDbIntegration || _dbConnectionFactory == null)
|
||||||
{
|
{
|
||||||
error = "DB integration not available for initial department sync.";
|
error = "DB integration not available for initial department sync.";
|
||||||
|
|
@ -374,55 +431,132 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error))
|
if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error))
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
var hasAllowList = employeeIdAllowList != null && employeeIdAllowList.Count > 0;
|
var hasDepartmentFilter = departmentIds != null && departmentIds.Count > 0;
|
||||||
|
var hasAllowList = hasDepartmentFilter &&
|
||||||
|
employeeIdAllowList != null &&
|
||||||
|
employeeIdAllowList.Count > 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var conn = new MySqlConnection(cs);
|
using var conn = new MySqlConnection(cs);
|
||||||
conn.Open();
|
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(')');
|
|
||||||
|
|
||||||
|
if (hasDepartmentFilter)
|
||||||
|
{
|
||||||
|
var departmentSql = new StringBuilder(
|
||||||
|
"SELECT id, is_active FROM department WHERE id IN (");
|
||||||
|
AppendSqlParameters(departmentSql, "@d", departmentIds.Count);
|
||||||
|
departmentSql.Append(')');
|
||||||
|
|
||||||
|
using var departmentCmd = new MySqlCommand(departmentSql.ToString(), conn);
|
||||||
|
AddSqlParameters(departmentCmd, "@d", departmentIds);
|
||||||
|
var foundDepartmentIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var departmentReader = departmentCmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (departmentReader.Read())
|
||||||
|
{
|
||||||
|
var departmentId = departmentReader["id"]?.ToString()?.Trim() ?? "";
|
||||||
|
if (departmentId.Length == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foundDepartmentIds.Add(departmentId);
|
||||||
|
if (IsDatabaseActive(departmentReader["is_active"]))
|
||||||
|
result.ActiveDepartmentIds.Add(departmentId);
|
||||||
|
else
|
||||||
|
result.InactiveDepartmentIds.Add(departmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var requestedId in departmentIds)
|
||||||
|
{
|
||||||
|
if (!foundDepartmentIds.Contains(requestedId))
|
||||||
|
result.MissingDepartmentIds.Add(requestedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var departmentCmd = new MySqlCommand(
|
||||||
|
"SELECT id FROM department WHERE is_active = 1", conn);
|
||||||
|
using (var departmentReader = departmentCmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (departmentReader.Read())
|
||||||
|
{
|
||||||
|
var departmentId = departmentReader["id"]?.ToString()?.Trim() ?? "";
|
||||||
|
if (departmentId.Length > 0)
|
||||||
|
result.ActiveDepartmentIds.Add(departmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var inactiveDepartmentCmd = new MySqlCommand(
|
||||||
|
"SELECT COUNT(*) FROM department WHERE is_active IS NULL OR is_active <> 1", conn);
|
||||||
|
result.InactiveDepartmentCount = Convert.ToInt32(inactiveDepartmentCmd.ExecuteScalar() ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ActiveDepartmentIds.Count == 0)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var employeeSql = new StringBuilder(
|
||||||
|
"SELECT e.id, e.serial_number, e.department_id " +
|
||||||
|
"FROM employee e " +
|
||||||
|
"INNER JOIN department d ON d.id = e.department_id " +
|
||||||
|
"WHERE d.is_active = 1 AND e.is_active = 1 " +
|
||||||
|
"AND e.location_site_id = @locationSiteId AND e.department_id IN (");
|
||||||
|
AppendSqlParameters(employeeSql, "@activeDept", result.ActiveDepartmentIds.Count);
|
||||||
|
employeeSql.Append(')');
|
||||||
if (hasAllowList)
|
if (hasAllowList)
|
||||||
{
|
{
|
||||||
sql.Append(" AND serial_number IN (");
|
employeeSql.Append(" AND e.serial_number IN (");
|
||||||
for (int i = 0; i < employeeIdAllowList.Count; i++)
|
AppendSqlParameters(employeeSql, "@e", employeeIdAllowList.Count);
|
||||||
{
|
employeeSql.Append(')');
|
||||||
if (i > 0) sql.Append(',');
|
|
||||||
sql.Append("@e").Append(i);
|
|
||||||
}
|
|
||||||
sql.Append(')');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
using var cmd = new MySqlCommand(sql.ToString(), conn);
|
using var cmd = new MySqlCommand(employeeSql.ToString(), conn);
|
||||||
for (int i = 0; i < departmentIds.Count; i++)
|
cmd.Parameters.AddWithValue("@locationSiteId", locationSiteId);
|
||||||
cmd.Parameters.AddWithValue("@d" + i, departmentIds[i]);
|
AddSqlParameters(cmd, "@activeDept", result.ActiveDepartmentIds);
|
||||||
if (hasAllowList)
|
if (hasAllowList)
|
||||||
{
|
AddSqlParameters(cmd, "@e", employeeIdAllowList);
|
||||||
for (int i = 0; i < employeeIdAllowList.Count; i++)
|
|
||||||
cmd.Parameters.AddWithValue("@e" + i, employeeIdAllowList[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
using var rd = cmd.ExecuteReader();
|
|
||||||
var seenSerial = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seenSerial = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
using (var rd = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
while (rd.Read())
|
while (rd.Read())
|
||||||
{
|
{
|
||||||
var serial = rd["serial_number"]?.ToString()?.Trim() ?? "";
|
var serial = rd["serial_number"]?.ToString()?.Trim() ?? "";
|
||||||
if (serial.Length == 0 || !seenSerial.Add(serial))
|
if (serial.Length == 0 || !seenSerial.Add(serial))
|
||||||
continue;
|
continue;
|
||||||
result.Add(new InitialSyncEmployee
|
result.Employees.Add(new InitialSyncEmployee
|
||||||
{
|
{
|
||||||
Id = rd["id"]?.ToString()?.Trim() ?? "",
|
Id = rd["id"]?.ToString()?.Trim() ?? "",
|
||||||
SerialNumber = serial,
|
SerialNumber = serial,
|
||||||
|
DepartmentId = rd["department_id"]?.ToString()?.Trim() ?? "",
|
||||||
Name = ""
|
Name = ""
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var inactiveEmployeeSql = new StringBuilder(
|
||||||
|
"SELECT COUNT(*) " +
|
||||||
|
"FROM employee e " +
|
||||||
|
"INNER JOIN department d ON d.id = e.department_id " +
|
||||||
|
"WHERE d.is_active = 1 AND (e.is_active IS NULL OR e.is_active <> 1) " +
|
||||||
|
"AND e.location_site_id = @inactiveLocationSiteId AND e.department_id IN (");
|
||||||
|
AppendSqlParameters(inactiveEmployeeSql, "@inactiveDept", result.ActiveDepartmentIds.Count);
|
||||||
|
inactiveEmployeeSql.Append(')');
|
||||||
|
if (hasAllowList)
|
||||||
|
{
|
||||||
|
inactiveEmployeeSql.Append(" AND e.serial_number IN (");
|
||||||
|
AppendSqlParameters(inactiveEmployeeSql, "@inactiveEmp", employeeIdAllowList.Count);
|
||||||
|
inactiveEmployeeSql.Append(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
using var inactiveEmployeeCmd = new MySqlCommand(inactiveEmployeeSql.ToString(), conn);
|
||||||
|
inactiveEmployeeCmd.Parameters.AddWithValue("@inactiveLocationSiteId", locationSiteId);
|
||||||
|
AddSqlParameters(inactiveEmployeeCmd, "@inactiveDept", result.ActiveDepartmentIds);
|
||||||
|
if (hasAllowList)
|
||||||
|
AddSqlParameters(inactiveEmployeeCmd, "@inactiveEmp", employeeIdAllowList);
|
||||||
|
result.SkippedInactiveEmployeeCount =
|
||||||
|
Convert.ToInt32(inactiveEmployeeCmd.ExecuteScalar() ?? 0);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
error = ex.Message;
|
error = ex.Message;
|
||||||
|
|
@ -431,6 +565,212 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LogInitialDepartmentSelection(
|
||||||
|
IReadOnlyList<string> requestedDepartmentIds,
|
||||||
|
IReadOnlyList<string> employeeIdAllowList,
|
||||||
|
InitialSyncSelection selection)
|
||||||
|
{
|
||||||
|
var allDepartments = requestedDepartmentIds == null || requestedDepartmentIds.Count == 0;
|
||||||
|
var selectiveEmployees = !allDepartments &&
|
||||||
|
employeeIdAllowList != null &&
|
||||||
|
employeeIdAllowList.Count > 0;
|
||||||
|
var inactiveCount = allDepartments
|
||||||
|
? selection.InactiveDepartmentCount
|
||||||
|
: selection.InactiveDepartmentIds.Count;
|
||||||
|
|
||||||
|
_logger.Ops("INITIAL_SYNC",
|
||||||
|
"InitialSyncLocationSiteId=" + selection.LocationSiteId +
|
||||||
|
" departmentMode=" + (allDepartments ? "ALL_ACTIVE" : "SELECTED") +
|
||||||
|
" selectedDepartmentCount=" + (allDepartments
|
||||||
|
? selection.ActiveDepartmentIds.Count
|
||||||
|
: requestedDepartmentIds.Count) +
|
||||||
|
" activeDepartmentCount=" + selection.ActiveDepartmentIds.Count +
|
||||||
|
" inactiveDepartmentCount=" + inactiveCount +
|
||||||
|
" activeDepartments=" + FormatLogValues(selection.ActiveDepartmentIds) +
|
||||||
|
" inactiveDepartments=" + (allDepartments
|
||||||
|
? "(all inactive departments excluded)"
|
||||||
|
: FormatLogValues(selection.InactiveDepartmentIds)) +
|
||||||
|
" missingDepartments=" + FormatLogValues(selection.MissingDepartmentIds));
|
||||||
|
|
||||||
|
_logger.Ops("INITIAL_SYNC",
|
||||||
|
"employeeFilterMode=" + (selectiveEmployees ? "SELECTIVE" : "ALL") +
|
||||||
|
(selectiveEmployees
|
||||||
|
? " allowListCount=" + employeeIdAllowList.Count +
|
||||||
|
" allowList=" + FormatLogValues(employeeIdAllowList)
|
||||||
|
: "") +
|
||||||
|
" finalEmployeeCount=" + selection.Employees.Count +
|
||||||
|
" skippedInactiveEmployees=" + selection.SkippedInactiveEmployeeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteDepartmentalSyncSummary(
|
||||||
|
HikvisionAttendanceWindowsService.DeviceConfig target,
|
||||||
|
InitialSyncSelection selection,
|
||||||
|
IReadOnlyList<string> employeeIdAllowList,
|
||||||
|
bool targetOnline,
|
||||||
|
IReadOnlyList<InitialSyncEmployeeOutcome> outcomes)
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
"DEPARTMENTAL SYNC SUMMARY",
|
||||||
|
"",
|
||||||
|
"Machine ID : " + (target.DeviceId ?? ""),
|
||||||
|
"Machine IP : " + (target.Ip ?? ""),
|
||||||
|
"InitialSyncLocationSiteId : " + selection.LocationSiteId,
|
||||||
|
"Employee Filter : " + (employeeIdAllowList.Count > 0 ? "SELECTIVE" : "ALL"),
|
||||||
|
"Active Departments : " + selection.ActiveDepartmentIds.Count,
|
||||||
|
"Inactive Departments Skipped : " +
|
||||||
|
(selection.InactiveDepartmentIds.Count + selection.InactiveDepartmentCount),
|
||||||
|
"Eligible Active Employees : " + selection.Employees.Count,
|
||||||
|
"Skipped Inactive Employees : " + selection.SkippedInactiveEmployeeCount,
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var departmentId in selection.ActiveDepartmentIds
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var eligibleCount = selection.Employees.Count(x =>
|
||||||
|
string.Equals(x.DepartmentId, departmentId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var departmentOutcomes = outcomes.Where(x =>
|
||||||
|
string.Equals(x.DepartmentId, departmentId, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
var syncedCount = departmentOutcomes.Count(x => x.Synced);
|
||||||
|
var failedCount = Math.Max(0, eligibleCount - syncedCount);
|
||||||
|
|
||||||
|
string status;
|
||||||
|
string reason = "";
|
||||||
|
if (!targetOnline)
|
||||||
|
{
|
||||||
|
status = "NOT SYNCED";
|
||||||
|
reason = "Target machine is offline.";
|
||||||
|
}
|
||||||
|
else if (eligibleCount == 0)
|
||||||
|
{
|
||||||
|
status = "NOT SYNCED";
|
||||||
|
reason = employeeIdAllowList.Count > 0
|
||||||
|
? "No selected active employees belong to this active department."
|
||||||
|
: "Department has no active employees.";
|
||||||
|
}
|
||||||
|
else if (syncedCount == eligibleCount)
|
||||||
|
{
|
||||||
|
status = "SYNCED";
|
||||||
|
}
|
||||||
|
else if (syncedCount > 0)
|
||||||
|
{
|
||||||
|
status = "PARTIALLY SYNCED";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
status = "NOT SYNCED";
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add("Department " + departmentId + " : " + status);
|
||||||
|
lines.Add("Eligible Users : " + eligibleCount);
|
||||||
|
lines.Add("Synced Users : " + syncedCount);
|
||||||
|
lines.Add("Failed Users : " + failedCount);
|
||||||
|
if (failedCount > 0)
|
||||||
|
lines.Add("Failed Employee Numbers : " +
|
||||||
|
string.Join(",", targetOnline
|
||||||
|
? departmentOutcomes.Where(x => !x.Synced).Select(x => x.EmployeeNo)
|
||||||
|
: selection.Employees
|
||||||
|
.Where(x => string.Equals(
|
||||||
|
x.DepartmentId,
|
||||||
|
departmentId,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(x => x.SerialNumber)));
|
||||||
|
if (!string.IsNullOrWhiteSpace(reason))
|
||||||
|
lines.Add("Reason : " + reason);
|
||||||
|
|
||||||
|
foreach (var failureGroup in departmentOutcomes
|
||||||
|
.Where(x => !x.Synced)
|
||||||
|
.GroupBy(x => DescribeInitialSyncFailure(x.Reason))
|
||||||
|
.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lines.Add("Failure Reason : " + failureGroup.Key +
|
||||||
|
" (" + failureGroup.Count() + " user(s))");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add("");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var departmentId in selection.InactiveDepartmentIds
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lines.Add("Department " + departmentId + " : NOT SYNCED");
|
||||||
|
lines.Add("Eligible Users : 0");
|
||||||
|
lines.Add("Synced Users : 0");
|
||||||
|
lines.Add("Failed Users : 0");
|
||||||
|
lines.Add("Reason : Department is inactive.");
|
||||||
|
lines.Add("");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var departmentId in selection.MissingDepartmentIds
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lines.Add("Department " + departmentId + " : NOT SYNCED");
|
||||||
|
lines.Add("Eligible Users : 0");
|
||||||
|
lines.Add("Synced Users : 0");
|
||||||
|
lines.Add("Failed Users : 0");
|
||||||
|
lines.Add("Reason : Department was not found.");
|
||||||
|
lines.Add("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.ActiveDepartmentIds.Count == 0 &&
|
||||||
|
selection.InactiveDepartmentIds.Count == 0 &&
|
||||||
|
selection.MissingDepartmentIds.Count == 0)
|
||||||
|
{
|
||||||
|
lines.Add("No active departments were available for synchronization.");
|
||||||
|
lines.Add("");
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSynced = outcomes.Count(x => x.Synced);
|
||||||
|
lines.Add("TOTAL SYNCED USERS : " + totalSynced);
|
||||||
|
lines.Add("TOTAL FAILED USERS : " + Math.Max(0, selection.Employees.Count - totalSynced));
|
||||||
|
lines.Add("");
|
||||||
|
|
||||||
|
_logger.Biz(BizChannel.DepartmentalSync, lines.ToArray());
|
||||||
|
_logger.BizSeparator(BizChannel.DepartmentalSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendSqlParameters(StringBuilder sql, string prefix, int count)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
sql.Append(',');
|
||||||
|
sql.Append(prefix).Append(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSqlParameters(
|
||||||
|
MySqlCommand command,
|
||||||
|
string prefix,
|
||||||
|
IReadOnlyList<string> values)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < values.Count; i++)
|
||||||
|
command.Parameters.AddWithValue(prefix + i, values[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDatabaseActive(object value)
|
||||||
|
{
|
||||||
|
if (value == null || value == DBNull.Value)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (value is bool boolValue)
|
||||||
|
return boolValue;
|
||||||
|
|
||||||
|
return string.Equals(
|
||||||
|
Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim(),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatLogValues(IEnumerable<string> values)
|
||||||
|
{
|
||||||
|
var items = (values ?? Enumerable.Empty<string>())
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.ToList();
|
||||||
|
return items.Count == 0 ? "(none)" : string.Join(",", items);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryDownloadEmployeePortalPhoto(string photoUrl, out byte[] bytes, out string error)
|
private static bool TryDownloadEmployeePortalPhoto(string photoUrl, out byte[] bytes, out string error)
|
||||||
{
|
{
|
||||||
bytes = Array.Empty<byte>();
|
bytes = Array.Empty<byte>();
|
||||||
|
|
@ -489,6 +829,26 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = "";
|
public string Id { get; set; } = "";
|
||||||
public string SerialNumber { get; set; } = "";
|
public string SerialNumber { get; set; } = "";
|
||||||
|
public string DepartmentId { get; set; } = "";
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class InitialSyncEmployeeOutcome
|
||||||
|
{
|
||||||
|
public string DepartmentId { get; set; } = "";
|
||||||
|
public string EmployeeNo { get; set; } = "";
|
||||||
|
public bool Synced { get; set; }
|
||||||
|
public string Reason { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InitialSyncSelection
|
||||||
|
{
|
||||||
|
public int LocationSiteId { get; set; }
|
||||||
|
public List<InitialSyncEmployee> Employees { get; } = new List<InitialSyncEmployee>();
|
||||||
|
public List<string> ActiveDepartmentIds { get; } = new List<string>();
|
||||||
|
public List<string> InactiveDepartmentIds { get; } = new List<string>();
|
||||||
|
public List<string> MissingDepartmentIds { get; } = new List<string>();
|
||||||
|
public int InactiveDepartmentCount { get; set; }
|
||||||
|
public int SkippedInactiveEmployeeCount { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,13 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public List<string> InitialSyncEmployeeIds { get; set; } = new List<string>();
|
public List<string> InitialSyncEmployeeIds { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Required HRMS employee.location_site_id filter for Initial Department Sync.
|
||||||
|
/// Null or a non-positive value prevents the initial sync from running.
|
||||||
|
/// </summary>
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public int? InitialSyncLocationSiteId { get; set; }
|
||||||
|
|
||||||
/// <summary>When true, initial sync downloads JPEG photos from <see cref="EmployeePhotoBaseUrl"/>.</summary>
|
/// <summary>When true, initial sync downloads JPEG photos from <see cref="EmployeePhotoBaseUrl"/>.</summary>
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public bool EnableEmployeePhotoSource { get; set; }
|
public bool EnableEmployeePhotoSource { get; set; }
|
||||||
|
|
@ -582,6 +589,8 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
cfg.InitialSyncDepartmentIds = new List<string>(overlay.InitialSyncDepartmentIds);
|
cfg.InitialSyncDepartmentIds = new List<string>(overlay.InitialSyncDepartmentIds);
|
||||||
if (overlay.InitialSyncEmployeeIds != null)
|
if (overlay.InitialSyncEmployeeIds != null)
|
||||||
cfg.InitialSyncEmployeeIds = new List<string>(overlay.InitialSyncEmployeeIds);
|
cfg.InitialSyncEmployeeIds = new List<string>(overlay.InitialSyncEmployeeIds);
|
||||||
|
if (overlay.InitialSyncLocationSiteId.HasValue)
|
||||||
|
cfg.InitialSyncLocationSiteId = overlay.InitialSyncLocationSiteId;
|
||||||
cfg.EnableEmployeePhotoSource = overlay.EnableEmployeePhotoSource;
|
cfg.EnableEmployeePhotoSource = overlay.EnableEmployeePhotoSource;
|
||||||
if (overlay.EmployeePhotoBaseUrl != null)
|
if (overlay.EmployeePhotoBaseUrl != null)
|
||||||
cfg.EmployeePhotoBaseUrl = overlay.EmployeePhotoBaseUrl;
|
cfg.EmployeePhotoBaseUrl = overlay.EmployeePhotoBaseUrl;
|
||||||
|
|
@ -684,6 +693,9 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public List<string> InitialSyncEmployeeIds { get; set; } = new List<string>();
|
public List<string> InitialSyncEmployeeIds { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
[DataMember(EmitDefaultValue = false)]
|
||||||
|
public int? InitialSyncLocationSiteId { get; set; }
|
||||||
|
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public bool EnableEmployeePhotoSource { get; set; }
|
public bool EnableEmployeePhotoSource { get; set; }
|
||||||
|
|
||||||
|
|
@ -773,6 +785,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
private readonly string _attendanceBizDirectory;
|
private readonly string _attendanceBizDirectory;
|
||||||
private readonly string _templateBizDirectory;
|
private readonly string _templateBizDirectory;
|
||||||
private readonly string _userSyncBizDirectory;
|
private readonly string _userSyncBizDirectory;
|
||||||
|
private readonly string _departmentalSyncBizDirectory;
|
||||||
private readonly string _unreachableBizDirectory;
|
private readonly string _unreachableBizDirectory;
|
||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
public readonly BizSessionTotals Totals = new BizSessionTotals();
|
public readonly BizSessionTotals Totals = new BizSessionTotals();
|
||||||
|
|
@ -786,11 +799,13 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
_attendanceBizDirectory = Path.Combine(logsRoot, "attendance_logs");
|
_attendanceBizDirectory = Path.Combine(logsRoot, "attendance_logs");
|
||||||
_templateBizDirectory = Path.Combine(logsRoot, "template_fetching_logs");
|
_templateBizDirectory = Path.Combine(logsRoot, "template_fetching_logs");
|
||||||
_userSyncBizDirectory = Path.Combine(logsRoot, "user_sync_logs");
|
_userSyncBizDirectory = Path.Combine(logsRoot, "user_sync_logs");
|
||||||
|
_departmentalSyncBizDirectory = Path.Combine(logsRoot, "departmental_sync_logs");
|
||||||
_unreachableBizDirectory = Path.Combine(_internalLogsDirectory, "unreachable_devices");
|
_unreachableBizDirectory = Path.Combine(_internalLogsDirectory, "unreachable_devices");
|
||||||
Directory.CreateDirectory(_internalLogsDirectory);
|
Directory.CreateDirectory(_internalLogsDirectory);
|
||||||
Directory.CreateDirectory(_attendanceBizDirectory);
|
Directory.CreateDirectory(_attendanceBizDirectory);
|
||||||
Directory.CreateDirectory(_templateBizDirectory);
|
Directory.CreateDirectory(_templateBizDirectory);
|
||||||
Directory.CreateDirectory(_userSyncBizDirectory);
|
Directory.CreateDirectory(_userSyncBizDirectory);
|
||||||
|
Directory.CreateDirectory(_departmentalSyncBizDirectory);
|
||||||
Directory.CreateDirectory(_unreachableBizDirectory);
|
Directory.CreateDirectory(_unreachableBizDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -858,6 +873,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
AppendBiz(BizChannel.Attendance, block);
|
AppendBiz(BizChannel.Attendance, block);
|
||||||
AppendBiz(BizChannel.UserSync, block);
|
AppendBiz(BizChannel.UserSync, block);
|
||||||
AppendBiz(BizChannel.Template, block);
|
AppendBiz(BizChannel.Template, block);
|
||||||
|
AppendBiz(BizChannel.DepartmentalSync, block);
|
||||||
AppendBiz(BizChannel.Unreachable, block);
|
AppendBiz(BizChannel.Unreachable, block);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -873,6 +889,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
AppendBiz(BizChannel.Attendance, stopped);
|
AppendBiz(BizChannel.Attendance, stopped);
|
||||||
AppendBiz(BizChannel.UserSync, stopped);
|
AppendBiz(BizChannel.UserSync, stopped);
|
||||||
AppendBiz(BizChannel.Template, stopped);
|
AppendBiz(BizChannel.Template, stopped);
|
||||||
|
AppendBiz(BizChannel.DepartmentalSync, stopped);
|
||||||
AppendBiz(BizChannel.Unreachable, stopped);
|
AppendBiz(BizChannel.Unreachable, stopped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -905,6 +922,9 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
case BizChannel.Template:
|
case BizChannel.Template:
|
||||||
path = Path.Combine(_templateBizDirectory, "template_fetching_logs_" + DateStamp() + ".txt");
|
path = Path.Combine(_templateBizDirectory, "template_fetching_logs_" + DateStamp() + ".txt");
|
||||||
break;
|
break;
|
||||||
|
case BizChannel.DepartmentalSync:
|
||||||
|
path = Path.Combine(_departmentalSyncBizDirectory, "departmental_sync_logs_" + DateStamp() + ".txt");
|
||||||
|
break;
|
||||||
case BizChannel.Unreachable:
|
case BizChannel.Unreachable:
|
||||||
path = Path.Combine(_unreachableBizDirectory, "unreachable_devices_" + DateStamp() + ".txt");
|
path = Path.Combine(_unreachableBizDirectory, "unreachable_devices_" + DateStamp() + ".txt");
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue