[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
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:
@ -2,13 +2,14 @@ using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/attendances")]
|
||||
[Authorize]
|
||||
public class AttendancesController(IMediator mediator) : ControllerBase
|
||||
public class AttendancesController(IMediator mediator, IAttendanceReportExcelExporter excelExporter) : ControllerBase
|
||||
{
|
||||
[HttpPost("check-in")]
|
||||
public async Task<IActionResult> CheckIn([FromBody] CheckInCommand cmd)
|
||||
@ -30,4 +31,19 @@ public class AttendancesController(IMediator mediator) : ControllerBase
|
||||
var now = DateTime.Now;
|
||||
return Ok(await mediator.Send(new GetMyAttendanceQuery(year ?? now.Year, month ?? now.Month)));
|
||||
}
|
||||
|
||||
// P11-E: báo cáo chấm công tháng + OT quy đổi — admin-only (MVP).
|
||||
[HttpGet("report")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> GetReport([FromQuery] int year, [FromQuery] int month, [FromQuery] Guid? departmentId)
|
||||
=> Ok(await mediator.Send(new GetAttendanceReportQuery(year, month, departmentId)));
|
||||
|
||||
[HttpGet("report/excel")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> GetReportExcel([FromQuery] int year, [FromQuery] int month, [FromQuery] Guid? departmentId)
|
||||
{
|
||||
var report = await mediator.Send(new GetAttendanceReportQuery(year, month, departmentId));
|
||||
var result = excelExporter.Export(report);
|
||||
return File(result.Content, result.ContentType, result.FileName);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -38,6 +38,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IPurchaseEvaluationCodeGenerator, PurchaseEvaluationCodeGenerator>();
|
||||
services.AddScoped<IEmployeeCodeGenerator, EmployeeCodeGenerator>();
|
||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddScoped<IChangelogService, ChangelogService>();
|
||||
services.AddSingleton<IFileStorage, LocalFileStorage>();
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
using ClosedXML.Excel;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Office;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Reports;
|
||||
|
||||
// Phase 11 P11-E — mirror ContractExcelExporter (ClosedXML XLWorkbook → MemoryStream → RenderResult).
|
||||
// Input = AttendanceReportDto (controller query rồi pass — KHÔNG đụng DB).
|
||||
public class AttendanceReportExcelExporter : IAttendanceReportExcelExporter
|
||||
{
|
||||
public RenderResult Export(AttendanceReportDto report)
|
||||
{
|
||||
using var wb = new XLWorkbook();
|
||||
var ws = wb.Worksheets.Add("ChamCong");
|
||||
|
||||
// Title row
|
||||
var headers = new[]
|
||||
{
|
||||
"STT", "Họ tên", "Phòng ban", "Ngày công", "Tổng giờ làm",
|
||||
"OT thường", "OT cuối tuần", "OT lễ", "OT quy đổi"
|
||||
};
|
||||
var titleRange = ws.Range(1, 1, 1, headers.Length);
|
||||
titleRange.Merge();
|
||||
titleRange.Value = $"BÁO CÁO CHẤM CÔNG THÁNG {report.Month}/{report.Year}";
|
||||
titleRange.Style.Font.Bold = true;
|
||||
titleRange.Style.Font.FontSize = 14;
|
||||
titleRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
|
||||
// Header row
|
||||
const int headerRowIdx = 2;
|
||||
for (int i = 0; i < headers.Length; i++)
|
||||
ws.Cell(headerRowIdx, i + 1).Value = headers[i];
|
||||
|
||||
var headerRange = ws.Range(headerRowIdx, 1, headerRowIdx, headers.Length);
|
||||
headerRange.Style.Font.Bold = true;
|
||||
headerRange.Style.Fill.BackgroundColor = XLColor.LightBlue;
|
||||
headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
|
||||
// Data rows
|
||||
const int firstDataRow = headerRowIdx + 1;
|
||||
for (int i = 0; i < report.Rows.Count; i++)
|
||||
{
|
||||
var r = report.Rows[i];
|
||||
var rowIdx = firstDataRow + i;
|
||||
ws.Cell(rowIdx, 1).Value = i + 1;
|
||||
ws.Cell(rowIdx, 2).Value = r.FullName;
|
||||
ws.Cell(rowIdx, 3).Value = r.DepartmentName ?? "—";
|
||||
ws.Cell(rowIdx, 4).Value = r.DaysPresent;
|
||||
ws.Cell(rowIdx, 5).Value = r.TotalWorkHours;
|
||||
ws.Cell(rowIdx, 5).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(rowIdx, 6).Value = r.OtWeekday;
|
||||
ws.Cell(rowIdx, 6).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(rowIdx, 7).Value = r.OtWeekend;
|
||||
ws.Cell(rowIdx, 7).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(rowIdx, 8).Value = r.OtHoliday;
|
||||
ws.Cell(rowIdx, 8).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(rowIdx, 9).Value = r.OtWeighted;
|
||||
ws.Cell(rowIdx, 9).Style.NumberFormat.Format = "#,##0.0#";
|
||||
}
|
||||
|
||||
// Footer total row
|
||||
if (report.Rows.Count > 0)
|
||||
{
|
||||
var sumRow = firstDataRow + report.Rows.Count;
|
||||
ws.Cell(sumRow, 4).Value = "TỔNG:";
|
||||
ws.Cell(sumRow, 4).Style.Font.Bold = true;
|
||||
ws.Cell(sumRow, 4).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
|
||||
ws.Cell(sumRow, 5).Value = report.GrandTotalWorkHours;
|
||||
ws.Cell(sumRow, 5).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(sumRow, 5).Style.Font.Bold = true;
|
||||
ws.Cell(sumRow, 9).Value = report.GrandTotalOtWeighted;
|
||||
ws.Cell(sumRow, 9).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(sumRow, 9).Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
ws.Columns().AdjustToContents();
|
||||
ws.SheetView.FreezeRows(headerRowIdx);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
wb.SaveAs(ms);
|
||||
return new RenderResult(
|
||||
ms.ToArray(),
|
||||
$"BaoCao-ChamCong-{report.Year}-{report.Month:D2}.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user