diff --git a/.github/workflows/discord-notify.yml b/.github/workflows/discord-notify.yml new file mode 100644 index 0000000..ce4ee51 --- /dev/null +++ b/.github/workflows/discord-notify.yml @@ -0,0 +1,22 @@ +name: Discord PR Notify + +on: + pull_request: + types: [opened] + +jobs: + discord: + runs-on: ubuntu-latest + steps: + - name: Send Discord + run: | + curl -X POST "${{ secrets.DISCORD_WEBHOOK_PULLREQUEST }}" \ + -H "Content-Type: application/json" \ + -d '{ + "embeds": [{ + "title": "🔔 **Service:** ${{ github.repository }}", + "description": "👤 **Author:** ${{ github.event.pull_request.user.login }}\n🌿 **Branch:** ${{ github.event.pull_request.head.ref }} → ${{ github.event.pull_request.base.ref }}\n📦 **Pull Request:** [#${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }}](${{ github.event.pull_request.html_url }})", + "color": 5814783, + "timestamp": "${{ github.event.pull_request.created_at }}" + }] + }' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 72adff2..f2b3ab2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,106 +1,106 @@ name: release run-name: release ${{ github.actor }} on: - push: - tags: - - "version-[0-9]+.[0-9]+.[0-9]+" - workflow_dispatch: + push: + tags: + - "version-[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: env: - REGISTRY: docker.frappet.com - IMAGE_NAME: ehr/bma-ehr-recruit-service - DEPLOY_HOST: frappet.com - COMPOSE_PATH: /home/frappet/docker/bma/bma-ehr-recruit + REGISTRY: docker.frappet.com + IMAGE_NAME: ehr/bma-ehr-recruit-service + DEPLOY_HOST: frappet.com + COMPOSE_PATH: /home/frappet/docker/bma/bma-ehr-recruit jobs: - # act workflow_dispatch -W .github/workflows/release.yaml --input IMAGE_VER=latest -s DOCKER_USER=admin -s DOCKER_PASS=FPTadmin2357 -s SSH_PASSWORD=FPTadmin2357 - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - # skip Set up QEMU because it fail on act and container - # Gen Version try to get version from tag or inut - - name: Set output tags - id: vars - run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT - - name: Gen Version - id: gen_ver - run: | - if [[ $GITHUB_REF == 'refs/tags/'* ]]; then - IMAGE_VER=${{ steps.vars.outputs.tag }} - else - IMAGE_VER=${{ github.event.inputs.IMAGE_VER }} - fi - if [[ $IMAGE_VER == '' ]]; then - IMAGE_VER='test-vBeta' - fi - echo '::set-output name=image_ver::'$IMAGE_VER - - name: Check Version - run: | - echo $GITHUB_REF - echo ${{ steps.gen_ver.outputs.image_ver }} + # act workflow_dispatch -W .github/workflows/release.yaml --input IMAGE_VER=latest -s DOCKER_USER=admin -s DOCKER_PASS=FPTadmin2357 -s SSH_PASSWORD=FPTadmin2357 + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + # skip Set up QEMU because it fail on act and container + # Gen Version try to get version from tag or inut + - name: Set output tags + id: vars + run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT + - name: Gen Version + id: gen_ver + run: | + if [[ $GITHUB_REF == 'refs/tags/'* ]]; then + IMAGE_VER=${{ steps.vars.outputs.tag }} + else + IMAGE_VER=${{ github.event.inputs.IMAGE_VER }} + fi + if [[ $IMAGE_VER == '' ]]; then + IMAGE_VER='test-vBeta' + fi + echo '::set-output name=image_ver::'$IMAGE_VER + - name: Check Version + run: | + echo $GITHUB_REF + echo ${{ steps.gen_ver.outputs.image_ver }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: Login in to registry - uses: docker/login-action@v2 - with: - registry: ${{env.REGISTRY}} - username: ${{secrets.DOCKER_USER}} - password: ${{secrets.DOCKER_PASS}} - - name: Build and push docker image - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64 - push: true - tags: ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:${{ steps.gen_ver.outputs.image_ver }},${{env.REGISTRY}}/${{env.IMAGE_NAME}}:latest + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login in to registry + uses: docker/login-action@v2 + with: + registry: ${{env.REGISTRY}} + username: ${{secrets.DOCKER_USER}} + password: ${{secrets.DOCKER_PASS}} + - name: Build and push docker image + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{env.REGISTRY}}/${{env.IMAGE_NAME}}:${{ steps.gen_ver.outputs.image_ver }},${{env.REGISTRY}}/${{env.IMAGE_NAME}}:latest - - name: Reload docker compose - uses: appleboy/ssh-action@v0.1.8 - with: - host: ${{env.DEPLOY_HOST}} - username: frappet - password: ${{ secrets.SSH_PASSWORD }} - port: 10102 - script: | - cd "${{env.COMPOSE_PATH}}" - docker compose pull - docker compose up -d - echo "${{ steps.gen_ver.outputs.image_ver }}"> success - - name: Notify Discord Success - if: success() - run: | - curl -H "Content-Type: application/json" \ - -X POST \ - -d '{ - "embeds": [{ - "title": "✅ Deployment Success!", - "description": "**Details:**\n- Image: `${{env.IMAGE_NAME}}`\n- Version: `${{ steps.gen_ver.outputs.image_ver }}`\n- Deployed by: `${{github.actor}}`", - "color": 3066993, - "footer": { - "text": "Release Notification", - "icon_url": "https://example.com/success-icon.png" - }, - "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'" - }] - }' \ - ${{ secrets.DISCORD_WEBHOOK }} + - name: Reload docker compose + uses: appleboy/ssh-action@v0.1.8 + with: + host: ${{env.DEPLOY_HOST}} + username: frappet + password: ${{ secrets.SSH_PASSWORD }} + port: 10102 + script: | + cd "${{env.COMPOSE_PATH}}" + docker compose pull + docker compose up -d + echo "${{ steps.gen_ver.outputs.image_ver }}"> success + - name: Notify Discord Success + if: success() + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d '{ + "embeds": [{ + "title": "✅ Deployment Success!", + "description": "**Details:**\n- Image: `${{env.IMAGE_NAME}}`\n- Version: `${{ steps.gen_ver.outputs.image_ver }}`\n- Deployed by: `${{github.actor}}`", + "color": 3066993, + "footer": { + "text": "Release Notification", + "icon_url": "https://example.com/success-icon.png" + }, + "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'" + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK }} - - name: Notify Discord Failure - if: failure() - run: | - curl -H "Content-Type: application/json" \ - -X POST \ - -d '{ - "embeds": [{ - "title": "❌ Deployment Failed!", - "description": "**Details:**\n- Image: `${{env.IMAGE_NAME}}`\n- Version: `${{ steps.gen_ver.outputs.image_ver }}`\n- Attempted by: `${{github.actor}}`", - "color": 15158332, - "footer": { - "text": "Release Notification", - "icon_url": "https://example.com/failure-icon.png" - }, - "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'" - }] - }' \ - ${{ secrets.DISCORD_WEBHOOK }} + - name: Notify Discord Failure + if: failure() + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d '{ + "embeds": [{ + "title": "❌ Deployment Failed!", + "description": "**Details:**\n- Image: `${{env.IMAGE_NAME}}`\n- Version: `${{ steps.gen_ver.outputs.image_ver }}`\n- Attempted by: `${{github.actor}}`", + "color": 15158332, + "footer": { + "text": "Release Notification", + "icon_url": "https://example.com/failure-icon.png" + }, + "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'" + }] + }' \ + ${{ secrets.DISCORD_WEBHOOK }} diff --git a/Controllers/RecruitController.cs b/Controllers/RecruitController.cs index 31b22e2..5ea2895 100644 --- a/Controllers/RecruitController.cs +++ b/Controllers/RecruitController.cs @@ -1959,103 +1959,87 @@ namespace BMA.EHR.Recruit.Service.Controllers { try { - var data = new List(); - var p_Id = new MySqlParameter("@id", id); - int total = 0; + var query = _context.Recruits + .Include(x => x.RecruitImport) + .Include(x => x.Educations) + .Include(x => x.Certificates) + .OrderBy(x => x.ExamId) + .Where(x => x.RecruitImport != null && x.RecruitImport.Id == id); - // --------------------------- - // 1️. ดึงรายละเอียดสอบ (exam_info) - // --------------------------- - using (var cmd = _context.Database.GetDbConnection().CreateCommand()) + var keywordParam = req.keyword?.Trim(); + if (!string.IsNullOrWhiteSpace(keywordParam)) { - cmd.CommandTimeout = 0; - - var sb = new StringBuilder(); - sb.Append(@" - SELECT - examID, profileID, prefix, fullName, dateofbirth, gender, degree, major, majorgroup, - certificateno, certificateIssueDate, score, result, examAttribute, remark, isspecial, - applydate, university, position_name, hddPosition, typeTest, position_level, position_type, - exam_name, exam_order, score_year, - COUNT(*) OVER() AS total_count - FROM exam_info - WHERE recruit_import_id = @id - "); - - cmd.Parameters.Clear(); - cmd.Parameters.Add(p_Id); - - var keywordParam = req.keyword?.Trim(); - if (!string.IsNullOrWhiteSpace(keywordParam)) - { - sb.Append(@" - AND ( - examID LIKE @kw - OR profileID LIKE @kw - OR prefix LIKE @kw - OR fullName LIKE @kw - OR hddPosition LIKE @kw - OR position_name LIKE @kw - ) - "); - cmd.Parameters.Add(new MySqlParameter("@kw", $"%{keywordParam}%")); - } - - // --------------------------- - // Paging + Sorting - // --------------------------- - sb.Append(" ORDER BY examID "); - sb.Append(" LIMIT @PageSize OFFSET @Offset "); - cmd.Parameters.Add(new MySqlParameter("@PageSize", req.PageSize)); - cmd.Parameters.Add(new MySqlParameter("@Offset", (req.Page - 1) * req.PageSize)); - - cmd.CommandText = sb.ToString(); - - _context.Database.OpenConnection(); - - // --------------------------- - // ดึงข้อมูล + total - // --------------------------- - using (var reader = cmd.ExecuteReader()) - { - while (reader.Read()) - { - if (total == 0) - total = Convert.ToInt32(reader["total_count"]); - - data.Add(new - { - examID = reader["examID"].ToString(), - profileID = reader["profileID"].ToString(), - prefix = reader["prefix"].ToString(), - fullName = reader["fullName"].ToString(), - dateOfBirth = reader["dateofbirth"] == DBNull.Value ? "" : Convert.ToDateTime(reader["dateofbirth"]).ToThaiShortDate(), - gender = reader["gender"].ToString(), - degree = reader["degree"].ToString(), - major = reader["major"].ToString(), - majorgroup = reader["majorgroup"].ToString(), - certificateNo = reader["certificateno"].ToString(), - certificateIssueDate = reader["certificateIssueDate"] == DBNull.Value ? "" : Convert.ToDateTime(reader["certificateIssueDate"]).ToThaiShortDate(), - ExamScore = reader["score"] == DBNull.Value ? 0 : Convert.ToDecimal(reader["score"]), - ExamResult = reader["result"].ToString(), - ExamAttribute = reader["examAttribute"].ToString(), - Remark = reader["remark"].ToString(), - IsSpecial = reader["isspecial"].ToString(), - applyDate = reader["applydate"] == DBNull.Value ? "" : Convert.ToDateTime(reader["applydate"]).ToThaiShortDate(), - university = reader["university"].ToString(), - position_name = reader["position_name"].ToString(), - hddPosition = reader["hddPosition"].ToString(), - typeTest = reader["typeTest"].ToString(), - position_level = reader["position_level"].ToString(), - position_type = reader["position_type"].ToString(), - exam_name = reader["exam_name"].ToString(), - exam_order = reader["exam_order"].ToString(), - score_year = Convert.ToInt32(reader["score_year"]).ToThaiYear().ToString() - }); - } - } + query = query.Where(x => + x.ExamId.Contains(keywordParam) || + x.CitizenId.Contains(keywordParam) || + x.Prefix.Contains(keywordParam) || + x.FirstName.Contains(keywordParam) || + x.LastName.Contains(keywordParam) || + x.HddPosition.Contains(keywordParam) || + x.PositionName.Contains(keywordParam) + ); } + int total = await query.CountAsync(); + + query = query + .Skip((req.Page - 1) * req.PageSize) + .Take(req.PageSize); + + var data = await query + .GroupJoin( + _context.RecruitScores.Include(x => x.ScoreImport), + rc => new { rc.RecruitImport!.Id, rc.ExamId }, + sc => new { Id = sc.ScoreImport!.RecruitImportId, sc.ExamId }, + (recruit, scores) => new { recruit, scores } + ) + .SelectMany( + x => x.scores.DefaultIfEmpty(), + (x, sr) => new + { + examID = x.recruit.ExamId, + profileID = x.recruit.CitizenId, + prefix = x.recruit.Prefix, + fullName = $"{x.recruit.FirstName} {x.recruit.LastName}", + dateOfBirth = x.recruit.DateOfBirth != null && x.recruit.DateOfBirth != DateTime.MinValue + ? x.recruit.DateOfBirth.ToThaiShortDate() + : "", + gender = x.recruit.Gendor, + degree = x.recruit.Educations.Any() ? x.recruit.Educations.First().Degree : "", + major = x.recruit.Educations.Any() ? x.recruit.Educations.First().Major : "", + certificateNo = x.recruit.Certificates.Any() + ? x.recruit.Certificates.First().CertificateNo ?? "" + : "", + certificateIssueDate = x.recruit.Certificates.Any() && x.recruit.Certificates.First().IssueDate != null && x.recruit.Certificates.First().IssueDate != DateTime.MinValue + ? x.recruit.Certificates.First().IssueDate.ToThaiShortDate() + : "", + examScore = sr == null ? 0.0 : sr.TotalScore, + examResult = sr == null ? "" : sr.ExamStatus, + examAttribute = x.recruit.Certificates.Any() && x.recruit.Certificates.First().IssueDate != null + ? _recruitService.CheckValidCertificate(x.recruit.Certificates.First().IssueDate, 5) + ? "มีคุณสมบัติ" : "ไม่มีคุณสมบัติ" + : "ไม่มีคุณสมบัติ", + remark = x.recruit.Remark, + isSpecial = x.recruit.Isspecial == "Y" ? x.recruit.Isspecial : "", + applyDate = x.recruit.ApplyDate != null && x.recruit.ApplyDate != DateTime.MinValue + ? x.recruit.ApplyDate.ToThaiShortDate() + : "", + university = x.recruit.Educations.Any() ? x.recruit.Educations.First().University : "", + position_name = x.recruit.PositionName, + hddPosition = x.recruit.HddPosition, + typeTest = x.recruit.typeTest, + position_level = x.recruit.PositionLevel, + position_type = x.recruit.PositionType, + exam_name = x.recruit.RecruitImport!.Name, + exam_order = x.recruit.RecruitImport != null && x.recruit.RecruitImport.Order != null + ? x.recruit.RecruitImport.Order.ToString() + : "", + score_year = x.recruit.RecruitImport != null && x.recruit.RecruitImport.Year != null + ? x.recruit.RecruitImport.Year.ToThaiYear().ToString() + : "", + }) + .ToListAsync(); + // --------------------------- // 3️. ดึงสรุปคะแนน // --------------------------- @@ -2090,7 +2074,7 @@ namespace BMA.EHR.Recruit.Service.Controllers } else { - header = new + header = new { count = _count, pass = 0, @@ -2099,7 +2083,7 @@ namespace BMA.EHR.Recruit.Service.Controllers other = 0 }; } - + // --------------------------- // 4️. ดึง period @@ -2627,15 +2611,15 @@ namespace BMA.EHR.Recruit.Service.Controllers /// เมื่อโอนคนแข่งขันไปบรรจุสำเร็จ /// ไม่ได้ Login เข้าระบบ /// เมื่อเกิดข้อผิดพลาดในการทำงาน - [HttpGet("placement/{examId:length(36)}")] + [HttpPost("placement/{examId:length(36)}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> UpdateAsyncRecruitToPlacement(Guid examId) + public async Task> UpdateAsyncRecruitToPlacement(Guid examId, [FromBody] RecruitDateRequest req) { try { - await _recruitService.UpdateAsyncRecruitToPlacement(examId); + await _recruitService.UpdateAsyncRecruitToPlacement(examId, req.AccountStartDate); return Success(); } catch (Exception ex) diff --git a/Models/MetaData/EducationLevel.cs b/Models/MetaData/EducationLevel.cs index 840b8d3..380f4c4 100644 --- a/Models/MetaData/EducationLevel.cs +++ b/Models/MetaData/EducationLevel.cs @@ -7,8 +7,8 @@ namespace BMA.EHR.MetaData.Service.Models { public class EducationLevel : EntityBase { - [Required, MaxLength(100), Column(Order = 1), Comment("ระดับการศึกษา")] - public string name { get; set; } = string.Empty; + [MaxLength(255), Column(Order = 1), Comment("ระดับการศึกษา")] + public string? name { get; set; } = null; // [Column(Order = 2), Comment("สถานะการใช้งาน")] // public bool IsActive { get; set; } = true; diff --git a/Models/MetaData/SubDistrict.cs b/Models/MetaData/SubDistrict.cs index 66ac804..dff6bed 100644 --- a/Models/MetaData/SubDistrict.cs +++ b/Models/MetaData/SubDistrict.cs @@ -8,11 +8,11 @@ namespace BMA.EHR.MetaData.Service.Models { public class SubDistrict : EntityBase { - [Required, MaxLength(150), Column(Order = 1), Comment("เขต/อำเภอ")] - public string name { get; set; } = string.Empty; + [MaxLength(255), Column(Order = 1), Comment("แขวง")] + public string? name { get; set; } = null; - [Required, MaxLength(10), Column(Order = 2), Comment("รหัสไปรษณีย์")] - public string zipCode { get; set; } = string.Empty; + [MaxLength(10), Column(Order = 2), Comment("รหัสไปรษณีย์")] + public string? zipCode { get; set; } = null; // [Column(Order = 3), Comment("สถานะการใช้งาน")] // public bool IsActive { get; set; } = true; diff --git a/Requests/Recruits/RecruitDateRequest.cs b/Requests/Recruits/RecruitDateRequest.cs new file mode 100644 index 0000000..ab9d2ba --- /dev/null +++ b/Requests/Recruits/RecruitDateRequest.cs @@ -0,0 +1,7 @@ +namespace BMA.EHR.Recruit.Service.Requests.Recruits +{ + public class RecruitDateRequest + { + public DateTime AccountStartDate { get; set; } + } +} diff --git a/Requests/Recruits/RecruitPosTypeRequest.cs b/Requests/Recruits/RecruitPosTypeRequest.cs new file mode 100644 index 0000000..90d174e --- /dev/null +++ b/Requests/Recruits/RecruitPosTypeRequest.cs @@ -0,0 +1,16 @@ +namespace BMA.EHR.Recruit.Service.Requests.Recruits +{ + public class RecruitPosRequest + { + public List result { get; set; } = new(); + } + public class RecruitPosLevelRequest + { + public string posLevelName { get; set; } + public RecruitPosTypeRequest posTypes { get; set; } = new(); + } + public class RecruitPosTypeRequest + { + public string posTypeName { get; set; } + } +} diff --git a/Services/RecruitService.cs b/Services/RecruitService.cs index ec16ef0..e4b1bea 100644 --- a/Services/RecruitService.cs +++ b/Services/RecruitService.cs @@ -13,6 +13,7 @@ using System.Security.Claims; using System.Net.Http.Headers; using Newtonsoft.Json; using System.Globalization; +using BMA.EHR.Recruit.Service.Requests.Recruits; namespace BMA.EHR.Recruit.Service.Services { @@ -175,10 +176,18 @@ namespace BMA.EHR.Recruit.Service.Services await _minIOService.DeleteFileAsync(doc_id); } - public async Task UpdateAsyncRecruitToPlacement(Guid examId) + public async Task UpdateAsyncRecruitToPlacement(Guid examId, DateTime accountStartDate) { try { + // 🚀 Prepare HTTP client once + var httpClient1 = new HttpClient(); + httpClient1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token?.Replace("Bearer ", "")); + httpClient1.DefaultRequestHeaders.Add("api_key", _configuration["API_KEY"]); + var apiUrl1 = $"{_configuration["API"]}/org/pos/level"; + var response1 = await httpClient1.GetStringAsync(apiUrl1); + var posOptions = JsonConvert.DeserializeObject(response1); + var recruitImport = await _context.RecruitImports.AsQueryable() .FirstOrDefaultAsync(x => x.Id == examId); @@ -190,16 +199,23 @@ namespace BMA.EHR.Recruit.Service.Services if (_placement != null) throw new Exception("รอบการสอบนี้ได้ทำการบรรจุไปแล้ว"); + // 🚀 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(); + var placement = new Placement { Name = recruitImport.Name, RefId = recruitImport.Id, - Round = recruitImport.Order == null ? "" : recruitImport.Order.ToString(), - Year = (int)(recruitImport.Year == null ? 0 : recruitImport.Year), + Round = recruitImport.Order.ToString() ?? "", + Year = recruitImport.Year, Number = await _context.Recruits.AsQueryable().Where(x => x.RecruitImport == recruitImport).CountAsync(), - PlacementType = await _contextMetadata.PlacementTypes.FirstOrDefaultAsync(x => x.Name.Trim().ToUpper().Contains("สอบแข่งขัน")) == null ? await _contextMetadata.PlacementTypes.FirstOrDefaultAsync() : await _contextMetadata.PlacementTypes.FirstOrDefaultAsync(x => x.Name.Trim().ToUpper().Contains("สอบแข่งขัน")), - StartDate = DateTime.Now, - EndDate = DateTime.Now.AddYears(2).AddDays(-1), + 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 ?? "", @@ -208,6 +224,8 @@ namespace BMA.EHR.Recruit.Service.Services LastUpdateFullName = FullName ?? "", }; await _contextMetadata.Placements.AddAsync(placement); + + // 🚀 Load all related data with single queries var candidates = await _context.Recruits.AsQueryable() .Include(x => x.Addresses) .Include(x => x.Certificates) @@ -215,147 +233,181 @@ namespace BMA.EHR.Recruit.Service.Services .Include(x => x.Occupations) .Where(x => x.RecruitImport == recruitImport) .ToListAsync(); + + 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); + + // 🚀 Prepare HTTP client once + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token?.Replace("Bearer ", "")); + httpClient.DefaultRequestHeaders.Add("api_key", _configuration["API_KEY"]); + + // 🚀 Batch HTTP requests + var orgTasks = candidates.Select(async candidate => + { + if (string.IsNullOrWhiteSpace(candidate.CitizenId)) + return new { CitizenId = candidate.CitizenId ?? "", org = (dynamic?)null }; + + var apiUrl = $"{_configuration["API"]}/org/profile/citizenid/position/{candidate.CitizenId}"; + try + { + var response = await httpClient.GetStringAsync(apiUrl); + return new { CitizenId = candidate.CitizenId, org = JsonConvert.DeserializeObject(response) }; + } + catch + { + return new { CitizenId = candidate.CitizenId ?? "", org = (dynamic?)null }; + } + }).ToList(); + + var orgResults = await Task.WhenAll(orgTasks); + var orgDict = orgResults.ToDictionary(x => x.CitizenId ?? "", x => x.org); + + // 🚀 Prepare batch inserts + var placementProfiles = new List(); + var placementEducations = new List(); + var placementCertificates = new List(); + foreach (var candidate in candidates) { - var IsOfficer = false; - dynamic org = null; - var apiUrl = $"{_configuration["API"]}/org/profile/citizenid/position/{candidate.CitizenId}"; - using (var client = new HttpClient()) - { - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Replace("Bearer ", "")); - client.DefaultRequestHeaders.Add("api_key", _configuration["API_KEY"]); - var _req = new HttpRequestMessage(HttpMethod.Get, apiUrl); - var _res = await client.SendAsync(_req); - var _result = await _res.Content.ReadAsStringAsync(); - - org = JsonConvert.DeserializeObject(_result); - - if (org == null || org.result == null) - { - IsOfficer = false; - } - else - { - IsOfficer = true; - } - } - var Address = candidate.Addresses.FirstOrDefault() == null ? null : $"{candidate.Addresses.FirstOrDefault().Address}"; - var Moo = candidate.Addresses.FirstOrDefault() == null ? null : $" หมู่ {candidate.Addresses.FirstOrDefault().Moo}"; - var Soi = candidate.Addresses.FirstOrDefault() == null ? null : $" ซอย {candidate.Addresses.FirstOrDefault().Soi}"; - var Road = candidate.Addresses.FirstOrDefault() == null ? null : $" ถนน {candidate.Addresses.FirstOrDefault().Road}"; - var Address1 = candidate.Addresses.FirstOrDefault() == null ? null : $"{candidate.Addresses.FirstOrDefault().Address1}"; - var Moo1 = candidate.Addresses.FirstOrDefault() == null ? null : $" หมู่ {candidate.Addresses.FirstOrDefault().Moo1}"; - var Soi1 = candidate.Addresses.FirstOrDefault() == null ? null : $" ซอย {candidate.Addresses.FirstOrDefault().Soi1}"; - var Road1 = candidate.Addresses.FirstOrDefault() == null ? null : $" ถนน {candidate.Addresses.FirstOrDefault().Road1}"; - var scoreImport = await _context.ScoreImports.AsQueryable() - .FirstOrDefaultAsync(x => x.RecruitImport == recruitImport); - var recruitScore = await _context.RecruitScores.AsQueryable() - .Where(x => x.ScoreImport == scoreImport) - .Where(x => x.ExamId == candidate.ExamId) - .Where(x => x.ExamStatus == "ผ่าน") - .FirstOrDefaultAsync(x => x.ExamId == candidate.ExamId && x.ScoreImport == scoreImport); - if (recruitScore == null) + 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; + + // 🚀 Cache repeated calculations + 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); + + // หาค่า posLevelName หลังสุด + var posLevelObject = posOptions?.result?.FirstOrDefault(x => + !string.IsNullOrWhiteSpace(x.posLevelName) && + !string.IsNullOrWhiteSpace(candidate.PositionName) && + candidate.PositionName.Contains(x.posLevelName)); + + // เก็บเฉพาะค่า posLevelName + var posLevelName = posLevelObject?.posLevelName; + + // สร้างตัวแปร PositionName ที่ตัดค่า posLevelName ออก + var positionNameWithoutLevel = candidate.PositionName ?? ""; + if (!string.IsNullOrWhiteSpace(posLevelName)) + { + positionNameWithoutLevel = positionNameWithoutLevel.Replace(posLevelName, "").Trim(); + } + var placementProfile = new PlacementProfile { Placement = placement, - PositionCandidate = candidate.PositionName, - PositionType = candidate.PositionType, - PositionLevel = candidate.PositionLevel, - Prefix = candidate.Prefix, - Firstname = candidate.FirstName, - Lastname = candidate.LastName, - Gender = candidate.Gendor, - Nationality = candidate.National, - Race = candidate.Race, - Religion = candidate.Religion, + 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 = _contextOrg.province.FirstOrDefault(x => x.name == candidate.CitizenCardIssuer)?.Id ?? null, + Relationship = candidate.Marry ?? "", + CitizenId = candidate.CitizenId ?? "", + CitizenProvinceId = provincesCache.FirstOrDefault(x => x.name == candidate.CitizenCardIssuer)?.Id, CitizenDate = candidate.CitizenCardExpireDate, - Telephone = candidate?.Addresses?.FirstOrDefault()?.Telephone ?? null, - MobilePhone = candidate?.Addresses?.FirstOrDefault()?.Mobile ?? null, - RegistAddress = $"{Address}{Moo}{Soi}{Road}", - RegistProvinceId = _contextOrg.province.FirstOrDefault(x => x.name == (candidate!.Addresses!.FirstOrDefault()!.Province ?? ""))?.Id ?? null, - RegistDistrictId = _contextOrg.district.FirstOrDefault(x => x.name == (candidate!.Addresses!.FirstOrDefault()!.Amphur ?? ""))?.Id ?? null, - RegistSubDistrictId = _contextOrg.subDistrict.FirstOrDefault(x => x.name == (candidate!.Addresses!.FirstOrDefault()!.District ?? ""))?.Id ?? null, - RegistZipCode = candidate?.Addresses?.FirstOrDefault()?.ZipCode ?? null, + Telephone = firstAddress?.Telephone ?? "", + MobilePhone = firstAddress?.Mobile ?? "", + RegistAddress = registAddress ?? "", + RegistProvinceId = provincesCache.FirstOrDefault(x => x.name == firstAddress?.Province)?.Id, + RegistDistrictId = districtsCache.FirstOrDefault(x => x.name == firstAddress?.Amphur)?.Id, + RegistSubDistrictId = subDistrictsCache.FirstOrDefault(x => x.name == firstAddress?.District)?.Id, + RegistZipCode = firstAddress?.ZipCode ?? "", RegistSame = false, - CurrentAddress = $"{Address1}{Moo1}{Soi1}{Road1}", - CurrentProvinceId = _contextOrg.province.FirstOrDefault(x => x.name == (candidate!.Addresses!.FirstOrDefault()!.Province1 ?? ""))?.Id ?? null, - CurrentDistrictId = _contextOrg.district.FirstOrDefault(x => x.name == (candidate!.Addresses!.FirstOrDefault()!.Amphur1 ?? ""))?.Id ?? null, - CurrentSubDistrictId = _contextOrg.subDistrict.FirstOrDefault(x => x.name == (candidate!.Addresses!.FirstOrDefault()!.District1 ?? ""))?.Id ?? null, - CurrentZipCode = candidate?.Addresses?.FirstOrDefault()?.ZipCode1 ?? null, - Marry = candidate?.Marry?.Contains("สมรส") ?? false, - + CurrentAddress = currentAddress, + CurrentProvinceId = provincesCache.FirstOrDefault(x => x.name == firstAddress?.Province1)?.Id, + CurrentDistrictId = districtsCache.FirstOrDefault(x => x.name == firstAddress?.Amphur1)?.Id, + CurrentSubDistrictId = subDistrictsCache.FirstOrDefault(x => x.name == firstAddress?.District1)?.Id, + CurrentZipCode = firstAddress?.ZipCode1, + Marry = candidate.Marry?.Contains("สมรส") ?? false, OccupationPositionType = "other", - OccupationTelephone = candidate?.Occupations?.FirstOrDefault()?.Telephone ?? null, - OccupationPosition = candidate?.Occupations?.FirstOrDefault()?.Position ?? null, - - PointTotalA = recruitScore == null ? null : Convert.ToDouble(recruitScore.FullA), - PointA = recruitScore == null ? null : Convert.ToDouble(recruitScore.SumA), - PointTotalB = recruitScore == null ? null : Convert.ToDouble(recruitScore.FullB), - PointB = recruitScore == null ? null : Convert.ToDouble(recruitScore.SumB), - PointTotalC = recruitScore == null ? null : Convert.ToDouble(recruitScore.FullC), - PointC = recruitScore == null ? null : Convert.ToDouble(recruitScore.SumC), - ExamNumber = recruitScore == null || int.TryParse(recruitScore.Number, out int n) == false ? null : Convert.ToInt32(recruitScore.Number), + OccupationTelephone = firstOccupation?.Telephone ?? "", + OccupationPosition = firstOccupation?.Position ?? "", + PointTotalA = recruitScore.FullA, // non-nullable int + PointA = recruitScore.SumA, // non-nullable double + PointTotalB = recruitScore.FullB ?? 0, // nullable int? + PointB = recruitScore.SumB ?? 0, // nullable double? + PointTotalC = recruitScore.FullC, // non-nullable int + PointC = recruitScore.SumC, // non-nullable double + ExamNumber = !string.IsNullOrWhiteSpace(recruitScore.Number) && int.TryParse(recruitScore.Number, out int n) ? n : null, ExamRound = null, IsRelief = false, PlacementStatus = "UN-CONTAIN", - Pass = recruitScore == null ? null : recruitScore.ExamStatus, + Pass = recruitScore.ExamStatus ?? "", RemarkHorizontal = "โดยมีเงื่อนไขว่าต้องปฏิบัติงานให้กรุงเทพมหานครเป็นระยะเวลาไม่น้อยกว่า ๕ ปี นับแต่วันที่ได้รับการบรรจุและแต่งตั้ง โดยห้ามโอนไปหน่วยงานหรือส่วนราชการอื่น เว้นเเต่ลาออกจากราชการ", - Amount = org?.result?.amount ?? null, - PositionSalaryAmount = org?.result?.positionSalaryAmount ?? null, - MouthSalaryAmount = org?.result?.mouthSalaryAmount ?? null, + 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 ?? null, - IsOld = org == null || org.result == null ? false : true, - AmountOld = org?.result?.amount ?? null, - nodeOld = org?.result?.node ?? null, - nodeIdOld = org?.result?.nodeId ?? null, - posmasterIdOld = org?.result?.posmasterId ?? null, - rootOld = org?.result?.root ?? null, - rootIdOld = org?.result?.rootId ?? null, - rootShortNameOld = org?.result?.rootShortName ?? null, - child1Old = org?.result?.child1 ?? null, - child1IdOld = org?.result?.child1Id ?? null, - child1ShortNameOld = org?.result?.child1ShortName ?? null, - child2Old = org?.result?.child2 ?? null, - child2IdOld = org?.result?.child2Id ?? null, - child2ShortNameOld = org?.result?.child2ShortName ?? null, - child3Old = org?.result?.child3 ?? null, - child3IdOld = org?.result?.child3Id ?? null, - child3ShortNameOld = org?.result?.child3ShortName ?? null, - child4Old = org?.result?.child4 ?? null, - child4IdOld = org?.result?.child4Id ?? null, - child4ShortNameOld = org?.result?.child4ShortName ?? null, - orgRevisionIdOld = org?.result?.orgRevisionId ?? null, - posMasterNoOld = org?.result?.posMasterNo ?? null, - positionNameOld = org?.result?.position ?? null, - posTypeIdOld = org?.result?.posTypeId ?? null, - posTypeNameOld = org?.result?.posTypeName ?? null, - posLevelIdOld = org?.result?.posLevelId ?? null, - posLevelNameOld = org?.result?.posLevelName ?? null, + 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 ?? "", }; - await _contextMetadata.PlacementProfiles.AddAsync(placementProfile); + placementProfiles.Add(placementProfile); var placementEducation = new PlacementEducation { PlacementProfile = placementProfile, - EducationLevelId = _contextOrg.educationLevel.FirstOrDefault(x => x.name == (candidate!.Educations!.FirstOrDefault()!.HighDegree ?? ""))?.Id ?? null, - EducationLevelName = _contextOrg.educationLevel.FirstOrDefault(x => x.name == (candidate!.Educations!.FirstOrDefault()!.HighDegree ?? ""))?.name ?? null, - Field = candidate?.Educations?.FirstOrDefault()?.Major ?? null, - Gpa = candidate?.Educations?.FirstOrDefault()?.GPA!.ToString() ?? null, - Institute = candidate?.Educations?.FirstOrDefault()?.University ?? null, - Degree = candidate?.Educations?.FirstOrDefault()?.Degree ?? null, - FinishDate = candidate?.Educations?.FirstOrDefault()?.BachelorDate ?? null, + 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 ?? "", @@ -364,15 +416,15 @@ namespace BMA.EHR.Recruit.Service.Services CreatedFullName = FullName ?? "", LastUpdateFullName = FullName ?? "", }; - await _contextMetadata.PlacementEducations.AddAsync(placementEducation); + placementEducations.Add(placementEducation); var placementCertificate = new PlacementCertificate { PlacementProfile = placementProfile, - CertificateNo = candidate?.Certificates?.FirstOrDefault()?.CertificateNo ?? null, - IssueDate = candidate?.Certificates?.FirstOrDefault()?.IssueDate ?? null, - ExpireDate = candidate?.Certificates?.FirstOrDefault()?.ExpiredDate ?? null, - CertificateType = candidate?.Certificates?.FirstOrDefault()?.Description ?? null, + CertificateNo = firstCertificate?.CertificateNo ?? "", + IssueDate = firstCertificate?.IssueDate, + ExpireDate = firstCertificate?.ExpiredDate, + CertificateType = firstCertificate?.Description ?? "", CreatedAt = DateTime.Now, CreatedUserId = UserId ?? "", LastUpdatedAt = DateTime.Now, @@ -380,15 +432,34 @@ namespace BMA.EHR.Recruit.Service.Services CreatedFullName = FullName ?? "", LastUpdateFullName = FullName ?? "", }; - await _contextMetadata.PlacementCertificates.AddAsync(placementCertificate); - await _contextMetadata.SaveChangesAsync(); + placementCertificates.Add(placementCertificate); } + + // 🚀 Batch insert all records + await _contextMetadata.PlacementProfiles.AddRangeAsync(placementProfiles); + await _contextMetadata.PlacementEducations.AddRangeAsync(placementEducations); + await _contextMetadata.PlacementCertificates.AddRangeAsync(placementCertificates); + + // 🚀 Single SaveChanges at the end + await _contextMetadata.SaveChangesAsync(); + + httpClient.Dispose(); } catch { throw; } } + + private string BuildAddress(string? address, string? moo, string? soi, string? road) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(address)) parts.Add(address); + if (!string.IsNullOrWhiteSpace(moo)) parts.Add($"หมู่ {moo}"); + if (!string.IsNullOrWhiteSpace(soi)) parts.Add($"ซอย {soi}"); + if (!string.IsNullOrWhiteSpace(road)) parts.Add($"ถนน {road}"); + return string.Join(" ", parts); + } public DateTime CheckDateTime(string Date, string Formate) { // ตอนนี้ทำไว้ให้รองรับแค่ "dd/MM/yyyy", "yyyy-MM-dd"