Compare commits
3 Commits
873e7a1b7b
...
6e913b37a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e913b37a1 | |||
| 90baa8e73c | |||
| 77a30584fc |
@ -33,6 +33,7 @@ import {
|
|||||||
type PeDepartmentOpinion,
|
type PeDepartmentOpinion,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
type PeDetailRow,
|
type PeDetailRow,
|
||||||
|
type PeLevelOpinion,
|
||||||
type PeQuote,
|
type PeQuote,
|
||||||
type PeSupplier,
|
type PeSupplier,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
@ -173,13 +174,17 @@ export function PeDetailTabs({
|
|||||||
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
||||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||||
</Section>
|
</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' && (
|
{mode === 'workspace' && (
|
||||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
|
<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>
|
</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>
|
</Section>
|
||||||
</div>
|
</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 =====
|
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||||||
|
|
||||||
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
||||||
|
|||||||
@ -298,6 +298,27 @@ export type PeDepartmentOpinion = {
|
|||||||
userName: string | null
|
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.
|
// 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).
|
// 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).
|
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||||
@ -366,5 +387,7 @@ export type PeDetailBundle = {
|
|||||||
approvals: PeApproval[]
|
approvals: PeApproval[]
|
||||||
attachments: PeAttachment[]
|
attachments: PeAttachment[]
|
||||||
departmentOpinions: PeDepartmentOpinion[]
|
departmentOpinions: PeDepartmentOpinion[]
|
||||||
|
// Mig 26 — Section 5 V2 dynamic. Empty cho V1 / V2 chưa có cấp duyệt.
|
||||||
|
levelOpinions: PeLevelOpinion[]
|
||||||
workflow: PeWorkflowSummary
|
workflow: PeWorkflowSummary
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import {
|
|||||||
type PeDepartmentOpinion,
|
type PeDepartmentOpinion,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
type PeDetailRow,
|
type PeDetailRow,
|
||||||
|
type PeLevelOpinion,
|
||||||
type PeQuote,
|
type PeQuote,
|
||||||
type PeSupplier,
|
type PeSupplier,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
@ -173,13 +174,17 @@ export function PeDetailTabs({
|
|||||||
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
||||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||||
</Section>
|
</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' && (
|
{mode === 'workspace' && (
|
||||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
|
<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>
|
</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>
|
</Section>
|
||||||
</div>
|
</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 =====
|
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||||||
|
|
||||||
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
||||||
|
|||||||
@ -295,6 +295,27 @@ export type PeDepartmentOpinion = {
|
|||||||
userName: string | null
|
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.
|
// 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).
|
// 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).
|
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||||
@ -363,5 +384,7 @@ export type PeDetailBundle = {
|
|||||||
approvals: PeApproval[]
|
approvals: PeApproval[]
|
||||||
attachments: PeAttachment[]
|
attachments: PeAttachment[]
|
||||||
departmentOpinions: PeDepartmentOpinion[]
|
departmentOpinions: PeDepartmentOpinion[]
|
||||||
|
// Mig 26 — Section 5 V2 dynamic. Empty cho V1 / V2 chưa có cấp duyệt.
|
||||||
|
levelOpinions: PeLevelOpinion[]
|
||||||
workflow: PeWorkflowSummary
|
workflow: PeWorkflowSummary
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,8 @@ public interface IApplicationDbContext
|
|||||||
DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; }
|
DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; }
|
||||||
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
|
DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions { get; }
|
||||||
DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { 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
|
// 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ể).
|
// 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,
|
Guid? UserId,
|
||||||
string? UserName);
|
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(
|
public record PurchaseEvaluationDetailBundleDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string? MaPhieu,
|
string? MaPhieu,
|
||||||
@ -180,4 +201,7 @@ public record PurchaseEvaluationDetailBundleDto(
|
|||||||
List<PurchaseEvaluationApprovalDto> Approvals,
|
List<PurchaseEvaluationApprovalDto> Approvals,
|
||||||
List<PurchaseEvaluationAttachmentDto> Attachments,
|
List<PurchaseEvaluationAttachmentDto> Attachments,
|
||||||
List<PurchaseEvaluationDepartmentOpinionDto> DepartmentOpinions,
|
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);
|
PurchaseEvaluationWorkflowSummaryDto Workflow);
|
||||||
|
|||||||
@ -447,6 +447,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
.Include(x => x.Approvals)
|
.Include(x => x.Approvals)
|
||||||
.Include(x => x.Attachments)
|
.Include(x => x.Attachments)
|
||||||
.Include(x => x.DepartmentOpinions)
|
.Include(x => x.DepartmentOpinions)
|
||||||
|
.Include(x => x.LevelOpinions) // Mig 26 — Section 5 V2 dynamic
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||||
|
|
||||||
@ -687,12 +688,73 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
o.Id, o.Kind, KindLabel(o.Kind),
|
o.Id, o.Kind, KindLabel(o.Kind),
|
||||||
o.Opinion, o.SignedAt, o.UserId, o.UserName))
|
o.Opinion, o.SignedAt, o.UserId, o.UserName))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
await BuildLevelOpinionsAsync(e, ct),
|
||||||
new PurchaseEvaluationWorkflowSummaryDto(
|
new PurchaseEvaluationWorkflowSummaryDto(
|
||||||
policy.Name, policy.Description,
|
policy.Name, policy.Description,
|
||||||
policy.ActivePhases.ToList(),
|
policy.ActivePhases.ToList(),
|
||||||
policy.NextPhasesFrom(e.Phase).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
|
private static string KindLabel(PeDepartmentKind k) => k switch
|
||||||
{
|
{
|
||||||
PeDepartmentKind.PheDuyet => "Phê duyệt",
|
PeDepartmentKind.PheDuyet => "Phê duyệt",
|
||||||
|
|||||||
@ -62,4 +62,9 @@ public class PurchaseEvaluation : AuditableEntity
|
|||||||
public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new();
|
public List<PurchaseEvaluationAttachment> Attachments { get; set; } = new();
|
||||||
public List<PurchaseEvaluationDepartmentOpinion> DepartmentOpinions { get; set; } = new();
|
public List<PurchaseEvaluationDepartmentOpinion> DepartmentOpinions { get; set; } = new();
|
||||||
public List<PurchaseEvaluationDepartmentApproval> DepartmentApprovals { 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<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
|
||||||
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
|
public DbSet<PurchaseEvaluationDepartmentOpinion> PurchaseEvaluationDepartmentOpinions => Set<PurchaseEvaluationDepartmentOpinion>();
|
||||||
public DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals => Set<PurchaseEvaluationDepartmentApproval>();
|
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.
|
// Quy trình duyệt mới (Mig 22 — Session 17): schema riêng UAT.
|
||||||
public DbSet<ApprovalWorkflow> ApprovalWorkflows => Set<ApprovalWorkflow>();
|
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);
|
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 =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -3647,6 +3705,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("PurchaseEvaluation");
|
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 =>
|
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail")
|
b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail")
|
||||||
@ -3787,6 +3864,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.Navigation("Details");
|
b.Navigation("Details");
|
||||||
|
|
||||||
|
b.Navigation("LevelOpinions");
|
||||||
|
|
||||||
b.Navigation("Quotes");
|
b.Navigation("Quotes");
|
||||||
|
|
||||||
b.Navigation("Suppliers");
|
b.Navigation("Suppliers");
|
||||||
|
|||||||
@ -187,6 +187,42 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
ApprovedAt = dateTime.UtcNow,
|
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
|
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
|
||||||
if (currentLevelOrder < maxLevelOrder)
|
if (currentLevelOrder < maxLevelOrder)
|
||||||
{
|
{
|
||||||
@ -353,4 +389,18 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
ct: ct);
|
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