855 lines
34 KiB
C#
855 lines
34 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
internal sealed partial class HikvisionAttendanceManager
|
|
{
|
|
/// <summary>
|
|
/// Provisions users/faces onto TargetMachineIps from InitialSyncDepartmentIds + employee-photo URL.
|
|
/// Gated by EnableInitialDepartmentSync only.
|
|
/// </summary>
|
|
public void RunInitialDepartmentSyncCycle(CancellationToken ct)
|
|
{
|
|
if (!_config.EnableInitialDepartmentSync)
|
|
return;
|
|
|
|
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>())
|
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.Select(x => x.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
var allActiveDepartmentsMode = deptIds.Count == 0;
|
|
|
|
var targets = ResolveInitialSyncTargets();
|
|
if (targets.Count == 0)
|
|
{
|
|
_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;
|
|
}
|
|
|
|
var employeeIdFilter = (_config.InitialSyncEmployeeIds ?? new List<string>())
|
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.Select(x => x.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
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))
|
|
{
|
|
_logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr);
|
|
_logger.Biz(BizChannel.DepartmentalSync,
|
|
"DEPARTMENTAL SYNC FAILED",
|
|
"",
|
|
"Reason : " + loadErr,
|
|
"");
|
|
_logger.BizSeparator(BizChannel.DepartmentalSync);
|
|
return;
|
|
}
|
|
|
|
LogInitialDepartmentSelection(deptIds, employeeIdFilter, selection);
|
|
var employees = selection.Employees;
|
|
|
|
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\"");
|
|
_logger.Biz(BizChannel.UserSync,
|
|
"MACHINE " + (target.DeviceId ?? ""),
|
|
"",
|
|
"Machine is not connected.",
|
|
"");
|
|
_logger.BizSeparator(BizChannel.UserSync);
|
|
WriteDepartmentalSyncSummary(
|
|
target,
|
|
selection,
|
|
employeeIdFilter,
|
|
false,
|
|
Array.Empty<InitialSyncEmployeeOutcome>());
|
|
continue;
|
|
}
|
|
|
|
_logger.Biz(BizChannel.UserSync,
|
|
"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 existingNos = new HashSet<string>(existing.Select(u => u.EmployeeNo), StringComparer.OrdinalIgnoreCase);
|
|
var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>();
|
|
var employeeOutcomes = new List<InitialSyncEmployeeOutcome>();
|
|
|
|
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 (!TryPrepareInitialSyncFaceJpeg(employeeNo, jpegBytes, out var uploadBytes))
|
|
{
|
|
reason = "FACE_NOT_DETECTED";
|
|
faceRejectedEmployees.Add((employeeNo, reason));
|
|
continue;
|
|
}
|
|
|
|
if (!UploadFaceOnTarget(target, employeeNo, uploadBytes, maxRetries, ct, out var upErr))
|
|
{
|
|
reason = string.IsNullOrWhiteSpace(upErr) ? "face_upload_failed" : upErr;
|
|
faceRejectedEmployees.Add((employeeNo, reason));
|
|
continue;
|
|
}
|
|
|
|
faceUploaded = true;
|
|
}
|
|
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);
|
|
|
|
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);
|
|
WriteDepartmentalSyncSummary(target, selection, employeeIdFilter, true, employeeOutcomes);
|
|
_logger.BizSeparator(BizChannel.UserSync);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors the DB→device sync wording in logs/user_sync_logs so provisioned users are visible
|
|
/// alongside the "removed successfully" entries, not only in internal_logs.
|
|
/// </summary>
|
|
private void WriteInitialSyncEmployeeBizLog(
|
|
HikvisionAttendanceWindowsService.DeviceConfig target,
|
|
string employeeNo,
|
|
bool userCreated,
|
|
bool faceUploaded,
|
|
string reason)
|
|
{
|
|
if (userCreated)
|
|
{
|
|
_logger.Totals.UsersAdded++;
|
|
_logger.Biz(BizChannel.UserSync,
|
|
"Machine ID : " + (target.DeviceId ?? ""),
|
|
"Machine IP : " + (target.Ip ?? ""),
|
|
"",
|
|
employeeNo + " added successfully.",
|
|
"");
|
|
}
|
|
|
|
if (faceUploaded)
|
|
{
|
|
_logger.Totals.TemplatesSaved++;
|
|
_logger.Biz(BizChannel.UserSync, employeeNo + " face enrolled successfully.", "");
|
|
return;
|
|
}
|
|
|
|
_logger.Totals.TemplatesFailed++;
|
|
_logger.Biz(BizChannel.UserSync,
|
|
employeeNo + " face enrollment failed.",
|
|
"",
|
|
"Reason :",
|
|
"",
|
|
DescribeInitialSyncFailure(reason),
|
|
"");
|
|
}
|
|
|
|
private static string DescribeInitialSyncFailure(string reason)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(reason))
|
|
return "Unknown error.";
|
|
|
|
switch (reason)
|
|
{
|
|
case "FACE_NOT_DETECTED":
|
|
return "No face detected in the employee photo.";
|
|
case "photo_not_found":
|
|
return "Employee photo not found on the HRMS portal.";
|
|
case "invalid_jpeg":
|
|
return "Employee photo is not a valid JPEG.";
|
|
case "photo_source_disabled":
|
|
return "Employee photo source is disabled.";
|
|
case "missing_employee_id":
|
|
return "Employee has no HRMS id, photo URL cannot be built.";
|
|
case "user_create_failed":
|
|
return "User could not be created on the device.";
|
|
default:
|
|
return reason;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initial-sync only: crops/normalizes the HRMS photo around the detected face before FaceDataRecord.
|
|
/// Returns false only when no face is found; decode/encode problems fall back to the raw portal JPEG.
|
|
/// </summary>
|
|
private bool TryPrepareInitialSyncFaceJpeg(string employeeNo, byte[] downloadedJpeg, out byte[] uploadBytes)
|
|
{
|
|
uploadBytes = downloadedJpeg;
|
|
var processed = EmployeePhotoFaceProcessor.Process(downloadedJpeg);
|
|
|
|
if (processed.Status == EmployeePhotoFaceProcessor.PhotoStatus.NoFace)
|
|
{
|
|
_logger.OpsWarn("INITIAL_SYNC",
|
|
"FACE_NOT_DETECTED employeeNo=" + employeeNo +
|
|
" original=" + processed.Original.Width + "x" + processed.Original.Height +
|
|
" uploadSkipped=true");
|
|
return false;
|
|
}
|
|
|
|
if (processed.Status != EmployeePhotoFaceProcessor.PhotoStatus.Ok)
|
|
{
|
|
_logger.OpsWarn("INITIAL_SYNC",
|
|
"PHOTO_PROCESS_FAILED employeeNo=" + employeeNo +
|
|
" err=" + processed.Error +
|
|
" fallback=original-jpeg bytes=" + downloadedJpeg.Length);
|
|
return true;
|
|
}
|
|
|
|
_logger.Ops("INITIAL_SYNC", "PHOTO_PROCESSED employeeNo=" + employeeNo + " " + processed.Describe());
|
|
_logger.Diag("INITIAL_SYNC",
|
|
"PHOTO_PROCESSED employeeNo=" + employeeNo + " " + processed.Describe() +
|
|
" jpegQuality=" + processed.JpegQuality +
|
|
" downloadedBytes=" + downloadedJpeg.Length +
|
|
" enhance[" + processed.Enhancement + "]");
|
|
|
|
uploadBytes = processed.JpegBytes;
|
|
return true;
|
|
}
|
|
|
|
private List<HikvisionAttendanceWindowsService.DeviceConfig> ResolveInitialSyncTargets()
|
|
{
|
|
var targets = new List<HikvisionAttendanceWindowsService.DeviceConfig>();
|
|
foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty<string>())
|
|
{
|
|
var t = ResolveDeviceConfigByIp(targetIp);
|
|
if (t == null || string.IsNullOrWhiteSpace(t.Ip))
|
|
{
|
|
_logger.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<string>())
|
|
{
|
|
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 InitialSyncSelection LoadEmployeesForInitialDepartmentSync(
|
|
IReadOnlyList<string> departmentIds,
|
|
IReadOnlyList<string> employeeIdAllowList,
|
|
int locationSiteId,
|
|
out string error)
|
|
{
|
|
error = "";
|
|
var result = new InitialSyncSelection { LocationSiteId = locationSiteId };
|
|
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;
|
|
|
|
var hasDepartmentFilter = departmentIds != null && departmentIds.Count > 0;
|
|
var hasAllowList = hasDepartmentFilter &&
|
|
employeeIdAllowList != null &&
|
|
employeeIdAllowList.Count > 0;
|
|
|
|
try
|
|
{
|
|
using var conn = new MySqlConnection(cs);
|
|
conn.Open();
|
|
|
|
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)
|
|
{
|
|
employeeSql.Append(" AND e.serial_number IN (");
|
|
AppendSqlParameters(employeeSql, "@e", employeeIdAllowList.Count);
|
|
employeeSql.Append(')');
|
|
}
|
|
|
|
using var cmd = new MySqlCommand(employeeSql.ToString(), conn);
|
|
cmd.Parameters.AddWithValue("@locationSiteId", locationSiteId);
|
|
AddSqlParameters(cmd, "@activeDept", result.ActiveDepartmentIds);
|
|
if (hasAllowList)
|
|
AddSqlParameters(cmd, "@e", employeeIdAllowList);
|
|
|
|
var seenSerial = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
using (var rd = cmd.ExecuteReader())
|
|
{
|
|
while (rd.Read())
|
|
{
|
|
var serial = rd["serial_number"]?.ToString()?.Trim() ?? "";
|
|
if (serial.Length == 0 || !seenSerial.Add(serial))
|
|
continue;
|
|
result.Employees.Add(new InitialSyncEmployee
|
|
{
|
|
Id = rd["id"]?.ToString()?.Trim() ?? "",
|
|
SerialNumber = serial,
|
|
DepartmentId = rd["department_id"]?.ToString()?.Trim() ?? "",
|
|
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)
|
|
{
|
|
error = ex.Message;
|
|
}
|
|
|
|
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)
|
|
{
|
|
bytes = Array.Empty<byte>();
|
|
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<byte>();
|
|
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 DepartmentId { 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; }
|
|
}
|
|
}
|