[CLAUDE] PurchaseEvaluation: nut XOA phieu o man duyet + menu 3 muc + man "Da xoa" (dot 2)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m47s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m47s
Dong ca UAT goc (Tra Sol): "lo bam sai cai goi thau, quay lai khong duoc, phai
xoa thi no moi ko co luy ke len". Dot 1 (b1bae77) da dung NEN (co F6 + lenh sua
quy trinh tai cho); dot 2 la phan nguoi dung cham vao.
- App: PeSoftDeleteFeatures.cs MOI
- DELETE /api/purchase-evaluations/{id}/by-approver — 3 rao: phase ChoDuyet
^ dung luot actor ^ matchingLevel.AllowApproverDelete (PER-NGUOI, khong g.Any)
- GET /api/purchase-evaluations/deleted — IgnoreQueryFilters() KEM .Where(IsDeleted)
(IgnoreQueryFilters GO filter chu khong DAO — thieu Where la liet ke ca phieu song)
+ tai lap khoi IDOR cua list hien hanh
- ghi vet ChangelogAction.Delete + ly do
- Api: 2 endpoint moi. 🔴 by-approver CO Y KHONG co [Authorize(Policy)] — do tren DB:
moi vai dang lam nguoi duyet (Procurement/CostControl...) deu CanDelete=0 va khong
seeder nao nang co do => gan policy la 403 voi dung nguoi can dung, ma FE gate cung
quyen nen khong thay nut khong thay loi (gotcha #44). Quyen = 3 rao handler; co F6
la admin tick dich danh tung nguoi => chat hon quyen theo vai.
- Dto: ApprovalWorkflowOptionsDto + AllowApproverDelete. Thieu field nay thi FE gate
luon sai => nut KHONG BAO GIO hien, ma BE lan FE deu build sach (reviewer bat).
- Domain/Infra: 2 menu key Pe_{code}_Approved / _Deleted (KHONG vao MenuKeys.All =>
giu 54/216) + seed tree + labelBackfill "Duyet" -> "Dang duyet" + 2 danh sach permission
- FE x2 app: nut Xoa trong PeWorkflowPanel (md5 mirror khop) + dialog ly do + Layout
regex/route cho 2 muc moi + man "Da xoa" chi-xem
- Tests: +11 (562 tong, 0 fail). T27 khoa CA LOP loi entity-co-co-ma-DTO-khong-co
(fault-inject xac nhan: go field ra => T27 do ngay)
Review: 12 FLAG, chot chan H1 (DTO thieu field) da va. Deploy KHONG co migration
nhung CO seed menu => phai restart API.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -143,6 +143,11 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// 🔴 [S155 đợt 2 — 7a] ĐƯỜNG XÓA NHÁP: GIỮ NGUYÊN, KHÔNG gắn policy.
|
||||
// Gắn `[Authorize(Policy="PurchaseEvaluations.Delete")]` vào đây = role `Drafter` MẤT
|
||||
// quyền xóa nháp của chính mình (đo thật DB Dev: 11/13 role `CanDelete=0`;
|
||||
// `DbInitializer.cs:2515` chỉ cấp DeptManager) ⇒ regression trên chức năng đang sống.
|
||||
// Rào của đường này vẫn là PHASE (`{DangSoanThao, TuChoi}`).
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
@ -150,6 +155,45 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// [S155 đợt 2 — 7b] ĐƯỜNG XÓA KHI ĐANG DUYỆT (mới). Endpoint RIÊNG để giữ đường xóa
|
||||
// nháp ở trên nguyên vẹn.
|
||||
//
|
||||
// 🔴 CỐ Ý KHÔNG có `[Authorize(Policy=...)]` — owner chốt 2026-07-27 sau khi reviewer
|
||||
// ĐO trên DB: mọi vai đang thật sự làm người duyệt (`Procurement`, `CostControl`, …)
|
||||
// đều `CanDelete = 0`, và 2 seeder KHÔNG BAO GIỜ nâng cờ đó (`DbInitializer.cs:2139-2140`
|
||||
// chỉ nâng Read/Create; `:2538` bỏ qua hàng đã tồn tại) ⇒ gắn policy vào là **403 với
|
||||
// đúng người cần dùng**, mà FE gate cùng quyền nên **không thấy nút, không thấy lỗi**
|
||||
// (gotcha #44 — 403 im lặng).
|
||||
//
|
||||
// Nguồn quyền THẬT của đường này = **3 rào nghiệp vụ trong handler** (`PeSoftDeleteFeatures.cs`):
|
||||
// (i) phase == ChoDuyet · (ii) ĐÚNG LƯỢT actor · (iii) `matchingLevel.AllowApproverDelete`
|
||||
// Rào (iii) là cờ F6 **per-NGƯỜI** — admin tick đích danh từng NV trong Designer quy trình.
|
||||
// Đó CHẶT HƠN permission theo vai (vai gộp nhiều người; cờ chỉ đúng một người, đúng một Cấp).
|
||||
// Đây KHÔNG phải "bỏ authz": handler có `ICurrentUser` và ném Forbidden — đúng khuôn cả
|
||||
// controller này vẫn dùng ("class [Authorize] any-auth; handler fine-grained Forbidden",
|
||||
// xem chú thích `:69-71`, `:88-90`, `:120`).
|
||||
// ⚠️ Hệ quả phải nhớ: FE **KHÔNG** được gate nút này bằng `can(..., 'Delete')` nữa —
|
||||
// gate đó sẽ ẩn nút với đúng người có quyền. Đã gỡ ở `PeWorkflowPanel.tsx`.
|
||||
[HttpDelete("{id:guid}/by-approver")]
|
||||
public async Task<IActionResult> DeleteByApprover(Guid id, [FromBody] DeleteByApproverBody? body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeletePurchaseEvaluationByApproverCommand(id, body?.Reason), ct);
|
||||
return NoContent();
|
||||
}
|
||||
public record DeleteByApproverBody(string? Reason);
|
||||
|
||||
// [S155 đợt 2 — mục 8] List phiếu ĐÃ XÓA (màn "Đã xóa", chỉ-xem). Handler RIÊNG là chỗ
|
||||
// DUY NHẤT dùng `IgnoreQueryFilters()` trong toàn `src/Backend` (D3).
|
||||
[HttpGet("deleted")]
|
||||
[Authorize(Policy = "PurchaseEvaluations.Read")]
|
||||
public async Task<ActionResult<PagedResult<PurchaseEvaluationListItemDto>>> ListDeleted(
|
||||
[FromQuery] PurchaseEvaluationType? type = null,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? search = null, [FromQuery] bool sortDesc = true,
|
||||
CancellationToken ct = default)
|
||||
=> Ok(await mediator.Send(new ListDeletedPurchaseEvaluationsQuery(type)
|
||||
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
|
||||
|
||||
// ========== Suppliers (N:M) ==========
|
||||
|
||||
[HttpPost("{id:guid}/suppliers")]
|
||||
|
||||
@ -128,7 +128,14 @@ public record ApprovalWorkflowOptionsDto(
|
||||
bool AllowApproverEditDetails,
|
||||
bool AllowApproverEditBudget,
|
||||
bool AllowApproverSkipToFinal,
|
||||
bool AllowApproverFinalize); // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
|
||||
bool AllowApproverFinalize, // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
|
||||
// [S155 đợt 2] F6 — slot này được XÓA phiếu khi đang duyệt. 🔴 FE gate nút Xóa bằng
|
||||
// ĐÚNG field này (`PeWorkflowPanel.tsx` `levelOptions?.allowApproverDelete === true`,
|
||||
// so `=== true` fail-closed). Thiếu field ⇒ vế đó LUÔN sai ⇒ nút KHÔNG BAO GIỜ hiện
|
||||
// với bất kỳ ai, mà BE lẫn FE đều build sạch — reviewer bắt được đúng ca này.
|
||||
// Test T27 (`PeDeleteByApproverTests`) khoá cả LỚP lỗi này: fault-inject xác nhận
|
||||
// gỡ field ra ⇒ T27 ĐỎ ngay, kèm tên cờ bị thiếu.
|
||||
bool AllowApproverDelete);
|
||||
|
||||
public record PurchaseEvaluationWorkflowSummaryDto(
|
||||
string PolicyName,
|
||||
|
||||
@ -0,0 +1,269 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Application.PurchaseEvaluations.Dtos;
|
||||
using SolutionErp.Domain.Contracts; // ChangelogAction (dùng chung PE)
|
||||
using SolutionErp.Domain.Identity; // AppRoles
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||
|
||||
// =====================================================================================
|
||||
// [S155 đợt 2] XÓA MỀM phiếu khi ĐANG DUYỆT + màn "Đã xóa".
|
||||
//
|
||||
// Vì sao có file này (UAT prod Tra Sol 2026-07-27): phiếu bấm SAI gói thầu đang treo ở
|
||||
// `ChoDuyet` vẫn **ăn lũy kế** ngân sách hạng mục — `PrevSubmitted` gom `ChoDuyet||DaDuyet`
|
||||
// (`PeBudgetAccumulator.cs:53-59`) và `PendingSelected` gom ChoDuyet-có-winner (`:114-126`).
|
||||
// "Trả lại" chỉ DỜI số sang dòng khác chứ không triệt tiêu, còn đường xóa cũ chỉ nhận
|
||||
// {DangSoanThao, TuChoi} (`PurchaseEvaluationFeatures.cs:1404-1406`) ⇒ không cửa nào dừng.
|
||||
// Xóa mềm đặt `IsDeleted=true` ⇒ global filter `PurchaseEvaluationConfiguration.cs:84`
|
||||
// tự loại phiếu khỏi CẢ 4 phép cộng lũy kế — KHÔNG phải sửa accumulator.
|
||||
//
|
||||
// 🔴 TÁCH ĐƯỜNG (owner chốt sau review H1): đường xóa NHÁP cũ
|
||||
// `DELETE /api/purchase-evaluations/{id}` GIỮ NGUYÊN **không policy** — gắn policy vào đó
|
||||
// làm role `Drafter` mất quyền xóa nháp của chính mình (đo thật trên DB Dev: 11/13 role
|
||||
// `CanDelete=0`, `DbInitializer.cs:2515` chỉ cấp cho DeptManager). Đường MỚI ở đây là
|
||||
// endpoint RIÊNG mang `[Authorize(Policy = "PurchaseEvaluations.Delete")]`.
|
||||
// =====================================================================================
|
||||
|
||||
// ========== A. DELETE BY APPROVER (phiếu đang duyệt) ==========
|
||||
|
||||
/// <param name="Reason">Lý do xóa — optional. `DeletedBy`/`DeletedAt` do `AuditingInterceptor`
|
||||
/// set chỉ trả lời "ai + lúc nào", KHÔNG có LÝ DO ⇒ changelog là chỗ chứa lý do.</param>
|
||||
public record DeletePurchaseEvaluationByApproverCommand(Guid Id, string? Reason = null) : IRequest;
|
||||
|
||||
public class DeletePurchaseEvaluationByApproverCommandValidator
|
||||
: AbstractValidator<DeletePurchaseEvaluationByApproverCommand>
|
||||
{
|
||||
public DeletePurchaseEvaluationByApproverCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Id).NotEmpty();
|
||||
// MaxLength MATCH EF config: `PurchaseEvaluationChangelog.ContextNote` HasMaxLength(2000)
|
||||
// (`PurchaseEvaluationConfiguration.cs:184`) — EF là source of truth, không trust spec.
|
||||
RuleFor(x => x.Reason).MaximumLength(2000);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeletePurchaseEvaluationByApproverCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationByApproverCommand>
|
||||
{
|
||||
public async Task Handle(DeletePurchaseEvaluationByApproverCommand request, CancellationToken ct)
|
||||
{
|
||||
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||
throw new UnauthorizedException();
|
||||
var actorId = currentUser.UserId.Value;
|
||||
|
||||
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||
|
||||
// ---- Rào (i): PHASE. Đường này CHỈ cho phiếu đang treo ở bước duyệt.
|
||||
// Phiếu nháp đi đường cũ (7a) — cố tình KHÔNG gộp để không đụng luồng đang sống.
|
||||
if (pe.Phase != PurchaseEvaluationPhase.ChoDuyet)
|
||||
throw new ConflictException(
|
||||
"Đường xóa này chỉ dùng cho phiếu đang ở trạng thái Đã gửi duyệt. " +
|
||||
"Phiếu nháp dùng nút Xóa ở màn soạn thảo.");
|
||||
|
||||
if (pe.ApprovalWorkflowId is not Guid awId)
|
||||
throw new ConflictException(
|
||||
"Phiếu chưa gắn quy trình duyệt (V2) nên không xác định được lượt duyệt.");
|
||||
|
||||
// ---- Rào (ii): ĐÚNG LƯỢT. Khuôn `ApproveV2Async`
|
||||
// (`PurchaseEvaluationWorkflowService.cs:677-720`): `CurrentWorkflowStepIndex` là
|
||||
// INDEX vào list Steps đã sort theo Order; `CurrentApprovalLevelOrder` so GIÁ TRỊ Order.
|
||||
var steps = await db.ApprovalWorkflowSteps.AsNoTracking()
|
||||
.Where(s => s.ApprovalWorkflowId == awId)
|
||||
.OrderBy(s => s.Order)
|
||||
.Select(s => new { s.Id, s.Order, s.Name })
|
||||
.ToListAsync(ct);
|
||||
if (steps.Count == 0)
|
||||
throw new ConflictException("Quy trình chưa có bước nào.");
|
||||
|
||||
var currentIdx = pe.CurrentWorkflowStepIndex ?? 0;
|
||||
if (currentIdx < 0 || currentIdx >= steps.Count)
|
||||
throw new ConflictException(
|
||||
$"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
|
||||
var currentStep = steps[currentIdx];
|
||||
var currentLevelOrder = pe.CurrentApprovalLevelOrder ?? 1;
|
||||
|
||||
var levelsOfStep = await db.ApprovalWorkflowLevels.AsNoTracking()
|
||||
.Where(l => l.ApprovalWorkflowStepId == currentStep.Id)
|
||||
.OrderBy(l => l.Order)
|
||||
.ToListAsync(ct);
|
||||
var pendingLevelGroup = levelsOfStep.Where(l => l.Order == currentLevelOrder).ToList();
|
||||
if (pendingLevelGroup.Count == 0)
|
||||
throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");
|
||||
|
||||
// 🔴 PER-ROW, KHÔNG fallback `.First()`. Fallback `pendingLevelGroup.First()` ở
|
||||
// `PurchaseEvaluationWorkflowService.cs:744-745` là đường Admin-KÝ-THAY khi DUYỆT —
|
||||
// bê sang đây sẽ biến "admin bất kỳ" thành người xóa được phiếu người khác.
|
||||
// Không match ⇒ chưa tới lượt ⇒ 403 (test 24).
|
||||
var matchingLevel = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)
|
||||
?? throw new ForbiddenException(
|
||||
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: " +
|
||||
"chưa tới lượt duyệt của bạn nên không xóa được phiếu này.");
|
||||
|
||||
// ---- Rào (iii): CỜ F6 **PER-ROW** (owner chốt per-NGƯỜI sau review H2).
|
||||
// Đọc `matchingLevel.AllowApproverDelete` (khuôn F5 `PurchaseEvaluationWorkflowService.cs:859`),
|
||||
// KHÔNG đọc per-Cấp `g.Any(l => ...)` (`PurchaseEvaluationFeatures.cs:1182`): 1 Cấp có N
|
||||
// người (OR-of-N — ảnh UAT: Cấp 2 Phòng Cung ứng 3 người) ⇒ `Any` phát quyền huỷ
|
||||
// chứng-từ tài chính cho CẢ Cấp thay vì đúng 1 người admin chỉ đích danh.
|
||||
if (!matchingLevel.AllowApproverDelete)
|
||||
throw new ForbiddenException(
|
||||
$"Cấp duyệt của bạn (Bước {currentIdx + 1} — Cấp {currentLevelOrder}) chưa được bật " +
|
||||
"quyền \"Xóa phiếu khi đang duyệt\". Liên hệ quản trị để bật trong Quy trình duyệt.");
|
||||
|
||||
// ---- Ghi VẾT trước khi xóa (khuôn `LogTransitionAsync`
|
||||
// `PurchaseEvaluationWorkflowService.cs:1169-1196`). `ChangelogAction.Delete = 3` đã tồn
|
||||
// tại (`ContractChangelog.cs:38-44`) ⇒ KHÔNG cần migration.
|
||||
// `PurchaseEvaluationChangelog : BaseEntity` (không `IsDeleted`) + query changelog lọc
|
||||
// theo `PurchaseEvaluationId` KHÔNG join PE (`PurchaseEvaluationFeatures.cs:1424-1425`)
|
||||
// ⇒ vết vẫn đọc được sau khi phiếu biến mất khỏi global filter.
|
||||
var reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
|
||||
|
||||
// 🔴 THỨ TỰ QUAN TRỌNG — `Remove()` TRƯỚC, `Add(changelog)` SAU. Đảo lại là MẤT VẾT.
|
||||
// Bug T26 bắt được (S155 đợt 2): EF chạy cascade client-side NGAY tại lời gọi
|
||||
// `Remove()` (`CascadeDeleteTiming.Immediate` = mặc định). Nếu changelog đã ở state
|
||||
// `Added` trước đó, EF thấy cha "sắp bị xóa" nên DETACH đứa con vừa thêm ⇒ SaveChanges
|
||||
// ghi 0 row, im lặng, không lỗi. Đo thật: `stateAfterAdd=Added → stateAfterRemove=Detached
|
||||
// → rowsInDb=0`.
|
||||
// Trớ trêu: phiếu KHÔNG hề bị xóa thật — `AuditingInterceptor` đổi Deleted→Modified
|
||||
// thành xóa MỀM. Nhưng cascade chạy TRƯỚC interceptor nên nó hành động dựa trên một vụ
|
||||
// xóa sẽ không bao giờ xảy ra.
|
||||
//
|
||||
// 🔴 CƠ CHẾ CỨU — đọc kỹ, đừng suy sai rồi dời `Add()` lên trên:
|
||||
// KHÔNG phải "thêm sau khi state đã đổi". Tại thời điểm `Add()` chạy, phiếu **VẪN ĐANG
|
||||
// là `Deleted`** — interceptor mãi tới `SaveChanges` mới đổi sang `Modified`. Thứ thật
|
||||
// sự cứu bản ghi là: **lượt cascade tức thời ĐÃ XẢY RA XONG ngay tại `Remove()`, lúc
|
||||
// chưa có đứa con nào để gỡ**. Không có lượt quét thứ hai ở `SaveChanges` gỡ lại
|
||||
// (T26 đọc NGƯỢC từ DB sau khi lưu và đòi đúng 2 dòng — nếu còn lượt quét thì phải là 0).
|
||||
// Chọn cách này thay vì `CascadeDeleteTiming.Never` (đụng setting toàn context) hoặc
|
||||
// 2 lần SaveChanges (đẻ nhu cầu transaction) — rẻ nhất và không có tác dụng phụ.
|
||||
db.PurchaseEvaluations.Remove(pe);
|
||||
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
EntityType = PurchaseEvaluationEntityType.Workflow,
|
||||
EntityId = matchingLevel.Id,
|
||||
Action = ChangelogAction.Delete,
|
||||
PhaseAtChange = pe.Phase, // ChoDuyet — phase TẠI LÚC xóa
|
||||
UserId = actorId,
|
||||
UserName = currentUser.FullName ?? currentUser.Email,
|
||||
Summary = $"Xóa phiếu khi đang duyệt (Bước {currentIdx + 1} — Cấp {currentLevelOrder})",
|
||||
ContextNote = reason,
|
||||
});
|
||||
|
||||
// Nhắc lại cho người đọc sau: `.Remove()` ở TRÊN = XÓA MỀM, không phải hard-delete.
|
||||
// `AuditingInterceptor.cs:54-63` đổi state Deleted→Modified rồi set
|
||||
// IsDeleted/DeletedAt/DeletedBy (indirect-assignment). Row PE ở lại ⇒ FK của
|
||||
// changelog vừa thêm vẫn hợp lệ.
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== B. LIST "ĐÃ XÓA" ==========
|
||||
|
||||
/// Màn "Đã xóa" — CHỈ XEM (owner chốt (6): chưa làm khôi phục).
|
||||
public record ListDeletedPurchaseEvaluationsQuery(PurchaseEvaluationType? Type = null)
|
||||
: PagedRequest, IRequest<PagedResult<PurchaseEvaluationListItemDto>>;
|
||||
|
||||
public class ListDeletedPurchaseEvaluationsQueryHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser)
|
||||
: IRequestHandler<ListDeletedPurchaseEvaluationsQuery, PagedResult<PurchaseEvaluationListItemDto>>
|
||||
{
|
||||
public async Task<PagedResult<PurchaseEvaluationListItemDto>> Handle(
|
||||
ListDeletedPurchaseEvaluationsQuery request, CancellationToken ct)
|
||||
{
|
||||
// 🔴 8a — `IgnoreQueryFilters()` **GỠ** filter chứ KHÔNG ĐẢO nó. Thiếu
|
||||
// `.Where(x => x.IsDeleted)` ⇒ màn "Đã xóa" liệt kê TOÀN BỘ phiếu sống lẫn chết.
|
||||
// 🔴 D3 — đây là chỗ DUY NHẤT trong `src/Backend` dùng `IgnoreQueryFilters`:
|
||||
// CẤM đưa vào helper dùng chung trả `IQueryable`, CẤM sửa
|
||||
// `PurchaseEvaluationConfiguration.cs:84`, CẤM thêm cờ `includeDeleted` vào query list
|
||||
// hiện có (cờ sẽ lan tới `PeBudgetAccumulator` = phá đúng thứ owner muốn).
|
||||
var q = from e in db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(x => x.IsDeleted)
|
||||
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
|
||||
join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj
|
||||
from s in sj.DefaultIfEmpty()
|
||||
join u in db.Users.AsNoTracking() on e.DrafterUserId equals u.Id into uj
|
||||
from u in uj.DefaultIfEmpty()
|
||||
join d in db.Departments.AsNoTracking() on e.DepartmentId equals d.Id into dj
|
||||
from d in dj.DefaultIfEmpty()
|
||||
join wi in db.WorkItems.AsNoTracking() on e.WorkItemId equals wi.Id into wij
|
||||
from wi in wij.DefaultIfEmpty()
|
||||
select new { e, p, s, u, d, wi };
|
||||
|
||||
// 🔴 8b — handler MỚI mặc định KHÔNG có rào nào. Tái lập IDOR-scope y hệt list sống
|
||||
// (`PurchaseEvaluationFeatures.cs:596-617`): non-admin chỉ thấy phiếu khi là Drafter
|
||||
// ∥ role eligible theo phase ∥ là approver của workflow đã pin. Thiếu bước này = mọi
|
||||
// user đọc được toàn bộ phiếu đã xóa của mọi dự án kèm `BudgetPeriodAmount` + tên gói
|
||||
// thầu + NCC.
|
||||
if (!currentUser.Roles.Contains(AppRoles.Admin))
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles);
|
||||
var userApprovalWfIds = userId is null
|
||||
? new List<Guid>()
|
||||
: await db.ApprovalWorkflowLevels.AsNoTracking()
|
||||
.Where(l => l.ApproverUserId == userId.Value)
|
||||
.Select(l => l.Step!.ApprovalWorkflowId)
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
q = q.Where(x =>
|
||||
x.e.DrafterUserId == userId
|
||||
|| eligiblePhases.Contains(x.e.Phase)
|
||||
|| (x.e.Phase != PurchaseEvaluationPhase.DangSoanThao
|
||||
&& x.e.ApprovalWorkflowId != null
|
||||
&& userApprovalWfIds.Contains(x.e.ApprovalWorkflowId.Value)));
|
||||
}
|
||||
|
||||
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||
{
|
||||
var s = request.Search.Trim();
|
||||
q = q.Where(x =>
|
||||
(x.e.MaPhieu != null && x.e.MaPhieu.Contains(s)) ||
|
||||
x.e.TenGoiThau.Contains(s) ||
|
||||
x.p.Name.Contains(s) ||
|
||||
x.p.Code.Contains(s));
|
||||
}
|
||||
|
||||
// Mới xóa lên đầu: `AuditingInterceptor` set `UpdatedAt` ngay tại lượt SaveChanges
|
||||
// của thao tác xóa mềm ⇒ UpdatedAt ≈ DeletedAt (DTO dùng chung không có cột DeletedAt).
|
||||
q = request.SortDesc
|
||||
? q.OrderByDescending(x => x.e.UpdatedAt ?? x.e.CreatedAt)
|
||||
: q.OrderBy(x => x.e.UpdatedAt ?? x.e.CreatedAt);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var items = await q
|
||||
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
|
||||
.Select(x => new PurchaseEvaluationListItemDto(
|
||||
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
|
||||
x.e.ProjectId, x.p.Name, x.p.Code,
|
||||
x.e.WorkItemId, x.wi != null ? x.wi.Name : null, x.wi != null ? x.wi.Code : null,
|
||||
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
|
||||
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
|
||||
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
|
||||
x.e.DepartmentId, x.d != null ? x.d.Name : null,
|
||||
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount,
|
||||
x.e.IsUrgentByPro, x.e.IsUrgentByCcm,
|
||||
(from sw in db.PurchaseEvaluationSuppliers
|
||||
where sw.PurchaseEvaluationId == x.e.Id && sw.IsWinner
|
||||
join sup in db.Suppliers on sw.SupplierId equals sup.Id
|
||||
orderby sup.Name
|
||||
select sup.Name).ToList(),
|
||||
// Badge "Kết thúc trước CEO" = thông tin về ĐƯỜNG ĐI CÒN LẠI của phiếu.
|
||||
// Phiếu đã xóa không còn đường đi ⇒ trả false/null tường minh thay vì tính
|
||||
// lại con-trỏ (khai rõ để người sau không tưởng là bug quên map).
|
||||
false, null, null))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
|
||||
}
|
||||
}
|
||||
@ -1093,7 +1093,8 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
curLevel.AllowApproverEditDetails,
|
||||
curLevel.AllowApproverEditBudget,
|
||||
curLevel.AllowApproverSkipToFinal,
|
||||
curLevel.AllowApproverFinalize);
|
||||
curLevel.AllowApproverFinalize,
|
||||
curLevel.AllowApproverDelete); // [S155 đợt 2] F6 — FE gate nút Xóa bằng đúng field này
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -135,6 +135,15 @@ public static class MenuKeys
|
||||
public static string PurchaseEvaluationList(string typeCode) => $"Pe_{typeCode}_List";
|
||||
public static string PurchaseEvaluationCreate(string typeCode) => $"Pe_{typeCode}_Create";
|
||||
public static string PurchaseEvaluationPending(string typeCode) => $"Pe_{typeCode}_Pending";
|
||||
// [S155 đợt 2 — owner restructure menu 2026-07-27] `Duyệt` đổi nhãn thành "Đang duyệt"
|
||||
// (qua `labelBackfill`, KHÔNG đổi key) + 2 mục MỚI:
|
||||
// `_Approved` → "Đã duyệt" → FE `/purchase-evaluations?type={N}&phase=7` (0 route mới)
|
||||
// `_Deleted` → "Đã xóa" → FE `/purchase-evaluations?type={N}&deleted=1`
|
||||
// 🔴 KHÔNG thêm vào `MenuKeys.All`: `Pe_*` là leaf sinh động qua factory (giống 5 key PE
|
||||
// hiện có), `All` chỉ chứa root `PurchaseEvaluations`. Thêm vào `All` sẽ đổi 2 số canonical
|
||||
// `Menu keys` / `Policies` ở `docs/STATUS.md` (Policies = |All| × |Actions|, DERIVED).
|
||||
public static string PurchaseEvaluationApproved(string typeCode) => $"Pe_{typeCode}_Approved";
|
||||
public static string PurchaseEvaluationDeleted(string typeCode) => $"Pe_{typeCode}_Deleted";
|
||||
// [Plan AA S24 t1] User read-only view ma trận phân quyền của workflow V2
|
||||
// admin Designer đã ghim (IsUserSelectable=true). Suffix `_WfView` distinct
|
||||
// với admin `PeWf_*` (Designer write).
|
||||
|
||||
@ -1874,7 +1874,13 @@ public static class DbInitializer
|
||||
tree.Add((MenuKeys.PurchaseEvaluationWorkflowView(code), "Luồng duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Network"));
|
||||
tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "List"));
|
||||
tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Plus"));
|
||||
tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2"));
|
||||
// [S155 đợt 2] Nhãn ở đây CHỈ có tác dụng cho DB TRỐNG. Với DB đang chạy, upsert
|
||||
// `:1893-1905` gặp key đã tồn tại thì chỉ update `Order` rồi `continue` — KHÔNG
|
||||
// đụng Label ⇒ rename "Duyệt" → "Đang duyệt" phải qua `labelBackfill` bên dưới.
|
||||
tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Đang duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2"));
|
||||
// 2 mục MỚI (owner chốt 2026-07-27) — key mới ⇒ đi nhánh INSERT bình thường.
|
||||
tree.Add((MenuKeys.PurchaseEvaluationApproved(code), "Đã duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCheck"));
|
||||
tree.Add((MenuKeys.PurchaseEvaluationDeleted(code), "Đã xóa", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Trash2"));
|
||||
}
|
||||
|
||||
// PE workflow admin leaves dưới `PeWorkflows`
|
||||
@ -1921,6 +1927,11 @@ public static class DbInitializer
|
||||
[MenuKeys.PurchaseEvaluationGroup("DuyetNccPhuongAn")] = "Duyệt NCC và Giải pháp",
|
||||
[MenuKeys.PeWorkflowTypeLeaf("DuyetNcc")] = "Duyệt NCC",
|
||||
[MenuKeys.PeWorkflowTypeLeaf("DuyetNccPhuongAn")] = "Duyệt NCC và Giải pháp",
|
||||
// [S155 đợt 2] 🔴 Rename "Duyệt" → "Đang duyệt" (owner, ảnh menu 2026-07-27).
|
||||
// BẮT BUỘC đi qua đây: sửa chữ trong `tree.Add(...)` là VÔ TÁC DỤNG trên DB đang
|
||||
// chạy vì upsert `:1893-1905` chỉ update `Order` cho key đã tồn tại.
|
||||
[MenuKeys.PurchaseEvaluationPending("DuyetNcc")] = "Đang duyệt",
|
||||
[MenuKeys.PurchaseEvaluationPending("DuyetNccPhuongAn")] = "Đang duyệt",
|
||||
};
|
||||
var updatedLabels = 0;
|
||||
foreach (var (key, expectedLabel) in labelBackfill)
|
||||
@ -2094,6 +2105,10 @@ public static class DbInitializer
|
||||
peKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode));
|
||||
peKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode));
|
||||
peKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode));
|
||||
// [S155 đợt 2] 2 mục menu mới — phải có mặt ở CẢ HAI danh sách permission
|
||||
// (đây + `SeedPurchaseEvaluationPermissionDefaultsAsync`), quên 1 bên = quyền lệch.
|
||||
peKeys.Add(MenuKeys.PurchaseEvaluationApproved(typeCode));
|
||||
peKeys.Add(MenuKeys.PurchaseEvaluationDeleted(typeCode));
|
||||
}
|
||||
|
||||
var reviewKeys = MenuKeys.All.Where(InReviewScope)
|
||||
@ -2494,6 +2509,10 @@ public static class DbInitializer
|
||||
menuKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode));
|
||||
menuKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode));
|
||||
menuKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode));
|
||||
// [S155 đợt 2] 2 mục menu mới ("Đã duyệt" / "Đã xóa") — mirror danh sách
|
||||
// `peKeys` trong `UpgradeReviewModulePermissionsForAllRolesAsync` (~:2092).
|
||||
menuKeys.Add(MenuKeys.PurchaseEvaluationApproved(typeCode));
|
||||
menuKeys.Add(MenuKeys.PurchaseEvaluationDeleted(typeCode));
|
||||
}
|
||||
|
||||
int added = 0;
|
||||
|
||||
Reference in New Issue
Block a user