[CLAUDE] App+Api+FE-User+FE-Admin+Tests: W2 KHKK — CRUD phieu nhap + can cu b.8-9 + FE 3 page x2 app + 12 fix reviewer
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s

- BE mega-file ContractSigningPlanFeatures (Create 4-rao + auto-Lines snapshot per-winner + MaKeHoach @CREATE + UpdateDraft null-safe + Get/List/inbox-precompute-V2/deleted + Dossier + Attachments + Delete allow-list M6 + picker) + Controller 15 action policy per-action 15/15 (KeHoachKyKet.*)
- FE pages/khkk 3 page + types x2 app SHA-pair + 4-place (6 leaf Khkk_* het coming-soon; WfView: user=matrix?type=10 [whitelist +10] / admin=Designer deep-link); root KeHoachKyKet -> /khkk/list
- Reviewer adversarial: 12 finding (3 CRITICAL dut-2-bo FE-BE: route picker + peId body + dossier planId; 2 MAJOR security: detail+download 0 rao -> EnsureCanViewAsync mirror PE S89 nhap-rieng-tu; PUT-vs-POST de-ban-sao; peTenGoiThau; winnerSupplierNames; Content-Disposition RFC6266; LEFT-join Projects) -> 11 FIXED + 1 GIU (F-10 ke thua khuon PE, bit dong bo sau)
- tests: +8 (574 total 0 fail) — authz reflection dong + Create/Delete/guard/khong-dot-ma-khi-409 + fixture-co-interceptor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 21:19:41 +07:00
parent 914c5a40f2
commit 6cbc6ad937
20 changed files with 5020 additions and 23 deletions

View File

@ -0,0 +1,200 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.ContractSigningPlans;
using SolutionErp.Domain.ContractSigningPlans;
namespace SolutionErp.Api.Controllers;
// [W2 — S161 2026-07-29] REST "Kế hoạch ký kết HĐ" (GĐ2).
//
// 🔴 AUTHZ 2 TẦNG (gotcha #82 — menu-flag ≠ API-authz, reviewer-adversarial bắt S118):
// tầng 1 = class `[Authorize(Policy = "KeHoachKyKet.Read")]` (mọi endpoint tối thiểu Read)
// tầng 2 = per-action override `KeHoachKyKet.{Create|Update|Delete}` — GHI ĐỦ TRÊN MỌI
// action, kể cả action chỉ-đọc (viết lại Read cho tường minh): `[Authorize]` trần
// hoặc để trống = lỗ hổng, và "ẩn menu" KHÔNG đóng được API.
// 4 policy `KeHoachKyKet.*` sinh runtime vì `KeHoachKyKet` ∈ `MenuKeys.All`
// (`MenuKeys.cs:42,180`) × `MenuKeys.Actions` trong `Api/Program.cs`. Key KHÔNG nằm
// trong `All` ⇒ 500 lúc chạy (policy không tồn tại), không phải 403.
//
// 🔴 KHÔNG có endpoint transition (submit/approve/reject/return) — đó là W3.
// GlobalExceptionMiddleware map exception → ProblemDetails ⇒ TUYỆT ĐỐI không try-catch ở đây.
[ApiController]
[Route("api/contract-signing-plans")]
[Authorize(Policy = "KeHoachKyKet.Read")]
public class ContractSigningPlansController(IMediator mediator) : ControllerBase
{
// ========================= Đọc =========================
[HttpGet]
[Authorize(Policy = "KeHoachKyKet.Read")]
public async Task<ActionResult<PagedResult<ContractSigningPlanListItemDto>>> List(
[FromQuery] ContractSigningPlanPhase? phase = null,
[FromQuery] Guid? projectId = null,
[FromQuery] Guid? purchaseEvaluationId = null,
[FromQuery] bool pendingMe = false,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null,
[FromQuery] bool sortDesc = true,
CancellationToken ct = default)
=> Ok(await mediator.Send(
new ListContractSigningPlansQuery(phase, projectId, purchaseEvaluationId, pendingMe)
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
/// Inbox "chờ tôi duyệt" — wire sẵn cho W3 (W2 trả rỗng vì chưa có đường trình duyệt).
[HttpGet("inbox")]
[Authorize(Policy = "KeHoachKyKet.Read")]
public async Task<ActionResult<List<ContractSigningPlanListItemDto>>> Inbox(CancellationToken ct)
=> Ok(await mediator.Send(new GetMyContractSigningPlanInboxQuery(), ct));
/// Màn "Đã xoá" — CHỈ XEM.
[HttpGet("deleted")]
[Authorize(Policy = "KeHoachKyKet.Read")]
public async Task<ActionResult<PagedResult<ContractSigningPlanListItemDto>>> ListDeleted(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null,
[FromQuery] bool sortDesc = true,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListDeletedContractSigningPlansQuery
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
/// Picker màn tạo: phiếu Duyệt NCC đã duyệt, có NCC trúng thầu, chưa có kế hoạch sống.
[HttpGet("approved-pe-awaiting-plan")]
[Authorize(Policy = "KeHoachKyKet.Read")]
public async Task<ActionResult<List<ApprovedPeAwaitingPlanDto>>> ListApprovedPeAwaitingPlan(
CancellationToken ct)
=> Ok(await mediator.Send(new ListApprovedPeAwaitingPlanQuery(), ct));
[HttpGet("{id:guid}")]
[Authorize(Policy = "KeHoachKyKet.Read")]
public async Task<ActionResult<ContractSigningPlanDetailDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetContractSigningPlanQuery(id), ct));
// ========================= Ghi header =========================
[HttpPost]
[Authorize(Policy = "KeHoachKyKet.Create")]
public async Task<ActionResult<CreateContractSigningPlanResult>> Create(
[FromBody] CreateContractSigningPlanCommand cmd, CancellationToken ct)
{
var result = await mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Get), new { id = result.Id }, result);
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<IActionResult> UpdateDraft(
Guid id, [FromBody] UpdateContractSigningPlanDraftBody body, CancellationToken ct)
{
await mediator.Send(new UpdateContractSigningPlanDraftCommand(
id, body.GhiChu, body.HoSoLink, body.ApprovalWorkflowId), ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
[Authorize(Policy = "KeHoachKyKet.Delete")]
public async Task<IActionResult> Delete(
Guid id, [FromQuery] string? reason = null, CancellationToken ct = default)
{
await mediator.Send(new DeleteContractSigningPlanCommand(id, reason), ct);
return NoContent();
}
// ================= Căn cứ b.8-9 (dossier items) =================
// Ghi vào phiếu ⇒ policy Update (KHÔNG Create): "tạo" ở module này = tạo PHIẾU.
[HttpPost("{id:guid}/dossier-items")]
[Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<ActionResult<object>> CreateDossierItem(
Guid id, [FromBody] UpsertContractSigningPlanDossierItemCommand cmd, CancellationToken ct)
{
if (id != cmd.ContractSigningPlanId)
return BadRequest(new { detail = "ID kế hoạch trên đường dẫn không khớp dữ liệu gửi lên." });
var newId = await mediator.Send(cmd with { Id = null }, ct);
return Ok(new { id = newId });
}
[HttpPut("{id:guid}/dossier-items/{itemId:guid}")]
[Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<ActionResult<object>> UpdateDossierItem(
Guid id, Guid itemId,
[FromBody] UpsertContractSigningPlanDossierItemCommand cmd, CancellationToken ct)
{
if (id != cmd.ContractSigningPlanId)
return BadRequest(new { detail = "ID kế hoạch trên đường dẫn không khớp dữ liệu gửi lên." });
var savedId = await mediator.Send(cmd with { Id = itemId }, ct);
return Ok(new { id = savedId });
}
[HttpDelete("{id:guid}/dossier-items/{itemId:guid}")]
[Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<IActionResult> DeleteDossierItem(Guid id, Guid itemId, CancellationToken ct)
{
await mediator.Send(new DeleteContractSigningPlanDossierItemCommand(id, itemId), ct);
return NoContent();
}
// ===================== Đính kèm (mở mọi phase) =====================
[HttpPost("{id:guid}/attachments")]
[Authorize(Policy = "KeHoachKyKet.Update")]
[RequestSizeLimit(25_000_000)]
public async Task<ActionResult<ContractSigningPlanAttachmentDto>> UploadAttachment(
Guid id,
IFormFile file,
[FromForm] Guid? dossierItemId = null,
[FromForm] ContractSigningPlanAttachmentPurpose purpose = ContractSigningPlanAttachmentPurpose.DossierScan,
[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, file.FileName, file.ContentType, file.Length, stream, purpose, 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)
{
var f = await mediator.Send(new DownloadContractSigningPlanAttachmentQuery(id, attId), ct);
return File(f.Content, f.ContentType, f.FileName);
}
/// Xem inline (PDF iframe / ảnh) — cùng handler download, khác Content-Disposition.
[HttpGet("{id:guid}/attachments/{attId:guid}/view")]
[Authorize(Policy = "KeHoachKyKet.Read")]
public async Task<IActionResult> ViewAttachment(Guid id, Guid attId, CancellationToken ct)
{
var f = await mediator.Send(new DownloadContractSigningPlanAttachmentQuery(id, attId), ct);
// [W2 S161 — reviewer F-11] KHÔNG nội suy FileName thô vào header: tên có dấu/ký tự `"`
// làm header non-ASCII/vỡ cú pháp. ContentDispositionHeaderValue tự mã hoá RFC 6266
// (filename*) — cùng cơ chế MVC dùng cho đường /download.
var cd = new Microsoft.Net.Http.Headers.ContentDispositionHeaderValue("inline");
cd.SetHttpFileName(f.FileName);
Response.Headers.ContentDisposition = cd.ToString();
return File(f.Content, f.ContentType);
}
[HttpDelete("{id:guid}/attachments/{attId:guid}")]
[Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct)
{
await mediator.Send(new DeleteContractSigningPlanAttachmentCommand(id, attId), ct);
return NoContent();
}
// ========================= Body records =========================
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
// Mọi field nullable = null-safe: client không gửi ⇒ giữ giá trị cũ (#73).
public record UpdateContractSigningPlanDraftBody(
string? GhiChu = null,
string? HoSoLink = null,
Guid? ApprovalWorkflowId = null);
}

View File

@ -6,7 +6,9 @@ namespace SolutionErp.Application.ContractSigningPlans.Services;
// - Sequence per-năm qua bảng WorkflowAppCodeSequences DÙNG CHUNG (per-prefix row,
// IApplicationDbContext) — KHÔNG bảng sequence riêng (spec W1 §②-9, #8-CẮT spec cũ).
// - Seq reset đầu năm tự nhiên (key Prefix "KHKK/{YYYY}" mới).
// Caller: W2 Submit (gen lúc trình duyệt, KHÔNG lúc tạo nháp — mirror quyết spec cũ).
// Caller: W2 `CreateContractSigningPlanCommandHandler` — gen NGAY LÚC TẠO phiếu.
// 🔴 Đính chính bản W1 ("gen lúc Submit"): spec W2 §③-B đòi POST trả `maKeHoach` khớp
// `^KHKK/\d{4}/\d{3}$` ⇒ mã phải có từ lúc tạo, không defer tới trình duyệt.
public interface IContractSigningPlanCodeGenerator
{
// Gen mã atomic — transaction SERIALIZABLE chống race (mirror PE/Contract codegen).

View File

@ -14,7 +14,12 @@ namespace SolutionErp.Domain.ContractSigningPlans;
// nội bộ module (Lines/DossierItems/... Cascade) + ApprovalWorkflowId → bảng V2 dùng chung.
public class ContractSigningPlan : AuditableEntity
{
public string? MaKeHoach { get; set; } // "KHKK/2026/001" — gen atomic lúc Submit (W2)
// [W2 S161] "KHKK/2026/001" — gen atomic NGAY LÚC TẠO phiếu (POST trả về mã liền).
// 🔴 Đính chính comment W1 ("gen lúc Submit"): spec W2 §③-B chốt acceptance
// `POST /api/contract-signing-plans` → mã khớp `^KHKK/\d{4}/\d{3}$` ⇒ không thể defer
// tới Submit. Vẫn để nullable vì cột đã sinh nullable ở Mig 69 (không đổi schema) —
// phiếu tạo bằng đường CQRS luôn có mã.
public string? MaKeHoach { get; set; }
public Guid PurchaseEvaluationId { get; set; } // loose-Guid + IX — phiếu PE nguồn (DaDuyet)
public Guid ProjectId { get; set; } // denorm để lọc list (loose-Guid, mirror PE)

View File

@ -21,8 +21,10 @@ public class ContractSigningPlanConfiguration : IEntityTypeConfiguration<Contrac
b.Property(x => x.HoSoLink).HasMaxLength(1000);
b.Property(x => x.GhiChu).HasMaxLength(2000);
// Mã phiếu UNIQUE khi đã gen (gen lúc Submit ⇒ nháp còn null) — mirror
// PurchaseEvaluationConfiguration.cs:56 / ProposalConfiguration.cs:23.
// Mã phiếu UNIQUE khi đã gen — mirror PurchaseEvaluationConfiguration.cs:56 /
// ProposalConfiguration.cs:23. 🔴 Đính chính comment W1 ("gen lúc Submit ⇒ nháp còn
// null"): W2 gen NGAY LÚC TẠO ⇒ thực tế cột luôn NOT NULL. Filter GIỮ NGUYÊN (rộng
// hơn mức cần, không sai) — đổi filter = migration mới, không đáng.
b.HasIndex(x => x.MaKeHoach).IsUnique().HasFilter("[MaKeHoach] IS NOT NULL");
b.HasIndex(x => x.PurchaseEvaluationId);