Compare commits
11 Commits
6eec8d78fb
...
3e92584238
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e92584238 | |||
| 14feb6955d | |||
| 48f6d22b3d | |||
| 62b50d112b | |||
| b51fc94ca6 | |||
| ef2330871d | |||
| 1f199b01a5 | |||
| 26c98d3c11 | |||
| 138469db4e | |||
| a85e437478 | |||
| 58898e8fbe |
@ -2,9 +2,8 @@
|
|||||||
name: cicd-monitor
|
name: cicd-monitor
|
||||||
description: |
|
description: |
|
||||||
CI/CD pipeline + post-deploy verification specialist for SOLUTION_ERP. Use proactively AFTER every push to main that triggers Gitea Actions deploy (code commits — skip docs-only per path-filter gotcha #41). Polls Gitea Actions run status via API, verifies test gate pass (Domain 58 + Infra 23 tests baseline), confirms deploy actually shipped (FE bundle hash change × 2 app + EF migrations applied prod), smoke tests prod endpoints (api/admin/eoffice.solutions.com.vn). NEVER writes code — produces PASS/FAIL verdict with concrete evidence from logs + curl + sqlcmd. Catches deploy fail tự động không phụ thuộc em main nhớ verify.
|
CI/CD pipeline + post-deploy verification specialist for SOLUTION_ERP. Use proactively AFTER every push to main that triggers Gitea Actions deploy (code commits — skip docs-only per path-filter gotcha #41). Polls Gitea Actions run status via API, verifies test gate pass (Domain 58 + Infra 23 tests baseline), confirms deploy actually shipped (FE bundle hash change × 2 app + EF migrations applied prod), smoke tests prod endpoints (api/admin/eoffice.solutions.com.vn). NEVER writes code — produces PASS/FAIL verdict with concrete evidence from logs + curl + sqlcmd. Catches deploy fail tự động không phụ thuộc em main nhớ verify.
|
||||||
model: claude-opus-4-7
|
model: inherit
|
||||||
effort: max
|
tools: [Read, Grep, Glob, Bash, WebFetch, mcp__rag-unified__search_memory, mcp__rag-unified__cross_project_search]
|
||||||
tools: [Read, Grep, Glob, Bash, WebFetch]
|
|
||||||
skills:
|
skills:
|
||||||
- iis-deploy-runbook
|
- iis-deploy-runbook
|
||||||
- dependency-audit-erp
|
- dependency-audit-erp
|
||||||
|
|||||||
@ -2,9 +2,8 @@
|
|||||||
name: implementer
|
name: implementer
|
||||||
description: |
|
description: |
|
||||||
Code execution specialist for SOLUTION_ERP. Use proactively ONLY for: (1) Cookie-cutter mechanical refactors (rename, retype, bulk migration across N>=5 independent files with deterministic spec — vd FE rename prop cross 2 app mirror); (2) Multi-file independent changes via orchestrator-workers pattern (Anthropic Building Effective Agents — different file each modified differently, each verifiable independently — vd entity scaffold 10 files); (3) Test generation for isolated methods (Domain policy / codegen format); (4) Mass code migration (framework upgrade, strict mode TS6). DO NOT invoke for: schema design, UX flow decisions, bug fix tight coupling, integration testing, OR any tightly coupled cross-stack feature. Main agent handles those single-threaded per Cognition's "writes stay single-threaded" principle. Implementer auto-refuses out-of-scope tasks.
|
Code execution specialist for SOLUTION_ERP. Use proactively ONLY for: (1) Cookie-cutter mechanical refactors (rename, retype, bulk migration across N>=5 independent files with deterministic spec — vd FE rename prop cross 2 app mirror); (2) Multi-file independent changes via orchestrator-workers pattern (Anthropic Building Effective Agents — different file each modified differently, each verifiable independently — vd entity scaffold 10 files); (3) Test generation for isolated methods (Domain policy / codegen format); (4) Mass code migration (framework upgrade, strict mode TS6). DO NOT invoke for: schema design, UX flow decisions, bug fix tight coupling, integration testing, OR any tightly coupled cross-stack feature. Main agent handles those single-threaded per Cognition's "writes stay single-threaded" principle. Implementer auto-refuses out-of-scope tasks.
|
||||||
model: claude-opus-4-7
|
model: inherit
|
||||||
effort: max
|
tools: [Read, Edit, Write, Bash, Skill, Grep, Glob, mcp__rag-unified__search_memory, mcp__rag-unified__cross_project_search]
|
||||||
tools: [Read, Edit, Write, Bash, Skill, Grep, Glob]
|
|
||||||
skills:
|
skills:
|
||||||
- ef-core-migration
|
- ef-core-migration
|
||||||
- permission-matrix
|
- permission-matrix
|
||||||
|
|||||||
@ -2,9 +2,8 @@
|
|||||||
name: investigator
|
name: investigator
|
||||||
description: |
|
description: |
|
||||||
Read-only research and audit specialist for SOLUTION_ERP codebase. Use proactively when main agent needs to scan >5 files for patterns, audit controllers/endpoints, research external sources (Anthropic docs, community blogs), pre-flight reconnaissance before implementation, smoke test endpoints, search V1/V2 workflow schema or sys.triggers, gather reference implementations from similar features (PE → Contract V2 mirror), audit memory entries cross-reference. NEVER writes code — only returns concise structured findings.
|
Read-only research and audit specialist for SOLUTION_ERP codebase. Use proactively when main agent needs to scan >5 files for patterns, audit controllers/endpoints, research external sources (Anthropic docs, community blogs), pre-flight reconnaissance before implementation, smoke test endpoints, search V1/V2 workflow schema or sys.triggers, gather reference implementations from similar features (PE → Contract V2 mirror), audit memory entries cross-reference. NEVER writes code — only returns concise structured findings.
|
||||||
model: claude-opus-4-7
|
model: inherit
|
||||||
effort: max
|
tools: [Read, Grep, Glob, Bash, WebFetch, WebSearch, mcp__rag-unified__search_memory, mcp__rag-unified__cross_project_search]
|
||||||
tools: [Read, Grep, Glob, Bash, WebFetch, WebSearch]
|
|
||||||
skills:
|
skills:
|
||||||
- contract-workflow
|
- contract-workflow
|
||||||
- permission-matrix
|
- permission-matrix
|
||||||
|
|||||||
@ -2,9 +2,8 @@
|
|||||||
name: reviewer
|
name: reviewer
|
||||||
description: |
|
description: |
|
||||||
Adversarial code review specialist for SOLUTION_ERP. Use proactively BEFORE every commit involving: wire BE claim (especially CRUD endpoints with POST/PUT/DELETE), schema migration, cross-stack feature, security-sensitive diff, or any change > 50 LOC. Provides independent verification that main agent's implementation matches spec, catches blind spots from self-review bias (gotcha #44 silent 403 type issues), and runs live verification on prod UAT environment for deploy claims. NEVER writes code — produces PASS/FAIL verdict with concrete issues file:line.
|
Adversarial code review specialist for SOLUTION_ERP. Use proactively BEFORE every commit involving: wire BE claim (especially CRUD endpoints with POST/PUT/DELETE), schema migration, cross-stack feature, security-sensitive diff, or any change > 50 LOC. Provides independent verification that main agent's implementation matches spec, catches blind spots from self-review bias (gotcha #44 silent 403 type issues), and runs live verification on prod UAT environment for deploy claims. NEVER writes code — produces PASS/FAIL verdict with concrete issues file:line.
|
||||||
model: claude-opus-4-7
|
model: inherit
|
||||||
effort: max
|
tools: [Read, Grep, Glob, Bash, mcp__rag-unified__search_memory, mcp__rag-unified__cross_project_search]
|
||||||
tools: [Read, Grep, Glob, Bash]
|
|
||||||
skills:
|
skills:
|
||||||
- dependency-audit-erp
|
- dependency-audit-erp
|
||||||
- iis-deploy-runbook
|
- iis-deploy-runbook
|
||||||
|
|||||||
@ -182,6 +182,47 @@ export function ContractDetailContent({
|
|||||||
<ContractDetailsTab contract={c} />
|
<ContractDetailsTab contract={c} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* [Plan B S29 2026-05-22 Chunk E3] Section 5 — Ý kiến cấp duyệt V2 dynamic.
|
||||||
|
Mirror PE LevelOpinionsSectionV2 pattern. Chỉ render khi V2 pin
|
||||||
|
(approvalWorkflowId set). V1 legacy contract KHÔNG hiển thị. */}
|
||||||
|
{c.approvalWorkflowId && (
|
||||||
|
<section className="rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
|
||||||
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-emerald-800">
|
||||||
|
<ListChecks className="h-4 w-4" />
|
||||||
|
Ý kiến cấp duyệt (Quy trình V2)
|
||||||
|
</h2>
|
||||||
|
{(c.levelOpinions?.length ?? 0) === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Chưa có ý kiến — workflow vừa bắt đầu hoặc chưa có ai duyệt.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{c.levelOpinions!.map(o => {
|
||||||
|
const adminProxy = o.signedByUserId !== o.approverUserId
|
||||||
|
return (
|
||||||
|
<li key={o.id} className="rounded-md border border-emerald-200 bg-white p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="font-medium text-emerald-900">
|
||||||
|
Bước {o.stepOrder} {o.stepName ? `(${o.stepName})` : ''} — Cấp {o.levelOrder}
|
||||||
|
{o.levelName ? ` (${o.levelName})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">{new Date(o.signedAt).toLocaleString('vi-VN')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-sm text-slate-800">{o.comment}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
|
||||||
|
<span>NV duyệt: <strong>{o.approverFullName ?? o.approverUserId.slice(0, 8)}</strong></span>
|
||||||
|
{adminProxy && (
|
||||||
|
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-800">
|
||||||
|
⚡ Admin duyệt thay ({o.signedByFullName ?? o.signedByUserId.slice(0, 8)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={actionOpen}
|
open={actionOpen}
|
||||||
onClose={() => setActionOpen(false)}
|
onClose={() => setActionOpen(false)}
|
||||||
|
|||||||
@ -309,6 +309,11 @@ function ContractHeaderForm({
|
|||||||
const [budgetManual, setBudgetManual] = useState(false)
|
const [budgetManual, setBudgetManual] = useState(false)
|
||||||
const [budgetManualName, setBudgetManualName] = useState('')
|
const [budgetManualName, setBudgetManualName] = useState('')
|
||||||
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
|
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
|
||||||
|
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
|
||||||
|
// PE PeWorkspaceCreateView pattern. Drafter pick V2 workflow IsUserSelectable
|
||||||
|
// filter ApplicableType=Contract(3). Nếu blank → BE fallback V1 auto pick
|
||||||
|
// (7 prod contract giữ behavior cũ).
|
||||||
|
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
||||||
|
|
||||||
// Reset type về default khi typeFilter (parent prop) thay đổi
|
// Reset type về default khi typeFilter (parent prop) thay đổi
|
||||||
useEffect(() => { setType(defaultType) }, [defaultType])
|
useEffect(() => { setType(defaultType) }, [defaultType])
|
||||||
@ -327,6 +332,19 @@ function ContractHeaderForm({
|
|||||||
queryKey: ['templates-by-type', type],
|
queryKey: ['templates-by-type', type],
|
||||||
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
|
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
|
||||||
})
|
})
|
||||||
|
// [Plan B S29 Chunk D Mig 32] V2 workflows ApplicableType=Contract(3) filter
|
||||||
|
// IsUserSelectable=true (admin ghim cho user pick). Mirror PE Mig 25 pattern.
|
||||||
|
const approvalWorkflows = useQuery({
|
||||||
|
queryKey: ['approval-workflows-v2-contract'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean; isUserSelectable: boolean }[] }[] }>(
|
||||||
|
'/approval-workflows-v2',
|
||||||
|
{ params: { applicableType: 3 } },
|
||||||
|
)
|
||||||
|
const typeBucket = res.data.types.find(t => t.applicableType === 3)
|
||||||
|
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
|
||||||
|
},
|
||||||
|
})
|
||||||
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
|
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
|
||||||
const eligibleBudgets = useQuery({
|
const eligibleBudgets = useQuery({
|
||||||
queryKey: ['eligible-budgets', projectId],
|
queryKey: ['eligible-budgets', projectId],
|
||||||
@ -357,6 +375,9 @@ function ContractHeaderForm({
|
|||||||
bypassProcurementAndCCM: bypass,
|
bypassProcurementAndCCM: bypass,
|
||||||
draftData: null,
|
draftData: null,
|
||||||
...budgetPayload,
|
...budgetPayload,
|
||||||
|
// [Plan B S29 Chunk D Mig 32] Pin V2 workflow nếu user chọn; null →
|
||||||
|
// BE fallback V1 auto pick.
|
||||||
|
approvalWorkflowId: approvalWorkflowId || null,
|
||||||
})
|
})
|
||||||
return res.data.id
|
return res.data.id
|
||||||
},
|
},
|
||||||
@ -395,6 +416,29 @@ function ContractHeaderForm({
|
|||||||
templates={templates.data ?? []}
|
templates={templates.data ?? []}
|
||||||
typeReadonly={false}
|
typeReadonly={false}
|
||||||
/>
|
/>
|
||||||
|
{/* [Plan B S29 Chunk D Mig 32] V2 workflow picker — mutually exclusive
|
||||||
|
với V1. Blank → BE fallback V1 auto pick (7 prod contract behavior).
|
||||||
|
Mirror PE PeWorkspaceCreateView pattern. */}
|
||||||
|
<div className="mt-4 space-y-1.5">
|
||||||
|
<Label>Quy trình duyệt V2 <span className="text-[10px] font-normal text-slate-400">(tùy chọn — bỏ trống = dùng V1 mặc định)</span></Label>
|
||||||
|
<Select
|
||||||
|
value={approvalWorkflowId}
|
||||||
|
onChange={e => setApprovalWorkflowId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— V1 mặc định (auto pick) —</option>
|
||||||
|
{approvalWorkflows.data?.map(w => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.code} v{String(w.version).padStart(2, '0')} — {w.name}
|
||||||
|
{w.isActive ? ' (đang áp dụng)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{approvalWorkflows.data && approvalWorkflows.data.length === 0 && (
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Chưa có quy trình duyệt V2 nào được admin ghim cho HĐ — HĐ sẽ chạy theo V1 mặc định.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="mt-4 space-y-1.5">
|
<div className="mt-4 space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
||||||
|
|||||||
@ -171,4 +171,28 @@ export type ContractDetail = {
|
|||||||
comments: ContractComment[]
|
comments: ContractComment[]
|
||||||
attachments: ContractAttachment[]
|
attachments: ContractAttachment[]
|
||||||
workflow: WorkflowSummary
|
workflow: WorkflowSummary
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E3] V2 workflow state — mirror PE pattern.
|
||||||
|
// Khi pin V2 (approvalWorkflowId set) → render Section 5 dynamic.
|
||||||
|
// V1 contract: 3 fields = null.
|
||||||
|
approvalWorkflowId: string | null
|
||||||
|
currentApprovalLevelOrder: number | null
|
||||||
|
levelOpinions: ContractLevelOpinion[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E3] Mirror BE ContractLevelOpinionDto 12 fields.
|
||||||
|
// Service ApproveV2Async UPSERT (Plan B Chunk B2). Comment empty → "(duyệt —
|
||||||
|
// không ý kiến)". signedByUserId !== approverUserId → banner "Admin duyệt thay".
|
||||||
|
export type ContractLevelOpinion = {
|
||||||
|
id: string
|
||||||
|
approvalWorkflowLevelId: string
|
||||||
|
stepOrder: number
|
||||||
|
stepName: string
|
||||||
|
levelOrder: number
|
||||||
|
levelName: string | null
|
||||||
|
approverUserId: string
|
||||||
|
approverFullName: string | null
|
||||||
|
comment: string
|
||||||
|
signedAt: string
|
||||||
|
signedByUserId: string
|
||||||
|
signedByFullName: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -182,6 +182,47 @@ export function ContractDetailContent({
|
|||||||
<ContractDetailsTab contract={c} />
|
<ContractDetailsTab contract={c} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* [Plan B S29 2026-05-22 Chunk E3] Section 5 — Ý kiến cấp duyệt V2 dynamic.
|
||||||
|
Mirror PE LevelOpinionsSectionV2 pattern. Chỉ render khi V2 pin
|
||||||
|
(approvalWorkflowId set). V1 legacy contract KHÔNG hiển thị. */}
|
||||||
|
{c.approvalWorkflowId && (
|
||||||
|
<section className="rounded-lg border border-emerald-200 bg-emerald-50/40 p-5">
|
||||||
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-emerald-800">
|
||||||
|
<ListChecks className="h-4 w-4" />
|
||||||
|
Ý kiến cấp duyệt (Quy trình V2)
|
||||||
|
</h2>
|
||||||
|
{(c.levelOpinions?.length ?? 0) === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Chưa có ý kiến — workflow vừa bắt đầu hoặc chưa có ai duyệt.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{c.levelOpinions!.map(o => {
|
||||||
|
const adminProxy = o.signedByUserId !== o.approverUserId
|
||||||
|
return (
|
||||||
|
<li key={o.id} className="rounded-md border border-emerald-200 bg-white p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="font-medium text-emerald-900">
|
||||||
|
Bước {o.stepOrder} {o.stepName ? `(${o.stepName})` : ''} — Cấp {o.levelOrder}
|
||||||
|
{o.levelName ? ` (${o.levelName})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">{new Date(o.signedAt).toLocaleString('vi-VN')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-sm text-slate-800">{o.comment}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
|
||||||
|
<span>NV duyệt: <strong>{o.approverFullName ?? o.approverUserId.slice(0, 8)}</strong></span>
|
||||||
|
{adminProxy && (
|
||||||
|
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-800">
|
||||||
|
⚡ Admin duyệt thay ({o.signedByFullName ?? o.signedByUserId.slice(0, 8)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={actionOpen}
|
open={actionOpen}
|
||||||
onClose={() => setActionOpen(false)}
|
onClose={() => setActionOpen(false)}
|
||||||
|
|||||||
@ -309,6 +309,11 @@ function ContractHeaderForm({
|
|||||||
const [budgetManual, setBudgetManual] = useState(false)
|
const [budgetManual, setBudgetManual] = useState(false)
|
||||||
const [budgetManualName, setBudgetManualName] = useState('')
|
const [budgetManualName, setBudgetManualName] = useState('')
|
||||||
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
|
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
|
||||||
|
// [Plan B S29 Chunk D 2026-05-22 Mig 32] V2 workflow pin lúc create — mirror
|
||||||
|
// PE PeWorkspaceCreateView pattern. Drafter pick V2 workflow IsUserSelectable
|
||||||
|
// filter ApplicableType=Contract(3). Nếu blank → BE fallback V1 auto pick
|
||||||
|
// (7 prod contract giữ behavior cũ).
|
||||||
|
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
|
||||||
|
|
||||||
// Reset type về default khi typeFilter (parent prop) thay đổi
|
// Reset type về default khi typeFilter (parent prop) thay đổi
|
||||||
useEffect(() => { setType(defaultType) }, [defaultType])
|
useEffect(() => { setType(defaultType) }, [defaultType])
|
||||||
@ -327,6 +332,19 @@ function ContractHeaderForm({
|
|||||||
queryKey: ['templates-by-type', type],
|
queryKey: ['templates-by-type', type],
|
||||||
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
|
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
|
||||||
})
|
})
|
||||||
|
// [Plan B S29 Chunk D Mig 32] V2 workflows ApplicableType=Contract(3) filter
|
||||||
|
// IsUserSelectable=true (admin ghim cho user pick). Mirror PE Mig 25 pattern.
|
||||||
|
const approvalWorkflows = useQuery({
|
||||||
|
queryKey: ['approval-workflows-v2-contract'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean; isUserSelectable: boolean }[] }[] }>(
|
||||||
|
'/approval-workflows-v2',
|
||||||
|
{ params: { applicableType: 3 } },
|
||||||
|
)
|
||||||
|
const typeBucket = res.data.types.find(t => t.applicableType === 3)
|
||||||
|
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
|
||||||
|
},
|
||||||
|
})
|
||||||
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
|
// Eligible Budgets: cùng Project + Phase=DaDuyet (BE-side filter).
|
||||||
const eligibleBudgets = useQuery({
|
const eligibleBudgets = useQuery({
|
||||||
queryKey: ['eligible-budgets', projectId],
|
queryKey: ['eligible-budgets', projectId],
|
||||||
@ -357,6 +375,9 @@ function ContractHeaderForm({
|
|||||||
bypassProcurementAndCCM: bypass,
|
bypassProcurementAndCCM: bypass,
|
||||||
draftData: null,
|
draftData: null,
|
||||||
...budgetPayload,
|
...budgetPayload,
|
||||||
|
// [Plan B S29 Chunk D Mig 32] Pin V2 workflow nếu user chọn; null →
|
||||||
|
// BE fallback V1 auto pick.
|
||||||
|
approvalWorkflowId: approvalWorkflowId || null,
|
||||||
})
|
})
|
||||||
return res.data.id
|
return res.data.id
|
||||||
},
|
},
|
||||||
@ -395,6 +416,29 @@ function ContractHeaderForm({
|
|||||||
templates={templates.data ?? []}
|
templates={templates.data ?? []}
|
||||||
typeReadonly={false}
|
typeReadonly={false}
|
||||||
/>
|
/>
|
||||||
|
{/* [Plan B S29 Chunk D Mig 32] V2 workflow picker — mutually exclusive
|
||||||
|
với V1. Blank → BE fallback V1 auto pick (7 prod contract behavior).
|
||||||
|
Mirror PE PeWorkspaceCreateView pattern. */}
|
||||||
|
<div className="mt-4 space-y-1.5">
|
||||||
|
<Label>Quy trình duyệt V2 <span className="text-[10px] font-normal text-slate-400">(tùy chọn — bỏ trống = dùng V1 mặc định)</span></Label>
|
||||||
|
<Select
|
||||||
|
value={approvalWorkflowId}
|
||||||
|
onChange={e => setApprovalWorkflowId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— V1 mặc định (auto pick) —</option>
|
||||||
|
{approvalWorkflows.data?.map(w => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.code} v{String(w.version).padStart(2, '0')} — {w.name}
|
||||||
|
{w.isActive ? ' (đang áp dụng)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{approvalWorkflows.data && approvalWorkflows.data.length === 0 && (
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Chưa có quy trình duyệt V2 nào được admin ghim cho HĐ — HĐ sẽ chạy theo V1 mặc định.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="mt-4 space-y-1.5">
|
<div className="mt-4 space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
||||||
|
|||||||
@ -171,4 +171,28 @@ export type ContractDetail = {
|
|||||||
comments: ContractComment[]
|
comments: ContractComment[]
|
||||||
attachments: ContractAttachment[]
|
attachments: ContractAttachment[]
|
||||||
workflow: WorkflowSummary
|
workflow: WorkflowSummary
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E3] V2 workflow state — mirror PE pattern.
|
||||||
|
// Khi pin V2 (approvalWorkflowId set) → render Section 5 dynamic.
|
||||||
|
// V1 contract: 3 fields = null.
|
||||||
|
approvalWorkflowId: string | null
|
||||||
|
currentApprovalLevelOrder: number | null
|
||||||
|
levelOpinions: ContractLevelOpinion[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E3] Mirror BE ContractLevelOpinionDto 12 fields.
|
||||||
|
// Service ApproveV2Async UPSERT (Plan B Chunk B2). Comment empty → "(duyệt —
|
||||||
|
// không ý kiến)". signedByUserId !== approverUserId → banner "Admin duyệt thay".
|
||||||
|
export type ContractLevelOpinion = {
|
||||||
|
id: string
|
||||||
|
approvalWorkflowLevelId: string
|
||||||
|
stepOrder: number
|
||||||
|
stepName: string
|
||||||
|
levelOrder: number
|
||||||
|
levelName: string | null
|
||||||
|
approverUserId: string
|
||||||
|
approverFullName: string | null
|
||||||
|
comment: string
|
||||||
|
signedAt: string
|
||||||
|
signedByUserId: string
|
||||||
|
signedByFullName: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,9 @@ public interface IApplicationDbContext
|
|||||||
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
|
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
|
||||||
DbSet<ContractChangelog> ContractChangelogs { get; }
|
DbSet<ContractChangelog> ContractChangelogs { get; }
|
||||||
DbSet<ContractDepartmentApproval> ContractDepartmentApprovals { get; }
|
DbSet<ContractDepartmentApproval> ContractDepartmentApprovals { get; }
|
||||||
|
// Plan B Chunk C (S29 — Mig 33) — Ý kiến cấp duyệt V2 dynamic cho HĐ.
|
||||||
|
// Cookie-cutter mirror PE Mig 26 PurchaseEvaluationLevelOpinions.
|
||||||
|
DbSet<ContractLevelOpinion> ContractLevelOpinions { get; }
|
||||||
DbSet<Notification> Notifications { get; }
|
DbSet<Notification> Notifications { get; }
|
||||||
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
|
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
|
||||||
DbSet<WorkflowDefinition> WorkflowDefinitions { get; }
|
DbSet<WorkflowDefinition> WorkflowDefinitions { get; }
|
||||||
|
|||||||
@ -27,7 +27,13 @@ public record CreateContractCommand(
|
|||||||
string? DraftData,
|
string? DraftData,
|
||||||
Guid? BudgetId,
|
Guid? BudgetId,
|
||||||
string? BudgetManualName,
|
string? BudgetManualName,
|
||||||
decimal? BudgetManualAmount) : IRequest<Guid>;
|
decimal? BudgetManualAmount,
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E1] Drafter pick V2 workflow lúc create —
|
||||||
|
// mirror PE pattern Workspace Select dropdown. Nếu null → fallback V1 auto
|
||||||
|
// pick activeWfId (7 prod contract giữ behavior). Mutually exclusive với
|
||||||
|
// V1: pin V2 + V1 cùng lúc OK schema (cả 2 nullable) — Service ApproveV2Async
|
||||||
|
// branch ưu tiên V2 nếu cả 2 set.
|
||||||
|
Guid? ApprovalWorkflowId = null) : IRequest<Guid>;
|
||||||
|
|
||||||
public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
|
public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
|
||||||
{
|
{
|
||||||
@ -64,6 +70,21 @@ public class CreateContractCommandHandler(
|
|||||||
.Select(w => (Guid?)w.Id)
|
.Select(w => (Guid?)w.Id)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
// [Plan B S29 2026-05-22 Hotfix Reviewer] Validate ApprovalWorkflowId V2
|
||||||
|
// (Mig 32) — User chọn lúc create. Phải tồn tại + ApplicableType=Contract(3).
|
||||||
|
// Mirror PE pattern PurchaseEvaluationFeatures.cs:62-77. Defense-in-depth:
|
||||||
|
// FE Workspace dropdown đã filter ApplicableType=3 server-side; BE guard
|
||||||
|
// chặn attacker forge POST với PE workflow ID (ApplicableType=1/2).
|
||||||
|
if (request.ApprovalWorkflowId is Guid awId)
|
||||||
|
{
|
||||||
|
var aw = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == awId, ct)
|
||||||
|
?? throw new NotFoundException("ApprovalWorkflow", awId);
|
||||||
|
if (aw.ApplicableType != Domain.ApprovalWorkflowsV2.ApprovalWorkflowApplicableType.Contract)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với HĐ (cần ApplicableType=Contract).");
|
||||||
|
}
|
||||||
|
|
||||||
// Validate Budget link nếu có: cùng Project + Phase=DaDuyet.
|
// Validate Budget link nếu có: cùng Project + Phase=DaDuyet.
|
||||||
if (request.BudgetId is Guid bid)
|
if (request.BudgetId is Guid bid)
|
||||||
{
|
{
|
||||||
@ -94,6 +115,10 @@ public class CreateContractCommandHandler(
|
|||||||
BudgetManualName = request.BudgetManualName,
|
BudgetManualName = request.BudgetManualName,
|
||||||
BudgetManualAmount = request.BudgetManualAmount,
|
BudgetManualAmount = request.BudgetManualAmount,
|
||||||
WorkflowDefinitionId = activeWfId,
|
WorkflowDefinitionId = activeWfId,
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E1] Pin V2 workflow nếu Drafter pick
|
||||||
|
// qua Workspace Select dropdown (Chunk D FE). Cả 2 set ok — Service
|
||||||
|
// ApproveV2Async branch dispatch theo ApprovalWorkflowId trước.
|
||||||
|
ApprovalWorkflowId = request.ApprovalWorkflowId,
|
||||||
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
|
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -235,7 +260,7 @@ public class TransitionContractCommandHandler(
|
|||||||
currentUser.Roles,
|
currentUser.Roles,
|
||||||
request.Decision,
|
request.Decision,
|
||||||
request.Comment,
|
request.Comment,
|
||||||
ct);
|
ct: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -467,6 +492,54 @@ public class GetContractQueryHandler(
|
|||||||
workflowPolicy = WorkflowPolicyRegistry.ForContractWithOverrides(c, workflowOverrides);
|
workflowPolicy = WorkflowPolicyRegistry.ForContractWithOverrides(c, workflowOverrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E2] Load V2 LevelOpinions nếu pin ApprovalWorkflowId.
|
||||||
|
// JOIN ApprovalWorkflowLevel + Step + Approver User để build dynamic DTO
|
||||||
|
// cho FE Section 5 render dynamic theo flow shape. V1 legacy → empty list.
|
||||||
|
List<ContractLevelOpinionDto>? levelOpinionsDto = null;
|
||||||
|
if (c.ApprovalWorkflowId is Guid awIdLoad)
|
||||||
|
{
|
||||||
|
var opinions = await db.ContractLevelOpinions.AsNoTracking()
|
||||||
|
.Where(o => o.ContractId == c.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (opinions.Count > 0)
|
||||||
|
{
|
||||||
|
var levelIds = opinions.Select(o => o.ApprovalWorkflowLevelId).ToHashSet();
|
||||||
|
var levels = await db.ApprovalWorkflowLevels.AsNoTracking()
|
||||||
|
.Include(l => l.Step)
|
||||||
|
.Where(l => levelIds.Contains(l.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var approverUserIds = levels.Select(l => l.ApproverUserId).ToHashSet();
|
||||||
|
var approverNames = await userManager.Users.AsNoTracking()
|
||||||
|
.Where(u => approverUserIds.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
|
||||||
|
levelOpinionsDto = opinions
|
||||||
|
.Select(o =>
|
||||||
|
{
|
||||||
|
var level = levels.FirstOrDefault(l => l.Id == o.ApprovalWorkflowLevelId);
|
||||||
|
var step = level?.Step;
|
||||||
|
return new ContractLevelOpinionDto(
|
||||||
|
o.Id,
|
||||||
|
o.ApprovalWorkflowLevelId,
|
||||||
|
StepOrder: step?.Order ?? 0,
|
||||||
|
StepName: step?.Name ?? "",
|
||||||
|
LevelOrder: level?.Order ?? 0,
|
||||||
|
LevelName: level?.Name,
|
||||||
|
ApproverUserId: level?.ApproverUserId ?? Guid.Empty,
|
||||||
|
ApproverFullName: level != null && approverNames.TryGetValue(level.ApproverUserId, out var afn) ? afn : null,
|
||||||
|
o.Comment ?? "",
|
||||||
|
o.SignedAt,
|
||||||
|
o.SignedByUserId,
|
||||||
|
o.SignedByFullName);
|
||||||
|
})
|
||||||
|
.OrderBy(d => d.StepOrder).ThenBy(d => d.LevelOrder)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
levelOpinionsDto = new List<ContractLevelOpinionDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve user names
|
// Resolve user names
|
||||||
var userIds = new HashSet<Guid>();
|
var userIds = new HashSet<Guid>();
|
||||||
if (c.DrafterUserId is Guid did) userIds.Add(did);
|
if (c.DrafterUserId is Guid did) userIds.Add(did);
|
||||||
@ -506,7 +579,11 @@ public class GetContractQueryHandler(
|
|||||||
att.Id, att.FileName, att.StoragePath, att.FileSize,
|
att.Id, att.FileName, att.StoragePath, att.FileSize,
|
||||||
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
|
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
BuildWorkflowSummary(c, workflowPolicy));
|
BuildWorkflowSummary(c, workflowPolicy),
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E2] V2 fields
|
||||||
|
ApprovalWorkflowId: c.ApprovalWorkflowId,
|
||||||
|
CurrentApprovalLevelOrder: c.CurrentApprovalLevelOrder,
|
||||||
|
LevelOpinions: levelOpinionsDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FE uses this to render next-phase buttons dynamically — no more hardcoded
|
// FE uses this to render next-phase buttons dynamically — no more hardcoded
|
||||||
|
|||||||
@ -46,7 +46,34 @@ public record ContractDetailDto(
|
|||||||
List<ContractApprovalDto> Approvals,
|
List<ContractApprovalDto> Approvals,
|
||||||
List<ContractCommentDto> Comments,
|
List<ContractCommentDto> Comments,
|
||||||
List<ContractAttachmentDto> Attachments,
|
List<ContractAttachmentDto> Attachments,
|
||||||
WorkflowSummaryDto Workflow);
|
WorkflowSummaryDto Workflow,
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E2] V2 workflow fields — mirror PE pattern.
|
||||||
|
// ApprovalWorkflowId pin lúc create (Mig 32). FE Section 5 detect V2 mode
|
||||||
|
// qua field này: nếu Guid → render dynamic LevelOpinionsSectionV2;
|
||||||
|
// nếu null → V1 legacy KHÔNG Section 5 V2.
|
||||||
|
// FE fetch ApprovalFlow shape via /api/approval-workflows-v2/{ApprovalWorkflowId}.
|
||||||
|
Guid? ApprovalWorkflowId = null,
|
||||||
|
int? CurrentApprovalLevelOrder = null,
|
||||||
|
List<ContractLevelOpinionDto>? LevelOpinions = null);
|
||||||
|
|
||||||
|
// [Plan B S29 2026-05-22 Chunk E2] Ý kiến cấp duyệt V2 dynamic theo
|
||||||
|
// ApprovalWorkflowLevel. Mirror PE PurchaseEvaluationLevelOpinionDto pattern.
|
||||||
|
// Service ApproveV2Async UPSERT (Plan B Chunk B2 1f199b0). Comment empty
|
||||||
|
// fallback "(duyệt — không ý kiến)". SignedByUserId !== Level.ApproverUserId
|
||||||
|
// → FE show banner "Admin duyệt thay".
|
||||||
|
public record ContractLevelOpinionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid ApprovalWorkflowLevelId,
|
||||||
|
int StepOrder,
|
||||||
|
string StepName,
|
||||||
|
int LevelOrder,
|
||||||
|
string? LevelName,
|
||||||
|
Guid ApproverUserId,
|
||||||
|
string? ApproverFullName,
|
||||||
|
string Comment,
|
||||||
|
DateTime SignedAt,
|
||||||
|
Guid SignedByUserId,
|
||||||
|
string? SignedByFullName);
|
||||||
|
|
||||||
// Policy snapshot for the FE — lets UI render next-phase buttons dynamically
|
// Policy snapshot for the FE — lets UI render next-phase buttons dynamically
|
||||||
// without hardcoding the transition map (single source of truth in BE).
|
// without hardcoding the transition map (single source of truth in BE).
|
||||||
|
|||||||
@ -6,6 +6,9 @@ public interface IContractWorkflowService
|
|||||||
{
|
{
|
||||||
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
|
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
|
||||||
// Tự tạo ContractApproval row + update Phase + SlaDeadline + gen mã HĐ nếu cần.
|
// Tự tạo ContractApproval row + update Phase + SlaDeadline + gen mã HĐ nếu cần.
|
||||||
|
// [Plan B S29 2026-05-22] +skipToFinal param F2 (Mig 31): Approver scope
|
||||||
|
// ChoDuyet skip thẳng Cấp cuối. V2 only, V1 legacy throw nếu non-admin.
|
||||||
|
// Default false để KHÔNG break existing caller (ContractsController).
|
||||||
Task TransitionAsync(
|
Task TransitionAsync(
|
||||||
Contract contract,
|
Contract contract,
|
||||||
ContractPhase targetPhase,
|
ContractPhase targetPhase,
|
||||||
@ -13,6 +16,7 @@ public interface IContractWorkflowService
|
|||||||
IReadOnlyList<string> actorRoles,
|
IReadOnlyList<string> actorRoles,
|
||||||
ApprovalDecision decision,
|
ApprovalDecision decision,
|
||||||
string? comment,
|
string? comment,
|
||||||
|
bool skipToFinal = false,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
// SLA còn bao lâu ở phase hiện tại (seconds). Null nếu không có SLA.
|
// SLA còn bao lâu ở phase hiện tại (seconds). Null nếu không có SLA.
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public class Contract : AuditableEntity
|
|||||||
public string? NoiDung { get; set; }
|
public string? NoiDung { get; set; }
|
||||||
public bool BypassProcurementAndCCM { get; set; } // HĐ Chủ đầu tư → skip CCM
|
public bool BypassProcurementAndCCM { get; set; } // HĐ Chủ đầu tư → skip CCM
|
||||||
public Guid? WorkflowDefinitionId { get; set; } // Pinned at creation — HĐ cũ chạy version cũ ngay cả khi admin active version mới
|
public Guid? WorkflowDefinitionId { get; set; } // Pinned at creation — HĐ cũ chạy version cũ ngay cả khi admin active version mới
|
||||||
|
public Guid? ApprovalWorkflowId { get; set; } // [Plan B S29 2026-05-22 Mig 32] Pin schema mới ApprovalWorkflowsV2 — mirror PE Mig 23. V1+V2 coexist: 7 V1 contract giữ WorkflowDefinitionId; V2 mới pin ApprovalWorkflowId. Service ApproveV2Async branch theo field này.
|
||||||
public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại
|
public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại
|
||||||
public string? DraftData { get; set; } // JSON field values (render template)
|
public string? DraftData { get; set; } // JSON field values (render template)
|
||||||
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
|
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
|
||||||
@ -39,9 +40,20 @@ public class Contract : AuditableEntity
|
|||||||
public int? CurrentWorkflowStepIndex { get; set; }
|
public int? CurrentWorkflowStepIndex { get; set; }
|
||||||
public int? RejectedAtStepIndex { get; set; }
|
public int? RejectedAtStepIndex { get; set; }
|
||||||
|
|
||||||
|
// [Plan B S29 2026-05-22 Mig 32] V2 workflow tracking — mirror PE Mig 24.
|
||||||
|
// CurrentApprovalLevelOrder: Cấp đang chờ duyệt (1/2/3) trong Step hiện tại
|
||||||
|
// khi pin ApprovalWorkflowId. Null khi V1 legacy hoặc V2 terminal (DaPhatHanh).
|
||||||
|
public int? CurrentApprovalLevelOrder { get; set; }
|
||||||
|
|
||||||
public List<ContractApproval> Approvals { get; set; } = new();
|
public List<ContractApproval> Approvals { get; set; } = new();
|
||||||
public List<ContractComment> Comments { get; set; } = new();
|
public List<ContractComment> Comments { get; set; } = new();
|
||||||
public List<ContractAttachment> Attachments { get; set; } = new();
|
public List<ContractAttachment> Attachments { get; set; } = new();
|
||||||
|
|
||||||
|
// Plan B Chunk C (S29 — Mig 33, 2026-05-22) — Ý kiến cấp duyệt V2 dynamic
|
||||||
|
// cookie-cutter mirror PE Mig 26. UPSERT auto từ ApproveV2Async (Plan B
|
||||||
|
// Chunk D em main wire). Section 5 FE render dynamic theo flow.steps[].levels[].
|
||||||
|
// HĐ V1 (WorkflowDefinitionId) KHÔNG dùng.
|
||||||
|
public List<ContractLevelOpinion> LevelOpinions { get; set; } = new();
|
||||||
public List<ContractChangelog> Changelogs { get; set; } = new();
|
public List<ContractChangelog> Changelogs { get; set; } = new();
|
||||||
public List<ContractDepartmentApproval> DepartmentApprovals { get; set; } = new();
|
public List<ContractDepartmentApproval> DepartmentApprovals { get; set; } = new();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Domain.Contracts;
|
||||||
|
|
||||||
|
// "Ý kiến cấp duyệt" V2 cho HĐ — sign-off DYNAMIC theo workflow ApprovalWorkflowV2
|
||||||
|
// (Mig 22-25 + Plan B Mig 32). Cookie-cutter mirror PE Mig 26
|
||||||
|
// `PurchaseEvaluationLevelOpinion` (S19 2026-05-09).
|
||||||
|
//
|
||||||
|
// Mỗi row = 1 (Contract × ApprovalWorkflowLevel). Service `ApproveV2Async` sau khi
|
||||||
|
// approve thành công Cấp hiện tại sẽ UPSERT row này (Plan B Chunk D em main wire):
|
||||||
|
// Comment = approval.Comment ?? "(duyệt — không ý kiến)"
|
||||||
|
// 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 `contract.approvalWorkflowId != null` → render dynamic
|
||||||
|
// theo flow.steps[].levels[]. HĐ V1 legacy (WorkflowDefinitionId set) → fallback
|
||||||
|
// không có ý kiến dynamic (giống PE V1 behavior).
|
||||||
|
public class ContractLevelOpinion : AuditableEntity
|
||||||
|
{
|
||||||
|
public Guid ContractId { 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 Contract? Contract { get; set; }
|
||||||
|
public ApprovalWorkflowLevel? Level { get; set; }
|
||||||
|
}
|
||||||
@ -37,6 +37,8 @@ public class ApplicationDbContext
|
|||||||
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
|
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
|
||||||
public DbSet<ContractChangelog> ContractChangelogs => Set<ContractChangelog>();
|
public DbSet<ContractChangelog> ContractChangelogs => Set<ContractChangelog>();
|
||||||
public DbSet<ContractDepartmentApproval> ContractDepartmentApprovals => Set<ContractDepartmentApproval>();
|
public DbSet<ContractDepartmentApproval> ContractDepartmentApprovals => Set<ContractDepartmentApproval>();
|
||||||
|
// Plan B Chunk C (S29 — Mig 33) — Ý kiến cấp duyệt V2 cho HĐ. Mirror PE Mig 26.
|
||||||
|
public DbSet<ContractLevelOpinion> ContractLevelOpinions => Set<ContractLevelOpinion>();
|
||||||
public DbSet<Notification> Notifications => Set<Notification>();
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
|
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
|
||||||
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();
|
public DbSet<WorkflowDefinition> WorkflowDefinitions => Set<WorkflowDefinition>();
|
||||||
|
|||||||
@ -28,6 +28,15 @@ public class ContractConfiguration : IEntityTypeConfiguration<Contract>
|
|||||||
b.HasIndex(x => x.ProjectId);
|
b.HasIndex(x => x.ProjectId);
|
||||||
b.HasIndex(x => x.SlaDeadline);
|
b.HasIndex(x => x.SlaDeadline);
|
||||||
b.HasIndex(x => x.BudgetId);
|
b.HasIndex(x => x.BudgetId);
|
||||||
|
b.HasIndex(x => x.ApprovalWorkflowId);
|
||||||
|
|
||||||
|
// FK ApprovalWorkflowId Restrict (Plan B Chunk A2 — Mig 32 mirror PE Mig 23)
|
||||||
|
// ApprovalWorkflowsV2 pin lúc create HĐ V2. Restrict để KHÔNG xóa workflow
|
||||||
|
// nếu còn HĐ pin.
|
||||||
|
b.HasOne<SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApprovalWorkflowId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.HasMany(x => x.Approvals).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(DeleteBehavior.Cascade);
|
b.HasMany(x => x.Approvals).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(DeleteBehavior.Cascade);
|
||||||
b.HasMany(x => x.Comments).WithOne(c => c.Contract).HasForeignKey(c => c.ContractId).OnDelete(DeleteBehavior.Cascade);
|
b.HasMany(x => x.Comments).WithOne(c => c.Contract).HasForeignKey(c => c.ContractId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
// Plan B Chunk C (S29 — Mig 33, 2026-05-22) — cookie-cutter mirror
|
||||||
|
// PurchaseEvaluationLevelOpinionConfiguration (Mig 26 S19).
|
||||||
|
//
|
||||||
|
// UPSERT auto sync từ ContractWorkflowService.ApproveV2Async (Plan B Chunk D em
|
||||||
|
// main wire). UNIQUE (ContractId, ApprovalWorkflowLevelId) đảm bảo 1 row/level/HĐ.
|
||||||
|
// FK Cascade Contract (xoá HĐ → 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 ContractLevelOpinionConfiguration : IEntityTypeConfiguration<ContractLevelOpinion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ContractLevelOpinion> e)
|
||||||
|
{
|
||||||
|
e.ToTable("ContractLevelOpinions");
|
||||||
|
|
||||||
|
e.Property(x => x.Comment).HasMaxLength(2000);
|
||||||
|
e.Property(x => x.SignedByFullName).HasMaxLength(200).IsRequired();
|
||||||
|
|
||||||
|
e.HasOne(x => x.Contract)
|
||||||
|
.WithMany(c => c.LevelOpinions)
|
||||||
|
.HasForeignKey(x => x.ContractId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Level)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.ApprovalWorkflowLevelId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.ContractId, x.ApprovalWorkflowLevelId }).IsUnique();
|
||||||
|
e.HasIndex(x => x.ApprovalWorkflowLevelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -68,6 +68,7 @@ public static class DbInitializer
|
|||||||
// - SeedDemoContractsAsync ([DEMO] HĐ 7-type sample)
|
// - SeedDemoContractsAsync ([DEMO] HĐ 7-type sample)
|
||||||
// - SeedDemoPurchaseEvaluationsAsync ([DEMO] PE 4 sample)
|
// - SeedDemoPurchaseEvaluationsAsync ([DEMO] PE 4 sample)
|
||||||
// - SeedSampleApprovalWorkflowsV2Async (V2 sample mẫu UAT cho type B)
|
// - SeedSampleApprovalWorkflowsV2Async (V2 sample mẫu UAT cho type B)
|
||||||
|
// - SeedSampleContractWorkflowV2Async (V2 sample mẫu UAT cho Contract — Mig 32 Plan B Chunk A2)
|
||||||
// GIỮ: SeedRoles, SeedAdmin, SeedDepartments, SeedDemoUsers (30 user UAT),
|
// GIỮ: SeedRoles, SeedAdmin, SeedDepartments, SeedDemoUsers (30 user UAT),
|
||||||
// SeedMenuTree, SeedAdminPermissions, SeedDemoMasterData (Supplier/Project
|
// SeedMenuTree, SeedAdminPermissions, SeedDemoMasterData (Supplier/Project
|
||||||
// master), SeedContractTemplates (file template), SeedCatalogs, backfill
|
// master), SeedContractTemplates (file template), SeedCatalogs, backfill
|
||||||
@ -76,7 +77,7 @@ public static class DbInitializer
|
|||||||
var config = sp.GetRequiredService<IConfiguration>();
|
var config = sp.GetRequiredService<IConfiguration>();
|
||||||
var demoSeedDisabled = config.GetValue<bool>("DemoSeed:Disabled");
|
var demoSeedDisabled = config.GetValue<bool>("DemoSeed:Disabled");
|
||||||
if (demoSeedDisabled)
|
if (demoSeedDisabled)
|
||||||
logger.LogInformation("DemoSeed:Disabled=true — skip workflow + contracts + PE + sample V2 seed (Plan T S23 t10)");
|
logger.LogInformation("DemoSeed:Disabled=true — skip workflow + contracts + PE + sample V2 seed (Plan T S23 t10 + Plan B Chunk A2 Contract V2)");
|
||||||
|
|
||||||
await SeedRolesAsync(roleManager, logger);
|
await SeedRolesAsync(roleManager, logger);
|
||||||
// Phase 6 rebrand: rename user email @solutionerp.local → @solutions.com.vn
|
// Phase 6 rebrand: rename user email @solutionerp.local → @solutions.com.vn
|
||||||
@ -106,6 +107,7 @@ public static class DbInitializer
|
|||||||
await SeedDemoContractsAsync(db, userManager, codeGen, logger);
|
await SeedDemoContractsAsync(db, userManager, codeGen, logger);
|
||||||
await SeedDemoPurchaseEvaluationsAsync(db, userManager, logger);
|
await SeedDemoPurchaseEvaluationsAsync(db, userManager, logger);
|
||||||
await SeedSampleApprovalWorkflowsV2Async(db, userManager, logger);
|
await SeedSampleApprovalWorkflowsV2Async(db, userManager, logger);
|
||||||
|
await SeedSampleContractWorkflowV2Async(db, userManager, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
await WarnDefaultAdminPasswordAsync(userManager, logger);
|
||||||
@ -163,6 +165,58 @@ public static class DbInitializer
|
|||||||
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for DuyetNccPhuongAn: QT-DN-PA-V2-001 v01");
|
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for DuyetNccPhuongAn: QT-DN-PA-V2-001 v01");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Plan B S29 2026-05-22 Chunk A2] Seed sample workflow V2 cho ApplicableType=Contract,
|
||||||
|
// giúp UAT HĐ V2 nhanh không cần admin tạo qua Designer trước. Idempotent — skip
|
||||||
|
// nếu đã có ANY workflow Contract (admin đã tạo) HOẶC nếu thiếu user CCM seed.
|
||||||
|
// Mirror SeedSampleApprovalWorkflowsV2Async pattern (DuyetNccPhuongAn).
|
||||||
|
private static async Task SeedSampleContractWorkflowV2Async(
|
||||||
|
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
|
||||||
|
{
|
||||||
|
var hasAnyContract = await db.ApprovalWorkflows
|
||||||
|
.AnyAsync(w => w.ApplicableType == ApprovalWorkflowApplicableType.Contract);
|
||||||
|
if (hasAnyContract) return;
|
||||||
|
|
||||||
|
var approver = await userManager.FindByEmailAsync("binh.le@solutions.com.vn");
|
||||||
|
if (approver is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("SeedSampleContractWorkflowV2Async: skip — approver binh.le@solutions.com.vn (Lê Văn Bình CCM) not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ccmDept = await db.Departments.FirstOrDefaultAsync(d => d.Code == "CCM");
|
||||||
|
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Code = "QT-HD-V2-001",
|
||||||
|
Version = 1,
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||||
|
Name = "Quy trình duyệt HĐ mẫu UAT V2",
|
||||||
|
Description = "Sample seed cho UAT HĐ V2 — 1 Bước Phòng CCM × 1 Cấp (Lê Văn Bình). Admin có thể clone tạo version mới qua Designer.",
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true, // Mig 25 — user pick qua Workspace dropdown
|
||||||
|
ActivatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
ApprovalWorkflow = wf,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Bước 1 - Phòng CCM",
|
||||||
|
DepartmentId = ccmDept?.Id,
|
||||||
|
};
|
||||||
|
var level = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Step = step,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Cấp 1",
|
||||||
|
ApproverUserId = approver.Id,
|
||||||
|
};
|
||||||
|
wf.Steps.Add(step);
|
||||||
|
step.Levels.Add(level);
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded sample ApprovalWorkflow V2 for Contract: QT-HD-V2-001 v01");
|
||||||
|
}
|
||||||
|
|
||||||
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
|
// Seed 4 master catalogs với defaults cho user nhập liệu Details. Idempotent:
|
||||||
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
|
// skip per-table nếu đã có row (admin có thể đã thêm/sửa — không clobber).
|
||||||
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
|
private static async Task SeedCatalogsAsync(ApplicationDbContext db, ILogger logger)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddApprovalWorkflowToContract : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ApprovalWorkflowId",
|
||||||
|
table: "Contracts",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CurrentApprovalLevelOrder",
|
||||||
|
table: "Contracts",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Contracts_ApprovalWorkflowId",
|
||||||
|
table: "Contracts",
|
||||||
|
column: "ApprovalWorkflowId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Contracts_ApprovalWorkflows_ApprovalWorkflowId",
|
||||||
|
table: "Contracts",
|
||||||
|
column: "ApprovalWorkflowId",
|
||||||
|
principalTable: "ApprovalWorkflows",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Contracts_ApprovalWorkflows_ApprovalWorkflowId",
|
||||||
|
table: "Contracts");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Contracts_ApprovalWorkflowId",
|
||||||
|
table: "Contracts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ApprovalWorkflowId",
|
||||||
|
table: "Contracts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CurrentApprovalLevelOrder",
|
||||||
|
table: "Contracts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 AddContractLevelOpinions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ContractLevelOpinions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ContractId = 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_ContractLevelOpinions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ContractLevelOpinions_ApprovalWorkflowLevels_ApprovalWorkflowLevelId",
|
||||||
|
column: x => x.ApprovalWorkflowLevelId,
|
||||||
|
principalTable: "ApprovalWorkflowLevels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ContractLevelOpinions_Contracts_ContractId",
|
||||||
|
column: x => x.ContractId,
|
||||||
|
principalTable: "Contracts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ContractLevelOpinions_ApprovalWorkflowLevelId",
|
||||||
|
table: "ContractLevelOpinions",
|
||||||
|
column: "ApprovalWorkflowLevelId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ContractLevelOpinions_ContractId_ApprovalWorkflowLevelId",
|
||||||
|
table: "ContractLevelOpinions",
|
||||||
|
columns: new[] { "ContractId", "ApprovalWorkflowLevelId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ContractLevelOpinions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -637,6 +637,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ApprovalWorkflowId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<Guid?>("BudgetId")
|
b.Property<Guid?>("BudgetId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
@ -657,6 +660,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid?>("CreatedBy")
|
b.Property<Guid?>("CreatedBy")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int?>("CurrentApprovalLevelOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("CurrentWorkflowStepIndex")
|
b.Property<int?>("CurrentWorkflowStepIndex")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@ -732,6 +738,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ApprovalWorkflowId");
|
||||||
|
|
||||||
b.HasIndex("BudgetId");
|
b.HasIndex("BudgetId");
|
||||||
|
|
||||||
b.HasIndex("MaHopDong")
|
b.HasIndex("MaHopDong")
|
||||||
@ -1036,6 +1044,64 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("ContractDepartmentApprovals", (string)null);
|
b.ToTable("ContractDepartmentApprovals", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", 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<Guid>("ContractId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
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<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("ContractId", "ApprovalWorkflowLevelId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ContractLevelOpinions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@ -3479,6 +3545,14 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Budget");
|
b.Navigation("Budget");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApprovalWorkflowId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
|
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
|
||||||
@ -3534,6 +3608,25 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Contract");
|
b.Navigation("Contract");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ApprovalWorkflowLevelId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
|
||||||
|
.WithMany("LevelOpinions")
|
||||||
|
.HasForeignKey("ContractId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Contract");
|
||||||
|
|
||||||
|
b.Navigation("Level");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
|
modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
|
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
|
||||||
@ -3866,6 +3959,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.Navigation("GiaoKhoanDetails");
|
b.Navigation("GiaoKhoanDetails");
|
||||||
|
|
||||||
|
b.Navigation("LevelOpinions");
|
||||||
|
|
||||||
b.Navigation("MuaBanDetails");
|
b.Navigation("MuaBanDetails");
|
||||||
|
|
||||||
b.Navigation("NguyenTacDvDetails");
|
b.Navigation("NguyenTacDvDetails");
|
||||||
|
|||||||
@ -38,6 +38,7 @@ public class ContractWorkflowService(
|
|||||||
IReadOnlyList<string> actorRoles,
|
IReadOnlyList<string> actorRoles,
|
||||||
ApprovalDecision decision,
|
ApprovalDecision decision,
|
||||||
string? comment,
|
string? comment,
|
||||||
|
bool skipToFinal = false,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var fromPhase = contract.Phase;
|
var fromPhase = contract.Phase;
|
||||||
@ -78,6 +79,9 @@ public class ContractWorkflowService(
|
|||||||
}
|
}
|
||||||
contract.Phase = ContractPhase.ChoDuyet;
|
contract.Phase = ContractPhase.ChoDuyet;
|
||||||
contract.CurrentWorkflowStepIndex = 0;
|
contract.CurrentWorkflowStepIndex = 0;
|
||||||
|
// [Plan B S29 2026-05-22] V2 pointer init — mirror PE line 153.
|
||||||
|
// Chỉ init levelOrder=1 nếu pin schema V2 (ApprovalWorkflowId set).
|
||||||
|
contract.CurrentApprovalLevelOrder = contract.ApprovalWorkflowId is not null ? 1 : null;
|
||||||
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||||
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
await LogTransitionAsync(contract, fromPhase, ContractPhase.ChoDuyet, actorUserId, decision, comment, ct);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
@ -87,6 +91,20 @@ public class ContractWorkflowService(
|
|||||||
// ===== APPROVE STEP =====
|
// ===== APPROVE STEP =====
|
||||||
if (fromPhase == ContractPhase.ChoDuyet && decision == ApprovalDecision.Approve)
|
if (fromPhase == ContractPhase.ChoDuyet && decision == ApprovalDecision.Approve)
|
||||||
{
|
{
|
||||||
|
// [Plan B S29 2026-05-22] Branch V2 schema mới (ApprovalWorkflowId pin)
|
||||||
|
// vs V1 legacy (WorkflowDefinitionId pin Mig 21). Mirror PE
|
||||||
|
// PurchaseEvaluationWorkflowService.cs line 161-180 pattern.
|
||||||
|
// V1 legacy giữ behavior cũ — 7 prod contract chạy nhánh này.
|
||||||
|
if (contract.ApprovalWorkflowId is Guid awId)
|
||||||
|
{
|
||||||
|
await ApproveV2Async(contract, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (skipToFinal && !isAdmin && !isSystem)
|
||||||
|
throw new ConflictException(
|
||||||
|
"skipToFinal chỉ hỗ trợ HĐ V2 (ApprovalWorkflowsV2). HĐ V1 legacy không có per-Approver-slot flag.");
|
||||||
|
|
||||||
var def = contract.WorkflowDefinitionId is Guid wfId
|
var def = contract.WorkflowDefinitionId is Guid wfId
|
||||||
? await db.WorkflowDefinitions.AsNoTracking()
|
? await db.WorkflowDefinitions.AsNoTracking()
|
||||||
.Include(d => d.Steps.OrderBy(s => s.Order))
|
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||||
@ -183,6 +201,198 @@ public class ContractWorkflowService(
|
|||||||
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
|
throw new ConflictException($"Transition {fromPhase} → {targetPhase} không hỗ trợ.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== V2 APPROVE (Mig 32+33 — Plan B S29 2026-05-22) =====
|
||||||
|
// Mirror PurchaseEvaluationWorkflowService.cs:ApproveV2Async (line 446-634).
|
||||||
|
// Khác PE: terminal hoàn tất → gen mã HĐ + Phase=DaPhatHanh (PE chỉ
|
||||||
|
// Phase=DaDuyet, không gen mã). V1 legacy giữ behavior cũ.
|
||||||
|
//
|
||||||
|
// skipToFinal F2 (Mig 31 Plan K S23): Approver tick "Duyệt thẳng Cấp cuối"
|
||||||
|
// + admin opt-in per slot tại matchingLevel.AllowApproverSkipToFinal → bỏ
|
||||||
|
// qua mọi Bước/Cấp trung gian, advance pointer tới Bước cuối + Cấp cuối.
|
||||||
|
// Phase giữ ChoDuyet — NV cuối vẫn duyệt thật để tiến DaPhatHanh.
|
||||||
|
//
|
||||||
|
// TODO Chunk C: UPSERT ContractLevelOpinion (table chưa tồn tại — Mig 33
|
||||||
|
// sẽ scaffold + entity + EF config). Sau Chunk C done, em main add block
|
||||||
|
// UPSERT mirror PE line 512-546.
|
||||||
|
private async Task ApproveV2Async(
|
||||||
|
Contract contract,
|
||||||
|
Guid awId,
|
||||||
|
Guid? actorUserId,
|
||||||
|
IReadOnlyList<string> actorRoles,
|
||||||
|
bool isAdmin,
|
||||||
|
bool isSystem,
|
||||||
|
string? comment,
|
||||||
|
bool skipToFinal,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var aw = await db.ApprovalWorkflows.AsNoTracking()
|
||||||
|
.Include(w => w.Steps.OrderBy(s => s.Order))
|
||||||
|
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == awId, ct)
|
||||||
|
?? throw new ConflictException($"ApprovalWorkflow {awId} không tồn tại.");
|
||||||
|
|
||||||
|
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
|
||||||
|
if (steps.Count == 0)
|
||||||
|
throw new ConflictException("Quy trình chưa có bước nào.");
|
||||||
|
|
||||||
|
var currentIdx = contract.CurrentWorkflowStepIndex ?? 0;
|
||||||
|
if (currentIdx < 0 || currentIdx >= steps.Count)
|
||||||
|
throw new ConflictException($"CurrentWorkflowStepIndex={currentIdx} không hợp lệ (max={steps.Count - 1}).");
|
||||||
|
|
||||||
|
var currentLevelOrder = contract.CurrentApprovalLevelOrder ?? 1;
|
||||||
|
var currentStep = steps[currentIdx];
|
||||||
|
|
||||||
|
// Group levels by Order = Cấp. Mỗi Cấp có N approvers (OR-of-N).
|
||||||
|
var levelGroups = currentStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
|
||||||
|
var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
|
||||||
|
if (currentLevelOrder < 1 || currentLevelOrder > maxLevelOrder)
|
||||||
|
throw new ConflictException($"CurrentApprovalLevelOrder={currentLevelOrder} không hợp lệ (max={maxLevelOrder}).");
|
||||||
|
|
||||||
|
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
|
||||||
|
?? throw new ConflictException($"Bước {currentIdx + 1} không có cấp {currentLevelOrder}.");
|
||||||
|
|
||||||
|
// Match approver: actor.Id ∈ pendingLevelGroup.ApproverUserId. Admin bypass.
|
||||||
|
if (!isAdmin && !isSystem)
|
||||||
|
{
|
||||||
|
if (actorUserId is null)
|
||||||
|
throw new ForbiddenException("Không xác định được approver.");
|
||||||
|
var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();
|
||||||
|
if (!allowedUserIds.Contains(actorUserId.Value))
|
||||||
|
{
|
||||||
|
var names = string.Join(", ", allowedUserIds);
|
||||||
|
throw new ForbiddenException(
|
||||||
|
$"Bước {currentIdx + 1} ({currentStep.Name}) — Cấp {currentLevelOrder}: bạn không có trong danh sách NV duyệt ({names}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log approval. Enrich comment với prefix "[Duyệt vượt cấp]" khi
|
||||||
|
// skipToFinal=true (mirror PE Plan AC S25 Bug 3b).
|
||||||
|
var skipPrefix = skipToFinal ? "[Duyệt vượt cấp tới Cấp cuối] " : "";
|
||||||
|
db.ContractApprovals.Add(new ContractApproval
|
||||||
|
{
|
||||||
|
ContractId = contract.Id,
|
||||||
|
FromPhase = contract.Phase,
|
||||||
|
ToPhase = contract.Phase,
|
||||||
|
ApproverUserId = actorUserId,
|
||||||
|
Decision = ApprovalDecision.Approve,
|
||||||
|
Comment = $"{skipPrefix}[Bước {currentIdx + 1} — Cấp {currentLevelOrder}] {comment ?? ""}".Trim(),
|
||||||
|
ApprovedAt = dateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
// [Plan B Chunk B2 S29 2026-05-22] UPSERT ContractLevelOpinion vào row
|
||||||
|
// Level chính chủ (mirror PE Mig 26 line 512-546). Section 5 FE render
|
||||||
|
// dynamic theo flow.steps[].levels[]. Comment khi duyệt auto sync sang
|
||||||
|
// Section 5 (read-only summary). Empty comment → "(duyệt — không ý kiến)"
|
||||||
|
// placeholder. 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.ContractLevelOpinions
|
||||||
|
.FirstOrDefaultAsync(o => o.ContractId == contract.Id
|
||||||
|
&& o.ApprovalWorkflowLevelId == matchingLevel.Id, ct);
|
||||||
|
var normalizedComment = string.IsNullOrWhiteSpace(comment)
|
||||||
|
? "(duyệt — không ý kiến)"
|
||||||
|
: comment.Trim();
|
||||||
|
if (existingOpinion is null)
|
||||||
|
{
|
||||||
|
db.ContractLevelOpinions.Add(new ContractLevelOpinion
|
||||||
|
{
|
||||||
|
ContractId = contract.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipToFinal F2 (Mig 31 Plan K S23) — Approver scope ChoDuyet skip
|
||||||
|
// thẳng Cấp cuối. Admin opt-in per slot tại AllowApproverSkipToFinal.
|
||||||
|
if (skipToFinal)
|
||||||
|
{
|
||||||
|
if (!isAdmin && !isSystem && !matchingLevel.AllowApproverSkipToFinal)
|
||||||
|
{
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Cấp Approver hiện tại (Bước {currentIdx + 1} Cấp {currentLevelOrder}) " +
|
||||||
|
"chưa được phép duyệt thẳng Cấp cuối. Admin phải tick checkbox " +
|
||||||
|
"'Duyệt thẳng Cấp cuối' trong Workflow Designer cho slot này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastStepIdx = steps.Count - 1;
|
||||||
|
var lastStep = steps[lastStepIdx];
|
||||||
|
var lastLevelGroups = lastStep.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
|
||||||
|
var lastLevelMaxOrder = lastLevelGroups.Count == 0 ? 1 : lastLevelGroups.Max(g => g.Key);
|
||||||
|
|
||||||
|
// Guard: actor đã ở Cấp cuối Bước cuối → fall through normal advance
|
||||||
|
// (sẽ hit branch nextIdx >= steps.Count → DaPhatHanh đúng).
|
||||||
|
if (!(currentIdx == lastStepIdx && currentLevelOrder == lastLevelMaxOrder))
|
||||||
|
{
|
||||||
|
contract.CurrentWorkflowStepIndex = lastStepIdx;
|
||||||
|
contract.CurrentApprovalLevelOrder = lastLevelMaxOrder;
|
||||||
|
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||||
|
await LogTransitionAsync(
|
||||||
|
contract,
|
||||||
|
ContractPhase.ChoDuyet,
|
||||||
|
ContractPhase.ChoDuyet,
|
||||||
|
actorUserId,
|
||||||
|
ApprovalDecision.Approve,
|
||||||
|
$"[Approver skip thẳng tới Bước {lastStepIdx + 1} Cấp {lastLevelMaxOrder} (NV cuối) — bỏ qua các Bước/Cấp trung gian] {comment ?? ""}".Trim(),
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
|
||||||
|
if (currentLevelOrder < maxLevelOrder)
|
||||||
|
{
|
||||||
|
contract.CurrentApprovalLevelOrder = currentLevelOrder + 1;
|
||||||
|
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||||
|
await LogTransitionAsync(contract, contract.Phase, contract.Phase, actorUserId, ApprovalDecision.Approve,
|
||||||
|
$"Hoàn tất Cấp {currentLevelOrder}, sang Cấp {currentLevelOrder + 1} cùng Bước {currentIdx + 1}", ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hết cấp trong Step — sang Step kế (Cấp 1)
|
||||||
|
var nextIdx = currentIdx + 1;
|
||||||
|
if (nextIdx >= steps.Count)
|
||||||
|
{
|
||||||
|
// All Steps done — terminal DaPhatHanh. Khác PE: phải gen mã HĐ
|
||||||
|
// theo RG-001 (mirror V1 line 148-155). FE sau khi nhận DaPhatHanh
|
||||||
|
// sẽ refresh hiển thị MaHopDong.
|
||||||
|
if (string.IsNullOrEmpty(contract.MaHopDong))
|
||||||
|
{
|
||||||
|
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
|
||||||
|
?? throw new NotFoundException("Supplier", contract.SupplierId);
|
||||||
|
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
|
||||||
|
?? throw new NotFoundException("Project", contract.ProjectId);
|
||||||
|
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
|
||||||
|
}
|
||||||
|
contract.Phase = ContractPhase.DaPhatHanh;
|
||||||
|
contract.CurrentWorkflowStepIndex = null;
|
||||||
|
contract.CurrentApprovalLevelOrder = null;
|
||||||
|
contract.SlaDeadline = null;
|
||||||
|
await LogTransitionAsync(contract, ContractPhase.ChoDuyet, ContractPhase.DaPhatHanh,
|
||||||
|
actorUserId, ApprovalDecision.Approve, comment, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
contract.CurrentWorkflowStepIndex = nextIdx;
|
||||||
|
contract.CurrentApprovalLevelOrder = 1;
|
||||||
|
contract.SlaDeadline = dateTime.UtcNow.AddDays(7);
|
||||||
|
await LogTransitionAsync(contract, contract.Phase, contract.Phase, actorUserId, ApprovalDecision.Approve,
|
||||||
|
$"Hoàn tất Bước {currentIdx + 1}/{steps.Count}, sang Bước {nextIdx + 1} (Cấp 1)", ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LogTransitionAsync(
|
private async Task LogTransitionAsync(
|
||||||
Contract contract,
|
Contract contract,
|
||||||
ContractPhase fromPhase,
|
ContractPhase fromPhase,
|
||||||
@ -216,4 +426,18 @@ public class ContractWorkflowService(
|
|||||||
ct: ct);
|
ct: ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Plan B Chunk B2 S29 2026-05-22] Resolve actor full name for
|
||||||
|
// ContractLevelOpinion.SignedByFullName denormalized field (Section 5 FE
|
||||||
|
// display). Mirror PE PurchaseEvaluationWorkflowService.cs:774-783.
|
||||||
|
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