[CLAUDE] Office: P11-E AttendanceReport+Excel+OtPolicy + P11-F MaTicket codegen (Wave 1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m10s

P11-F: MaTicket gen-on-create qua WorkflowAppCodeGen (IT/2026/NNN Serializable atomic, kanban no-workflow). P11-E: GetAttendanceReportQuery monthly aggregate (day-type weekday/weekend/holiday OT x OtPolicy multiplier in-memory) + AttendanceReportExcelExporter (ClosedXML) + 2 endpoint Admin-only + fe-admin AttendanceReportPage. Migration-free. +5 test (186->191). reviewer PASS (gotcha #44 role-string verified, 0 blocker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-08 12:34:48 +07:00
parent e9ee97fb3b
commit 6a664298fa
17 changed files with 719 additions and 7 deletions

View File

@ -0,0 +1,107 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
namespace SolutionErp.Application.Office;
// Phase 11 P11-E (S?? 2026-06-08) — Báo cáo chấm công tháng + OT quy đổi theo OtPolicy.
// Aggregate Attendance theo tháng × phòng ban, phân loại OT thường/cuối tuần/lễ,
// nhân hệ số OtPolicy active → OT quy đổi (weighted). Admin-only report (controller guard).
//
// GOTCHA day-type (S?? em main spec): phân loại day-type dùng DayOfWeek + holidaySet
// KHÔNG EF-translate được → .ToListAsync() rồi classify IN-MEMORY (C#). KHÔNG để trong IQueryable.
// GOTCHA Holiday.Date = DateOnly (KHÔNG phải DateTime) → HashSet<DateOnly> + so qua
// DateOnly.FromDateTime(a.AttendanceDate) (spec viết HashSet<DateTime> nhầm type — dùng DateOnly đúng).
public record AttendanceReportRowDto(Guid UserId, string FullName, string? DepartmentName, int DaysPresent,
decimal TotalWorkHours, decimal OtRaw, decimal OtWeekday, decimal OtWeekend, decimal OtHoliday, decimal OtWeighted);
public record AttendanceReportDto(int Year, int Month, IReadOnlyList<AttendanceReportRowDto> Rows,
decimal GrandTotalWorkHours, decimal GrandTotalOtWeighted);
public record GetAttendanceReportQuery(int Year, int Month, Guid? DepartmentId) : IRequest<AttendanceReportDto>;
public class GetAttendanceReportHandler(IApplicationDbContext db)
: IRequestHandler<GetAttendanceReportQuery, AttendanceReportDto>
{
public async Task<AttendanceReportDto> Handle(GetAttendanceReportQuery q, CancellationToken ct)
{
// 1. OtPolicy active → hệ số nhân (fallback 1.5 / 2.0 / 3.0 nếu chưa cấu hình).
var policy = await db.OtPolicies.AsNoTracking()
.FirstOrDefaultAsync(p => p.IsActive && !p.IsDeleted, ct);
var mWd = policy?.MultiplierWeekday ?? 1.5m;
var mWe = policy?.MultiplierWeekend ?? 2.0m;
var mHol = policy?.MultiplierHoliday ?? 3.0m;
// 2. Holiday set của năm → HashSet<DateOnly> (Holiday.Date = DateOnly).
var holidayList = await db.Holidays.AsNoTracking()
.Where(h => h.Year == q.Year && !h.IsDeleted)
.Select(h => h.Date)
.ToListAsync(ct);
var holidaySet = holidayList.ToHashSet();
// 3. Attendance tháng (+ lọc phòng ban qua join Users nếu có DepartmentId).
var attQuery = db.Attendances.AsNoTracking()
.Where(a => !a.IsDeleted && a.AttendanceDate.Year == q.Year && a.AttendanceDate.Month == q.Month);
if (q.DepartmentId is Guid deptId)
{
attQuery = from a in attQuery
join u in db.Users.AsNoTracking() on a.UserId equals u.Id
where u.DepartmentId == deptId
select a;
}
// ⚠️ Materialize TRƯỚC khi classify day-type (DayOfWeek + holidaySet không EF-translate).
var rows = await attQuery.ToListAsync(ct);
// 4. Denorm FullName + DepartmentName (FullName ưu tiên Attendance.UserFullName đã denorm).
var userIds = rows.Select(r => r.UserId).Distinct().ToList();
var userMeta = await (from u in db.Users.AsNoTracking()
where userIds.Contains(u.Id)
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
select new { u.Id, u.FullName, DepartmentName = d != null ? d.Name : null })
.ToListAsync(ct);
var metaByUser = userMeta.ToDictionary(x => x.Id);
// 5. Group by UserId + classify IN-MEMORY.
var reportRows = rows
.GroupBy(a => a.UserId)
.Select(g =>
{
var meta = metaByUser.GetValueOrDefault(g.Key);
var fullName = g.Select(a => a.UserFullName).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n))
?? meta?.FullName ?? "(unknown)";
var daysPresent = g.Count(a => a.CheckInAt != null);
var totalWork = g.Sum(a => a.WorkHours ?? 0m);
var otRaw = g.Sum(a => a.OtHours ?? 0m);
decimal otWeekday = 0m, otWeekend = 0m, otHoliday = 0m;
foreach (var a in g)
{
var ot = a.OtHours ?? 0m;
if (ot == 0m) continue;
var d = a.AttendanceDate.Date;
if (holidaySet.Contains(DateOnly.FromDateTime(d)))
otHoliday += ot;
else if (d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
otWeekend += ot;
else
otWeekday += ot;
}
var otWeighted = otWeekday * mWd + otWeekend * mWe + otHoliday * mHol;
return new AttendanceReportRowDto(g.Key, fullName, meta?.DepartmentName, daysPresent,
totalWork, otRaw, otWeekday, otWeekend, otHoliday, otWeighted);
})
.OrderBy(r => r.FullName, StringComparer.CurrentCulture)
.ToList();
var grandWork = reportRows.Sum(r => r.TotalWorkHours);
var grandOtWeighted = reportRows.Sum(r => r.OtWeighted);
return new AttendanceReportDto(q.Year, q.Month, reportRows, grandWork, grandOtWeighted);
}
}

View File

@ -345,6 +345,9 @@ public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
};
// P11-F: gen mã ticket lúc Create (ItTicket = kanban KHÔNG workflow,
// khác Leave/OT gen lúc Submit). Format "IT/2026/001" (Serializable tx atomic).
e.MaTicket = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "IT", clock.Now.Year, clock, ct);
db.ItTickets.Add(e);
await db.SaveChangesAsync(ct);
return e.Id;

View File

@ -0,0 +1,11 @@
using SolutionErp.Application.Forms.Services;
using SolutionErp.Application.Office;
namespace SolutionErp.Application.Reports.Services;
// Phase 11 P11-E — export báo cáo chấm công tháng ra Excel.
// Input = AttendanceReportDto (controller query qua MediatR rồi pass — exporter KHÔNG đụng DB).
public interface IAttendanceReportExcelExporter
{
RenderResult Export(AttendanceReportDto report);
}