diff --git a/Controllers/RecruitController.cs b/Controllers/RecruitController.cs index f88a564..582e9d4 100644 --- a/Controllers/RecruitController.cs +++ b/Controllers/RecruitController.cs @@ -2360,6 +2360,32 @@ namespace BMA.EHR.Recruit.Controllers } } + /// + /// โอนชื่อผู้ผ่านการสอบแข่งขันตามเลขบัตรประชาชนไปบรรจุ + /// + /// รหัสรอบสมัคร + /// ข้อมูลรายการเลขบัตรประชาชนและวันเริ่มงาน + /// + /// เมื่อโอนคนแข่งขันไปบรรจุสำเร็จ + /// ไม่ได้ Login เข้าระบบ + /// เมื่อเกิดข้อผิดพลาดในการทำงาน + [HttpPost("placement-selective/{examId:length(36)}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> UpdateAsyncSelectivePlacement(Guid examId, [FromBody] SelectivePlacementRequest req) + { + try + { + await _recruitService.UpdateAsyncSelectivePlacement(examId, req.CitizenIds, req.AccountStartDate); + return Success(); + } + catch (Exception ex) + { + return Error(ex); + } + } + #endregion #region " Report " diff --git a/Requests/Recruits/SelectivePlacementRequest.cs b/Requests/Recruits/SelectivePlacementRequest.cs new file mode 100644 index 0000000..657c159 --- /dev/null +++ b/Requests/Recruits/SelectivePlacementRequest.cs @@ -0,0 +1,8 @@ +namespace BMA.EHR.Recruit.Requests.Recruits +{ + public class SelectivePlacementRequest + { + public List CitizenIds { get; set; } = new(); + public DateTime AccountStartDate { get; set; } + } +} diff --git a/Services/RecruitService.cs b/Services/RecruitService.cs index 4132b07..472d21b 100644 --- a/Services/RecruitService.cs +++ b/Services/RecruitService.cs @@ -504,6 +504,308 @@ namespace BMA.EHR.Recruit.Services } } + public async Task UpdateAsyncSelectivePlacement(Guid examId, List citizenIds, DateTime accountStartDate) + { + try + { + var recruitImport = await _context.RecruitImports.AsQueryable() + .FirstOrDefaultAsync(x => x.Id == examId); + + if (recruitImport == null) + throw new Exception(GlobalMessages.DataNotFound); + + // เช็ครอบสอบเคยบรรจุไปแล้วหรือยัง และกรองคนซ้ำออก + var existingPlacement = await _contextMetadata.Placements.AsQueryable() + .FirstOrDefaultAsync(x => x.PlacementType.Name == "สอบแข่งขัน" && x.RefId == recruitImport.Id); + + var citizenIdsToProcess = new List(citizenIds); + + if (existingPlacement != null) + { + // ถ้ารอบสอบเคยบรรจุแล้ว หาคนที่ซ้ำและกรองออก + var existingCitizenIds = await _contextMetadata.PlacementProfiles + .Where(x => x.Placement.Id == existingPlacement.Id && + x.CitizenId != null && + citizenIds.Contains(x.CitizenId)) + .Select(x => x.CitizenId!) + .ToListAsync(); + + // กรองเฉพาะคนที่ยังไม่เคยถูกบรรจุ + citizenIdsToProcess = citizenIds.Except(existingCitizenIds).ToList(); + } + + // ถ้าไม่มีคนที่จะบรรจุ ให้ return + if (!citizenIdsToProcess.Any()) + return; + + // 🚀 Prepare HTTP client once via factory with timeout + var clientForPos = _httpClientFactory.CreateClient("default"); + clientForPos.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token?.Replace("Bearer ", "")); + clientForPos.DefaultRequestHeaders.Remove("api_key"); + clientForPos.DefaultRequestHeaders.Add("api_key", _configuration["API_KEY"] ?? ""); + var apiUrl1 = $"{_configuration["API"]}/org/pos/level"; + var response1 = string.Empty; + try + { + using var ctsPos = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + response1 = await clientForPos.GetStringAsync(apiUrl1, ctsPos.Token); + } + catch (TaskCanceledException) + { + response1 = string.Empty; + } + + var posOptions = string.IsNullOrWhiteSpace(response1) ? null : JsonConvert.DeserializeObject(response1); + + // 🚀 Pre-load all lookup data once + var placementTypesCache = await _contextMetadata.PlacementTypes.ToListAsync(); + var provincesCache = await _contextOrg.province.ToListAsync(); + var districtsCache = await _contextOrg.district.ToListAsync(); + var subDistrictsCache = await _contextOrg.subDistrict.ToListAsync(); + var educationLevelsCache = await _contextOrg.educationLevel.ToListAsync(); + + // ใช้ Placement เดิมถ้ามี หรือสร้างใหม่ถ้าไม่มี + var placement = existingPlacement; + if (placement == null) + { + placement = new Placement + { + Name = recruitImport.Name, + RefId = recruitImport.Id, + Round = recruitImport.Order.ToString() ?? "", + Year = recruitImport.Year, + Number = citizenIds.Count, + PlacementType = placementTypesCache.FirstOrDefault(x => x.Name.Trim().ToUpper().Contains("สอบแข่งขัน")) ?? placementTypesCache.First(), + StartDate = accountStartDate, + EndDate = accountStartDate.AddYears(2).AddDays(-1), + CreatedAt = DateTime.Now, + CreatedUserId = UserId ?? "", + CreatedFullName = FullName ?? "", + LastUpdatedAt = DateTime.Now, + LastUpdateUserId = UserId ?? "", + LastUpdateFullName = FullName ?? "", + }; + await _contextMetadata.Placements.AddAsync(placement); + } + + var scoreImport = await _context.ScoreImports.AsQueryable() + .FirstOrDefaultAsync(x => x.RecruitImport == recruitImport); + + var recruitScores = await _context.RecruitScores.AsQueryable() + .Where(x => x.ScoreImport == scoreImport && x.ExamStatus == "ผ่าน") + .ToListAsync(); + + var recruitScoresDict = recruitScores + .Where(x => !string.IsNullOrWhiteSpace(x.ExamId)) + .ToDictionary(x => x.ExamId, x => x); + + var candidates = await _context.Recruits.AsQueryable() + .Include(x => x.Addresses) + .Include(x => x.Certificates) + .Include(x => x.Educations) + .Include(x => x.Occupations) + .Where(x => x.RecruitImport == recruitImport && citizenIdsToProcess.Contains(x.CitizenId)) + .ToListAsync(); + + // 🚀 Batch HTTP requests using IHttpClientFactory with concurrency limit and cancellation + var clientForOrg = _httpClientFactory.CreateClient("default"); + clientForOrg.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token?.Replace("Bearer ", "")); + clientForOrg.DefaultRequestHeaders.Remove("api_key"); + clientForOrg.DefaultRequestHeaders.Add("api_key", _configuration["API_KEY"] ?? ""); + + var semaphore = new SemaphoreSlim(10); + var orgTasks = candidates.Select(async candidate => + { + if (string.IsNullOrWhiteSpace(candidate.CitizenId)) + return new { CitizenId = candidate.CitizenId ?? "", org = (dynamic?)null }; + + await semaphore.WaitAsync(); + try + { + var apiUrl = $"{_configuration["API"]}/org/profile/citizenid/position/{candidate.CitizenId}"; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var response = await clientForOrg.GetStringAsync(apiUrl, cts.Token); + return new { CitizenId = candidate.CitizenId, org = JsonConvert.DeserializeObject(response) }; + } + catch (TaskCanceledException) + { + return new { CitizenId = candidate.CitizenId ?? "", org = (dynamic?)null }; + } + catch (Exception) + { + return new { CitizenId = candidate.CitizenId ?? "", org = (dynamic?)null }; + } + } + finally + { + semaphore.Release(); + } + }).ToList(); + + var orgResults = await Task.WhenAll(orgTasks); + var orgDict = orgResults.ToDictionary(x => x.CitizenId ?? "", x => x.org); + + var placementProfiles = new List(); + var placementEducations = new List(); + + foreach (var candidate in candidates) + { + if (string.IsNullOrWhiteSpace(candidate.ExamId) || + !recruitScoresDict.TryGetValue(candidate.ExamId, out var recruitScore)) + continue; + + var org = orgDict.TryGetValue(candidate.CitizenId ?? "", out var orgValue) ? orgValue : null; + var isOfficer = org?.result != null; + + var firstAddress = candidate.Addresses?.FirstOrDefault(); + var firstEducation = candidate.Educations?.FirstOrDefault(); + var firstCertificate = candidate.Certificates?.FirstOrDefault(); + var firstOccupation = candidate.Occupations?.FirstOrDefault(); + + var registAddress = BuildAddress(firstAddress?.Address, firstAddress?.Moo, firstAddress?.Soi, firstAddress?.Road); + var currentAddress = BuildAddress(firstAddress?.Address1, firstAddress?.Moo1, firstAddress?.Soi1, firstAddress?.Road1); + + var posLevelObject = posOptions?.result?.FirstOrDefault(x => + !string.IsNullOrWhiteSpace(x.posLevelName) && + !string.IsNullOrWhiteSpace(candidate.PositionName) && + candidate.PositionName.Contains(x.posLevelName)); + + var posLevelName = posLevelObject?.posLevelName; + var positionNameWithoutLevel = candidate.PositionName ?? ""; + if (!string.IsNullOrWhiteSpace(posLevelName)) + { + positionNameWithoutLevel = positionNameWithoutLevel.Replace(posLevelName, "").Trim(); + } + + var registProvinceId = provincesCache.FirstOrDefault(x => x.name == firstAddress?.Province)?.Id; + var registDistrictId = districtsCache.FirstOrDefault(x => x.name == firstAddress?.Amphur && x.provinceId == registProvinceId)?.Id; + var registSubDistrictId = subDistrictsCache.FirstOrDefault(x => x.name == firstAddress?.District && x.districtId == registDistrictId)?.Id; + var currentProvinceId = provincesCache.FirstOrDefault(x => x.name == firstAddress?.Province1)?.Id; + var currentDistrictId = districtsCache.FirstOrDefault(x => x.name == firstAddress?.Amphur1 && x.provinceId == currentProvinceId)?.Id; + var currentSubDistrictId = subDistrictsCache.FirstOrDefault(x => x.name == firstAddress?.District1 && x.districtId == currentDistrictId)?.Id; + + var placementProfile = new PlacementProfile + { + Placement = placement, + PositionCandidate = positionNameWithoutLevel ?? "", + PositionType = posLevelObject?.posTypes?.posTypeName ?? "", + PositionLevel = posLevelName ?? "", + Prefix = candidate.Prefix ?? "", + Firstname = candidate.FirstName ?? "", + Lastname = candidate.LastName ?? "", + Gender = candidate.Gendor ?? "", + Nationality = candidate.National ?? "", + Race = candidate.Race ?? "", + Religion = candidate.Religion ?? "", + DateOfBirth = candidate.DateOfBirth, + Relationship = candidate.Marry ?? "", + CitizenId = candidate.CitizenId ?? "", + CitizenProvinceId = provincesCache.FirstOrDefault(x => x.name == candidate.CitizenCardIssuer)?.Id, + CitizenDate = candidate.CitizenCardExpireDate, + Telephone = firstAddress?.Telephone ?? "", + MobilePhone = firstAddress?.Mobile ?? "", + RegistAddress = registAddress ?? "", + RegistProvinceId = registProvinceId, + RegistDistrictId = registDistrictId, + RegistSubDistrictId = registSubDistrictId, + RegistZipCode = firstAddress?.ZipCode ?? "", + RegistSame = false, + CurrentAddress = currentAddress, + CurrentProvinceId = currentProvinceId, + CurrentDistrictId = currentDistrictId, + CurrentSubDistrictId = currentSubDistrictId, + CurrentZipCode = firstAddress?.ZipCode1, + Marry = candidate.Marry?.Contains("สมรส") ?? false, + OccupationPositionType = "other", + OccupationTelephone = firstOccupation?.Telephone ?? "", + OccupationPosition = firstOccupation?.Position ?? "", + PointTotalA = recruitScore.FullA, + PointA = recruitScore.SumA, + PointTotalB = recruitScore.FullB ?? 0, + PointB = recruitScore.SumB ?? 0, + PointTotalC = recruitScore.FullC, + PointC = recruitScore.SumC, + ExamNumber = !string.IsNullOrWhiteSpace(recruitScore.Number) && int.TryParse(recruitScore.Number, out int n) ? n : null, + ExamRound = null, + IsRelief = false, + PlacementStatus = "UN-CONTAIN", + Pass = recruitScore.ExamStatus ?? "", + RemarkHorizontal = "โดยมีเงื่อนไขว่าต้องปฏิบัติงานให้กรุงเทพมหานครเป็นระยะเวลาไม่น้อยกว่า ๕ ปี นับแต่วันที่ได้รับการบรรจุและแต่งตั้ง โดยห้ามโอนไปหน่วยงานหรือส่วนราชการอื่น เว้นเเต่ลาออกจากราชการ", + Amount = org?.result?.amount, + PositionSalaryAmount = org?.result?.positionSalaryAmount, + MouthSalaryAmount = org?.result?.mouthSalaryAmount, + CreatedAt = DateTime.Now, + CreatedUserId = UserId ?? "", + CreatedFullName = FullName ?? "", + LastUpdatedAt = DateTime.Now, + LastUpdateUserId = UserId ?? "", + LastUpdateFullName = FullName ?? "", + IsOfficer = isOfficer, + profileId = org?.result?.profileId ?? "", + IsOld = org?.result != null, + AmountOld = org?.result?.amount, + nodeOld = org?.result?.node ?? "", + nodeIdOld = org?.result?.nodeId ?? "", + posmasterIdOld = org?.result?.posmasterId ?? "", + rootOld = org?.result?.root ?? "", + rootIdOld = org?.result?.rootId ?? "", + rootShortNameOld = org?.result?.rootShortName ?? "", + child1Old = org?.result?.child1 ?? "", + child1IdOld = org?.result?.child1Id ?? "", + child1ShortNameOld = org?.result?.child1ShortName ?? "", + child2Old = org?.result?.child2 ?? "", + child2IdOld = org?.result?.child2Id ?? "", + child2ShortNameOld = org?.result?.child2ShortName ?? "", + child3Old = org?.result?.child3 ?? "", + child3IdOld = org?.result?.child3Id ?? "", + child3ShortNameOld = org?.result?.child3ShortName ?? "", + child4Old = org?.result?.child4 ?? "", + child4IdOld = org?.result?.child4Id ?? "", + child4ShortNameOld = org?.result?.child4ShortName ?? "", + orgRevisionIdOld = org?.result?.orgRevisionId ?? "", + posMasterNoOld = org?.result?.posMasterNo, + positionNameOld = org?.result?.position ?? "", + posTypeIdOld = org?.result?.posTypeId ?? "", + posTypeNameOld = org?.result?.posTypeName ?? "", + posLevelIdOld = org?.result?.posLevelId ?? "", + posLevelNameOld = org?.result?.posLevelName ?? "", + }; + placementProfiles.Add(placementProfile); + + var placementEducation = new PlacementEducation + { + PlacementProfile = placementProfile, + EducationLevelId = educationLevelsCache.FirstOrDefault(x => x.name == firstEducation?.HighDegree)?.Id, + EducationLevelName = educationLevelsCache.FirstOrDefault(x => x.name == firstEducation?.HighDegree)?.name, + Field = firstEducation?.Major ?? "", + Gpa = firstEducation == null || firstEducation?.GPA == null ? "" : firstEducation.GPA.ToString(), + Institute = firstEducation?.University ?? "", + Degree = firstEducation?.Degree ?? "", + FinishDate = firstEducation?.BachelorDate, + IsDate = true, + CreatedAt = DateTime.Now, + CreatedUserId = UserId ?? "", + LastUpdatedAt = DateTime.Now, + LastUpdateUserId = UserId ?? "", + CreatedFullName = FullName ?? "", + LastUpdateFullName = FullName ?? "", + }; + placementEducations.Add(placementEducation); + } + + await _contextMetadata.PlacementProfiles.AddRangeAsync(placementProfiles); + await _contextMetadata.PlacementEducations.AddRangeAsync(placementEducations); + await _contextMetadata.SaveChangesAsync(); + + } + catch + { + throw; + } + } + private string BuildAddress(string? address, string? moo, string? soi, string? road) { var parts = new List();