Compare commits
3 Commits
873e7a1b7b
...
6e913b37a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e913b37a1 | |||
| 90baa8e73c | |||
| 77a30584fc |
@ -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ữ ký nhập khi duyệt phiếu — vào menu “Duyệt” để ký.
|
||||
Ý kiến + chữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
|
||||
</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ó 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 }) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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ữ ký nhập khi duyệt phiếu — vào menu “Duyệt” để ký.
|
||||
Ý kiến + chữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
|
||||
</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ó 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 }) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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ể).
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user