[CLAUDE] App: golive harden — LeaveBalance concurrency + ItTicket authz-order + DocxRenderer + Travel/Vehicle tests
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s
Pre-golive verification (S56) surfaced 4 issues; all fixed + verified (228 test pass, 0 build warning). #3 LeaveBalance lost-update (DB11 concurrency): terminal-approve deduction was an in-memory read-modify-write (UsedDays += NumDays) under a bare SaveChanges, so two concurrent terminal approvals of the same (user,type,year) lost an update. Fix: atomic server-side ExecuteUpdateAsync (UsedDays = UsedDays + n) inside an explicit Serializable transaction (matches the codegen/Proposal/TravelVehicle convention; serializes the auto-create-row race too). Exactly-once guard (Status != DaGuiDuyet) intact. No migration. #5 ItTicket reassign existence-oracle: AssignItTicketHandler checked ticket-NotFound before the Admin-OR-dept-IT Forbidden guard. Reordered so authorization runs first -> fail-closed (a non-IT/non-admin caller gets Forbidden for any ticketId, existent or not). #6 DocxRenderer CS8602: null-guard MainDocumentPart + Document with clear exceptions (cleared 2 build warnings -> 0). #4 Travel/Vehicle ApproveV2: added smoke tests (Submit->Approve terminal + outsider-Forbidden) — previously zero coverage. Tests 216 -> 228 (+12). database-agent DB-layer review PASS; em-main cross-stack review clean (reviewer workflow stage did not emit StructuredOutput -> em-main covered the cross-stack review by reading every diff). Bundles agent-memory harvest (S56). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -354,10 +354,22 @@ public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser c
|
||||
{
|
||||
p.Status = WorkflowAppStatus.DaDuyet;
|
||||
p.CurrentApprovalLevelOrder = null;
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
|
||||
// P11-B: trừ phép khi duyệt cuối — chạy đúng 1 lần (DaDuyet không approve lại,
|
||||
// early guard Status != DaGuiDuyet chặn re-approve). UPSERT LeaveBalance theo năm.
|
||||
// P11-B + S56 #3 (lost-update fix): trừ phép khi duyệt cuối — chạy đúng 1 lần
|
||||
// (DaDuyet không approve lại, early guard Status != DaGuiDuyet chặn re-approve).
|
||||
// Race-free: ExecuteUpdateAsync làm increment server-side dưới row lock (2 lượt
|
||||
// duyệt cuối song song serialize, không mất update). Opinion + status + balance
|
||||
// gói trong 1 explicit transaction → atomic all-or-nothing như SaveChanges đơn cũ.
|
||||
// S56: IsolationLevel.Serializable khớp convention codebase (codegen/Proposal/
|
||||
// TravelVehicle) + serialize cả nhánh auto-create row mới (2 lượt đầu-tiên cùng key).
|
||||
var year = p.StartDate.Year;
|
||||
var dbContext = (DbContext)db;
|
||||
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||
|
||||
// STEP 1 — persist opinion-upsert + status=DaDuyet + ENSURE balance row tồn tại.
|
||||
// Auto-create: insert UsedDays=0 qua change tracker để STEP 2 increment được.
|
||||
var bal = await db.LeaveBalances.FirstOrDefaultAsync(
|
||||
b => b.UserId == p.RequesterUserId && b.LeaveTypeId == p.LeaveTypeId && b.Year == year, ct);
|
||||
if (bal is null)
|
||||
@ -377,9 +389,19 @@ public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser c
|
||||
};
|
||||
db.LeaveBalances.Add(bal);
|
||||
}
|
||||
bal.UsedDays += p.NumDays;
|
||||
bal.UpdatedAt = clock.UtcNow;
|
||||
bal.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct); // commit opinion + status + (insert row nếu mới) trong tx
|
||||
|
||||
// STEP 2 — ATOMIC server-side increment (race-free; KHÔNG qua change tracker).
|
||||
// bal tracked vẫn giữ UsedDays cũ sau dòng này — AN TOÀN vì không đọc lại bal,
|
||||
// handler return ngay. KHÔNG thêm bal.UsedDays += ... (sẽ double-count).
|
||||
await db.LeaveBalances
|
||||
.Where(b => b.UserId == p.RequesterUserId && b.LeaveTypeId == p.LeaveTypeId && b.Year == year)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(b => b.UsedDays, b => b.UsedDays + p.NumDays), ct);
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
return; // terminal branch xử lý trọn trong tx riêng — bỏ qua trailing SaveChanges
|
||||
}
|
||||
p.UpdatedAt = clock.UtcNow;
|
||||
p.UpdatedBy = cu.UserId;
|
||||
|
||||
@ -493,10 +493,10 @@ public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
|
||||
public async Task Handle(AssignItTicketCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (t is null) throw new NotFoundException("ItTicket", req.Id);
|
||||
|
||||
// S54: authz — chỉ Admin HOẶC thành viên tổ IT mới reassign (controller đã hạ [Authorize] any-auth).
|
||||
// S54 + S56 #5: authz check TRƯỚC khi tra ticket — fail-closed. Non-Admin/non-IT
|
||||
// caller nhận ForbiddenException cho MỌI ticketId (tồn tại hay không), tránh
|
||||
// existence-oracle leak (NotFound vs Forbidden tiết lộ ticket có thật).
|
||||
var itDeptId = await db.Departments.AsNoTracking()
|
||||
.Where(d => d.Code == "IT" && !d.IsDeleted)
|
||||
.Select(d => (Guid?)d.Id)
|
||||
@ -507,6 +507,9 @@ public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
|
||||
if (!isAdmin && !(itDeptId is Guid mine && myDeptId == mine))
|
||||
throw new ForbiddenException("Chỉ Admin hoặc nhân viên tổ IT mới được gán lại ticket.");
|
||||
|
||||
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (t is null) throw new NotFoundException("ItTicket", req.Id);
|
||||
|
||||
var assignee = await db.Users.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == req.AssignedToUserId && u.IsActive, ct);
|
||||
if (assignee is null) throw new NotFoundException("User", req.AssignedToUserId);
|
||||
|
||||
Reference in New Issue
Block a user