[CLAUDE] App+Api+Docs: Chunk E1 — List endpoint + Bypass-review + Notify TPB + chốt session 8
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m15s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m15s
3 endpoint mới + Notify TPB + Docs update để chốt session 8.
Application:
- PurchaseEvaluationDepartmentApprovalFeatures.cs (NEW):
* ListPeDepartmentApprovalsQuery + DTO PeDepartmentApprovalDto
* Join Departments (lấy Name) + lookup Users.FullName denorm cho FE timeline
- UserFeatures.cs: SetUserBypassReviewCommand + Handler dùng UserManager.UpdateAsync
- IApplicationDbContext: thêm DbSet<User> Users + DbSet<Role> Roles (cần cho lookup)
Api:
- PurchaseEvaluationsController: GET /api/purchase-evaluations/{id}/department-approvals
- UsersController: PATCH /api/users/{id}/bypass-review (Authorize Users.Update)
Infra:
- PurchaseEvaluationWorkflowService: notify TPB cùng dept khi NV review.
Query db.Users.Where(DeptId match + IsActive) → UserManager.GetRolesAsync
filter DeptManager → notifications.NotifyAsync. Best effort fail non-critical.
Docs:
- STATUS.md: Recently Done thêm row session 8 + Phase header update
count 52→55 tables, 15→16 migrations, 128→131 endpoints
- HANDOFF.md: TL;DR session 8 + 8 cảnh báo session 9 (FE chưa làm,
test flow anh Kiệt, smart reject test, lock edit test, ...)
- migration-todos.md: Phase 9 done section đầy đủ 3 ràng buộc + pending Chunk E-bis
- CLAUDE.md: count 52→55 + migration 16 description
- session log: 2026-05-04-1230-chot-session-8-2-stage-dept-approval.md (full report)
Verify final:
- Build pass 0 warning 0 error
- 77 unit test pass (54 Domain + 23 Infra)
- Migration 16 applied LocalDB OK + schema verified
Total session 8 cumulative: 5 commit per-chunk:
- 5fe61cc (A: Migration 16 schema)
- 14f3c9f (B: Lock edit guards 17 handler)
- 9747f8c (C: Smart reject + Resume 3 module)
- a532ba6 (D: PE 2-stage logic)
- (current E1: List + Notify + Bypass + Docs)
Pending Chunk E-bis (defer cho session 9 sau UAT PE):
- FE Workflow Panel hiển thị 2-stage timeline
- FE UserManager toggle CanBypassReview
- HĐ + Budget 2-stage extension
- Tests Phase 3 mini cho 2-stage Service-layer logic
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -220,6 +220,15 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new DeletePeDepartmentOpinionCommand(id, kind), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ========== 2-stage department approvals (Phase 9 — Migration 16) ==========
|
||||
|
||||
// List approvals progress per phase × dept × stage. FE Workflow Panel
|
||||
// hiển thị timeline: phase nào đã review/confirm, ai duyệt khi nào.
|
||||
[HttpGet("{id:guid}/department-approvals")]
|
||||
public async Task<ActionResult<List<PeDepartmentApprovalDto>>> ListDepartmentApprovals(
|
||||
Guid id, CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new ListPeDepartmentApprovalsQuery(id), ct));
|
||||
}
|
||||
|
||||
public record OpinionBody(PeDepartmentKind Kind, string? Opinion, bool Sign);
|
||||
|
||||
@ -62,7 +62,18 @@ public class UsersController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new UnlockUserCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// 2-stage department approval (Phase 9): admin toggle bypass-review per user.
|
||||
[HttpPatch("{id:guid}/bypass-review")]
|
||||
[Authorize(Policy = "Users.Update")]
|
||||
public async Task<IActionResult> SetBypassReview(
|
||||
Guid id, [FromBody] SetBypassReviewBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new SetUserBypassReviewCommand(id, body.CanBypassReview), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record AssignRolesBody(List<string> Roles);
|
||||
public record ResetPasswordBody(string NewPassword);
|
||||
public record SetBypassReviewBody(bool CanBypassReview);
|
||||
|
||||
@ -23,6 +23,8 @@ public interface IApplicationDbContext
|
||||
DbSet<WorkItem> WorkItems { get; }
|
||||
DbSet<MenuItem> MenuItems { get; }
|
||||
DbSet<Permission> Permissions { get; }
|
||||
DbSet<User> Users { get; }
|
||||
DbSet<Role> Roles { get; }
|
||||
DbSet<ContractTemplate> ContractTemplates { get; }
|
||||
DbSet<ContractClause> ContractClauses { get; }
|
||||
DbSet<Contract> Contracts { get; }
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||
|
||||
// 2-stage department approval list (Phase 9 — Migration 16).
|
||||
// Query để FE hiển thị progress: phase nào × dept nào đã review/confirm.
|
||||
//
|
||||
// FE Workflow Panel sẽ render dạng timeline:
|
||||
// - Phase ChoPurchasing (PRO):
|
||||
// - Stage Review: long.chau (NV) — 14:30
|
||||
// - Stage Confirm: tra.bui (TPB) — 14:35 → unlock transition
|
||||
//
|
||||
// Insertion + Block logic ở PurchaseEvaluationWorkflowService.TransitionAsync.
|
||||
|
||||
public record ListPeDepartmentApprovalsQuery(Guid PurchaseEvaluationId) : IRequest<List<PeDepartmentApprovalDto>>;
|
||||
|
||||
public record PeDepartmentApprovalDto(
|
||||
Guid Id,
|
||||
int PhaseAtApproval,
|
||||
Guid DepartmentId,
|
||||
string? DepartmentName,
|
||||
ApprovalStage Stage,
|
||||
Guid ApproverUserId,
|
||||
string? ApproverName,
|
||||
string? ApproverRoleSnapshot, // "TPB" / "NV" / "NV(bypass)"
|
||||
string? Comment,
|
||||
DateTime ApprovedAt,
|
||||
bool IsBypassed);
|
||||
|
||||
public class ListPeDepartmentApprovalsQueryHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<ListPeDepartmentApprovalsQuery, List<PeDepartmentApprovalDto>>
|
||||
{
|
||||
public async Task<List<PeDepartmentApprovalDto>> Handle(
|
||||
ListPeDepartmentApprovalsQuery request, CancellationToken ct)
|
||||
{
|
||||
// Join với Departments để lấy Name. Join với Users để lấy ApproverName denorm.
|
||||
var rows = await (
|
||||
from a in db.PurchaseEvaluationDepartmentApprovals.AsNoTracking()
|
||||
join d in db.Departments.AsNoTracking() on a.DepartmentId equals d.Id into deptJoin
|
||||
from d in deptJoin.DefaultIfEmpty()
|
||||
where a.PurchaseEvaluationId == request.PurchaseEvaluationId
|
||||
orderby a.PhaseAtApproval, a.Stage, a.ApprovedAt
|
||||
select new
|
||||
{
|
||||
a.Id,
|
||||
a.PhaseAtApproval,
|
||||
a.DepartmentId,
|
||||
DepartmentName = d != null ? d.Name : null,
|
||||
a.Stage,
|
||||
a.ApproverUserId,
|
||||
a.ApproverRoleSnapshot,
|
||||
a.Comment,
|
||||
a.ApprovedAt,
|
||||
a.IsBypassed,
|
||||
}).ToListAsync(ct);
|
||||
|
||||
// Lookup approver names (separate query to avoid Identity user join complexity)
|
||||
var userIds = rows.Select(r => r.ApproverUserId).Distinct().ToList();
|
||||
var users = await db.Users.AsNoTracking()
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, Name = u.FullName ?? u.Email ?? "" })
|
||||
.ToDictionaryAsync(u => u.Id, u => u.Name, ct);
|
||||
|
||||
return rows.Select(r => new PeDepartmentApprovalDto(
|
||||
r.Id,
|
||||
r.PhaseAtApproval,
|
||||
r.DepartmentId,
|
||||
r.DepartmentName,
|
||||
r.Stage,
|
||||
r.ApproverUserId,
|
||||
users.TryGetValue(r.ApproverUserId, out var n) ? n : null,
|
||||
r.ApproverRoleSnapshot,
|
||||
r.Comment,
|
||||
r.ApprovedAt,
|
||||
r.IsBypassed)).ToList();
|
||||
}
|
||||
}
|
||||
@ -267,3 +267,23 @@ public class UnlockUserCommandHandler(UserManager<User> userManager) : IRequestH
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SET BYPASS REVIEW (Phase 9 — Migration 16) ==========
|
||||
// Admin toggle CanBypassReview cho 1 user. Khi true, user (NV) được duyệt
|
||||
// thay TPB ở 2-stage department approval (skip Stage Review, đẩy thẳng
|
||||
// Stage Confirm). Mặc định false (an toàn).
|
||||
public record SetUserBypassReviewCommand(Guid Id, bool CanBypassReview) : IRequest;
|
||||
|
||||
public class SetUserBypassReviewCommandHandler(UserManager<User> userManager)
|
||||
: IRequestHandler<SetUserBypassReviewCommand>
|
||||
{
|
||||
public async Task Handle(SetUserBypassReviewCommand request, CancellationToken ct)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(request.Id.ToString())
|
||||
?? throw new NotFoundException("User", request.Id);
|
||||
user.CanBypassReview = request.CanBypassReview;
|
||||
var result = await userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,7 +176,38 @@ public class PurchaseEvaluationWorkflowService(
|
||||
ContextNote = comment,
|
||||
});
|
||||
|
||||
// TODO Chunk E: notify TPB cùng dept để confirm.
|
||||
// Notify TPB cùng dept để confirm. Best effort — fail OK.
|
||||
try
|
||||
{
|
||||
var managers = await db.Users.AsNoTracking()
|
||||
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(ct);
|
||||
if (managers.Count > 0)
|
||||
{
|
||||
// Filter: chỉ notify user có role DeptManager (TPB).
|
||||
// Không có direct join với UserRoles ở IApplicationDbContext —
|
||||
// dùng UserManager để filter từng user.
|
||||
foreach (var mgrId in managers)
|
||||
{
|
||||
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
|
||||
if (mgr is null) continue;
|
||||
var roles = await userManager.GetRolesAsync(mgr);
|
||||
if (!roles.Contains(AppRoles.DeptManager)) continue;
|
||||
|
||||
await notifications.NotifyAsync(
|
||||
mgrId,
|
||||
NotificationType.ContractPhaseTransition,
|
||||
title: $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} chờ TPB confirm",
|
||||
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
|
||||
href: $"/purchase-evaluations/{evaluation.Id}",
|
||||
refId: evaluation.Id,
|
||||
ct: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* notification fail non-critical */ }
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user