[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:
pqhuy1987
2026-08-04 19:48:38 +07:00
parent f151320780
commit 998ea559f3
21 changed files with 4581 additions and 3179 deletions

View File

@ -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)

View File

@ -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);
}
}

View File

@ -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,
}