diff --git a/TemplateTransferRunner.cs b/TemplateTransferRunner.cs index 06c07be..464f36f 100644 --- a/TemplateTransferRunner.cs +++ b/TemplateTransferRunner.cs @@ -90,92 +90,191 @@ namespace HanvonF710XAttendanceService foreach (var job in enabledJobs) { - logs.Add($"[TemplateJob {job.Id}] Mode={mode} SourceIp={job.SourceIp} Targets={job.TargetIps.Count}"); - - if (!_machineDao.TryGetAttendanceMachineByIp(connection, job.SourceIp, out var sourceMachine, out var sourceType)) - { - logs.Add($"[TemplateJob {job.Id}] Source machine not found in DB. ip={job.SourceIp}"); - continue; - } - - if (!MachineScope.IsHanvonMachineType(sourceType)) - { - logs.Add($"[TemplateJob {job.Id}] Source machine_type={sourceType} is not a Hanvon F710X device. Skipping."); - continue; - } - - var targetMachines = new List<(AttendanceMachine Machine, string Type)>(); - foreach (var targetIp in job.TargetIps.Distinct(StringComparer.OrdinalIgnoreCase)) - { - if (!_machineDao.TryGetAttendanceMachineByIp(connection, targetIp, out var targetMachine, out var targetType)) - { - logs.Add($"[TemplateJob {job.Id}] Target machine not found in DB. ip={targetIp}"); - continue; - } - - if (!MachineScope.IsHanvonMachineType(targetType)) - { - logs.Add($"[TemplateJob {job.Id}] Target ip={targetIp} machine_type={targetType} is not a Hanvon F710X device. Skipping this target."); - continue; - } - - targetMachines.Add((targetMachine, targetType)); - } - - if (targetMachines.Count == 0) - { - logs.Add($"[TemplateJob {job.Id}] No valid targets to transfer to."); - continue; - } - if (mode == TemplateTransferMode.DB_TO_DEVICE) { - List idsForTransfer; - if (job.EmpIds != null && job.EmpIds.Count > 0) - { - // Restore from DB even when user was deleted from source device. - idsForTransfer = job.EmpIds - .Where(id => !string.IsNullOrWhiteSpace(id)) - .Select(id => id.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - logs.Add($"[TemplateJob {job.Id}] DB_TO_DEVICE using EmpIds from config (source device list skipped): {string.Join(",", idsForTransfer)}"); - } - else - { - if (!TryGetEmployeeIdsFromDevice(sourceMachine, out var employeeIds, logs, job.Id)) - { - continue; - } - if (employeeIds.Count == 0) - { - logs.Add($"[TemplateJob {job.Id}] Source device returned 0 employee IDs."); - continue; - } - idsForTransfer = employeeIds; - } - TransferDbToDevice(job, idsForTransfer, targetMachines, connection, logs); + RunDbToDeviceJob(job, connection, logs); continue; } - if (!TryGetEmployeeIdsFromDevice(sourceMachine, out var deviceEmployeeIds, logs, job.Id)) - { - continue; - } - - if (deviceEmployeeIds.Count == 0) - { - logs.Add($"[TemplateJob {job.Id}] Source device returned 0 employee IDs."); - continue; - } - - if (mode == TemplateTransferMode.DEVICE_TO_DEVICE) - { - TransferDeviceToDevice(job.Id, sourceMachine, deviceEmployeeIds, targetMachines, logs); - } + RunDeviceSourceTransferJob(job, mode, connection, logs); } } + private void RunDbToDeviceJob(TemplateTransferJob job, MySqlConnection connection, List logs) + { + logs.Add($"[TemplateJob {job.Id}] Mode=DB_TO_DEVICE Targets={job.TargetIps.Count}"); + + if (!string.IsNullOrWhiteSpace(job.SourceIp)) + { + logs.Add($"[DB_TO_DEVICE] SourceIp={job.SourceIp} ignored. Users/templates will be loaded from HRMS DB."); + Program.WriteInternalLog($"[DB_TO_DEVICE] SourceIp={job.SourceIp} ignored because transfer mode is DB_TO_DEVICE."); + } + else + { + logs.Add("[DB_TO_DEVICE] SourceIp not specified. Users/templates will be loaded from HRMS DB."); + Program.WriteInternalLog("[DB_TO_DEVICE] SourceIp ignored because transfer mode is DB_TO_DEVICE."); + } + + var targetMachines = ResolveHanvonTargetMachines(job, connection, logs); + if (targetMachines.Count == 0) + { + return; + } + + foreach (var target in targetMachines) + { + logs.Add($"[DB_TO_DEVICE] Target device={target.Machine.MachineIp}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Target={target.Machine.MachineIp}"); + } + + logs.Add("[DB_TO_DEVICE] Loading employees/templates from HRMS DB..."); + Program.WriteInternalLog("[DB_TO_DEVICE] Loading employees/templates from HRMS DB..."); + + var targetMachineIds = targetMachines + .Select(t => t.Machine.MachineId) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + List idsForTransfer; + try + { + idsForTransfer = DbToDevicePlanning.ResolveEmployeeIds( + job, + departmentIds => + { + try + { + return _templateDao.GetActiveSerialNumbersByDepartmentIds(connection, departmentIds, targetMachineIds); + } + catch (Exception exDept) + { + logs.Add($"[DB_TO_DEVICE] DepartmentIds lookup failed; falling back to registered employees on targets. err={exDept.Message}"); + Program.WriteInternalLog("[DB_TO_DEVICE] DepartmentIds lookup failed: " + exDept.Message); + return new List(); + } + }, + () => LoadRegisteredEmployeeIdsOnTargets(connection, targetMachines, logs, job.Id)); + } + catch (Exception exResolve) + { + logs.Add($"[DB_TO_DEVICE] Failed to resolve employees from HRMS DB. err={exResolve.Message}"); + Program.WriteInternalLog("[DB_TO_DEVICE] Failed to resolve employees from HRMS DB: " + exResolve.Message); + return; + } + + int requestedCount = job.EmpIds != null && job.EmpIds.Count > 0 + ? job.EmpIds.Count + : (job.DepartmentIds != null && job.DepartmentIds.Count > 0 ? job.DepartmentIds.Count : idsForTransfer.Count); + + logs.Add($"[DB_TO_DEVICE] Employee filter requested={requestedCount} willSync={idsForTransfer.Count}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Employee filter requested={requestedCount} willSync={idsForTransfer.Count}"); + + if (idsForTransfer.Count == 0) + { + logs.Add($"[DB_TO_DEVICE] No employees resolved from HRMS DB for job {job.Id}."); + Program.WriteInternalLog($"[DB_TO_DEVICE] No employees resolved from HRMS DB for job {job.Id}."); + return; + } + + TransferDbToDevice(job, idsForTransfer, targetMachines, connection, logs); + } + + private void RunDeviceSourceTransferJob(TemplateTransferJob job, TemplateTransferMode mode, MySqlConnection connection, List logs) + { + logs.Add($"[TemplateJob {job.Id}] Mode={mode} SourceIp={job.SourceIp} Targets={job.TargetIps.Count}"); + + if (string.IsNullOrWhiteSpace(job.SourceIp)) + { + logs.Add($"[TemplateJob {job.Id}] SourceIp is required for mode={mode}."); + return; + } + + if (!_machineDao.TryGetAttendanceMachineByIp(connection, job.SourceIp, out var sourceMachine, out var sourceType)) + { + logs.Add($"[TemplateJob {job.Id}] Source machine not found in DB. ip={job.SourceIp}"); + return; + } + + if (!MachineScope.IsHanvonMachineType(sourceType)) + { + logs.Add($"[TemplateJob {job.Id}] Source machine_type={sourceType} is not a Hanvon F710X device. Skipping."); + return; + } + + var targetMachines = ResolveHanvonTargetMachines(job, connection, logs); + if (targetMachines.Count == 0) + { + return; + } + + if (!TryGetEmployeeIdsFromDevice(sourceMachine, out var deviceEmployeeIds, logs, job.Id)) + { + return; + } + + if (deviceEmployeeIds.Count == 0) + { + logs.Add($"[TemplateJob {job.Id}] Source device returned 0 employee IDs."); + return; + } + + if (mode == TemplateTransferMode.DEVICE_TO_DEVICE) + { + TransferDeviceToDevice(job.Id, sourceMachine, deviceEmployeeIds, targetMachines, logs); + } + } + + private List<(AttendanceMachine Machine, string Type)> ResolveHanvonTargetMachines( + TemplateTransferJob job, + MySqlConnection connection, + List logs) + { + var targetMachines = new List<(AttendanceMachine Machine, string Type)>(); + foreach (var targetIp in job.TargetIps.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!_machineDao.TryGetAttendanceMachineByIp(connection, targetIp, out var targetMachine, out var targetType)) + { + logs.Add($"[TemplateJob {job.Id}] Target machine not found in DB. ip={targetIp}"); + continue; + } + + if (!MachineScope.IsHanvonMachineType(targetType)) + { + logs.Add($"[TemplateJob {job.Id}] Target ip={targetIp} machine_type={targetType} is not a Hanvon F710X device. Skipping this target."); + continue; + } + + targetMachines.Add((targetMachine, targetType)); + } + + if (targetMachines.Count == 0) + { + logs.Add($"[TemplateJob {job.Id}] No valid targets to transfer to."); + } + + return targetMachines; + } + + private List LoadRegisteredEmployeeIdsOnTargets( + MySqlConnection connection, + List<(AttendanceMachine Machine, string Type)> targetMachines, + List logs, + string jobId) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var target in targetMachines) + { + var registered = _templateDao.GetActiveTemplatesForRegisteredMachine(connection, target.Machine.MachineId); + foreach (var serial in registered.Keys) + { + ids.Add(serial); + } + logs.Add($"[DB_TO_DEVICE] job={jobId} target={target.Machine.MachineIp} registered_with_templates={registered.Count}"); + } + return ids.ToList(); + } + private bool TryGetEmployeeIdsFromDevice(AttendanceMachine machine, out List employeeIds, List logs, string jobId) { employeeIds = new List(); @@ -665,49 +764,116 @@ namespace HanvonF710XAttendanceService } } + private Dictionary LoadTemplatesForDbToDevice( + MySqlConnection connection, + List employeeIds, + List logs) + { + var accumulated = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (employeeIds == null || employeeIds.Count == 0) + { + return accumulated; + } + + var batches = SplitIntoBatches(employeeIds, DbToDevicePlanning.TemplateLoadBatchSize).ToList(); + int totalBatches = batches.Count; + + for (int batchIndex = 0; batchIndex < batches.Count; batchIndex++) + { + var batch = batches[batchIndex]; + int batchNumber = batchIndex + 1; + logs.Add($"[DB_TO_DEVICE] Loading template batch {batchNumber}/{totalBatches} employees={batch.Count}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Loading template batch {batchNumber}/{totalBatches} employees={batch.Count}"); + + Dictionary batchTemplates; + try + { + batchTemplates = _templateDao.GetActiveTemplatesBySerialNos(connection, batch); + } + catch (Exception exBatch) + { + logs.Add($"[DB_TO_DEVICE] Template batch {batchNumber} FAILED employees={batch.Count} err={exBatch.Message}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Template batch {batchNumber} FAILED employees={batch.Count} err={exBatch}"); + continue; + } + + foreach (var kvp in batchTemplates) + { + accumulated[kvp.Key] = kvp.Value ?? Array.Empty(); + } + + logs.Add($"[DB_TO_DEVICE] Template batch {batchNumber} loaded={batchTemplates.Count}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Template batch {batchNumber} loaded={batchTemplates.Count}"); + } + + return accumulated; + } + + internal Dictionary LoadTemplatesForDbToDeviceTest( + MySqlConnection connection, + List employeeIds, + List logs) + { + return LoadTemplatesForDbToDevice(connection, employeeIds, logs); + } + private void TransferDbToDevice(TemplateTransferJob job, List employeeIds, List<(AttendanceMachine Machine, string Type)> targets, MySqlConnection connection, List logs) { bool verifyAfterPush = GetBoolAppSetting("TEMPLATE_VERIFY_AFTER_PUSH", true); int delayMs = GetIntAppSetting("TEMPLATE_DEVICE_DELAY_MS", 0); - var effectiveEmployeeIds = employeeIds ?? new List(); + var effectiveEmployeeIds = DbToDevicePlanning.NormalizeEmployeeIds(employeeIds); + var templates = LoadTemplatesForDbToDevice(connection, effectiveEmployeeIds, logs); - // Optional filtering by EmpIds configured on the job (for safe, small-scope tests) - if (job.EmpIds != null && job.EmpIds.Count > 0) - { - var filter = new HashSet(job.EmpIds, StringComparer.OrdinalIgnoreCase); - effectiveEmployeeIds = effectiveEmployeeIds.Where(id => filter.Contains(id)).ToList(); - Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} source={job.SourceIp} employees_filtered={effectiveEmployeeIds.Count}/{employeeIds.Count}"); - } + int missingTemplateCount = effectiveEmployeeIds.Count - templates.Count; + logs.Add($"[DB_TO_DEVICE] Template loading complete requested={effectiveEmployeeIds.Count} loaded={templates.Count} missing={missingTemplateCount}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Template loading complete requested={effectiveEmployeeIds.Count} loaded={templates.Count} missing={missingTemplateCount}"); - var templates = _templateDao.GetActiveTemplatesBySerialNos(connection, effectiveEmployeeIds); - logs.Add($"[TemplateJob {job.Id}] DB templates fetched. count={templates.Count}"); + logs.Add("[DB_TO_DEVICE] Creating/updating users on target..."); + Program.WriteInternalLog("[DB_TO_DEVICE] Creating/updating users on target..."); - // Log whether each requested employee has a template in DB foreach (var empId in effectiveEmployeeIds) { if (templates.ContainsKey(empId)) { - Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} source={job.SourceIp} emp={empId} template=FOUND"); + Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} emp={empId} template=FOUND"); } else { - Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} source={job.SourceIp} emp={empId} template=NOT_FOUND"); + logs.Add($"[DB_TO_DEVICE] employee={empId} template=MISSING no active face template in HRMS DB"); + Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} emp={empId} template=NOT_FOUND"); } } + if (missingTemplateCount > 0) + { + logs.Add($"[DB_TO_DEVICE] Employees missing templates in HRMS DB: {missingTemplateCount}/{effectiveEmployeeIds.Count}"); + } + // Batch per target machine. var templatePairs = templates.ToList(); int totalTemplates = templatePairs.Count; int batchSize = ChooseBatchSize(totalTemplates); var batches = SplitIntoBatches(templatePairs, batchSize).ToList(); + var missingTemplateSerials = effectiveEmployeeIds + .Where(id => !templates.ContainsKey(id)) + .ToList(); foreach (var target in targets) { - int ok = 0; - int fail = 0; + int success = 0; + int duplicateFace = 0; + int uploadFailed = 0; int verifyOk = 0; int verifyFail = 0; + int alreadyRegistered = 0; + var duplicateFaceEntries = new List(); + var summary = new DbToDeviceTargetSummary + { + MachineId = target.Machine.MachineId ?? "", + MachineIp = target.Machine.MachineIp ?? "", + MachineType = string.IsNullOrWhiteSpace(target.Type) ? MachineScope.MachineTypeHanvon : target.Type + }; logs.Add($"[TemplateTransfer] job={job.Id} target_ip={target.Machine.MachineIp} total_templates={totalTemplates} batch_size={batchSize} total_batches={batches.Count}"); @@ -718,77 +884,254 @@ namespace HanvonF710XAttendanceService { logs.Add($"[TemplateJob {job.Id}] HTTP login failed for target. ip={target.Machine.MachineIp} err={loginErr}"); Program.RecordUnreachableMachine(target.Machine, "DB_TO_DEVICE HTTP login: " + loginErr); + WriteDbToDeviceTargetSummary(job.Id, summary, logs); continue; } var machineUserDao = new AttendanceMachineUserDAO(); + var employeeDao = new HrmsEmployeeDAO(); + HashSet registeredSerials; + try + { + registeredSerials = machineUserDao.GetActiveSerialNumbersForMachine(connection, target.Machine.MachineId); + } + catch (Exception exReg) + { + registeredSerials = new HashSet(StringComparer.OrdinalIgnoreCase); + Program.WriteInternalLog($"[DB_TO_DEVICE] registered lookup failed machine={target.Machine.MachineId} err={exReg.Message}"); + } + + foreach (var missingSerial in missingTemplateSerials) + { + if (DbToDeviceSkipLogic.ShouldSkipAlreadyRegistered(registeredSerials, missingSerial)) + { + alreadyRegistered++; + summary.AlreadyRegistered++; + Program.WriteInternalLog($"[DB_TO_DEVICE] employee_serial={missingSerial} machine_id={target.Machine.MachineId} machine_ip={target.Machine.MachineIp} status=SKIPPED_ALREADY_REGISTERED"); + continue; + } + + HrmsEmployeeInfo missingEmp = null; + try { employeeDao.TryGetByDeviceSerial(connection, missingSerial, out missingEmp); } catch { } + if (missingEmp == null) + { + summary.AddMissingMapping(missingSerial); + } + + summary.AddFailure( + missingSerial, + missingEmp?.ConcatenatedName ?? "", + missingEmp?.Department ?? "", + "Missing template in HRMS DB"); + uploadFailed++; + } for (int bi = 0; bi < batches.Count; bi++) { var swBatch = Stopwatch.StartNew(); int bAttempted = 0; int bOk = 0; - int bFail = 0; + int bDuplicate = 0; + int bUploadFailed = 0; int bVerifyOk = 0; int bVerifyFail = 0; + int bSkipped = 0; foreach (var kvp in batches[bi]) { bAttempted++; - string empId = kvp.Key; - byte[] templateBlob = kvp.Value ?? Array.Empty(); - string recordBase64 = HanvonHttpApiClient.NormalizeFaceTemplatePayload(templateBlob, out string pushMode); - if (string.IsNullOrWhiteSpace(recordBase64)) + string empId = kvp.Key; // device serial / Hanvon enroll id + + if (DbToDeviceSkipLogic.ShouldSkipAlreadyRegistered(registeredSerials, empId)) { - fail++; - bFail++; - logs.Add($"[TemplateJob {job.Id}] Empty template payload. emp={empId} blob_bytes={templateBlob.Length} target={target.Machine.MachineIp}"); - Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL reason=EmptyTemplate blob_bytes={templateBlob.Length}"); + alreadyRegistered++; + summary.AlreadyRegistered++; + bSkipped++; + Program.WriteInternalLog($"[DB_TO_DEVICE] employee_serial={empId} machine_id={target.Machine.MachineId} machine_ip={target.Machine.MachineIp} status=SKIPPED_ALREADY_REGISTERED"); + continue; + } + + byte[] templateBlob = kvp.Value ?? Array.Empty(); + var uploadPlan = DbToDeviceUploadPlan.FromBlob(templateBlob); + bool portalPhotoEnabled = uploadPlan.Format == DbToDeviceTemplateFormat.NedoXml + && EmployeePhotoSourceSettings.IsEnabled(); + + HrmsEmployeeInfo hrmsEmployee = null; + try + { + employeeDao.TryGetByDeviceSerial(connection, empId, out hrmsEmployee); + } + catch (Exception exEmp) + { + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} hrms_lookup=FAILED err={exEmp.Message}"); + } + + if (hrmsEmployee == null) + { + summary.AddMissingMapping(empId); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} hrms_lookup=MISS"); + } + + string displayName = !string.IsNullOrWhiteSpace(hrmsEmployee?.ConcatenatedName) + ? hrmsEmployee.ConcatenatedName + : empId; + string department = hrmsEmployee?.Department ?? ""; + + if (!uploadPlan.HasUploadPayload && !portalPhotoEnabled) + { + string reason = uploadPlan.Format == DbToDeviceTemplateFormat.NedoXml + ? (uploadPlan.NedoPhotoStatus == NedoPhotoExtractResult.Invalid + ? "NEDO photo invalid" + : "NEDO photo missing") + : "Empty template payload"; + uploadFailed++; + bUploadFailed++; + summary.AddFailure(empId, displayName, department, reason); + logs.Add($"[TemplateJob {job.Id}] {reason}. emp={empId} target={target.Machine.MachineIp}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} face_upload=FAILED response={reason}"); continue; } if (!int.TryParse(empId, out int enrollId)) { - fail++; - bFail++; + uploadFailed++; + bUploadFailed++; + summary.AddFailure(empId, displayName, department, "Invalid enroll id"); logs.Add($"[TemplateJob {job.Id}] Invalid enroll id for HTTP setuserinfo. emp={empId} target={target.Machine.MachineIp}"); Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL reason=InvalidEnrollId"); continue; } - string displayName = machineUserDao.GetEmployeeNameForMachine(connection, target.Machine.MachineId, empId) - ?? empId; - - if (!httpClient.TryRestoreUserWithFaceTemplate(enrollId, displayName, recordBase64, pushMode, out string pushErr)) + if (hrmsEmployee != null) { - fail++; - bFail++; - logs.Add($"[TemplateJob {job.Id}] HTTP template push failed. emp={empId} mode={pushMode} payload_len={recordBase64.Length} target={target.Machine.MachineIp} err={pushErr}"); - Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} mode={pushMode} payload_len={recordBase64.Length} send=FAIL err={pushErr}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} hrms_id={hrmsEmployee.EmployeeId} name={displayName}"); + } + + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} template=FOUND format={uploadPlan.FormatLabel}"); + + if (uploadPlan.Format == DbToDeviceTemplateFormat.NedoXml) + { + bool hadNedoPayload = uploadPlan.HasUploadPayload; + if (portalPhotoEnabled) + { + string photoEmployeeId = hrmsEmployee?.EmployeeId; + if (uploadPlan.TryResolveEmployeePhoto(photoEmployeeId, out string resolveErr)) + { + if (string.Equals(uploadPlan.PhotoSourceLabel, "HRMS_PORTAL", StringComparison.OrdinalIgnoreCase)) + { + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} hrms_id={photoEmployeeId} photo_source=HRMS_PORTAL photo_url={uploadPlan.PhotoSourceUrl} photo_size={uploadPlan.PhotoByteLength} width={uploadPlan.PreparedWidth} height={uploadPlan.PreparedHeight}"); + } + else if (hadNedoPayload) + { + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} photo_source=HRMS_PORTAL FAILED err={resolveErr ?? "unknown"}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} photo_source=NEDO_XML FALLBACK"); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} nedo_photo=FOUND base64_len={uploadPlan.PhotoBase64Length} size={uploadPlan.PhotoByteLength} blob_len={uploadPlan.SourceBlobLength}"); + } + } + else + { + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} photo_source=HRMS_PORTAL FAILED err={resolveErr ?? "unknown"}"); + } + } + else if (uploadPlan.HasUploadPayload) + { + uploadPlan.PhotoSourceLabel = "NEDO_XML"; + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} nedo_photo=FOUND base64_len={uploadPlan.PhotoBase64Length} size={uploadPlan.PhotoByteLength} blob_len={uploadPlan.SourceBlobLength}"); + } + } + + if (!uploadPlan.HasUploadPayload) + { + uploadFailed++; + bUploadFailed++; + string reason = hrmsEmployee == null + ? "Employee photo not found (missing HRMS mapping and no NEDO photo)" + : "Employee photo not found"; + summary.AddFailure(empId, displayName, department, reason); + logs.Add($"[TemplateJob {job.Id}] No usable face photo after HRMS/NEDO resolution. emp={empId} target={target.Machine.MachineIp}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} face_upload=FAILED response=no_photo_available"); continue; } - ok++; - bOk++; - logs.Add($"[TemplateJob {job.Id}] HTTP template push ok. emp={empId} name={displayName} mode={pushMode} payload_len={recordBase64.Length} target={target.Machine.MachineIp}"); - Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=OK"); + if (!uploadPlan.TryPrepareFacePayload(out string prepareErr)) + { + uploadFailed++; + bUploadFailed++; + summary.AddFailure(empId, displayName, department, "Photo prepare failed: " + (prepareErr ?? "unknown")); + logs.Add($"[DB_TO_DEVICE] employee={empId} target={target.Machine.MachineIp} face_prepare=FAILED err={prepareErr}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} format={uploadPlan.FormatLabel} photo=INVALID err={prepareErr}"); + continue; + } + + if (!string.IsNullOrWhiteSpace(uploadPlan.PrepareNote)) + { + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} nedo_photo=PREPARED note={uploadPlan.PrepareNote} width={uploadPlan.PreparedWidth} height={uploadPlan.PreparedHeight} size={uploadPlan.PhotoByteLength}"); + } + + if (!httpClient.TryUploadDbToDeviceTemplate(enrollId, displayName, uploadPlan, out string pushErr)) + { + if (DbToDeviceFaceErrors.TryParseDuplicateFace(pushErr, out string existingDeviceId)) + { + duplicateFace++; + bDuplicate++; + duplicateFaceEntries.Add(new DbToDeviceDuplicateFaceEntry(empId, existingDeviceId, target.Machine.MachineIp)); + summary.AddFailure(empId, displayName, department, "DUPLICATE_FACE existing_device_id=" + existingDeviceId); + logs.Add($"[DB_TO_DEVICE] employee={empId} face=DUPLICATE existing_device_id={existingDeviceId} target={target.Machine.MachineIp}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} face=DUPLICATE existing_device_id={existingDeviceId}"); + continue; + } + + uploadFailed++; + bUploadFailed++; + summary.AddFailure(empId, displayName, department, "UPLOAD_FAILED: " + (pushErr ?? "unknown")); + logs.Add($"[DB_TO_DEVICE] employee={empId} target={target.Machine.MachineIp} user=UPLOAD_FAILED face=FAILED err={pushErr}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} target={target.Machine.MachineIp} face_upload=FAILED response={pushErr}"); + continue; + } if (verifyAfterPush) { - if (httpClient.UserHasFaceTemplate(enrollId, out string verifyErr)) - { - verifyOk++; - bVerifyOk++; - Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=OK"); - } - else + bool faceVerified = httpClient.VerifyDbToDeviceTemplate( + enrollId, + uploadPlan, + out string verifyFaceflag, + out string verifyPhotoUrl, + out string verifyErr); + if (!faceVerified) { verifyFail++; bVerifyFail++; + summary.AddFailure(empId, displayName, department, "VERIFY_FAILED: " + (verifyErr ?? "unknown")); logs.Add($"[TemplateJob {job.Id}] HTTP verify failed after push. emp={empId} target={target.Machine.MachineIp} err={verifyErr}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} verify=FAILED faceflag={verifyFaceflag ?? ""} photourl={verifyPhotoUrl ?? ""} err={verifyErr}"); Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=FAIL err={verifyErr}"); + continue; } + + verifyOk++; + bVerifyOk++; + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} verify faceflag={verifyFaceflag ?? ""} photourl={verifyPhotoUrl ?? ""} face=OK"); + Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=OK"); } + + Program.WriteInternalLog($"[DB_TO_DEVICE] employee={empId} target={target.Machine.MachineIp} face_upload=OK photo_source={uploadPlan.PhotoSourceLabel ?? uploadPlan.FormatLabel} push_mode={uploadPlan.PushMode} payload_len={uploadPlan.Payload?.Length ?? 0}"); + + try + { + machineUserDao.Add(new AttendanceMachineUser(target.Machine.MachineId, empId, displayName), connection); + registeredSerials.Add(empId); + } + catch (Exception exPersist) + { + logs.Add($"[DB_TO_DEVICE] employee={empId} target={target.Machine.MachineIp} user=CREATED face=UPLOADED db_user_persist=WARN err={exPersist.Message}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] attendance_machine_user persist warning emp={empId} err={exPersist.Message}"); + } + + success++; + summary.NewlyRegistered++; + bOk++; + logs.Add($"[DB_TO_DEVICE] employee={empId} target={target.Machine.MachineIp} user=CREATED face=UPLOADED"); + Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=OK"); } if (delayMs > 0) @@ -797,7 +1140,7 @@ namespace HanvonF710XAttendanceService } swBatch.Stop(); - logs.Add($"[TemplateTransferBatch] job={job.Id} target_ip={target.Machine.MachineIp} batch={bi + 1}/{batches.Count} attempted={bAttempted} ok={bOk} fail={bFail} verify_ok={bVerifyOk} verify_fail={bVerifyFail} duration_sec={Math.Round(swBatch.Elapsed.TotalSeconds, 1)}"); + logs.Add($"[TemplateTransferBatch] job={job.Id} target_ip={target.Machine.MachineIp} batch={bi + 1}/{batches.Count} attempted={bAttempted} success={bOk} skipped={bSkipped} duplicate={bDuplicate} upload_fail={bUploadFailed} verify_ok={bVerifyOk} verify_fail={bVerifyFail} duration_sec={Math.Round(swBatch.Elapsed.TotalSeconds, 1)}"); } } else @@ -810,7 +1153,7 @@ namespace HanvonF710XAttendanceService var swBatch = Stopwatch.StartNew(); int bAttempted = 0; int bOk = 0; - int bFail = 0; + int bUploadFailed = 0; int bVerifyOk = 0; int bVerifyFail = 0; @@ -821,8 +1164,9 @@ namespace HanvonF710XAttendanceService string templateStr = Encoding.UTF8.GetString(kvp.Value ?? Array.Empty()); if (!TemplateParser.TryBuildSetEmployeeCommand(templateStr, out var setCmd)) { - fail++; - bFail++; + uploadFailed++; + bUploadFailed++; + summary.AddFailure(empId, empId, "", "Build SetEmployee failed"); logs.Add($"[TemplateJob {job.Id}] Build SetEmployee failed. emp={empId} target={target.Machine.MachineIp}"); Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL reason=BuildSetEmployee"); continue; @@ -833,14 +1177,16 @@ namespace HanvonF710XAttendanceService int rc = Program.test(targetDevInfo, targetDevInfo.Length, setCmd, setCmd.Length, ref response, ref recvLen, callback); if (rc != 0 || (response != null && response.IndexOf("fail", StringComparison.OrdinalIgnoreCase) >= 0)) { - fail++; - bFail++; + uploadFailed++; + bUploadFailed++; + summary.AddFailure(empId, empId, "", "SetEmployee failed rc=" + rc); logs.Add($"[TemplateJob {job.Id}] SetEmployee failed. emp={empId} target={target.Machine.MachineIp}"); Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL rc={rc}"); } else { - ok++; + success++; + summary.NewlyRegistered++; bOk++; Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=OK"); @@ -850,8 +1196,8 @@ namespace HanvonF710XAttendanceService string verifyResp = ""; uint verifyRecvLen = 0; int vrc = Program.test(targetDevInfo, targetDevInfo.Length, verifyCmd, verifyCmd.Length, ref verifyResp, ref verifyRecvLen, callback); - bool success = vrc == 0 && verifyResp != null && verifyResp.IndexOf("success", StringComparison.OrdinalIgnoreCase) >= 0; - if (success) + bool verifiedOk = vrc == 0 && verifyResp != null && verifyResp.IndexOf("success", StringComparison.OrdinalIgnoreCase) >= 0; + if (verifiedOk) { verifyOk++; bVerifyOk++; @@ -861,6 +1207,7 @@ namespace HanvonF710XAttendanceService { verifyFail++; bVerifyFail++; + summary.AddFailure(empId, empId, "", "VERIFY_FAILED rc=" + vrc); Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=FAIL rc={vrc}"); } } @@ -873,18 +1220,82 @@ namespace HanvonF710XAttendanceService } swBatch.Stop(); - logs.Add($"[TemplateTransferBatch] job={job.Id} target_ip={target.Machine.MachineIp} batch={bi + 1}/{batches.Count} attempted={bAttempted} ok={bOk} fail={bFail} verify_ok={bVerifyOk} verify_fail={bVerifyFail} duration_sec={Math.Round(swBatch.Elapsed.TotalSeconds, 1)}"); + logs.Add($"[TemplateTransferBatch] job={job.Id} target_ip={target.Machine.MachineIp} batch={bi + 1}/{batches.Count} attempted={bAttempted} success={bOk} upload_fail={bUploadFailed} verify_ok={bVerifyOk} verify_fail={bVerifyFail} duration_sec={Math.Round(swBatch.Elapsed.TotalSeconds, 1)}"); } } - if (verifyAfterPush) + WriteDbToDeviceDuplicateReport(job.Id, duplicateFaceEntries, logs); + WriteDbToDeviceTargetSummary(job.Id, summary, logs); + + int failed = summary.Failed; + logs.Add($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} summary SUCCESS={success} ALREADY_REGISTERED={alreadyRegistered} MISSING_TEMPLATE={missingTemplateCount} DUPLICATE_FACE={duplicateFace} UPLOAD_FAILED={uploadFailed} VERIFY_FAILED={verifyFail}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} created={success} faceUploaded={success} alreadyRegistered={alreadyRegistered} duplicateFace={duplicateFace} missingTemplate={missingTemplateCount} failed={failed}"); + } + } + + private static void WriteDbToDeviceTargetSummary(string jobId, DbToDeviceTargetSummary summary, List logs) + { + if (summary == null) + { + return; + } + + string report = summary.FormatReport(); + foreach (var line in report.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)) + { + if (string.IsNullOrEmpty(line) && logs.Count > 0 && logs[logs.Count - 1] == "") { - logs.Add($"[TemplateJob {job.Id}] DB_TO_DEVICE target={target.Machine.MachineIp} ok={ok} fail={fail} verify_ok={verifyOk} verify_fail={verifyFail}"); + continue; } - else + + logs.Add(line); + Program.WriteInternalLog(line); + } + + try + { + string dir = ApplicationPaths.InternalLogs; + Directory.CreateDirectory(dir); + string path = Path.Combine( + dir, + "DbToDeviceSummary_" + jobId + "_" + (summary.MachineId ?? "machine") + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".txt"); + File.WriteAllText(path, report, Encoding.UTF8); + logs.Add($"[DB_TO_DEVICE] Summary report written. path={path}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Summary report written. path={path}"); + } + catch (Exception ex) + { + logs.Add($"[DB_TO_DEVICE] Summary report write failed. err={ex.Message}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Summary report write failed. err={ex.Message}"); + } + } + + private static void WriteDbToDeviceDuplicateReport(string jobId, IReadOnlyList entries, List logs) + { + if (entries == null || entries.Count == 0) + { + return; + } + + try + { + string dir = ApplicationPaths.InternalLogs; + Directory.CreateDirectory(dir); + string path = Path.Combine(dir, "DbToDeviceDuplicates_" + jobId + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".txt"); + var lines = new List { "employee_id | existing_device_id | target_device" }; + foreach (var entry in entries) { - logs.Add($"[TemplateJob {job.Id}] DB_TO_DEVICE target={target.Machine.MachineIp} ok={ok} fail={fail}"); + lines.Add($"{entry.EmployeeId} | {entry.ExistingDeviceId} | {entry.TargetDevice}"); } + + File.WriteAllLines(path, lines, Encoding.UTF8); + logs.Add($"[DB_TO_DEVICE] Duplicate face report written. path={path} count={entries.Count}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Duplicate face report written. path={path} count={entries.Count}"); + } + catch (Exception ex) + { + logs.Add($"[DB_TO_DEVICE] Duplicate face report write failed. err={ex.Message}"); + Program.WriteInternalLog($"[DB_TO_DEVICE] Duplicate face report write failed. err={ex.Message}"); } } @@ -967,7 +1378,7 @@ namespace HanvonF710XAttendanceService { try { - string filepath = AppDomain.CurrentDomain.BaseDirectory + "\\TemplateRawLogs\\TemplateRaw_" + DateTime.Now.Date.ToShortDateString().Replace('/', '_') + ".txt"; + string filepath = ApplicationPaths.DatedFile(ApplicationPaths.TemplateRawLogs, "TemplateRaw"); LogService.EnqueueLine(filepath, "----- " + DateTime.Now + " -----"); LogService.EnqueueLine(filepath, header); LogService.EnqueueLine(filepath, raw ?? "");