diff --git a/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/CheckInJobStatusRepository.cs b/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/CheckInJobStatusRepository.cs index 302bdd12..85c575d3 100644 --- a/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/CheckInJobStatusRepository.cs +++ b/BMA.EHR.Application/Repositories/Leaves/TimeAttendants/CheckInJobStatusRepository.cs @@ -114,6 +114,60 @@ namespace BMA.EHR.Application.Repositories.Leaves.TimeAttendants return job!; } + /// + /// ดึงข้อมูลงานที่ค้างอยู่ในสถานะ PENDING หรือ PROCESSING เกินเวลาที่กำหนด (นาที) + /// + public async Task> GetStalePendingOrProcessingJobsAsync(int timeoutMinutes = 30) + { + var cutoffDate = DateTime.Now.AddMinutes(-timeoutMinutes); + var staleJobs = await _dbContext.Set() + .Where(x => (x.Status == "PENDING" || x.Status == "PROCESSING") + && x.CreatedDate < cutoffDate) + .OrderBy(x => x.CreatedDate) + .ToListAsync(); + + return staleJobs; + } + + /// + /// ดึงข้อมูลงานที่ค้างอยู่ในสถานะ PENDING หรือ PROCESSING เกินเวลาที่กำหนด (นาที) ของ user คนใดคนหนึ่ง + /// + public async Task> GetStalePendingOrProcessingJobsByUserAsync(Guid userId, int timeoutMinutes = 30) + { + var cutoffDate = DateTime.Now.AddMinutes(-timeoutMinutes); + var staleJobs = await _dbContext.Set() + .Where(x => x.KeycloakUserId == userId + && (x.Status == "PENDING" || x.Status == "PROCESSING") + && x.CreatedDate < cutoffDate) + .OrderBy(x => x.CreatedDate) + .ToListAsync(); + + return staleJobs; + } + + /// + /// Mark งานที่ค้างเกินเวลาที่กำหนดเป็น FAILED + /// + public async Task MarkStaleJobsAsFailedAsync(int timeoutMinutes = 30) + { + var staleJobs = await GetStalePendingOrProcessingJobsAsync(timeoutMinutes); + + foreach (var job in staleJobs) + { + job.Status = "FAILED"; + job.CompletedDate = DateTime.Now; + job.ErrorMessage = $"งานค้างในสถานะ {job.Status} เกิน {timeoutMinutes} นาที ระบบทำเครื่องหมายเป็น FAILED อัตโนมัติ"; + } + + if (staleJobs.Any()) + { + _dbContext.Set().UpdateRange(staleJobs); + await _dbContext.SaveChangesAsync(); + } + + return staleJobs.Count; + } + /// /// ล้างข้อมูล Job Status ที่เก่าเกิน X วัน /// diff --git a/BMA.EHR.Leave/BMA.EHR.Leave.csproj b/BMA.EHR.Leave/BMA.EHR.Leave.csproj index 28f42590..e7c58efa 100644 --- a/BMA.EHR.Leave/BMA.EHR.Leave.csproj +++ b/BMA.EHR.Leave/BMA.EHR.Leave.csproj @@ -74,6 +74,9 @@ PreserveNewest + + PreserveNewest + diff --git a/BMA.EHR.Leave/Controllers/LeaveController.cs b/BMA.EHR.Leave/Controllers/LeaveController.cs index 3b1ec5b7..78594aef 100644 --- a/BMA.EHR.Leave/Controllers/LeaveController.cs +++ b/BMA.EHR.Leave/Controllers/LeaveController.cs @@ -536,7 +536,18 @@ namespace BMA.EHR.Leave.Service.Controllers // prepare data and convert request body and send to queue var userId = UserId == null ? Guid.Empty : Guid.Parse(UserId); var currentDate = DateTime.Now; - + + // ตรวจสอบและ mark งานเก่าที่ค้างเกิน 30 นาทีเป็น FAILED อัตโนมัติ + var staleJobs = await _checkInJobStatusRepository.GetStalePendingOrProcessingJobsByUserAsync(userId, 30); + if (staleJobs != null && staleJobs.Count > 0) + { + foreach (var staleJob in staleJobs) + { + await _checkInJobStatusRepository.UpdateToFailedAsync(staleJob.TaskId, + $"งานค้างในสถานะ {staleJob.Status} เกิน 30 นาที ระบบทำเครื่องหมายเป็น FAILED อัตโนมัติ"); + } + } + // ตรวจสอบว่ามีงานที่กำลัง pending หรือ processing อยู่หรือไม่ var existingJobs = await _checkInJobStatusRepository.GetPendingOrProcessingJobsAsync(userId); if (existingJobs != null && existingJobs.Count > 0) @@ -544,10 +555,10 @@ namespace BMA.EHR.Leave.Service.Controllers // กรองเฉพาะงานที่เป็นประเภทเดียวกัน (CHECK_IN หรือ CHECK_OUT) var checkType = data.CheckInId == null ? "CHECK_IN" : "CHECK_OUT"; var sameTypeJob = existingJobs.FirstOrDefault(j => j.CheckType == checkType); - + if (sameTypeJob != null) { - + return Error($"มีงาน {checkType} กำลังดำเนินการอยู่", StatusCodes.Status500InternalServerError); // var timeDiff = (currentDate - sameTypeJob.CreatedDate).TotalMinutes; // if (timeDiff < 2) @@ -586,7 +597,7 @@ namespace BMA.EHR.Leave.Service.Controllers LocationName = data.LocationName, Remark = data.Remark, CheckInFileName = data.Img == null ? "no-file" : data.Img.FileName, - CheckInFileBytes = checkFileBytes, + //CheckInFileBytes = checkFileBytes, Token = AccessToken ?? "" }; @@ -613,14 +624,7 @@ namespace BMA.EHR.Leave.Service.Controllers Status = "PENDING", CheckType = data.CheckInId == null ? "CHECK_IN" : "CHECK_OUT", CheckInId = data.CheckInId, - AdditionalData = JsonConvert.SerializeObject(new - { - IsLocation = data.IsLocation, - LocationName = data.LocationName, - POI = data.POI, - KeycloakId = userId, - Token = AccessToken, - }) + AdditionalData = JsonConvert.SerializeObject(checkData) }; await _checkInJobStatusRepository.AddAsync(jobStatus); @@ -727,6 +731,117 @@ namespace BMA.EHR.Leave.Service.Controllers return Success(new { count = result.Count, jobs = result }); } + /// + /// ประมวลผลงาน CheckIn ที่ค้างอยู่ในสถานะ PENDING/PROCESSING เกินเวลาที่กำหนดใหม่อีกครั้ง + /// + /// + /// เมื่อทำรายการสำเร็จ + /// ไม่ได้ Login เข้าระบบ + /// เมื่อเกิดข้อผิดพลาดในการทำงาน + [HttpPost("reprocess-stale-checkin-jobs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> ReprocessStaleCheckInJobsAsync([FromQuery] int timeoutMinutes = 30) + { + try + { + var staleJobs = await _checkInJobStatusRepository.GetStalePendingOrProcessingJobsAsync(timeoutMinutes); + + if (staleJobs == null || staleJobs.Count == 0) + { + return Success(new { message = "ไม่พบงานที่ค้างอยู่", count = 0 }); + } + + var results = new List(); + foreach (var job in staleJobs) + { + try + { + // อ่านข้อมูลเดิมจาก AdditionalData + if (string.IsNullOrEmpty(job.AdditionalData)) + { + await _checkInJobStatusRepository.UpdateToFailedAsync(job.TaskId, + "ไม่พบข้อมูลสำหรับประมวลผลซ้ำ (AdditionalData is null)"); + results.Add(new + { + taskId = job.TaskId, + keycloakUserId = job.KeycloakUserId, + checkType = job.CheckType, + createdDate = job.CreatedDate, + previousStatus = job.Status, + newStatus = "FAILED", + errorMessage = "ไม่พบข้อมูลสำหรับประมวลผลซ้ำ" + }); + continue; + } + + var checkData = JsonConvert.DeserializeObject(job.AdditionalData); + checkData.UserId = job.KeycloakUserId; + checkData.CurrentDate = job.CreatedDate; + if (checkData == null) + { + await _checkInJobStatusRepository.UpdateToFailedAsync(job.TaskId, + "ไม่สามารถอ่านข้อมูลสำหรับประมวลผลซ้ำได้"); + results.Add(new + { + taskId = job.TaskId, + keycloakUserId = job.KeycloakUserId, + checkType = job.CheckType, + createdDate = job.CreatedDate, + previousStatus = job.Status, + newStatus = "FAILED", + errorMessage = "ไม่สามารถอ่านข้อมูลสำหรับประมวลผลซ้ำได้" + }); + continue; + } + + // ตั้ง TaskId ให้ตรงกับ job เดิม + checkData.TaskId = job.TaskId; + + // เรียก ProcessCheckInAsync ด้วยข้อมูลเดิม + var processResult = await ProcessCheckInAsync(checkData); + + results.Add(new + { + taskId = job.TaskId, + keycloakUserId = job.KeycloakUserId, + checkType = job.CheckType, + createdDate = job.CreatedDate, + previousStatus = job.Status, + result = processResult + }); + } + catch (Exception ex) + { + await _checkInJobStatusRepository.UpdateToFailedAsync(job.TaskId, + $"เกิดข้อผิดพลาดในการประมวลผลซ้ำ: {ex.Message}"); + results.Add(new + { + taskId = job.TaskId, + keycloakUserId = job.KeycloakUserId, + checkType = job.CheckType, + createdDate = job.CreatedDate, + previousStatus = job.Status, + newStatus = "FAILED", + errorMessage = ex.Message + }); + } + } + + return Success(new + { + message = $"ประมวลผลซ้ำงาน {staleJobs.Count} รายการเสร็จสิ้น", + count = staleJobs.Count, + jobs = results + }); + } + catch (Exception ex) + { + return Error(ex); + } + } + [HttpGet("check-status")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -958,16 +1073,18 @@ namespace BMA.EHR.Leave.Service.Controllers var currentDate = data.CurrentDate ?? DateTime.Now; - if (data.CheckInFileName == "no-file") + if (data.CheckInFileName == "no-file") { //throw new Exception(GlobalMessages.NoFileToUpload); await _checkInJobStatusRepository.UpdateToFailedAsync(taskId, GlobalMessages.NoFileToUpload); - await _notificationService.SendNotificationAsync(data.Token, true, $"ลงเวลาไม่สำเร็จ \r\nเนื่องจาก {GlobalMessages.NoFileToUpload}\r\nกรุณาลองใหม่อีกครั้ง"); + await _notificationService.SendNotificationAsync(data.Token, true, + $"ลงเวลาไม่สำเร็จ \r\nเนื่องจาก {GlobalMessages.NoFileToUpload}\r\nกรุณาลองใหม่อีกครั้ง"); // send notification to user var noti1 = new Notification { - Body = $"ประมวลผลการลงเวลาวันที่ {currentDate.ToString("dd-MM-yyyy")} ไม่สำเร็จ \r\nเนื่องจาก {GlobalMessages.NoFileToUpload}", + Body = + $"ประมวลผลการลงเวลาวันที่ {currentDate.ToString("dd-MM-yyyy")} ไม่สำเร็จ \r\nเนื่องจาก {GlobalMessages.NoFileToUpload}", ReceiverUserId = profile.Id, Type = "", Payload = "", @@ -977,46 +1094,54 @@ namespace BMA.EHR.Leave.Service.Controllers return Error(GlobalMessages.NoFileToUpload, StatusCodes.Status400BadRequest); } - + // last check-in record var lastCheckIn = await _userTimeStampRepository.GetLastRecord(userId); var check_status = data.CheckInId == null ? "check-in-picture" : "check-out-picture"; var check_out_status = "check-out-picture"; - var fileName = $"{_bucketName}/{userId}/{currentDate.ToString("dd-MM-yyyy")}/{check_status}/{data.CheckInFileName}"; - var fileNameCheckOut = $"{_bucketName}/{userId}/{currentDate.ToString("dd-MM-yyyy")}/{check_out_status}/{data.CheckInFileName}"; - using (var ms = new MemoryStream(data.CheckInFileBytes ?? new byte[0])) + // ถ้าไม่มี CheckInFileBytes ให้ใช้ภาพ blank.jpeg แทน + var fileBytes = data.CheckInFileBytes; + if (fileBytes == null || fileBytes.Length == 0) + { + var blankPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "wwwroot", "blank.jpeg"); + fileBytes = await System.IO.File.ReadAllBytesAsync(blankPath); + data.CheckInFileName = "blank.jpeg"; + } + + var fileName = + $"{_bucketName}/{userId}/{currentDate.ToString("dd-MM-yyyy")}/{check_status}/{data.CheckInFileName}"; + var fileNameCheckOut = + $"{_bucketName}/{userId}/{currentDate.ToString("dd-MM-yyyy")}/{check_out_status}/{data.CheckInFileName}"; + using (var ms = new MemoryStream(fileBytes)) { try { await _minIOService.UploadFileAsync(fileName, ms); - // if (lastCheckIn != null && lastCheckIn.CheckOut == null) - // { - // // ยังไม่เคย check-out มาก่อน หรือ check-out เป็น null ให้ใช้ชื่อไฟล์แบบ check-out - // await _minIOService.UploadFileAsync(fileNameCheckOut, ms); - // } } catch (Exception ex) { - await _checkInJobStatusRepository.UpdateToFailedAsync(taskId, $"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}"); - await _notificationService.SendNotificationAsync(data.Token, true, $"ลงเวลาไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}\r\nกรุณาลองใหม่อีกครั้ง"); + await _checkInJobStatusRepository.UpdateToFailedAsync(taskId, + $"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}"); + await _notificationService.SendNotificationAsync(data.Token, true, + $"ลงเวลาไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}\r\nกรุณาลองใหม่อีกครั้ง"); // send notification to user - var noti1 = new Notification + var noti2 = new Notification { - Body = $"ประมวลผลการลงเวลาวันที่ {currentDate.ToString("dd-MM-yyyy")} ไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}", + Body = + $"ประมวลผลการลงเวลาวันที่ {currentDate.ToString("dd-MM-yyyy")} ไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}", ReceiverUserId = profile.Id, Type = "", Payload = "", }; - _appDbContext.Set().Add(noti1); + _appDbContext.Set().Add(noti2); await _appDbContext.SaveChangesAsync(); - - return Error($"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}", StatusCodes.Status500InternalServerError); + return Error($"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}", + StatusCodes.Status500InternalServerError); } - } if (lastCheckIn != null && lastCheckIn.CheckOut == null) @@ -1026,36 +1151,32 @@ namespace BMA.EHR.Leave.Service.Controllers try { await _minIOService.UploadFileAsync(fileNameCheckOut, ms2); - // if (lastCheckIn != null && lastCheckIn.CheckOut == null) - // { - // // ยังไม่เคย check-out มาก่อน หรือ check-out เป็น null ให้ใช้ชื่อไฟล์แบบ check-out - // await _minIOService.UploadFileAsync(fileNameCheckOut, ms); - // } } catch (Exception ex) { - await _checkInJobStatusRepository.UpdateToFailedAsync(taskId, $"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}"); - await _notificationService.SendNotificationAsync(data.Token, true, $"ลงเวลาไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}\r\nกรุณาลองใหม่อีกครั้ง"); + await _checkInJobStatusRepository.UpdateToFailedAsync(taskId, + $"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}"); + await _notificationService.SendNotificationAsync(data.Token, true, + $"ลงเวลาไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}\r\nกรุณาลองใหม่อีกครั้ง"); // send notification to user - var noti1 = new Notification + var noti3 = new Notification { - Body = $"ประมวลผลการลงเวลาวันที่ {currentDate.ToString("dd-MM-yyyy")} ไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}", + Body = + $"ประมวลผลการลงเวลาวันที่ {currentDate.ToString("dd-MM-yyyy")} ไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}", ReceiverUserId = profile.Id, Type = "", Payload = "", }; - _appDbContext.Set().Add(noti1); + _appDbContext.Set().Add(noti3); await _appDbContext.SaveChangesAsync(); - - return Error($"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}", StatusCodes.Status500InternalServerError); + return Error($"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}", + StatusCodes.Status500InternalServerError); } - } } - var defaultRound = await _dutyTimeRepository.GetDefaultAsync(); if (defaultRound == null) { diff --git a/BMA.EHR.Leave/Program.cs b/BMA.EHR.Leave/Program.cs index 9d42133e..3dd83203 100644 --- a/BMA.EHR.Leave/Program.cs +++ b/BMA.EHR.Leave/Program.cs @@ -198,12 +198,20 @@ if (manager != null) // ทำความสะอาดข้อมูล CheckIn Job Status ที่เก่ากว่า 30 วัน - รันทุกวันเวลา 02:00 น. manager.AddOrUpdate("ทำความสะอาดข้อมูล CheckIn Job Status", Job.FromExpression(x => x.CleanupOldJobsAsync(30)), "0 2 * * *", bangkokTimeZone); - manager.AddOrUpdate("ประมวลผลงานที่ค้างอยู่ในสถานะ Pending หรือ Processing", Job.FromExpression(x => x.ProcessPendingJobsAsync()), "0 3 * * *", - new RecurringJobOptions - { - TimeZone = bangkokTimeZone, - QueueName = "leave" // ← กำหนด queue - }); + // ตรวจสอบและ mark งาน CheckIn ที่ค้างเกิน 30 นาทีเป็น FAILED - รันทุก 15 นาที + // manager.AddOrUpdate("ตรวจสอบงาน CheckIn ที่ค้างเกินเวลา", Job.FromExpression(x => x.MarkStaleJobsAsFailedAsync(30)), "*/15 * * * *", + // new RecurringJobOptions + // { + // TimeZone = bangkokTimeZone, + // QueueName = "leave" + // }); + // + // manager.AddOrUpdate("ประมวลผลงานที่ค้างอยู่ในสถานะ Pending หรือ Processing", Job.FromExpression(x => x.ProcessPendingJobsAsync()), "0 3 * * *", + // new RecurringJobOptions + // { + // TimeZone = bangkokTimeZone, + // QueueName = "leave" // ← กำหนด queue + // }); } // apply migrations diff --git a/BMA.EHR.Leave/wwwroot/blank.jpeg b/BMA.EHR.Leave/wwwroot/blank.jpeg new file mode 100644 index 00000000..2b153486 Binary files /dev/null and b/BMA.EHR.Leave/wwwroot/blank.jpeg differ