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