From e6844553a42a7e57474c69b51145138672266820 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 23 Apr 2026 10:16:18 +0700 Subject: [PATCH] [CLAUDE] App+Api: 7 Details CRUD endpoints + Changelogs query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .../Controllers/ContractsController.cs | 68 ++++ .../Contracts/ContractChangelogFeatures.cs | 26 ++ .../Contracts/ContractDetailsFeatures.cs | 344 ++++++++++++++++++ .../Contracts/Dtos/ContractDetailDtos.cs | 47 +++ 4 files changed, 485 insertions(+) create mode 100644 src/Backend/SolutionErp.Application/Contracts/ContractChangelogFeatures.cs create mode 100644 src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs create mode 100644 src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDetailDtos.cs diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs index 73f73f7..c992f26 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs @@ -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 GetDetails(Guid id, CancellationToken ct) + => await mediator.Send(new GetContractDetailsQuery(id), ct); + + [HttpPost("{id:guid}/details/thau-phu")] + public async Task 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 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 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 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 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 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 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 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> GetChangelogs(Guid id, CancellationToken ct) + => await mediator.Send(new ListContractChangelogsQuery(id), ct); } public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment); diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractChangelogFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractChangelogFeatures.cs new file mode 100644 index 0000000..2d7253a --- /dev/null +++ b/src/Backend/SolutionErp.Application/Contracts/ContractChangelogFeatures.cs @@ -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>; + +public class ListContractChangelogsQueryHandler(IApplicationDbContext db) : IRequestHandler> +{ + public async Task> 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); + } +} diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs new file mode 100644 index 0000000..18b87c5 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Contracts/ContractDetailsFeatures.cs @@ -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; + +public class GetContractDetailsQueryHandler(IApplicationDbContext db) : IRequestHandler +{ + public async Task 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; +public class AddThauPhuDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler +{ + public async Task 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; +public class AddGiaoKhoanDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler +{ + public async Task 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; +public class AddNhaCungCapDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler +{ + public async Task 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; +public class AddDichVuDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler +{ + public async Task 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; +public class AddMuaBanDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler +{ + public async Task 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; +public class AddNguyenTacNccDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler +{ + public async Task 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; +public class AddNguyenTacDvDetailHandler(IApplicationDbContext db, IChangelogService changelog) : IRequestHandler +{ + public async Task 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 +{ + 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 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; + } +} diff --git a/src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDetailDtos.cs b/src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDetailDtos.cs new file mode 100644 index 0000000..02fdd46 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDetailDtos.cs @@ -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 ThauPhu, + List GiaoKhoan, + List NhaCungCap, + List DichVu, + List MuaBan, + List NguyenTacNcc, + List 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);