ปรับให้เก็บข้อมูลเพิ่มเติมใน CheckinJobStatus

This commit is contained in:
Suphonchai Phoonsawat 2026-05-25 14:19:44 +07:00
parent 2146e0e0ca
commit f6bf1ab026
5 changed files with 238 additions and 52 deletions

View file

@ -114,6 +114,60 @@ namespace BMA.EHR.Application.Repositories.Leaves.TimeAttendants
return job!; return job!;
} }
/// <summary>
/// ดึงข้อมูลงานที่ค้างอยู่ในสถานะ PENDING หรือ PROCESSING เกินเวลาที่กำหนด (นาที)
/// </summary>
public async Task<List<CheckInJobStatus>> GetStalePendingOrProcessingJobsAsync(int timeoutMinutes = 30)
{
var cutoffDate = DateTime.Now.AddMinutes(-timeoutMinutes);
var staleJobs = await _dbContext.Set<CheckInJobStatus>()
.Where(x => (x.Status == "PENDING" || x.Status == "PROCESSING")
&& x.CreatedDate < cutoffDate)
.OrderBy(x => x.CreatedDate)
.ToListAsync();
return staleJobs;
}
/// <summary>
/// ดึงข้อมูลงานที่ค้างอยู่ในสถานะ PENDING หรือ PROCESSING เกินเวลาที่กำหนด (นาที) ของ user คนใดคนหนึ่ง
/// </summary>
public async Task<List<CheckInJobStatus>> GetStalePendingOrProcessingJobsByUserAsync(Guid userId, int timeoutMinutes = 30)
{
var cutoffDate = DateTime.Now.AddMinutes(-timeoutMinutes);
var staleJobs = await _dbContext.Set<CheckInJobStatus>()
.Where(x => x.KeycloakUserId == userId
&& (x.Status == "PENDING" || x.Status == "PROCESSING")
&& x.CreatedDate < cutoffDate)
.OrderBy(x => x.CreatedDate)
.ToListAsync();
return staleJobs;
}
/// <summary>
/// Mark งานที่ค้างเกินเวลาที่กำหนดเป็น FAILED
/// </summary>
public async Task<int> 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<CheckInJobStatus>().UpdateRange(staleJobs);
await _dbContext.SaveChangesAsync();
}
return staleJobs.Count;
}
/// <summary> /// <summary>
/// ล้างข้อมูล Job Status ที่เก่าเกิน X วัน /// ล้างข้อมูล Job Status ที่เก่าเกิน X วัน
/// </summary> /// </summary>

View file

@ -74,6 +74,9 @@
<Content Update="wwwroot\keycloak.json"> <Content Update="wwwroot\keycloak.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Update="wwwroot\blank.jpeg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -536,7 +536,18 @@ namespace BMA.EHR.Leave.Service.Controllers
// prepare data and convert request body and send to queue // prepare data and convert request body and send to queue
var userId = UserId == null ? Guid.Empty : Guid.Parse(UserId); var userId = UserId == null ? Guid.Empty : Guid.Parse(UserId);
var currentDate = DateTime.Now; 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 อยู่หรือไม่ // ตรวจสอบว่ามีงานที่กำลัง pending หรือ processing อยู่หรือไม่
var existingJobs = await _checkInJobStatusRepository.GetPendingOrProcessingJobsAsync(userId); var existingJobs = await _checkInJobStatusRepository.GetPendingOrProcessingJobsAsync(userId);
if (existingJobs != null && existingJobs.Count > 0) if (existingJobs != null && existingJobs.Count > 0)
@ -544,10 +555,10 @@ namespace BMA.EHR.Leave.Service.Controllers
// กรองเฉพาะงานที่เป็นประเภทเดียวกัน (CHECK_IN หรือ CHECK_OUT) // กรองเฉพาะงานที่เป็นประเภทเดียวกัน (CHECK_IN หรือ CHECK_OUT)
var checkType = data.CheckInId == null ? "CHECK_IN" : "CHECK_OUT"; var checkType = data.CheckInId == null ? "CHECK_IN" : "CHECK_OUT";
var sameTypeJob = existingJobs.FirstOrDefault(j => j.CheckType == checkType); var sameTypeJob = existingJobs.FirstOrDefault(j => j.CheckType == checkType);
if (sameTypeJob != null) if (sameTypeJob != null)
{ {
return Error($"มีงาน {checkType} กำลังดำเนินการอยู่", StatusCodes.Status500InternalServerError); return Error($"มีงาน {checkType} กำลังดำเนินการอยู่", StatusCodes.Status500InternalServerError);
// var timeDiff = (currentDate - sameTypeJob.CreatedDate).TotalMinutes; // var timeDiff = (currentDate - sameTypeJob.CreatedDate).TotalMinutes;
// if (timeDiff < 2) // if (timeDiff < 2)
@ -586,7 +597,7 @@ namespace BMA.EHR.Leave.Service.Controllers
LocationName = data.LocationName, LocationName = data.LocationName,
Remark = data.Remark, Remark = data.Remark,
CheckInFileName = data.Img == null ? "no-file" : data.Img.FileName, CheckInFileName = data.Img == null ? "no-file" : data.Img.FileName,
CheckInFileBytes = checkFileBytes, //CheckInFileBytes = checkFileBytes,
Token = AccessToken ?? "" Token = AccessToken ?? ""
}; };
@ -613,14 +624,7 @@ namespace BMA.EHR.Leave.Service.Controllers
Status = "PENDING", Status = "PENDING",
CheckType = data.CheckInId == null ? "CHECK_IN" : "CHECK_OUT", CheckType = data.CheckInId == null ? "CHECK_IN" : "CHECK_OUT",
CheckInId = data.CheckInId, CheckInId = data.CheckInId,
AdditionalData = JsonConvert.SerializeObject(new AdditionalData = JsonConvert.SerializeObject(checkData)
{
IsLocation = data.IsLocation,
LocationName = data.LocationName,
POI = data.POI,
KeycloakId = userId,
Token = AccessToken,
})
}; };
await _checkInJobStatusRepository.AddAsync(jobStatus); await _checkInJobStatusRepository.AddAsync(jobStatus);
@ -727,6 +731,117 @@ namespace BMA.EHR.Leave.Service.Controllers
return Success(new { count = result.Count, jobs = result }); return Success(new { count = result.Count, jobs = result });
} }
/// <summary>
/// ประมวลผลงาน CheckIn ที่ค้างอยู่ในสถานะ PENDING/PROCESSING เกินเวลาที่กำหนดใหม่อีกครั้ง
/// </summary>
/// <returns></returns>
/// <response code="200">เมื่อทำรายการสำเร็จ</response>
/// <response code="401">ไม่ได้ Login เข้าระบบ</response>
/// <response code="500">เมื่อเกิดข้อผิดพลาดในการทำงาน</response>
[HttpPost("reprocess-stale-checkin-jobs")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<ResponseObject>> 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<object>();
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<CheckTimeDtoRB>(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")] [HttpGet("check-status")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
@ -958,16 +1073,18 @@ namespace BMA.EHR.Leave.Service.Controllers
var currentDate = data.CurrentDate ?? DateTime.Now; var currentDate = data.CurrentDate ?? DateTime.Now;
if (data.CheckInFileName == "no-file") if (data.CheckInFileName == "no-file")
{ {
//throw new Exception(GlobalMessages.NoFileToUpload); //throw new Exception(GlobalMessages.NoFileToUpload);
await _checkInJobStatusRepository.UpdateToFailedAsync(taskId, 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 // send notification to user
var noti1 = new Notification 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, ReceiverUserId = profile.Id,
Type = "", Type = "",
Payload = "", Payload = "",
@ -977,46 +1094,54 @@ namespace BMA.EHR.Leave.Service.Controllers
return Error(GlobalMessages.NoFileToUpload, StatusCodes.Status400BadRequest); return Error(GlobalMessages.NoFileToUpload, StatusCodes.Status400BadRequest);
} }
// last check-in record // last check-in record
var lastCheckIn = await _userTimeStampRepository.GetLastRecord(userId); var lastCheckIn = await _userTimeStampRepository.GetLastRecord(userId);
var check_status = data.CheckInId == null ? "check-in-picture" : "check-out-picture"; var check_status = data.CheckInId == null ? "check-in-picture" : "check-out-picture";
var check_out_status = "check-out-picture"; var check_out_status = "check-out-picture";
var fileName = $"{_bucketName}/{userId}/{currentDate.ToString("dd-MM-yyyy")}/{check_status}/{data.CheckInFileName}"; // ถ้าไม่มี CheckInFileBytes ให้ใช้ภาพ blank.jpeg แทน
var fileNameCheckOut = $"{_bucketName}/{userId}/{currentDate.ToString("dd-MM-yyyy")}/{check_out_status}/{data.CheckInFileName}"; var fileBytes = data.CheckInFileBytes;
using (var ms = new MemoryStream(data.CheckInFileBytes ?? new byte[0])) 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 try
{ {
await _minIOService.UploadFileAsync(fileName, ms); 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) catch (Exception ex)
{ {
await _checkInJobStatusRepository.UpdateToFailedAsync(taskId, $"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}"); await _checkInJobStatusRepository.UpdateToFailedAsync(taskId,
await _notificationService.SendNotificationAsync(data.Token, true, $"ลงเวลาไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}\r\nกรุณาลองใหม่อีกครั้ง"); $"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}");
await _notificationService.SendNotificationAsync(data.Token, true,
$"ลงเวลาไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}\r\nกรุณาลองใหม่อีกครั้ง");
// send notification to user // 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, ReceiverUserId = profile.Id,
Type = "", Type = "",
Payload = "", Payload = "",
}; };
_appDbContext.Set<Notification>().Add(noti1); _appDbContext.Set<Notification>().Add(noti2);
await _appDbContext.SaveChangesAsync(); await _appDbContext.SaveChangesAsync();
return Error($"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}",
return Error($"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}", StatusCodes.Status500InternalServerError); StatusCodes.Status500InternalServerError);
} }
} }
if (lastCheckIn != null && lastCheckIn.CheckOut == null) if (lastCheckIn != null && lastCheckIn.CheckOut == null)
@ -1026,36 +1151,32 @@ namespace BMA.EHR.Leave.Service.Controllers
try try
{ {
await _minIOService.UploadFileAsync(fileNameCheckOut, ms2); 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) catch (Exception ex)
{ {
await _checkInJobStatusRepository.UpdateToFailedAsync(taskId, $"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}"); await _checkInJobStatusRepository.UpdateToFailedAsync(taskId,
await _notificationService.SendNotificationAsync(data.Token, true, $"ลงเวลาไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}\r\nกรุณาลองใหม่อีกครั้ง"); $"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}");
await _notificationService.SendNotificationAsync(data.Token, true,
$"ลงเวลาไม่สำเร็จ \r\nเนื่องจากไม่สามารถอัปโหลดรูปภาพได้ {ex.Message}\r\nกรุณาลองใหม่อีกครั้ง");
// send notification to user // 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, ReceiverUserId = profile.Id,
Type = "", Type = "",
Payload = "", Payload = "",
}; };
_appDbContext.Set<Notification>().Add(noti1); _appDbContext.Set<Notification>().Add(noti3);
await _appDbContext.SaveChangesAsync(); await _appDbContext.SaveChangesAsync();
return Error($"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}",
return Error($"ไม่สามารถอัปโหลดรูปภาพได้: {ex.Message}", StatusCodes.Status500InternalServerError); StatusCodes.Status500InternalServerError);
} }
} }
} }
var defaultRound = await _dutyTimeRepository.GetDefaultAsync(); var defaultRound = await _dutyTimeRepository.GetDefaultAsync();
if (defaultRound == null) if (defaultRound == null)
{ {

View file

@ -198,12 +198,20 @@ if (manager != null)
// ทำความสะอาดข้อมูล CheckIn Job Status ที่เก่ากว่า 30 วัน - รันทุกวันเวลา 02:00 น. // ทำความสะอาดข้อมูล CheckIn Job Status ที่เก่ากว่า 30 วัน - รันทุกวันเวลา 02:00 น.
manager.AddOrUpdate("ทำความสะอาดข้อมูล CheckIn Job Status", Job.FromExpression<CheckInJobStatusRepository>(x => x.CleanupOldJobsAsync(30)), "0 2 * * *", bangkokTimeZone); manager.AddOrUpdate("ทำความสะอาดข้อมูล CheckIn Job Status", Job.FromExpression<CheckInJobStatusRepository>(x => x.CleanupOldJobsAsync(30)), "0 2 * * *", bangkokTimeZone);
manager.AddOrUpdate("ประมวลผลงานที่ค้างอยู่ในสถานะ Pending หรือ Processing", Job.FromExpression<LeaveProcessJobStatusRepository>(x => x.ProcessPendingJobsAsync()), "0 3 * * *", // ตรวจสอบและ mark งาน CheckIn ที่ค้างเกิน 30 นาทีเป็น FAILED - รันทุก 15 นาที
new RecurringJobOptions // manager.AddOrUpdate("ตรวจสอบงาน CheckIn ที่ค้างเกินเวลา", Job.FromExpression<CheckInJobStatusRepository>(x => x.MarkStaleJobsAsFailedAsync(30)), "*/15 * * * *",
{ // new RecurringJobOptions
TimeZone = bangkokTimeZone, // {
QueueName = "leave" // ← กำหนด queue // TimeZone = bangkokTimeZone,
}); // QueueName = "leave"
// });
//
// manager.AddOrUpdate("ประมวลผลงานที่ค้างอยู่ในสถานะ Pending หรือ Processing", Job.FromExpression<LeaveProcessJobStatusRepository>(x => x.ProcessPendingJobsAsync()), "0 3 * * *",
// new RecurringJobOptions
// {
// TimeZone = bangkokTimeZone,
// QueueName = "leave" // ← กำหนด queue
// });
} }
// apply migrations // apply migrations

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB