From 01a7c0b855df07aa0228b86ca4d7fdcdcda2e18d Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 10 Aug 2026 18:20:41 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Contract:=20W2=20BE-DTO=20n=E1=BB=81?= =?UTF-8?q?n=20G=C4=903=20=E2=80=94=20ContractSourceDto=20reverse-join=20K?= =?UTF-8?q?HKK=20+=20CurrentWorkflowStepIndex=20+=20WorkflowSteps=20c?= =?UTF-8?q?=C3=A2y=20th=C3=B4=20khu=C3=B4n=20KHKK;=20types=20=C3=972=20app?= =?UTF-8?q?=20kh=E1=BB=9Bp=20t=C3=AAn=20JSON=20BE=20(v=C3=A1=20reviewer=20?= =?UTF-8?q?F-1/F-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- fe-admin/src/types/contracts.ts | 61 +++++++++++++ fe-user/src/types/contracts.ts | 61 +++++++++++++ .../Contracts/ContractFeatures.cs | 89 ++++++++++++++++++- .../Contracts/Dtos/ContractDtos.cs | 76 +++++++++++++++- 4 files changed, 285 insertions(+), 2 deletions(-) diff --git a/fe-admin/src/types/contracts.ts b/fe-admin/src/types/contracts.ts index 6d041dd..66b2bd5 100644 --- a/fe-admin/src/types/contracts.ts +++ b/fe-admin/src/types/contracts.ts @@ -170,6 +170,67 @@ export type ContractDetail = { approvalWorkflowId: string | null currentApprovalLevelOrder: number | null levelOpinions: ContractLevelOpinion[] | null + + // ========= [W2 S187 · YC-023 GĐ3] Nguồn gốc KHKK + cây quy trình V2 (3 field) ========== + // 🔴 Khai `?` KÈM `| null` CÓ CHỦ ĐÍCH — 2 nghĩa KHÁC NHAU, đừng gộp: + // • `undefined` (vắng key) = payload đến từ API/bundle CHƯA có W2, hoặc cache TanStack cũ; + // • `null` = BE đã trả lời "HĐ này KHÔNG có nguồn KHKK / chưa pin quy trình". + // BE append 3 field này ở CUỐI `ContractDetailDto` với default null (hôm nay field cuối là + // `LevelOpinions` — `ContractDtos.cs:64`) ⇒ wire cũ không vỡ. Nơi đọc PHẢI null-check, + // TUYỆT ĐỐI không suy "vắng field ⇒ HĐ tạo tay". + /** HĐ kế thừa từ phiếu KHKK đã duyệt (bridge K7). HĐ tạo tay ⇒ null, FE không render card. */ + source?: ContractSource | null + /** INDEX 0-based vào mảng `workflowSteps` (KHÔNG phải `order`) — mirror khuôn KHKK. */ + currentWorkflowStepIndex?: number | null + /** Cây quy trình THÔ (xem `ContractWorkflowStep`). */ + workflowSteps?: ContractWorkflowStep[] | null +} + +// [W2 S187 · YC-023] Khối "Nguồn gốc KHKK" — BE reverse-join `ContractSigningPlanLines +// WHERE ContractId = id` → phiếu KHKK + hạng mục + Σ tiền (spec `§2 W2(a)`). Link nằm ở +// `ContractSigningPlanLine.ContractId`, KHÔNG có con-trỏ ngược trên `Contract` ⇒ HĐ 0 dòng +// nối trả null. +// ✅ FLAG-A ĐÓNG @S188 (reviewer F-1): BE đặt `Source` (`ContractDtos.cs:87`) đúng AC spec ⇒ FE +// rename khớp JSON BE từng tên: `contractSource`→`source` · `tenHangMuc`→`tenHangMucs` · +// `totalApprovedAmount`→`approvedAmountTotal` (JSON bind theo TÊN; System.Text.Json camelCase). +export type ContractSource = { + /** Mã phiếu KHKK, vd `KHKK/2026/001`. null khi phiếu cha đã xoá mềm (F-2 — BE trả null qua global filter). */ + maKeHoach: string | null + planId: string + /** Nhóm duyệt N1–N8 của phiếu (Mig 71). null = phiếu không mang nhóm. */ + approvalGroup?: number | null + /** Tên các hạng mục của phiếu đã nối sang HĐ này (có thể rỗng). */ + tenHangMucs: string[] + /** Σ `ApprovedAmount` các dòng nối — số đã đóng băng tại choke-point finalize của phiếu. */ + approvedAmountTotal?: number | null + /** Phiếu Duyệt NCC gốc — chuỗi PE → KHKK → HĐ. */ + purchaseEvaluationId?: string | null +} + +// [W2 S187] Cây quy trình THÔ do BE trả — mirror khuôn KHKK (`types/khkk.ts:275-296`, BE +// `ContractSigningPlanFeatures.cs:131-152`). +// 🔴 KHÁC PE: BE **KHÔNG** precompute `status` Done/Current/Pending. FE tự suy từ CẶP con-trỏ +// `currentWorkflowStepIndex` (INDEX 0-based vào mảng steps) + `currentApprovalLevelOrder` +// (GIÁ TRỊ `level.order`, KHÔNG phải index). +export type ContractWorkflowLevel = { + id: string + order: number + name: string | null + approverUserId: string + approverFullName: string | null + /** Cờ per-Cấp "được KẾT THÚC quy trình tại cấp này" — spec `§2 W2(b)` gọi là "cờ per-level". */ + allowApproverFinalize: boolean +} + +export type ContractWorkflowStep = { + id: string + order: number + name: string + levels: ContractWorkflowLevel[] + // 2 field dưới có trong khuôn KHKK nhưng spec W2 KHÔNG nêu tên ⇒ khai `?` để nơi đọc buộc + // null-check, thay vì hứa một field payload có thể không mang (bài học `khkk.ts:403-407`). + departmentId?: string | null + departmentName?: string | null } // [Plan B S29 2026-05-22 Chunk E3] Mirror BE ContractLevelOpinionDto 12 fields. diff --git a/fe-user/src/types/contracts.ts b/fe-user/src/types/contracts.ts index 6431a57..3004a6e 100644 --- a/fe-user/src/types/contracts.ts +++ b/fe-user/src/types/contracts.ts @@ -174,6 +174,67 @@ export type ContractDetail = { approvalWorkflowId: string | null currentApprovalLevelOrder: number | null levelOpinions: ContractLevelOpinion[] | null + + // ========= [W2 S187 · YC-023 GĐ3] Nguồn gốc KHKK + cây quy trình V2 (3 field) ========== + // 🔴 Khai `?` KÈM `| null` CÓ CHỦ ĐÍCH — 2 nghĩa KHÁC NHAU, đừng gộp: + // • `undefined` (vắng key) = payload đến từ API/bundle CHƯA có W2, hoặc cache TanStack cũ; + // • `null` = BE đã trả lời "HĐ này KHÔNG có nguồn KHKK / chưa pin quy trình". + // BE append 3 field này ở CUỐI `ContractDetailDto` với default null (hôm nay field cuối là + // `LevelOpinions` — `ContractDtos.cs:64`) ⇒ wire cũ không vỡ. Nơi đọc PHẢI null-check, + // TUYỆT ĐỐI không suy "vắng field ⇒ HĐ tạo tay". + /** HĐ kế thừa từ phiếu KHKK đã duyệt (bridge K7). HĐ tạo tay ⇒ null, FE không render card. */ + source?: ContractSource | null + /** INDEX 0-based vào mảng `workflowSteps` (KHÔNG phải `order`) — mirror khuôn KHKK. */ + currentWorkflowStepIndex?: number | null + /** Cây quy trình THÔ (xem `ContractWorkflowStep`). */ + workflowSteps?: ContractWorkflowStep[] | null +} + +// [W2 S187 · YC-023] Khối "Nguồn gốc KHKK" — BE reverse-join `ContractSigningPlanLines +// WHERE ContractId = id` → phiếu KHKK + hạng mục + Σ tiền (spec `§2 W2(a)`). Link nằm ở +// `ContractSigningPlanLine.ContractId`, KHÔNG có con-trỏ ngược trên `Contract` ⇒ HĐ 0 dòng +// nối trả null. +// ✅ FLAG-A ĐÓNG @S188 (reviewer F-1): BE đặt `Source` (`ContractDtos.cs:87`) đúng AC spec ⇒ FE +// rename khớp JSON BE từng tên: `contractSource`→`source` · `tenHangMuc`→`tenHangMucs` · +// `totalApprovedAmount`→`approvedAmountTotal` (JSON bind theo TÊN; System.Text.Json camelCase). +export type ContractSource = { + /** Mã phiếu KHKK, vd `KHKK/2026/001`. null khi phiếu cha đã xoá mềm (F-2 — BE trả null qua global filter). */ + maKeHoach: string | null + planId: string + /** Nhóm duyệt N1–N8 của phiếu (Mig 71). null = phiếu không mang nhóm. */ + approvalGroup?: number | null + /** Tên các hạng mục của phiếu đã nối sang HĐ này (có thể rỗng). */ + tenHangMucs: string[] + /** Σ `ApprovedAmount` các dòng nối — số đã đóng băng tại choke-point finalize của phiếu. */ + approvedAmountTotal?: number | null + /** Phiếu Duyệt NCC gốc — chuỗi PE → KHKK → HĐ. */ + purchaseEvaluationId?: string | null +} + +// [W2 S187] Cây quy trình THÔ do BE trả — mirror khuôn KHKK (`types/khkk.ts:275-296`, BE +// `ContractSigningPlanFeatures.cs:131-152`). +// 🔴 KHÁC PE: BE **KHÔNG** precompute `status` Done/Current/Pending. FE tự suy từ CẶP con-trỏ +// `currentWorkflowStepIndex` (INDEX 0-based vào mảng steps) + `currentApprovalLevelOrder` +// (GIÁ TRỊ `level.order`, KHÔNG phải index). +export type ContractWorkflowLevel = { + id: string + order: number + name: string | null + approverUserId: string + approverFullName: string | null + /** Cờ per-Cấp "được KẾT THÚC quy trình tại cấp này" — spec `§2 W2(b)` gọi là "cờ per-level". */ + allowApproverFinalize: boolean +} + +export type ContractWorkflowStep = { + id: string + order: number + name: string + levels: ContractWorkflowLevel[] + // 2 field dưới có trong khuôn KHKK nhưng spec W2 KHÔNG nêu tên ⇒ khai `?` để nơi đọc buộc + // null-check, thay vì hứa một field payload có thể không mang (bài học `khkk.ts:403-407`). + departmentId?: string | null + departmentName?: string | null } // [Plan B S29 2026-05-22 Chunk E3] Mirror BE ContractLevelOpinionDto 12 fields. diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs index 94d7931..5deaa1c 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs @@ -709,6 +709,88 @@ public class GetContractQueryHandler( } } + // ---- [W2 S187 — YC-023 GĐ3] (a) NGUỒN GỐC KHKK: reverse-join qua DÒNG kế hoạch. + // `Contract` không mang con-trỏ ngược sang GĐ2; con-trỏ nằm ở `Line.ContractId` + // (`ContractSigningPlanLine.cs:46`), write-site DUY NHẤT = bridge K7 + // (`CreateContractFromSigningPlanFeatures.cs:238` — gán cho TỪNG dòng được chọn). + // 0 dòng ⇒ `null` ⇒ FE không render card (HĐ tạo tay / HĐ cũ trước K7). + // 🔴 Cả `ContractSigningPlanLines` lẫn `ContractSigningPlans` CÓ global query filter + // `!IsDeleted` (`ContractSigningPlanLineConfiguration.cs:52` + `...PlanConfiguration.cs:55`) + // ⇒ KHÔNG cần `.Where(!IsDeleted)` thủ công như họ HRM. + ContractSourceDto? sourceDto = null; + var planLines = await db.ContractSigningPlanLines.AsNoTracking() + .Where(l => l.ContractId == c.Id) + .OrderBy(l => l.CreatedAt).ThenBy(l => l.Id) + .Select(l => new { l.ContractSigningPlanId, l.TenHangMuc, l.ApprovedAmount }) + .ToListAsync(ct); + if (planLines.Count > 0) + { + // "N dòng nối cùng 1 phiếu" là bất biến do rào (2) của bridge + // (`CreateContractFromSigningPlanFeatures.cs:101-109`). Vẫn chốt phiếu theo dòng + // ĐẦU (thứ tự tất định) rồi lọc lại: nếu có ai INSERT SQL thô (họ #84) làm lệch, + // card ra số của ĐÚNG 1 phiếu thay vì trộn tiền của 2 phiếu. + var planId = planLines[0].ContractSigningPlanId; + var linesOfPlan = planLines.Where(l => l.ContractSigningPlanId == planId).ToList(); + var plan = await db.ContractSigningPlans.AsNoTracking() + .Where(p => p.Id == planId) + .Select(p => new { p.MaKeHoach, p.ApprovalGroup, p.PurchaseEvaluationId }) + .FirstOrDefaultAsync(ct); + sourceDto = new ContractSourceDto( + PlanId: planId, + MaKeHoach: plan?.MaKeHoach, + ApprovalGroup: plan?.ApprovalGroup, + PurchaseEvaluationId: plan?.PurchaseEvaluationId, + TenHangMucs: linesOfPlan + .Select(l => string.IsNullOrWhiteSpace(l.TenHangMuc) ? "(chưa gán hạng mục)" : l.TenHangMuc!) + .ToList(), + // Σ in-memory, KHÔNG `Sum()` dịch xuống SQL: provider SQLite của test-fixture + // không cộng được `decimal` (lưu TEXT) — cộng ở đây cho cả 2 provider ra CÙNG số. + ApprovedAmountTotal: linesOfPlan.Sum(l => l.ApprovedAmount ?? 0)); + } + + // ---- [W2 S187] (b) CÂY THÔ workflow V2 (Bước → Cấp → người) — khuôn KHKK + // `ContractSigningPlanFeatures.cs:1014-1057`. Chỉ nạp khi HĐ ĐÃ pin V2; HĐ V1 legacy + // ⇒ null (FE giữ nhánh render cũ). KHÔNG precompute status — panel tự suy từ cặp + // con-trỏ (`CurrentWorkflowStepIndex` INDEX 0-based + `CurrentApprovalLevelOrder`). + List? workflowStepsDto = null; + if (c.ApprovalWorkflowId is Guid awIdTree) + { + var wf = await db.ApprovalWorkflows.AsNoTracking() + .Include(w => w.Steps.OrderBy(s => s.Order)) + .ThenInclude(s => s.Levels.OrderBy(l => l.Order)) + .FirstOrDefaultAsync(w => w.Id == awIdTree, ct); + if (wf is not null) + { + var approverIds = wf.Steps.SelectMany(s => s.Levels) + .Select(l => l.ApproverUserId).Distinct().ToList(); + var approverNames = await userManager.Users.AsNoTracking() + .Where(u => approverIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName, ct); + + // `Step.DepartmentId` loose-Guid (không nav) ⇒ gom 1 lượt, KHÔNG N+1 trong vòng lặp. + var stepDeptIds = wf.Steps.Where(s => s.DepartmentId.HasValue) + .Select(s => s.DepartmentId!.Value).Distinct().ToList(); + var stepDeptNames = stepDeptIds.Count == 0 + ? new Dictionary() + : await db.Departments.AsNoTracking() + .Where(d => stepDeptIds.Contains(d.Id)) + .Select(d => new { d.Id, d.Name }) + .ToDictionaryAsync(d => d.Id, d => d.Name, ct); + + workflowStepsDto = wf.Steps.OrderBy(s => s.Order) + .Select(s => new ContractWorkflowStepDto( + s.Id, s.Order, s.Name, s.DepartmentId, + s.Levels.OrderBy(l => l.Order) + .Select(l => new ContractWorkflowLevelDto( + l.Id, l.Order, l.Name, l.ApproverUserId, + approverNames.TryGetValue(l.ApproverUserId, out var afn2) ? afn2 : null, + l.AllowApproverFinalize)) + .ToList(), + s.DepartmentId is Guid sdId && stepDeptNames.TryGetValue(sdId, out var sdn) ? sdn : null)) + .ToList(); + } + } + // Resolve user names var userIds = new HashSet(); if (c.DrafterUserId is Guid did) userIds.Add(did); @@ -751,7 +833,12 @@ public class GetContractQueryHandler( // [Plan B S29 2026-05-22 Chunk E2] V2 fields ApprovalWorkflowId: c.ApprovalWorkflowId, CurrentApprovalLevelOrder: c.CurrentApprovalLevelOrder, - LevelOpinions: levelOpinionsDto); + LevelOpinions: levelOpinionsDto, + // [W2 S187] Nền hiển thị GĐ3 — nguồn gốc KHKK + đủ cặp con-trỏ + cây thô. + // `CurrentWorkflowStepIndex` đọc THẲNG cột đã có (`Contract.cs:40`) ⇒ 0-migration. + Source: sourceDto, + CurrentWorkflowStepIndex: c.CurrentWorkflowStepIndex, + WorkflowSteps: workflowStepsDto); } // FE uses this to render next-phase buttons dynamically — no more hardcoded diff --git a/src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs b/src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs index 614abad..1cf926f 100644 --- a/src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs +++ b/src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs @@ -61,7 +61,81 @@ public record ContractDetailDto( // FE fetch ApprovalFlow shape via /api/approval-workflows-v2/{ApprovalWorkflowId}. Guid? ApprovalWorkflowId = null, int? CurrentApprovalLevelOrder = null, - List? LevelOpinions = null); + List? LevelOpinions = null, + // ===================== [W2 S187 — YC-023 GĐ3] append-CUỐI ========================= + // 🔴 Positional record: 3 field dưới BẮT BUỘC có default `null` và đứng CUỐI — + // call-site duy nhất (`ContractFeatures.cs:721`) truyền theo vị trí, thêm vào giữa + // là vỡ mọi arg sau nó. + // + // • `Source` — NGUỒN GỐC KHKK (GĐ2) của HĐ này. Đường đọc là REVERSE-JOIN + // `ContractSigningPlanLines WHERE ContractId = {id}`: `Contract` KHÔNG mang con-trỏ + // ngược nào sang kế hoạch (`Contract.cs` 0 field `SigningPlan*`) — con-trỏ nằm ở + // phía dòng kế hoạch (`ContractSigningPlanLine.cs:46`, write-site DUY NHẤT là bridge + // K7 `CreateContractFromSigningPlanFeatures.cs:238`). HĐ tạo tay / HĐ cũ trước K7 + // ⇒ 0 dòng nối ⇒ `null` ⇒ FE KHÔNG render card (null-path là ca THƯỜNG, không phải lỗi). + // • `CurrentWorkflowStepIndex` — con-trỏ Bước 0-based, đọc THẲNG cột đã có sẵn + // (`Contract.cs:40`, Mig 21) ⇒ wave này 0-migration. Ghép với + // `CurrentApprovalLevelOrder` (đã có `:63`) thành ĐỦ CẶP con-trỏ mà panel duyệt cần. + // • `WorkflowSteps` — CÂY THÔ (Bước → Cấp → người) của quy trình V2 đã pin. + // 🔴 KHUÔN KHKK, KHÔNG PHẢI KHUÔN PE: BE trả cây thô + cặp con-trỏ, panel FE TỰ SUY + // Done/Current/Pending — cố ý KHÔNG precompute `status` như cặp DTO PE + // (`PurchaseEvaluationCurrentApprovalDto`/`...ApprovalFlowDto`), vì panel được chọn + // mirror là `KhkkWorkflowPanel` và nó tiêu thụ ĐÚNG shape này + // (`ContractSigningPlanFeatures.cs:194` + `:205`). + // `null` khi HĐ chưa pin `ApprovalWorkflowId` (V1 legacy) — FE giữ nhánh cũ. + // ⚠️ Đường CŨ vẫn sống: FE Section 5 từng fetch cây qua + // `/api/approval-workflows-v2/{ApprovalWorkflowId}` (xem chú thích `:61`). Field này + // là đường INLINE 1-request để panel không phải gọi thêm — KHÔNG đẻ đường thứ 3. + ContractSourceDto? Source = null, + int? CurrentWorkflowStepIndex = null, + List? WorkflowSteps = null); + +// [W2 S187] Nguồn gốc GĐ2 (Kế hoạch ký kết — KHKK) của một HĐ. Dựng từ N dòng kế hoạch +// cùng trỏ về HĐ này (bridge K7 gán `Line.ContractId` cho TỪNG dòng được chọn — +// `CreateContractFromSigningPlanFeatures.cs:238`; rào (3) `:111-115` bảo đảm N dòng đó +// cùng 1 NCC và rào (2) `:101-109` bảo đảm cùng 1 phiếu). +// +// 3 field nullable (`MaKeHoach`/`ApprovalGroup`/`PurchaseEvaluationId`) = dữ liệu của +// PHIẾU CHA. Chúng null KHI VÀ CHỈ KHI dòng còn sống mà phiếu cha không đọc được nữa +// (xoá mềm) — ca gần như bất khả (xoá phiếu chỉ cho ở Nháp/Trả lại/Từ chối, mà phiếu đẻ +// ra HĐ thì đã DaDuyet). Cố ý KHÔNG nuốt cả `Source` thành null ở ca đó: nuốt đi thì HĐ +// mồ-côi trông Y HỆT HĐ tạo tay — hai thứ khác hẳn nhau. +public record ContractSourceDto( + Guid PlanId, + string? MaKeHoach, + int? ApprovalGroup, + Guid? PurchaseEvaluationId, + // Tên hạng mục của TỪNG dòng nối (denorm tại thời điểm gán — `ContractSigningPlanLine.cs:27`). + // Dòng chưa gán hạng mục ⇒ "(chưa gán hạng mục)" (đúng chữ bridge dùng ở `:121`) thay vì + // bị lọc bỏ: giữ `Count` == số dòng để FE không hiển thị ít chip hơn số tiền đã cộng. + List TenHangMucs, + // Σ `ApprovedAmount` — giá CHỐT tại choke-point finalize. Bridge cấm dòng chưa có giá + // chốt (`:125-127`) ⇒ trên thực tế 0 dòng nào null; `?? 0` chỉ là rào. + decimal ApprovedAmountTotal); + +// [W2 S187] Cây thô workflow V2 — mirror 1-1 `ContractSigningPlanWorkflowStepDto` +// (`ContractSigningPlanFeatures.cs:142-152`). KHÔNG có field `Status`: trạng thái là thứ +// panel tự suy từ cặp con-trỏ, BE chỉ trả cấu hình. +public record ContractWorkflowStepDto( + Guid Id, + int Order, + string Name, + Guid? DepartmentId, + List Levels, + // Tên phòng của Bước — `Step.DepartmentId` là loose-Guid (không nav) ⇒ join 1 lượt ở + // handler, KHÔNG N+1. Default null để append thêm field sau này không vỡ call-site. + string? DepartmentName = null); + +public record ContractWorkflowLevelDto( + Guid Id, + int Order, + string? Name, + Guid ApproverUserId, + string? ApproverFullName, + // Cờ per-Cấp "Cấp này được KẾT THÚC quy trình" — trả kèm để panel mirror KHKK có đủ + // dữ liệu; HĐ có dùng ô-tích này hay không là quyết của wave màn duyệt (W6), không + // phải của DTO. + bool AllowApproverFinalize = false); // [Plan B S29 2026-05-22 Chunk E2] Ý kiến cấp duyệt V2 dynamic theo // ApprovalWorkflowLevel. Mirror PE PurchaseEvaluationLevelOpinionDto pattern.