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();