using System; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using PayRollBLL.Employee; using PayRollDAL.DTO.Employee; using RecruitmentBLL.Candidate; using RecruitmentDAL.CustomeCode.Candidate; using RecruitmentDAL.DTO.Candidate; namespace RecruitmentBLL.CandidateConversion { // Internal mobility (Phase 6 of the Recruitment build-out plan, see // /Users/venkatv/.claude/plans/can-you-check-the-precious-sunbeam.md) — the conceptual // REVERSE of RecruitmentBLL.CandidateConversion.CandidateConversionBLL's existing // Candidate->Employee "final joining event": here an *existing* Employee becomes a Candidate // so they can apply, through the same MCANDIDATE/TAPPLICATION pipeline, to a different // internal opening. Deliberately a new, separate file — CandidateConversionBLL.cs itself is // not modified. // // Same orchestrating-BLL shape as CandidateConversionBLL: this does not own a table of its // own, so there is no ExecuteSaveAsync call here directly — it composes an existing read // (PayRollBLL.Employee.IEmployeeBLL.GetEmployee, same-host direct DI as // CandidateConversionBLL's own SaveEmployee call — see RecruitmentBLL.csproj's // ProjectReference comment) with the existing, already-instrumented // ICandidateBLL.SaveCandidate pipeline (ExecuteSaveAsync, AutoNumber, event-publish, cache // invalidation all already happen inside that call), mirroring CareerBLL.SubmitApplication's // "reuse an existing Candidate or create a new one" composition style. // // Field mapping — only what EmployeeDTO genuinely exposes, nothing invented: // FirstName/LastName -> Candidate.FirstName/LastName (falls back to splitting // EmployeeDTO.EmployeeName on the first space when FirstName/LastName are both blank — // some legacy Employee rows only ever populated the single combined EmployeeName field). // EmployeePrimaryEMailId -> Candidate.Email // EmployeePrimaryMobile -> Candidate.Phone // No resume/skills/salary/address data is carried over — none of that has a Candidate-side // home yet and none of it was asked for by this pass. // // Candidate.EmployeeId is stamped with the real EmployeeId immediately (not left at the -1 // placeholder) — see the Phase 6 migration's header comment for why this is a safe reuse of // the same column CandidateConversionBLL stamps in the opposite direction, and why it // correctly makes CandidateConversionBLL.ConvertCandidateToEmployeeAsync refuse to run again // against this Candidate (an internal transfer must never mint a second MEMPLOYEE row). // // Consent: unlike the public career-site flow (CareerBLL.SubmitApplication), GDPR-style // explicit consent is not re-collected here — the employer already lawfully holds this // employee's name/contact data for the employment relationship itself, so ConsentGiven is // set true / ConsentDate now as an auto-implied consent for this internal, employment-context // use, not a re-run of the external-candidate consent capture. public class EmployeeToInternalCandidateBLL : IEmployeeToInternalCandidateBLL { // MCANDIDATE.CANDIDATETYPE / SOURCE — see the Phase 6 migration / CandidateDTO doc // comments. CandidateType=2 is InternalEmployee; Source=5 ("Internal") already existed // pre-Phase-6 (CandidateDTO.Source's original 1..6 enum) and is simply the correct value // for this path rather than a new one. private const byte CANDIDATETYPE_INTERNALEMPLOYEE = 2; private const byte SOURCE_INTERNAL = 5; private readonly ICandidateBLL _CandidateBLL; private readonly ICandidateDAL _CandidateDAL; private readonly IEmployeeBLL _EmployeeBLL; private readonly ILogger _Logger; public EmployeeToInternalCandidateBLL( ICandidateBLL candidateBLL, ICandidateDAL candidateDAL, IEmployeeBLL employeeBLL, ILogger logger) { _CandidateBLL = candidateBLL; _CandidateDAL = candidateDAL; _EmployeeBLL = employeeBLL; _Logger = logger; } public async Task ConvertEmployeeToInternalCandidate(int employeeId, LoginDTO login, CancellationToken ct) { if (employeeId <= 0) throw new ArgumentOutOfRangeException(nameof(employeeId), "EmployeeId is required."); try { GB5Trace.Step("resolve-internal-mobility-candidate", new { employeeId }); // ── Reuse an existing InternalEmployee-type Candidate for this employee, or // create a new one — same "dedup by identity, don't duplicate" shape as // CareerBLL.SubmitApplication's email-based lookup, keyed by EmployeeId // instead (the precise, unambiguous identity for this path). ────────────── var existingJson = await _CandidateDAL .GetCandidateByEmployeeId(employeeId, login, ct) .ConfigureAwait(false); var existing = JsonConvert.DeserializeObject(existingJson ?? "null"); if (existing != null && existing.CandidateId > 0) { _Logger.LogInformation( "Internal mobility: reusing existing Candidate {CandidateId} for EmployeeId {EmployeeId}", existing.CandidateId, employeeId); return existingJson!; } // ── Load the Employee's own master data ─────────────────────────────────── var employeeJson = await _EmployeeBLL.GetEmployee(employeeId, string.Empty, login).ConfigureAwait(false); var employee = JsonConvert.DeserializeObject(employeeJson ?? string.Empty); if (employee is null || employee.EmployeeId <= 0) throw new ValidationException($"Employee {employeeId} was not found."); // Some legacy Employee rows only ever populated the single combined // EmployeeName field, not FirstName/LastName — fall back to splitting it on the // first space rather than leaving Candidate.FirstName blank (CandidateBLL. // SaveCandidate requires FirstName non-empty). string firstName = employee.FirstName; string lastName = employee.LastName; if (string.IsNullOrWhiteSpace(firstName) && !string.IsNullOrWhiteSpace(employee.EmployeeName)) { var parts = employee.EmployeeName.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); firstName = parts.Length > 0 ? parts[0] : employee.EmployeeName; lastName = parts.Length > 1 ? parts[1] : lastName; } GB5Trace.Step("save-internal-mobility-candidate", new { employeeId }); var candidateDto = new CandidateDTO { FirstName = firstName, LastName = lastName, Email = employee.EmployeePrimaryEMailId, Phone = employee.EmployeePrimaryMobile, Source = SOURCE_INTERNAL, CandidateType = CANDIDATETYPE_INTERNALEMPLOYEE, EmployeeId = employeeId, // Auto-implied — see class doc comment for why explicit re-consent is not // collected for an already-employed internal applicant. ConsentGiven = true, ConsentDate = DateTime.UtcNow }; // Routes through CandidateBLL.SaveCandidate — ExecuteSaveAsync, AutoNumber, // event-publish and cache invalidation all already happen inside that call. await _CandidateBLL.SaveCandidate(candidateDto, login, ct).ConfigureAwait(false); _Logger.LogInformation( "Internal mobility: created new Candidate {CandidateId} for EmployeeId {EmployeeId}", candidateDto.CandidateId, employeeId); return await _CandidateBLL.GetCandidate(candidateDto.CandidateId, login, ct).ConfigureAwait(false); } catch (ValidationException vex) { GB5Trace.MarkFailed("employee-to-internal-candidate-failed", vex); _Logger.LogError(vex, "ConvertEmployeeToInternalCandidate validation failed for EmployeeId {EmployeeId}", employeeId); throw new Exception(vex.Message); } catch (Exception ex) { GB5Trace.MarkFailed("employee-to-internal-candidate-failed", ex); _Logger.LogError(ex, "ConvertEmployeeToInternalCandidate failed for EmployeeId {EmployeeId}", employeeId); throw; } } } }