issue #2551
All checks were successful
Build & Deploy Leave Service / build (push) Successful in 2m58s

This commit is contained in:
Suphonchai Phoonsawat 2026-06-19 10:51:18 +07:00
parent 71966eb4e9
commit 2cdae3578e
7 changed files with 1996 additions and 40 deletions

View file

@ -9,6 +9,7 @@ using BMA.EHR.Domain.Shared;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Collections.Concurrent;
namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
{
@ -23,6 +24,12 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
private readonly IConfiguration _configuration;
private readonly EmailSenderService _emailSenderService;
/// <summary>
/// Keyed locks to serialize get-or-create for LeaveBeginning rows by (ProfileId, LeaveYear, LeaveTypeId).
/// Prevents duplicate inserts when concurrent requests (e.g. UI calling /user/check twice) hit the same key.
/// </summary>
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _getOrAddLocks = new();
#endregion
#region " Constructor and Destuctor "
@ -121,6 +128,27 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
await _dbContext.SaveChangesAsync();
}
public async Task ProcessEarlyLeaveRequest(int year)
{
// Get Early Leave Request
var leaveReq = await _dbContext.Set<LeaveRequest>()
.Include(x => x.Type)
.Where(x => x.LeaveStatus == "APPROVE")
.Where(x => x.LeaveStartDate.Year == year || x.LeaveEndDate.Year == year)
.ToListAsync();
foreach (var leave in leaveReq)
{
await GetByYearAndTypeIdForUserAsync(year, leave.Type.Id, leave.KeycloakUserId);
}
}
public async Task ProcessEarlyLeaveRequestSchedule()
{
int year = DateTime.Now.Year;
await ProcessEarlyLeaveRequest(year);
}
public async Task<LeaveBeginning?> GetByYearAndTypeIdForUserAsync(int year, Guid typeId, Guid userId)
{
// var pf = await _userProfileRepository.GetProfileByKeycloakIdAsync(userId, AccessToken);
@ -134,17 +162,13 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
var leaveType = await _dbContext.Set<LeaveType>().FirstOrDefaultAsync(x => x.Id == typeId);
var data = await _dbContext.Set<LeaveBeginning>()
.Include(x => x.LeaveType)
.FirstOrDefaultAsync(x => x.LeaveYear == year && x.LeaveTypeId == typeId && x.ProfileId == pf.Id);
if (data == null)
LeaveBeginning Factory()
{
var limit = 0.0;
var prev = await _dbContext.Set<LeaveBeginning>()
var prev = _dbContext.Set<LeaveBeginning>()
.Include(x => x.LeaveType)
.FirstOrDefaultAsync(x => x.LeaveYear == year - 1 && x.LeaveTypeId == typeId && x.ProfileId == pf.Id);
.FirstOrDefault(x => x.LeaveYear == year - 1 && x.LeaveTypeId == typeId && x.ProfileId == pf.Id);
var prevRemain = 0.0;
if (prev != null)
@ -170,7 +194,7 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
limit = 0.0;
}
data = new LeaveBeginning
return new LeaveBeginning
{
LeaveYear = year,
LeaveTypeId = typeId,
@ -186,12 +210,9 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
Child3DnaId = pf.Child3DnaId,
Child4DnaId = pf.Child4DnaId
};
_dbContext.Set<LeaveBeginning>().Add(data);
await _dbContext.SaveChangesAsync();
}
return data;
return await GetOrAddForUserAsync(year, typeId, pf.Id, Factory);
}
public async Task<LeaveBeginning?> GetByYearAndTypeIdForUser(int year, Guid typeId, GetProfileByKeycloakIdDto? pf)
@ -200,17 +221,13 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
var leaveType = await _dbContext.Set<LeaveType>().FirstOrDefaultAsync(x => x.Id == typeId);
var data = await _dbContext.Set<LeaveBeginning>()
.Include(x => x.LeaveType)
.FirstOrDefaultAsync(x => x.LeaveYear == year && x.LeaveTypeId == typeId && x.ProfileId == pf.Id);
if (data == null)
LeaveBeginning Factory()
{
var limit = 0.0;
var prev = await _dbContext.Set<LeaveBeginning>()
var prev = _dbContext.Set<LeaveBeginning>()
.Include(x => x.LeaveType)
.FirstOrDefaultAsync(x => x.LeaveYear == year - 1 && x.LeaveTypeId == typeId && x.ProfileId == pf.Id);
.FirstOrDefault(x => x.LeaveYear == year - 1 && x.LeaveTypeId == typeId && x.ProfileId == pf.Id);
var prevRemain = 0.0;
if (prev != null)
@ -236,7 +253,7 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
limit = 0.0;
}
data = new LeaveBeginning
return new LeaveBeginning
{
LeaveYear = year,
LeaveTypeId = typeId,
@ -252,12 +269,9 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
Child3DnaId = pf.Child3DnaId,
Child4DnaId = pf.Child4DnaId
};
_dbContext.Set<LeaveBeginning>().Add(data);
await _dbContext.SaveChangesAsync();
}
return data;
return await GetOrAddForUserAsync(year, typeId, pf.Id, Factory);
}
public async Task<LeaveBeginning?> GetByYearAndTypeIdForUser2Async(int year, Guid typeId, Guid userId)
@ -273,17 +287,13 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
var leaveType = await _dbContext.Set<LeaveType>().FirstOrDefaultAsync(x => x.Id == typeId);
var data = await _dbContext.Set<LeaveBeginning>()
.Include(x => x.LeaveType)
.FirstOrDefaultAsync(x => x.LeaveYear == year && x.LeaveTypeId == typeId && x.ProfileId == pf.Id);
if (data == null)
LeaveBeginning Factory()
{
var limit = 0.0;
var prev = await _dbContext.Set<LeaveBeginning>()
var prev = _dbContext.Set<LeaveBeginning>()
.Include(x => x.LeaveType)
.FirstOrDefaultAsync(x => x.LeaveYear == year - 1 && x.LeaveTypeId == typeId && x.ProfileId == pf.Id);
.FirstOrDefault(x => x.LeaveYear == year - 1 && x.LeaveTypeId == typeId && x.ProfileId == pf.Id);
var prevRemain = 0.0;
if (prev != null)
@ -309,7 +319,7 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
limit = 0.0;
}
data = new LeaveBeginning
return new LeaveBeginning
{
LeaveYear = year,
LeaveTypeId = typeId,
@ -325,17 +335,59 @@ namespace BMA.EHR.Application.Repositories.Leaves.LeaveRequests
Child3DnaId = pf.Child3DnaId,
Child4DnaId = pf.Child4DnaId
};
_dbContext.Set<LeaveBeginning>().Add(data);
await _dbContext.SaveChangesAsync();
}
return data;
return await GetOrAddForUserAsync(year, typeId, pf.Id, Factory);
}
/// <summary>
/// Get-or-create a LeaveBeginning row for (ProfileId, LeaveYear, LeaveTypeId) with concurrency protection.
/// Uses a keyed SemaphoreSlim to serialize within-process requests, and re-queries after acquiring the lock.
/// If a cross-process insert wins (unique index violation), the duplicate key exception is caught and the row
/// created by the winner is returned.
/// </summary>
private async Task<LeaveBeginning?> GetOrAddForUserAsync(int year, Guid typeId, Guid profileId, Func<LeaveBeginning> factory)
{
var key = $"{profileId}_{year}_{typeId}";
var semaphore = _getOrAddLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync();
try
{
// Re-query inside the lock — another thread may have created it while we waited.
var existing = await _dbContext.Set<LeaveBeginning>()
.Include(x => x.LeaveType)
.FirstOrDefaultAsync(x => x.LeaveYear == year && x.LeaveTypeId == typeId && x.ProfileId == profileId);
if (existing != null)
{
return existing;
}
var entity = factory();
_dbContext.Set<LeaveBeginning>().Add(entity);
try
{
await _dbContext.SaveChangesAsync();
return entity;
}
catch (DbUpdateException)
{
// Cross-process/cross-server race hit the unique index (IX_LeaveBeginnings_ProfileId_LeaveYear_LeaveTypeId).
// Detach the failed insert and return the row created by the winner.
_dbContext.Detach(entity);
var winner = await _dbContext.Set<LeaveBeginning>()
.Include(x => x.LeaveType)
.FirstOrDefaultAsync(x => x.LeaveYear == year && x.LeaveTypeId == typeId && x.ProfileId == profileId);
return winner;
}
}
finally
{
semaphore.Release();
}
}
public async Task<List<LeaveBeginning>> GetAllByYearAndTypeAsync(int year, Guid typeId, List<ProfileData> userIdList)
{
var updateList = new List<LeaveBeginning>();
var result = new List<LeaveBeginning>();

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BMA.EHR.Infrastructure.Migrations.LeaveDb
{
/// <inheritdoc />
public partial class AddUniqueIndexLeaveBeginningProfileIdYearTypeId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_LeaveBeginnings_ProfileId_LeaveYear_LeaveTypeId",
table: "LeaveBeginnings",
columns: new[] { "ProfileId", "LeaveYear", "LeaveTypeId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_LeaveBeginnings_ProfileId_LeaveYear_LeaveTypeId",
table: "LeaveBeginnings");
}
}
}

View file

@ -226,6 +226,10 @@ namespace BMA.EHR.Infrastructure.Migrations.LeaveDb
b.HasIndex("LeaveTypeId");
b.HasIndex("ProfileId", "LeaveYear", "LeaveTypeId")
.IsUnique()
.HasDatabaseName("IX_LeaveBeginnings_ProfileId_LeaveYear_LeaveTypeId");
b.ToTable("LeaveBeginnings");
});

View file

@ -61,5 +61,18 @@ namespace BMA.EHR.Infrastructure.Persistence
{
base.Entry(entity).State = EntityState.Detached;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Composite unique index on the natural key for LeaveBeginning.
// Prevents duplicate rows when concurrent requests (e.g. UI calling /user/check twice)
// race through the get-or-create flow in LeaveBeginningRepository.
modelBuilder.Entity<LeaveBeginning>()
.HasIndex(b => new { b.ProfileId, b.LeaveYear, b.LeaveTypeId })
.HasDatabaseName("IX_LeaveBeginnings_ProfileId_LeaveYear_LeaveTypeId")
.IsUnique();
}
}
}

View file

@ -1016,6 +1016,30 @@ namespace BMA.EHR.Leave.Service.Controllers
});
}
/// <summary>
/// ทดสอบประมวลผล beginning
/// </summary>
/// <param name="year"></param>
/// <returns></returns>
[HttpGet("process-beginning/{year:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[AllowAnonymous]
public async Task<ActionResult<ResponseObject>> ProcessBeginningByYearAsync([FromRoute] int year)
{
try
{
await _leaveBeginningRepository.ProcessEarlyLeaveRequest(year);
return Success();
}
catch (Exception e)
{
return Error(e);
}
}
/// <summary>
/// LV2_003 - เช็คการยืนขอลา (USER)
/// </summary>

View file

@ -18,6 +18,7 @@ using System.Text;
using Hangfire;
using Hangfire.MySql;
using System.Transactions;
using BMA.EHR.Application.Repositories.Leaves.LeaveRequests;
using BMA.EHR.Leave.Service.Filters;
using Hangfire.Common;
using BMA.EHR.Application.Repositories.Leaves.TimeAttendants;
@ -194,10 +195,28 @@ app.UseHangfireDashboard("/hangfire", new DashboardOptions()
var manager = new RecurringJobManager();
if (manager != null)
{
manager.AddOrUpdate("ปรับปรุงรอบการลงเวลาทำงาน", Job.FromExpression<UserDutyTimeRepository>(x => x.UpdateUserDutyTime()), "0 1 * * *", bangkokTimeZone);
manager.AddOrUpdate("ปรับปรุงรอบการลงเวลาทำงาน", Job.FromExpression<UserDutyTimeRepository>(x => x.UpdateUserDutyTime()), "0 1 * * *",
new RecurringJobOptions
{
TimeZone = bangkokTimeZone,
QueueName = "leave"
});
// ทำความสะอาดข้อมูล 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 * * *",
new RecurringJobOptions
{
TimeZone = bangkokTimeZone,
QueueName = "leave"
});
manager.AddOrUpdate("Proceess Beginning สำหรับการลาล่วงหน้า", Job.FromExpression<LeaveBeginningRepository>(x => x.ProcessEarlyLeaveRequestSchedule()), "0 1 1 10 *",
new RecurringJobOptions
{
TimeZone = bangkokTimeZone,
QueueName = "leave"
});
// ตรวจสอบและ mark งาน CheckIn ที่ค้างเกิน 30 นาทีเป็น FAILED - รันทุก 15 นาที
// manager.AddOrUpdate("ตรวจสอบงาน CheckIn ที่ค้างเกินเวลา", Job.FromExpression<CheckInJobStatusRepository>(x => x.MarkStaleJobsAsFailedAsync(30)), "*/15 * * * *",
// new RecurringJobOptions