[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:
@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user