hikvision-sync-service/HikvisionAttendanceManager....

961 lines
39 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 configuredTargetIps = (_config.TargetMachineIps ?? new List<string>())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var targets = ResolveInitialSyncTargets();
if (targets.Count == 0)
{
var reason = configuredTargetIps.Count == 0
? "No target machines are configured. Set TargetMachineIps in DeviceSettings.config."
: "Target machines are configured (" + string.Join(", ", configuredTargetIps) +
") but none passed ISAPI connectivity on HTTP port " +
(_config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80) +
". Machine was found in DB but Connection Failed — check network/firewall, device web port, and credentials.";
_logger.OpsWarn("INITIAL_SYNC", "skipped — " + reason);
_logger.Biz(BizChannel.DepartmentalSync,
"DEPARTMENTAL SYNC FAILED",
"",
"Reason : " + reason,
"");
_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();
_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 userAlreadyPresent = false;
var faceUploaded = false;
var reason = "";
try
{
userAlreadyPresent = existingNos.Contains(employeeNo);
if (!userAlreadyPresent && VerifyUserOnTarget(target, employeeNo, out _))
{
userAlreadyPresent = true;
existingNos.Add(employeeNo);
}
if (!userAlreadyPresent)
{
var dto = new UserDto
{
EmployeeNo = employeeNo,
Name = emp.Name ?? "",
NumOfFace = 0,
NumOfFp = 0
};
if (!CreateUserOnTarget(target, dto, maxRetries, ct, out var createErr, out var alreadyExisted))
{
reason = string.IsNullOrWhiteSpace(createErr) ? "user_create_failed" : createErr;
continue;
}
if (alreadyExisted)
{
userAlreadyPresent = true;
existingNos.Add(employeeNo);
}
else
{
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,
LocationSiteId = emp.LocationSiteId,
Synced = faceUploaded,
Reason = faceUploaded
? ""
: (string.IsNullOrWhiteSpace(reason) ? "face_upload_failed" : reason)
});
WriteInitialSyncEmployeeBizLog(
target,
employeeNo,
emp.LocationSiteId,
userCreated,
userAlreadyPresent,
faceUploaded,
reason);
if (photoDownloaded && faceUploaded)
{
_logger.Ops("INITIAL_SYNC",
"employeeNo=" + employeeNo +
" employeeId=" + employeeId +
" locationSiteId=" + emp.LocationSiteId +
" photoUrl=" + photoUrl +
" photoDownloaded=true" +
" userCreated=" + (userCreated ? "true" : "false") +
" faceUploaded=true");
}
else if (!photoDownloaded)
{
_logger.Ops("INITIAL_SYNC",
"employeeNo=" + employeeNo +
(string.IsNullOrWhiteSpace(employeeId) ? "" : (" employeeId=" + employeeId)) +
" locationSiteId=" + emp.LocationSiteId +
(string.IsNullOrWhiteSpace(photoUrl) ? "" : (" photoUrl=" + photoUrl)) +
" photoDownloaded=false" +
" reason=" + (string.IsNullOrWhiteSpace(reason) ? "photo_not_found" : reason));
}
else
{
_logger.Ops("INITIAL_SYNC",
"employeeNo=" + employeeNo +
" employeeId=" + employeeId +
" locationSiteId=" + emp.LocationSiteId +
" 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,
int locationSiteId,
bool userCreated,
bool userAlreadyPresent,
bool faceUploaded,
string reason)
{
if (userCreated)
{
_logger.Totals.UsersAdded++;
_logger.Biz(BizChannel.UserSync, employeeNo + " added successfully.", "");
_logger.Biz(BizChannel.DepartmentalSync, employeeNo + " added successfully.", "");
}
else if (userAlreadyPresent)
{
// Print this line only when the user was confirmed present on the device.
_logger.Biz(BizChannel.UserSync, employeeNo + " already present.", "");
_logger.Biz(BizChannel.DepartmentalSync, employeeNo + " already present.", "");
}
if (faceUploaded)
{
_logger.Totals.TemplatesSaved++;
_logger.Biz(BizChannel.UserSync, employeeNo + " face enrolled successfully.", "");
return;
}
if (string.IsNullOrWhiteSpace(reason))
return;
_logger.Totals.TemplatesFailed++;
var failureTitle = ClassifyInitialSyncFailureTitle(reason);
var siteLine = "HRMS location_site_id : " + locationSiteId;
_logger.Biz(BizChannel.UserSync,
employeeNo + " " + failureTitle,
"",
siteLine,
"",
"Reason :",
"",
DescribeInitialSyncFailure(reason),
"");
_logger.Biz(BizChannel.DepartmentalSync,
"Employee " + employeeNo + " failed.",
"",
siteLine,
"",
"Reason : " + DescribeInitialSyncFailure(reason),
"");
}
private static string ClassifyInitialSyncFailureTitle(string reason)
{
if (string.IsNullOrWhiteSpace(reason))
return "sync failed.";
switch (reason.Trim())
{
case "user_create_failed":
return "user create failed.";
case "photo_source_disabled":
case "missing_employee_id":
case "photo_not_found":
case "invalid_jpeg":
return "photo sync failed.";
case "FACE_NOT_DETECTED":
case "face_upload_failed":
return "face enrollment failed.";
}
if (reason.IndexOf("401", StringComparison.OrdinalIgnoreCase) >= 0 ||
reason.IndexOf("Unauthorized", StringComparison.OrdinalIgnoreCase) >= 0 ||
reason.IndexOf("Authentication", StringComparison.OrdinalIgnoreCase) >= 0)
return "device authentication failed.";
if (reason.IndexOf("UserInfo/Record", StringComparison.OrdinalIgnoreCase) >= 0 ||
reason.StartsWith("ISAPI error", StringComparison.OrdinalIgnoreCase) ||
reason.StartsWith("HTTP ", StringComparison.OrdinalIgnoreCase))
return "user create failed.";
if (reason.IndexOf("FACE_NOT_DETECTED", StringComparison.OrdinalIgnoreCase) >= 0 ||
reason.IndexOf("FaceDataRecord", StringComparison.OrdinalIgnoreCase) >= 0 ||
reason.IndexOf("FDLib", StringComparison.OrdinalIgnoreCase) >= 0 ||
reason.IndexOf("face", StringComparison.OrdinalIgnoreCase) >= 0)
return "face enrollment failed.";
if (reason.StartsWith("photo_", StringComparison.OrdinalIgnoreCase) ||
reason.IndexOf("photo", StringComparison.OrdinalIgnoreCase) >= 0)
return "photo sync failed.";
return "sync failed.";
}
private static string DescribeInitialSyncFailure(string reason)
{
if (string.IsNullOrWhiteSpace(reason))
return "Unknown error.";
if (reason.IndexOf("401", StringComparison.OrdinalIgnoreCase) >= 0 ||
reason.IndexOf("Unauthorized", StringComparison.OrdinalIgnoreCase) >= 0)
{
return "Device rejected login (HTTP 401 Unauthorized). " +
"Username/password for this target IP are wrong in serviceconfig.json Devices. " +
"This is not related to HRMS location_site_id. Device detail: " + reason.Trim();
}
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.";
case "face_upload_failed":
return "Face upload to the device failed.";
default:
if (reason.StartsWith("photo_http_", StringComparison.OrdinalIgnoreCase))
return "Employee photo download failed (" + reason + "). Portal may require auth or the URL is blocked.";
return reason.Trim();
}
}
/// <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() =>
CollectTestedSyncTargets(source: null, hasSource: false, CancellationToken.None);
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, e.location_site_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() ?? "",
LocationSiteId = ReadNullableInt(rd["location_site_id"]) ?? locationSiteId,
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 (!string.IsNullOrWhiteSpace(reason))
lines.Add("Reason : " + reason);
if (failedCount > 0)
{
lines.Add("Failed Users Detail :");
if (targetOnline)
{
foreach (var fail in departmentOutcomes
.Where(x => !x.Synced)
.OrderBy(x => x.EmployeeNo, StringComparer.OrdinalIgnoreCase))
{
lines.Add(" " + FormatFailedEmployeeLine(fail.EmployeeNo, fail.LocationSiteId,
DescribeInitialSyncFailure(fail.Reason)));
}
}
else
{
foreach (var emp in selection.Employees
.Where(x => string.Equals(
x.DepartmentId,
departmentId,
StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.SerialNumber, StringComparer.OrdinalIgnoreCase))
{
lines.Add(" " + FormatFailedEmployeeLine(emp.SerialNumber, emp.LocationSiteId,
"Target machine is offline."));
}
}
}
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);
var totalFailed = Math.Max(0, selection.Employees.Count - totalSynced);
lines.Add("TOTAL SYNCED USERS : " + totalSynced);
lines.Add("TOTAL FAILED USERS : " + totalFailed);
if (totalFailed > 0)
{
lines.Add("");
lines.Add("CONCLUSION — FAILED USERS:");
foreach (var fail in outcomes
.Where(x => !x.Synced)
.OrderBy(x => x.EmployeeNo, StringComparer.OrdinalIgnoreCase))
{
lines.Add(" " + FormatFailedEmployeeLine(fail.EmployeeNo, fail.LocationSiteId,
DescribeInitialSyncFailure(fail.Reason)));
}
}
lines.Add("");
_logger.Biz(BizChannel.DepartmentalSync, lines.ToArray());
_logger.Biz(BizChannel.UserSync, lines.ToArray());
_logger.BizSeparator(BizChannel.DepartmentalSync);
}
private static string FormatFailedEmployeeLine(string employeeNo, int locationSiteId, string reason) =>
employeeNo + " [HRMS location_site_id=" + locationSiteId + "] : " + reason;
private static int? ReadNullableInt(object value)
{
if (value == null || value == DBNull.Value)
return null;
if (value is int i)
return i;
if (int.TryParse(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim(),
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out var parsed))
return parsed;
return null;
}
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 int LocationSiteId { get; set; }
public string Name { get; set; } = "";
}
private sealed class InitialSyncEmployeeOutcome
{
public string DepartmentId { get; set; } = "";
public string EmployeeNo { get; set; } = "";
public int LocationSiteId { 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; }
}
}