[CLAUDE] App+Api: 7 Details CRUD endpoints + Changelogs query
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m37s
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:
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
Reference in New Issue
Block a user