[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,150 @@
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Office;
using SolutionErp.Domain.Hrm;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Office;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// Phase 11 P11-E (S52 2026-06-08) — test-after GetAttendanceReportHandler aggregate.
// CORE logic = day-type classification (weekday / weekend Sat-Sun / holiday∈set) IN-MEMORY
// + OT quy đổi (weighted) theo OtPolicy active multipliers.
//
// Handler (AttendanceReportFeatures.cs):
// otWeighted = otWeekday*mWd + otWeekend*mWe + otHoliday*mHol
// day-type: holidaySet.Contains(DateOnly.FromDateTime(d)) → Holiday
// else d.DayOfWeek is Sat/Sun → Weekend
// else → Weekday
// DaysPresent = Count(CheckInAt != null); TotalWorkHours = Sum(WorkHours ?? 0); OtRaw = Sum(OtHours ?? 0)
//
// ⚠️ Holiday OVERRIDE day-of-week: 2026-06-01 là thứ Hai (weekday) NHƯNG ∈ holidaySet
// → phân Holiday (holiday check chạy TRƯỚC weekend/weekday). Đây là điểm test then chốt.
// Holiday.Date = DateOnly (KHÔNG DateTime) — handler so qua DateOnly.FromDateTime.
public class AttendanceReportTests
{
private static IdentityFixture NewFix() => new();
// 2026-06-01 Monday (nhưng là ngày lễ) / 2026-06-02 Tuesday (weekday) / 2026-06-06 Saturday (weekend).
private static readonly DateTime Holiday0601 = new(2026, 6, 1);
private static readonly DateTime Weekday0602 = new(2026, 6, 2);
private static readonly DateTime Weekend0606 = new(2026, 6, 6);
private static OtPolicy BuildPolicy()
=> new()
{
Id = Guid.NewGuid(),
Code = "STANDARD",
Name = "OT chuẩn",
MultiplierWeekday = 1.5m,
MultiplierWeekend = 2.0m,
MultiplierHoliday = 3.0m,
MaxHoursPerDay = 4,
MaxHoursPerMonth = 40,
MaxHoursPerYear = 200,
IsActive = true,
};
private static Holiday BuildHoliday(int year, DateTime date)
=> new()
{
Id = Guid.NewGuid(),
Year = year,
Date = DateOnly.FromDateTime(date),
Name = "Ngày lễ test",
IsActive = true,
};
private static Attendance BuildAttendance(Guid userId, string fullName, DateTime date,
decimal otHours, decimal workHours, bool checkedIn = true)
=> new()
{
Id = Guid.NewGuid(),
UserId = userId,
UserFullName = fullName,
AttendanceDate = date.Date,
CheckInAt = checkedIn ? date.Date.AddHours(8) : null,
CheckOutAt = checkedIn ? date.Date.AddHours(17) : null,
SourceIn = AttendanceSource.Web,
SourceOut = AttendanceSource.Web,
OtHours = otHours,
WorkHours = workHours,
};
// ============ Case 1: Aggregate đầy đủ 1 user — day-type + weighted ============
[Fact]
public async Task GetReport_OneUser_ClassifiesDayTypesAndComputesWeightedOt()
{
var fix = NewFix();
using (fix)
{
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var u = await fix.CreateUserAsync("u-rep1@test.local", "Nguyễn Văn A", null, Array.Empty<string>());
db.OtPolicies.Add(BuildPolicy());
db.Holidays.Add(BuildHoliday(2026, Holiday0601));
db.Attendances.AddRange(
BuildAttendance(u.Id, u.FullName, Weekday0602, otHours: 2m, workHours: 8m), // weekday
BuildAttendance(u.Id, u.FullName, Weekend0606, otHours: 3m, workHours: 8m), // weekend (Sat)
BuildAttendance(u.Id, u.FullName, Holiday0601, otHours: 1m, workHours: 8m)); // holiday (Mon nhưng lễ)
await db.SaveChangesAsync(CancellationToken.None);
var handler = new GetAttendanceReportHandler(db);
var report = await handler.Handle(new GetAttendanceReportQuery(2026, 6, null), CancellationToken.None);
report.Year.Should().Be(2026);
report.Month.Should().Be(6);
report.Rows.Should().HaveCount(1);
var row = report.Rows[0];
row.UserId.Should().Be(u.Id);
row.OtWeekday.Should().Be(2m, "2026-06-02 Tuesday = weekday");
row.OtWeekend.Should().Be(3m, "2026-06-06 Saturday = weekend");
row.OtHoliday.Should().Be(1m, "2026-06-01 thứ Hai NHƯNG ∈ holidaySet → Holiday (override day-of-week)");
row.OtRaw.Should().Be(6m, "tổng raw 2+3+1");
row.OtWeighted.Should().Be(12.0m, "2×1.5 + 3×2.0 + 1×3.0 = 3+6+3");
row.TotalWorkHours.Should().Be(24m, "8+8+8");
row.DaysPresent.Should().Be(3, "cả 3 ngày có CheckInAt");
report.GrandTotalOtWeighted.Should().Be(12.0m);
report.GrandTotalWorkHours.Should().Be(24m);
}
}
// ============ Case 2: DepartmentId filter — chỉ user đúng phòng ============
[Fact]
public async Task GetReport_WithDepartmentFilter_ReturnsOnlyUsersInDept()
{
var fix = NewFix();
using (fix)
{
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var deptA = new Domain.Master.Department { Id = Guid.NewGuid(), Code = "DA", Name = "Phòng A" };
var deptB = new Domain.Master.Department { Id = Guid.NewGuid(), Code = "DB", Name = "Phòng B" };
db.Departments.AddRange(deptA, deptB);
await db.SaveChangesAsync(CancellationToken.None);
var inDept = await fix.CreateUserAsync("u-deptA@test.local", "User Phòng A", deptA.Id, Array.Empty<string>());
var outDept = await fix.CreateUserAsync("u-deptB@test.local", "User Phòng B", deptB.Id, Array.Empty<string>());
db.OtPolicies.Add(BuildPolicy());
db.Holidays.Add(BuildHoliday(2026, Holiday0601));
db.Attendances.AddRange(
BuildAttendance(inDept.Id, inDept.FullName, Weekday0602, otHours: 2m, workHours: 8m),
BuildAttendance(outDept.Id, outDept.FullName, Weekday0602, otHours: 5m, workHours: 8m));
await db.SaveChangesAsync(CancellationToken.None);
var handler = new GetAttendanceReportHandler(db);
var report = await handler.Handle(new GetAttendanceReportQuery(2026, 6, deptA.Id), CancellationToken.None);
report.Rows.Should().HaveCount(1, "chỉ user phòng A khớp filter");
report.Rows[0].UserId.Should().Be(inDept.Id);
report.Rows[0].DepartmentName.Should().Be("Phòng A");
report.Rows[0].OtWeekday.Should().Be(2m);
report.GrandTotalOtWeighted.Should().Be(3.0m, "chỉ 2h weekday × 1.5 = 3.0 (loại trừ user phòng B)");
}
}
}

View File

@ -0,0 +1,119 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Office;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// Phase 11 P11-F (S52 2026-06-08) — test-after critical-algo codegen MaTicket.
// CreateItTicketHandler set e.MaTicket = WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "IT", clock.Now.Year, ...)
// → fullPrefix "IT/{year}" → format "IT/{year}/001" (seq D3 per-year, LastSeq++ atomic).
//
// ItTicket = kanban KHÔNG workflow V2 → gen mã lúc Create (khác Leave/OT gen lúc Submit).
// Coverage = FORMAT (regex) + SEQUENCE (001→002 cùng prefix) + PER-YEAR-PREFIX (year boundary tách seq).
//
// GOTCHA Serializable-on-SQLite (em main spec): codegen dùng
// BeginTransactionAsync(IsolationLevel.Serializable). SqliteDbFixture.cs comment xác nhận
// SQLite map IsolationLevel enum GRACEFULLY (no exception, just default behavior) — đã proven
// bởi WorkflowAppApproveV2Tests (assert "DT/LR/2026/001" + LastSeq==1 PASS). Cùng codegen path
// → KHÔNG throw trên SQLite. Test này confirm lại cho prefix "IT".
public class ItTicketCodeGenTests
{
private static readonly DateTime FixedNow = new(2026, 6, 8, 8, 0, 0, DateTimeKind.Utc);
private static (IdentityFixture fix, TestApplicationDbContext db, FixedDateTime clock) NewCtx()
{
var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var clock = new FixedDateTime(FixedNow);
return (fix, db, clock);
}
private static TestCurrentUser AsUser(User u)
=> new() { UserId = u.Id, FullName = u.FullName, Roles = Array.Empty<string>() };
private static CreateItTicketCommand BuildCmd(string title = "Máy in hỏng")
=> new(title, "Máy in tầng 3 không nhận lệnh in.", Category: 1, Priority: 2);
// ============ Case 1: Format khớp regex IT/{year}/{seq:D3} ============
[Fact]
public async Task CreateItTicket_GeneratesMaTicket_MatchesFormat()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-it1@test.local", "Người tạo", null, Array.Empty<string>());
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
var id = await handler.Handle(BuildCmd(), CancellationToken.None);
var ticket = await db.ItTickets.FirstAsync(t => t.Id == id);
ticket.MaTicket.Should().NotBeNullOrEmpty();
ticket.MaTicket.Should().MatchRegex(@"^IT/\d{4}/\d{3}$", "format IT/{year}/{seq:D3}");
ticket.MaTicket.Should().Be("IT/2026/001", "year = clock.Now.Year, seq đầu tiên = 001");
// Sequence row tạo đúng key per-year prefix.
var seq = await db.WorkflowAppCodeSequences.FirstAsync(s => s.Prefix == "IT/2026");
seq.LastSeq.Should().Be(1);
}
}
// ============ Case 2: Sequential — 2 create liên tiếp 001 → 002 ============
[Fact]
public async Task CreateItTicket_Sequential_IncrementsSeqSamePrefix()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-it2@test.local", "Người tạo", null, Array.Empty<string>());
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
var id1 = await handler.Handle(BuildCmd("Ticket 1"), CancellationToken.None);
var id2 = await handler.Handle(BuildCmd("Ticket 2"), CancellationToken.None);
var t1 = await db.ItTickets.FirstAsync(t => t.Id == id1);
var t2 = await db.ItTickets.FirstAsync(t => t.Id == id2);
t1.MaTicket.Should().Be("IT/2026/001");
t2.MaTicket.Should().Be("IT/2026/002", "LastSeq++ trên cùng prefix IT/2026");
// Chỉ 1 sequence row (cùng prefix) — LastSeq = 2.
var seqs = await db.WorkflowAppCodeSequences.Where(s => s.Prefix == "IT/2026").ToListAsync();
seqs.Should().HaveCount(1);
seqs[0].LastSeq.Should().Be(2);
}
}
// ============ Case 3: Per-year-prefix — đổi năm → seq reset (key prefix mới) ============
[Fact]
public async Task CreateItTicket_DifferentYear_UsesSeparatePrefixSequence()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-it3@test.local", "Người tạo", null, Array.Empty<string>());
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
// 2 ticket năm 2026.
await handler.Handle(BuildCmd("2026-a"), CancellationToken.None);
await handler.Handle(BuildCmd("2026-b"), CancellationToken.None);
// Tua clock sang năm 2027 → prefix mới IT/2027 → seq bắt đầu lại từ 001.
clock.UtcNow = new DateTime(2027, 1, 5, 8, 0, 0, DateTimeKind.Utc);
var id2027 = await handler.Handle(BuildCmd("2027-a"), CancellationToken.None);
var t2027 = await db.ItTickets.FirstAsync(t => t.Id == id2027);
t2027.MaTicket.Should().Be("IT/2027/001", "year boundary → key prefix mới → seq reset 001");
var seq2026 = await db.WorkflowAppCodeSequences.FirstAsync(s => s.Prefix == "IT/2026");
var seq2027 = await db.WorkflowAppCodeSequences.FirstAsync(s => s.Prefix == "IT/2027");
seq2026.LastSeq.Should().Be(2, "2 ticket năm 2026 không bị ảnh hưởng");
seq2027.LastSeq.Should().Be(1);
}
}
}