Compare commits

..

3 Commits

Author SHA1 Message Date
6e913b37a1 [CLAUDE] FE-PE: Chunk C Section 5 V2 dynamic theo ApprovalWorkflowLevel
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 14m51s
Section 5 PeDetailTabs render dynamic theo workflow đã pin (V2). Thay
4 box CỨNG (PheDuyet/CCM/MuaHàng/SmPm Mig 15) cho phiếu V2.

Type: `PeLevelOpinion` (15 field) + `PeDetailBundle.levelOpinions[]`.

Section 5 conditional:
- evaluation.approvalWorkflowId set → <LevelOpinionsSectionV2/>
- V1 legacy (no awId) → <DepartmentOpinionsSection/> readOnly fallback (giữ data Mig 15)

LevelOpinionsSectionV2:
- Layout 5A — group theo Step (header "Bước N — <name>" + dept badge emerald)
- grid-cols-2 cho approvers trong tất cả Levels của Step
- Hint "(N người duyệt)" khi totalApprovers > 1
- Empty state khi flow null / 0 steps

LevelOpinionBox (read-only — Q1=1B sync auto từ Workflow Panel):
- Title "Cấp N — <ApproverFullName>"
- Badge amber "⚠ Admin <name> duyệt thay" khi SignedByUserId !== ApproverUserId
- Badge emerald "✓ Đã duyệt" khi opinion tồn tại
- Empty: "— chưa duyệt" italic gray
- Footer: timestamp signedAt format vi-VN

Workspace mode hint giữ amber "Ý kiến + chữ ký auto đồng bộ khi NV duyệt".

Mirror fe-admin + fe-user (rule §3.9).

Verify: npm run build × 2 pass · 0 TS error.

Chunk D kế tiếp: Docs (STATUS/HANDOFF/schema-diagram/session log).
2026-05-09 11:05:03 +07:00
90baa8e73c [CLAUDE] PurchaseEvaluation: Chunk B Service V2 hook UPSERT opinion + DTO + GET include
Service `ApproveV2Async` sau khi log approval (Decision=Approve) → UPSERT
row `PurchaseEvaluationLevelOpinions` cho Cấp hiện tại (auto sync ý kiến
từ comment khi duyệt). Reject KHÔNG sync.

Match level theo ApproverUserId của actor (multi-NV cùng Cấp OR-of-N).
Admin override (actor.Id KHÔNG match) → fallback first level — FE detect
SignedByUserId !== Level.ApproverUserId hiển thị "Admin duyệt thay".

Empty/whitespace comment → "(duyệt — không ý kiến)" placeholder (Q4 bonus).

Helper `ResolveActorFullNameAsync(actorUserId, isSystem, ct)` lookup
denorm SignedByFullName từ Users (fallback "(System)" / "(unknown)").

DTO `PurchaseEvaluationLevelOpinionDto` (15 fields):
- StepOrder/StepName/StepDepartmentId/StepDepartmentName (Bước Phòng)
- LevelOrder/LevelName/ApproverUserId/ApproverFullName (Cấp NV)
- Comment/SignedAt/SignedByUserId/SignedByFullName (sign-off)

GetPurchaseEvaluationQueryHandler:
- Include LevelOpinions
- helper BuildLevelOpinionsAsync JOIN ApprovalWorkflows.Steps.Levels +
  Departments + Users → denorm DTO. Empty list cho phiếu V1 / V2 chưa
  có cấp nào duyệt → FE fallback message.

Verify: dotnet build pass + dotnet test 81 pass (no regression).

Chunk C kế tiếp: FE Section 5 dynamic mirror 2 app.
2026-05-09 11:00:01 +07:00
77a30584fc [CLAUDE] PurchaseEvaluation: Mig 26 PeLevelOpinions V2 dynamic — Chunk A Domain + EF
Schema mới cho Section 5 "Ý kiến cấp duyệt" V2 dynamic theo
ApprovalWorkflowsV2 (Mig 22-25). Thay thế Mig 15 cố định 4 box (V1).

Entity `PurchaseEvaluationLevelOpinion : AuditableEntity`:
- (PEId, ApprovalWorkflowLevelId) UNIQUE composite
- Comment nvarchar(2000) — text ý kiến hoặc "(duyệt — không ý kiến)" placeholder (Q4 bonus)
- SignedAt datetime2 (luôn có khi UPSERT từ ApproveV2Async)
- SignedByUserId Guid (NV chính chủ HOẶC Admin override)
- SignedByFullName nvarchar(200) — denorm tránh user bị xóa/đổi tên

EF: FK Cascade Pe + Restrict Level. SignedByUserId KHÔNG nav (denorm only).
Migration 26 `AddPeLevelOpinionsForV2`: 1 CREATE TABLE + 2 FK + 2 index.
3-file rule commit đủ (.cs + Designer + Snapshot).

Apply LocalDB SolutionErp_Dev OK (Mig 25 + 26 catchup).

Verify: dotnet build pass + dotnet test 81 pass (no regression).

Chunk B kế tiếp: Service V2 hook UPSERT auto trong ApproveV2Async.
2026-05-09 10:56:16 +07:00
15 changed files with 4552 additions and 6 deletions

View File

@ -33,6 +33,7 @@ import {
type PeDepartmentOpinion,
type PeDetailBundle,
type PeDetailRow,
type PeLevelOpinion,
type PeQuote,
type PeSupplier,
} from '@/types/purchaseEvaluation'
@ -173,13 +174,17 @@ export function PeDetailTabs({
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
<ItemsTab ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
<Section title="5. Ý kiến cấp duyệt (sign-off theo workflow)">
{mode === 'workspace' && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
Ý kiến + chữ nhập khi duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
Ý kiến + chữ auto đng bộ khi NV duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
</div>
)}
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
{/* Mig 26 — V2 dynamic theo ApprovalWorkflowLevel. V1 phiếu cũ
fallback render 4 box CỨNG readOnly (data legacy giữ Mig 15). */}
{evaluation.approvalWorkflowId
? <LevelOpinionsSectionV2 ev={evaluation} />
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
</Section>
</div>
@ -377,6 +382,123 @@ function OpinionBox({
)
}
// ===== Section 5 V2 — Ý kiến cấp duyệt dynamic (Mig 26 — Session 19) =====
//
// Render theo workflow đã pin: forEach Step → forEach Level (Cấp) → forEach
// approver (NV). Mỗi NV = 1 OpinionBox (read-only). Service ApproveV2Async
// auto sync comment khi duyệt (Q1=1B). Empty list → fallback message.
//
// Layout 5A: header "Bước N — Phòng X" badge + grid-cols-2 cho N approvers
// (wrap nếu N>2). Admin override badge khi SignedByUserId !== ApproverUserId.
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow
const opinions = ev.levelOpinions
if (!flow || flow.steps.length === 0) {
return (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500">
Workflow chưa đưc cấu hình hoặc chưa cấp duyệt nào.
</div>
)
}
return (
<div className="space-y-4">
{flow.steps.map(step => {
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
return (
<div key={step.order} className="rounded-lg border border-slate-200 bg-slate-50/50 p-3">
<div className="mb-2.5 flex flex-wrap items-center gap-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {step.order} {step.name}
</span>
{step.departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName}
</span>
)}
{totalApprovers > 1 && (
<span className="text-[10px] text-slate-400">
({totalApprovers} người duyệt)
</span>
)}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{step.levels.flatMap(level =>
level.approvers.map(approver => {
const opinion = opinions.find(o =>
o.stepOrder === step.order
&& o.levelOrder === level.order
&& o.approverUserId === approver.userId,
) ?? null
return (
<LevelOpinionBox
key={`${step.order}-${level.order}-${approver.userId}`}
levelOrder={level.order}
approverUserId={approver.userId}
approverName={approver.fullName}
opinion={opinion}
/>
)
}),
)}
</div>
</div>
)
})}
</div>
)
}
function LevelOpinionBox({
levelOrder,
approverUserId,
approverName,
opinion,
}: {
levelOrder: number
approverUserId: string
approverName: string
opinion: PeLevelOpinion | null
}) {
const isSigned = !!opinion
const isAdminOverride = isSigned && opinion!.signedByUserId !== approverUserId
return (
<div className={cn(
'rounded-lg border bg-white p-3',
isSigned ? 'border-emerald-200' : 'border-slate-200',
)}>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-slate-700">
Cấp {levelOrder} <span className="text-slate-900">{approverName}</span>
</h4>
{isAdminOverride && (
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
Admin <strong>{opinion!.signedByFullName}</strong> duyệt thay
</div>
)}
</div>
{isSigned && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã duyệt
</span>
)}
</div>
<div className="min-h-[40px] whitespace-pre-wrap text-sm text-slate-800">
{opinion?.comment ?? <span className="italic text-slate-400"> chưa duyệt</span>}
</div>
{isSigned && (
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
{new Date(opinion!.signedAt).toLocaleString('vi-VN')}
</div>
)}
</div>
)
}
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {

View File

@ -298,6 +298,27 @@ export type PeDepartmentOpinion = {
userName: string | null
}
// Mig 26 (Session 19) — Section 5 V2 dynamic theo ApprovalWorkflowLevel.
// Service ApproveV2Async UPSERT auto khi NV duyệt (Q1=1B). Empty list cho
// phiếu V1 / V2 chưa có cấp nào duyệt → FE fallback message.
// `signedByUserId !== approverUserId` → FE banner "Admin duyệt thay".
export type PeLevelOpinion = {
id: string
approvalWorkflowLevelId: string
stepOrder: number
stepName: string
stepDepartmentId: string | null
stepDepartmentName: string | null
levelOrder: number
levelName: string | null
approverUserId: string
approverFullName: string | null
comment: string
signedAt: string
signedByUserId: string
signedByFullName: string
}
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
// BLOCK transition khi NV review chưa có TPB confirm cùng (PE, Phase, Dept).
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
@ -366,5 +387,7 @@ export type PeDetailBundle = {
approvals: PeApproval[]
attachments: PeAttachment[]
departmentOpinions: PeDepartmentOpinion[]
// Mig 26 — Section 5 V2 dynamic. Empty cho V1 / V2 chưa có cấp duyệt.
levelOpinions: PeLevelOpinion[]
workflow: PeWorkflowSummary
}

View File

@ -33,6 +33,7 @@ import {
type PeDepartmentOpinion,
type PeDetailBundle,
type PeDetailRow,
type PeLevelOpinion,
type PeQuote,
type PeSupplier,
} from '@/types/purchaseEvaluation'
@ -173,13 +174,17 @@ export function PeDetailTabs({
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
<ItemsTab ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
<Section title="5. Ý kiến cấp duyệt (sign-off theo workflow)">
{mode === 'workspace' && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
Ý kiến + chữ nhập khi duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
Ý kiến + chữ auto đng bộ khi NV duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
</div>
)}
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
{/* Mig 26 — V2 dynamic theo ApprovalWorkflowLevel. V1 phiếu cũ
fallback render 4 box CỨNG readOnly (data legacy giữ Mig 15). */}
{evaluation.approvalWorkflowId
? <LevelOpinionsSectionV2 ev={evaluation} />
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
</Section>
</div>
@ -377,6 +382,123 @@ function OpinionBox({
)
}
// ===== Section 5 V2 — Ý kiến cấp duyệt dynamic (Mig 26 — Session 19) =====
//
// Render theo workflow đã pin: forEach Step → forEach Level (Cấp) → forEach
// approver (NV). Mỗi NV = 1 OpinionBox (read-only). Service ApproveV2Async
// auto sync comment khi duyệt (Q1=1B). Empty list → fallback message.
//
// Layout 5A: header "Bước N — Phòng X" badge + grid-cols-2 cho N approvers
// (wrap nếu N>2). Admin override badge khi SignedByUserId !== ApproverUserId.
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow
const opinions = ev.levelOpinions
if (!flow || flow.steps.length === 0) {
return (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500">
Workflow chưa đưc cấu hình hoặc chưa cấp duyệt nào.
</div>
)
}
return (
<div className="space-y-4">
{flow.steps.map(step => {
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
return (
<div key={step.order} className="rounded-lg border border-slate-200 bg-slate-50/50 p-3">
<div className="mb-2.5 flex flex-wrap items-center gap-2">
<span className="text-[12px] font-bold uppercase tracking-wide text-slate-700">
Bước {step.order} {step.name}
</span>
{step.departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName}
</span>
)}
{totalApprovers > 1 && (
<span className="text-[10px] text-slate-400">
({totalApprovers} người duyệt)
</span>
)}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{step.levels.flatMap(level =>
level.approvers.map(approver => {
const opinion = opinions.find(o =>
o.stepOrder === step.order
&& o.levelOrder === level.order
&& o.approverUserId === approver.userId,
) ?? null
return (
<LevelOpinionBox
key={`${step.order}-${level.order}-${approver.userId}`}
levelOrder={level.order}
approverUserId={approver.userId}
approverName={approver.fullName}
opinion={opinion}
/>
)
}),
)}
</div>
</div>
)
})}
</div>
)
}
function LevelOpinionBox({
levelOrder,
approverUserId,
approverName,
opinion,
}: {
levelOrder: number
approverUserId: string
approverName: string
opinion: PeLevelOpinion | null
}) {
const isSigned = !!opinion
const isAdminOverride = isSigned && opinion!.signedByUserId !== approverUserId
return (
<div className={cn(
'rounded-lg border bg-white p-3',
isSigned ? 'border-emerald-200' : 'border-slate-200',
)}>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-slate-700">
Cấp {levelOrder} <span className="text-slate-900">{approverName}</span>
</h4>
{isAdminOverride && (
<div className="mt-1 inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
Admin <strong>{opinion!.signedByFullName}</strong> duyệt thay
</div>
)}
</div>
{isSigned && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã duyệt
</span>
)}
</div>
<div className="min-h-[40px] whitespace-pre-wrap text-sm text-slate-800">
{opinion?.comment ?? <span className="italic text-slate-400"> chưa duyệt</span>}
</div>
{isSigned && (
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
{new Date(opinion!.signedAt).toLocaleString('vi-VN')}
</div>
)}
</div>
)
}
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {

View File

@ -295,6 +295,27 @@ export type PeDepartmentOpinion = {
userName: string | null
}
// Mig 26 (Session 19) — Section 5 V2 dynamic theo ApprovalWorkflowLevel.
// Service ApproveV2Async UPSERT auto khi NV duyệt (Q1=1B). Empty list cho
// phiếu V1 / V2 chưa có cấp nào duyệt → FE fallback message.
// `signedByUserId !== approverUserId` → FE banner "Admin duyệt thay".
export type PeLevelOpinion = {
id: string
approvalWorkflowLevelId: string
stepOrder: number
stepName: string
stepDepartmentId: string | null
stepDepartmentName: string | null
levelOrder: number
levelName: string | null
approverUserId: string
approverFullName: string | null
comment: string
signedAt: string
signedByUserId: string
signedByFullName: string
}
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
// BLOCK transition khi NV review chưa có TPB confirm cùng (PE, Phase, Dept).
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
@ -363,5 +384,7 @@ export type PeDetailBundle = {
approvals: PeApproval[]
attachments: PeAttachment[]
departmentOpinions: PeDepartmentOpinion[]
// Mig 26 — Section 5 V2 dynamic. Empty cho V1 / V2 chưa có cấp duyệt.
levelOpinions: PeLevelOpinion[]
workflow: PeWorkflowSummary
}

View File

@ -64,6 +64,8 @@ public interface IApplicationDbContext
DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; }
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; }
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic theo ApprovalWorkflowLevel
DbSet<PurchaseEvaluationLevelOpinion> PurchaseEvaluationLevelOpinions { get; }
// Quy trình duyệt MỚI (Mig 22 — Session 17): schema riêng UAT trước khi
// drop legacy WorkflowDefinition. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).

View File

@ -142,6 +142,27 @@ public record PurchaseEvaluationDepartmentOpinionDto(
Guid? UserId,
string? UserName);
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic theo ApprovalWorkflowLevel.
// FE Section 5 render dynamic: forEach Step → forEach Level → 1 OpinionBox.
// Service ApproveV2Async UPSERT tự động khi NV duyệt (Q1=1B). Comment empty
// fallback "(duyệt — không ý kiến)". `SignedByUserId !== ApproverUserId` →
// FE show banner "Admin duyệt thay <ApproverFullName>".
public record PurchaseEvaluationLevelOpinionDto(
Guid Id,
Guid ApprovalWorkflowLevelId,
int StepOrder,
string StepName,
Guid? StepDepartmentId,
string? StepDepartmentName,
int LevelOrder,
string? LevelName,
Guid ApproverUserId,
string? ApproverFullName,
string Comment,
DateTime SignedAt,
Guid SignedByUserId,
string SignedByFullName);
public record PurchaseEvaluationDetailBundleDto(
Guid Id,
string? MaPhieu,
@ -180,4 +201,7 @@ public record PurchaseEvaluationDetailBundleDto(
List<PurchaseEvaluationApprovalDto> Approvals,
List<PurchaseEvaluationAttachmentDto> Attachments,
List<PurchaseEvaluationDepartmentOpinionDto> DepartmentOpinions,
// Mig 26 (Session 19) — Section 5 V2 dynamic. Empty list cho phiếu V1
// legacy hoặc phiếu V2 chưa có cấp nào duyệt → FE fallback message.
List<PurchaseEvaluationLevelOpinionDto> LevelOpinions,
PurchaseEvaluationWorkflowSummaryDto Workflow);

View File

@ -447,6 +447,7 @@ public class GetPurchaseEvaluationQueryHandler(
.Include(x => x.Approvals)
.Include(x => x.Attachments)
.Include(x => x.DepartmentOpinions)
.Include(x => x.LevelOpinions) // Mig 26 — Section 5 V2 dynamic
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
@ -687,12 +688,73 @@ public class GetPurchaseEvaluationQueryHandler(
o.Id, o.Kind, KindLabel(o.Kind),
o.Opinion, o.SignedAt, o.UserId, o.UserName))
.ToList(),
await BuildLevelOpinionsAsync(e, ct),
new PurchaseEvaluationWorkflowSummaryDto(
policy.Name, policy.Description,
policy.ActivePhases.ToList(),
policy.NextPhasesFrom(e.Phase).ToList()));
}
// Mig 26 (Session 19) — Build LevelOpinionDto[] cho Section 5 dynamic.
// Phiếu V1 (no ApprovalWorkflowId) hoặc chưa có cấp duyệt nào → empty list,
// FE hiển thị fallback message. JOIN Steps/Levels lấy meta (StepOrder, name,
// DepartmentName, ApproverFullName) — denorm vào DTO để FE render trực tiếp.
private async Task<List<PurchaseEvaluationLevelOpinionDto>> BuildLevelOpinionsAsync(
PurchaseEvaluation e, CancellationToken ct)
{
var result = new List<PurchaseEvaluationLevelOpinionDto>();
if (e.LevelOpinions.Count == 0 || e.ApprovalWorkflowId is not Guid wfV2Id)
return result;
var aw = await db.ApprovalWorkflows.AsNoTracking()
.Include(w => w.Steps).ThenInclude(s => s.Levels)
.FirstOrDefaultAsync(w => w.Id == wfV2Id, ct);
if (aw is null) return result;
var levelMap = aw.Steps
.SelectMany(s => s.Levels.Select(l => new { Step = s, Level = l }))
.ToDictionary(x => x.Level.Id);
var deptIds = aw.Steps.Where(s => s.DepartmentId.HasValue)
.Select(s => s.DepartmentId!.Value).Distinct().ToList();
var depts = await db.Departments.AsNoTracking()
.Where(d => deptIds.Contains(d.Id))
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
var userIds = new HashSet<Guid>(
aw.Steps.SelectMany(s => s.Levels.Select(l => l.ApproverUserId)));
// SignedByUserId có thể KHÁC ApproverUserId (Admin override) — load thêm.
foreach (var op in e.LevelOpinions) userIds.Add(op.SignedByUserId);
var users = await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
foreach (var op in e.LevelOpinions.OrderBy(o => o.SignedAt))
{
if (!levelMap.TryGetValue(op.ApprovalWorkflowLevelId, out var meta)) continue;
string? deptName = meta.Step.DepartmentId.HasValue
&& depts.TryGetValue(meta.Step.DepartmentId.Value, out var dn)
? dn : null;
string? approverFullName = users.TryGetValue(meta.Level.ApproverUserId, out var an) ? an : null;
result.Add(new PurchaseEvaluationLevelOpinionDto(
op.Id,
op.ApprovalWorkflowLevelId,
meta.Step.Order,
meta.Step.Name,
meta.Step.DepartmentId,
deptName,
meta.Level.Order,
meta.Level.Name,
meta.Level.ApproverUserId,
approverFullName,
op.Comment ?? "",
op.SignedAt,
op.SignedByUserId,
op.SignedByFullName));
}
return result;
}
private static string KindLabel(PeDepartmentKind k) => k switch
{
PeDepartmentKind.PheDuyet => "Phê duyệt",

View File

@ -62,4 +62,9 @@ public class PurchaseEvaluation : AuditableEntity
public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new();
public List<PurchaseEvaluationDepartmentOpinion> DepartmentOpinions { get; set; } = new();
public List<PurchaseEvaluationDepartmentApproval> DepartmentApprovals { get; set; } = new();
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic. UPSERT auto từ
// ApproveV2Async theo Cấp hiện tại. Section 5 FE render dynamic theo
// flow.steps[].levels[]. Phiếu V1 (WorkflowDefinitionId) KHÔNG dùng.
public List<PurchaseEvaluationLevelOpinion> LevelOpinions { get; set; } = new();
}

View File

@ -0,0 +1,36 @@
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.PurchaseEvaluations;
// "Ý kiến cấp duyệt" V2 — sign-off DYNAMIC theo workflow ApprovalWorkflowV2
// (Mig 22-25). Thay thế `PurchaseEvaluationDepartmentOpinion` (Mig 15) cố
// định 4 box (PheDuyet/Ccm/MuaHang/SmPm) cho phiếu V1.
//
// Mỗi row = 1 (PE × ApprovalWorkflowLevel). Service `ApproveV2Async` sau khi
// approve thành công Cấp hiện tại sẽ UPSERT row này:
// Comment = approval.Comment ?? "(duyệt — không ý kiến)" (Q4 bonus)
// SignedAt = clock.UtcNow
// SignedByUserId = actor.Id (NV chính chủ HOẶC Admin override)
// SignedByFullName = actor.FullName (denorm — tránh user bị xóa/đổi tên)
//
// Reject (Trả lại / Từ chối) KHÔNG sync (vì không phải sign-off của level đó).
// Khi user resubmit từ TraLai → workflow chạy lại từ Cấp 1, opinion cũ bị
// OVERWRITE bằng UPSERT mới (latest-write-wins).
//
// Section 5 FE detect V2 qua `pe.approvalWorkflowId != null` → render dynamic
// theo flow.steps[].levels[]. Phiếu V1 (WorkflowDefinitionId set) → fallback
// message "phiếu cũ không có ý kiến dynamic" (Q3 chốt: chuyển V2 hết).
public class PurchaseEvaluationLevelOpinion : AuditableEntity
{
public Guid PurchaseEvaluationId { get; set; }
public Guid ApprovalWorkflowLevelId { get; set; }
public string? Comment { get; set; } // ý kiến (max 2000) hoặc placeholder "(duyệt — không ý kiến)"
public DateTime SignedAt { get; set; } // luôn có khi UPSERT (Service set khi Approve)
public Guid SignedByUserId { get; set; } // người ký thực sự (có thể là Admin thay NV)
public string SignedByFullName { get; set; } = string.Empty; // snapshot tên — denorm
public PurchaseEvaluation? PurchaseEvaluation { get; set; }
public ApprovalWorkflowLevel? Level { get; set; }
}

View File

@ -63,6 +63,8 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic
public DbSet<PurchaseEvaluationLevelOpinion> PurchaseEvaluationLevelOpinions => Set<PurchaseEvaluationLevelOpinion>();
// Quy trình duyệt mới (Mig 22 — Session 17): schema riêng UAT.
public DbSet<ApprovalWorkflow> ApprovalWorkflows => Set<ApprovalWorkflow>();

View File

@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 26 — UPSERT auto sync từ ApproveV2Async. UNIQUE (PEId, LevelId)
// đảm bảo 1 row/level/phiếu. FK Cascade Pe (xoá phiếu → xoá opinions),
// FK Restrict Level (admin xoá Level chặn nếu opinion tồn tại — bảo vệ data).
// SignedByUserId KHÔNG nav (tránh cascade khi xoá user; denorm SignedByFullName).
public class PurchaseEvaluationLevelOpinionConfiguration : IEntityTypeConfiguration<PurchaseEvaluationLevelOpinion>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationLevelOpinion> e)
{
e.ToTable("PurchaseEvaluationLevelOpinions");
e.Property(x => x.Comment).HasMaxLength(2000);
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
e.HasOne(x => x.PurchaseEvaluation)
.WithMany(p => p.LevelOpinions)
.HasForeignKey(x => x.PurchaseEvaluationId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Level)
.WithMany()
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.PurchaseEvaluationId, x.ApprovalWorkflowLevelId }).IsUnique();
e.HasIndex(x => x.ApprovalWorkflowLevelId);
}
}

View File

@ -0,0 +1,69 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeLevelOpinionsForV2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PurchaseEvaluationLevelOpinions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PurchaseEvaluationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApprovalWorkflowLevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Comment = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
SignedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
SignedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SignedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationLevelOpinions", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseEvaluationLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
column: x => x.ApprovalWorkflowLevelId,
principalTable: "ApprovalWorkflowLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PurchaseEvaluationLevelOpinions_PurchaseEvaluations_PurchaseEvaluationId",
column: x => x.PurchaseEvaluationId,
principalTable: "PurchaseEvaluations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationLevelOpinions_ApprovalWorkflowLevelId",
table: "PurchaseEvaluationLevelOpinions",
column: "ApprovalWorkflowLevelId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluationLevelOpinions_PurchaseEvaluationId_ApprovalWorkflowLevelId",
table: "PurchaseEvaluationLevelOpinions",
columns: new[] { "PurchaseEvaluationId", "ApprovalWorkflowLevelId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PurchaseEvaluationLevelOpinions");
}
}
}

View File

@ -2990,6 +2990,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("PurchaseEvaluationDetails", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationLevelOpinion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApprovalWorkflowLevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid>("PurchaseEvaluationId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("SignedAt")
.HasColumnType("datetime2");
b.Property<string>("SignedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("SignedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ApprovalWorkflowLevelId");
b.HasIndex("PurchaseEvaluationId", "ApprovalWorkflowLevelId")
.IsUnique();
b.ToTable("PurchaseEvaluationLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b =>
{
b.Property<Guid>("Id")
@ -3647,6 +3705,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationLevelOpinion", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
.WithMany()
.HasForeignKey("ApprovalWorkflowLevelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation")
.WithMany("LevelOpinions")
.HasForeignKey("PurchaseEvaluationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
b.Navigation("PurchaseEvaluation");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b =>
{
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail")
@ -3787,6 +3864,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Details");
b.Navigation("LevelOpinions");
b.Navigation("Quotes");
b.Navigation("Suppliers");

View File

@ -187,6 +187,42 @@ public class PurchaseEvaluationWorkflowService(
ApprovedAt = dateTime.UtcNow,
});
// Mig 26 (Session 19) — UPSERT opinion vào row Level chính chủ. Section 5
// FE render dynamic theo flow.steps[].levels[]. Q1=1B chốt: comment khi
// duyệt auto sync sang Section 5 (read-only summary). Empty comment →
// "(duyệt — không ý kiến)" placeholder Q4 bonus.
// Multi-NV cùng Cấp (OR-of-N): match level theo ApproverUserId. Admin
// override → fallback first level group; FE detect SignedByUserId !==
// Level.ApproverUserId → banner "Admin duyệt thay".
var matchingLevel = pendingLevelGroup.FirstOrDefault(l => actorUserId.HasValue && l.ApproverUserId == actorUserId.Value)
?? pendingLevelGroup.First();
var actorFullName = await ResolveActorFullNameAsync(actorUserId, isSystem, ct);
var existingOpinion = await db.PurchaseEvaluationLevelOpinions
.FirstOrDefaultAsync(o => o.PurchaseEvaluationId == evaluation.Id
&& o.ApprovalWorkflowLevelId == matchingLevel.Id, ct);
var normalizedComment = string.IsNullOrWhiteSpace(comment)
? "(duyệt — không ý kiến)"
: comment.Trim();
if (existingOpinion is null)
{
db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = evaluation.Id,
ApprovalWorkflowLevelId = matchingLevel.Id,
Comment = normalizedComment,
SignedAt = dateTime.UtcNow,
SignedByUserId = actorUserId ?? Guid.Empty,
SignedByFullName = actorFullName,
});
}
else
{
existingOpinion.Comment = normalizedComment;
existingOpinion.SignedAt = dateTime.UtcNow;
existingOpinion.SignedByUserId = actorUserId ?? Guid.Empty;
existingOpinion.SignedByFullName = actorFullName;
}
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
if (currentLevelOrder < maxLevelOrder)
{
@ -353,4 +389,18 @@ public class PurchaseEvaluationWorkflowService(
ct: ct);
}
}
// Mig 26 (Session 19) — helper resolve FullName cho denorm `SignedByFullName`.
// System auto-approve (actorUserId null + isSystem) → "(System)". User không
// tồn tại / xóa → fallback UserName / "(unknown)".
private async Task<string> ResolveActorFullNameAsync(Guid? actorUserId, bool isSystem, CancellationToken ct)
{
if (isSystem || actorUserId is null) return "(System)";
var user = await db.Users.AsNoTracking()
.Where(u => u.Id == actorUserId.Value)
.Select(u => new { u.FullName, u.UserName })
.FirstOrDefaultAsync(ct);
if (user is null) return "(unknown)";
return !string.IsNullOrWhiteSpace(user.FullName) ? user.FullName : (user.UserName ?? "(unknown)");
}
}