[CLAUDE] PE: Workflow designer admin UI + Ý kiến 4 phòng ban (P1 Session 5)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m51s

==== Task 1: PE Workflow Designer admin ====

BE (mirror Contract WorkflowAdminFeatures pattern):
- Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs ~250 LOC:
  - GetPeWorkflowAdminOverviewQuery → list 2 EvaluationType (DuyetNcc / DuyetNccPhuongAn) với Active + History versions + count phiếu đang dùng
  - CreatePeWorkflowDefinitionCommand + Validator: auto-increment Version per Code, deactivate Active cũ trong cùng EvaluationType (1 active per type invariant)
  - DTOs: PeWorkflowStepApproverDto / PeWorkflowStepDto / PeWorkflowDefinitionDto / PeWorkflowTypeSummaryDto / PeWorkflowAdminOverviewDto
  - Phase validation 1..7 (state thường, không bao gồm 99=TuChoi)
- Api/Controllers/PeWorkflowsController.cs: 2 endpoint GET /api/pe-workflows + POST. Reuse policy "Workflows.Read" + "Workflows.Create" (admin chung quyền cho cả 2 nhóm WF).

FE:
- pages/system/PeWorkflowsPage.tsx ~500 LOC mirror WorkflowsPage:
  - Landing 2-card grid khi /system/pe-workflows (chưa pick type)
  - TypePanel khi /system/pe-workflows/:typeCode (DuyetNcc / DuyetNccPhuongAn)
  - DefinitionCard read-only view với active badge + version + steps + approvers (Role/User chip)
  - PeWorkflowDesigner dialog: clone từ existing, edit Code/Name/Description, add/remove steps, +Role / +User approvers per step, save → version mới + deactivate cũ
- App.tsx route /system/pe-workflows + /system/pe-workflows/:typeCode
- Layout đã có resolver PeWf_<Code> → /system/pe-workflows/<code> từ session 3

==== Task 2: Ý kiến 4 phòng ban PE ====

Domain:
- PurchaseEvaluationDepartmentOpinion entity (AuditableEntity) — PEId + Kind + Opinion text + SignedAt + UserId + UserName denorm
- PeDepartmentKind enum (PheDuyet / Ccm / MuaHang / SmPm)
- PE entity + collection navigation DepartmentOpinions

Infrastructure:
- PurchaseEvaluationDepartmentOpinionConfiguration EF: UNIQUE(PEId, Kind) — max 1 row per phòng ban per phiếu (UPDATE in-place)
- ApplicationDbContext + IApplicationDbContext DbSet
- Migration 15 AddPurchaseEvaluationDepartmentOpinions (15 migration total / 52 DB tables)

Application:
- PeDepartmentOpinionFeatures.cs: UpsertPeDepartmentOpinionCommand (sign=true → set SignedAt+UserId, sign=false chỉ lưu text giữ chữ ký cũ) + DeletePeDepartmentOpinionCommand
- DTO bundle update: + DepartmentOpinions list trong PurchaseEvaluationDetailBundleDto
- GetPurchaseEvaluationQueryHandler load DepartmentOpinions + KindLabel resolution

API:
- POST /api/purchase-evaluations/{id}/opinions (upsert)
- DELETE /api/purchase-evaluations/{id}/opinions/{kind}

FE:
- types/purchaseEvaluation.ts: + PeDepartmentKind enum + PeDepartmentKindLabel + PeDepartmentOpinion type + departmentOpinions vào bundle
- PeDetailTabs Section "5. Ý kiến 4 phòng ban (sign-off)" — 2x2 grid OpinionBox per kind:
  - Read mode (readOnly menu Duyệt): hiển thị text + chữ ký
  - Edit mode: textarea + 2 button "Lưu text" / "Lưu & Ký"
  - Badge "Đã ký" emerald + tên người ký + ngày khi signedAt != null

==== Task 3: User seed verify ====

Seed `SeedDemoUsersAsync` đã match đúng user list authoritative (5 PRO TPB+NV / 7 CCM TPB+NV / 1 ISO / 1 CEO) từ prior commit. DbInitializer reconcile sẽ tự sync khi API restart. Typo trong list user (soluttions / trương) đã fixed sensibly trong seed.

==== Build verify ====
- dotnet build clean (0 error)
- fe-admin TS build pass (1 module mới PeWorkflowsPage)
- fe-user TS build pass (PE detail mirror)

Total: 8 file mới (BE 4 + FE 1 + Migration 2 + 1 Domain) + 13 file modified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-29 11:17:14 +07:00
parent 7e36241db9
commit 5d94bb449a
20 changed files with 4771 additions and 0 deletions

View File

@ -0,0 +1,27 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.PurchaseEvaluations;
namespace SolutionErp.Api.Controllers;
// Versioned workflow admin cho module Duyệt NCC (PE). Reuse policy
// "Workflows.Read" + "Workflows.Create" giống Contract — admin có quyền
// quản lý cả 2 nhóm workflow (HĐ + PE).
[ApiController]
[Route("api/pe-workflows")]
[Authorize(Policy = "Workflows.Read")]
public class PeWorkflowsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PeWorkflowAdminOverviewDto>> Overview(CancellationToken ct)
=> Ok(await mediator.Send(new GetPeWorkflowAdminOverviewQuery(), ct));
[HttpPost]
[Authorize(Policy = "Workflows.Create")]
public async Task<ActionResult<object>> Create([FromBody] CreatePeWorkflowDefinitionCommand cmd, CancellationToken ct)
{
var id = await mediator.Send(cmd, ct);
return Ok(new { id });
}
}

View File

@ -201,8 +201,29 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
id, body.ContractType, body.TenHopDong, body.BypassProcurementAndCCM), ct);
return Ok(new { contractId });
}
// ========== Ý kiến 4 phòng ban ==========
// Upsert opinion (Add nếu chưa có, Update text + optional sign).
[HttpPost("{id:guid}/opinions")]
public async Task<ActionResult<object>> UpsertOpinion(
Guid id, [FromBody] OpinionBody body, CancellationToken ct)
{
var resultId = await mediator.Send(new UpsertPeDepartmentOpinionCommand(
id, body.Kind, body.Opinion, body.Sign), ct);
return Ok(new { id = resultId });
}
[HttpDelete("{id:guid}/opinions/{kind}")]
public async Task<IActionResult> DeleteOpinion(Guid id, PeDepartmentKind kind, CancellationToken ct)
{
await mediator.Send(new DeletePeDepartmentOpinionCommand(id, kind), ct);
return NoContent();
}
}
public record OpinionBody(PeDepartmentKind Kind, string? Opinion, bool Sign);
public record CreateContractFromEvaluationBody(
Domain.Contracts.ContractType ContractType,
string? TenHopDong,

View File

@ -58,6 +58,7 @@ public interface IApplicationDbContext
DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps { get; }
DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers { get; }
DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; }
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
// Module Ngân sách (Phase 7)
DbSet<Budget> Budgets { get; }

View File

@ -95,6 +95,15 @@ public record PurchaseEvaluationAttachmentDto(
string? Note,
DateTime CreatedAt);
public record PurchaseEvaluationDepartmentOpinionDto(
Guid Id,
PeDepartmentKind Kind,
string KindLabel,
string? Opinion,
DateTime? SignedAt,
Guid? UserId,
string? UserName);
public record PurchaseEvaluationDetailBundleDto(
Guid Id,
string? MaPhieu,
@ -122,4 +131,5 @@ public record PurchaseEvaluationDetailBundleDto(
List<PurchaseEvaluationDetailDto> Details,
List<PurchaseEvaluationApprovalDto> Approvals,
List<PurchaseEvaluationAttachmentDto> Attachments,
List<PurchaseEvaluationDepartmentOpinionDto> DepartmentOpinions,
PurchaseEvaluationWorkflowSummaryDto Workflow);

View File

@ -0,0 +1,152 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts; // ChangelogAction
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// Ý kiến 4 phòng ban (Phê duyệt / CCM / MuaHàng / SM-PM) trên PHIẾU TRÌNH KÝ.
// Upsert pattern — UPDATE in-place khi user đổi ý (không version), audit qua
// PurchaseEvaluationChangelog. UNIQUE index (PEId, Kind) bảo vệ tối đa 1 row
// mỗi loại phòng ban per phiếu.
// ========== UPSERT (Add nếu chưa có, Update nếu rồi) ==========
public record UpsertPeDepartmentOpinionCommand(
Guid PurchaseEvaluationId,
PeDepartmentKind Kind,
string? Opinion,
bool Sign) : IRequest<Guid>;
// Sign=true → set SignedAt + UserId hiện tại (đóng dấu xác nhận).
// Sign=false → chỉ lưu text Opinion (chưa ký).
public class UpsertPeDepartmentOpinionCommandValidator : AbstractValidator<UpsertPeDepartmentOpinionCommand>
{
public UpsertPeDepartmentOpinionCommandValidator()
{
RuleFor(x => x.PurchaseEvaluationId).NotEmpty();
RuleFor(x => x.Kind).IsInEnum();
RuleFor(x => x.Opinion).MaximumLength(2000);
}
}
public class UpsertPeDepartmentOpinionCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
UserManager<User> userManager) : IRequestHandler<UpsertPeDepartmentOpinionCommand, Guid>
{
public async Task<Guid> Handle(UpsertPeDepartmentOpinionCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(p => p.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
var existing = await db.PurchaseEvaluationDepartmentOpinions
.FirstOrDefaultAsync(o => o.PurchaseEvaluationId == pe.Id && o.Kind == request.Kind, ct);
string? actorName = null;
if (request.Sign)
{
var u = await userManager.FindByIdAsync(currentUser.UserId.Value.ToString());
actorName = u?.FullName ?? u?.Email;
}
Guid resultId;
ChangelogAction action;
if (existing == null)
{
var entity = new PurchaseEvaluationDepartmentOpinion
{
PurchaseEvaluationId = pe.Id,
Kind = request.Kind,
Opinion = request.Opinion,
SignedAt = request.Sign ? DateTime.UtcNow : null,
UserId = request.Sign ? currentUser.UserId : null,
UserName = request.Sign ? actorName : null,
};
db.PurchaseEvaluationDepartmentOpinions.Add(entity);
resultId = entity.Id;
action = ChangelogAction.Insert;
}
else
{
existing.Opinion = request.Opinion;
if (request.Sign)
{
existing.SignedAt = DateTime.UtcNow;
existing.UserId = currentUser.UserId;
existing.UserName = actorName;
}
// Sign=false giữ nguyên SignedAt/UserId cũ (user đã ký rồi vẫn giữ chữ ký).
resultId = existing.Id;
action = ChangelogAction.Update;
}
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header, // không có entity type riêng cho opinion
EntityId = resultId,
Action = action,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = actorName,
Summary = request.Sign
? $"Ý kiến {KindLabel(request.Kind)} — đã ký"
: $"Ý kiến {KindLabel(request.Kind)} — cập nhật text",
});
await db.SaveChangesAsync(ct);
return resultId;
}
private static string KindLabel(PeDepartmentKind k) => k switch
{
PeDepartmentKind.PheDuyet => "Phê duyệt",
PeDepartmentKind.Ccm => "P.CCM",
PeDepartmentKind.MuaHang => "P.Mua hàng",
PeDepartmentKind.SmPm => "SM-PM",
_ => k.ToString(),
};
}
// ========== DELETE (rare — admin override) ==========
public record DeletePeDepartmentOpinionCommand(Guid PurchaseEvaluationId, PeDepartmentKind Kind) : IRequest;
public class DeletePeDepartmentOpinionCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeletePeDepartmentOpinionCommand>
{
public async Task Handle(DeletePeDepartmentOpinionCommand request, CancellationToken ct)
{
var entity = await db.PurchaseEvaluationDepartmentOpinions
.FirstOrDefaultAsync(o => o.PurchaseEvaluationId == request.PurchaseEvaluationId && o.Kind == request.Kind, ct)
?? throw new NotFoundException("PEDepartmentOpinion", request.Kind);
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(p => p.Id == request.PurchaseEvaluationId, ct);
db.PurchaseEvaluationDepartmentOpinions.Remove(entity);
if (pe is not null)
{
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Delete,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
Summary = $"Xóa ý kiến phòng ban ({request.Kind})",
});
}
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,247 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts; // WorkflowApproverKind reuse
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// Versioned workflow management cho module Duyệt NCC (PE) — mirror Contract
// `WorkflowAdminFeatures` pattern. Phiếu PE đã pin WorkflowDefinitionId tại
// thời điểm tạo → vẫn chạy version cũ kể cả khi admin activate version mới.
public record PeWorkflowStepApproverDto(
int Kind, // 1=Role, 2=User (reuse WorkflowApproverKind)
string AssignmentValue,
string? DisplayName);
public record PeWorkflowStepDto(
Guid Id,
int Order,
int Phase,
string PhaseLabel,
string Name,
int? SlaDays,
List<PeWorkflowStepApproverDto> Approvers);
public record PeWorkflowDefinitionDto(
Guid Id,
string Code,
int Version,
int EvaluationType,
string EvaluationTypeLabel,
string Name,
string? Description,
bool IsActive,
DateTime? ActivatedAt,
DateTime CreatedAt,
int EvaluationsUsingCount,
List<PeWorkflowStepDto> Steps);
public record PeWorkflowTypeSummaryDto(
int EvaluationType,
string EvaluationTypeLabel,
PeWorkflowDefinitionDto? Active,
List<PeWorkflowDefinitionDto> History);
public record PeWorkflowAdminOverviewDto(List<PeWorkflowTypeSummaryDto> Types);
// ========== GET overview ==========
public record GetPeWorkflowAdminOverviewQuery : IRequest<PeWorkflowAdminOverviewDto>;
public class GetPeWorkflowAdminOverviewQueryHandler(
IApplicationDbContext db,
UserManager<User> userManager) : IRequestHandler<GetPeWorkflowAdminOverviewQuery, PeWorkflowAdminOverviewDto>
{
private static readonly Dictionary<PurchaseEvaluationType, string> TypeLabels = new()
{
[PurchaseEvaluationType.DuyetNcc] = "Duyệt NCC",
[PurchaseEvaluationType.DuyetNccPhuongAn] = "Duyệt NCC + Giải pháp",
};
private static readonly Dictionary<PurchaseEvaluationPhase, string> PhaseLabels = new()
{
[PurchaseEvaluationPhase.DangSoanThao] = "Đang soạn thảo",
[PurchaseEvaluationPhase.ChoPurchasing] = "Chờ Purchasing",
[PurchaseEvaluationPhase.ChoDuAn] = "Chờ Dự án",
[PurchaseEvaluationPhase.ChoCCM] = "Chờ CCM",
[PurchaseEvaluationPhase.ChoCEODuyetPA] = "Chờ CEO duyệt PA",
[PurchaseEvaluationPhase.ChoCEODuyetNCC] = "Chờ CEO duyệt NCC",
[PurchaseEvaluationPhase.DaDuyet] = "Đã duyệt",
};
public async Task<PeWorkflowAdminOverviewDto> Handle(GetPeWorkflowAdminOverviewQuery request, CancellationToken ct)
{
var definitions = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Include(d => d.Steps.OrderBy(s => s.Order))
.ThenInclude(s => s.Approvers)
.OrderByDescending(d => d.Version)
.ToListAsync(ct);
// Resolve user names cho User-kind approvers
var userIds = definitions
.SelectMany(d => d.Steps)
.SelectMany(s => s.Approvers)
.Where(a => a.Kind == WorkflowApproverKind.User && Guid.TryParse(a.AssignmentValue, out _))
.Select(a => Guid.Parse(a.AssignmentValue))
.Distinct()
.ToList();
var userNames = userIds.Count == 0
? new Dictionary<Guid, string>()
: await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
// Count phiếu PE per definition
var usageCounts = await db.PurchaseEvaluations.AsNoTracking()
.Where(p => p.WorkflowDefinitionId != null)
.GroupBy(p => p.WorkflowDefinitionId!.Value)
.Select(g => new { Id = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Count, ct);
PeWorkflowDefinitionDto ToDto(PurchaseEvaluationWorkflowDefinition d) => new(
d.Id,
d.Code,
d.Version,
(int)d.EvaluationType,
TypeLabels.GetValueOrDefault(d.EvaluationType, d.EvaluationType.ToString()),
d.Name,
d.Description,
d.IsActive,
d.ActivatedAt,
d.CreatedAt,
usageCounts.GetValueOrDefault(d.Id, 0),
d.Steps.OrderBy(s => s.Order).Select(s => new PeWorkflowStepDto(
s.Id,
s.Order,
(int)s.Phase,
PhaseLabels.GetValueOrDefault(s.Phase, s.Phase.ToString()),
s.Name,
s.SlaDays,
s.Approvers.Select(a => new PeWorkflowStepApproverDto(
(int)a.Kind,
a.AssignmentValue,
ResolveDisplay(a, userNames))).ToList()
)).ToList());
var types = Enum.GetValues<PurchaseEvaluationType>()
.Select(type =>
{
var versions = definitions.Where(d => d.EvaluationType == type).Select(ToDto).ToList();
return new PeWorkflowTypeSummaryDto(
(int)type,
TypeLabels.GetValueOrDefault(type, type.ToString()),
versions.FirstOrDefault(v => v.IsActive),
versions);
})
.ToList();
return new PeWorkflowAdminOverviewDto(types);
}
private static string? ResolveDisplay(PurchaseEvaluationWorkflowStepApprover a, Dictionary<Guid, string> userNames)
{
if (a.Kind == WorkflowApproverKind.Role) return a.AssignmentValue;
if (Guid.TryParse(a.AssignmentValue, out var uid) && userNames.TryGetValue(uid, out var n)) return n;
return a.AssignmentValue;
}
}
// ========== POST new version ==========
public record CreatePeWorkflowStepApproverInput(int Kind, string AssignmentValue);
public record CreatePeWorkflowStepInput(
int Order,
int Phase,
string Name,
int? SlaDays,
List<CreatePeWorkflowStepApproverInput> Approvers);
public record CreatePeWorkflowDefinitionCommand(
PurchaseEvaluationType EvaluationType,
string Code,
string Name,
string? Description,
List<CreatePeWorkflowStepInput> Steps) : IRequest<Guid>;
public class CreatePeWorkflowDefinitionCommandValidator : AbstractValidator<CreatePeWorkflowDefinitionCommand>
{
public CreatePeWorkflowDefinitionCommandValidator()
{
RuleFor(x => x.EvaluationType).IsInEnum();
RuleFor(x => x.Code).NotEmpty().MaximumLength(100)
.Matches("^[A-Za-z0-9._-]+$")
.WithMessage("Code chỉ dùng chữ, số, và các ký tự . _ -");
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.Steps).NotEmpty()
.WithMessage("Quy trình phải có ít nhất 1 bước.");
RuleForEach(x => x.Steps).ChildRules(step =>
{
step.RuleFor(s => s.Order).GreaterThanOrEqualTo(1);
// Phase 1..7 thường, 99 = TuChoi (không nên dùng làm step)
step.RuleFor(s => s.Phase).Must(p => p >= 1 && p <= 7)
.WithMessage("Phase phải nằm trong 1..7 (state thường, không bao gồm Từ chối=99).");
step.RuleFor(s => s.Name).NotEmpty().MaximumLength(200);
step.RuleFor(s => s.SlaDays).GreaterThanOrEqualTo(0)
.When(s => s.SlaDays != null);
step.RuleForEach(s => s.Approvers).ChildRules(app =>
{
app.RuleFor(a => a.Kind).InclusiveBetween(1, 2);
app.RuleFor(a => a.AssignmentValue).NotEmpty().MaximumLength(100);
});
});
}
}
public class CreatePeWorkflowDefinitionCommandHandler(IApplicationDbContext db)
: IRequestHandler<CreatePeWorkflowDefinitionCommand, Guid>
{
public async Task<Guid> Handle(CreatePeWorkflowDefinitionCommand request, CancellationToken ct)
{
var nextVersion = await db.PurchaseEvaluationWorkflowDefinitions
.Where(w => w.Code == request.Code)
.MaxAsync(w => (int?)w.Version, ct) ?? 0;
nextVersion++;
// Deactivate active version cho EvaluationType này (only ONE active per type)
var activeVersions = await db.PurchaseEvaluationWorkflowDefinitions
.Where(w => w.EvaluationType == request.EvaluationType && w.IsActive)
.ToListAsync(ct);
foreach (var old in activeVersions) old.IsActive = false;
var def = new PurchaseEvaluationWorkflowDefinition
{
Code = request.Code,
Version = nextVersion,
EvaluationType = request.EvaluationType,
Name = request.Name,
Description = request.Description,
IsActive = true,
ActivatedAt = DateTime.UtcNow,
Steps = request.Steps
.OrderBy(s => s.Order)
.Select(s => new PurchaseEvaluationWorkflowStep
{
Order = s.Order,
Phase = (PurchaseEvaluationPhase)s.Phase,
Name = s.Name,
SlaDays = s.SlaDays,
Approvers = s.Approvers.Select(a => new PurchaseEvaluationWorkflowStepApprover
{
Kind = (WorkflowApproverKind)a.Kind,
AssignmentValue = a.AssignmentValue,
}).ToList(),
})
.ToList(),
};
db.PurchaseEvaluationWorkflowDefinitions.Add(def);
await db.SaveChangesAsync(ct);
return def.Id;
}
}

View File

@ -342,6 +342,7 @@ public class GetPurchaseEvaluationQueryHandler(
.Include(x => x.Details).ThenInclude(d => d.Quotes)
.Include(x => x.Approvals)
.Include(x => x.Attachments)
.Include(x => x.DepartmentOpinions)
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
@ -438,11 +439,26 @@ public class GetPurchaseEvaluationQueryHandler(
a.Id, a.PurchaseEvaluationSupplierId, a.FileName, a.StoragePath,
a.FileSize, a.ContentType, a.Purpose, a.Note, a.CreatedAt))
.ToList(),
e.DepartmentOpinions
.OrderBy(o => (int)o.Kind)
.Select(o => new PurchaseEvaluationDepartmentOpinionDto(
o.Id, o.Kind, KindLabel(o.Kind),
o.Opinion, o.SignedAt, o.UserId, o.UserName))
.ToList(),
new PurchaseEvaluationWorkflowSummaryDto(
policy.Name, policy.Description,
policy.ActivePhases.ToList(),
policy.NextPhasesFrom(e.Phase).ToList()));
}
private static string KindLabel(PeDepartmentKind k) => k switch
{
PeDepartmentKind.PheDuyet => "Phê duyệt",
PeDepartmentKind.Ccm => "P.CCM",
PeDepartmentKind.MuaHang => "P.Mua hàng",
PeDepartmentKind.SmPm => "SM-PM",
_ => k.ToString(),
};
}
// ========== DELETE ==========

View File

@ -34,4 +34,5 @@ public class PurchaseEvaluation : AuditableEntity
public List<PurchaseEvaluationApproval> Approvals { get; set; } = new();
public List<PurchaseEvaluationChangelog> Changelogs { get; set; } = new();
public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new();
public List<PurchaseEvaluationDepartmentOpinion> DepartmentOpinions { get; set; } = new();
}

View File

@ -0,0 +1,35 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
// "Ý kiến 4 phòng ban" — sign-off block trên PHIẾU TRÌNH KÝ CHỌN TP/NCC.
// 4 box: Phê duyệt / P.CCM / P.MuaHàng / SM-PM. Mỗi box có:
// - Opinion text (text ý kiến)
// - SignedAt date
// - UserId người ký (lưu UserName denorm để render readable)
//
// Lưu thành table riêng (1:N với PurchaseEvaluation, max 4 row mỗi PE) để:
// - Dễ query "phòng nào chưa ký"
// - Audit history (mỗi update là 1 record? — không, dùng UPDATE in-place,
// audit qua PurchaseEvaluationChangelog).
// - Không bloat header với 12 column nullable.
public enum PeDepartmentKind
{
PheDuyet = 1, // box "PHÊ DUYỆT" trên cùng — typically Drafter/PM ký xác nhận trình
Ccm = 2, // P.CCM — Cost Control review
MuaHang = 3, // P.MuaHàng (PRO) — Procurement
SmPm = 4, // SM-PM — Site Manager / Project Manager
}
public class PurchaseEvaluationDepartmentOpinion : AuditableEntity
{
public Guid PurchaseEvaluationId { get; set; }
public PeDepartmentKind Kind { get; set; }
public string? Opinion { get; set; } // text ý kiến (max 2000)
public DateTime? SignedAt { get; set; }
public Guid? UserId { get; set; } // người ký
public string? UserName { get; set; } // denorm cho readable render
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
}

View File

@ -59,6 +59,7 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps => Set<PurchaseEvaluationWorkflowStep>();
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
// Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs.
public DbSet<Budget> Budgets => Set<Budget>();

View File

@ -32,6 +32,7 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.HasMany(x => x.Approvals).WithOne(a => a.PurchaseEvaluation).HasForeignKey(a => a.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Changelogs).WithOne(c => c.PurchaseEvaluation).HasForeignKey(c => c.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Attachments).WithOne(a => a.PurchaseEvaluation).HasForeignKey(a => a.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.DepartmentOpinions).WithOne(o => o.PurchaseEvaluation).HasForeignKey(o => o.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
// Quotes không FK trực tiếp tới PurchaseEvaluation (đi qua Detail) —
// nhưng collection navigation có nên cần config riêng bên dưới.
@ -217,3 +218,21 @@ public class PurchaseEvaluationCodeSequenceConfiguration
b.Property(x => x.Prefix).HasMaxLength(100);
}
}
public class PurchaseEvaluationDepartmentOpinionConfiguration
: IEntityTypeConfiguration<PurchaseEvaluationDepartmentOpinion>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationDepartmentOpinion> b)
{
b.ToTable("PurchaseEvaluationDepartmentOpinions");
b.HasKey(x => x.Id);
b.Property(x => x.Kind).HasConversion<int>();
b.Property(x => x.Opinion).HasMaxLength(2000);
b.Property(x => x.UserName).HasMaxLength(200);
// Each PE × Kind unique — max 1 ý kiến per phòng ban per phiếu.
// UPDATE in-place khi user đổi ý → audit qua Changelog.
b.HasIndex(x => new { x.PurchaseEvaluationId, x.Kind }).IsUnique();
}
}

View File

@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseEvaluationDepartmentOpinions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PurchaseEvaluationDepartmentOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
Opinion = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UserName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationDepartmentOpinions", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationDepartmentOpinions_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationDepartmentOpinions_PurchaseEvaluationId_Kind",
table: "PurchaseEvaluationDepartmentOpinions",
columns: new[] { "PurchaseEvaluationId", "Kind" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PurchaseEvaluationDepartmentOpinions");
}
}
}

View File

@ -2451,6 +2451,61 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("PurchaseEvaluationCodeSequences", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("Kind")
.HasColumnType("int");
b.Property<string>("Opinion")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("SignedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("PurchaseEvaluationId", "Kind")
.IsUnique();
b.ToTable("PurchaseEvaluationDepartmentOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
{
b.Property<Guid>("Id")
@ -3073,6 +3128,17 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("DepartmentOpinions")
.HasForeignKey("PurchaseEvaluationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
@ -3199,6 +3265,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Changelogs");
b.Navigation("DepartmentOpinions");
b.Navigation("Details");
b.Navigation("Quotes");