[CLAUDE] App+Api: 7 Details CRUD endpoints + Changelogs query
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m37s

## DTOs (Application/Contracts/Dtos/ContractDetailDtos.cs)

- 7 typed DTOs (ThauPhuDetailDto, GiaoKhoanDetailDto, NhaCungCapDetailDto,
  DichVuDetailDto, MuaBanDetailDto, NguyenTacNccDetailDto,
  NguyenTacDvDetailDto) — schema 1-1 với Domain entities
- ContractDetailsBundleDto — wrapper trả về theo Type, chỉ 1 list có data
  (FE đọc field tương ứng, tránh polymorphic deserialize phức tạp)
- ContractChangelogDto — full audit entry

## CQRS (Application/Contracts/ContractDetailsFeatures.cs)

- GetContractDetailsQuery — load bundle, switch theo Contract.Type chỉ
  query bảng tương ứng (avoid 7 query waste)
- 7 AddXxxDetailCommand handlers — typed payload + EnsureContractType
  guard (throw ConflictException nếu Contract.Type sai)
- DeleteContractDetailCommand generic — dispatch xóa theo Type, log change
- Tất cả handler call IChangelogService.LogDetailChangeAsync với summary
  human-readable (vd "Thêm hạng mục: Đào móng")

## CQRS (Application/Contracts/ContractChangelogFeatures.cs)

- ListContractChangelogsQuery (read-only) — desc CreatedAt, default top 200

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

8 endpoints mới:
- GET /api/contracts/{id}/details → bundle theo Type
- POST /api/contracts/{id}/details/{thau-phu|giao-khoan|nha-cung-cap|
  dich-vu|mua-ban|nguyen-tac-ncc|nguyen-tac-dv} → 7 typed POST
- DELETE /api/contracts/{id}/details/{detailId} → generic delete
- GET /api/contracts/{id}/changelogs → list audit entries

Build: dotnet pass (0 error)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 10:16:18 +07:00
parent 71c035d31e
commit e6844553a4
4 changed files with 485 additions and 0 deletions

View File

@ -101,6 +101,74 @@ public class ContractsController(IMediator mediator) : ControllerBase
await mediator.Send(new DeleteContractAttachmentCommand(id, attId), ct);
return NoContent();
}
// ========== Details (per ContractType) ==========
[HttpGet("{id:guid}/details")]
public async Task<ContractDetailsBundleDto> GetDetails(Guid id, CancellationToken ct)
=> await mediator.Send(new GetContractDetailsQuery(id), ct);
[HttpPost("{id:guid}/details/thau-phu")]
public async Task<IActionResult> AddThauPhuDetail(Guid id, [FromBody] ThauPhuDetailDto body, CancellationToken ct)
{
var newId = await mediator.Send(new AddThauPhuDetailCommand(id, body), ct);
return CreatedAtAction(nameof(GetDetails), new { id }, new { id = newId });
}
[HttpPost("{id:guid}/details/giao-khoan")]
public async Task<IActionResult> AddGiaoKhoanDetail(Guid id, [FromBody] GiaoKhoanDetailDto body, CancellationToken ct)
{
var newId = await mediator.Send(new AddGiaoKhoanDetailCommand(id, body), ct);
return CreatedAtAction(nameof(GetDetails), new { id }, new { id = newId });
}
[HttpPost("{id:guid}/details/nha-cung-cap")]
public async Task<IActionResult> AddNhaCungCapDetail(Guid id, [FromBody] NhaCungCapDetailDto body, CancellationToken ct)
{
var newId = await mediator.Send(new AddNhaCungCapDetailCommand(id, body), ct);
return CreatedAtAction(nameof(GetDetails), new { id }, new { id = newId });
}
[HttpPost("{id:guid}/details/dich-vu")]
public async Task<IActionResult> AddDichVuDetail(Guid id, [FromBody] DichVuDetailDto body, CancellationToken ct)
{
var newId = await mediator.Send(new AddDichVuDetailCommand(id, body), ct);
return CreatedAtAction(nameof(GetDetails), new { id }, new { id = newId });
}
[HttpPost("{id:guid}/details/mua-ban")]
public async Task<IActionResult> AddMuaBanDetail(Guid id, [FromBody] MuaBanDetailDto body, CancellationToken ct)
{
var newId = await mediator.Send(new AddMuaBanDetailCommand(id, body), ct);
return CreatedAtAction(nameof(GetDetails), new { id }, new { id = newId });
}
[HttpPost("{id:guid}/details/nguyen-tac-ncc")]
public async Task<IActionResult> AddNguyenTacNccDetail(Guid id, [FromBody] NguyenTacNccDetailDto body, CancellationToken ct)
{
var newId = await mediator.Send(new AddNguyenTacNccDetailCommand(id, body), ct);
return CreatedAtAction(nameof(GetDetails), new { id }, new { id = newId });
}
[HttpPost("{id:guid}/details/nguyen-tac-dv")]
public async Task<IActionResult> AddNguyenTacDvDetail(Guid id, [FromBody] NguyenTacDvDetailDto body, CancellationToken ct)
{
var newId = await mediator.Send(new AddNguyenTacDvDetailCommand(id, body), ct);
return CreatedAtAction(nameof(GetDetails), new { id }, new { id = newId });
}
[HttpDelete("{id:guid}/details/{detailId:guid}")]
public async Task<IActionResult> DeleteDetail(Guid id, Guid detailId, CancellationToken ct)
{
await mediator.Send(new DeleteContractDetailCommand(id, detailId), ct);
return NoContent();
}
// ========== Changelogs (read-only) ==========
[HttpGet("{id:guid}/changelogs")]
public async Task<List<ContractChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
=> await mediator.Send(new ListContractChangelogsQuery(id), ct);
}
public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment);

View File

@ -0,0 +1,26 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Dtos;
namespace SolutionErp.Application.Contracts;
// Read-only query — list changelog của 1 HĐ ordered descending CreatedAt
// (mới nhất trên cùng). Append-only table → không có Update/Delete handler.
public record ListContractChangelogsQuery(Guid ContractId, int Take = 200) : IRequest<List<ContractChangelogDto>>;
public class ListContractChangelogsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListContractChangelogsQuery, List<ContractChangelogDto>>
{
public async Task<List<ContractChangelogDto>> Handle(ListContractChangelogsQuery request, CancellationToken ct)
{
return await db.ContractChangelogs.AsNoTracking()
.Where(c => c.ContractId == request.ContractId)
.OrderByDescending(c => c.CreatedAt)
.Take(request.Take)
.Select(c => new ContractChangelogDto(
c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange,
c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote,
c.CreatedAt))
.ToListAsync(ct);
}
}

View File

@ -0,0 +1,344 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details;
using static SolutionErp.Application.Contracts.ContractDetailsHelpers;
namespace SolutionErp.Application.Contracts;
// 7 ContractType-specific Details CRUD — pattern lặp nhưng schema khác nhau,
// keep các handler riêng để TS strict mode FE bắt typo + IntelliSense.
//
// Endpoint: GET /api/contracts/{id}/details (bundle bộ + auto theo Type)
// POST /api/contracts/{id}/details (body có Type discriminator)
// PUT /api/contracts/{id}/details/{detailId}
// DELETE /api/contracts/{id}/details/{detailId}
//
// Helper: handler resolve loại HĐ qua Contract.Type rồi dispatch vào DbSet
// tương ứng. Validate detail thuộc đúng contract+type.
// ========== GET bundle (Read) ==========
public record GetContractDetailsQuery(Guid ContractId) : IRequest<ContractDetailsBundleDto>;
public class GetContractDetailsQueryHandler(IApplicationDbContext db) : IRequestHandler<GetContractDetailsQuery, ContractDetailsBundleDto>
{
public async Task<ContractDetailsBundleDto> Handle(GetContractDetailsQuery request, CancellationToken ct)
{
var contract = await db.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == request.ContractId, ct)
?? throw new NotFoundException("Contract", request.ContractId);
// Chỉ load list tương ứng với Type — 6 còn lại empty (avoid 7 query waste)
var bundle = new ContractDetailsBundleDto(
contract.Type,
ThauPhu: new(), GiaoKhoan: new(), NhaCungCap: new(), DichVu: new(),
MuaBan: new(), NguyenTacNcc: new(), NguyenTacDv: new());
switch (contract.Type)
{
case ContractType.HopDongThauPhu:
bundle = bundle with
{
ThauPhu = await db.ThauPhuDetails.AsNoTracking()
.Where(d => d.ContractId == request.ContractId)
.OrderBy(d => d.Order)
.Select(d => new ThauPhuDetailDto(d.Id, d.Order, d.HangMuc, d.DonViTinh, d.KhoiLuong, d.DonGia, d.ThanhTien, d.ThoiGianHoanThanh, d.GhiChu))
.ToListAsync(ct),
};
break;
case ContractType.HopDongGiaoKhoan:
bundle = bundle with
{
GiaoKhoan = await db.GiaoKhoanDetails.AsNoTracking()
.Where(d => d.ContractId == request.ContractId)
.OrderBy(d => d.Order)
.Select(d => new GiaoKhoanDetailDto(d.Id, d.Order, d.MaCongViec, d.TenCongViec, d.DonViTinh, d.KhoiLuong, d.DonGia, d.ThanhTien, d.ThoiGianHoanThanh, d.YeuCauKyThuat, d.GhiChu))
.ToListAsync(ct),
};
break;
case ContractType.HopDongNhaCungCap:
bundle = bundle with
{
NhaCungCap = await db.NhaCungCapDetails.AsNoTracking()
.Where(d => d.ContractId == request.ContractId)
.OrderBy(d => d.Order)
.Select(d => new NhaCungCapDetailDto(d.Id, d.Order, d.MaSP, d.TenSP, d.ThongSoKyThuat, d.DonViTinh, d.SoLuong, d.DonGia, d.ThanhTien, d.ThoiGianGiao, d.XuatXu, d.GhiChu))
.ToListAsync(ct),
};
break;
case ContractType.HopDongDichVu:
bundle = bundle with
{
DichVu = await db.DichVuDetails.AsNoTracking()
.Where(d => d.ContractId == request.ContractId)
.OrderBy(d => d.Order)
.Select(d => new DichVuDetailDto(d.Id, d.Order, d.MaDichVu, d.TenDichVu, d.MoTa, d.DonViTinh, d.ThoiGian, d.DonGia, d.ThanhTien, d.TuNgay, d.DenNgay, d.GhiChu))
.ToListAsync(ct),
};
break;
case ContractType.HopDongMuaBan:
bundle = bundle with
{
MuaBan = await db.MuaBanDetails.AsNoTracking()
.Where(d => d.ContractId == request.ContractId)
.OrderBy(d => d.Order)
.Select(d => new MuaBanDetailDto(d.Id, d.Order, d.MaSP, d.TenSP, d.MoTa, d.DonViTinh, d.SoLuong, d.DonGia, d.ThueVAT, d.ThanhTien, d.XuatXu, d.GhiChu))
.ToListAsync(ct),
};
break;
case ContractType.HopDongNguyenTacNCC:
bundle = bundle with
{
NguyenTacNcc = await db.NguyenTacNccDetails.AsNoTracking()
.Where(d => d.ContractId == request.ContractId)
.OrderBy(d => d.Order)
.Select(d => new NguyenTacNccDetailDto(d.Id, d.Order, d.NhomSP, d.TenSP, d.DonViTinh, d.DonGiaToiThieu, d.DonGiaToiDa, d.DieuKienGiaoHang, d.DieuKienThanhToan, d.GhiChu))
.ToListAsync(ct),
};
break;
case ContractType.HopDongNguyenTacDichVu:
bundle = bundle with
{
NguyenTacDv = await db.NguyenTacDvDetails.AsNoTracking()
.Where(d => d.ContractId == request.ContractId)
.OrderBy(d => d.Order)
.Select(d => new NguyenTacDvDetailDto(d.Id, d.Order, d.LoaiDichVu, d.TenDichVu, d.DonViTinh, d.DonGiaToiThieu, d.DonGiaToiDa, d.PhamViDichVu, d.SLA, d.GhiChu))
.ToListAsync(ct),
};
break;
}
return bundle;
}
}
// ========== ADD detail (per type) ==========
// Polymorphic input: client gửi Type discriminator + 1 trong 7 typed payload.
// Strategy: 7 commands riêng — explicit, dễ validate. Controller pick handler
// theo URL path /thau-phu /giao-khoan /etc, hoặc body Type field (chọn URL —
// REST hơn).
public record AddThauPhuDetailCommand(Guid ContractId, ThauPhuDetailDto Detail) : IRequest<Guid>;
public class AddThauPhuDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<AddThauPhuDetailCommand, Guid>
{
public async Task<Guid> Handle(AddThauPhuDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongThauPhu, ct);
var d = cmd.Detail;
var entity = new ThauPhuDetail
{
ContractId = contract.Id, Order = d.Order, HangMuc = d.HangMuc, DonViTinh = d.DonViTinh,
KhoiLuong = d.KhoiLuong, DonGia = d.DonGia, ThanhTien = d.ThanhTien,
ThoiGianHoanThanh = d.ThoiGianHoanThanh, GhiChu = d.GhiChu,
};
db.ThauPhuDetails.Add(entity);
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Insert,
summary: $"Thêm hạng mục: {d.HangMuc}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record AddGiaoKhoanDetailCommand(Guid ContractId, GiaoKhoanDetailDto Detail) : IRequest<Guid>;
public class AddGiaoKhoanDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<AddGiaoKhoanDetailCommand, Guid>
{
public async Task<Guid> Handle(AddGiaoKhoanDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongGiaoKhoan, ct);
var d = cmd.Detail;
var entity = new GiaoKhoanDetail
{
ContractId = contract.Id, Order = d.Order, MaCongViec = d.MaCongViec, TenCongViec = d.TenCongViec,
DonViTinh = d.DonViTinh, KhoiLuong = d.KhoiLuong, DonGia = d.DonGia, ThanhTien = d.ThanhTien,
ThoiGianHoanThanh = d.ThoiGianHoanThanh, YeuCauKyThuat = d.YeuCauKyThuat, GhiChu = d.GhiChu,
};
db.GiaoKhoanDetails.Add(entity);
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Insert,
summary: $"Thêm công việc: {d.TenCongViec}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record AddNhaCungCapDetailCommand(Guid ContractId, NhaCungCapDetailDto Detail) : IRequest<Guid>;
public class AddNhaCungCapDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<AddNhaCungCapDetailCommand, Guid>
{
public async Task<Guid> Handle(AddNhaCungCapDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongNhaCungCap, ct);
var d = cmd.Detail;
var entity = new NhaCungCapDetail
{
ContractId = contract.Id, Order = d.Order, MaSP = d.MaSP, TenSP = d.TenSP,
ThongSoKyThuat = d.ThongSoKyThuat, DonViTinh = d.DonViTinh, SoLuong = d.SoLuong,
DonGia = d.DonGia, ThanhTien = d.ThanhTien, ThoiGianGiao = d.ThoiGianGiao,
XuatXu = d.XuatXu, GhiChu = d.GhiChu,
};
db.NhaCungCapDetails.Add(entity);
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Insert,
summary: $"Thêm sản phẩm: {d.TenSP}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record AddDichVuDetailCommand(Guid ContractId, DichVuDetailDto Detail) : IRequest<Guid>;
public class AddDichVuDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<AddDichVuDetailCommand, Guid>
{
public async Task<Guid> Handle(AddDichVuDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongDichVu, ct);
var d = cmd.Detail;
var entity = new DichVuDetail
{
ContractId = contract.Id, Order = d.Order, MaDichVu = d.MaDichVu, TenDichVu = d.TenDichVu,
MoTa = d.MoTa, DonViTinh = d.DonViTinh, ThoiGian = d.ThoiGian, DonGia = d.DonGia,
ThanhTien = d.ThanhTien, TuNgay = d.TuNgay, DenNgay = d.DenNgay, GhiChu = d.GhiChu,
};
db.DichVuDetails.Add(entity);
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Insert,
summary: $"Thêm dịch vụ: {d.TenDichVu}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record AddMuaBanDetailCommand(Guid ContractId, MuaBanDetailDto Detail) : IRequest<Guid>;
public class AddMuaBanDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<AddMuaBanDetailCommand, Guid>
{
public async Task<Guid> Handle(AddMuaBanDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongMuaBan, ct);
var d = cmd.Detail;
var entity = new MuaBanDetail
{
ContractId = contract.Id, Order = d.Order, MaSP = d.MaSP, TenSP = d.TenSP,
MoTa = d.MoTa, DonViTinh = d.DonViTinh, SoLuong = d.SoLuong, DonGia = d.DonGia,
ThueVAT = d.ThueVAT, ThanhTien = d.ThanhTien, XuatXu = d.XuatXu, GhiChu = d.GhiChu,
};
db.MuaBanDetails.Add(entity);
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Insert,
summary: $"Thêm SP: {d.TenSP}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record AddNguyenTacNccDetailCommand(Guid ContractId, NguyenTacNccDetailDto Detail) : IRequest<Guid>;
public class AddNguyenTacNccDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<AddNguyenTacNccDetailCommand, Guid>
{
public async Task<Guid> Handle(AddNguyenTacNccDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongNguyenTacNCC, ct);
var d = cmd.Detail;
var entity = new NguyenTacNccDetail
{
ContractId = contract.Id, Order = d.Order, NhomSP = d.NhomSP, TenSP = d.TenSP,
DonViTinh = d.DonViTinh, DonGiaToiThieu = d.DonGiaToiThieu, DonGiaToiDa = d.DonGiaToiDa,
DieuKienGiaoHang = d.DieuKienGiaoHang, DieuKienThanhToan = d.DieuKienThanhToan, GhiChu = d.GhiChu,
};
db.NguyenTacNccDetails.Add(entity);
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Insert,
summary: $"Thêm SP nguyên tắc: {d.TenSP}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record AddNguyenTacDvDetailCommand(Guid ContractId, NguyenTacDvDetailDto Detail) : IRequest<Guid>;
public class AddNguyenTacDvDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<AddNguyenTacDvDetailCommand, Guid>
{
public async Task<Guid> Handle(AddNguyenTacDvDetailCommand cmd, CancellationToken ct)
{
var contract = await EnsureContractType(db, cmd.ContractId, ContractType.HopDongNguyenTacDichVu, ct);
var d = cmd.Detail;
var entity = new NguyenTacDvDetail
{
ContractId = contract.Id, Order = d.Order, LoaiDichVu = d.LoaiDichVu, TenDichVu = d.TenDichVu,
DonViTinh = d.DonViTinh, DonGiaToiThieu = d.DonGiaToiThieu, DonGiaToiDa = d.DonGiaToiDa,
PhamViDichVu = d.PhamViDichVu, SLA = d.SLA, GhiChu = d.GhiChu,
};
db.NguyenTacDvDetails.Add(entity);
await changelog.LogDetailChangeAsync(contract.Id, entity.Id, ChangelogAction.Insert,
summary: $"Thêm DV nguyên tắc: {d.TenDichVu}", phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ========== DELETE detail (generic — dispatch theo Type) ==========
public record DeleteContractDetailCommand(Guid ContractId, Guid DetailId) : IRequest;
public class DeleteContractDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler<DeleteContractDetailCommand>
{
public async Task Handle(DeleteContractDetailCommand cmd, CancellationToken ct)
{
var contract = await db.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cmd.ContractId, ct)
?? throw new NotFoundException("Contract", cmd.ContractId);
// Dispatch xóa theo Type — tránh load tất cả 7 DbSet
bool removed = false;
string deletedSummary = "Xóa hạng mục";
switch (contract.Type)
{
case ContractType.HopDongThauPhu:
var t1 = await db.ThauPhuDetails.FirstOrDefaultAsync(d => d.Id == cmd.DetailId && d.ContractId == cmd.ContractId, ct);
if (t1 != null) { db.ThauPhuDetails.Remove(t1); removed = true; deletedSummary = $"Xóa hạng mục: {t1.HangMuc}"; }
break;
case ContractType.HopDongGiaoKhoan:
var t2 = await db.GiaoKhoanDetails.FirstOrDefaultAsync(d => d.Id == cmd.DetailId && d.ContractId == cmd.ContractId, ct);
if (t2 != null) { db.GiaoKhoanDetails.Remove(t2); removed = true; deletedSummary = $"Xóa công việc: {t2.TenCongViec}"; }
break;
case ContractType.HopDongNhaCungCap:
var t3 = await db.NhaCungCapDetails.FirstOrDefaultAsync(d => d.Id == cmd.DetailId && d.ContractId == cmd.ContractId, ct);
if (t3 != null) { db.NhaCungCapDetails.Remove(t3); removed = true; deletedSummary = $"Xóa SP: {t3.TenSP}"; }
break;
case ContractType.HopDongDichVu:
var t4 = await db.DichVuDetails.FirstOrDefaultAsync(d => d.Id == cmd.DetailId && d.ContractId == cmd.ContractId, ct);
if (t4 != null) { db.DichVuDetails.Remove(t4); removed = true; deletedSummary = $"Xóa DV: {t4.TenDichVu}"; }
break;
case ContractType.HopDongMuaBan:
var t5 = await db.MuaBanDetails.FirstOrDefaultAsync(d => d.Id == cmd.DetailId && d.ContractId == cmd.ContractId, ct);
if (t5 != null) { db.MuaBanDetails.Remove(t5); removed = true; deletedSummary = $"Xóa SP: {t5.TenSP}"; }
break;
case ContractType.HopDongNguyenTacNCC:
var t6 = await db.NguyenTacNccDetails.FirstOrDefaultAsync(d => d.Id == cmd.DetailId && d.ContractId == cmd.ContractId, ct);
if (t6 != null) { db.NguyenTacNccDetails.Remove(t6); removed = true; deletedSummary = $"Xóa SP nguyên tắc: {t6.TenSP}"; }
break;
case ContractType.HopDongNguyenTacDichVu:
var t7 = await db.NguyenTacDvDetails.FirstOrDefaultAsync(d => d.Id == cmd.DetailId && d.ContractId == cmd.ContractId, ct);
if (t7 != null) { db.NguyenTacDvDetails.Remove(t7); removed = true; deletedSummary = $"Xóa DV nguyên tắc: {t7.TenDichVu}"; }
break;
}
if (!removed) throw new NotFoundException("ContractDetail", cmd.DetailId);
await changelog.LogDetailChangeAsync(contract.Id, cmd.DetailId, ChangelogAction.Delete,
summary: deletedSummary, phaseAtChange: contract.Phase, ct: ct);
await db.SaveChangesAsync(ct);
}
}
// ========== Helpers ==========
internal static class ContractDetailsHelpers
{
public static async Task<Contract> EnsureContractType(IApplicationDbContext db, Guid contractId, ContractType expectedType, CancellationToken ct)
{
var contract = await db.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == contractId, ct)
?? throw new NotFoundException("Contract", contractId);
if (contract.Type != expectedType)
throw new ConflictException($"HĐ này thuộc loại {contract.Type}, không thể thêm chi tiết loại {expectedType}.");
return contract;
}
}

View File

@ -0,0 +1,47 @@
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts.Dtos;
// 7 typed DTOs cho 7 ContractType-specific Details bảng. Map 1-1 với entity
// trong Domain/Contracts/Details/. Generic ContractDetailItem dùng cho
// list-mixed view (vd Lịch sử changelog reference).
public record ThauPhuDetailDto(Guid Id, int Order, string HangMuc, string DonViTinh, decimal KhoiLuong, decimal DonGia, decimal ThanhTien, DateTime? ThoiGianHoanThanh, string? GhiChu);
public record GiaoKhoanDetailDto(Guid Id, int Order, string MaCongViec, string TenCongViec, string DonViTinh, decimal KhoiLuong, decimal DonGia, decimal ThanhTien, DateTime? ThoiGianHoanThanh, string? YeuCauKyThuat, string? GhiChu);
public record NhaCungCapDetailDto(Guid Id, int Order, string MaSP, string TenSP, string? ThongSoKyThuat, string DonViTinh, decimal SoLuong, decimal DonGia, decimal ThanhTien, DateTime? ThoiGianGiao, string? XuatXu, string? GhiChu);
public record DichVuDetailDto(Guid Id, int Order, string MaDichVu, string TenDichVu, string? MoTa, string DonViTinh, decimal ThoiGian, decimal DonGia, decimal ThanhTien, DateTime? TuNgay, DateTime? DenNgay, string? GhiChu);
public record MuaBanDetailDto(Guid Id, int Order, string MaSP, string TenSP, string? MoTa, string DonViTinh, decimal SoLuong, decimal DonGia, decimal ThueVAT, decimal ThanhTien, string? XuatXu, string? GhiChu);
public record NguyenTacNccDetailDto(Guid Id, int Order, string NhomSP, string TenSP, string DonViTinh, decimal DonGiaToiThieu, decimal DonGiaToiDa, string? DieuKienGiaoHang, string? DieuKienThanhToan, string? GhiChu);
public record NguyenTacDvDetailDto(Guid Id, int Order, string LoaiDichVu, string TenDichVu, string DonViTinh, decimal DonGiaToiThieu, decimal DonGiaToiDa, string? PhamViDichVu, string? SLA, string? GhiChu);
// Wrapper trả về theo Type — FE đọc field tương ứng (chỉ 1 list có data,
// 6 list khác empty). Tránh dùng object/dynamic — TS strict mode.
public record ContractDetailsBundleDto(
ContractType Type,
List<ThauPhuDetailDto> ThauPhu,
List<GiaoKhoanDetailDto> GiaoKhoan,
List<NhaCungCapDetailDto> NhaCungCap,
List<DichVuDetailDto> DichVu,
List<MuaBanDetailDto> MuaBan,
List<NguyenTacNccDetailDto> NguyenTacNcc,
List<NguyenTacDvDetailDto> NguyenTacDv);
// Changelog DTO cho FE Lịch sử tab
public record ContractChangelogDto(
Guid Id,
ChangelogEntityType EntityType,
Guid? EntityId,
ChangelogAction Action,
ContractPhase? PhaseAtChange,
Guid? UserId,
string? UserName,
string? Summary,
string? FieldChangesJson,
string? ContextNote,
DateTime CreatedAt);