diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx
index 338bddd..9ac24cc 100644
--- a/fe-admin/src/App.tsx
+++ b/fe-admin/src/App.tsx
@@ -12,6 +12,7 @@ import { CatalogsPage } from '@/pages/master/CatalogsPage'
import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { RolesPage } from '@/pages/system/RolesPage'
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
+import { PeWorkflowsPage } from '@/pages/system/PeWorkflowsPage'
import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
@@ -47,6 +48,8 @@ function App() {
menu key)
+const PE_TYPE_CODE_TO_INT: Record = {
+ DuyetNcc: 1,
+ DuyetNccPhuongAn: 2,
+}
+
+export function PeWorkflowsPage() {
+ const qc = useQueryClient()
+ const { typeCode } = useParams<{ typeCode?: string }>()
+ const overview = useQuery({
+ queryKey: ['pe-workflow-overview'],
+ queryFn: async () => (await api.get<{ types: TypeSummaryDto[] }>('/pe-workflows')).data,
+ })
+
+ const selectedTypeInt = typeCode ? PE_TYPE_CODE_TO_INT[typeCode] : null
+ const currentType = selectedTypeInt
+ ? overview.data?.types.find(t => t.evaluationType === selectedTypeInt)
+ : null
+
+ return (
+
+
+
+ {currentType ? `Quy trình: ${currentType.evaluationTypeLabel}` : 'Quy trình duyệt NCC (PE)'}
+
+ }
+ description={
+ currentType
+ ? 'Tạo version mới → phiếu PE tương lai dùng. Phiếu đã tạo giữ version cũ (pinned lúc tạo).'
+ : 'Chọn loại Duyệt NCC từ menu bên trái để xem + chỉnh quy trình.'
+ }
+ />
+
+ {overview.isLoading && Đang tải…}
+
+ {overview.data && !currentType && (
+
+ {overview.data.types.map(t => (
+
+
+ {t.evaluationTypeLabel}
+ {t.active && (
+
+ {t.active.code} v{String(t.active.version).padStart(2, '0')}
+
+ )}
+
+
+ {t.active
+ ? `${t.active.steps.length} bước · ${t.history.length} version${t.history.length > 1 ? 's' : ''}`
+ : 'Chưa có quy trình'}
+
+
+ ))}
+
+ )}
+
+ {currentType && qc.invalidateQueries({ queryKey: ['pe-workflow-overview'] })} />}
+
+ )
+}
+
+// ===== Per-type panel =====
+
+function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
+ const [designerOpen, setDesignerOpen] = useState(false)
+ const [cloneFrom, setCloneFrom] = useState(null)
+
+ return (
+
+ {type.active ? (
+ { setCloneFrom(d); setDesignerOpen(true) }} />
+ ) : (
+
+ Chưa có quy trình cho loại này. Tạo version đầu tiên bên dưới.
+
+ )}
+
+
+ Lịch sử versions
+
+
+
+ {type.history.filter(d => !d.isActive).length === 0 && (
+
+ Chưa có version cũ. Khi tạo version mới, version hiện tại tự động archive.
+
+ )}
+
+
+ {type.history
+ .filter(d => !d.isActive)
+ .map(d => (
+ { setCloneFrom(dd); setDesignerOpen(true) }} />
+ ))}
+
+
+ {designerOpen && (
+ { setDesignerOpen(false); setCloneFrom(null) }}
+ onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }}
+ />
+ )}
+
+ )
+}
+
+// ===== Definition card (read-only view) =====
+
+function DefinitionCard({ def, isActive, onClone }: { def: DefinitionDto; isActive: boolean; onClone: (d: DefinitionDto) => void }) {
+ return (
+
+
+
+
+ {def.name}
+
+ {def.code} v{String(def.version).padStart(2, '0')}
+
+ {isActive ? (
+
+
+ Đang áp dụng
+
+ ) : (
+
+
+ Archived · {def.evaluationsUsingCount} phiếu còn chạy
+
+ )}
+
+ {def.description && {def.description}
}
+
+
+ {def.steps.map(s => (
+ -
+
+ {s.order}
+
+
+
+ {s.name}
+ ({s.phaseLabel})
+ {s.slaDays != null && (
+
+ SLA {s.slaDays}d
+
+ )}
+
+
+ {s.approvers.length === 0 && (
+ Chưa có người duyệt
+ )}
+ {s.approvers.map((a, i) => (
+
+ {a.kind === 1 ? 'Role' : 'User'}: {a.displayName ?? a.assignmentValue}
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+// ===== Designer dialog =====
+
+function PeWorkflowDesigner({
+ evaluationType,
+ evaluationTypeLabel,
+ cloneFrom,
+ onClose,
+ onSaved,
+}: {
+ evaluationType: number
+ evaluationTypeLabel: string
+ cloneFrom: DefinitionDto | null
+ onClose: () => void
+ onSaved: () => void
+}) {
+ const initialSteps: EditStep[] = useMemo(
+ () =>
+ cloneFrom
+ ? copyFromDefinition(cloneFrom)
+ : [{ phase: 1, name: 'Soạn thảo', slaDays: 3, approvers: [] }],
+ [cloneFrom],
+ )
+
+ const defaultCode = evaluationType === 1 ? 'QT-DN-A' : 'QT-DN-B'
+ const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
+ const [name, setName] = useState(cloneFrom ? `${cloneFrom.name} (clone)` : `Quy trình ${evaluationTypeLabel}`)
+ const [description, setDescription] = useState(cloneFrom?.description ?? '')
+ const [steps, setSteps] = useState(initialSteps)
+
+ const usersList = useQuery({
+ queryKey: ['users-for-approver'],
+ queryFn: async () =>
+ (await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
+ })
+
+ const save = useMutation({
+ mutationFn: async () => {
+ await api.post('/pe-workflows', {
+ evaluationType,
+ code,
+ name,
+ description: description || null,
+ steps: steps.map((s, i) => ({
+ order: i + 1,
+ phase: s.phase,
+ name: s.name,
+ slaDays: s.slaDays,
+ approvers: s.approvers,
+ })),
+ })
+ },
+ onSuccess: () => {
+ toast.success('Đã lưu quy trình mới. Version cũ đã archive.')
+ onSaved()
+ },
+ onError: err => toast.error(getErrorMessage(err)),
+ })
+
+ function submit(e: FormEvent) {
+ e.preventDefault()
+ if (steps.length === 0) {
+ toast.error('Phải có ít nhất 1 bước')
+ return
+ }
+ save.mutate()
+ }
+
+ return (
+
+ )
+}
diff --git a/fe-admin/src/types/purchaseEvaluation.ts b/fe-admin/src/types/purchaseEvaluation.ts
index 25a3c77..23e525e 100644
--- a/fe-admin/src/types/purchaseEvaluation.ts
+++ b/fe-admin/src/types/purchaseEvaluation.ts
@@ -175,6 +175,32 @@ export type BudgetSummary = {
tongNganSach: number
}
+// Mirror BE PeDepartmentKind enum
+export const PeDepartmentKind = {
+ PheDuyet: 1,
+ Ccm: 2,
+ MuaHang: 3,
+ SmPm: 4,
+} as const
+export type PeDepartmentKind = typeof PeDepartmentKind[keyof typeof PeDepartmentKind]
+
+export const PeDepartmentKindLabel: Record = {
+ 1: 'Phê duyệt',
+ 2: 'P.CCM',
+ 3: 'P.Mua hàng',
+ 4: 'SM-PM',
+}
+
+export type PeDepartmentOpinion = {
+ id: string
+ kind: number
+ kindLabel: string
+ opinion: string | null
+ signedAt: string | null
+ userId: string | null
+ userName: string | null
+}
+
export type PeDetailBundle = {
id: string
maPhieu: string | null
@@ -202,5 +228,6 @@ export type PeDetailBundle = {
details: PeDetailRow[]
approvals: PeApproval[]
attachments: PeAttachment[]
+ departmentOpinions: PeDepartmentOpinion[]
workflow: PeWorkflowSummary
}
diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx
index aac435d..ec81f72 100644
--- a/fe-user/src/components/pe/PeDetailTabs.tsx
+++ b/fe-user/src/components/pe/PeDetailTabs.tsx
@@ -18,12 +18,15 @@ import { cn } from '@/lib/cn'
import {
PeAttachmentPurpose,
PeAttachmentPurposeLabel,
+ PeDepartmentKind,
+ PeDepartmentKindLabel,
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeAttachment,
type PeChangelog,
+ type PeDepartmentOpinion,
type PeDetailBundle,
type PeDetailRow,
type PeQuote,
@@ -108,6 +111,9 @@ export function PeDetailTabs({
+
+
+
)
@@ -122,6 +128,131 @@ function Section({ title, children }: { title: string; children: React.ReactNode
)
}
+// ===== Section 5 — Ý kiến 4 phòng ban =====
+// Render 2x2 grid 4 box (Phê duyệt / CCM / MuaHàng / SM-PM). Mỗi box hiển
+// thị Opinion text + chữ ký (UserName + SignedAt) nếu đã ký, hoặc form nhập
+// + 2 button "Lưu" + "Lưu & Ký" khi chưa ký / readOnly=false.
+function DepartmentOpinionsSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
+ const KINDS: { kind: number; label: string }[] = [
+ { kind: PeDepartmentKind.PheDuyet, label: PeDepartmentKindLabel[PeDepartmentKind.PheDuyet] },
+ { kind: PeDepartmentKind.Ccm, label: PeDepartmentKindLabel[PeDepartmentKind.Ccm] },
+ { kind: PeDepartmentKind.MuaHang, label: PeDepartmentKindLabel[PeDepartmentKind.MuaHang] },
+ { kind: PeDepartmentKind.SmPm, label: PeDepartmentKindLabel[PeDepartmentKind.SmPm] },
+ ]
+ return (
+
+ {KINDS.map(k => {
+ const existing = ev.departmentOpinions.find(o => o.kind === k.kind) ?? null
+ return (
+
+ )
+ })}
+
+ )
+}
+
+function OpinionBox({
+ evaluationId,
+ kind,
+ kindLabel,
+ existing,
+ readOnly,
+}: {
+ evaluationId: string
+ kind: number
+ kindLabel: string
+ existing: PeDepartmentOpinion | null
+ readOnly: boolean
+}) {
+ const qc = useQueryClient()
+ const [text, setText] = useState(existing?.opinion ?? '')
+ const isSigned = !!existing?.signedAt
+
+ const save = useMutation({
+ mutationFn: async (sign: boolean) =>
+ api.post(`/purchase-evaluations/${evaluationId}/opinions`, {
+ kind,
+ opinion: text || null,
+ sign,
+ }),
+ onSuccess: () => {
+ toast.success('Đã lưu ý kiến.')
+ qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
+ },
+ onError: e => toast.error(getErrorMessage(e)),
+ })
+
+ return (
+
+
+ {kindLabel}
+ {isSigned && (
+
+ Đã ký
+
+ )}
+
+
+ {readOnly ? (
+ <>
+
+ {existing?.opinion ?? — chưa có ý kiến}
+
+ {isSigned && (
+
+ Ký bởi {existing?.userName ?? '—'} · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}
+
+ )}
+ >
+ ) : (
+ <>
+
+ )
+}
+
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts
index 25a3c77..23e525e 100644
--- a/fe-user/src/types/purchaseEvaluation.ts
+++ b/fe-user/src/types/purchaseEvaluation.ts
@@ -175,6 +175,32 @@ export type BudgetSummary = {
tongNganSach: number
}
+// Mirror BE PeDepartmentKind enum
+export const PeDepartmentKind = {
+ PheDuyet: 1,
+ Ccm: 2,
+ MuaHang: 3,
+ SmPm: 4,
+} as const
+export type PeDepartmentKind = typeof PeDepartmentKind[keyof typeof PeDepartmentKind]
+
+export const PeDepartmentKindLabel: Record = {
+ 1: 'Phê duyệt',
+ 2: 'P.CCM',
+ 3: 'P.Mua hàng',
+ 4: 'SM-PM',
+}
+
+export type PeDepartmentOpinion = {
+ id: string
+ kind: number
+ kindLabel: string
+ opinion: string | null
+ signedAt: string | null
+ userId: string | null
+ userName: string | null
+}
+
export type PeDetailBundle = {
id: string
maPhieu: string | null
@@ -202,5 +228,6 @@ export type PeDetailBundle = {
details: PeDetailRow[]
approvals: PeApproval[]
attachments: PeAttachment[]
+ departmentOpinions: PeDepartmentOpinion[]
workflow: PeWorkflowSummary
}
diff --git a/src/Backend/SolutionErp.Api/Controllers/PeWorkflowsController.cs b/src/Backend/SolutionErp.Api/Controllers/PeWorkflowsController.cs
new file mode 100644
index 0000000..d9c2750
--- /dev/null
+++ b/src/Backend/SolutionErp.Api/Controllers/PeWorkflowsController.cs
@@ -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> Overview(CancellationToken ct)
+ => Ok(await mediator.Send(new GetPeWorkflowAdminOverviewQuery(), ct));
+
+ [HttpPost]
+ [Authorize(Policy = "Workflows.Create")]
+ public async Task> Create([FromBody] CreatePeWorkflowDefinitionCommand cmd, CancellationToken ct)
+ {
+ var id = await mediator.Send(cmd, ct);
+ return Ok(new { id });
+ }
+}
diff --git a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs
index e7172b5..60992ef 100644
--- a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs
+++ b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs
@@ -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> 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 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,
diff --git a/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs b/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs
index 2dd1a79..a093bfc 100644
--- a/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs
+++ b/src/Backend/SolutionErp.Application/Common/Interfaces/IApplicationDbContext.cs
@@ -58,6 +58,7 @@ public interface IApplicationDbContext
DbSet PurchaseEvaluationWorkflowSteps { get; }
DbSet PurchaseEvaluationWorkflowStepApprovers { get; }
DbSet PurchaseEvaluationCodeSequences { get; }
+ DbSet PurchaseEvaluationDepartmentOpinions { get; }
// Module Ngân sách (Phase 7)
DbSet Budgets { get; }
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
index a52b92a..dbb4bcd 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
@@ -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 Details,
List Approvals,
List Attachments,
+ List DepartmentOpinions,
PurchaseEvaluationWorkflowSummaryDto Workflow);
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeDepartmentOpinionFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeDepartmentOpinionFeatures.cs
new file mode 100644
index 0000000..c1475e3
--- /dev/null
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeDepartmentOpinionFeatures.cs
@@ -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;
+// 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
+{
+ 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 userManager) : IRequestHandler
+{
+ public async Task 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
+{
+ 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);
+ }
+}
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs
new file mode 100644
index 0000000..866394a
--- /dev/null
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs
@@ -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 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 Steps);
+
+public record PeWorkflowTypeSummaryDto(
+ int EvaluationType,
+ string EvaluationTypeLabel,
+ PeWorkflowDefinitionDto? Active,
+ List History);
+
+public record PeWorkflowAdminOverviewDto(List Types);
+
+// ========== GET overview ==========
+
+public record GetPeWorkflowAdminOverviewQuery : IRequest;
+
+public class GetPeWorkflowAdminOverviewQueryHandler(
+ IApplicationDbContext db,
+ UserManager userManager) : IRequestHandler
+{
+ private static readonly Dictionary TypeLabels = new()
+ {
+ [PurchaseEvaluationType.DuyetNcc] = "Duyệt NCC",
+ [PurchaseEvaluationType.DuyetNccPhuongAn] = "Duyệt NCC + Giải pháp",
+ };
+
+ private static readonly Dictionary 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 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()
+ : 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()
+ .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 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 Approvers);
+
+public record CreatePeWorkflowDefinitionCommand(
+ PurchaseEvaluationType EvaluationType,
+ string Code,
+ string Name,
+ string? Description,
+ List Steps) : IRequest;
+
+public class CreatePeWorkflowDefinitionCommandValidator : AbstractValidator
+{
+ 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
+{
+ public async Task 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;
+ }
+}
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
index c4dd91e..eed9496 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
@@ -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 ==========
diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs
index 5f12979..cb48f73 100644
--- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs
+++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluation.cs
@@ -34,4 +34,5 @@ public class PurchaseEvaluation : AuditableEntity
public List Approvals { get; set; } = new();
public List Changelogs { get; set; } = new();
public List Attachments { get; set; } = new();
+ public List DepartmentOpinions { get; set; } = new();
}
diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationDepartmentOpinion.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationDepartmentOpinion.cs
new file mode 100644
index 0000000..c8a71d5
--- /dev/null
+++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationDepartmentOpinion.cs
@@ -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; }
+}
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs
index bff4df7..ca28025 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/ApplicationDbContext.cs
@@ -59,6 +59,7 @@ public class ApplicationDbContext
public DbSet PurchaseEvaluationWorkflowSteps => Set();
public DbSet PurchaseEvaluationWorkflowStepApprovers => Set();
public DbSet PurchaseEvaluationCodeSequences => Set();
+ public DbSet PurchaseEvaluationDepartmentOpinions => Set();
// Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs.
public DbSet Budgets => Set();
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
index 63cb303..78b4147 100644
--- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
@@ -32,6 +32,7 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration 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
+{
+ public void Configure(EntityTypeBuilder b)
+ {
+ b.ToTable("PurchaseEvaluationDepartmentOpinions");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Kind).HasConversion();
+ 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();
+ }
+}
diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260429041117_AddPurchaseEvaluationDepartmentOpinions.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260429041117_AddPurchaseEvaluationDepartmentOpinions.Designer.cs
new file mode 100644
index 0000000..7ca2d3a
--- /dev/null
+++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260429041117_AddPurchaseEvaluationDepartmentOpinions.Designer.cs
@@ -0,0 +1,3297 @@
+//
+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("20260429041117_AddPurchaseEvaluationDepartmentOpinions")]
+ partial class AddPurchaseEvaluationDepartmentOpinions
+ {
+ ///
+ 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.Budgets.Budget", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ 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("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("DrafterUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("MaNganSach")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("NamNganSach")
+ .HasColumnType("int");
+
+ b.Property("Phase")
+ .HasColumnType("int");
+
+ b.Property("ProjectId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("SlaDeadline")
+ .HasColumnType("datetime2");
+
+ b.Property("SlaWarningSent")
+ .HasColumnType("bit");
+
+ b.Property("TenNganSach")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("TongNganSach")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MaNganSach")
+ .IsUnique()
+ .HasFilter("[MaNganSach] IS NOT NULL");
+
+ b.HasIndex("NamNganSach");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("SlaDeadline");
+
+ b.HasIndex("Phase", "IsDeleted");
+
+ b.ToTable("Budgets", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ApprovedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ApproverUserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Comment")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ 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("BudgetId", "ApprovedAt");
+
+ b.ToTable("BudgetApprovals", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetChangelog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Action")
+ .HasColumnType("int");
+
+ b.Property("BudgetId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContextNote")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityType")
+ .HasColumnType("int");
+
+ b.Property("FieldChangesJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhaseAtChange")
+ .HasColumnType("int");
+
+ b.Property("Summary")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserName")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BudgetId", "CreatedAt");
+
+ b.HasIndex("BudgetId", "EntityType");
+
+ b.ToTable("BudgetChangelogs", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DonGia")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DonViTinh")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GhiChu")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("GroupCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GroupName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("ItemCode")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("KhoiLuong")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("NoiDung")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("ThanhTien")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BudgetId", "Order");
+
+ b.ToTable("BudgetDetails", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("BudgetId")
+ .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.Property("WorkflowDefinitionId")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BudgetId");
+
+ 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.ContractChangelog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Action")
+ .HasColumnType("int");
+
+ b.Property("ContextNote")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("EntityType")
+ .HasColumnType("int");
+
+ b.Property("FieldChangesJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhaseAtChange")
+ .HasColumnType("int");
+
+ b.Property("Summary")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("UserName")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "CreatedAt");
+
+ b.HasIndex("ContractId", "EntityType");
+
+ b.ToTable("ContractChangelogs", (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.Details.DichVuDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DenNgay")
+ .HasColumnType("datetime2");
+
+ b.Property("DonGia")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DonViTinh")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GhiChu")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("MaDichVu")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("MoTa")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("TenDichVu")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("ThanhTien")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("ThoiGian")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TuNgay")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContractId", "Order");
+
+ b.ToTable("DichVuDetails", (string)null);
+ });
+
+ modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ContractId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DonGia")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DonViTinh")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("GhiChu")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("KhoiLuong")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("MaCongViec")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("TenCongViec")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("ThanhTien")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("ThoiGianHoanThanh")
+ .HasColumnType("datetime2");
+
+ b.Property