[CLAUDE] Contract: W2 BE-DTO nền GĐ3 — ContractSourceDto reverse-join KHKK + CurrentWorkflowStepIndex + WorkflowSteps cây thô khuôn KHKK; types ×2 app khớp tên JSON BE (vá reviewer F-1/F-2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-10 18:20:41 +07:00
parent 7bad4ae186
commit 01a7c0b855
4 changed files with 285 additions and 2 deletions

View File

@ -170,6 +170,67 @@ export type ContractDetail = {
approvalWorkflowId: string | null approvalWorkflowId: string | null
currentApprovalLevelOrder: number | null currentApprovalLevelOrder: number | null
levelOpinions: ContractLevelOpinion[] | 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 N1N8 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. // [Plan B S29 2026-05-22 Chunk E3] Mirror BE ContractLevelOpinionDto 12 fields.

View File

@ -174,6 +174,67 @@ export type ContractDetail = {
approvalWorkflowId: string | null approvalWorkflowId: string | null
currentApprovalLevelOrder: number | null currentApprovalLevelOrder: number | null
levelOpinions: ContractLevelOpinion[] | 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 N1N8 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. // [Plan B S29 2026-05-22 Chunk E3] Mirror BE ContractLevelOpinionDto 12 fields.

View File

@ -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<ContractWorkflowStepDto>? 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<Guid, string>()
: 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 // Resolve user names
var userIds = new HashSet<Guid>(); var userIds = new HashSet<Guid>();
if (c.DrafterUserId is Guid did) userIds.Add(did); 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 // [Plan B S29 2026-05-22 Chunk E2] V2 fields
ApprovalWorkflowId: c.ApprovalWorkflowId, ApprovalWorkflowId: c.ApprovalWorkflowId,
CurrentApprovalLevelOrder: c.CurrentApprovalLevelOrder, 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 // FE uses this to render next-phase buttons dynamically — no more hardcoded

View File

@ -61,7 +61,81 @@ public record ContractDetailDto(
// FE fetch ApprovalFlow shape via /api/approval-workflows-v2/{ApprovalWorkflowId}. // FE fetch ApprovalFlow shape via /api/approval-workflows-v2/{ApprovalWorkflowId}.
Guid? ApprovalWorkflowId = null, Guid? ApprovalWorkflowId = null,
int? CurrentApprovalLevelOrder = null, int? CurrentApprovalLevelOrder = null,
List<ContractLevelOpinionDto>? LevelOpinions = null); List<ContractLevelOpinionDto>? 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<ContractWorkflowStepDto>? 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<string> 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<ContractWorkflowLevelDto> 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 // [Plan B S29 2026-05-22 Chunk E2] Ý kiến cấp duyệt V2 dynamic theo
// ApprovalWorkflowLevel. Mirror PE PurchaseEvaluationLevelOpinionDto pattern. // ApprovalWorkflowLevel. Mirror PE PurchaseEvaluationLevelOpinionDto pattern.