[CLAUDE] Contract: K2 SP-002 phieu KHKK mang hang-muc + nhom duyet N1-8 (Mig 71) + FE x2 app + 11 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s

Mig 71 AddKhkkApprovalGroupCatalogAndFinalizeRuntime: +ApprovalGroup (backfill=1)
+EndedByLevelFinalize (K2 de, K3 tieu - ghost-window khai) + Line +CatalogEntryId/
TenHangMuc + unique (Plan,Supplier,CatalogEntry) filtered IsDeleted=0. Rao
1-PE-1-phieu doi (PeId,ApprovalGroup) 2 site + picker OG-7 ExistingGroups + nut
'Tao them cho nhom khac'. Submit-guard va-5 (CHAN null, thang nen cho-qua-legacy;
ghost-window K2->K4b khai) + dem-so-khop (gate F-1: bit duong hang-muc xoa-mem).
List ?group= end-to-end + Tong-Approved list (va-8). FE: select nhom default 1 +
badge N{g} + cot Hang-muc + cot Tong-duyet, SHA-pair 5/5. Gate K2 PASS-WITH-FLAGS
10f: F-1 guard fix + 2 test bit (xoa-mem chan + reflection ?group=); F-2/F-5/F-7/
F-8/F-9 comment-honest; F-3/F-4 docs-delta. Suite 609/0 (45D+564I). Wave co #53 x5,
artifact + relaunch-cat-got cuu 100% (0 mat cong).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 17:05:27 +07:00
parent 89b5bbee8a
commit 0779f2d45a
23 changed files with 8905 additions and 109 deletions

View File

@ -54,7 +54,14 @@ public record ContractSigningPlanListItemDto(
decimal TotalProposedAmount,
DateTime? SlaDeadline,
DateTime CreatedAt,
DateTime? UpdatedAt);
DateTime? UpdatedAt,
// [K2 S164] append-CUỐI. `ApprovalGroup` = nhóm duyệt 1..8 (cây 8 folder + badge).
// `TotalApprovedAmount` (vá-8): list ĐANG có 2 Σ (PeReference `:711` + Proposed `:713`)
// nhưng KHÔNG có Σ của số ĐÃ CHỐT ⇒ sau khi phiếu DaDuyet, màn danh sách vẫn khoe số
// đề xuất trong khi màn chi tiết đã khoá theo `ApprovedAmount` = hai màn lệch số trên
// cùng một phiếu. Σ này đóng đúng khe đó. Phiếu chưa duyệt → mọi dòng null → 0.
int ApprovalGroup,
decimal TotalApprovedAmount);
public record ContractSigningPlanLineDto(
Guid Id,
@ -65,7 +72,11 @@ public record ContractSigningPlanLineDto(
decimal ProposedAmount,
decimal? ApprovedAmount,
Guid? ContractId,
string? Note);
string? Note,
// [K2 S164] append-CUỐI — hạng mục SP-002 của dòng. `TenHangMuc` là DENORM lúc gán
// (đừng join ngược lấy tên mới: danh mục đổi tên không được viết lại lịch sử phiếu).
Guid? CatalogEntryId,
string? TenHangMuc);
public record ContractSigningPlanDossierItemDto(
Guid Id,
@ -144,7 +155,15 @@ public record ContractSigningPlanDetailDto(
List<ContractSigningPlanDossierItemDto> DossierItems,
List<ContractSigningPlanAttachmentDto> Attachments,
List<ContractSigningPlanLevelOpinionDto> LevelOpinions,
List<ContractSigningPlanWorkflowStepDto> WorkflowSteps);
List<ContractSigningPlanWorkflowStepDto> WorkflowSteps,
// [K2 S164] append-CUỐI (sau cả các List — convention của file là append cuối CÙNG,
// không chèn giữa; JSON bind theo TÊN nên FE không quan tâm vị trí).
int ApprovalGroup,
// Display-gate (khuôn tên `budgetFrozen` PE S133): phiếu DaDuyet ⇒ FE đọc `ApprovedAmount`
// + badge khoá; trước đó đọc `ProposedAmount`. Đây là CỜ HIỂN THỊ dẫn xuất từ Phase,
// KHÔNG phải cột DB — số đã được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại
// choke-point finalize (`ContractSigningPlanWorkflowService.cs:301-305`).
bool BudgetFrozen);
/// Dòng picker "PE đã duyệt — chờ lập kế hoạch ký kết".
public record ApprovedPeAwaitingPlanDto(
@ -162,7 +181,14 @@ public record ApprovedPeAwaitingPlanDto(
decimal WinnerQuoteTotal,
DateTime CreatedAt,
DateTime? UpdatedAt,
List<string> WinnerSupplierNames); // [W2 S161 — F-6] FE render chip tên NCC trúng thầu (append cuối)
List<string> WinnerSupplierNames, // [W2 S161 — F-6] FE render chip tên NCC trúng thầu (append cuối)
// [K2 S164 — OG-7 UI-hướng-1] Nhóm duyệt của các kế hoạch ĐANG SỐNG trên PE này
// (`Phase != TuChoi`, xoá mềm đã bị global filter loại). Rỗng = PE chưa có kế hoạch nào.
// 🔴 Đổi HÌNH DẠNG picker: trước K2, PE có kế hoạch bị LOẠI khỏi danh sách (biến mất);
// giờ vẫn hiện KÈM danh sách nhóm đã dùng để FE hiện nút "Tạo thêm cho nhóm khác" và
// disable đúng những nhóm đã có. Ẩn phiếu đi thì user không phân biệt được
// "PE không đủ điều kiện" với "PE đã dùng hết nhóm" — hai thứ khác nhau.
List<int> ExistingGroups);
public record CreateContractSigningPlanResult(Guid Id, string MaKeHoach, int LineCount);
@ -170,6 +196,12 @@ public record CreateContractSigningPlanResult(Guid Id, string MaKeHoach, int Lin
internal static class ContractSigningPlanScope
{
// [K2 S164] Biên nhóm duyệt N1..N8 (SP-002) + default hướng-1 (OG-1/OG-7 UI-1-1:
// FE không gửi nhóm ⇒ phiếu vào nhóm 1 — khớp backfill Mig 71 ApprovalGroup=1).
internal const int MinApprovalGroup = 1;
internal const int MaxApprovalGroup = 8;
internal const int DefaultApprovalGroup = 1;
/// Phase cho phép SỬA nội dung nháp (header + căn cứ b.8-9).
internal static bool IsDraftEditable(ContractSigningPlanPhase phase)
=> phase is ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TraLai;
@ -291,10 +323,18 @@ internal static class ContractSigningPlanScope
/// W2 §②-2 viết đúng chữ đó và lane FE dựng body JSON từ CÙNG bản spec ⇒ đổi tên ở đây
/// là gãy tích hợp giữa 2 lane. DTO trả về vẫn dùng tên đầy đủ.
/// </remarks>
/// <param name="ApprovalGroup">
/// [K2 S164] Nhóm duyệt N1..N8 của kế hoạch. **NULL = nhóm 1** (mặc định) — CỐ Ý nullable
/// thay vì `int` bắt buộc: client hiện tại (`KhkkCreatePage.tsx:73-75` ×2 app) CHƯA gửi
/// trường này, mà giá trị bỏ trống của `int` là 0 → rơi thẳng vào 400 "ngoài 1..8" ⇒ đổi
/// sang bắt buộc là làm gãy màn tạo phiếu đang chạy. `null → 1` khớp đúng ngữ nghĩa
/// backfill của Mig 71 (mọi phiếu cũ = nhóm 1). Client gửi số thì validator 1..8 vẫn có răng.
/// </param>
public record CreateContractSigningPlanCommand(
Guid PeId,
Guid ApprovalWorkflowId,
string? GhiChu = null) : IRequest<CreateContractSigningPlanResult>;
string? GhiChu = null,
int? ApprovalGroup = null) : IRequest<CreateContractSigningPlanResult>;
public class CreateContractSigningPlanCommandValidator : AbstractValidator<CreateContractSigningPlanCommand>
{
@ -305,6 +345,12 @@ public class CreateContractSigningPlanCommandValidator : AbstractValidator<Creat
// MaxLength MATCH EF config `ContractSigningPlanConfiguration.cs:22` (GhiChu = 2000).
// EF là source of truth — KHÔNG lấy số từ spec (bài S35).
RuleFor(x => x.GhiChu).MaximumLength(2000);
// Chỉ soi khi client CÓ gửi (`.When`): null là đường hợp lệ (= nhóm 1), 0 hoặc 9 thì không.
RuleFor(x => x.ApprovalGroup)
.InclusiveBetween(ContractSigningPlanScope.MinApprovalGroup,
ContractSigningPlanScope.MaxApprovalGroup)
.When(x => x.ApprovalGroup.HasValue)
.WithMessage("Nhóm duyệt phải trong khoảng N1..N8.");
}
}
@ -336,15 +382,26 @@ public class CreateContractSigningPlanCommandHandler(
if (winners.Count == 0)
throw new ConflictException("Phiếu chưa chọn NCC/TP trúng thầu — chưa có dòng giá để lập kế hoạch.");
// ---- Rào (iii) 1 PE = 1 kế hoạch SỐNG. `TuChoi` KHÔNG tính là sống ⇒ phiếu bị từ
// chối vẫn lập lại được. Phiếu xoá mềm cũng không tính (global filter tự loại) ⇒
// xoá xong tạo lại được (acceptance §③-B "DELETE → tạo lại → 201").
// ---- Rào (iii) [K2 S164 — OG-7] 1 PE = 1 kế hoạch SỐNG **MỖI NHÓM DUYỆT** (trước K2 là
// 1 kế hoạch/PE tuyệt đối). Một gói thầu có thể phải ký NHIỀU loại hợp đồng thuộc các
// nhóm duyệt khác nhau ⇒ khoá theo PE là khoá nhầm chiều. `TuChoi` KHÔNG tính là sống ⇒
// phiếu bị từ chối vẫn lập lại được. Phiếu xoá mềm cũng không tính (global filter tự
// loại) ⇒ xoá xong tạo lại được (acceptance §③-B "DELETE → tạo lại → 201").
// 🔴 CỐ Ý KHÔNG dựng UNIQUE (PeId, ApprovalGroup) dưới DB — lý do THẬT (gate-K2 F-7 sửa;
// bản cũ nói "unique không diễn tả được Phase != TuChoi" là SAI kỹ thuật: filtered index
// `WHERE [Phase] <> 3 AND [IsDeleted] = 0` diễn tả được). Đợt này chấp nhận rào handler
// (check-then-insert, còn cửa race 2-request-đồng-thời nhỏ) vì Phase là cột mutable +
// UI-hướng-1; cần chống race tuyệt đối thì dựng filtered unique ở wave sau.
var approvalGroup = request.ApprovalGroup ?? ContractSigningPlanScope.DefaultApprovalGroup;
var hasLivePlan = await db.ContractSigningPlans
.AnyAsync(p => p.PurchaseEvaluationId == pe.Id
&& p.ApprovalGroup == approvalGroup
&& p.Phase != ContractSigningPlanPhase.TuChoi, ct);
if (hasLivePlan)
throw new ConflictException(
"Phiếu Duyệt NCC này đã có kế hoạch ký kết. Xoá hoặc để bị từ chối kế hoạch cũ trước khi lập lại.");
$"Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm duyệt N{approvalGroup}. " +
"Chọn nhóm duyệt khác, hoặc xoá/để bị từ chối kế hoạch cũ của nhóm này trước khi lập lại.");
// ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct);
@ -371,6 +428,7 @@ public class CreateContractSigningPlanCommandHandler(
// `Guid.Empty` cho phiếu PE cũ thiếu drafter = hỏng cả quyền sửa lẫn scope list.
DrafterUserId = actorId,
Phase = ContractSigningPlanPhase.DangSoanThao,
ApprovalGroup = approvalGroup, // [K2 S164] nhóm duyệt của phiếu — chốt lúc TẠO
ApprovalWorkflowId = request.ApprovalWorkflowId,
GhiChu = string.IsNullOrWhiteSpace(request.GhiChu) ? null : request.GhiChu.Trim(),
};
@ -390,6 +448,13 @@ public class CreateContractSigningPlanCommandHandler(
{
ContractSigningPlanId = plan.Id,
SupplierId = w.SupplierId,
// [K2 S164] Mồi hạng mục = NULL CÓ CHỦ ĐÍCH. Lúc tạo, hệ thống biết NCC nào
// trúng thầu nhưng KHÔNG biết dòng đó sẽ ký theo hạng mục SP-002 nào — đoán bừa
// một hạng mục là bịa dữ liệu pháp lý. Người soạn gán sau qua
// `UpdateContractSigningPlanLineCommand`; cửa ra là SUBMIT-GUARD
// (`ContractSigningPlanWorkflowService.SubmitAsync`) chặn trình khi còn dòng NULL.
CatalogEntryId = null,
TenHangMuc = null,
PeReferenceAmount = peReference,
// Mồi ProposedAmount = số PE. W2 CHƯA có đường sửa dòng giá (spec §②-2 không
// liệt kê command nào cho Line) ⇒ để 0 thì cả phiếu là 0 và không ai sửa được.
@ -571,7 +636,11 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
l.Id, l.SupplierId,
suppliers.TryGetValue(l.SupplierId, out var sup) ? sup.Name : null,
sup?.Code,
l.PeReferenceAmount, l.ProposedAmount, l.ApprovedAmount, l.ContractId, l.Note))
l.PeReferenceAmount, l.ProposedAmount, l.ApprovedAmount, l.ContractId, l.Note,
// [K2 S164] Tên hạng mục lấy từ CỘT DENORM của chính dòng, KHÔNG join ngược sang
// `ContractCatalogEntries`: danh mục sửa tên/xoá mềm sau ngày ký không được phép
// viết lại nội dung phiếu đã lập (khuôn `SignedByFullName` Mig 26).
l.CatalogEntryId, l.TenHangMuc))
.ToList();
var dossierItems = plan.DossierItems
@ -613,17 +682,29 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
plan.CurrentWorkflowStepIndex, plan.CurrentApprovalLevelOrder,
plan.SlaDeadline, plan.HoSoLink, plan.GhiChu,
plan.CreatedAt, plan.UpdatedAt,
lines, dossierItems, attachments, opinions, stepDtos);
lines, dossierItems, attachments, opinions, stepDtos,
plan.ApprovalGroup,
// [K2 S164] `BudgetFrozen` = CỜ HIỂN THỊ dẫn xuất từ Phase, KHÔNG phải cột DB. Số đã
// được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại choke-point finalize
// (`ContractSigningPlanWorkflowService.ApplyApprovedValuesOnFinalize`); cờ này chỉ
// bảo FE "đọc `ApprovedAmount` + khoá ô" thay vì đọc `ProposedAmount`.
plan.Phase == ContractSigningPlanPhase.DaDuyet);
}
}
// ======================= Region 4: LIST / INBOX / ĐÃ XOÁ ============================
/// <param name="ApprovalGroup">
/// [K2 S164] Lọc theo nhóm duyệt N1..N8. K2 là CHỦ tham số này; cây 8 folder (K5) và các
/// màn con (K4b) chỉ TIÊU THỤ — thêm chỗ lọc thứ hai ở tầng trên là đẻ hai định nghĩa
/// "thuộc nhóm N" rồi lệch nhau. `null` = mọi nhóm (giữ nguyên hành vi trước K2).
/// </param>
public record ListContractSigningPlansQuery(
ContractSigningPlanPhase? Phase = null,
Guid? ProjectId = null,
Guid? PurchaseEvaluationId = null,
bool PendingMe = false) : PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>;
bool PendingMe = false,
int? ApprovalGroup = null) : PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>;
public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
: IRequestHandler<ListContractSigningPlansQuery, PagedResult<ContractSigningPlanListItemDto>>
@ -653,6 +734,10 @@ public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICur
if (request.ProjectId is not null) q = q.Where(p => p.ProjectId == request.ProjectId);
if (request.PurchaseEvaluationId is not null)
q = q.Where(p => p.PurchaseEvaluationId == request.PurchaseEvaluationId);
// [K2 S164] Lọc nhóm duyệt — đặt CÙNG chỗ với các vế lọc khác, SAU vế IDOR ở trên:
// lọc không bao giờ được là thứ NỚI phạm vi thấy được, chỉ được thu hẹp.
if (request.ApprovalGroup is not null)
q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup);
if (request.PendingMe)
{
@ -711,7 +796,14 @@ public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICur
.Sum(l => (decimal?)l.PeReferenceAmount) ?? 0m,
db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
.Sum(l => (decimal?)l.ProposedAmount) ?? 0m,
p.SlaDeadline, p.CreatedAt, p.UpdatedAt))
p.SlaDeadline, p.CreatedAt, p.UpdatedAt,
p.ApprovalGroup,
// [K2 S164 — vá-8] Σ số ĐÃ CHỐT. Thiếu Σ này thì phiếu `DaDuyet` khoe số ĐỀ XUẤT
// ở màn danh sách trong khi màn chi tiết đã khoá theo `ApprovedAmount` ⇒ hai màn
// nói hai con số trên CÙNG một phiếu. `ApprovedAmount` vốn nullable ⇒ phiếu chưa
// duyệt cho ra `null` → `0`, đúng nghĩa "chưa chốt đồng nào".
db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
.Sum(l => l.ApprovedAmount) ?? 0m))
.ToListAsync(ct);
return new PagedResult<ContractSigningPlanListItemDto>(items, total, page, pageSize);
@ -1198,8 +1290,11 @@ public class ListApprovedPeAwaitingPlanQueryHandler(IApplicationDbContext db)
from wi in wij.DefaultIfEmpty()
where e.Phase == PurchaseEvaluationPhase.DaDuyet
&& db.PurchaseEvaluationSuppliers.Any(sw => sw.PurchaseEvaluationId == e.Id && sw.IsWinner)
&& !db.ContractSigningPlans.Any(pl => pl.PurchaseEvaluationId == e.Id
&& pl.Phase != ContractSigningPlanPhase.TuChoi)
// 🔴 [K2 S164 — OG-7] ĐÃ GỠ vế `&& !db.ContractSigningPlans.Any(...)`: rào không còn
// là "1 PE = 1 kế hoạch" mà là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ loại PE-đã-có-phiếu
// khỏi picker sẽ chặn luôn đường lập cho nhóm khác (chính là năng lực K2 mở ra).
// Vế đó chuyển thành DỮ LIỆU trả về (`ExistingGroups` dưới) để FE khoá đúng nhóm
// đã dùng — rào cứng 409 vẫn nằm ở Create, picker chỉ có nhiệm vụ không mời nhầm.
orderby e.UpdatedAt ?? e.CreatedAt descending
select new ApprovedPeAwaitingPlanDto(
e.Id, e.MaPhieu, e.TenGoiThau,
@ -1223,6 +1318,14 @@ public class ListApprovedPeAwaitingPlanQueryHandler(IApplicationDbContext db)
where sw.PurchaseEvaluationId == e.Id && sw.IsWinner
join sup in db.Suppliers on sw.SupplierId equals sup.Id
orderby sup.Name
select sup.Name).ToList())).ToListAsync(ct);
select sup.Name).ToList(),
// [K2 S164] Nhóm đã dùng — CÙNG HÌNH DẠNG subquery-ToList với vế trên (khuôn đã
// chạy thật), KHÔNG `Distinct()`: rào Create bảo đảm mỗi (PE, nhóm) tối đa 1 phiếu
// sống ⇒ danh sách vốn đã không trùng; thêm Distinct chỉ để SQL sinh phức tạp hơn.
(from pl in db.ContractSigningPlans
where pl.PurchaseEvaluationId == e.Id
&& pl.Phase != ContractSigningPlanPhase.TuChoi
orderby pl.ApprovalGroup
select pl.ApprovalGroup).ToList())).ToListAsync(ct);
}
}