[CLAUDE] Phase4: Report MVP + Docs Consolidation (rules, architecture, schema-diagram)
Backend Report: - Application/Reports/Dtos/DashboardStatsDto: 5 KPI + PhaseCount + SupplierCount + ProjectCount + MonthlyValue - Application/Reports/Queries/GetDashboardStats handler: total/active/overdue/published this month/totalValueActive + byPhase + top 5 NCC/du an + 12 thang monthly (fill zero khi thang empty) - Application/Reports/Services/IContractExcelExporter interface - Infrastructure/Reports/ContractExcelExporter: ClosedXML workbook 10 cot, header style bold+blue, number format #,##0, formula SUM, auto-fit, freeze header - Application/Reports/Commands/ExportContractsToExcelCommand: filter phase/supplier/project/date range - Api/Controllers/ReportsController: GET /reports/dashboard, GET /reports/contracts/export - DI register IContractExcelExporter (Scoped) Frontend fe-admin: - types/reports.ts: DashboardStats type - components/BarChart.tsx: generic horizontal bar chart — chi Tailwind, khong thu vien ngoai - pages/DashboardPage.tsx REWRITE: 5 KPI card (FileText/TrendingUp/AlertTriangle/CheckCircle2/Coins) + by-phase bar + monthly 12-month chart + top 5 NCC + top 5 du an + skeleton loader - pages/ReportsPage.tsx MOI: filter phase/fromDate/toDate → export Excel button - Route /reports vao App.tsx E2E verified: - GET /api/reports/dashboard → 200 voi day du KPI + monthly fill 12 thang - GET /api/reports/contracts/export → 200 xlsx 7229 bytes (Microsoft Excel 2007+) Docs consolidation (theo yeu cau user): - docs/rules.md MOI: 9 section coding conventions (ngon ngu UI/code/DB/docs, BE Clean Arch, CQRS+MediatR, Validation FluentValidation, Error handling, Async, Entity rules, DI, Package pinning, FE React/TS erasableSyntaxOnly, path alias, TanStack Query, Permission guard, Toast+error, DB convention, Git commit format, Docs structure, Testing, Security) - docs/architecture.md MOI: layered overview ASCII art, request lifecycle (1 POST/api/contracts qua 10 step), workflow state machine 9 phase, permission model, data flow sequence diagram 4 actor (Drafter/Manager/CCM/BOD/HRA), deployment architecture Phase 5, skill library, non-functional table - docs/database/schema-diagram.md MOI: full ERD 19 table mermaid + data flow diagram + vong doi 1 HD (create → 7 transition → gen ma → publish) + index strategy table + relationship cardinality + soft delete behavior + SQL queries (inbox/dashboard/gen ma) + migration history - docs/gotchas.md UPDATE: 17 → 26 pitfalls, them section "Claude Code harness quirks" (Edit File not read, DI build pass nhung runtime fail) + "Contract workflow" (ma HD gen 2 lan, BE-FE NEXT_PHASES sync, race condition) + "Permission matrix" (cache real-time, MenuKey typo) - docs/STATUS.md: Phase 4 MVP done, docs entry points section liet ke het, next Phase 5 Production - docs/HANDOFF.md: phase table them Phase 4 row, file tree update voi Reports, test points day du, git state commit 7 - docs/changelog/migration-todos.md: tick Phase 4 MVP items + them iteration 2 list - docs/changelog/sessions/2026-04-21-1430-phase4-report.md: session log voi thong so cumulative (BE 3100 LOC, 30 docs) - CLAUDE.md root: update Tai lieu quan trong section them rules.md, architecture.md, schema-diagram.md, .claude/skills (13 links now) Bug fix: - TS unused import ContractPhaseLabel trong DashboardPage - DI thieu register IContractExcelExporter — build pass but runtime would fail (added) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
32
src/Backend/SolutionErp.Api/Controllers/ReportsController.cs
Normal file
32
src/Backend/SolutionErp.Api/Controllers/ReportsController.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Reports.Commands.ExportContractsToExcel;
|
||||
using SolutionErp.Application.Reports.Dtos;
|
||||
using SolutionErp.Application.Reports.Queries.GetDashboardStats;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/reports")]
|
||||
[Authorize]
|
||||
public class ReportsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("dashboard")]
|
||||
public async Task<ActionResult<DashboardStatsDto>> Dashboard(CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new GetDashboardStatsQuery(), ct));
|
||||
|
||||
[HttpGet("contracts/export")]
|
||||
public async Task<IActionResult> ExportContracts(
|
||||
[FromQuery] ContractPhase? phase,
|
||||
[FromQuery] Guid? supplierId,
|
||||
[FromQuery] Guid? projectId,
|
||||
[FromQuery] DateTime? fromDate,
|
||||
[FromQuery] DateTime? toDate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await mediator.Send(new ExportContractsToExcelCommand(phase, supplierId, projectId, fromDate, toDate), ct);
|
||||
return File(result.Content, result.ContentType, result.FileName);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Application.Reports.Commands.ExportContractsToExcel;
|
||||
|
||||
public record ExportContractsToExcelCommand(
|
||||
ContractPhase? Phase = null,
|
||||
Guid? SupplierId = null,
|
||||
Guid? ProjectId = null,
|
||||
DateTime? FromDate = null,
|
||||
DateTime? ToDate = null) : IRequest<RenderResult>;
|
||||
|
||||
public class ExportContractsToExcelCommandHandler(IContractExcelExporter exporter)
|
||||
: IRequestHandler<ExportContractsToExcelCommand, RenderResult>
|
||||
{
|
||||
public Task<RenderResult> Handle(ExportContractsToExcelCommand request, CancellationToken ct)
|
||||
=> exporter.ExportAsync(request.Phase, request.SupplierId, request.ProjectId, request.FromDate, request.ToDate, ct);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Application.Reports.Dtos;
|
||||
|
||||
public record DashboardStatsDto(
|
||||
int TotalContracts,
|
||||
int ActiveContracts, // chưa ở final phase (DaPhatHanh hoặc TuChoi)
|
||||
int OverdueContracts, // SlaDeadline < UtcNow
|
||||
int PublishedThisMonth, // DaPhatHanh trong tháng này
|
||||
decimal TotalValueActive, // sum(GiaTri) của ActiveContracts
|
||||
List<PhaseCountDto> ByPhase,
|
||||
List<SupplierCountDto> TopSuppliers, // top 5 NCC theo số HĐ
|
||||
List<ProjectCountDto> TopProjects, // top 5 dự án theo số HĐ
|
||||
List<MonthlyValueDto> MonthlyValue); // 12 tháng gần nhất: tháng → tổng giá trị HĐ tạo
|
||||
|
||||
public record PhaseCountDto(ContractPhase Phase, int Count);
|
||||
public record SupplierCountDto(Guid SupplierId, string SupplierName, int Count, decimal TotalValue);
|
||||
public record ProjectCountDto(Guid ProjectId, string ProjectName, int Count, decimal TotalValue);
|
||||
public record MonthlyValueDto(int Year, int Month, decimal TotalValue, int Count);
|
||||
@ -0,0 +1,73 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Reports.Dtos;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Application.Reports.Queries.GetDashboardStats;
|
||||
|
||||
public record GetDashboardStatsQuery : IRequest<DashboardStatsDto>;
|
||||
|
||||
public class GetDashboardStatsQueryHandler(IApplicationDbContext db, IDateTime dateTime)
|
||||
: IRequestHandler<GetDashboardStatsQuery, DashboardStatsDto>
|
||||
{
|
||||
public async Task<DashboardStatsDto> Handle(GetDashboardStatsQuery request, CancellationToken ct)
|
||||
{
|
||||
var now = dateTime.UtcNow;
|
||||
var monthStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var earliest = now.AddMonths(-11);
|
||||
var earliestMonth = new DateTime(earliest.Year, earliest.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var total = await db.Contracts.AsNoTracking().CountAsync(ct);
|
||||
var active = await db.Contracts.AsNoTracking()
|
||||
.CountAsync(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi, ct);
|
||||
var overdue = await db.Contracts.AsNoTracking()
|
||||
.CountAsync(c => c.SlaDeadline != null && c.SlaDeadline < now
|
||||
&& c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi, ct);
|
||||
var publishedThisMonth = await db.Contracts.AsNoTracking()
|
||||
.CountAsync(c => c.Phase == ContractPhase.DaPhatHanh && c.UpdatedAt != null && c.UpdatedAt >= monthStart, ct);
|
||||
var totalValueActive = await db.Contracts.AsNoTracking()
|
||||
.Where(c => c.Phase != ContractPhase.DaPhatHanh && c.Phase != ContractPhase.TuChoi)
|
||||
.SumAsync(c => (decimal?)c.GiaTri, ct) ?? 0m;
|
||||
|
||||
var byPhase = await db.Contracts.AsNoTracking()
|
||||
.GroupBy(c => c.Phase)
|
||||
.Select(g => new PhaseCountDto(g.Key, g.Count()))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var topSuppliers = await (from c in db.Contracts.AsNoTracking()
|
||||
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
|
||||
group new { c, s } by new { c.SupplierId, s.Name } into g
|
||||
orderby g.Count() descending
|
||||
select new SupplierCountDto(g.Key.SupplierId, g.Key.Name, g.Count(), g.Sum(x => x.c.GiaTri)))
|
||||
.Take(5)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var topProjects = await (from c in db.Contracts.AsNoTracking()
|
||||
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
|
||||
group new { c, p } by new { c.ProjectId, p.Name } into g
|
||||
orderby g.Count() descending
|
||||
select new ProjectCountDto(g.Key.ProjectId, g.Key.Name, g.Count(), g.Sum(x => x.c.GiaTri)))
|
||||
.Take(5)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var monthlyRaw = await db.Contracts.AsNoTracking()
|
||||
.Where(c => c.CreatedAt >= earliestMonth)
|
||||
.GroupBy(c => new { c.CreatedAt.Year, c.CreatedAt.Month })
|
||||
.Select(g => new { g.Key.Year, g.Key.Month, TotalValue = g.Sum(c => c.GiaTri), Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Fill 12 tháng liên tục (kể cả tháng không có data)
|
||||
var monthly = new List<MonthlyValueDto>();
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
var d = earliestMonth.AddMonths(i);
|
||||
var row = monthlyRaw.FirstOrDefault(r => r.Year == d.Year && r.Month == d.Month);
|
||||
monthly.Add(new MonthlyValueDto(d.Year, d.Month, row?.TotalValue ?? 0m, row?.Count ?? 0));
|
||||
}
|
||||
|
||||
return new DashboardStatsDto(
|
||||
total, active, overdue, publishedThisMonth, totalValueActive,
|
||||
byPhase, topSuppliers, topProjects, monthly);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Application.Reports.Services;
|
||||
|
||||
public interface IContractExcelExporter
|
||||
{
|
||||
Task<RenderResult> ExportAsync(
|
||||
ContractPhase? phase,
|
||||
Guid? supplierId,
|
||||
Guid? projectId,
|
||||
DateTime? fromDate,
|
||||
DateTime? toDate,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@ -5,11 +5,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts.Services;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Forms;
|
||||
using SolutionErp.Infrastructure.Identity;
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
using SolutionErp.Infrastructure.Reports;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure;
|
||||
@ -26,6 +28,7 @@ public static class DependencyInjection
|
||||
services.AddSingleton<IFormRenderer, FormRenderer>();
|
||||
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||
|
||||
services.AddScoped<AuditingInterceptor>();
|
||||
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Reports;
|
||||
|
||||
public class ContractExcelExporter(IApplicationDbContext db, IDateTime dateTime) : IContractExcelExporter
|
||||
{
|
||||
private static readonly Dictionary<ContractPhase, string> PhaseLabel = new()
|
||||
{
|
||||
[ContractPhase.DangChon] = "Đang chọn NCC",
|
||||
[ContractPhase.DangSoanThao] = "Đang soạn thảo",
|
||||
[ContractPhase.DangGopY] = "Đang góp ý",
|
||||
[ContractPhase.DangDamPhan] = "Đang đàm phán",
|
||||
[ContractPhase.DangInKy] = "Đang in ký",
|
||||
[ContractPhase.DangKiemTraCCM] = "CCM kiểm tra",
|
||||
[ContractPhase.DangTrinhKy] = "Đang trình ký",
|
||||
[ContractPhase.DangDongDau] = "Đang đóng dấu",
|
||||
[ContractPhase.DaPhatHanh] = "Đã phát hành",
|
||||
[ContractPhase.TuChoi] = "Từ chối",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<ContractType, string> TypeLabel = new()
|
||||
{
|
||||
[ContractType.HopDongThauPhu] = "HĐ Thầu phụ",
|
||||
[ContractType.HopDongGiaoKhoan] = "HĐ Giao khoán",
|
||||
[ContractType.HopDongNhaCungCap] = "HĐ NCC",
|
||||
[ContractType.HopDongDichVu] = "HĐ Dịch vụ",
|
||||
[ContractType.HopDongMuaBan] = "HĐ Mua bán",
|
||||
[ContractType.HopDongNguyenTacNCC] = "HĐ Nguyên tắc NCC",
|
||||
[ContractType.HopDongNguyenTacDichVu] = "HĐ Nguyên tắc DV",
|
||||
};
|
||||
|
||||
public async Task<RenderResult> ExportAsync(
|
||||
ContractPhase? phase, Guid? supplierId, Guid? projectId,
|
||||
DateTime? fromDate, DateTime? toDate, CancellationToken ct = default)
|
||||
{
|
||||
var q = from c in db.Contracts.AsNoTracking()
|
||||
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
|
||||
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
|
||||
select new { c, s, p };
|
||||
|
||||
if (phase is not null) q = q.Where(x => x.c.Phase == phase);
|
||||
if (supplierId is not null) q = q.Where(x => x.c.SupplierId == supplierId);
|
||||
if (projectId is not null) q = q.Where(x => x.c.ProjectId == projectId);
|
||||
if (fromDate is not null) q = q.Where(x => x.c.CreatedAt >= fromDate);
|
||||
if (toDate is not null) q = q.Where(x => x.c.CreatedAt < toDate);
|
||||
|
||||
var rows = await q.OrderByDescending(x => x.c.CreatedAt).ToListAsync(ct);
|
||||
|
||||
using var wb = new XLWorkbook();
|
||||
var ws = wb.Worksheets.Add("Contracts");
|
||||
|
||||
// Header
|
||||
var headers = new[] { "STT", "Mã HĐ", "Tên HĐ", "Loại", "Phase", "NCC", "Dự án", "Giá trị (VND)", "SLA", "Ngày tạo" };
|
||||
for (int i = 0; i < headers.Length; i++)
|
||||
ws.Cell(1, i + 1).Value = headers[i];
|
||||
|
||||
var headerRange = ws.Range(1, 1, 1, headers.Length);
|
||||
headerRange.Style.Font.Bold = true;
|
||||
headerRange.Style.Fill.BackgroundColor = XLColor.LightBlue;
|
||||
headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
|
||||
// Data
|
||||
for (int i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var r = rows[i];
|
||||
var rowIdx = i + 2;
|
||||
ws.Cell(rowIdx, 1).Value = i + 1;
|
||||
ws.Cell(rowIdx, 2).Value = r.c.MaHopDong ?? "—";
|
||||
ws.Cell(rowIdx, 3).Value = r.c.TenHopDong ?? "—";
|
||||
ws.Cell(rowIdx, 4).Value = TypeLabel.GetValueOrDefault(r.c.Type, "?");
|
||||
ws.Cell(rowIdx, 5).Value = PhaseLabel.GetValueOrDefault(r.c.Phase, "?");
|
||||
ws.Cell(rowIdx, 6).Value = r.s.Name;
|
||||
ws.Cell(rowIdx, 7).Value = r.p.Name;
|
||||
ws.Cell(rowIdx, 8).Value = r.c.GiaTri;
|
||||
ws.Cell(rowIdx, 8).Style.NumberFormat.Format = "#,##0";
|
||||
ws.Cell(rowIdx, 9).Value = r.c.SlaDeadline?.ToString("yyyy-MM-dd HH:mm") ?? "—";
|
||||
ws.Cell(rowIdx, 10).Value = r.c.CreatedAt.ToString("yyyy-MM-dd HH:mm");
|
||||
}
|
||||
|
||||
// Auto-fit
|
||||
ws.Columns().AdjustToContents();
|
||||
ws.SheetView.FreezeRows(1);
|
||||
|
||||
// Footer summary
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
var sumRow = rows.Count + 3;
|
||||
ws.Cell(sumRow, 7).Value = "TỔNG:";
|
||||
ws.Cell(sumRow, 7).Style.Font.Bold = true;
|
||||
ws.Cell(sumRow, 7).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
|
||||
ws.Cell(sumRow, 8).FormulaA1 = $"=SUM(H2:H{rows.Count + 1})";
|
||||
ws.Cell(sumRow, 8).Style.NumberFormat.Format = "#,##0";
|
||||
ws.Cell(sumRow, 8).Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
wb.SaveAs(ms);
|
||||
var fileName = $"Contracts_{dateTime.UtcNow:yyyyMMdd_HHmmss}.xlsx";
|
||||
return new RenderResult(
|
||||
ms.ToArray(),
|
||||
fileName,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user