diff --git a/fe-user/src/pages/InboxPage.tsx b/fe-user/src/pages/InboxPage.tsx
index ed491b8..17dc509 100644
--- a/fe-user/src/pages/InboxPage.tsx
+++ b/fe-user/src/pages/InboxPage.tsx
@@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
-import { useNavigate } from 'react-router-dom'
+import { useNavigate, useSearchParams } from 'react-router-dom'
import { Inbox, AlertTriangle, Clock, FileText } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable'
@@ -40,13 +40,17 @@ function StatCard({
export function InboxPage() {
const navigate = useNavigate()
const { user } = useAuth()
+ const [searchParams] = useSearchParams()
+ const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
const list = useQuery({
queryKey: ['inbox'],
queryFn: async () => (await api.get
('/contracts/inbox')).data,
})
- const rows = list.data ?? []
+ const allRows = list.data ?? []
+ // Apply type filter from sidebar nested menu (?type=X)
+ const rows = typeFilter == null ? allRows : allRows.filter(c => c.type === typeFilter)
const stats = useMemo(() => {
const now = Date.now()
diff --git a/fe-user/src/pages/contracts/MyContractsPage.tsx b/fe-user/src/pages/contracts/MyContractsPage.tsx
index f33478d..3789a09 100644
--- a/fe-user/src/pages/contracts/MyContractsPage.tsx
+++ b/fe-user/src/pages/contracts/MyContractsPage.tsx
@@ -1,5 +1,6 @@
+import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
-import { useNavigate } from 'react-router-dom'
+import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileText, Plus } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable'
@@ -16,12 +17,20 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function MyContractsPage() {
const navigate = useNavigate()
+ const [searchParams] = useSearchParams()
+ const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
const list = useQuery({
- queryKey: ['my-contracts'],
+ queryKey: ['my-contracts', typeFilter],
queryFn: async () => (await api.get>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
})
+ // Filter client-side by URL type param (sidebar nested menu passes it)
+ const rows = useMemo(() => {
+ const items = list.data?.items ?? []
+ return typeFilter == null ? items : items.filter(c => c.type === typeFilter)
+ }, [list.data, typeFilter])
+
const columns: Column[] = [
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => {c.maHopDong ?? '—'} },
{ key: 'tenHopDong', header: 'Tên', render: c => c.tenHopDong ?? '—' },
@@ -46,7 +55,7 @@ export function MyContractsPage() {
c.id}
isLoading={list.isLoading}
empty={
diff --git a/src/Backend/SolutionErp.Api/Controllers/WorkflowsController.cs b/src/Backend/SolutionErp.Api/Controllers/WorkflowsController.cs
new file mode 100644
index 0000000..893d707
--- /dev/null
+++ b/src/Backend/SolutionErp.Api/Controllers/WorkflowsController.cs
@@ -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> Overview(CancellationToken ct)
+ => Ok(await mediator.Send(new GetWorkflowAdminOverviewQuery(), ct));
+
+ [HttpPut("{contractType:int}")]
+ [Authorize(Policy = "Workflows.Update")]
+ public async Task 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);
diff --git a/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs b/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs
index 0ffc988..0da5eab 100644
--- a/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs
+++ b/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs
@@ -22,6 +22,7 @@ public interface IApplicationDbContext
DbSet ContractAttachments { get; }
DbSet ContractCodeSequences { get; }
DbSet Notifications { get; }
+ DbSet WorkflowTypeAssignments { get; }
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs
index 1177368..8d0d138 100644
--- a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs
+++ b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs
@@ -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? overrides)
{
- var policy = WorkflowPolicyRegistry.ForContract(c);
+ var policy = WorkflowPolicyRegistry.ForContractWithOverrides(c, overrides);
return new WorkflowSummaryDto(
PolicyName: policy.Name,
PolicyDescription: policy.Description,
diff --git a/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs
new file mode 100644
index 0000000..846c89b
--- /dev/null
+++ b/src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs
@@ -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 AllowedRolesAnyDir);
+
+public record WorkflowPolicyDto(string Name, string Description, List ActivePhases);
+
+public record WorkflowTypeAssignmentDto(
+ int ContractType,
+ string ContractTypeLabel,
+ string CurrentPolicy,
+ string DefaultPolicy,
+ WorkflowPolicyDto Policy);
+
+public record WorkflowAdminOverviewDto(
+ List AvailablePolicies,
+ List Assignments);
+
+public record GetWorkflowAdminOverviewQuery : IRequest;
+
+public class GetWorkflowAdminOverviewQueryHandler(IApplicationDbContext db)
+ : IRequestHandler
+{
+ private static readonly Dictionary 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 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()
+ .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
+{
+ 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
+{
+ 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);
+ }
+}
diff --git a/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs b/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs
index 2877b06..d16d42b 100644
--- a/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs
+++ b/src/Backend/SolutionErp.Domain/Contracts/WorkflowPolicy.cs
@@ -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? 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);
+ }
}
diff --git a/src/Backend/SolutionErp.Domain/Contracts/WorkflowTypeAssignment.cs b/src/Backend/SolutionErp.Domain/Contracts/WorkflowTypeAssignment.cs
new file mode 100644
index 0000000..d09a25d
--- /dev/null
+++ b/src/Backend/SolutionErp.Domain/Contracts/WorkflowTypeAssignment.cs
@@ -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)
+}
diff --git a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
index 8c67752..fa0277f 100644
--- a/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
+++ b/src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs
@@ -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_[_] — 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"];
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs
index 5657198..b76bd0f 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs
@@ -27,6 +27,7 @@ public class ApplicationDbContext
public DbSet ContractAttachments => Set();
public DbSet ContractCodeSequences => Set();
public DbSet Notifications => Set();
+ public DbSet WorkflowTypeAssignments => Set();
protected override void OnModelCreating(ModelBuilder builder)
{
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/WorkflowTypeAssignmentConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/WorkflowTypeAssignmentConfiguration.cs
new file mode 100644
index 0000000..259f2ff
--- /dev/null
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/WorkflowTypeAssignmentConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder e)
+ {
+ e.ToTable("WorkflowTypeAssignments");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.ContractType).HasConversion();
+ e.Property(x => x.PolicyName).HasMaxLength(50).IsRequired();
+ e.HasIndex(x => x.ContractType).IsUnique();
+ }
+}
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
index 57153fb..39d9043 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs
@@ -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
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421153913_AddWorkflowTypeAssignments.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421153913_AddWorkflowTypeAssignments.Designer.cs
new file mode 100644
index 0000000..8faf420
--- /dev/null
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421153913_AddWorkflowTypeAssignments.Designer.cs
@@ -0,0 +1,1102 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SolutionErp.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace SolutionErp.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260421153913_AddWorkflowTypeAssignments")]
+ partial class AddWorkflowTypeAssignments
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.6")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("RoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("UserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("UserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BypassProcurementAndCCM")
+ .HasColumnType("bit");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DepartmentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DraftData")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DrafterUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("GiaTri")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("MaHopDong")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("NoiDung")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("Phase")
+ .HasColumnType("int");
+
+ b.Property("ProjectId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("SlaDeadline")
+ .HasColumnType("datetime2");
+
+ b.Property("SlaWarningSent")
+ .HasColumnType("bit");
+
+ b.Property("SupplierId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("TemplateId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("TenHopDong")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MaHopDong")
+ .IsUnique()
+ .HasFilter("[MaHopDong] IS NOT NULL");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("SlaDeadline");
+
+ b.HasIndex("SupplierId");
+
+ b.HasIndex("Phase", "IsDeleted");
+
+ b.ToTable("Contracts", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Comment")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Decision")
+ .HasColumnType("int");
+
+ b.Property("FromPhase")
+ .HasColumnType("int");
+
+ b.Property("ToPhase")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "ApprovedAt");
+
+ b.ToTable("ContractApprovals", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("FileSize")
+ .HasColumnType("bigint");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Purpose")
+ .HasColumnType("int");
+
+ b.Property("StoragePath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId");
+
+ b.ToTable("ContractAttachments", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractCodeSequence", b =>
+ {
+ b.Property("Prefix")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("LastSeq")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Prefix");
+
+ b.ToTable("ContractCodeSequences", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Phase")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "CreatedAt");
+
+ b.ToTable("ContractComments", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContractType")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("PolicyName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractType")
+ .IsUnique();
+
+ b.ToTable("WorkflowTypeAssignments", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Version")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.ToTable("ContractClauses", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Forms.ContractTemplate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContractType")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("FieldSpec")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("FormCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Format")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("StoragePath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractType");
+
+ b.HasIndex("FormCode")
+ .IsUnique();
+
+ b.ToTable("ContractTemplates", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Icon")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("ParentKey")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.HasKey("Key");
+
+ b.HasIndex("ParentKey");
+
+ b.ToTable("MenuItems", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CanCreate")
+ .HasColumnType("bit");
+
+ b.Property("CanDelete")
+ .HasColumnType("bit");
+
+ b.Property("CanRead")
+ .HasColumnType("bit");
+
+ b.Property("CanUpdate")
+ .HasColumnType("bit");
+
+ b.Property("MenuKey")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("RoleId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MenuKey");
+
+ b.HasIndex("RoleId", "MenuKey")
+ .IsUnique();
+
+ b.ToTable("Permissions", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("Roles", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Identity.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("RefreshToken")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("RefreshTokenExpiresAt")
+ .HasColumnType("datetime2");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("bit");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex")
+ .HasFilter("[NormalizedUserName] IS NOT NULL");
+
+ b.ToTable("Users", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Master.Department", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("ManagerUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Note")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.ToTable("Departments", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Master.Project", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetTotal")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EndDate")
+ .HasColumnType("datetime2");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("ManagerUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Note")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("StartDate")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.ToTable("Projects", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Master.Supplier", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("ContactPerson")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Email")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Note")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("Phone")
+ .HasMaxLength(30)
+ .HasColumnType("nvarchar(30)");
+
+ b.Property("TaxCode")
+ .HasMaxLength(20)
+ .HasColumnType("nvarchar(20)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("Type");
+
+ b.ToTable("Suppliers", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Notifications.Notification", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("Href")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("ReadAt")
+ .HasColumnType("datetime2");
+
+ b.Property("RefId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property