[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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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).
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user