[CLAUDE] Domain+App+Api: 4 master catalogs cho Details (migration 10)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m48s

User feedback: thêm Master Data cho phần Chi tiết (line items) — autocomplete
khi user nhập field thay vì gõ tay free text.

## 4 entities mới (Domain/Master/Catalogs/)

| Entity | Bảng | Dùng cho HĐ Detail |
|---|---|---|
| UnitOfMeasure | UnitsOfMeasure | Tất cả 7 type (DonViTinh) |
| MaterialItem | MaterialItems | NCC + Mua bán + Nguyên tắc NCC (MaSP/TenSP) |
| ServiceItem | ServiceItems | Dịch vụ + Nguyên tắc DV (MaDichVu/TenDichVu) |
| WorkItem | WorkItems | Thầu phụ + Giao khoán (HangMuc/MaCongViec) |

Common pattern: Code unique (filter IsDeleted=0) + Name + Category +
DefaultUnit + AuditableEntity (soft delete) + IsActive flag.

## Migration 10: AddMasterCatalogs

3-file rule (gotcha #17). Total DB: 32 → 36 tables. Apply LocalDB OK.

## Seed defaults (idempotent — skip per-table nếu có row)

- 20 UnitsOfMeasure: m2, m3, kg, tấn, lít, ngc (ngày công), giờ, gói, ...
- 15 MaterialItems demo: xi măng PCB40, cát vàng, đá 1x2, thép D10, gạch
  4 lỗ, sơn lót, ống PVC...
- 10 ServiceItems demo: vận chuyển, bảo trì, tư vấn, kiểm định, vệ sinh...
- 15 WorkItems demo: đào móng, đổ bê tông, xây tường, trát, lát gạch,
  sơn nước, lắp điện, lắp nước, thấm chống...

## CQRS (Application/Master/Catalogs/CatalogsFeatures.cs ~290 dòng)

Mỗi catalog 5 handlers (List filter q+category, Create với unique code
guard, Update, Delete soft). FluentValidation max length per spec EF.

## Controller (Api/Controllers/CatalogsController.cs)

13 endpoints:
- GET  /api/catalogs/{units|materials|services|work-items}
- POST /api/catalogs/{kind}                  (Admin role)
- PUT  /api/catalogs/{kind}/{id}             (Admin role)
- DELETE /api/catalogs/{kind}/{id}           (Admin role)

Read open cho mọi role (FE Details add form autocomplete cần list).

## Menu (5 mới)

- Catalogs (group, Master parent, order 24, "Library" icon)
  - CatalogUnits "Đơn vị tính" (Ruler)
  - CatalogMaterials "Vật tư / SP" (Package)
  - CatalogServices "Dịch vụ" (Wrench)
  - CatalogWorkItems "Hạng mục công việc" (ListChecks)

Admin auto-grant tất cả CRUD action (qua SeedAdminPermissionsAsync
loop MenuKeys.All).

## Build

dotnet build BE pass (0 error)

## Note

FE admin page CatalogsPage + datalist autocomplete trong Details form
sẽ ở commit kế tiếp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 12:21:39 +07:00
parent 51449d6b9d
commit e27c54702a
14 changed files with 3302 additions and 0 deletions

View File

@ -4,6 +4,7 @@ using SolutionErp.Domain.Contracts.Details;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Application.Common.Interfaces;
@ -13,6 +14,11 @@ public interface IApplicationDbContext
DbSet<Supplier> Suppliers { get; }
DbSet<Project> Projects { get; }
DbSet<Department> Departments { get; }
// 4 master catalogs cho Details add form (autocomplete)
DbSet<UnitOfMeasure> UnitsOfMeasure { get; }
DbSet<MaterialItem> MaterialItems { get; }
DbSet<ServiceItem> ServiceItems { get; }
DbSet<WorkItem> WorkItems { get; }
DbSet<MenuItem> MenuItems { get; }
DbSet<Permission> Permissions { get; }
DbSet<ContractTemplate> ContractTemplates { get; }

View File

@ -0,0 +1,334 @@
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;
// CRUD CQRS cho 4 master catalogs (Units, Materials, Services, WorkItems).
// Pattern lặp đi lặp lại với mỗi catalog — gộp 1 file để dễ maintain, mỗi
// catalog 5 handlers (List + GetById + Create + Update + Delete).
//
// Endpoints expose qua CatalogsController:
// GET /api/catalogs/{kind} — list (filter q + category)
// GET /api/catalogs/{kind}/{id} — get by id
// POST /api/catalogs/{kind} — create
// PUT /api/catalogs/{kind}/{id} — update
// DELETE /api/catalogs/{kind}/{id} — soft delete
// kind ∈ {units, materials, services, work-items}
// ===== DTOs =====
public record UnitOfMeasureDto(Guid Id, string Code, string Name, string? Description);
public record MaterialItemDto(Guid Id, string Code, string Name, string? Category, string? DefaultUnit, string? Specification, string? OriginCountry, bool IsActive);
public record ServiceItemDto(Guid Id, string Code, string Name, string? Category, string? DefaultUnit, string? Description, bool IsActive);
public record WorkItemDto(Guid Id, string Code, string Name, string? Category, string? DefaultUnit, string? Description, bool IsActive);
// ===== UnitOfMeasure =====
public record ListUnitsQuery(string? Q = null) : IRequest<List<UnitOfMeasureDto>>;
public class ListUnitsHandler(IApplicationDbContext db) : IRequestHandler<ListUnitsQuery, List<UnitOfMeasureDto>>
{
public async Task<List<UnitOfMeasureDto>> Handle(ListUnitsQuery req, CancellationToken ct)
{
var q = db.UnitsOfMeasure.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Q))
{
var s = req.Q.ToLower();
q = q.Where(x => x.Code.ToLower().Contains(s) || x.Name.ToLower().Contains(s));
}
return await q.OrderBy(x => x.Code)
.Select(x => new UnitOfMeasureDto(x.Id, x.Code, x.Name, x.Description))
.ToListAsync(ct);
}
}
public record CreateUnitCommand(string Code, string Name, string? Description) : IRequest<Guid>;
public class CreateUnitValidator : AbstractValidator<CreateUnitCommand>
{
public CreateUnitValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
}
}
public class CreateUnitHandler(IApplicationDbContext db) : IRequestHandler<CreateUnitCommand, Guid>
{
public async Task<Guid> Handle(CreateUnitCommand req, CancellationToken ct)
{
if (await db.UnitsOfMeasure.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
var entity = new UnitOfMeasure { Code = req.Code, Name = req.Name, Description = req.Description };
db.UnitsOfMeasure.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateUnitCommand(Guid Id, string Code, string Name, string? Description) : IRequest;
public class UpdateUnitHandler(IApplicationDbContext db) : IRequestHandler<UpdateUnitCommand>
{
public async Task Handle(UpdateUnitCommand req, CancellationToken ct)
{
var entity = await db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("UnitOfMeasure", req.Id);
if (entity.Code != req.Code && await db.UnitsOfMeasure.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.Name = req.Name;
entity.Description = req.Description;
await db.SaveChangesAsync(ct);
}
}
public record DeleteUnitCommand(Guid Id) : IRequest;
public class DeleteUnitHandler(IApplicationDbContext db) : IRequestHandler<DeleteUnitCommand>
{
public async Task Handle(DeleteUnitCommand req, CancellationToken ct)
{
var entity = await db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("UnitOfMeasure", req.Id);
db.UnitsOfMeasure.Remove(entity); // Soft delete via AuditingInterceptor
await db.SaveChangesAsync(ct);
}
}
// ===== MaterialItem =====
public record ListMaterialsQuery(string? Q = null, string? Category = null) : IRequest<List<MaterialItemDto>>;
public class ListMaterialsHandler(IApplicationDbContext db) : IRequestHandler<ListMaterialsQuery, List<MaterialItemDto>>
{
public async Task<List<MaterialItemDto>> Handle(ListMaterialsQuery req, CancellationToken ct)
{
var q = db.MaterialItems.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Q))
{
var s = req.Q.ToLower();
q = q.Where(x => x.Code.ToLower().Contains(s) || x.Name.ToLower().Contains(s));
}
if (!string.IsNullOrWhiteSpace(req.Category))
q = q.Where(x => x.Category == req.Category);
return await q.OrderBy(x => x.Category).ThenBy(x => x.Code)
.Select(x => new MaterialItemDto(x.Id, x.Code, x.Name, x.Category, x.DefaultUnit, x.Specification, x.OriginCountry, x.IsActive))
.ToListAsync(ct);
}
}
public record CreateMaterialCommand(string Code, string Name, string? Category, string? DefaultUnit, string? Specification, string? OriginCountry, bool IsActive) : IRequest<Guid>;
public class CreateMaterialValidator : AbstractValidator<CreateMaterialCommand>
{
public CreateMaterialValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}
public class CreateMaterialHandler(IApplicationDbContext db) : IRequestHandler<CreateMaterialCommand, Guid>
{
public async Task<Guid> Handle(CreateMaterialCommand req, CancellationToken ct)
{
if (await db.MaterialItems.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
var entity = new MaterialItem
{
Code = req.Code, Name = req.Name, Category = req.Category,
DefaultUnit = req.DefaultUnit, Specification = req.Specification,
OriginCountry = req.OriginCountry, IsActive = req.IsActive,
};
db.MaterialItems.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateMaterialCommand(Guid Id, string Code, string Name, string? Category, string? DefaultUnit, string? Specification, string? OriginCountry, bool IsActive) : IRequest;
public class UpdateMaterialHandler(IApplicationDbContext db) : IRequestHandler<UpdateMaterialCommand>
{
public async Task Handle(UpdateMaterialCommand req, CancellationToken ct)
{
var entity = await db.MaterialItems.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("MaterialItem", req.Id);
if (entity.Code != req.Code && await db.MaterialItems.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.Name = req.Name;
entity.Category = req.Category;
entity.DefaultUnit = req.DefaultUnit;
entity.Specification = req.Specification;
entity.OriginCountry = req.OriginCountry;
entity.IsActive = req.IsActive;
await db.SaveChangesAsync(ct);
}
}
public record DeleteMaterialCommand(Guid Id) : IRequest;
public class DeleteMaterialHandler(IApplicationDbContext db) : IRequestHandler<DeleteMaterialCommand>
{
public async Task Handle(DeleteMaterialCommand req, CancellationToken ct)
{
var entity = await db.MaterialItems.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("MaterialItem", req.Id);
db.MaterialItems.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
// ===== ServiceItem =====
public record ListServicesQuery(string? Q = null, string? Category = null) : IRequest<List<ServiceItemDto>>;
public class ListServicesHandler(IApplicationDbContext db) : IRequestHandler<ListServicesQuery, List<ServiceItemDto>>
{
public async Task<List<ServiceItemDto>> Handle(ListServicesQuery req, CancellationToken ct)
{
var q = db.ServiceItems.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Q))
{
var s = req.Q.ToLower();
q = q.Where(x => x.Code.ToLower().Contains(s) || x.Name.ToLower().Contains(s))
;
}
if (!string.IsNullOrWhiteSpace(req.Category))
q = q.Where(x => x.Category == req.Category)
;
return await q.OrderBy(x => x.Category).ThenBy(x => x.Code)
.Select(x => new ServiceItemDto(x.Id, x.Code, x.Name, x.Category, x.DefaultUnit, x.Description, x.IsActive))
.ToListAsync(ct);
}
}
public record CreateServiceCommand(string Code, string Name, string? Category, string? DefaultUnit, string? Description, bool IsActive) : IRequest<Guid>;
public class CreateServiceValidator : AbstractValidator<CreateServiceCommand>
{
public CreateServiceValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}
public class CreateServiceHandler(IApplicationDbContext db) : IRequestHandler<CreateServiceCommand, Guid>
{
public async Task<Guid> Handle(CreateServiceCommand req, CancellationToken ct)
{
if (await db.ServiceItems.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
var entity = new ServiceItem
{
Code = req.Code, Name = req.Name, Category = req.Category,
DefaultUnit = req.DefaultUnit, Description = req.Description, IsActive = req.IsActive,
};
db.ServiceItems.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateServiceCommand(Guid Id, string Code, string Name, string? Category, string? DefaultUnit, string? Description, bool IsActive) : IRequest;
public class UpdateServiceHandler(IApplicationDbContext db) : IRequestHandler<UpdateServiceCommand>
{
public async Task Handle(UpdateServiceCommand req, CancellationToken ct)
{
var entity = await db.ServiceItems.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("ServiceItem", req.Id);
if (entity.Code != req.Code && await db.ServiceItems.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.Name = req.Name;
entity.Category = req.Category;
entity.DefaultUnit = req.DefaultUnit;
entity.Description = req.Description;
entity.IsActive = req.IsActive;
await db.SaveChangesAsync(ct);
}
}
public record DeleteServiceCommand(Guid Id) : IRequest;
public class DeleteServiceHandler(IApplicationDbContext db) : IRequestHandler<DeleteServiceCommand>
{
public async Task Handle(DeleteServiceCommand req, CancellationToken ct)
{
var entity = await db.ServiceItems.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("ServiceItem", req.Id);
db.ServiceItems.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
// ===== WorkItem =====
public record ListWorkItemsQuery(string? Q = null, string? Category = null) : IRequest<List<WorkItemDto>>;
public class ListWorkItemsHandler(IApplicationDbContext db) : IRequestHandler<ListWorkItemsQuery, List<WorkItemDto>>
{
public async Task<List<WorkItemDto>> Handle(ListWorkItemsQuery req, CancellationToken ct)
{
var q = db.WorkItems.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(req.Q))
{
var s = req.Q.ToLower();
q = q.Where(x => x.Code.ToLower().Contains(s) || x.Name.ToLower().Contains(s));
}
if (!string.IsNullOrWhiteSpace(req.Category))
q = q.Where(x => x.Category == req.Category);
return await q.OrderBy(x => x.Category).ThenBy(x => x.Code)
.Select(x => new WorkItemDto(x.Id, x.Code, x.Name, x.Category, x.DefaultUnit, x.Description, x.IsActive))
.ToListAsync(ct);
}
}
public record CreateWorkItemCommand(string Code, string Name, string? Category, string? DefaultUnit, string? Description, bool IsActive) : IRequest<Guid>;
public class CreateWorkItemValidator : AbstractValidator<CreateWorkItemCommand>
{
public CreateWorkItemValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
}
}
public class CreateWorkItemHandler(IApplicationDbContext db) : IRequestHandler<CreateWorkItemCommand, Guid>
{
public async Task<Guid> Handle(CreateWorkItemCommand req, CancellationToken ct)
{
if (await db.WorkItems.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
var entity = new WorkItem
{
Code = req.Code, Name = req.Name, Category = req.Category,
DefaultUnit = req.DefaultUnit, Description = req.Description, IsActive = req.IsActive,
};
db.WorkItems.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateWorkItemCommand(Guid Id, string Code, string Name, string? Category, string? DefaultUnit, string? Description, bool IsActive) : IRequest;
public class UpdateWorkItemHandler(IApplicationDbContext db) : IRequestHandler<UpdateWorkItemCommand>
{
public async Task Handle(UpdateWorkItemCommand req, CancellationToken ct)
{
var entity = await db.WorkItems.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("WorkItem", req.Id);
if (entity.Code != req.Code && await db.WorkItems.AnyAsync(x => x.Code == req.Code, ct))
throw new ConflictException($"Mã '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.Name = req.Name;
entity.Category = req.Category;
entity.DefaultUnit = req.DefaultUnit;
entity.Description = req.Description;
entity.IsActive = req.IsActive;
await db.SaveChangesAsync(ct);
}
}
public record DeleteWorkItemCommand(Guid Id) : IRequest;
public class DeleteWorkItemHandler(IApplicationDbContext db) : IRequestHandler<DeleteWorkItemCommand>
{
public async Task Handle(DeleteWorkItemCommand req, CancellationToken ct)
{
var entity = await db.WorkItems.FirstOrDefaultAsync(x => x.Id == req.Id, ct)
?? throw new NotFoundException("WorkItem", req.Id);
db.WorkItems.Remove(entity);
await db.SaveChangesAsync(ct);
}
}