[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
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:
@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user