[CLAUDE] Contract: KHKK them N hang muc (gia+ghi chu) + gop muc 3-4 file dinh kem (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m32s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m32s
Nguon: /fable-real investigator-codebase (wf_a574cff1-6b9, 1 lane, 169K tok).
Spec 3-muc: runs/2026-08-05-S175-duan-budget-column/spec-hang-muc-nhieu-dong-va-cot-du-an-05-08-2026.md
Anh chot: "Chi them hang muc -> nhieu hang muc -> gia va ghi chu, vay thoi" + "file 3+4 ->
Hop thanh file dinh kem nhu cua Duyet NCC nhe".
=== 1. THEM N HANG MUC / NCC (0 MIGRATION) ===
🔴 Cau hoi TIEN da co dap an: KHONG co luat chia — NGUOI NHAP TAY tung dong.
Truoc do lead dung lai vi hoi "tach N hang muc thi chia tien NCC the nao"; anh tra loi khong
theo tien, nhap tay. Het chan.
BE — 0 migration (unique 3 cot Plan x Supplier x CatalogEntry co tu Mig 71; may DA rong,
lan nay chi bung UI — dung khuon "may rong, UI hep" anh chot S164):
· UpdateContractSigningPlanLineCommand +ProposedAmount? +Note?, CatalogEntryId -> NULLABLE.
Null-safe khuon UpdateDraftCommand:559-563 => call-site FE cu (gui dung 1 field) KHONG phai
sua dong nao.
· AddContractSigningPlanLineCommand (MOI) + route POST {id}/lines.
🔴 SupplierId PHAI thuoc phieu — NCC den tu winner phieu Duyet NCC; de NCC moi o day = ky
voi nha thau chua ai chon. Khong thuoc => 409.
🔴 CatalogEntryId BAT BUOC: unique index CO Y khong loc "IS NOT NULL"
(ContractSigningPlanLineConfiguration.cs:35-38) => them dong NULL se dung dong goc.
🔴 PeReferenceAmount = 0, KHONG copy so tham chieu cua NCC. Tham chieu la tong bao gia DUOC
CHON cua CA NCC, da nam tron o dong goc (Features.cs:512-515). Lap lai o dong them tay =
DEM DOI tien tham chieu du an. No cung la DAU phan biet dong-goc/dong-them-tay.
· DeleteContractSigningPlanLineCommand (MOI) + route DELETE {id}/lines/{lineId}.
🔴 CHI xoa duoc dong THEM TAY (PeReferenceAmount == 0). Dong goc mang snapshot tham chieu —
xoa la mat so goc + mat dau vet NCC tung trung thau.
· Bat TRUNG cap (Plan, Supplier, CatalogEntry) TRUOC SaveChanges => 409 doc duoc, thay vi 500
unique-violation. Ap cho CA duong them LAN duong doi hang muc (NCC nay co N dong => doi
trung hang muc dong khac cung NCC la co that).
· Rao ContractId nay che CA TIEN, khong chi hang muc: ProposedAmount la so di vao HD.
· Changelog ghi cap CU->MOI cho tien ("gia de xuat 1.000 -> 2.000 d") — day la tien, nguoi
duyet phai truy duoc ai doi tu bao nhieu sang bao nhieu, khong chi biet "co sua".
· 3 route deu policy KeHoachKyKet.Update (gotcha #85 — dung key cua endpoint; "tao" o module
nay nghia la tao PHIEU nen POST line van la Update).
FE: cot GIA DE XUAT + GHI CHU nhap duoc inline (chi Nhap/Tra lai, chi dong chua co HD), luu
onBlur va CHI khi so doi that. Nut "+ Them hang muc" liet ke dung NCC da co tren phieu. Dong
them tay hien "—" o cot tham chieu (khong phai "0 d" — de khong doc nham la tham chieu bang 0).
=== 2. GOP MUC 3 + 4 -> 1 KHOI "FILE DINH KEM" ===
Khuon dong file be tu PE (PeDetailTabs.tsx:3382-3418): 1 danh sach PHANG, moi dong co badge
cho biet file thuoc can cu nao (hoac "Ho so phieu" neu o muc phieu), + eye xem truoc + download.
🔴 Bang can cu 5 cot GO khoi man nhung MAY GIU NGUYEN: entity KhkkDossierItem + 3 endpoint +
changelog con nguyen; kind/status/TVGS/ghi-chu van sua qua nut ✎ (mo dung dialog cu, du 5
truong) va van hien duoi dang badge. Go luon entity de "cho gon giao dien" = mat du-lieu BE
dang ghi va nguoi duyet dang doc.
Can cu DA KHAI ma chua co file van hien 1 dong (dashed) — khong thi khai xong no bien mat va
nguoi dung tuong bam hut.
=== 3. ENGINE BAC 2 GIA-THUYET CUA LEAD (ghi lai vi la SAI CUA LEAD) ===
(a) Lead: "cot DU AN la DI-CHUNG module Budget cu bi Mig 50 rut ruot" — SAI. Cot sinh o
e33481e 19-06 (Mig 56), SAU Mig 50 (13-06) 6 ngay, va CUNG COMMIT voi comment y-dinh
PeWorkItemBudget.cs:19 "DU AN: hien thi FE-only (chua wire BE — sau moi co NGUOI DU AN
NHAP)" => CHO-CHUA-SAN co chu dich, khong phai xac chet.
(b) Lead do "≥3 cho hardcode —" — THIEU. That = 11 o/app (Block A 3 + Block B 8), mirror 2 app.
(c) 🔑 PHAT HIEN: Project.BudgetTotal DA TON TAI tu Mig 2 (day-1), wired tron 2 dau (BE CRUD
ProjectFeatures.cs + FE cot "Ngan sach" ProjectsPage.tsx:117 + form :205). Chi chua noi vao
ma tran PE, va DU-LIEU RONG: Dev 61/69 du an NULL (8 cai co so deu la demo-seed gated-OFF
prod). Rat co the CHINH LA thu anh noi "ben muc ngan sach co muc du an chua dien vao" —
lop "co-che dung, du-lieu khong co" lan thu 7.
=== CON NO ===
🔴 Cot DU AN (11 o) CHUA LAND — cho anh chot 3 cau trong spec §③: (1) Sigma KHKK do vao O NAO
trong 11 o; (2) phieu DaDuyet thi cot DU AN live hay dong bang; (3) sua thang ProposedAmount
(delta tu-audit = Proposed − PeReference) du chua hay can field "dieu chinh" rieng.
🔴 CHUA soi mat prod 375/1366/1440 (5 lane NO-SCREENSHOT, gotcha #3).
🔸 Test-before cho lop luy-ke la BAT BUOC khi land cot DU AN (financial-critical, rules §7) —
lan nay chua cham luy-ke nen chua no test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -123,7 +123,30 @@ public class ContractSigningPlansController(
|
||||
public async Task<IActionResult> UpdateLine(
|
||||
Guid id, Guid lineId, [FromBody] UpdateSigningPlanLineBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new UpdateContractSigningPlanLineCommand(id, lineId, body.CatalogEntryId), ct);
|
||||
await mediator.Send(new UpdateContractSigningPlanLineCommand(
|
||||
id, lineId, body.CatalogEntryId, body.ProposedAmount, body.Note), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// [S175] Thêm dòng hạng mục. 🔴 Policy `Update` chứ KHÔNG phải `Create`: "tạo" ở module này
|
||||
/// nghĩa là tạo PHIẾU (khuôn dossier-items `:142`); thêm dòng là SỬA phiếu đang soạn.
|
||||
/// Gotcha #85 — dùng ĐÚNG key của endpoint, không OR key con.
|
||||
[HttpPost("{id:guid}/lines")]
|
||||
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||
public async Task<ActionResult<Guid>> AddLine(
|
||||
Guid id, [FromBody] AddSigningPlanLineBody body, CancellationToken ct)
|
||||
{
|
||||
var lineId = await mediator.Send(new AddContractSigningPlanLineCommand(
|
||||
id, body.SupplierId, body.CatalogEntryId, body.ProposedAmount, body.Note), ct);
|
||||
return Ok(lineId);
|
||||
}
|
||||
|
||||
/// [S175] Xoá dòng hạng mục THÊM TAY (handler chặn dòng gốc sinh từ phiếu Duyệt NCC).
|
||||
[HttpDelete("{id:guid}/lines/{lineId:guid}")]
|
||||
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||
public async Task<IActionResult> DeleteLine(Guid id, Guid lineId, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteContractSigningPlanLineCommand(id, lineId), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -310,7 +333,19 @@ public class ContractSigningPlansController(
|
||||
bool? ApplyLevelFinalize = null);
|
||||
|
||||
// [S168 K8-blocker] Body gán hạng mục cho dòng — PlanId/LineId lấy từ route (1 nguồn sự thật).
|
||||
public record UpdateSigningPlanLineBody(Guid CatalogEntryId);
|
||||
// [S175] +2 field null-safe: `null` = client không gửi ⇒ giữ nguyên. `CatalogEntryId` đổi
|
||||
// sang nullable ⇒ call-site FE cũ (gửi đúng 1 field) KHÔNG phải sửa.
|
||||
public record UpdateSigningPlanLineBody(
|
||||
Guid? CatalogEntryId = null,
|
||||
decimal? ProposedAmount = null,
|
||||
string? Note = null);
|
||||
|
||||
/// [S175] Thêm dòng hạng mục cho NCC ĐÃ CÓ trên phiếu — anh chốt "nhiều hạng mục → giá và ghi chú".
|
||||
public record AddSigningPlanLineBody(
|
||||
Guid SupplierId,
|
||||
Guid CatalogEntryId,
|
||||
decimal ProposedAmount,
|
||||
string? Note = null);
|
||||
|
||||
// [K7 S167] Cầu → HĐ. `PlanId` lấy từ route (không nhận trong body — 1 nguồn sự thật).
|
||||
// `LineIds` nullable ở BODY để client gửi thiếu ra 400 của validator ("chọn ít nhất 1 dòng")
|
||||
|
||||
@ -617,10 +617,17 @@ public class UpdateContractSigningPlanDraftCommandHandler(
|
||||
// auto-gen (`:481-484`) HẸN nhưng chưa từng land (build-order K4b đổi việc sang `?group=`):
|
||||
// submit-guard vá-5 đã CHẶN line CatalogEntryId=null ⇒ thiếu command này thì MỌI phiếu tạo
|
||||
// sau K2 kẹt Nháp vĩnh viễn. Dry-run K8 bắt lỗ này trước giờ chạy.
|
||||
/// [S175 — anh chốt] "Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi."
|
||||
/// ⇒ 3 field, MỌI field null-safe theo khuôn `UpdateContractSigningPlanDraftCommand:559-563`:
|
||||
/// `null` = client KHÔNG gửi ⇒ giữ giá trị cũ (bug-class #73/S42 — form rút gọn null-hoá field
|
||||
/// nó không hiển thị). `CatalogEntryId` đổi từ bắt-buộc sang nullable ⇒ call-site cũ (FE gửi
|
||||
/// đúng 1 field `catalogEntryId`) KHÔNG phải sửa dòng nào.
|
||||
public record UpdateContractSigningPlanLineCommand(
|
||||
Guid PlanId,
|
||||
Guid LineId,
|
||||
Guid CatalogEntryId) : IRequest;
|
||||
Guid? CatalogEntryId = null,
|
||||
decimal? ProposedAmount = null,
|
||||
string? Note = null) : IRequest;
|
||||
|
||||
public class UpdateContractSigningPlanLineCommandValidator
|
||||
: AbstractValidator<UpdateContractSigningPlanLineCommand>
|
||||
@ -629,7 +636,10 @@ public class UpdateContractSigningPlanLineCommandValidator
|
||||
{
|
||||
RuleFor(x => x.PlanId).NotEmpty();
|
||||
RuleFor(x => x.LineId).NotEmpty();
|
||||
RuleFor(x => x.CatalogEntryId).NotEmpty();
|
||||
// 🔴 KHÔNG cho giá ÂM. Khác `AdjustmentAmount` của ngân sách (cái đó cho âm vì là
|
||||
// "hiệu chỉnh tăng GIẢM"); đây là GIÁ ĐỀ XUẤT của một hạng mục — âm là vô nghĩa.
|
||||
RuleFor(x => x.ProposedAmount).GreaterThanOrEqualTo(0).When(x => x.ProposedAmount.HasValue);
|
||||
RuleFor(x => x.Note).MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
|
||||
@ -650,23 +660,55 @@ public class UpdateContractSigningPlanLineCommandHandler(
|
||||
?? throw new NotFoundException("ContractSigningPlanLine", request.LineId);
|
||||
|
||||
// [gate-lineeditor F-6] Rào BE thật (FE khoá chỉ là hiển thị): dòng đã bắc cầu HĐ
|
||||
// thì hạng mục bất biến — đổi sau khi có HĐ = đổi lịch sử pháp lý của HĐ đã sinh.
|
||||
// thì BẤT BIẾN — đổi sau khi có HĐ = đổi lịch sử pháp lý của HĐ đã sinh.
|
||||
// 🔴 [S175] Rào này nay che CẢ TIỀN, không chỉ hạng mục: `ProposedAmount` là số đi vào
|
||||
// HĐ, sửa sau khi HĐ sinh cũng là viết lại lịch sử.
|
||||
if (line.ContractId != null)
|
||||
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không đổi hạng mục được nữa.");
|
||||
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không sửa được nữa.");
|
||||
|
||||
// Fail-fast tầng-gán (submit-guard vẫn là chốt CUỐI chống data cũ/lách API):
|
||||
// hạng mục phải CÒN SỐNG + đang hoạt động + ĐÚNG nhóm duyệt của phiếu.
|
||||
var entry = await db.ContractCatalogEntries.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct)
|
||||
?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId);
|
||||
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
|
||||
throw new ConflictException(
|
||||
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
|
||||
var changes = new List<string>();
|
||||
|
||||
line.CatalogEntryId = entry.Id;
|
||||
// Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`):
|
||||
// danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu.
|
||||
line.TenHangMuc = entry.TenVi;
|
||||
if (request.CatalogEntryId is Guid catId)
|
||||
{
|
||||
// Fail-fast tầng-gán (submit-guard vẫn là chốt CUỐI chống data cũ/lách API):
|
||||
// hạng mục phải CÒN SỐNG + đang hoạt động + ĐÚNG nhóm duyệt của phiếu.
|
||||
var entry = await db.ContractCatalogEntries.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == catId, ct)
|
||||
?? throw new NotFoundException("ContractCatalogEntry", catId);
|
||||
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
|
||||
throw new ConflictException(
|
||||
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
|
||||
|
||||
// 🔴 [S175] Đổi hạng mục có thể ĐỤNG UNIQUE (PlanId, SupplierId, CatalogEntryId):
|
||||
// NCC nay được N dòng ⇒ đổi dòng này trùng hạng mục dòng khác CÙNG NCC là có thật.
|
||||
// Bắt TRƯỚC khi SaveChanges để trả 409 đọc được, thay vì 500 unique-violation.
|
||||
if (plan.Lines.Any(l => l.Id != line.Id && l.SupplierId == line.SupplierId && l.CatalogEntryId == catId))
|
||||
throw new ConflictException(
|
||||
$"NCC này đã có dòng cho hạng mục '{entry.TenVi}' — mỗi cặp NCC × hạng mục chỉ 1 dòng.");
|
||||
|
||||
line.CatalogEntryId = entry.Id;
|
||||
// Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`):
|
||||
// danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu.
|
||||
line.TenHangMuc = entry.TenVi;
|
||||
changes.Add($"hạng mục → '{entry.Code} — {entry.TenVi}'");
|
||||
}
|
||||
|
||||
// [S175] Ghi changelog theo cặp CŨ→MỚI: đây là TIỀN, người duyệt phải truy được ai
|
||||
// đổi từ bao nhiêu sang bao nhiêu, không chỉ biết "có sửa".
|
||||
if (request.ProposedAmount is decimal amt && amt != line.ProposedAmount)
|
||||
{
|
||||
changes.Add($"giá đề xuất {line.ProposedAmount:N0} → {amt:N0} đ");
|
||||
line.ProposedAmount = amt;
|
||||
}
|
||||
|
||||
if (request.Note != null)
|
||||
{
|
||||
// Chuỗi rỗng = client CHỦ ĐỘNG xoá ⇒ null (khuôn `HoSoLink`).
|
||||
var note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
|
||||
if (note != line.Note) { changes.Add("ghi chú"); line.Note = note; }
|
||||
}
|
||||
|
||||
if (changes.Count == 0) return; // không có gì đổi ⇒ đừng đẻ changelog rỗng
|
||||
|
||||
// [gate-lineeditor F-1] Phiếu liên danh N dòng ⇒ nhật ký PHẢI trỏ được DÒNG NÀO:
|
||||
// EntityId = line.Id (quy ước mọi changelog thực-thể-con của file) + tên NCC vào Summary.
|
||||
@ -684,7 +726,159 @@ public class UpdateContractSigningPlanLineCommandHandler(
|
||||
PhaseAtChange = plan.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = currentUser.FullName ?? currentUser.Email,
|
||||
Summary = $"Gán hạng mục '{entry.Code} — {entry.TenVi}' cho dòng NCC {supplierName ?? "(không rõ tên)"}",
|
||||
Summary = $"Sửa dòng NCC {supplierName ?? "(không rõ tên)"}: {string.Join(" · ", changes)}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== [S175] Thêm / xoá DÒNG hạng mục =============================
|
||||
// Anh chốt: "Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi."
|
||||
// 🔴 0 MIGRATION: unique `(PlanId, SupplierId, CatalogEntryId)` filter `IsDeleted=0` đã có
|
||||
// từ Mig 71 ⇒ 1 NCC × N hạng mục **máy đã cho phép từ lâu**, chỉ UI khoá 1-1. Đây đúng khuôn
|
||||
// "máy rộng, UI hẹp" anh chốt S164 — nay bung UI, KHÔNG đụng máy.
|
||||
|
||||
public record AddContractSigningPlanLineCommand(
|
||||
Guid PlanId,
|
||||
Guid SupplierId,
|
||||
Guid CatalogEntryId,
|
||||
decimal ProposedAmount,
|
||||
string? Note = null) : IRequest<Guid>;
|
||||
|
||||
public class AddContractSigningPlanLineCommandValidator
|
||||
: AbstractValidator<AddContractSigningPlanLineCommand>
|
||||
{
|
||||
public AddContractSigningPlanLineCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PlanId).NotEmpty();
|
||||
RuleFor(x => x.SupplierId).NotEmpty();
|
||||
// 🔴 BẮT BUỘC, khác đường auto-sinh (dòng gốc để null có chủ đích): unique index CỐ Ý
|
||||
// KHÔNG lọc `CatalogEntryId IS NOT NULL` (`ContractSigningPlanLineConfiguration.cs:35-38`)
|
||||
// ⇒ thêm dòng NULL sẽ đụng ngay dòng gốc của chính NCC đó.
|
||||
RuleFor(x => x.CatalogEntryId).NotEmpty();
|
||||
RuleFor(x => x.ProposedAmount).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.Note).MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
|
||||
public class AddContractSigningPlanLineCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<AddContractSigningPlanLineCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(AddContractSigningPlanLineCommand request, CancellationToken ct)
|
||||
{
|
||||
var plan = await db.ContractSigningPlans
|
||||
.Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
|
||||
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
|
||||
|
||||
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
|
||||
|
||||
// 🔴 NCC phải ĐÃ CÓ trên phiếu. NCC đến từ winner của phiếu Duyệt NCC
|
||||
// (`ContractSigningPlanFeatures.cs:508-531`); cho đẻ NCC mới ở đây = lách cả vòng
|
||||
// duyệt NCC, ký hợp đồng với nhà thầu chưa ai chọn.
|
||||
if (!plan.Lines.Any(l => l.SupplierId == request.SupplierId))
|
||||
throw new ConflictException(
|
||||
"NCC không thuộc kế hoạch này — chỉ thêm hạng mục cho NCC đã trúng thầu trên phiếu Duyệt NCC.");
|
||||
|
||||
var entry = await db.ContractCatalogEntries.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct)
|
||||
?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId);
|
||||
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
|
||||
throw new ConflictException(
|
||||
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
|
||||
|
||||
// Bắt trùng TRƯỚC SaveChanges ⇒ 409 đọc được thay vì 500 unique-violation.
|
||||
if (plan.Lines.Any(l => l.SupplierId == request.SupplierId && l.CatalogEntryId == entry.Id))
|
||||
throw new ConflictException(
|
||||
$"NCC này đã có dòng cho hạng mục '{entry.TenVi}' — mỗi cặp NCC × hạng mục chỉ 1 dòng.");
|
||||
|
||||
var line = new ContractSigningPlanLine
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = request.SupplierId,
|
||||
CatalogEntryId = entry.Id,
|
||||
TenHangMuc = entry.TenVi,
|
||||
// 🔴 = 0, KHÔNG copy số tham chiếu của NCC. `PeReferenceAmount` là Σ báo giá ĐƯỢC
|
||||
// CHỌN của CẢ NCC đó trên phiếu PE, đã nằm trọn ở dòng gốc (`:512-515`). Lặp lại ở
|
||||
// dòng thêm tay = ĐẾM ĐÔI tiền tham chiếu của dự án. Nó cũng là DẤU phân biệt
|
||||
// dòng-gốc ⟂ dòng-thêm-tay mà `Delete` dựa vào để chặn xoá nhầm.
|
||||
PeReferenceAmount = 0m,
|
||||
ProposedAmount = request.ProposedAmount,
|
||||
Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim(),
|
||||
};
|
||||
db.ContractSigningPlanLines.Add(line);
|
||||
|
||||
var supplierName = await db.Suppliers.AsNoTracking()
|
||||
.Where(x => x.Id == request.SupplierId)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
EntityType = ContractSigningPlanEntityType.Line,
|
||||
EntityId = line.Id,
|
||||
Action = ChangelogAction.Insert,
|
||||
PhaseAtChange = plan.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = currentUser.FullName ?? currentUser.Email,
|
||||
Summary = $"Thêm hạng mục '{entry.Code} — {entry.TenVi}' cho NCC {supplierName ?? "(không rõ tên)"} — "
|
||||
+ $"giá đề xuất {request.ProposedAmount:N0} đ",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return line.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record DeleteContractSigningPlanLineCommand(Guid PlanId, Guid LineId) : IRequest;
|
||||
|
||||
public class DeleteContractSigningPlanLineCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<DeleteContractSigningPlanLineCommand>
|
||||
{
|
||||
public async Task Handle(DeleteContractSigningPlanLineCommand request, CancellationToken ct)
|
||||
{
|
||||
var plan = await db.ContractSigningPlans
|
||||
.Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
|
||||
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
|
||||
|
||||
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
|
||||
|
||||
var line = plan.Lines.FirstOrDefault(l => l.Id == request.LineId)
|
||||
?? throw new NotFoundException("ContractSigningPlanLine", request.LineId);
|
||||
|
||||
if (line.ContractId != null)
|
||||
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không xoá được.");
|
||||
|
||||
// 🔴 CHỈ xoá được dòng THÊM TAY. Dòng gốc (`PeReferenceAmount != 0`) sinh từ winner của
|
||||
// phiếu Duyệt NCC và mang SNAPSHOT tham chiếu — xoá là mất số gốc, và mất luôn dấu vết
|
||||
// NCC đó từng trúng thầu. Muốn bỏ NCC thì sửa ở phiếu PE, không phải xoá lén ở đây.
|
||||
if (line.PeReferenceAmount != 0m)
|
||||
throw new ConflictException(
|
||||
"Đây là dòng gốc sinh từ phiếu Duyệt NCC — không xoá được. Chỉ xoá được hạng mục thêm tay.");
|
||||
|
||||
var supplierName = await db.Suppliers.AsNoTracking()
|
||||
.Where(x => x.Id == line.SupplierId)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
var tenHangMuc = line.TenHangMuc ?? "(chưa gán hạng mục)";
|
||||
|
||||
db.ContractSigningPlanLines.Remove(line);
|
||||
|
||||
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
EntityType = ContractSigningPlanEntityType.Line,
|
||||
EntityId = line.Id,
|
||||
Action = ChangelogAction.Delete,
|
||||
PhaseAtChange = plan.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = currentUser.FullName ?? currentUser.Email,
|
||||
Summary = $"Xoá hạng mục '{tenHangMuc}' của NCC {supplierName ?? "(không rõ tên)"}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
Reference in New Issue
Block a user