[CLAUDE] Contract: KHKK 3-panel mirror Duyet NCC (GD2 het lech kien-truc) + 4 mitigation cuong-hanh
Dua man Ke hoach Ky ket HD ve dung lop kien-truc ma GD1 (PE) va GD3 (HD) dang
dung: 1 man 3-panel h-[calc(100vh-4rem)] grid [400px_1fr_360px], thay cho
list 2-panel + detail trang rieng co workflow xep chong. GD2 la mat-xich lech
duy nhat trong 4 giai-doan truoc dot nay.
BE (7/7):
- DetailDto +5 field: WorkflowVersion / EndedByLevelFinalize / FinalizeStepName
/ FinalizeLevelName / Approvals. 2 field Finalize* KHONG phai field append —
ContractSigningPlan chi co co bool, phai DAN XUAT (lam in-memory tu cay
workflow da nap, thay vi port 3 subquery cua PE).
- ContractSigningPlanApprovalDto de moi (truoc do 0 hit toan repo).
- StepDto +DepartmentName (nuoi chip phong panel 3 + header nhom Section 5).
- GET {id}/changelogs — policy KeHoachKyKet.Read.
- POST {id}/approval-attachments + enum +ApprovalAttachment=3 (append-only).
- approvals[] loc bo Decision=Pending de so (N) cung nghia voi (N) cua PE.
- KHONG chep khuon authz cua PurchaseEvaluationsController: file do la mot lo
gotcha #82 (class [Authorize] tran + changelogs khong policy).
FE (x2 app, SHA256 byte-identical 8/8):
- components/khkk/{KhkkListPanel,KhkkDetailContent,KhkkWorkflowPanel}.tsx;
KhkkWorkflowPanel doi tu pages/ sang components/.
- Panel 2: 5 Section danh so, Section 5 "Y kien cap duyet" duoi cung — khoi nay
DOI TU panel 3 sang panel 2 (owner khoanh do trong anh); luoi 2 cot + meta
"x/y cap da duyet · N NV tham gia" thay list phang 1 cot.
- Panel 3: banner ket-thuc-som, chip ten phong, badge Duyet thay CEO, lam mo
buoc sau finalize, khoi File dinh kem khi duyet, Lich su duyet, Lich su
thay doi. Chua chon phieu -> hop dashed y PE (QD owner).
4 mitigation cuong-hanh (owner chot panel 1 = PipelineTreePanel; chon HINH
khong phai chap-nhan VO):
- M-1 fallback !canPe -> danh-sach KHKK phang. Khong co no, user chi co quyen
KHKK (vd nguoi duyet nhom N5) MAT TRANG danh-sach du API van cho doc —
display-layer khoa authz-layer dang mo, mat con lai cua #82.
- M-2 onOpen thanh callback tu host; mac-dinh giu navigate cu => 0 hoi-quy
trang PE/GD3.
- B1 stage2Content di CAP voi stage2Count qua conditional-spread:
khong truyen => stage2 van nhan groups => trang PE GIU NGUYEN 8 ngan N1-N8.
- R-11 search/loc di server-side, KHONG loc-tren-cay; giu dong canh-bao
"Cay tai 200 phieu moi nhat".
Chong hoi-quy da do: build 3/3 PASS · acceptance tinh 20/20 x 2 app ·
mirror SHA256 8/8 · 0 hit path cam (components|pages)/(pe|contracts) ·
KHONG ghi filter/view/group vao URL (chong tai-sinh bug S155 sidebar sang
nham leaf) · giu set-state-in-render :97-101 (dung khuon React, refactor
thanh useEffect se tai-hien va-5).
Con no, khai thang:
- E-7 bang "cot nao chet, chuyen di dau" (8 cot + 6 KpiCard -> card 400px)
THIEU: lane FE chet o luot tra ve, ly-do bo cot mat theo. Phai soi diff
bang mat truoc UAT, neu khong dung ca M-7 mat-chuc-nang-im-lang.
- Test 643/644. 1 FLAKY Create_DifferentEvaluationType_DoesNotAffect_Other
(full-suite 2->1->1, chay rieng 0/0/0; PeWorkflowAdminTests tao
new SqliteDbFixture() rieng x6 test). Baseline tren HEAD sach CHUA do
(git worktree fail duong-dan Dropbox) => CHUA duoc noi "flake co san".
- 5 acceptance RUNTIME cho UAT: approvals co dong Tra lai + ten nguoi (DB dev
0 hang KHKK) · UAT fe-user:8080 an-danh · deep-link fe-admin:8082 (menu
KHKK bi an trong ADMIN_HIDDEN_KEYS) · negative-control go fallback canPe ·
khoi file tu-an khi rong.
- P3-12 4 return-mode = HOAN, khong phai buoc-khac: tien-de "KHKK 3 tram ngan"
do lai la SAI (DbInitializer:554 = 3 Buoc x <=2 Cap, seed tu ghi "sao tu
workflow Duyet NCC prod").
Spec + 6 artifact phan-bien: .claude/workflows/runs/2026-08-04-S171-khkk-ui-mirror-pe/
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -81,6 +81,19 @@ public class ContractSigningPlansController(
|
||||
public async Task<ActionResult<ContractSigningPlanDetailDto>> Get(Guid id, CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new GetContractSigningPlanQuery(id), ct));
|
||||
|
||||
/// [S171 BE-5] "Lịch sử thay đổi" panel 3 mục 7.
|
||||
// 🔴 `KeHoachKyKet.Read` GHI TƯỜNG MINH đúng key của endpoint (gotcha #85 — KHÔNG OR key con).
|
||||
// ⚠️ KHÔNG chép khuôn PE: `PurchaseEvaluationsController.cs:15` `[Authorize]` TRẦN + `:281`
|
||||
// endpoint lịch-sử KHÔNG policy nào ⇒ đăng nhập bất kỳ là đọc được lịch sử phiếu (#82
|
||||
// đang treo bên đó). Handler còn bịt thêm tầng hàng bằng `EnsureCanViewAsync`.
|
||||
// 🔸 Comment này CỐ Ý không viết lại chuỗi route: thước B-5 đếm literal, chú-thích nhắc tên
|
||||
// sẽ bơm tử-số (đúng lớp citation-trap mà B-6 vừa phải sửa).
|
||||
[HttpGet("{id:guid}/changelogs")]
|
||||
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||
public async Task<ActionResult<List<ContractSigningPlanChangelogDto>>> ListChangelogs(
|
||||
Guid id, [FromQuery] int take = 200, CancellationToken ct = default)
|
||||
=> Ok(await mediator.Send(new ListContractSigningPlanChangelogsQuery(id, take), ct));
|
||||
|
||||
// ========================= Ghi header =========================
|
||||
|
||||
[HttpPost]
|
||||
@ -179,6 +192,33 @@ public class ContractSigningPlansController(
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
/// [S171 BE-7 — owner chốt 2026-08-04] File người DUYỆT đính kèm trong dialog Duyệt/Trả lại/
|
||||
/// Từ chối (panel 3 mục 5, Ảnh 2). Đường RIÊNG thay vì bắt FE tự truyền `purpose=3`:
|
||||
/// • `Purpose` ép cứng `ApprovalAttachment` ở BE ⇒ FE không đặt nhầm mục đích, và khối
|
||||
/// "📎 File đính kèm khi duyệt" đọc theo purpose không lẫn với file căn cứ b.8-9;
|
||||
/// • `dossierItemId` KHÔNG nhận (file duyệt không treo vào căn cứ nào).
|
||||
// 🔴 Policy `KeHoachKyKet.Update` — đây là đường GHI (gotcha #85: đúng key của endpoint, KHÔNG
|
||||
// hạ xuống Read cho tiện, KHÔNG OR key con). Người duyệt cần quyền Update để bấm Duyệt
|
||||
// (`/transitions` cùng policy) ⇒ đính kèm lúc duyệt không đẻ thêm rào mới cho họ.
|
||||
[HttpPost("{id:guid}/approval-attachments")]
|
||||
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||
[RequestSizeLimit(25_000_000)]
|
||||
public async Task<ActionResult<ContractSigningPlanAttachmentDto>> UploadApprovalAttachment(
|
||||
Guid id,
|
||||
IFormFile file,
|
||||
[FromForm] string? note = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { detail = "Chưa chọn file." });
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var dto = await mediator.Send(new UploadContractSigningPlanAttachmentCommand(
|
||||
id, DossierItemId: null, file.FileName, file.ContentType, file.Length, stream,
|
||||
ContractSigningPlanAttachmentPurpose.ApprovalAttachment, note), ct);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/attachments/{attId:guid}/download")]
|
||||
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||
public async Task<IActionResult> DownloadAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||
|
||||
@ -140,7 +140,32 @@ public record ContractSigningPlanWorkflowStepDto(
|
||||
int Order,
|
||||
string Name,
|
||||
Guid? DepartmentId,
|
||||
List<ContractSigningPlanWorkflowLevelDto> Levels);
|
||||
List<ContractSigningPlanWorkflowLevelDto> Levels,
|
||||
// [S171 BE-4] append-CUỐI (KHÔNG chèn cạnh `DepartmentId` — convention file này là
|
||||
// append cuối, JSON bind theo TÊN nên FE không quan tâm vị trí). Tên phòng của Bước,
|
||||
// nuôi ĐỒNG THỜI 2 chỗ: chip phòng ở panel 3 (P3-4) và header nhóm Section 5 (P2-5).
|
||||
// Join `Departments` 1 lượt trong handler — `Step.DepartmentId` là loose-Guid (không nav).
|
||||
string? DepartmentName = null);
|
||||
|
||||
// [S171 BE-2] Lịch sử duyệt của phiếu — record MỚI (trước S171 grep = 0 hit; panel 3 mục 6
|
||||
// "Lịch sử duyệt (N)" của Ảnh 2 không có nguồn nào để đọc).
|
||||
// 🔴 `StepOrder/StepName/LevelOrder/LevelName` DẪN XUẤT từ `ApprovalWorkflowLevelId`
|
||||
// (loose-Guid, no-FK — `ContractSigningPlanApproval.cs:14-16`) qua bản-đồ Cấp của workflow đã
|
||||
// pin. Cấp bị admin xoá ⇒ 4 field null, dòng lịch sử VẪN hiện (không nuốt hàng).
|
||||
public record ContractSigningPlanApprovalDto(
|
||||
Guid Id,
|
||||
ContractSigningPlanPhase FromPhase,
|
||||
ContractSigningPlanPhase ToPhase,
|
||||
ApprovalDecision Decision,
|
||||
string? Comment,
|
||||
Guid? ApprovalWorkflowLevelId,
|
||||
int? StepOrder,
|
||||
string? StepName,
|
||||
int? LevelOrder,
|
||||
string? LevelName,
|
||||
Guid? ApprovedByUserId,
|
||||
string? ApprovedByFullName,
|
||||
DateTime ApprovedAt);
|
||||
|
||||
public record ContractSigningPlanDetailDto(
|
||||
Guid Id,
|
||||
@ -178,7 +203,21 @@ public record ContractSigningPlanDetailDto(
|
||||
// + 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);
|
||||
bool BudgetFrozen,
|
||||
// ===================== [S171 BE-1/BE-1b/BE-2] append-CUỐI =========================
|
||||
// Nuôi panel 3 "Quy trình duyệt" (Ảnh 2) — mirror khuôn PE.
|
||||
// • `WorkflowVersion` — dòng meta `{code} v{NN} · {name}` (ApprovalWorkflow.Version:24).
|
||||
// • `EndedByLevelFinalize` — CỜ RUNTIME đọc từ cột phiếu (Mig 71), banner "… không qua CEO".
|
||||
// • `FinalizeStepName`/`FinalizeLevelName` — 🔴 KHÔNG có cột DB nào lưu "Bước/Cấp nào đã
|
||||
// kết thúc" (`ContractSigningPlan.cs:51` chỉ có bool) ⇒ DẪN XUẤT từ cây workflow đã pin,
|
||||
// đúng ngữ nghĩa PE list (`PurchaseEvaluationFeatures.cs:678-685`): Cấp `AllowApproverFinalize`
|
||||
// ĐẦU TIÊN theo (Step.Order, Level.Order). Xem 3-ngả ở handler.
|
||||
// • `Approvals` — lịch sử duyệt (BE-2). ĐÃ LỌC `Decision=Pending`, xem ghi chú handler.
|
||||
int? WorkflowVersion,
|
||||
bool EndedByLevelFinalize,
|
||||
string? FinalizeStepName,
|
||||
string? FinalizeLevelName,
|
||||
List<ContractSigningPlanApprovalDto> Approvals);
|
||||
|
||||
/// Dòng picker "PE đã duyệt — chờ lập kế hoạch ký kết".
|
||||
public record ApprovedPeAwaitingPlanDto(
|
||||
@ -702,8 +741,12 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
||||
|
||||
// ---- Workflow tree + metadata Cấp cho LevelOpinions.
|
||||
string? wfCode = null, wfName = null;
|
||||
int? wfVersion = null; // [S171 BE-1] meta `{code} v{NN}`
|
||||
string? finalizeStepName = null, finalizeLevelName = null; // [S171 BE-1b] DẪN XUẤT
|
||||
var endedByLevelFinalize = false; // [S171 BE-1b] 3-ngả, xem dưới
|
||||
var stepDtos = new List<ContractSigningPlanWorkflowStepDto>();
|
||||
var levelMeta = new Dictionary<Guid, (int StepOrder, string StepName, int LevelOrder, Guid ApproverUserId)>();
|
||||
var levelMeta = new Dictionary<Guid,
|
||||
(int StepOrder, string StepName, int LevelOrder, string? LevelName, Guid ApproverUserId)>();
|
||||
|
||||
if (plan.ApprovalWorkflowId is Guid wfId)
|
||||
{
|
||||
@ -715,6 +758,7 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
||||
{
|
||||
wfCode = wf.Code;
|
||||
wfName = wf.Name;
|
||||
wfVersion = wf.Version;
|
||||
var approverIds = wf.Steps.SelectMany(s => s.Levels).Select(l => l.ApproverUserId)
|
||||
.Distinct().ToList();
|
||||
var approverNames = await db.Users.AsNoTracking()
|
||||
@ -722,18 +766,64 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
||||
.Select(u => new { u.Id, u.FullName })
|
||||
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
|
||||
|
||||
foreach (var s in wf.Steps.OrderBy(s => s.Order))
|
||||
// [S171 BE-4] Tên phòng của Bước — `Step.DepartmentId` loose-Guid (không nav) ⇒
|
||||
// 1 lượt truy gom, 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);
|
||||
|
||||
var sortedSteps = wf.Steps.OrderBy(s => s.Order).ToList();
|
||||
for (var i = 0; i < sortedSteps.Count; i++)
|
||||
{
|
||||
var s = sortedSteps[i];
|
||||
stepDtos.Add(new ContractSigningPlanWorkflowStepDto(
|
||||
s.Id, s.Order, s.Name, s.DepartmentId,
|
||||
s.Levels.OrderBy(l => l.Order).Select(l => new ContractSigningPlanWorkflowLevelDto(
|
||||
l.Id, l.Order, l.Name, l.ApproverUserId,
|
||||
approverNames.TryGetValue(l.ApproverUserId, out var fn) ? fn : null,
|
||||
l.AllowApproverFinalize)).ToList()));
|
||||
l.AllowApproverFinalize)).ToList(),
|
||||
s.DepartmentId is Guid dId && stepDeptNames.TryGetValue(dId, out var dn) ? dn : null));
|
||||
|
||||
foreach (var l in s.Levels)
|
||||
levelMeta[l.Id] = (s.Order, s.Name, l.Order, l.ApproverUserId);
|
||||
levelMeta[l.Id] = (s.Order, s.Name, l.Order, l.Name, l.ApproverUserId);
|
||||
}
|
||||
|
||||
// ---- [S171 BE-1b] "Kết thúc tại cấp" — KHÔNG có cột DB nào lưu Bước/Cấp nào đã
|
||||
// kết thúc (`ContractSigningPlan.cs:51` chỉ có bool) ⇒ DẪN XUẤT tại chỗ. Ngữ nghĩa
|
||||
// mirror PE list `PurchaseEvaluationFeatures.cs:670-685`, làm IN-MEMORY (cây đã nạp
|
||||
// đủ ở trên) thay vì port 3 subquery — cùng kết quả, bớt 3 lượt truy.
|
||||
// 🔴 KHÁC PE MỘT CHỖ, CỐ Ý: PE so `Step.Order - 1` với con-trỏ; KHKK
|
||||
// `CurrentWorkflowStepIndex` là INDEX 0-based vào list ĐÃ SORT
|
||||
// (`ContractSigningPlanWorkflowService.cs:26,422-435`), KHÔNG phải `Order - 1`.
|
||||
// Chép nguyên `Order - 1` sẽ sai ngay khi admin để Order không tuần-tự 1..N.
|
||||
var finalizeLevels = sortedSteps
|
||||
.SelectMany((s, idx) => s.Levels.OrderBy(l => l.Order)
|
||||
.Where(l => l.AllowApproverFinalize)
|
||||
.Select(l => new { StepIdx = idx, Step = s, Level = l }))
|
||||
.ToList();
|
||||
|
||||
// Tên Bước/Cấp = cấp finalize ĐẦU TIÊN của quy trình (KHÔNG lọc theo con-trỏ —
|
||||
// đúng như PE `:678-685`): đây là nhãn CẤU HÌNH "quy trình này kết thúc ở đâu".
|
||||
var firstFinalize = finalizeLevels.FirstOrDefault();
|
||||
finalizeStepName = firstFinalize?.Step.Name;
|
||||
finalizeLevelName = firstFinalize?.Level.Name;
|
||||
|
||||
// 3-ngả: phiếu ĐÃ DUYỆT đọc CỜ RUNTIME (finalize sớm THẬT đã xảy ra hay chưa —
|
||||
// bài S97 gotcha #78/#79: suy từ config là sai khi người duyệt bỏ tick opt-out);
|
||||
// phiếu chưa xong thì POINTER-AWARE (chỉ tính cấp finalize ở-hoặc-SAU con-trỏ, để
|
||||
// không dán banner "kết thúc sớm" khi phiếu đã QUA cấp đó và đang lên CEO);
|
||||
// không pin workflow ⇒ danh sách rỗng ⇒ false.
|
||||
var curIdx = plan.CurrentWorkflowStepIndex ?? 0;
|
||||
var curLevel = plan.CurrentApprovalLevelOrder ?? 1;
|
||||
endedByLevelFinalize = plan.Phase == ContractSigningPlanPhase.DaDuyet
|
||||
? plan.EndedByLevelFinalize
|
||||
: finalizeLevels.Any(x => x.StepIdx > curIdx
|
||||
|| (x.StepIdx == curIdx && x.Level.Order >= curLevel));
|
||||
}
|
||||
}
|
||||
|
||||
@ -781,6 +871,54 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
||||
.OrderBy(o => o.StepOrder).ThenBy(o => o.LevelOrder)
|
||||
.ToList();
|
||||
|
||||
// ---- [S171 BE-2/BE-3/BE-6] Lịch sử duyệt (panel 3 mục 6 của Ảnh 2).
|
||||
// 🔴 LỌC `Decision = Pending`: hàng Pending là hành-vi GỬI duyệt (`...WorkflowService.cs:204`),
|
||||
// KHÔNG phải một lần ai đó bấm Duyệt/Trả lại/Từ chối. Để nguyên thì số `(N)` của KHKK
|
||||
// đếm khác nghĩa với `(N)` của PE (PE dedupe ở FE `PeDetailTabs.tsx:3152-3192`) — trong
|
||||
// khi cả đợt này là "cho đồng nhất". KHÔNG mất thông tin: mọi transition kể cả Gửi duyệt
|
||||
// đều có 1 hàng ở `ContractSigningPlanChangelogs` (cùng hàm ghi `:546`) → khối
|
||||
// "Lịch sử thay đổi" vẫn hiện đủ.
|
||||
var approvalRows = await db.ContractSigningPlanApprovals.AsNoTracking()
|
||||
.Where(a => a.ContractSigningPlanId == plan.Id && a.Decision != ApprovalDecision.Pending)
|
||||
.OrderByDescending(a => a.ApprovedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// [BE-3] Tên người duyệt. `ContractSigningPlanApproval` KHÔNG denorm tên (khác hàng
|
||||
// changelog anh-em `:546-557` CÓ `UserName`).
|
||||
// 🔴 Chọn JOIN `Users`, KHÔNG lấy tên từ changelog cùng transition: 2 hàng đó ghi cùng
|
||||
// unit-of-work nhưng KHÔNG có khoá nối chính xác — `Approval.ApprovedAt` là `now` của
|
||||
// domain còn `Changelog.CreatedAt` do AuditingInterceptor đóng lúc SaveChanges ⇒ lệch ms,
|
||||
// ghép lại phải dùng "bucket thời gian" (đúng cái hack 5s PE đang phải chịu ở FE).
|
||||
// Phương án denorm thêm cột tên vào Approval thì cần MIGRATION — ngoài phạm vi đợt UI này.
|
||||
var approverActorIds = approvalRows
|
||||
.Where(a => a.ApprovedByUserId.HasValue).Select(a => a.ApprovedByUserId!.Value)
|
||||
.Distinct().ToList();
|
||||
var approverActorNames = approverActorIds.Count == 0
|
||||
? new Dictionary<Guid, string>()
|
||||
: await db.Users.AsNoTracking()
|
||||
.Where(u => approverActorIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FullName })
|
||||
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
|
||||
|
||||
var approvals = approvalRows
|
||||
.Select(a =>
|
||||
{
|
||||
var known = a.ApprovalWorkflowLevelId is Guid lid && levelMeta.ContainsKey(lid);
|
||||
var meta = known ? levelMeta[a.ApprovalWorkflowLevelId!.Value] : default;
|
||||
return new ContractSigningPlanApprovalDto(
|
||||
a.Id, a.FromPhase, a.ToPhase, a.Decision, a.Comment,
|
||||
a.ApprovalWorkflowLevelId,
|
||||
known ? meta.StepOrder : null,
|
||||
known ? meta.StepName : null,
|
||||
known ? meta.LevelOrder : null,
|
||||
known ? meta.LevelName : null,
|
||||
a.ApprovedByUserId,
|
||||
a.ApprovedByUserId is Guid uid && approverActorNames.TryGetValue(uid, out var an)
|
||||
? an : null,
|
||||
a.ApprovedAt);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new ContractSigningPlanDetailDto(
|
||||
plan.Id, plan.MaKeHoach, plan.PurchaseEvaluationId, pe?.MaPhieu, pe?.TenGoiThau,
|
||||
plan.ProjectId, project?.Name, project?.Code,
|
||||
@ -795,7 +933,9 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
||||
// đượ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);
|
||||
plan.Phase == ContractSigningPlanPhase.DaDuyet,
|
||||
// [S171 BE-1/BE-1b/BE-2] 5 field nuôi panel 3.
|
||||
wfVersion, endedByLevelFinalize, finalizeStepName, finalizeLevelName, approvals);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1451,3 +1591,55 @@ public class ListApprovedPeAwaitingPlanQueryHandler(IApplicationDbContext db)
|
||||
select pl.ApprovalGroup).ToList())).ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Region 8: CHANGELOG list (S171 BE-5) ==========================
|
||||
// Panel 3 mục 7 "Lịch sử thay đổi" — nguồn `ContractSigningPlanChangelogs` (ghi từ ngày 1,
|
||||
// 10 write-site kể cả cầu KHKK→HĐ `CreateContractFromSigningPlanFeatures.cs:226`).
|
||||
// Khuôn `ListPurchaseEvaluationChangelogsQueryHandler` (`PurchaseEvaluationFeatures.cs:1419`)
|
||||
// — 🔴 NHƯNG có THÊM rào xem, xem ghi chú ở handler.
|
||||
|
||||
public record ContractSigningPlanChangelogDto(
|
||||
Guid Id,
|
||||
ContractSigningPlanEntityType EntityType,
|
||||
Guid? EntityId,
|
||||
ChangelogAction Action,
|
||||
ContractSigningPlanPhase? PhaseAtChange,
|
||||
Guid? UserId,
|
||||
string? UserName,
|
||||
string? Summary,
|
||||
string? FieldChangesJson,
|
||||
string? ContextNote,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record ListContractSigningPlanChangelogsQuery(Guid ContractSigningPlanId, int Take = 200)
|
||||
: IRequest<List<ContractSigningPlanChangelogDto>>;
|
||||
|
||||
public class ListContractSigningPlanChangelogsQueryHandler(
|
||||
IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<ListContractSigningPlanChangelogsQuery, List<ContractSigningPlanChangelogDto>>
|
||||
{
|
||||
public async Task<List<ContractSigningPlanChangelogDto>> Handle(
|
||||
ListContractSigningPlanChangelogsQuery request, CancellationToken ct)
|
||||
{
|
||||
// 🔴 AUTHZ 2 TẦNG — tầng policy `KeHoachKyKet.Read` ở controller CHƯA ĐỦ (gotcha #82):
|
||||
// policy chỉ nói "được vào module", không nói "được đọc PHIẾU NÀY". Detail đã bịt bằng
|
||||
// `EnsureCanViewAsync` (`:678-679`) ⇒ lịch sử của cùng phiếu phải bịt CÙNG VỊ NGỮ, nếu
|
||||
// không thì bịt cửa chính còn mở cửa sau (nội dung ý kiến/ghi chú duyệt nằm ở đây).
|
||||
// ⚠️ CỐ Ý KHÔNG chép PE: `PurchaseEvaluationsController.cs:281` KHÔNG có policy nào và
|
||||
// handler PE cũng không có rào hàng — đó là lỗ #82 thứ hai đang treo, KHÔNG phải mẫu.
|
||||
var plan = await db.ContractSigningPlans.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Id == request.ContractSigningPlanId, ct)
|
||||
?? throw new NotFoundException("ContractSigningPlan", request.ContractSigningPlanId);
|
||||
await ContractSigningPlanScope.EnsureCanViewAsync(db, currentUser, plan, ct);
|
||||
|
||||
return await db.ContractSigningPlanChangelogs.AsNoTracking()
|
||||
.Where(c => c.ContractSigningPlanId == request.ContractSigningPlanId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Take(request.Take)
|
||||
.Select(c => new ContractSigningPlanChangelogDto(
|
||||
c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange,
|
||||
c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote,
|
||||
c.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,10 @@ public enum ContractSigningPlanAttachmentPurpose
|
||||
{
|
||||
DossierScan = 1, // Bản scan căn cứ (mẫu vật liệu / shopdrawing / kết quả TVGS)
|
||||
PlanDocument = 2, // Bản kế hoạch ký kết đã ký/xuất
|
||||
// [S171 BE-7 — owner chốt 2026-08-04] File người DUYỆT đính kèm lúc bấm Duyệt/Trả lại/Từ chối
|
||||
// (panel 3 mục 5 của Ảnh 2). APPEND-ONLY đúng luật dòng trên: KHÔNG đánh số lại `Other = 99`
|
||||
// — Purpose lưu là int trong DB, đổi số cũ = viết lại nghĩa của hàng đã có.
|
||||
ApprovalAttachment = 3,
|
||||
Other = 99,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user