[CLAUDE] Domain+App+Api: 4 master catalogs cho Details (migration 10)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m48s
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:
@ -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; }
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user