[CLAUDE] Move nested-type menu → fe-user; Admin workflow config page
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m41s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m41s
User clarified: menu loại HĐ 3-level (Danh sách/Thao tác/Duyệt) thuộc
fe-user. Admin có page riêng để config quy trình per loại HĐ.
fe-admin Layout:
- filterForAdmin() drops Ct_* entries (hide nested type menu).
- Admin sidebar giờ về lại đơn giản: Dashboard / Master / Hợp đồng
(leaf) / Forms / Reports / System.
fe-user Layout:
- Dynamic menu tree từ /menus/me (thay fixed USER_MENU hardcoded).
- Recursive MenuNodeRenderer (top-level expanded, nested collapsed).
- resolvePath user-specific: Ct_*_List → /my-contracts?type=X,
Ct_*_Create → /contracts/new?type=X, Ct_*_Pending → /inbox?type=X.
- filterForUser drops admin-only entries (Master/System/Forms/Reports).
- Static USER_FIXED_TOP prepends "Hộp thư" leaf → /inbox.
- MyContractsPage + InboxPage đọc ?type=X param, filter client-side.
Workflow config (Admin side):
- Domain: WorkflowTypeAssignment entity (ContractType → PolicyName
override). Registry.ForContractWithOverrides() prefer DB override
else default.
- Infrastructure: EF config + migration AddWorkflowTypeAssignments,
unique index trên ContractType. ContractWorkflowService load
overrides dict mỗi transition. ContractFeatures load overrides khi
build WorkflowSummaryDto.
- Application: GetWorkflowAdminOverviewQuery returns 7 types × current
policy + available policies. SetWorkflowAssignmentCommand validate
policy name tồn tại; nếu = default thì delete override (no stale row).
- Api: GET /api/workflows + PUT /api/workflows/{contractType}
với policy "Workflows.Read" + "Workflows.Update".
- Menu: new key `Workflows` dưới System, label "Quy trình HĐ".
- FE /system/workflows: 7 card per type, dropdown Standard/SkipCcm +
'Đã override' badge khi khác default, phase sequence timeline,
explanation banner ở top. Iteration 2 note: admin-authored custom
policies.
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.Contracts;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/workflows")]
|
||||
[Authorize(Policy = "Workflows.Read")]
|
||||
public class WorkflowsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<WorkflowAdminOverviewDto>> Overview(CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new GetWorkflowAdminOverviewQuery(), ct));
|
||||
|
||||
[HttpPut("{contractType:int}")]
|
||||
[Authorize(Policy = "Workflows.Update")]
|
||||
public async Task<IActionResult> SetAssignment(int contractType, [FromBody] SetWorkflowAssignmentBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new SetWorkflowAssignmentCommand((ContractType)contractType, body.PolicyName), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record SetWorkflowAssignmentBody(string PolicyName);
|
||||
@ -22,6 +22,7 @@ public interface IApplicationDbContext
|
||||
DbSet<ContractAttachment> ContractAttachments { get; }
|
||||
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
|
||||
DbSet<Notification> Notifications { get; }
|
||||
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -337,6 +337,8 @@ public class GetContractQueryHandler(
|
||||
|
||||
var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct);
|
||||
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
|
||||
var workflowOverrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
|
||||
|
||||
// Resolve user names
|
||||
@ -376,14 +378,16 @@ public class GetContractQueryHandler(
|
||||
att.Id, att.FileName, att.StoragePath, att.FileSize,
|
||||
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
|
||||
.ToList(),
|
||||
BuildWorkflowSummary(c));
|
||||
BuildWorkflowSummary(c, workflowOverrides));
|
||||
}
|
||||
|
||||
// FE uses this to render next-phase buttons dynamically — no more hardcoded
|
||||
// NEXT_PHASES map that silently drifts from the BE policy.
|
||||
private static WorkflowSummaryDto BuildWorkflowSummary(Contract c)
|
||||
private static WorkflowSummaryDto BuildWorkflowSummary(
|
||||
Contract c,
|
||||
IReadOnlyDictionary<ContractType, string>? overrides)
|
||||
{
|
||||
var policy = WorkflowPolicyRegistry.ForContract(c);
|
||||
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(c, overrides);
|
||||
return new WorkflowSummaryDto(
|
||||
PolicyName: policy.Name,
|
||||
PolicyDescription: policy.Description,
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Application.Contracts;
|
||||
|
||||
// Admin UI /system/workflows — list current policy assignment per ContractType
|
||||
// + change via dropdown. Iteration 2: let admin define custom policies; for
|
||||
// now they pick from WorkflowPolicyRegistry.AvailablePolicyNames.
|
||||
|
||||
public record WorkflowPhaseDto(int Phase, int? SlaDays, List<string> AllowedRolesAnyDir);
|
||||
|
||||
public record WorkflowPolicyDto(string Name, string Description, List<int> ActivePhases);
|
||||
|
||||
public record WorkflowTypeAssignmentDto(
|
||||
int ContractType,
|
||||
string ContractTypeLabel,
|
||||
string CurrentPolicy,
|
||||
string DefaultPolicy,
|
||||
WorkflowPolicyDto Policy);
|
||||
|
||||
public record WorkflowAdminOverviewDto(
|
||||
List<WorkflowPolicyDto> AvailablePolicies,
|
||||
List<WorkflowTypeAssignmentDto> Assignments);
|
||||
|
||||
public record GetWorkflowAdminOverviewQuery : IRequest<WorkflowAdminOverviewDto>;
|
||||
|
||||
public class GetWorkflowAdminOverviewQueryHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetWorkflowAdminOverviewQuery, WorkflowAdminOverviewDto>
|
||||
{
|
||||
private static readonly Dictionary<ContractType, string> TypeLabels = new()
|
||||
{
|
||||
[ContractType.HopDongThauPhu] = "HĐ Thầu phụ",
|
||||
[ContractType.HopDongGiaoKhoan] = "HĐ Giao khoán",
|
||||
[ContractType.HopDongNhaCungCap] = "HĐ Nhà cung cấp",
|
||||
[ContractType.HopDongDichVu] = "HĐ Dịch vụ",
|
||||
[ContractType.HopDongMuaBan] = "HĐ Mua bán",
|
||||
[ContractType.HopDongNguyenTacNCC] = "HĐ Nguyên tắc NCC",
|
||||
[ContractType.HopDongNguyenTacDichVu] = "HĐ Nguyên tắc Dịch vụ",
|
||||
};
|
||||
|
||||
public async Task<WorkflowAdminOverviewDto> Handle(GetWorkflowAdminOverviewQuery request, CancellationToken ct)
|
||||
{
|
||||
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
|
||||
var availablePolicies = WorkflowPolicyRegistry.AvailablePolicyNames
|
||||
.Select(WorkflowPolicyRegistry.ByName)
|
||||
.Select(p => new WorkflowPolicyDto(p.Name, p.Description, p.ActivePhases.Select(x => (int)x).ToList()))
|
||||
.ToList();
|
||||
|
||||
var assignments = Enum.GetValues<ContractType>()
|
||||
.Select(t =>
|
||||
{
|
||||
var defaultName = WorkflowPolicyRegistry.DefaultPolicyNameFor(t);
|
||||
var currentName = overrides.TryGetValue(t, out var n) ? n : defaultName;
|
||||
var policy = WorkflowPolicyRegistry.ByName(currentName);
|
||||
return new WorkflowTypeAssignmentDto(
|
||||
(int)t,
|
||||
TypeLabels.GetValueOrDefault(t, t.ToString()),
|
||||
currentName,
|
||||
defaultName,
|
||||
new WorkflowPolicyDto(policy.Name, policy.Description, policy.ActivePhases.Select(x => (int)x).ToList()));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new WorkflowAdminOverviewDto(availablePolicies, assignments);
|
||||
}
|
||||
}
|
||||
|
||||
public record SetWorkflowAssignmentCommand(ContractType ContractType, string PolicyName) : IRequest;
|
||||
|
||||
public class SetWorkflowAssignmentCommandValidator : AbstractValidator<SetWorkflowAssignmentCommand>
|
||||
{
|
||||
public SetWorkflowAssignmentCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ContractType).IsInEnum();
|
||||
RuleFor(x => x.PolicyName).NotEmpty()
|
||||
.Must(name => WorkflowPolicyRegistry.AvailablePolicyNames.Contains(name))
|
||||
.WithMessage(x => $"Policy '{x.PolicyName}' không tồn tại. Cho phép: {string.Join(",", WorkflowPolicyRegistry.AvailablePolicyNames)}.");
|
||||
}
|
||||
}
|
||||
|
||||
public class SetWorkflowAssignmentCommandHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<SetWorkflowAssignmentCommand>
|
||||
{
|
||||
public async Task Handle(SetWorkflowAssignmentCommand request, CancellationToken ct)
|
||||
{
|
||||
var existing = await db.WorkflowTypeAssignments
|
||||
.FirstOrDefaultAsync(a => a.ContractType == request.ContractType, ct);
|
||||
|
||||
// If user sets policy back to the hardcoded default, delete the override
|
||||
// row so the registry uses the code-level default (no stale DB noise).
|
||||
var isDefault = request.PolicyName == WorkflowPolicyRegistry.DefaultPolicyNameFor(request.ContractType);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
if (isDefault) return; // nothing to persist
|
||||
db.WorkflowTypeAssignments.Add(new WorkflowTypeAssignment
|
||||
{
|
||||
ContractType = request.ContractType,
|
||||
PolicyName = request.PolicyName,
|
||||
});
|
||||
}
|
||||
else if (isDefault)
|
||||
{
|
||||
db.WorkflowTypeAssignments.Remove(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.PolicyName = request.PolicyName;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@ -117,23 +117,40 @@ public static class WorkflowPolicies
|
||||
|
||||
public static class WorkflowPolicyRegistry
|
||||
{
|
||||
// Mapping contract type → policy. Tuned to the real business from
|
||||
// QT-TP-NCC.docx: formal NTP/NCC/Giao khoán need full CCM review; service /
|
||||
// purchase / framework contracts skip CCM.
|
||||
public static WorkflowPolicy For(ContractType type) => type switch
|
||||
// Available policy names — extend when admin-authored custom policies
|
||||
// are added in iteration 2.
|
||||
public static readonly string[] AvailablePolicyNames = ["Standard", "SkipCcm"];
|
||||
|
||||
public static WorkflowPolicy ByName(string name) => name switch
|
||||
{
|
||||
ContractType.HopDongThauPhu => WorkflowPolicies.Standard,
|
||||
ContractType.HopDongGiaoKhoan => WorkflowPolicies.Standard,
|
||||
ContractType.HopDongNhaCungCap => WorkflowPolicies.Standard,
|
||||
ContractType.HopDongDichVu => WorkflowPolicies.SkipCcm,
|
||||
ContractType.HopDongMuaBan => WorkflowPolicies.SkipCcm,
|
||||
ContractType.HopDongNguyenTacNCC => WorkflowPolicies.SkipCcm,
|
||||
ContractType.HopDongNguyenTacDichVu => WorkflowPolicies.SkipCcm,
|
||||
"SkipCcm" => WorkflowPolicies.SkipCcm,
|
||||
_ => WorkflowPolicies.Standard,
|
||||
};
|
||||
|
||||
// Instance-level bypass flag overrides the default: if a contract has
|
||||
// Default mapping per contract type — used when DB override missing.
|
||||
// Matches business from QT-TP-NCC.docx.
|
||||
public static string DefaultPolicyNameFor(ContractType type) => type switch
|
||||
{
|
||||
ContractType.HopDongThauPhu or ContractType.HopDongGiaoKhoan or ContractType.HopDongNhaCungCap => "Standard",
|
||||
_ => "SkipCcm",
|
||||
};
|
||||
|
||||
public static WorkflowPolicy For(ContractType type) => ByName(DefaultPolicyNameFor(type));
|
||||
|
||||
// Instance-level bypass flag overrides the policy — if a contract has
|
||||
// BypassProcurementAndCCM=true, always use SkipCcm regardless of type.
|
||||
public static WorkflowPolicy ForContract(Contract contract) =>
|
||||
contract.BypassProcurementAndCCM ? WorkflowPolicies.SkipCcm : For(contract.Type);
|
||||
|
||||
// DB-aware resolver: prefer assignment, else default. Pass in the
|
||||
// assignments dict once per request (cached from DB).
|
||||
public static WorkflowPolicy ForContractWithOverrides(
|
||||
Contract contract,
|
||||
IReadOnlyDictionary<ContractType, string>? assignments)
|
||||
{
|
||||
if (contract.BypassProcurementAndCCM) return WorkflowPolicies.SkipCcm;
|
||||
if (assignments is not null && assignments.TryGetValue(contract.Type, out var policyName))
|
||||
return ByName(policyName);
|
||||
return For(contract.Type);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Contracts;
|
||||
|
||||
// Admin-configurable: which WorkflowPolicy applies to each ContractType.
|
||||
// Seed with hardcoded defaults (see DbInitializer); admin can switch via
|
||||
// /system/workflows UI without code changes. Later iteration will let admin
|
||||
// define custom policies with bespoke phase/role/SLA.
|
||||
public class WorkflowTypeAssignment : BaseEntity
|
||||
{
|
||||
public ContractType ContractType { get; set; }
|
||||
public string PolicyName { get; set; } = string.Empty; // "Standard" | "SkipCcm" (extensible)
|
||||
}
|
||||
@ -16,6 +16,7 @@ public static class MenuKeys
|
||||
public const string Users = "Users";
|
||||
public const string Roles = "Roles";
|
||||
public const string Permissions = "Permissions";
|
||||
public const string Workflows = "Workflows";
|
||||
|
||||
// Per-contract-type menu groups + 3 action leaves each.
|
||||
// Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from
|
||||
@ -35,7 +36,7 @@ public static class MenuKeys
|
||||
Dashboard,
|
||||
Master, Suppliers, Projects, Departments,
|
||||
Contracts, Forms, Reports,
|
||||
System, Users, Roles, Permissions,
|
||||
System, Users, Roles, Permissions, Workflows,
|
||||
];
|
||||
|
||||
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];
|
||||
|
||||
@ -27,6 +27,7 @@ public class ApplicationDbContext
|
||||
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
|
||||
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class WorkflowTypeAssignmentConfiguration : IEntityTypeConfiguration<WorkflowTypeAssignment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkflowTypeAssignment> e)
|
||||
{
|
||||
e.ToTable("WorkflowTypeAssignments");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.ContractType).HasConversion<int>();
|
||||
e.Property(x => x.PolicyName).HasMaxLength(50).IsRequired();
|
||||
e.HasIndex(x => x.ContractType).IsUnique();
|
||||
}
|
||||
}
|
||||
@ -113,6 +113,7 @@ public static class DbInitializer
|
||||
(MenuKeys.Users, "Người dùng", MenuKeys.System, 91, "User"),
|
||||
(MenuKeys.Roles, "Vai trò", MenuKeys.System, 92, "Shield"),
|
||||
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
|
||||
(MenuKeys.Workflows, "Quy trình HĐ", MenuKeys.System, 94, "GitBranch"),
|
||||
};
|
||||
|
||||
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWorkflowTypeAssignments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkflowTypeAssignments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ContractType = table.Column<int>(type: "int", nullable: false),
|
||||
PolicyName = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
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)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkflowTypeAssignments", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkflowTypeAssignments_ContractType",
|
||||
table: "WorkflowTypeAssignments",
|
||||
column: "ContractType",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkflowTypeAssignments");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -374,6 +374,40 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("ContractComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("ContractType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("PolicyName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContractType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkflowTypeAssignments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@ -36,7 +36,11 @@ public class ContractWorkflowService(
|
||||
if (contract.Phase == targetPhase)
|
||||
throw new ConflictException("HĐ đã ở phase đích.");
|
||||
|
||||
var policy = WorkflowPolicyRegistry.ForContract(contract);
|
||||
// Admin may override the default policy per ContractType via the
|
||||
// /system/workflows page. Load all overrides once (7 rows max).
|
||||
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(contract, overrides);
|
||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user