[CLAUDE] Contract: K1 SP-002 danh muc loai HD - ContractCatalogEntries (Mig 70) + seed 86 + CQRS + FE kind-5 x2 app + 8 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m31s

Gate: reviewer PASS-WITH-FLAGS 9f (sub-reviewer-gate-k1.md). Xu ly: F4
IncludeInactive mac-dinh-loai (spec K1.c) + test T5b; F7 comment 5-kind;
F1 OG-6 doi nhan soat-SAU @PAUSE-2 (seeder skip-if-exists giu sua-tay CRUD
qua restart - update-branch co y KHONG co); F3 authz mirror khuon Catalogs,
gioi-han khai: 4 policy ContractCatalog.* sinh ra nhung chua endpoint nao
tieu thu (display-layer khac API-layer, #82); F6 ten index IX_ giu EF-default
(NIT). Suite 598/0 (45D+553I). Mig 70 ap SQL Server that OK. Drift DELTA:
MenuKeys.All 55->56, policies 220->224, bang 96->97.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 15:45:50 +07:00
parent c0a96e54eb
commit 50e6d8c382
20 changed files with 8566 additions and 80 deletions

View File

@ -24,6 +24,8 @@ public interface IApplicationDbContext
DbSet<MaterialItem> MaterialItems { get; }
DbSet<ServiceItem> ServiceItems { get; }
DbSet<WorkItem> WorkItems { get; }
// [K1 S164 — Mig 70] Danh mục Hợp đồng SOL-CCM-SP-002 (86 dòng × 8 nhóm duyệt)
DbSet<ContractCatalogEntry> ContractCatalogEntries { get; }
DbSet<MenuItem> MenuItems { get; }
DbSet<Permission> Permissions { get; }
DbSet<User> Users { get; }

View File

@ -0,0 +1,226 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Master.Catalogs;
namespace SolutionErp.Application.Master.Catalogs;
// [K1 — S164, Mig 70] CRUD CQRS cho "Danh mục Hợp đồng" (SOL-CCM-SP-002, 86 dòng).
// File RIÊNG cạnh CatalogsFeatures.cs (mega-file 4 catalog cũ) vì shape khác hẳn:
// có GroupCode + ApprovalGroup (1-8 = nhóm duyệt N1..N8) + 2 cột vai-ký dạng TEXT.
//
// Endpoints (ContractCatalogController, route `api/catalogs/contract-catalog`):
// GET /api/catalogs/contract-catalog — list (filter groupCode/approvalGroup/q;
// mặc định CHỈ trả IsActive=true — spec K1.c `IncludeInactive?` mặc-định-loại;
// truyền includeInactive=true để thấy cả dòng ngừng hiệu lực — gate-K1 F4 @S165)
// GET /api/catalogs/contract-catalog/{id} — get by id
// POST /api/catalogs/contract-catalog — create (Admin)
// PUT /api/catalogs/contract-catalog/{id} — update (Admin)
// DELETE /api/catalogs/contract-catalog/{id} — soft delete (Admin)
//
// KHÔNG paging: 86 dòng, dùng chung cho trang quản trị + picker chọn hạng mục (K2).
// Dup-Code check chạy trên ACTIVE-set (query filter !IsDeleted) — khớp UNIQUE index
// filtered `[IsDeleted] = 0` ⇒ Code của row đã xoá mềm được tái sử dụng (gotcha #57).
// ===== DTO =====
public record ContractCatalogEntryDto(
Guid Id,
string Code,
string TenVi,
string? TenEn,
string GroupCode,
int ApprovalGroup,
string SignerRole,
string? InitialsChain,
string? NduqNote,
bool IsActive,
int SortOrder);
// ===== List =====
public record ListContractCatalogQuery(
string? GroupCode = null,
int? ApprovalGroup = null,
bool IncludeInactive = false,
string? Q = null) : IRequest<List<ContractCatalogEntryDto>>;
public class ListContractCatalogHandler(IApplicationDbContext db)
: IRequestHandler<ListContractCatalogQuery, List<ContractCatalogEntryDto>>
{
public async Task<List<ContractCatalogEntryDto>> Handle(ListContractCatalogQuery req, CancellationToken ct)
{
var q = db.ContractCatalogEntries.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.GroupCode))
q = q.Where(x => x.GroupCode == req.GroupCode);
if (req.ApprovalGroup is int g)
q = q.Where(x => x.ApprovalGroup == g);
if (!req.IncludeInactive)
q = q.Where(x => x.IsActive);
if (!string.IsNullOrWhiteSpace(req.Q))
{
var s = req.Q.ToLower();
q = q.Where(x => x.Code.ToLower().Contains(s)
|| x.TenVi.ToLower().Contains(s)
|| (x.TenEn != null && x.TenEn.ToLower().Contains(s)));
}
return await q.OrderBy(x => x.GroupCode).ThenBy(x => x.SortOrder).ThenBy(x => x.Code)
.Select(x => new ContractCatalogEntryDto(
x.Id, x.Code, x.TenVi, x.TenEn, x.GroupCode, x.ApprovalGroup,
x.SignerRole, x.InitialsChain, x.NduqNote, x.IsActive, x.SortOrder))
.ToListAsync(ct);
}
}
// ===== Get by id =====
public record GetContractCatalogEntryQuery(Guid Id) : IRequest<ContractCatalogEntryDto>;
public class GetContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<GetContractCatalogEntryQuery, ContractCatalogEntryDto>
{
public async Task<ContractCatalogEntryDto> Handle(GetContractCatalogEntryQuery req, CancellationToken ct)
=> await db.ContractCatalogEntries.AsNoTracking()
.Where(x => x.Id == req.Id)
.Select(x => new ContractCatalogEntryDto(
x.Id, x.Code, x.TenVi, x.TenEn, x.GroupCode, x.ApprovalGroup,
x.SignerRole, x.InitialsChain, x.NduqNote, x.IsActive, x.SortOrder))
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException("ContractCatalogEntry", req.Id);
}
// ===== Create =====
public record CreateContractCatalogEntryCommand(
string Code,
string TenVi,
string? TenEn,
string GroupCode,
int ApprovalGroup,
string SignerRole,
string? InitialsChain,
string? NduqNote,
bool IsActive,
int SortOrder) : IRequest<Guid>;
// MaxLength khớp ĐÚNG EF config (ContractCatalogEntryConfiguration.cs:18-24) —
// EF là source of truth, KHÔNG lấy số từ spec.
public class CreateContractCatalogEntryValidator : AbstractValidator<CreateContractCatalogEntryCommand>
{
public CreateContractCatalogEntryValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.TenVi).NotEmpty().MaximumLength(500);
RuleFor(x => x.TenEn).MaximumLength(500);
RuleFor(x => x.GroupCode).NotEmpty().MaximumLength(10);
RuleFor(x => x.ApprovalGroup).InclusiveBetween(1, 8)
.WithMessage("Nhóm duyệt phải từ 1 đến 8 (N1..N8).");
RuleFor(x => x.SignerRole).NotEmpty().MaximumLength(100);
RuleFor(x => x.InitialsChain).MaximumLength(300);
RuleFor(x => x.NduqNote).MaximumLength(500);
}
}
public class CreateContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<CreateContractCatalogEntryCommand, Guid>
{
public async Task<Guid> Handle(CreateContractCatalogEntryCommand req, CancellationToken ct)
{
if (await db.ContractCatalogEntries.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
var entity = new ContractCatalogEntry
{
Code = req.Code,
TenVi = req.TenVi,
TenEn = req.TenEn,
GroupCode = req.GroupCode,
ApprovalGroup = req.ApprovalGroup,
SignerRole = req.SignerRole,
InitialsChain = req.InitialsChain,
NduqNote = req.NduqNote,
IsActive = req.IsActive,
SortOrder = req.SortOrder,
};
db.ContractCatalogEntries.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ===== Update =====
public record UpdateContractCatalogEntryCommand(
Guid Id,
string Code,
string TenVi,
string? TenEn,
string GroupCode,
int ApprovalGroup,
string SignerRole,
string? InitialsChain,
string? NduqNote,
bool IsActive,
int SortOrder) : IRequest;
public class UpdateContractCatalogEntryValidator : AbstractValidator<UpdateContractCatalogEntryCommand>
{
public UpdateContractCatalogEntryValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.TenVi).NotEmpty().MaximumLength(500);
RuleFor(x => x.TenEn).MaximumLength(500);
RuleFor(x => x.GroupCode).NotEmpty().MaximumLength(10);
RuleFor(x => x.ApprovalGroup).InclusiveBetween(1, 8)
.WithMessage("Nhóm duyệt phải từ 1 đến 8 (N1..N8).");
RuleFor(x => x.SignerRole).NotEmpty().MaximumLength(100);
RuleFor(x => x.InitialsChain).MaximumLength(300);
RuleFor(x => x.NduqNote).MaximumLength(500);
}
}
public class UpdateContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<UpdateContractCatalogEntryCommand>
{
public async Task Handle(UpdateContractCatalogEntryCommand req, CancellationToken ct)
{
var entity = await db.ContractCatalogEntries.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("ContractCatalogEntry", req.Id);
if (entity.Code != req.Code && await db.ContractCatalogEntries.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.TenVi = req.TenVi;
entity.TenEn = req.TenEn;
entity.GroupCode = req.GroupCode;
entity.ApprovalGroup = req.ApprovalGroup;
entity.SignerRole = req.SignerRole;
entity.InitialsChain = req.InitialsChain;
entity.NduqNote = req.NduqNote;
entity.IsActive = req.IsActive;
entity.SortOrder = req.SortOrder;
await db.SaveChangesAsync(ct);
}
}
// ===== Delete (soft) =====
public record DeleteContractCatalogEntryCommand(Guid Id) : IRequest;
public class DeleteContractCatalogEntryHandler(IApplicationDbContext db)
: IRequestHandler<DeleteContractCatalogEntryCommand>
{
public async Task Handle(DeleteContractCatalogEntryCommand req, CancellationToken ct)
{
var entity = await db.ContractCatalogEntries.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("ContractCatalogEntry", req.Id);
db.ContractCatalogEntries.Remove(entity); // Soft delete via AuditingInterceptor
await db.SaveChangesAsync(ct);
}
}