[CLAUDE] App+Infra+Api+FE-User+FE-Admin+Tests: W3 KHKK — duyet 3 tram OR-of-N + finalize chot gia (test-before 6 RED->GREEN)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m54s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m54s
- ContractSigningPlanWorkflowService: copy khuon ApproveV2Async (con-tro doi StepIndex+LevelOrder, GroupBy Order, OR-of-N, LevelOpinions UPSERT "(duyet — khong y kien)") — CAM Proposal-flatten; KHONG port 2 duong ket-thuc-som (owner phan PHA VO S155) - Choke-point finalize ApplyApprovedValuesOnFinalize (Line.ApprovedAmount ??= Proposed) 1-SITE-BY-DESIGN: hop-dong transitions khong co targetPhase => admin cung xuyen choke-point (triet lop #81 tu goc); RULE-comment khuon :356-360 - Transitions endpoint: submit/approve/return/reject (hop-dong LEAD-dinh-nghia literal — reviewer Truc-4 KHOP 5/5, truc W2 tung dut 6 diem); guard trinh CreatedBy|Drafter|DeptManager-cung-phong|Admin; changelog/approvals bang KHKK rieng (CAM IChangelogService — FK-547 rao bang vang-mat-dep); notify Cap-ke dich danh + exclusion actor/drafter (S86) - Save-diem: 1 SaveChanges cuoi = 1 transition 1 unit-of-work (service la diem vao, khac nguon :537) - FE KhkkWorkflowPanel x2 app SHA-pair: so-do Buoc/Cap + banner den-luot + Gui/Duyet/Tra/Tu-choi + y kien; panel GATE nut theo actor (khong de an 403) - Test-before: 6 test san PIN RED-evidence 6/6 (5 NotImplemented + 1 assert) -> GREEN 6/6; sua-test dung 1 seed FAULT-INJECT sot (0 assert doi); suite 574 -> 580/0 - Reviewer 7/7 truc DAT 0 chan (4 truc vai + 3 truc lead on-behalf; Q1-Q6 duyet) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -6,7 +6,8 @@
|
|||||||
// per-NCC trúng thầu (READ-ONLY ở W2 — `PeReferenceAmount` là SNAPSHOT lúc lập kế
|
// per-NCC trúng thầu (READ-ONLY ở W2 — `PeReferenceAmount` là SNAPSHOT lúc lập kế
|
||||||
// hoạch) · (3) Căn cứ hồ sơ b.8-9 (CRUD chỉ mở ở DangSoanThao|TraLai) · (4) File
|
// hoạch) · (3) Căn cứ hồ sơ b.8-9 (CRUD chỉ mở ở DangSoanThao|TraLai) · (4) File
|
||||||
// đính kèm (mở MỌI phase — triết lý PE S147).
|
// đính kèm (mở MỌI phase — triết lý PE S147).
|
||||||
// 🔴 CỐ Ý KHÔNG có panel trình/duyệt + banner cấp duyệt: transitions = W3.
|
// [W3 S161] +panel duyệt `KhkkWorkflowPanel` NGAY DƯỚI header (trên "Thông tin kế hoạch"):
|
||||||
|
// sơ đồ Bước→Cấp + banner đến-lượt + Gửi duyệt / Duyệt / Trả lại / Từ chối.
|
||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
@ -31,6 +32,7 @@ import {
|
|||||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||||
type UpsertKhkkDossierItemInput,
|
type UpsertKhkkDossierItemInput,
|
||||||
} from '@/types/khkk'
|
} from '@/types/khkk'
|
||||||
|
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
|
||||||
|
|
||||||
function formatVnd(n: number | null): string {
|
function formatVnd(n: number | null): string {
|
||||||
if (n === null || n === undefined) return '—'
|
if (n === null || n === undefined) return '—'
|
||||||
@ -316,6 +318,11 @@ export function KhkkDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* [W3 S161] Panel duyệt — đặt NGAY dưới header để người duyệt thấy việc-phải-làm
|
||||||
|
trước nội dung phiếu (mirror thứ tự PE: workflow panel là thứ đầu tiên trong
|
||||||
|
màn Duyệt). `onChanged` dùng `invalidate` của trang để refetch theo id URL. */}
|
||||||
|
<KhkkWorkflowPanel plan={k} onChanged={invalidate} />
|
||||||
|
|
||||||
{/* Section 1: Thông tin */}
|
{/* Section 1: Thông tin */}
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Thông tin kế hoạch"
|
title="Thông tin kế hoạch"
|
||||||
|
|||||||
423
fe-admin/src/pages/khkk/KhkkWorkflowPanel.tsx
Normal file
423
fe-admin/src/pages/khkk/KhkkWorkflowPanel.tsx
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
// [W3 KHKK — S161 2026-07-29] Panel duyệt 3 trạm (PMH → CCM → CEO) cho Kế hoạch ký kết HĐ.
|
||||||
|
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||||
|
//
|
||||||
|
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
|
||||||
|
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
|
||||||
|
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
|
||||||
|
// KHÔNG 4 return-mode (W3 = TraLai toàn phần), KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
|
||||||
|
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là
|
||||||
|
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này.
|
||||||
|
//
|
||||||
|
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
|
||||||
|
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.
|
||||||
|
// Panel này TỰ SUY trạng thái từ cặp con-trỏ:
|
||||||
|
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) — KHÔNG phải Order
|
||||||
|
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
|
||||||
|
// (BE tự khai ngữ nghĩa này ở `ContractSigningPlanFeatures.cs:224-226`; luật so khớp OR-of-N canonical
|
||||||
|
// `:259-260` = `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror Y NGUYÊN
|
||||||
|
// bên dưới, dùng `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.)
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Check, MessageSquare, Send, Undo2, Workflow, X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { Label } from '@/components/ui/Label'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import {
|
||||||
|
KHKK_PHASE_LABELS, KhkkPhase, KhkkTransitionAction,
|
||||||
|
type KhkkDetailDto, type KhkkPhaseValue, type KhkkTransitionActionValue,
|
||||||
|
type KhkkTransitionInput, type KhkkTransitionResult, type KhkkWorkflowLevelDto,
|
||||||
|
} from '@/types/khkk'
|
||||||
|
|
||||||
|
type NodeStatus = 'Done' | 'Current' | 'Pending'
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('vi-VN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
|
||||||
|
const STATUS_DOT: Record<NodeStatus, string> = {
|
||||||
|
Done: 'bg-emerald-500 text-white',
|
||||||
|
Current: 'bg-brand-600 text-white',
|
||||||
|
Pending: 'bg-slate-200 text-slate-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BOX: Record<NodeStatus, string> = {
|
||||||
|
Done: 'border-emerald-200 bg-emerald-50/40',
|
||||||
|
Current: 'border-brand-300 bg-brand-50/50',
|
||||||
|
Pending: 'border-slate-200 bg-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<NodeStatus, string> = {
|
||||||
|
Done: '✓',
|
||||||
|
Current: '●',
|
||||||
|
Pending: '○',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KhkkWorkflowPanel({
|
||||||
|
plan,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
plan: KhkkDetailDto
|
||||||
|
/** gọi sau khi transition thành công — caller refetch detail/list theo id URL của nó. */
|
||||||
|
onChanged?: () => void
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
|
||||||
|
const phase = plan.phase as KhkkPhaseValue
|
||||||
|
const steps = plan.workflowSteps ?? []
|
||||||
|
const opinions = plan.levelOpinions ?? []
|
||||||
|
|
||||||
|
const isWaiting = phase === KhkkPhase.ChoDuyet
|
||||||
|
const isApproved = phase === KhkkPhase.DaDuyet
|
||||||
|
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
||||||
|
|
||||||
|
const curStepIdx = plan.currentWorkflowStepIndex
|
||||||
|
const curLevelOrder = plan.currentApprovalLevelOrder
|
||||||
|
|
||||||
|
// Suy trạng thái Bước: chỉ có nghĩa khi phiếu ĐANG chờ duyệt. DaDuyet ⇒ tất cả Done;
|
||||||
|
// Nháp/TraLai/TuChoi ⇒ tất cả Pending (quy trình chưa chạy / đã dừng).
|
||||||
|
const stepStatus = (idx: number): NodeStatus => {
|
||||||
|
if (isApproved) return 'Done'
|
||||||
|
if (!isWaiting || curStepIdx == null) return 'Pending'
|
||||||
|
if (idx < curStepIdx) return 'Done'
|
||||||
|
if (idx === curStepIdx) return 'Current'
|
||||||
|
return 'Pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelStatus = (idx: number, order: number): NodeStatus => {
|
||||||
|
const s = stepStatus(idx)
|
||||||
|
if (s !== 'Current' || curLevelOrder == null) return s
|
||||||
|
if (order < curLevelOrder) return 'Done'
|
||||||
|
if (order === curLevelOrder) return 'Current'
|
||||||
|
return 'Pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này —
|
||||||
|
// 1 người ký là phiếu tiến (mirror BE `:259-260` `.Any(...)`). KHÔNG `.find()` một người.
|
||||||
|
const currentLevels: KhkkWorkflowLevelDto[] =
|
||||||
|
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
|
||||||
|
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
|
||||||
|
: []
|
||||||
|
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
|
||||||
|
? steps[curStepIdx]
|
||||||
|
: null
|
||||||
|
|
||||||
|
const isAdmin = user?.roles?.includes('Admin') ?? false
|
||||||
|
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
|
||||||
|
// Admin bypass — mirror `PeWorkflowPanel.tsx:110-114` (`actorInV2Level` có `isAdmin ||`).
|
||||||
|
const actorInLevel = isAdmin || actorIsCurrentApprover
|
||||||
|
const blockedByLevel = isWaiting && !actorInLevel
|
||||||
|
|
||||||
|
// Nút "Gửi duyệt": guard BE là `CreatedBy == actor ∨ DeptManager(cùng phòng) ∨ Admin`
|
||||||
|
// (spec W3 §②-5). FE KHÔNG suy được vế DeptManager — `UserInfo` chỉ có {id,email,fullName,roles}
|
||||||
|
// (`types/auth.ts:1-6`), không có phòng ban. CỐ Ý **không ẩn** nút theo vai: ẩn sẽ giấu nút với
|
||||||
|
// đúng người có quyền, im lặng, không lỗi (bug-class gotcha #44 — đã đốt 1 lần ở PE S155).
|
||||||
|
// Rào THẬT nằm ở BE (403 → toast). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
|
||||||
|
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
|
||||||
|
|
||||||
|
const transition = useMutation({
|
||||||
|
mutationFn: async (a: KhkkTransitionActionValue) => {
|
||||||
|
// 🔴 BODY LITERAL theo hợp-đồng LEAD chốt: đúng 2 field `action` + `comment`.
|
||||||
|
const body: KhkkTransitionInput = { action: a, comment: comment.trim() || null }
|
||||||
|
const res = await api.post<KhkkTransitionResult>(
|
||||||
|
`/contract-signing-plans/${plan.id}/transitions`,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
|
||||||
|
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
|
||||||
|
setAction(null)
|
||||||
|
setComment('')
|
||||||
|
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['khkk-list'] })
|
||||||
|
onChanged?.()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogTitle =
|
||||||
|
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
|
||||||
|
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
|
||||||
|
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
|
||||||
|
: '✗ Từ chối kế hoạch ký kết'
|
||||||
|
// Trả lại / Từ chối bắt buộc nêu lý do (ghi vào ý kiến cấp duyệt + changelog BE).
|
||||||
|
const commentRequired =
|
||||||
|
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
|
||||||
|
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
|
||||||
|
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||||
|
<span
|
||||||
|
className="icon-chip"
|
||||||
|
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Workflow className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
|
||||||
|
{plan.workflowCode && (
|
||||||
|
<span className="font-mono text-[11px] text-slate-500">
|
||||||
|
{plan.workflowCode}
|
||||||
|
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */}
|
||||||
|
{steps.length > 0 ? (
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{steps.map((step, idx) => {
|
||||||
|
const st = stepStatus(idx)
|
||||||
|
return (
|
||||||
|
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium">
|
||||||
|
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
|
||||||
|
{STATUS_ICON[st]}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-800">Bước {step.order} — {step.name}</span>
|
||||||
|
</div>
|
||||||
|
{step.levels.length > 0 && (
|
||||||
|
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
|
||||||
|
{step.levels.map(lv => {
|
||||||
|
const ls = levelStatus(idx, lv.order)
|
||||||
|
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
|
||||||
|
return (
|
||||||
|
<li key={lv.id} className="text-[11px]">
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
|
||||||
|
{STATUS_ICON[ls]}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium text-slate-700">
|
||||||
|
{lv.name || `Cấp ${lv.order}`}
|
||||||
|
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
|
||||||
|
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500">
|
||||||
|
{lv.approverFullName ?? '(chưa cấu hình)'}
|
||||||
|
{signed && (
|
||||||
|
<span className="ml-1.5 text-[10px] text-emerald-600">
|
||||||
|
✓ ký {formatDateTime(signed.signedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
|
||||||
|
Quy trình duyệt chưa cấu hình Bước/Cấp — liên hệ Admin để dựng quy trình loại
|
||||||
|
“Kế hoạch ký kết HĐ” trước khi trình duyệt.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. Chỉ có nghĩa khi ĐANG chờ duyệt. */}
|
||||||
|
{isWaiting && currentStep && (
|
||||||
|
<div className={cn(
|
||||||
|
'rounded border px-3 py-2 text-[11px]',
|
||||||
|
actorInLevel
|
||||||
|
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||||
|
: 'border-amber-300 bg-amber-50 text-amber-800',
|
||||||
|
)}>
|
||||||
|
<div className="font-medium">
|
||||||
|
Đang chờ Bước {currentStep.order} ({currentStep.name}) — Cấp {curLevelOrder}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
|
||||||
|
</div>
|
||||||
|
{actorInLevel
|
||||||
|
? <div className="mt-0.5 font-medium">✓ Đến lượt bạn duyệt</div>
|
||||||
|
: <div className="mt-0.5">⚠ Không phải lượt bạn — chỉ người trên mới thao tác cấp này</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === KhkkPhase.TraLai && (
|
||||||
|
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
|
||||||
|
⚠ Kế hoạch bị TRẢ LẠI — sửa nội dung rồi bấm “Gửi duyệt”, quy trình chạy lại từ Bước 1 · Cấp 1.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{phase === KhkkPhase.TuChoi && (
|
||||||
|
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||||
|
✗ Kế hoạch đã bị TỪ CHỐI — không thao tác được nữa. Lập kế hoạch mới nếu cần làm lại.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hành động */}
|
||||||
|
{(canSubmit || isWaiting) && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Hành động:</Label>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{canSubmit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setComment(''); setAction(KhkkTransitionAction.Submit) }}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
|
||||||
|
>
|
||||||
|
<Send className="h-3 w-3" /> Gửi duyệt
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isDraftLike && !plan.approvalWorkflowId && (
|
||||||
|
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
|
||||||
|
Chưa pin quy trình duyệt — không gửi duyệt được.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isWaiting && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={blockedByLevel}
|
||||||
|
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Approve) } }}
|
||||||
|
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||||
|
blockedByLevel
|
||||||
|
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||||
|
: 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" /> Duyệt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={blockedByLevel}
|
||||||
|
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Return) } }}
|
||||||
|
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||||
|
blockedByLevel
|
||||||
|
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||||
|
: 'border-amber-300 text-amber-700 hover:bg-amber-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Undo2 className="h-3 w-3" /> Trả lại
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={blockedByLevel}
|
||||||
|
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Reject) } }}
|
||||||
|
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||||
|
blockedByLevel
|
||||||
|
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||||
|
: 'border-red-300 text-red-700 hover:bg-red-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" /> Từ chối
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */}
|
||||||
|
{opinions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5 text-violet-500" />
|
||||||
|
Ý kiến cấp duyệt
|
||||||
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||||
|
{opinions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1.5 space-y-1.5">
|
||||||
|
{opinions.map(o => (
|
||||||
|
<li key={o.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
|
||||||
|
<span className="font-medium">{o.signedByFullName}</span>
|
||||||
|
<span className="text-slate-400">·</span>
|
||||||
|
<span>
|
||||||
|
{o.stepName ? `Bước ${o.stepOrder} — ${o.stepName}` : 'Cấp duyệt'}
|
||||||
|
{o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">·</span>
|
||||||
|
<span className="text-slate-500">{formatDateTime(o.signedAt)}</span>
|
||||||
|
{/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
|
||||||
|
{o.approverUserId && o.approverUserId !== o.signedByUserId && (
|
||||||
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
|
||||||
|
duyệt thay
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
|
||||||
|
{o.comment?.trim() || '(duyệt — không ý kiến)'}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action !== null && (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onClose={() => setAction(null)}
|
||||||
|
title={dialogTitle}
|
||||||
|
footer={<>
|
||||||
|
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
|
||||||
|
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
|
||||||
|
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
{action === KhkkTransitionAction.Submit && (
|
||||||
|
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
|
||||||
|
Kế hoạch chuyển sang “Chờ duyệt” và trình lên Bước 1 · Cấp 1 của quy trình
|
||||||
|
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa được nữa.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{action === KhkkTransitionAction.Return && (
|
||||||
|
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||||
|
Kế hoạch về trạng thái “Trả lại”. Người soạn sửa xong gửi lại thì quy trình chạy
|
||||||
|
từ Bước 1 · Cấp 1.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{action === KhkkTransitionAction.Reject && (
|
||||||
|
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||||
|
⚠ Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Label>
|
||||||
|
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={e => setComment(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
|
||||||
|
/>
|
||||||
|
{action === KhkkTransitionAction.Approve && (
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
Ý kiến sẽ được ghi vào mục “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||||||
|
thì ghi đè ô cũ).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -20,7 +20,10 @@
|
|||||||
// POST /contract-signing-plans/{id}/attachments multipart { file, purpose, dossierItemId? }
|
// POST /contract-signing-plans/{id}/attachments multipart { file, purpose, dossierItemId? }
|
||||||
// GET /contract-signing-plans/{id}/attachments/{attId}/download
|
// GET /contract-signing-plans/{id}/attachments/{attId}/download
|
||||||
// DELETE /contract-signing-plans/{id}/attachments/{attId}
|
// DELETE /contract-signing-plans/{id}/attachments/{attId}
|
||||||
// (transitions submit/approve/return/reject = W3, CỐ Ý chưa có ở đây.)
|
//
|
||||||
|
// Hợp đồng FE ↔ BE lượt W3 (LEAD chốt — 2 lane implement đúng LITERAL từng tên field):
|
||||||
|
// POST /contract-signing-plans/{id}/transitions KhkkTransitionInput → KhkkTransitionResult
|
||||||
|
// policy `KeHoachKyKet.Update`
|
||||||
|
|
||||||
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
|
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
|
||||||
export const KhkkPhase = {
|
export const KhkkPhase = {
|
||||||
@ -161,12 +164,36 @@ export interface KhkkLevelOpinionDto {
|
|||||||
stepOrder: number | null
|
stepOrder: number | null
|
||||||
stepName: string | null
|
stepName: string | null
|
||||||
levelOrder: number | null
|
levelOrder: number | null
|
||||||
|
// [W3 S161] BE `ContractSigningPlanLevelOpinionDto:99` CÓ field này (W2 khai thiếu).
|
||||||
|
// `signedByUserId !== approverUserId` ⇒ có người duyệt THAY (Admin) — panel gắn nhãn.
|
||||||
|
approverUserId: string | null
|
||||||
comment: string | null
|
comment: string | null
|
||||||
signedAt: string
|
signedAt: string
|
||||||
signedByUserId: string
|
signedByUserId: string
|
||||||
signedByFullName: string
|
signedByFullName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [W3 S161] Cây quy trình THÔ do BE trả (`ContractSigningPlanWorkflowLevelDto:105-110`).
|
||||||
|
// 🔴 KHÁC PE: BE **KHÔNG** precompute `status` Done/Current/Pending — FE tự suy từ cặp
|
||||||
|
// con-trỏ `currentWorkflowStepIndex` (INDEX 0-based vào mảng steps) + `currentApprovalLevelOrder`
|
||||||
|
// (GIÁ TRỊ `level.order`). Xem `ContractSigningPlanFeatures.cs:224-226` + `:259-260`.
|
||||||
|
export interface KhkkWorkflowLevelDto {
|
||||||
|
id: string
|
||||||
|
order: number
|
||||||
|
name: string | null
|
||||||
|
approverUserId: string
|
||||||
|
approverFullName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
|
||||||
|
export interface KhkkWorkflowStepDto {
|
||||||
|
id: string
|
||||||
|
order: number
|
||||||
|
name: string
|
||||||
|
departmentId: string | null
|
||||||
|
levels: KhkkWorkflowLevelDto[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface KhkkDetailDto extends KhkkListItemDto {
|
export interface KhkkDetailDto extends KhkkListItemDto {
|
||||||
workflowName: string | null
|
workflowName: string | null
|
||||||
currentWorkflowStepIndex: number | null
|
currentWorkflowStepIndex: number | null
|
||||||
@ -177,6 +204,8 @@ export interface KhkkDetailDto extends KhkkListItemDto {
|
|||||||
dossierItems: KhkkDossierItemDto[]
|
dossierItems: KhkkDossierItemDto[]
|
||||||
attachments: KhkkAttachmentDto[]
|
attachments: KhkkAttachmentDto[]
|
||||||
levelOpinions: KhkkLevelOpinionDto[]
|
levelOpinions: KhkkLevelOpinionDto[]
|
||||||
|
// `ContractSigningPlanDetailDto:147` — field CUỐI của record BE.
|
||||||
|
workflowSteps: KhkkWorkflowStepDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
|
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
|
||||||
@ -218,6 +247,34 @@ export interface UpsertKhkkDossierItemInput {
|
|||||||
note?: string | null
|
note?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== W3 — chuyển trạng thái (duyệt 3 trạm) =========================
|
||||||
|
// 🔴 HỢP-ĐỒNG LITERAL do LEAD chốt (S161 W3). Tên field ở đây = tên field BE bind.
|
||||||
|
// Đổi 1 chữ ở đây mà không đổi BE = đứt im lặng (bài W2 6-điểm-đứt).
|
||||||
|
// submit = trình duyệt (DangSoanThao | TraLai → ChoDuyet)
|
||||||
|
// approve = duyệt cấp hiện tại (OR-of-N; comment ≠ rỗng ⇒ UPSERT LevelOpinion)
|
||||||
|
// return = trả lại (→ TraLai)
|
||||||
|
// reject = từ chối (→ TuChoi)
|
||||||
|
export const KhkkTransitionAction = {
|
||||||
|
Submit: 'submit',
|
||||||
|
Approve: 'approve',
|
||||||
|
Return: 'return',
|
||||||
|
Reject: 'reject',
|
||||||
|
} as const
|
||||||
|
export type KhkkTransitionActionValue =
|
||||||
|
(typeof KhkkTransitionAction)[keyof typeof KhkkTransitionAction]
|
||||||
|
|
||||||
|
export interface KhkkTransitionInput {
|
||||||
|
action: KhkkTransitionActionValue
|
||||||
|
comment: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200 — 3 field, ĐÚNG tên hợp-đồng. `phase` là INT (mirror `KhkkPhase`).
|
||||||
|
export interface KhkkTransitionResult {
|
||||||
|
phase: number
|
||||||
|
currentWorkflowStepIndex: number | null
|
||||||
|
currentApprovalLevelOrder: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface PagedResult<T> {
|
export interface PagedResult<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
// per-NCC trúng thầu (READ-ONLY ở W2 — `PeReferenceAmount` là SNAPSHOT lúc lập kế
|
// per-NCC trúng thầu (READ-ONLY ở W2 — `PeReferenceAmount` là SNAPSHOT lúc lập kế
|
||||||
// hoạch) · (3) Căn cứ hồ sơ b.8-9 (CRUD chỉ mở ở DangSoanThao|TraLai) · (4) File
|
// hoạch) · (3) Căn cứ hồ sơ b.8-9 (CRUD chỉ mở ở DangSoanThao|TraLai) · (4) File
|
||||||
// đính kèm (mở MỌI phase — triết lý PE S147).
|
// đính kèm (mở MỌI phase — triết lý PE S147).
|
||||||
// 🔴 CỐ Ý KHÔNG có panel trình/duyệt + banner cấp duyệt: transitions = W3.
|
// [W3 S161] +panel duyệt `KhkkWorkflowPanel` NGAY DƯỚI header (trên "Thông tin kế hoạch"):
|
||||||
|
// sơ đồ Bước→Cấp + banner đến-lượt + Gửi duyệt / Duyệt / Trả lại / Từ chối.
|
||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
@ -31,6 +32,7 @@ import {
|
|||||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||||
type UpsertKhkkDossierItemInput,
|
type UpsertKhkkDossierItemInput,
|
||||||
} from '@/types/khkk'
|
} from '@/types/khkk'
|
||||||
|
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
|
||||||
|
|
||||||
function formatVnd(n: number | null): string {
|
function formatVnd(n: number | null): string {
|
||||||
if (n === null || n === undefined) return '—'
|
if (n === null || n === undefined) return '—'
|
||||||
@ -316,6 +318,11 @@ export function KhkkDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* [W3 S161] Panel duyệt — đặt NGAY dưới header để người duyệt thấy việc-phải-làm
|
||||||
|
trước nội dung phiếu (mirror thứ tự PE: workflow panel là thứ đầu tiên trong
|
||||||
|
màn Duyệt). `onChanged` dùng `invalidate` của trang để refetch theo id URL. */}
|
||||||
|
<KhkkWorkflowPanel plan={k} onChanged={invalidate} />
|
||||||
|
|
||||||
{/* Section 1: Thông tin */}
|
{/* Section 1: Thông tin */}
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Thông tin kế hoạch"
|
title="Thông tin kế hoạch"
|
||||||
|
|||||||
423
fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx
Normal file
423
fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
// [W3 KHKK — S161 2026-07-29] Panel duyệt 3 trạm (PMH → CCM → CEO) cho Kế hoạch ký kết HĐ.
|
||||||
|
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||||
|
//
|
||||||
|
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
|
||||||
|
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
|
||||||
|
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
|
||||||
|
// KHÔNG 4 return-mode (W3 = TraLai toàn phần), KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
|
||||||
|
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là
|
||||||
|
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này.
|
||||||
|
//
|
||||||
|
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
|
||||||
|
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.
|
||||||
|
// Panel này TỰ SUY trạng thái từ cặp con-trỏ:
|
||||||
|
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) — KHÔNG phải Order
|
||||||
|
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
|
||||||
|
// (BE tự khai ngữ nghĩa này ở `ContractSigningPlanFeatures.cs:224-226`; luật so khớp OR-of-N canonical
|
||||||
|
// `:259-260` = `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror Y NGUYÊN
|
||||||
|
// bên dưới, dùng `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.)
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Check, MessageSquare, Send, Undo2, Workflow, X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
|
import { Label } from '@/components/ui/Label'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import {
|
||||||
|
KHKK_PHASE_LABELS, KhkkPhase, KhkkTransitionAction,
|
||||||
|
type KhkkDetailDto, type KhkkPhaseValue, type KhkkTransitionActionValue,
|
||||||
|
type KhkkTransitionInput, type KhkkTransitionResult, type KhkkWorkflowLevelDto,
|
||||||
|
} from '@/types/khkk'
|
||||||
|
|
||||||
|
type NodeStatus = 'Done' | 'Current' | 'Pending'
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('vi-VN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
|
||||||
|
const STATUS_DOT: Record<NodeStatus, string> = {
|
||||||
|
Done: 'bg-emerald-500 text-white',
|
||||||
|
Current: 'bg-brand-600 text-white',
|
||||||
|
Pending: 'bg-slate-200 text-slate-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BOX: Record<NodeStatus, string> = {
|
||||||
|
Done: 'border-emerald-200 bg-emerald-50/40',
|
||||||
|
Current: 'border-brand-300 bg-brand-50/50',
|
||||||
|
Pending: 'border-slate-200 bg-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<NodeStatus, string> = {
|
||||||
|
Done: '✓',
|
||||||
|
Current: '●',
|
||||||
|
Pending: '○',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KhkkWorkflowPanel({
|
||||||
|
plan,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
plan: KhkkDetailDto
|
||||||
|
/** gọi sau khi transition thành công — caller refetch detail/list theo id URL của nó. */
|
||||||
|
onChanged?: () => void
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
|
||||||
|
const phase = plan.phase as KhkkPhaseValue
|
||||||
|
const steps = plan.workflowSteps ?? []
|
||||||
|
const opinions = plan.levelOpinions ?? []
|
||||||
|
|
||||||
|
const isWaiting = phase === KhkkPhase.ChoDuyet
|
||||||
|
const isApproved = phase === KhkkPhase.DaDuyet
|
||||||
|
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
||||||
|
|
||||||
|
const curStepIdx = plan.currentWorkflowStepIndex
|
||||||
|
const curLevelOrder = plan.currentApprovalLevelOrder
|
||||||
|
|
||||||
|
// Suy trạng thái Bước: chỉ có nghĩa khi phiếu ĐANG chờ duyệt. DaDuyet ⇒ tất cả Done;
|
||||||
|
// Nháp/TraLai/TuChoi ⇒ tất cả Pending (quy trình chưa chạy / đã dừng).
|
||||||
|
const stepStatus = (idx: number): NodeStatus => {
|
||||||
|
if (isApproved) return 'Done'
|
||||||
|
if (!isWaiting || curStepIdx == null) return 'Pending'
|
||||||
|
if (idx < curStepIdx) return 'Done'
|
||||||
|
if (idx === curStepIdx) return 'Current'
|
||||||
|
return 'Pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelStatus = (idx: number, order: number): NodeStatus => {
|
||||||
|
const s = stepStatus(idx)
|
||||||
|
if (s !== 'Current' || curLevelOrder == null) return s
|
||||||
|
if (order < curLevelOrder) return 'Done'
|
||||||
|
if (order === curLevelOrder) return 'Current'
|
||||||
|
return 'Pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này —
|
||||||
|
// 1 người ký là phiếu tiến (mirror BE `:259-260` `.Any(...)`). KHÔNG `.find()` một người.
|
||||||
|
const currentLevels: KhkkWorkflowLevelDto[] =
|
||||||
|
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
|
||||||
|
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
|
||||||
|
: []
|
||||||
|
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
|
||||||
|
? steps[curStepIdx]
|
||||||
|
: null
|
||||||
|
|
||||||
|
const isAdmin = user?.roles?.includes('Admin') ?? false
|
||||||
|
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
|
||||||
|
// Admin bypass — mirror `PeWorkflowPanel.tsx:110-114` (`actorInV2Level` có `isAdmin ||`).
|
||||||
|
const actorInLevel = isAdmin || actorIsCurrentApprover
|
||||||
|
const blockedByLevel = isWaiting && !actorInLevel
|
||||||
|
|
||||||
|
// Nút "Gửi duyệt": guard BE là `CreatedBy == actor ∨ DeptManager(cùng phòng) ∨ Admin`
|
||||||
|
// (spec W3 §②-5). FE KHÔNG suy được vế DeptManager — `UserInfo` chỉ có {id,email,fullName,roles}
|
||||||
|
// (`types/auth.ts:1-6`), không có phòng ban. CỐ Ý **không ẩn** nút theo vai: ẩn sẽ giấu nút với
|
||||||
|
// đúng người có quyền, im lặng, không lỗi (bug-class gotcha #44 — đã đốt 1 lần ở PE S155).
|
||||||
|
// Rào THẬT nằm ở BE (403 → toast). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
|
||||||
|
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
|
||||||
|
|
||||||
|
const transition = useMutation({
|
||||||
|
mutationFn: async (a: KhkkTransitionActionValue) => {
|
||||||
|
// 🔴 BODY LITERAL theo hợp-đồng LEAD chốt: đúng 2 field `action` + `comment`.
|
||||||
|
const body: KhkkTransitionInput = { action: a, comment: comment.trim() || null }
|
||||||
|
const res = await api.post<KhkkTransitionResult>(
|
||||||
|
`/contract-signing-plans/${plan.id}/transitions`,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
|
||||||
|
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
|
||||||
|
setAction(null)
|
||||||
|
setComment('')
|
||||||
|
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['khkk-list'] })
|
||||||
|
onChanged?.()
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogTitle =
|
||||||
|
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
|
||||||
|
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
|
||||||
|
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
|
||||||
|
: '✗ Từ chối kế hoạch ký kết'
|
||||||
|
// Trả lại / Từ chối bắt buộc nêu lý do (ghi vào ý kiến cấp duyệt + changelog BE).
|
||||||
|
const commentRequired =
|
||||||
|
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
|
||||||
|
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
|
||||||
|
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||||
|
<span
|
||||||
|
className="icon-chip"
|
||||||
|
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Workflow className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
|
||||||
|
{plan.workflowCode && (
|
||||||
|
<span className="font-mono text-[11px] text-slate-500">
|
||||||
|
{plan.workflowCode}
|
||||||
|
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */}
|
||||||
|
{steps.length > 0 ? (
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{steps.map((step, idx) => {
|
||||||
|
const st = stepStatus(idx)
|
||||||
|
return (
|
||||||
|
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium">
|
||||||
|
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
|
||||||
|
{STATUS_ICON[st]}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-800">Bước {step.order} — {step.name}</span>
|
||||||
|
</div>
|
||||||
|
{step.levels.length > 0 && (
|
||||||
|
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
|
||||||
|
{step.levels.map(lv => {
|
||||||
|
const ls = levelStatus(idx, lv.order)
|
||||||
|
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
|
||||||
|
return (
|
||||||
|
<li key={lv.id} className="text-[11px]">
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
|
||||||
|
{STATUS_ICON[ls]}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium text-slate-700">
|
||||||
|
{lv.name || `Cấp ${lv.order}`}
|
||||||
|
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
|
||||||
|
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500">
|
||||||
|
{lv.approverFullName ?? '(chưa cấu hình)'}
|
||||||
|
{signed && (
|
||||||
|
<span className="ml-1.5 text-[10px] text-emerald-600">
|
||||||
|
✓ ký {formatDateTime(signed.signedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
|
||||||
|
Quy trình duyệt chưa cấu hình Bước/Cấp — liên hệ Admin để dựng quy trình loại
|
||||||
|
“Kế hoạch ký kết HĐ” trước khi trình duyệt.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. Chỉ có nghĩa khi ĐANG chờ duyệt. */}
|
||||||
|
{isWaiting && currentStep && (
|
||||||
|
<div className={cn(
|
||||||
|
'rounded border px-3 py-2 text-[11px]',
|
||||||
|
actorInLevel
|
||||||
|
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||||
|
: 'border-amber-300 bg-amber-50 text-amber-800',
|
||||||
|
)}>
|
||||||
|
<div className="font-medium">
|
||||||
|
Đang chờ Bước {currentStep.order} ({currentStep.name}) — Cấp {curLevelOrder}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
|
||||||
|
</div>
|
||||||
|
{actorInLevel
|
||||||
|
? <div className="mt-0.5 font-medium">✓ Đến lượt bạn duyệt</div>
|
||||||
|
: <div className="mt-0.5">⚠ Không phải lượt bạn — chỉ người trên mới thao tác cấp này</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === KhkkPhase.TraLai && (
|
||||||
|
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
|
||||||
|
⚠ Kế hoạch bị TRẢ LẠI — sửa nội dung rồi bấm “Gửi duyệt”, quy trình chạy lại từ Bước 1 · Cấp 1.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{phase === KhkkPhase.TuChoi && (
|
||||||
|
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||||
|
✗ Kế hoạch đã bị TỪ CHỐI — không thao tác được nữa. Lập kế hoạch mới nếu cần làm lại.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hành động */}
|
||||||
|
{(canSubmit || isWaiting) && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Hành động:</Label>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{canSubmit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setComment(''); setAction(KhkkTransitionAction.Submit) }}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
|
||||||
|
>
|
||||||
|
<Send className="h-3 w-3" /> Gửi duyệt
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isDraftLike && !plan.approvalWorkflowId && (
|
||||||
|
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
|
||||||
|
Chưa pin quy trình duyệt — không gửi duyệt được.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isWaiting && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={blockedByLevel}
|
||||||
|
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Approve) } }}
|
||||||
|
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||||
|
blockedByLevel
|
||||||
|
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||||
|
: 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" /> Duyệt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={blockedByLevel}
|
||||||
|
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Return) } }}
|
||||||
|
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||||
|
blockedByLevel
|
||||||
|
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||||
|
: 'border-amber-300 text-amber-700 hover:bg-amber-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Undo2 className="h-3 w-3" /> Trả lại
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={blockedByLevel}
|
||||||
|
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Reject) } }}
|
||||||
|
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||||
|
blockedByLevel
|
||||||
|
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||||
|
: 'border-red-300 text-red-700 hover:bg-red-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" /> Từ chối
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */}
|
||||||
|
{opinions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5 text-violet-500" />
|
||||||
|
Ý kiến cấp duyệt
|
||||||
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||||
|
{opinions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1.5 space-y-1.5">
|
||||||
|
{opinions.map(o => (
|
||||||
|
<li key={o.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
|
||||||
|
<span className="font-medium">{o.signedByFullName}</span>
|
||||||
|
<span className="text-slate-400">·</span>
|
||||||
|
<span>
|
||||||
|
{o.stepName ? `Bước ${o.stepOrder} — ${o.stepName}` : 'Cấp duyệt'}
|
||||||
|
{o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">·</span>
|
||||||
|
<span className="text-slate-500">{formatDateTime(o.signedAt)}</span>
|
||||||
|
{/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
|
||||||
|
{o.approverUserId && o.approverUserId !== o.signedByUserId && (
|
||||||
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
|
||||||
|
duyệt thay
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
|
||||||
|
{o.comment?.trim() || '(duyệt — không ý kiến)'}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action !== null && (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onClose={() => setAction(null)}
|
||||||
|
title={dialogTitle}
|
||||||
|
footer={<>
|
||||||
|
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
|
||||||
|
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
|
||||||
|
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
{action === KhkkTransitionAction.Submit && (
|
||||||
|
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
|
||||||
|
Kế hoạch chuyển sang “Chờ duyệt” và trình lên Bước 1 · Cấp 1 của quy trình
|
||||||
|
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa được nữa.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{action === KhkkTransitionAction.Return && (
|
||||||
|
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||||
|
Kế hoạch về trạng thái “Trả lại”. Người soạn sửa xong gửi lại thì quy trình chạy
|
||||||
|
từ Bước 1 · Cấp 1.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{action === KhkkTransitionAction.Reject && (
|
||||||
|
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||||
|
⚠ Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Label>
|
||||||
|
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={e => setComment(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
|
||||||
|
/>
|
||||||
|
{action === KhkkTransitionAction.Approve && (
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
Ý kiến sẽ được ghi vào mục “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||||||
|
thì ghi đè ô cũ).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -20,7 +20,10 @@
|
|||||||
// POST /contract-signing-plans/{id}/attachments multipart { file, purpose, dossierItemId? }
|
// POST /contract-signing-plans/{id}/attachments multipart { file, purpose, dossierItemId? }
|
||||||
// GET /contract-signing-plans/{id}/attachments/{attId}/download
|
// GET /contract-signing-plans/{id}/attachments/{attId}/download
|
||||||
// DELETE /contract-signing-plans/{id}/attachments/{attId}
|
// DELETE /contract-signing-plans/{id}/attachments/{attId}
|
||||||
// (transitions submit/approve/return/reject = W3, CỐ Ý chưa có ở đây.)
|
//
|
||||||
|
// Hợp đồng FE ↔ BE lượt W3 (LEAD chốt — 2 lane implement đúng LITERAL từng tên field):
|
||||||
|
// POST /contract-signing-plans/{id}/transitions KhkkTransitionInput → KhkkTransitionResult
|
||||||
|
// policy `KeHoachKyKet.Update`
|
||||||
|
|
||||||
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
|
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
|
||||||
export const KhkkPhase = {
|
export const KhkkPhase = {
|
||||||
@ -161,12 +164,36 @@ export interface KhkkLevelOpinionDto {
|
|||||||
stepOrder: number | null
|
stepOrder: number | null
|
||||||
stepName: string | null
|
stepName: string | null
|
||||||
levelOrder: number | null
|
levelOrder: number | null
|
||||||
|
// [W3 S161] BE `ContractSigningPlanLevelOpinionDto:99` CÓ field này (W2 khai thiếu).
|
||||||
|
// `signedByUserId !== approverUserId` ⇒ có người duyệt THAY (Admin) — panel gắn nhãn.
|
||||||
|
approverUserId: string | null
|
||||||
comment: string | null
|
comment: string | null
|
||||||
signedAt: string
|
signedAt: string
|
||||||
signedByUserId: string
|
signedByUserId: string
|
||||||
signedByFullName: string
|
signedByFullName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [W3 S161] Cây quy trình THÔ do BE trả (`ContractSigningPlanWorkflowLevelDto:105-110`).
|
||||||
|
// 🔴 KHÁC PE: BE **KHÔNG** precompute `status` Done/Current/Pending — FE tự suy từ cặp
|
||||||
|
// con-trỏ `currentWorkflowStepIndex` (INDEX 0-based vào mảng steps) + `currentApprovalLevelOrder`
|
||||||
|
// (GIÁ TRỊ `level.order`). Xem `ContractSigningPlanFeatures.cs:224-226` + `:259-260`.
|
||||||
|
export interface KhkkWorkflowLevelDto {
|
||||||
|
id: string
|
||||||
|
order: number
|
||||||
|
name: string | null
|
||||||
|
approverUserId: string
|
||||||
|
approverFullName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
|
||||||
|
export interface KhkkWorkflowStepDto {
|
||||||
|
id: string
|
||||||
|
order: number
|
||||||
|
name: string
|
||||||
|
departmentId: string | null
|
||||||
|
levels: KhkkWorkflowLevelDto[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface KhkkDetailDto extends KhkkListItemDto {
|
export interface KhkkDetailDto extends KhkkListItemDto {
|
||||||
workflowName: string | null
|
workflowName: string | null
|
||||||
currentWorkflowStepIndex: number | null
|
currentWorkflowStepIndex: number | null
|
||||||
@ -177,6 +204,8 @@ export interface KhkkDetailDto extends KhkkListItemDto {
|
|||||||
dossierItems: KhkkDossierItemDto[]
|
dossierItems: KhkkDossierItemDto[]
|
||||||
attachments: KhkkAttachmentDto[]
|
attachments: KhkkAttachmentDto[]
|
||||||
levelOpinions: KhkkLevelOpinionDto[]
|
levelOpinions: KhkkLevelOpinionDto[]
|
||||||
|
// `ContractSigningPlanDetailDto:147` — field CUỐI của record BE.
|
||||||
|
workflowSteps: KhkkWorkflowStepDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
|
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
|
||||||
@ -218,6 +247,34 @@ export interface UpsertKhkkDossierItemInput {
|
|||||||
note?: string | null
|
note?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== W3 — chuyển trạng thái (duyệt 3 trạm) =========================
|
||||||
|
// 🔴 HỢP-ĐỒNG LITERAL do LEAD chốt (S161 W3). Tên field ở đây = tên field BE bind.
|
||||||
|
// Đổi 1 chữ ở đây mà không đổi BE = đứt im lặng (bài W2 6-điểm-đứt).
|
||||||
|
// submit = trình duyệt (DangSoanThao | TraLai → ChoDuyet)
|
||||||
|
// approve = duyệt cấp hiện tại (OR-of-N; comment ≠ rỗng ⇒ UPSERT LevelOpinion)
|
||||||
|
// return = trả lại (→ TraLai)
|
||||||
|
// reject = từ chối (→ TuChoi)
|
||||||
|
export const KhkkTransitionAction = {
|
||||||
|
Submit: 'submit',
|
||||||
|
Approve: 'approve',
|
||||||
|
Return: 'return',
|
||||||
|
Reject: 'reject',
|
||||||
|
} as const
|
||||||
|
export type KhkkTransitionActionValue =
|
||||||
|
(typeof KhkkTransitionAction)[keyof typeof KhkkTransitionAction]
|
||||||
|
|
||||||
|
export interface KhkkTransitionInput {
|
||||||
|
action: KhkkTransitionActionValue
|
||||||
|
comment: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200 — 3 field, ĐÚNG tên hợp-đồng. `phase` là INT (mirror `KhkkPhase`).
|
||||||
|
export interface KhkkTransitionResult {
|
||||||
|
phase: number
|
||||||
|
currentWorkflowStepIndex: number | null
|
||||||
|
currentApprovalLevelOrder: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface PagedResult<T> {
|
export interface PagedResult<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SolutionErp.Application.Common.Models;
|
using SolutionErp.Application.Common.Models;
|
||||||
using SolutionErp.Application.ContractSigningPlans;
|
using SolutionErp.Application.ContractSigningPlans;
|
||||||
|
using SolutionErp.Application.ContractSigningPlans.Services;
|
||||||
using SolutionErp.Domain.ContractSigningPlans;
|
using SolutionErp.Domain.ContractSigningPlans;
|
||||||
|
|
||||||
namespace SolutionErp.Api.Controllers;
|
namespace SolutionErp.Api.Controllers;
|
||||||
@ -18,12 +19,16 @@ namespace SolutionErp.Api.Controllers;
|
|||||||
// (`MenuKeys.cs:42,180`) × `MenuKeys.Actions` trong `Api/Program.cs`. Key KHÔNG nằm
|
// (`MenuKeys.cs:42,180`) × `MenuKeys.Actions` trong `Api/Program.cs`. Key KHÔNG nằm
|
||||||
// trong `All` ⇒ 500 lúc chạy (policy không tồn tại), không phải 403.
|
// trong `All` ⇒ 500 lúc chạy (policy không tồn tại), không phải 403.
|
||||||
//
|
//
|
||||||
// 🔴 KHÔNG có endpoint transition (submit/approve/reject/return) — đó là W3.
|
// [W3 đợt-1 — S161] ĐÃ CÓ endpoint transition (đính chính dòng cũ "KHÔNG có ... đó là W3"):
|
||||||
|
// `POST {id}/transitions` mở ở cuối file. Đợt-1 mới là SKELETON — service ném
|
||||||
|
// NotImplementedException ⇒ endpoint trả 500 cho tới khi đợt-3 điền logic. CỐ Ý: test-before.
|
||||||
// GlobalExceptionMiddleware map exception → ProblemDetails ⇒ TUYỆT ĐỐI không try-catch ở đây.
|
// GlobalExceptionMiddleware map exception → ProblemDetails ⇒ TUYỆT ĐỐI không try-catch ở đây.
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/contract-signing-plans")]
|
[Route("api/contract-signing-plans")]
|
||||||
[Authorize(Policy = "KeHoachKyKet.Read")]
|
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||||
public class ContractSigningPlansController(IMediator mediator) : ControllerBase
|
public class ContractSigningPlansController(
|
||||||
|
IMediator mediator,
|
||||||
|
IContractSigningPlanWorkflowService workflow) : ControllerBase
|
||||||
{
|
{
|
||||||
// ========================= Đọc =========================
|
// ========================= Đọc =========================
|
||||||
|
|
||||||
@ -190,6 +195,21 @@ public class ContractSigningPlansController(IMediator mediator) : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== Duyệt 3 trạm (W3) =====================
|
||||||
|
// 🔴 HỢP-ĐỒNG LITERAL (lead chốt @S161 — 2 lane BE/FE khớp từng tên field):
|
||||||
|
// body { "action": "submit"|"approve"|"return"|"reject", "comment": string|null }
|
||||||
|
// response { "phase": int, "currentWorkflowStepIndex": int|null,
|
||||||
|
// "currentApprovalLevelOrder": int|null }
|
||||||
|
// Policy `KeHoachKyKet.Update` (KHÔNG Create/Delete): duyệt = GHI vào phiếu đã có.
|
||||||
|
// Đi THẲNG service (không qua MediatR) vì đây là 1 thao tác state-machine, không phải
|
||||||
|
// CQRS command — mirror `ContractsController` gọi `IContractWorkflowService`.
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/transitions")]
|
||||||
|
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||||
|
public async Task<ActionResult<ContractSigningPlanTransitionResult>> Transition(
|
||||||
|
Guid id, [FromBody] ContractSigningPlanTransitionBody body, CancellationToken ct)
|
||||||
|
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment, ct));
|
||||||
|
|
||||||
// ========================= Body records =========================
|
// ========================= Body records =========================
|
||||||
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
|
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
|
||||||
// Mọi field nullable = null-safe: client không gửi ⇒ giữ giá trị cũ (#73).
|
// Mọi field nullable = null-safe: client không gửi ⇒ giữ giá trị cũ (#73).
|
||||||
@ -197,4 +217,12 @@ public class ContractSigningPlansController(IMediator mediator) : ControllerBase
|
|||||||
string? GhiChu = null,
|
string? GhiChu = null,
|
||||||
string? HoSoLink = null,
|
string? HoSoLink = null,
|
||||||
Guid? ApprovalWorkflowId = null);
|
Guid? ApprovalWorkflowId = null);
|
||||||
|
|
||||||
|
// Transition: `Action` BẮT BUỘC (thiếu ⇒ service ném 409 "Hành động không hợp lệ"),
|
||||||
|
// `Comment` tuỳ chọn. Tên property PascalCase bind đúng JSON "action"/"comment" —
|
||||||
|
// ASP.NET Core mặc định camelCase + đọc không phân biệt hoa/thường
|
||||||
|
// (`Api/Program.cs:33-34` chỉ thêm converter DateTime, KHÔNG đổi naming policy).
|
||||||
|
public record ContractSigningPlanTransitionBody(
|
||||||
|
string Action,
|
||||||
|
string? Comment = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
namespace SolutionErp.Application.ContractSigningPlans.Services;
|
||||||
|
|
||||||
|
// [W3 ĐỢT-1 SKELETON — S161 2026-07-29] Hợp đồng transition phiếu "Kế hoạch ký kết HĐ" (GĐ2).
|
||||||
|
//
|
||||||
|
// 🔴 ĐỢT NÀY = SKELETON THUẦN (test-before): interface + record chốt CHỮ KÝ để
|
||||||
|
// test-specialist viết 6 test RED compile được. Thân service NÉM NotImplementedException
|
||||||
|
// — logic thật (OR-of-N, con-trỏ đôi, LevelOpinions UPSERT, finalize) là ĐỢT-3.
|
||||||
|
//
|
||||||
|
// 🔴 HỢP-ĐỒNG LITERAL (lead định nghĩa @S161 — bài W2 "6 điểm đứt": 2 lane implement phải
|
||||||
|
// khớp TỪNG TÊN FIELD, không tự đổi):
|
||||||
|
// POST /api/contract-signing-plans/{id}/transitions [Authorize(Policy="KeHoachKyKet.Update")]
|
||||||
|
// body : { "action": "submit" | "approve" | "return" | "reject", "comment": string|null }
|
||||||
|
// response : { "phase": int, "currentWorkflowStepIndex": int|null, "currentApprovalLevelOrder": int|null }
|
||||||
|
//
|
||||||
|
// Ý nghĩa 4 action:
|
||||||
|
// submit — người soạn trình phiếu: DangSoanThao | TraLai → ChoDuyet (chạy LẠI từ Bước 1 Cấp 1)
|
||||||
|
// approve — approver Cấp đang chờ duyệt: tiến con-trỏ OR-of-N (+comment ⇒ UPSERT LevelOpinion)
|
||||||
|
// return — trả lại người soạn: → TraLai
|
||||||
|
// reject — từ chối: → TuChoi (terminal)
|
||||||
|
public interface IContractSigningPlanWorkflowService
|
||||||
|
{
|
||||||
|
// Thực hiện 1 transition. Ném ConflictException/ForbiddenException/NotFoundException
|
||||||
|
// (GlobalExceptionMiddleware map → ProblemDetails; controller KHÔNG try-catch).
|
||||||
|
// Trả về trạng-thái SAU transition để controller phản hồi thẳng 3 field hợp-đồng.
|
||||||
|
Task<ContractSigningPlanTransitionResult> TransitionAsync(
|
||||||
|
Guid planId,
|
||||||
|
string action,
|
||||||
|
string? comment,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 field ĐÚNG BẰNG response hợp-đồng, KHÔNG thừa không thiếu.
|
||||||
|
// 🔴 `Phase` để kiểu `int` (KHÔNG ContractSigningPlanPhase) là CỐ Ý: hợp-đồng chốt
|
||||||
|
// `"phase": int` ⇒ ép cast tại nguồn, khỏi phụ thuộc cấu hình JsonStringEnumConverter
|
||||||
|
// (Api/Program.cs:33-34 hiện KHÔNG đăng ký converter enum, nhưng thêm sau sẽ lật kiểu
|
||||||
|
// JSON của mọi enum ⇒ khoá kiểu ở đây là rào chống hồi-quy im lặng).
|
||||||
|
// Null-semantics: 2 con-trỏ = null khi phiếu ở phase KHÔNG chạy workflow (nháp/terminal).
|
||||||
|
public record ContractSigningPlanTransitionResult(
|
||||||
|
int Phase,
|
||||||
|
int? CurrentWorkflowStepIndex,
|
||||||
|
int? CurrentApprovalLevelOrder);
|
||||||
@ -40,6 +40,9 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IPurchaseEvaluationCodeGenerator, PurchaseEvaluationCodeGenerator>();
|
services.AddScoped<IPurchaseEvaluationCodeGenerator, PurchaseEvaluationCodeGenerator>();
|
||||||
// [W1 Mig 69 — S161] mã phiếu KHKK/{YYYY}/{Seq:D3} qua WorkflowAppCodeSequences dùng chung.
|
// [W1 Mig 69 — S161] mã phiếu KHKK/{YYYY}/{Seq:D3} qua WorkflowAppCodeSequences dùng chung.
|
||||||
services.AddScoped<IContractSigningPlanCodeGenerator, ContractSigningPlanCodeGenerator>();
|
services.AddScoped<IContractSigningPlanCodeGenerator, ContractSigningPlanCodeGenerator>();
|
||||||
|
// [W3 đợt-1 — S161] workflow duyệt 3 trạm KHKK. Đợt-1 = SKELETON (mọi nhánh ném
|
||||||
|
// NotImplementedException); đăng ký sẵn để endpoint + test resolve được đúng chữ ký.
|
||||||
|
services.AddScoped<IContractSigningPlanWorkflowService, ContractSigningPlanWorkflowService>();
|
||||||
services.AddScoped<IEmployeeCodeGenerator, EmployeeCodeGenerator>();
|
services.AddScoped<IEmployeeCodeGenerator, EmployeeCodeGenerator>();
|
||||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||||
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();
|
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();
|
||||||
|
|||||||
@ -0,0 +1,589 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.ContractSigningPlans.Services;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.ContractSigningPlans;
|
||||||
|
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (enum dùng chung)
|
||||||
|
using SolutionErp.Domain.Identity; // AppRoles
|
||||||
|
using SolutionErp.Domain.Notifications; // NotificationType
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Services;
|
||||||
|
|
||||||
|
// [W3 ĐỢT-3 — S161 2026-07-29] Workflow phiếu "Kế hoạch ký kết HĐ" (GĐ2).
|
||||||
|
//
|
||||||
|
// Đợt-1 để skeleton ném NotImplementedException cho 6 test sàn ĐỎ THẬT; đợt này điền
|
||||||
|
// logic để chúng TỰ xanh. Hợp-đồng đợt-1 (4 GIỮ + 3 ADAPT + 2 CẤM) chép lại rút gọn
|
||||||
|
// bên dưới kèm ĐỊA CHỈ, và ghi rõ chỗ nào đã thi hành / chỗ nào CỐ Ý không.
|
||||||
|
//
|
||||||
|
// NGUỒN COPY: `ContractWorkflowService.ApproveV2Async` (`ContractWorkflowService.cs:217-394`).
|
||||||
|
// 🔴 CẤM copy `ProposalFeatures` — Proposal FLATTEN Level (SelectMany) nên OR-of-N biến
|
||||||
|
// thành AND ÂM THẦM, và nó chỉ có 1 con-trỏ LevelOrder ⇒ không diễn tả được "Bước mới
|
||||||
|
// reset Cấp về 1". Ở đây giữ đúng khuôn nguồn: `GroupBy(l => l.Order)` + `Contains(actor)`.
|
||||||
|
//
|
||||||
|
// 4 ĐIỂM GIỮ (mỗi điểm = 1 acceptance, đều đã thi hành):
|
||||||
|
// 1. Con-trỏ ĐÔI `CurrentWorkflowStepIndex` (0-based INDEX vào list Steps đã sort theo
|
||||||
|
// Order — KHÔNG phải giá trị Order) + `CurrentApprovalLevelOrder` (1-based).
|
||||||
|
// Sang Bước mới ⇒ Cấp reset về 1 (`AdvancePointer` bên dưới).
|
||||||
|
// 2. GroupBy Order = Cấp (khuôn `ContractWorkflowService.cs:246`).
|
||||||
|
// 3. OR-of-N: MỘT người trong Cấp duyệt là Cấp đó xong (khuôn `:255-266`) —
|
||||||
|
// `ResolveActingLevel` khớp theo tập, KHÔNG đòi đủ chữ ký cả nhóm.
|
||||||
|
// 4. UPSERT ý kiến vào `ContractSigningPlanLevelOpinions` (khuôn `:292-316`), 1 row /
|
||||||
|
// cặp (Plan × Level), `SignedByUserId` = người ký THẬT (Admin duyệt thay vẫn ghi Admin).
|
||||||
|
//
|
||||||
|
// 3 ĐIỂM ADAPT:
|
||||||
|
// (a) Finalize choke-point: helper `ApplyApprovedValuesOnFinalize` gọi ở MỌI nhánh set
|
||||||
|
// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔴 KHAI RÕ: service này có **ĐÚNG MỘT**
|
||||||
|
// nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy
|
||||||
|
// (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` qua BIẾN).
|
||||||
|
// Lý do: hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số
|
||||||
|
// `targetPhase` ⇒ Admin muốn kết thúc phiếu vẫn phải đi đúng đường `approve`
|
||||||
|
// (Admin chỉ được miễn vế "có tên trong Cấp", không được miễn choke-point).
|
||||||
|
// Đó chính là write-path 2 mà test T3 đo.
|
||||||
|
// (b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (`AllowApproverFinalize`, `CeoApprovalThreshold`)
|
||||||
|
// và cũng KHÔNG port `AllowApproverSkipToFinal` (`:318-352` nguồn copy) — chủ dự án
|
||||||
|
// phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật.
|
||||||
|
// (c) Trả lại = `TraLai` TOÀN PHẦN (về người soạn, xoá con-trỏ; gửi lại chạy LẠI từ Bước 1
|
||||||
|
// Cấp 1). 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) CỐ Ý chưa đọc —
|
||||||
|
// mở thêm mode là đổi hành vi duyệt, thuộc quyền lead.
|
||||||
|
//
|
||||||
|
// LỊCH SỬ: mỗi transition ghi `ContractSigningPlanApprovals` + `ContractSigningPlanChangelogs`
|
||||||
|
// qua `LogSigningPlanTransitionAsync` (method KHKK-RIÊNG, bên dưới).
|
||||||
|
// 🔴 CẤM `IChangelogService.LogWorkflowTransitionAsync(...)`: nó nhận `Guid contractId` và
|
||||||
|
// ghi `ContractChangelogs` — bảng có FK VẬT LÝ `ContractId` Cascade
|
||||||
|
// (`ContractChangelogConfiguration.cs:22-26`) ⇒ truyền `plan.Id` = FK violation 547 LÚC CHẠY,
|
||||||
|
// compiler KHÔNG chặn. Rào bằng THIẾT KẾ: service CỐ Ý không inject `IChangelogService`
|
||||||
|
// (không có sẵn thì không lỡ tay gọi) — giữ nguyên từ đợt-1.
|
||||||
|
//
|
||||||
|
// ĐIỂM LƯU (khai để lead phân xử): ĐÚNG MỘT `SaveChangesAsync` ở CUỐI `TransitionAsync`.
|
||||||
|
// Khác nguồn copy (`ContractWorkflowService.cs:537` TransitionAsync KHÔNG tự save, caller
|
||||||
|
// save) vì ở đây service LÀ điểm vào (controller gọi thẳng, không qua MediatR) ⇒ không có
|
||||||
|
// "caller downstream" nào để save hộ. 1 transition = 1 unit-of-work: con-trỏ + hàng
|
||||||
|
// Approvals + Changelogs + LevelOpinion + giá chốt + Notifications cùng sống hoặc cùng chết
|
||||||
|
// (`NotificationService.cs:7-8` cố ý KHÔNG tự SaveChanges để caller giữ atomicity).
|
||||||
|
public class ContractSigningPlanWorkflowService(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IDateTime dateTime,
|
||||||
|
INotificationService notifications) : IContractSigningPlanWorkflowService
|
||||||
|
{
|
||||||
|
// Tập action khoá theo hợp-đồng literal của lead. So khớp KHÔNG phân biệt hoa/thường
|
||||||
|
// (client gửi "Submit" vẫn hiểu), nhưng nhánh xử lý luôn chạy trên bản đã chuẩn hoá.
|
||||||
|
private static readonly HashSet<string> AllowedActions =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { "submit", "approve", "return", "reject" };
|
||||||
|
|
||||||
|
// Ý kiến rỗng → chữ thay thế (khuôn nguồn copy `ContractWorkflowService.cs:295-297`):
|
||||||
|
// ô câm không phân biệt được "đã ký, không ý kiến" với "chưa ký".
|
||||||
|
private const string NoOpinionPlaceholder = "(duyệt — không ý kiến)";
|
||||||
|
|
||||||
|
// Trần độ dài ý kiến = cột CHẶT NHẤT trong các nơi chứa nguyên văn comment:
|
||||||
|
// `ContractSigningPlanApprovals.Comment` = 1000 (`ContractSigningPlanApprovalConfiguration.cs:26`).
|
||||||
|
// Chặn TẠI CỔNG thay vì cắt ngầm: đường này KHÔNG đi qua MediatR nên KHÔNG có
|
||||||
|
// FluentValidation nào đỡ; để lọt thì SQL Server ném 2628 (chuỗi bị cắt) = 500 khó hiểu.
|
||||||
|
private const int MaxCommentLength = 1000;
|
||||||
|
|
||||||
|
public async Task<ContractSigningPlanTransitionResult> TransitionAsync(
|
||||||
|
Guid planId,
|
||||||
|
string action,
|
||||||
|
string? comment,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Rào action đứng TRƯỚC mọi truy vấn: gõ sai tên hành động là lỗi dữ liệu gửi lên,
|
||||||
|
// không phải "không tìm thấy phiếu" — trả 409 để FE hiện đúng nguyên nhân.
|
||||||
|
var normalized = (action ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
if (!AllowedActions.Contains(normalized))
|
||||||
|
throw new ConflictException("Hành động không hợp lệ.");
|
||||||
|
|
||||||
|
var normalizedComment = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim();
|
||||||
|
if (normalizedComment is not null && normalizedComment.Length > MaxCommentLength)
|
||||||
|
throw new ConflictException($"Ý kiến duyệt tối đa {MaxCommentLength} ký tự.");
|
||||||
|
|
||||||
|
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
var actorId = currentUser.UserId.Value;
|
||||||
|
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
|
||||||
|
|
||||||
|
// Include Lines NGAY: nhánh finalize cần chốt giá, và nạp kèm ở đây thì phép lọc
|
||||||
|
// "chỉ dòng của phiếu này" là do CHÍNH quan hệ cha-con bảo đảm (xem LUẬT ở
|
||||||
|
// `ApplyApprovedValuesOnFinalize`), không phải do nhớ viết `Where(PlanId == ...)`.
|
||||||
|
var plan = await db.ContractSigningPlans
|
||||||
|
.Include(p => p.Lines)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == planId, ct)
|
||||||
|
?? throw new NotFoundException("ContractSigningPlan", planId);
|
||||||
|
|
||||||
|
// switch TÁCH RIÊNG từng nhánh (không gộp) — mỗi hành động có guard riêng; gộp lại
|
||||||
|
// là mất ranh giới giữa "ai được trình" và "ai được duyệt".
|
||||||
|
switch (normalized)
|
||||||
|
{
|
||||||
|
case "submit":
|
||||||
|
await SubmitAsync(plan, actorId, isAdmin, normalizedComment, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "approve":
|
||||||
|
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "return":
|
||||||
|
await ReturnOrRejectAsync(
|
||||||
|
plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TraLai, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "reject":
|
||||||
|
await ReturnOrRejectAsync(
|
||||||
|
plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TuChoi, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Không tới được (đã lọc ở rào trên) — giữ để switch đóng kín.
|
||||||
|
throw new ConflictException("Hành động không hợp lệ.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Trả trạng thái SAU transition, đọc từ chính entity vừa lưu ⇒ FE vẽ lại panel không
|
||||||
|
// cần refetch mà vẫn không thể lệch với DB.
|
||||||
|
return new ContractSigningPlanTransitionResult(
|
||||||
|
(int)plan.Phase, plan.CurrentWorkflowStepIndex, plan.CurrentApprovalLevelOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// submit — người soạn trình phiếu
|
||||||
|
// =====================================================================
|
||||||
|
private async Task SubmitAsync(
|
||||||
|
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TraLai))
|
||||||
|
throw new ConflictException("Chỉ trình duyệt được phiếu đang ở trạng thái Nháp hoặc Trả lại.");
|
||||||
|
|
||||||
|
// Nói RÕ nguyên nhân: phiếu chưa gắn quy trình thì không có Bước/Cấp nào để chờ,
|
||||||
|
// trình đại = phiếu treo ở "Đã gửi duyệt" mà không nằm trong hộp thư của ai.
|
||||||
|
if (plan.ApprovalWorkflowId is not Guid awId)
|
||||||
|
throw new ConflictException(
|
||||||
|
"Phiếu chưa gắn quy trình duyệt — chọn quy trình trong phiếu nháp trước khi trình.");
|
||||||
|
|
||||||
|
await EnsureCanSubmitAsync(plan, actorId, isAdmin, ct);
|
||||||
|
|
||||||
|
var steps = await LoadStepsAsync(awId, ct);
|
||||||
|
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
|
||||||
|
if (firstLevelGroup.Count == 0)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Quy trình duyệt chưa cấu hình Cấp 1 cho Bước 1 ({steps[0].Name}) — chưa trình được.");
|
||||||
|
|
||||||
|
var fromPhase = plan.Phase;
|
||||||
|
plan.Phase = ContractSigningPlanPhase.ChoDuyet;
|
||||||
|
|
||||||
|
// 🔴 RESET về đầu quy trình cho CẢ hai lối vào (Nháp lần đầu VÀ Trả lại gửi lại):
|
||||||
|
// nối tiếp chỗ đang dở thì bản sửa của người soạn không được các Bước trước xem lại
|
||||||
|
// lần nào (acceptance #6).
|
||||||
|
plan.CurrentWorkflowStepIndex = 0;
|
||||||
|
plan.CurrentApprovalLevelOrder = 1;
|
||||||
|
|
||||||
|
await LogSigningPlanTransitionAsync(
|
||||||
|
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Pending, comment,
|
||||||
|
actingLevelId: null,
|
||||||
|
summary: fromPhase == ContractSigningPlanPhase.TraLai
|
||||||
|
? "Trình duyệt lại kế hoạch ký kết (chạy lại từ Bước 1 Cấp 1)"
|
||||||
|
: "Trình duyệt kế hoạch ký kết",
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
await NotifyPendingApproversAsync(plan, firstLevelGroup, actorId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guard trình — CỐ Ý khác khuôn role-based của Contract (né lớp 403-PMH): phiếu là của
|
||||||
|
/// người soạn + phòng của người soạn, không phải của một vai trong quy trình.
|
||||||
|
/// 🔴 KHAI RÕ nới 1 vế so với chữ của spec ("CreatedBy == actor"): chấp nhận CẢ
|
||||||
|
/// `DrafterUserId == actor`. Hai cột này chỉ đường tới CÙNG một người ở đường tạo phiếu
|
||||||
|
/// W2 (`ContractSigningPlanFeatures.cs:372` gán `DrafterUserId = actorId`, còn `CreatedBy`
|
||||||
|
/// do `AuditingInterceptor` ghi từ cùng `ICurrentUser`) ⇒ nới KHÔNG mở thêm ai. Nhưng
|
||||||
|
/// phiếu sinh ngoài đường CQRS (seed/import chạy dưới danh nghĩa hệ thống) sẽ có
|
||||||
|
/// `CreatedBy = null` — đọc mỗi cột đó thì chính người soạn bị chặn trình phiếu của mình.
|
||||||
|
private async Task EnsureCanSubmitAsync(
|
||||||
|
ContractSigningPlan plan, Guid actorId, bool isAdmin, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (isAdmin) return;
|
||||||
|
if (plan.CreatedBy == actorId || plan.DrafterUserId == actorId) return;
|
||||||
|
|
||||||
|
// Trưởng phòng CÙNG phòng ban CỦA PHIẾU. So với `plan.DepartmentId` (phòng của phiếu),
|
||||||
|
// KHÔNG phải `Step.DepartmentId` — cột kia chỉ là hint của quy trình
|
||||||
|
// (`ContractSigningPlan.cs:26-28`).
|
||||||
|
if (currentUser.Roles.Contains(AppRoles.DeptManager) && plan.DepartmentId is Guid deptId)
|
||||||
|
{
|
||||||
|
var sameDept = await db.Users.AsNoTracking()
|
||||||
|
.AnyAsync(u => u.Id == actorId && u.DepartmentId == deptId, ct);
|
||||||
|
if (sameDept) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Chỉ người soạn kế hoạch, trưởng phòng cùng phòng ban của phiếu hoặc Admin được trình duyệt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// approve — approver Cấp đang chờ duyệt (OR-of-N)
|
||||||
|
// =====================================================================
|
||||||
|
private async Task ApproveV2Async(
|
||||||
|
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
|
||||||
|
throw new ConflictException("Chỉ duyệt được phiếu đang ở trạng thái Đã gửi duyệt.");
|
||||||
|
if (plan.ApprovalWorkflowId is not Guid awId)
|
||||||
|
throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");
|
||||||
|
|
||||||
|
var steps = await LoadStepsAsync(awId, ct);
|
||||||
|
var (currentIdx, currentStep, currentLevelOrder, levelGroups, maxLevelOrder) =
|
||||||
|
ResolvePointer(plan, steps);
|
||||||
|
|
||||||
|
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
|
||||||
|
?? throw new ConflictException($"Bước {currentIdx + 1} không có Cấp {currentLevelOrder}.");
|
||||||
|
|
||||||
|
var actingLevel = ResolveActingLevel(
|
||||||
|
pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);
|
||||||
|
|
||||||
|
// Ý kiến sync ngay lúc duyệt (Q1=1B — KHÔNG endpoint ý kiến rời).
|
||||||
|
await UpsertLevelOpinionAsync(plan, actingLevel, actorId, comment, ct);
|
||||||
|
|
||||||
|
var fromPhase = plan.Phase;
|
||||||
|
|
||||||
|
// ---- Advance con-trỏ ĐÔI: còn Cấp trong Bước → Cấp++; hết Cấp → Bước kế (Cấp 1);
|
||||||
|
// hết Bước → FINALIZE.
|
||||||
|
if (currentLevelOrder < maxLevelOrder)
|
||||||
|
{
|
||||||
|
plan.CurrentApprovalLevelOrder = currentLevelOrder + 1;
|
||||||
|
await LogSigningPlanTransitionAsync(
|
||||||
|
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
|
||||||
|
actingLevel.Id,
|
||||||
|
$"Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — chuyển Cấp {currentLevelOrder + 1}",
|
||||||
|
ct);
|
||||||
|
await NotifyPendingApproversAsync(
|
||||||
|
plan, LevelsOf(currentStep, currentLevelOrder + 1), actorId, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextIdx = currentIdx + 1;
|
||||||
|
if (nextIdx < steps.Count)
|
||||||
|
{
|
||||||
|
plan.CurrentWorkflowStepIndex = nextIdx;
|
||||||
|
plan.CurrentApprovalLevelOrder = 1; // 🔴 Bước mới ⇒ Cấp RESET về 1
|
||||||
|
await LogSigningPlanTransitionAsync(
|
||||||
|
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
|
||||||
|
actingLevel.Id,
|
||||||
|
$"Hoàn tất Bước {currentIdx + 1}/{steps.Count} — chuyển Bước {nextIdx + 1} (Cấp 1)",
|
||||||
|
ct);
|
||||||
|
await NotifyPendingApproversAsync(plan, LevelsOf(steps[nextIdx], 1), actorId, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Cấp cuối của Bước cuối ⇒ terminal.
|
||||||
|
ApplyApprovedValuesOnFinalize(plan);
|
||||||
|
plan.Phase = ContractSigningPlanPhase.DaDuyet;
|
||||||
|
plan.CurrentWorkflowStepIndex = null; // terminal ⇒ không nằm trong hộp thư ai nữa
|
||||||
|
plan.CurrentApprovalLevelOrder = null;
|
||||||
|
await LogSigningPlanTransitionAsync(
|
||||||
|
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
|
||||||
|
actingLevel.Id,
|
||||||
|
$"Duyệt Cấp cuối (Bước {currentIdx + 1}/{steps.Count}) — kế hoạch ký kết ĐÃ DUYỆT, chốt giá",
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🔴 LUẬT (không đếm số — số-đếm tự lão-hoá, bài `PurchaseEvaluationWorkflowService.cs:1008`):
|
||||||
|
/// MỌI nhánh đưa phiếu sang `DaDuyet` BẮT BUỘC gọi helper này TRƯỚC khi gán Phase.
|
||||||
|
/// Bỏ sót một nhánh = phiếu "Đã duyệt" mà `ApprovedAmount` rỗng ⇒ GĐ3 sinh hợp đồng 0đ.
|
||||||
|
/// Tự kiểm 2 lệnh (chạy cả hai, cùng phải khớp nhau):
|
||||||
|
/// grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs
|
||||||
|
/// grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs
|
||||||
|
///
|
||||||
|
/// Vế "CHỈ dòng của phiếu này" (acceptance `AND PlanId = @id`) do QUAN HỆ bảo đảm:
|
||||||
|
/// `plan.Lines` nạp bằng `Include` trên đúng một phiếu, nên không có đường nào chạm dòng
|
||||||
|
/// của phiếu hàng xóm — cố ý KHÔNG viết `db.ContractSigningPlanLines.Where(...)` để không
|
||||||
|
/// tồn tại chỗ cho phép quên vế lọc.
|
||||||
|
///
|
||||||
|
/// `??=` — GIỮ giá trị đã chốt, KHÔNG đè: số do người có thẩm quyền sửa tay phải thắng.
|
||||||
|
private static void ApplyApprovedValuesOnFinalize(ContractSigningPlan plan)
|
||||||
|
{
|
||||||
|
foreach (var line in plan.Lines)
|
||||||
|
line.ApprovedAmount ??= line.ProposedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// return / reject — trả lại người soạn hoặc từ chối hẳn
|
||||||
|
// =====================================================================
|
||||||
|
private async Task ReturnOrRejectAsync(
|
||||||
|
ContractSigningPlan plan,
|
||||||
|
Guid actorId,
|
||||||
|
bool isAdmin,
|
||||||
|
string? comment,
|
||||||
|
ContractSigningPlanPhase targetPhase,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var isReturn = targetPhase == ContractSigningPlanPhase.TraLai;
|
||||||
|
|
||||||
|
if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
|
||||||
|
throw new ConflictException(isReturn
|
||||||
|
? "Chỉ trả lại được phiếu đang ở trạng thái Đã gửi duyệt."
|
||||||
|
: "Chỉ từ chối được phiếu đang ở trạng thái Đã gửi duyệt.");
|
||||||
|
if (plan.ApprovalWorkflowId is not Guid awId)
|
||||||
|
throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");
|
||||||
|
|
||||||
|
var steps = await LoadStepsAsync(awId, ct);
|
||||||
|
var (currentIdx, currentStep, currentLevelOrder, levelGroups, _) = ResolvePointer(plan, steps);
|
||||||
|
|
||||||
|
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
|
||||||
|
?? throw new ConflictException($"Bước {currentIdx + 1} không có Cấp {currentLevelOrder}.");
|
||||||
|
|
||||||
|
// Cùng vế quyền với `approve`: chỉ NV của Cấp ĐANG CHỜ (OR-of-N) hoặc Admin.
|
||||||
|
// Rào bằng CHÍNH hàm của approve ⇒ không thể lệch nhau về sau.
|
||||||
|
var actingLevel = ResolveActingLevel(
|
||||||
|
pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);
|
||||||
|
|
||||||
|
// 🔴 KHÔNG UPSERT LevelOpinion ở đây: bảng ý kiến là chữ ký DUYỆT
|
||||||
|
// (`ContractSigningPlanLevelOpinion.cs:11` "Reject (TraLai/TuChoi) KHÔNG sync").
|
||||||
|
// Lý do trả lại đã nằm ở Approvals + Changelogs ngay dưới.
|
||||||
|
var fromPhase = plan.Phase;
|
||||||
|
plan.Phase = targetPhase;
|
||||||
|
|
||||||
|
// Trả lại TOÀN PHẦN / từ chối: xoá con-trỏ. Để sót thì phiếu vừa nằm ở tay người soạn
|
||||||
|
// vừa nằm trong hộp thư người duyệt (`ResolveV2InboxIdsAsync` lọc theo con-trỏ).
|
||||||
|
plan.CurrentWorkflowStepIndex = null;
|
||||||
|
plan.CurrentApprovalLevelOrder = null;
|
||||||
|
|
||||||
|
await LogSigningPlanTransitionAsync(
|
||||||
|
plan, fromPhase, targetPhase, actorId, ApprovalDecision.Reject, comment,
|
||||||
|
actingLevel.Id,
|
||||||
|
isReturn
|
||||||
|
? $"Trả lại người soạn tại Bước {currentIdx + 1} Cấp {currentLevelOrder}"
|
||||||
|
: $"Từ chối tại Bước {currentIdx + 1} Cấp {currentLevelOrder}",
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Helpers dùng chung
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Nạp cây quy trình đã SORT sẵn (Bước theo Order, Cấp theo Order) — mọi nhánh đọc CÙNG
|
||||||
|
/// một thứ tự, nên INDEX con-trỏ luôn hiểu như nhau (khuôn `ContractWorkflowService.cs:228-236`).
|
||||||
|
private async Task<List<ApprovalWorkflowStep>> LoadStepsAsync(Guid awId, 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($"Quy trình duyệt {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 duyệt chưa có Bước nào.");
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Đọc con-trỏ ĐÔI + kiểm biên. 🔴 `CurrentWorkflowStepIndex` là INDEX vào list đã sort,
|
||||||
|
/// KHÔNG phải `Step.Order` — đọc nhầm thì phiếu nhảy sang Bước của người khác trong im lặng
|
||||||
|
/// (W2 đã chốt semantic INDEX ở `ContractSigningPlanFeatures.cs:256-260`).
|
||||||
|
private static (int Idx,
|
||||||
|
ApprovalWorkflowStep Step,
|
||||||
|
int LevelOrder,
|
||||||
|
List<IGrouping<int, ApprovalWorkflowLevel>> LevelGroups,
|
||||||
|
int MaxLevelOrder)
|
||||||
|
ResolvePointer(ContractSigningPlan plan, List<ApprovalWorkflowStep> steps)
|
||||||
|
{
|
||||||
|
var idx = plan.CurrentWorkflowStepIndex ?? 0;
|
||||||
|
if (idx < 0 || idx >= steps.Count)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"CurrentWorkflowStepIndex={idx} không hợp lệ (max={steps.Count - 1}).");
|
||||||
|
|
||||||
|
var step = steps[idx];
|
||||||
|
var levelOrder = plan.CurrentApprovalLevelOrder ?? 1;
|
||||||
|
|
||||||
|
// Cấp = nhóm Level CÙNG Order (khuôn `ContractWorkflowService.cs:246`). Giữ GroupBy —
|
||||||
|
// flatten kiểu Proposal sẽ biến OR-of-N thành AND mà build vẫn sạch.
|
||||||
|
var levelGroups = step.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
|
||||||
|
var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
|
||||||
|
if (levelOrder < 1 || levelOrder > maxLevelOrder)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"CurrentApprovalLevelOrder={levelOrder} không hợp lệ (max={maxLevelOrder}).");
|
||||||
|
|
||||||
|
return (idx, step, levelOrder, levelGroups, maxLevelOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OR-of-N (khuôn `ContractWorkflowService.cs:255-266`): BẤT KỲ ai trong Cấp đang chờ là
|
||||||
|
/// duyệt được — trả về ĐÚNG row Level của người ký để ý kiến treo dưới đúng tên.
|
||||||
|
/// Admin duyệt thay: miễn vế "có tên trong Cấp", ý kiến rơi vào row đầu Cấp và
|
||||||
|
/// `SignedByUserId` vẫn là Admin ⇒ FE phân biệt được (`SignedByUserId != ApproverUserId`).
|
||||||
|
private static ApprovalWorkflowLevel ResolveActingLevel(
|
||||||
|
IGrouping<int, ApprovalWorkflowLevel> pendingLevelGroup,
|
||||||
|
ApprovalWorkflowStep step,
|
||||||
|
int stepIdx,
|
||||||
|
int levelOrder,
|
||||||
|
Guid actorId,
|
||||||
|
bool isAdmin)
|
||||||
|
{
|
||||||
|
var own = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId);
|
||||||
|
if (own is not null) return own;
|
||||||
|
if (isAdmin) return pendingLevelGroup.First();
|
||||||
|
|
||||||
|
throw new ForbiddenException(
|
||||||
|
$"Bước {stepIdx + 1} ({step.Name}) — Cấp {levelOrder}: bạn không có trong danh sách NV duyệt của cấp này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ApprovalWorkflowLevel> LevelsOf(ApprovalWorkflowStep step, int levelOrder)
|
||||||
|
=> step.Levels.Where(l => l.Order == levelOrder).ToList();
|
||||||
|
|
||||||
|
/// UPSERT 1 row / cặp (Plan × Level) — UNIQUE `(PlanId, LevelId)`
|
||||||
|
/// (`ContractSigningPlanLevelOpinionConfiguration.cs:36`). Duyệt lại cùng Cấp (sau khi bị
|
||||||
|
/// trả lại) = CẬP NHẬT dòng cũ; `Add` thẳng sẽ nổ UNIQUE hoặc đẻ dòng thứ hai.
|
||||||
|
private async Task UpsertLevelOpinionAsync(
|
||||||
|
ContractSigningPlan plan,
|
||||||
|
ApprovalWorkflowLevel actingLevel,
|
||||||
|
Guid actorId,
|
||||||
|
string? comment,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var text = string.IsNullOrWhiteSpace(comment) ? NoOpinionPlaceholder : comment;
|
||||||
|
var signedByFullName = await ResolveActorFullNameAsync(actorId, ct);
|
||||||
|
var now = dateTime.UtcNow;
|
||||||
|
|
||||||
|
var existing = await db.ContractSigningPlanLevelOpinions
|
||||||
|
.FirstOrDefaultAsync(o => o.ContractSigningPlanId == plan.Id
|
||||||
|
&& o.ApprovalWorkflowLevelId == actingLevel.Id, ct);
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.ContractSigningPlanLevelOpinions.Add(new ContractSigningPlanLevelOpinion
|
||||||
|
{
|
||||||
|
ContractSigningPlanId = plan.Id,
|
||||||
|
ApprovalWorkflowLevelId = actingLevel.Id,
|
||||||
|
Comment = text,
|
||||||
|
SignedAt = now,
|
||||||
|
SignedByUserId = actorId,
|
||||||
|
SignedByFullName = signedByFullName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// latest-write-wins TRỌN BỘ 4 field: giữ lại chữ/người/giờ cũ = hồ sơ nói sai
|
||||||
|
// sự thật về lần ký mới.
|
||||||
|
existing.Comment = text;
|
||||||
|
existing.SignedAt = now;
|
||||||
|
existing.SignedByUserId = actorId;
|
||||||
|
existing.SignedByFullName = signedByFullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Method ghi lịch sử KHKK-RIÊNG (spec §②-4). Ghi CẢ HAI bảng trong cùng unit-of-work:
|
||||||
|
/// • `ContractSigningPlanApprovals` — 1 row / transition (ai, Cấp nào, quyết định gì)
|
||||||
|
/// • `ContractSigningPlanChangelogs` — 1 row / transition (nhật ký đọc-được cho người dùng)
|
||||||
|
/// 🔴 KHÔNG gọi `IChangelogService.LogWorkflowTransitionAsync` — xem lý do FK-547 đầu file.
|
||||||
|
/// Kèm notify NGƯỜI SOẠN (mirror `ContractWorkflowService.cs:407-427`); notify approver
|
||||||
|
/// Cấp kế nằm riêng ở `NotifyPendingApproversAsync` (vế THÊM của spec §②-6).
|
||||||
|
private async Task LogSigningPlanTransitionAsync(
|
||||||
|
ContractSigningPlan plan,
|
||||||
|
ContractSigningPlanPhase fromPhase,
|
||||||
|
ContractSigningPlanPhase toPhase,
|
||||||
|
Guid actorId,
|
||||||
|
ApprovalDecision decision,
|
||||||
|
string? comment,
|
||||||
|
Guid? actingLevelId,
|
||||||
|
string summary,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var now = dateTime.UtcNow;
|
||||||
|
|
||||||
|
db.ContractSigningPlanApprovals.Add(new ContractSigningPlanApproval
|
||||||
|
{
|
||||||
|
ContractSigningPlanId = plan.Id,
|
||||||
|
FromPhase = fromPhase,
|
||||||
|
ToPhase = toPhase,
|
||||||
|
Decision = decision,
|
||||||
|
Comment = comment,
|
||||||
|
ApprovalWorkflowLevelId = actingLevelId,
|
||||||
|
ApprovedByUserId = actorId,
|
||||||
|
ApprovedAt = now,
|
||||||
|
});
|
||||||
|
|
||||||
|
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
|
||||||
|
{
|
||||||
|
ContractSigningPlanId = plan.Id,
|
||||||
|
EntityType = ContractSigningPlanEntityType.Workflow,
|
||||||
|
EntityId = actingLevelId,
|
||||||
|
Action = ChangelogAction.Transition,
|
||||||
|
PhaseAtChange = toPhase,
|
||||||
|
UserId = actorId,
|
||||||
|
UserName = Truncate(await ResolveActorFullNameAsync(actorId, ct), 200),
|
||||||
|
Summary = Truncate(summary, 1000),
|
||||||
|
ContextNote = comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify NGƯỜI SOẠN — trừ khi chính họ vừa bấm (khỏi tự báo cho mình).
|
||||||
|
if (plan.DrafterUserId != Guid.Empty && plan.DrafterUserId != actorId)
|
||||||
|
{
|
||||||
|
var label = plan.MaKeHoach ?? "Kế hoạch ký kết";
|
||||||
|
var (title, type) = toPhase switch
|
||||||
|
{
|
||||||
|
ContractSigningPlanPhase.DaDuyet =>
|
||||||
|
($"Kế hoạch ký kết {label} đã được duyệt", NotificationType.ContractPublished),
|
||||||
|
ContractSigningPlanPhase.TuChoi =>
|
||||||
|
($"Kế hoạch ký kết {label} bị từ chối", NotificationType.ContractRejected),
|
||||||
|
ContractSigningPlanPhase.TraLai =>
|
||||||
|
($"Kế hoạch ký kết {label} bị trả lại — vui lòng sửa và trình lại",
|
||||||
|
NotificationType.ContractRejected),
|
||||||
|
_ => ($"Kế hoạch ký kết {label} chuyển trạng thái", NotificationType.ContractPhaseTransition),
|
||||||
|
};
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
plan.DrafterUserId, type, title,
|
||||||
|
description: summary + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||||
|
href: $"/khkk/{plan.Id}",
|
||||||
|
refId: plan.Id,
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vế THÊM của spec §②-6 — báo ĐÍCH DANH NV của Cấp đang chờ (khuôn đã chạy thật ở PE
|
||||||
|
/// `PurchaseEvaluationWorkflowService.cs:1243-1289`), vá lỗ "chỉ người soạn được báo".
|
||||||
|
/// Bỏ qua: chính người vừa bấm, và NGƯỜI SOẠN — họ đã thấy phiếu ở màn "phiếu của tôi"
|
||||||
|
/// và nhận chuông KẾT QUẢ ở trên (bài UAT S86).
|
||||||
|
private async Task NotifyPendingApproversAsync(
|
||||||
|
ContractSigningPlan plan,
|
||||||
|
IEnumerable<ApprovalWorkflowLevel> pendingLevels,
|
||||||
|
Guid actorId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var approverIds = pendingLevels
|
||||||
|
.Select(l => l.ApproverUserId)
|
||||||
|
.Where(id => id != Guid.Empty && id != actorId && id != plan.DrafterUserId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (approverIds.Count == 0) return;
|
||||||
|
|
||||||
|
await notifications.NotifyManyAsync(
|
||||||
|
approverIds,
|
||||||
|
NotificationType.Generic,
|
||||||
|
$"Kế hoạch ký kết cần bạn duyệt: {plan.MaKeHoach ?? "(chưa có mã)"}",
|
||||||
|
"Có kế hoạch ký kết HĐ đang chờ bạn duyệt.",
|
||||||
|
$"/khkk/{plan.Id}",
|
||||||
|
plan.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot tên người ký (`SignedByFullName` là cột NOT NULL 200 ký tự —
|
||||||
|
/// `ContractSigningPlanLevelOpinionConfiguration.cs:24`). Khuôn
|
||||||
|
/// `ContractWorkflowService.cs:433-442`; thêm vế cuối lấy từ token vì service này lấy
|
||||||
|
/// actor qua `ICurrentUser` chứ không qua `UserManager`.
|
||||||
|
private async Task<string> ResolveActorFullNameAsync(Guid actorId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await db.Users.AsNoTracking()
|
||||||
|
.Where(u => u.Id == actorId)
|
||||||
|
.Select(u => new { u.FullName, u.UserName })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (user is not null && !string.IsNullOrWhiteSpace(user.FullName)) return Truncate(user.FullName, 200);
|
||||||
|
if (user is not null && !string.IsNullOrWhiteSpace(user.UserName)) return Truncate(user.UserName, 200);
|
||||||
|
if (!string.IsNullOrWhiteSpace(currentUser.FullName)) return Truncate(currentUser.FullName!, 200);
|
||||||
|
if (!string.IsNullOrWhiteSpace(currentUser.Email)) return Truncate(currentUser.Email!, 200);
|
||||||
|
return "(unknown)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cắt AN TOÀN cho các chuỗi service TỰ DỰNG (tên Bước do admin đặt có thể dài).
|
||||||
|
/// Ý kiến của người dùng KHÔNG đi qua đây — chuỗi đó bị chặn ở cổng
|
||||||
|
/// (`MaxCommentLength`) để không bao giờ bị cắt ngầm mất chữ.
|
||||||
|
private static string Truncate(string value, int max)
|
||||||
|
=> value.Length <= max ? value : value[..max];
|
||||||
|
}
|
||||||
@ -0,0 +1,729 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
|
||||||
|
using SolutionErp.Application.ContractSigningPlans; // GetMyContractSigningPlanInboxQuery (W2, đã sống)
|
||||||
|
using SolutionErp.Application.ContractSigningPlans.Services;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.ContractSigningPlans;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Infrastructure.Persistence;
|
||||||
|
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
|
// [W3 KHKK — S161 2026-07-29] Duyệt 3 trạm phiếu "Kế hoạch ký kết HĐ" (GĐ2) — **TEST-BEFORE**.
|
||||||
|
//
|
||||||
|
// 🔴 ĐỢT NÀY 5/6 TEST PHẢI ĐỎ. Service mới chỉ là skeleton: mọi nhánh ném
|
||||||
|
// `NotImplementedException("W3 đợt-3: <action>")` (`ContractSigningPlanWorkflowService.cs:108/113/117/121`).
|
||||||
|
// Đỏ-vì-CHƯA-LÀM (không phải đỏ-vì-không-biên-dịch-được) chính là bằng chứng duy nhất
|
||||||
|
// rằng 6 lưới này có răng — đợt-3 điền logic xong chúng phải TỰ xanh, không được sửa test
|
||||||
|
// cho vừa. Test số 4 (Inbox) đi qua query handler W2 đã sống ⇒ **xanh ngay hôm nay**, cố ý
|
||||||
|
// không ép đỏ; nó đồng thời là phép thử "seed có hợp lệ không" cho 5 test còn lại (cùng
|
||||||
|
// bộ helper: nếu FK/So-sánh seed sai thì test 4 đỏ trước tiên).
|
||||||
|
//
|
||||||
|
// Tên 6 test PIN theo spec W3 §③-B (`spec-wave-w3-duyet-3-tram-29-07-2026.md:35`) — thiếu 1
|
||||||
|
// tên là TRƯỢT acceptance, KHÔNG đổi chữ.
|
||||||
|
//
|
||||||
|
// ── Đo cái gì (map 1:1 spec §③-B) ──────────────────────────────────────────────
|
||||||
|
// T1 OR-of-N trọn 4 vế (a)(b)(c)(d) T2 con-trỏ ĐÔI + reset Cấp
|
||||||
|
// T3 finalize `AND PlanId=@id`, 2 write-path T4 Inbox đúng người đúng Cấp
|
||||||
|
// T5 UPSERT ý kiến 1 row T6 Trả lại → gửi lại chạy LẠI từ Bước 1 Cấp 1
|
||||||
|
//
|
||||||
|
// ── 3 quyết định hạ tầng (đều là ĐIỀU KIỆN để phép đo có nghĩa) ────────────────
|
||||||
|
//
|
||||||
|
// (1) Fixture RIÊNG có `AuditingInterceptor` (mirror `ContractSigningPlanCrudTests.cs:49-82`).
|
||||||
|
// Guard trình đợt-3 = `CreatedBy == actor ∨ DeptManager ∨ Admin`
|
||||||
|
// (`ContractSigningPlanWorkflowService.cs:58-59`). `TestApplicationDbContext` trần KHÔNG
|
||||||
|
// wire interceptor ⇒ `CreatedBy` của MỌI phiếu seed sẽ là null ⇒ vế đầu của guard không
|
||||||
|
// bao giờ đúng và `submit` đỏ vì lý do SAI. Seed phiếu bằng context của chính người soạn
|
||||||
|
// ⇒ `CreatedBy` == `DrafterUserId` (đợt-3 đọc cột nào cũng ra cùng một người).
|
||||||
|
//
|
||||||
|
// (2) MỖI transition chạy trên MỘT DbContext MỚI (mô phỏng scoped-per-request của prod).
|
||||||
|
// Dùng lại 1 context cho cả seed lẫn lệnh sẽ để EF track sẵn Lines/LevelOpinions —
|
||||||
|
// một đường đi KHÔNG có thật ở prod (ở đó context mới chỉ nạp thứ service tự Include).
|
||||||
|
//
|
||||||
|
// (3) User seed THẲNG `db.Users.Add(...)`, KHÔNG kéo `IdentityFixture`/`UserManager`:
|
||||||
|
// service nhận role qua `ICurrentUser.Roles` (lane BE QUYẾT-2 cố ý không inject
|
||||||
|
// UserManager), còn `ApprovalWorkflowLevel.ApproverUserId` chỉ cần **row tồn tại**
|
||||||
|
// (FK Restrict — `ApprovalWorkflowConfiguration.cs:72-75`), không cần đăng nhập được.
|
||||||
|
public class ContractSigningPlanApprovalTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Fixture: SQLite in-memory + AuditingInterceptor, 1 connection dùng chung.
|
||||||
|
// ===================================================================
|
||||||
|
private sealed class KhkkWorkflowFixture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _conn;
|
||||||
|
public FixedDateTime Clock { get; } = new(Clock2026);
|
||||||
|
|
||||||
|
public KhkkWorkflowFixture()
|
||||||
|
{
|
||||||
|
_conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
_conn.Open();
|
||||||
|
using var boot = NewDb(TestCurrentUser.System());
|
||||||
|
boot.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context MỚI cho mỗi "request". `actor` phải là ĐÚNG người mà lệnh chạy dưới danh
|
||||||
|
/// nghĩa — prod resolve CÙNG một `ICurrentUser` scoped cho cả interceptor lẫn service,
|
||||||
|
/// tách hai ra thì `CreatedBy`/`UpdatedBy` ghi ra người sai *theo kiểu trông như đúng*.
|
||||||
|
public TestApplicationDbContext NewDb(ICurrentUser actor)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(_conn)
|
||||||
|
.EnableSensitiveDataLogging()
|
||||||
|
.AddInterceptors(new AuditingInterceptor(actor, Clock))
|
||||||
|
.Options;
|
||||||
|
return new TestApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _conn.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Seed helpers
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private static TestCurrentUser As(User u, params string[] roles)
|
||||||
|
=> new(u.Id, u.FullName, u.Email, roles);
|
||||||
|
|
||||||
|
/// Row `AspNetUsers` tối thiểu nhưng ĐỦ THẬT: FK `Level.ApproverUserId` Restrict cần
|
||||||
|
/// row tồn tại, còn `SignedByFullName` (snapshot người ký) cần `FullName` khác rỗng.
|
||||||
|
private static async Task<User> AddUserAsync(KhkkWorkflowFixture f, string fullName)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(TestCurrentUser.System());
|
||||||
|
var email = $"u{Guid.NewGuid():N}@test.local";
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
FullName = fullName,
|
||||||
|
UserName = email,
|
||||||
|
NormalizedUserName = email.ToUpperInvariant(),
|
||||||
|
Email = email,
|
||||||
|
NormalizedEmail = email.ToUpperInvariant(),
|
||||||
|
EmailConfirmed = true,
|
||||||
|
SecurityStamp = Guid.NewGuid().ToString("N"),
|
||||||
|
ConcurrencyStamp = Guid.NewGuid().ToString("N"),
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = Clock2026,
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `steps[i][j]` = danh sách NV của **Cấp j+1** trong **Bước i+1**. Nhiều NV trong cùng một
|
||||||
|
// Cấp = OR-of-N (`ApprovalWorkflow.cs:85-88`).
|
||||||
|
//
|
||||||
|
// `stepOrderStart` / `stepOrderStride`: CỐ Ý cho phép `Step.Order` KHÔNG bắt đầu từ 1 và
|
||||||
|
// không liền nhau. `CurrentWorkflowStepIndex` là **INDEX vào list đã sort theo Order**, KHÔNG
|
||||||
|
// phải giá trị `Order` — mã W2 đã chốt semantic đó (`ContractSigningPlanFeatures.cs:256-260`
|
||||||
|
// `steps[idx]`). T2 dùng Order 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn.
|
||||||
|
private static async Task<Guid> SeedWorkflowAsync(
|
||||||
|
KhkkWorkflowFixture f,
|
||||||
|
string code,
|
||||||
|
Guid[][][] steps,
|
||||||
|
int stepOrderStart = 1,
|
||||||
|
int stepOrderStride = 1)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(TestCurrentUser.System());
|
||||||
|
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = code,
|
||||||
|
Version = 1,
|
||||||
|
Name = "Quy trình duyệt Kế hoạch ký kết",
|
||||||
|
// Loại phải là ContractSigningPlan(10) — quy trình module khác bị rào Create
|
||||||
|
// của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`).
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
|
||||||
|
for (var s = 0; s < steps.Length; s++)
|
||||||
|
{
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = stepOrderStart + (s * stepOrderStride),
|
||||||
|
Name = $"Bước {s + 1}",
|
||||||
|
DepartmentId = null, // null = bỏ qua FK phòng ban (khuôn Pattern 11)
|
||||||
|
};
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
|
||||||
|
for (var l = 0; l < steps[s].Length; l++)
|
||||||
|
{
|
||||||
|
foreach (var approverId in steps[s][l])
|
||||||
|
{
|
||||||
|
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = l + 1, // Cấp 1-based
|
||||||
|
Name = $"Cấp {l + 1}",
|
||||||
|
ApproverUserId = approverId,
|
||||||
|
// 5 cờ Allow* GIỮ MẶC ĐỊNH: W3 default dry-run = đường THƯỜNG duy nhất,
|
||||||
|
// KHÔNG bật kết-thúc-sớm (spec §②-3b — chủ dự án phán "PHÁ VỠ" @S155).
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return wf.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record LineSeed(decimal PeReference, decimal Proposed, decimal? Approved = null);
|
||||||
|
|
||||||
|
private static async Task<Guid> SeedPlanAsync(
|
||||||
|
KhkkWorkflowFixture f,
|
||||||
|
TestCurrentUser drafter,
|
||||||
|
Guid workflowId,
|
||||||
|
string maKeHoach,
|
||||||
|
ContractSigningPlanPhase phase,
|
||||||
|
int? stepIndex,
|
||||||
|
int? levelOrder,
|
||||||
|
LineSeed[] lines)
|
||||||
|
{
|
||||||
|
// Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
|
||||||
|
await using var db = f.NewDb(drafter);
|
||||||
|
|
||||||
|
var plan = new ContractSigningPlan
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
MaKeHoach = maKeHoach,
|
||||||
|
// 4 cột dưới là loose-Guid KHÔNG FK vật lý (`ContractSigningPlanConfiguration.cs:10-12`)
|
||||||
|
// ⇒ W3 không phải seed PE/Project/Department như W2.
|
||||||
|
PurchaseEvaluationId = Guid.NewGuid(),
|
||||||
|
ProjectId = Guid.NewGuid(),
|
||||||
|
DepartmentId = Guid.NewGuid(),
|
||||||
|
DrafterUserId = drafter.UserId!.Value,
|
||||||
|
Phase = phase,
|
||||||
|
ApprovalWorkflowId = workflowId,
|
||||||
|
CurrentWorkflowStepIndex = stepIndex,
|
||||||
|
CurrentApprovalLevelOrder = levelOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var l in lines)
|
||||||
|
{
|
||||||
|
plan.Lines.Add(new ContractSigningPlanLine
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ContractSigningPlanId = plan.Id,
|
||||||
|
SupplierId = Guid.NewGuid(), // loose-Guid (`ContractSigningPlanLine.cs:15`)
|
||||||
|
PeReferenceAmount = l.PeReference,
|
||||||
|
ProposedAmount = l.Proposed,
|
||||||
|
ApprovedAmount = l.Approved,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ContractSigningPlans.Add(plan);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return plan.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Act / Read helpers — gọi service THẬT, KHÔNG mock.
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private static async Task<ContractSigningPlanTransitionResult> ActAsync(
|
||||||
|
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, string action, string? comment = null)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(actor);
|
||||||
|
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
|
||||||
|
return await svc.TransitionAsync(planId, action, comment, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkWorkflowFixture f, Guid planId)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(TestCurrentUser.System());
|
||||||
|
return await db.ContractSigningPlans.AsNoTracking()
|
||||||
|
.Include(p => p.Lines)
|
||||||
|
.SingleAsync(p => p.Id == planId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<ContractSigningPlanLevelOpinion>> ReadOpinionsAsync(
|
||||||
|
KhkkWorkflowFixture f, Guid planId)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(TestCurrentUser.System());
|
||||||
|
// Lọc `WHERE PlanId = @id` ĐÚNG như acceptance §③-B yêu cầu cho Lines: nếu đợt-3
|
||||||
|
// quên vế này ở bất kỳ đâu thì phiếu hàng xóm sẽ dính, và các assert "phiếu chứng
|
||||||
|
// nhân bất động" bên dưới bắt được.
|
||||||
|
return await db.ContractSigningPlanLevelOpinions.AsNoTracking()
|
||||||
|
.Where(o => o.ContractSigningPlanId == planId)
|
||||||
|
.ToListAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> LevelIdOfAsync(KhkkWorkflowFixture f, Guid approverUserId)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(TestCurrentUser.System());
|
||||||
|
return await db.ApprovalWorkflowLevels.AsNoTracking()
|
||||||
|
.Where(l => l.ApproverUserId == approverUserId)
|
||||||
|
.Select(l => l.Id)
|
||||||
|
.SingleAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<ContractSigningPlanListItemDto>> InboxAsync(
|
||||||
|
KhkkWorkflowFixture f, TestCurrentUser actor)
|
||||||
|
{
|
||||||
|
await using var db = f.NewDb(actor);
|
||||||
|
var handler = new GetMyContractSigningPlanInboxQueryHandler(db, actor);
|
||||||
|
return await handler.Handle(new GetMyContractSigningPlanInboxQuery(), CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Con-trỏ ĐÔI + phase là MỘT bộ ba — kiểm cả ba cùng lúc để không có chuyện
|
||||||
|
/// "phase đúng mà con-trỏ trôi" lọt lưới.
|
||||||
|
private static void ShouldBeAt(
|
||||||
|
ContractSigningPlan plan, ContractSigningPlanPhase phase, int? stepIndex, int? levelOrder, string because)
|
||||||
|
{
|
||||||
|
plan.Phase.Should().Be(phase, because);
|
||||||
|
plan.CurrentWorkflowStepIndex.Should().Be(stepIndex, because);
|
||||||
|
plan.CurrentApprovalLevelOrder.Should().Be(levelOrder, because);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 3 field response (FE đọc để vẽ lại panel ngay, không refetch) phải KHỚP DB.
|
||||||
|
/// Lệch = FE hiển thị một trạng thái không tồn tại cho tới lần F5 kế.
|
||||||
|
private static void ResultShouldMirror(ContractSigningPlanTransitionResult r, ContractSigningPlan plan)
|
||||||
|
{
|
||||||
|
r.Phase.Should().Be((int)plan.Phase, "response `phase` là int (hợp-đồng literal) và phải bằng phase đã ghi DB");
|
||||||
|
r.CurrentWorkflowStepIndex.Should().Be(plan.CurrentWorkflowStepIndex, "response phải soi đúng con-trỏ Bước trong DB");
|
||||||
|
r.CurrentApprovalLevelOrder.Should().Be(plan.CurrentApprovalLevelOrder, "response phải soi đúng con-trỏ Cấp trong DB");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (T1) OR-of-N — acceptance #1 TRỌN 4 VẾ (a)(b)(c)(d), spec §③-B `:28`.
|
||||||
|
//
|
||||||
|
// Đây là lưới DUY NHẤT bắt được lỗi chết người của wave này: copy nhầm khuôn
|
||||||
|
// `ProposalFeatures` (flatten `SelectMany` các Level) làm OR-of-N biến thành AND **ÂM
|
||||||
|
// THẦM** — build sạch, 574 test cũ vẫn xanh, chỉ vỡ khi phiếu thật treo giữa chừng vì
|
||||||
|
// chờ đủ chữ ký. Vì thế:
|
||||||
|
// (a) 2 phiếu SONG SINH cùng quy trình: phiếu-1 do A duyệt, phiếu-2 do B duyệt;
|
||||||
|
// (d) mỗi phiếu tiến với ĐÚNG MỘT chữ ký — nếu đòi cả hai thì con-trỏ đứng im ở Cấp 1;
|
||||||
|
// (c) mỗi phiếu ghi ĐÚNG 1 dòng ý kiến, và dòng đó trỏ Level của CHÍNH người ký;
|
||||||
|
// (b) ca ÂM 2 lớp: người sai Cấp (C, Cấp 2 chưa tới lượt) và người NGOÀI quy trình (D).
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures()
|
||||||
|
{
|
||||||
|
using var f = new KhkkWorkflowFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
var a = await AddUserAsync(f, "NV Cung ứng A");
|
||||||
|
var b = await AddUserAsync(f, "NV Cung ứng B");
|
||||||
|
var c = await AddUserAsync(f, "Trưởng phòng C");
|
||||||
|
var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");
|
||||||
|
|
||||||
|
// 1 Bước — Cấp 1 = {A, B} (OR-of-N), Cấp 2 = {C}.
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-ORN", steps:
|
||||||
|
[
|
||||||
|
[[a.Id, b.Id], [c.Id]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
|
||||||
|
var p1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/001", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
|
||||||
|
var p2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/002", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
|
||||||
|
var p3 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/003", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
|
||||||
|
|
||||||
|
// Sanity TRƯỚC khi gọi service: seed đặt đúng lượt Cấp 1 Bước 1. Thiếu chốt này thì
|
||||||
|
// một seed hỏng (con-trỏ null) vẫn cho ra assert xanh ở thế giới sai.
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, p1), ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||||
|
"seed phải đặt phiếu ĐÚNG lượt Cấp 1 Bước 1 — sai seed thì test đo chính nó");
|
||||||
|
|
||||||
|
// ---- (a) + (d): A duyệt phiếu-1, B duyệt phiếu-2. Cả hai đều TIẾN.
|
||||||
|
await ActAsync(f, As(a), p1, "approve", "A đồng ý đơn giá");
|
||||||
|
var after1 = await ReadPlanAsync(f, p1);
|
||||||
|
ShouldBeAt(after1, ContractSigningPlanPhase.ChoDuyet, 0, 2,
|
||||||
|
"MỘT người trong Cấp 1 duyệt là Cấp 1 xong ⇒ con-trỏ sang Cấp 2 CÙNG Bước; "
|
||||||
|
+ "nếu quy trình đòi đủ cả A lẫn B (AND) thì con-trỏ còn đứng ở Cấp 1");
|
||||||
|
|
||||||
|
await ActAsync(f, As(b), p2, "approve", "B đồng ý đơn giá");
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, p2), ContractSigningPlanPhase.ChoDuyet, 0, 2,
|
||||||
|
"phiếu song sinh: người CÒN LẠI của cùng Cấp duyệt cũng phải tiến y hệt — "
|
||||||
|
+ "chỉ 1 trong 2 người tiến được là dấu hiệu match cứng theo NV đầu danh sách");
|
||||||
|
|
||||||
|
// Chưa phải cấp cuối ⇒ TUYỆT ĐỐI chưa được chốt giá.
|
||||||
|
after1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||||
|
"giá chốt CHỈ sinh tại cấp cuối cùng — chốt sớm là ký thay người chưa duyệt");
|
||||||
|
|
||||||
|
// ---- (c) ĐÚNG 1 dòng ý kiến / phiếu, trỏ Level của CHÍNH người ký.
|
||||||
|
var op1 = await ReadOpinionsAsync(f, p1);
|
||||||
|
op1.Should().ContainSingle("mỗi lượt duyệt ghi đúng 1 dòng ý kiến cho Cấp đó (UNIQUE PlanId × LevelId)");
|
||||||
|
op1[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, a.Id),
|
||||||
|
"ý kiến phải gắn vào row Level CỦA NGƯỜI KÝ; gắn nhầm row của B thì màn Ý kiến cấp duyệt "
|
||||||
|
+ "hiện chữ ký treo dưới tên người chưa hề bấm nút");
|
||||||
|
op1[0].SignedByUserId.Should().Be(a.Id);
|
||||||
|
|
||||||
|
var op2 = await ReadOpinionsAsync(f, p2);
|
||||||
|
op2.Should().ContainSingle();
|
||||||
|
op2[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, b.Id),
|
||||||
|
"hai phiếu song sinh ký bởi hai người khác nhau ⇒ hai LevelId khác nhau");
|
||||||
|
op2[0].SignedByUserId.Should().Be(b.Id);
|
||||||
|
|
||||||
|
// ---- (b) ca ÂM lớp 1: C có chân trong quy trình nhưng Ở CẤP KHÁC, chưa tới lượt.
|
||||||
|
Func<Task> actWrongLevel = async () => await ActAsync(f, As(c), p3, "approve", "duyệt vượt lượt");
|
||||||
|
await actWrongLevel.Should().ThrowAsync<ForbiddenException>(
|
||||||
|
"NV Cấp 2 KHÔNG được ký thay Cấp 1 — đây là chỗ mất trắng vòng kiểm soát nếu lọt");
|
||||||
|
|
||||||
|
// ---- (b) ca ÂM lớp 2: người ngoài quy trình.
|
||||||
|
Func<Task> actOutsider = async () => await ActAsync(f, As(outsider), p3, "approve", "tôi ký hộ");
|
||||||
|
await actOutsider.Should().ThrowAsync<ForbiddenException>(
|
||||||
|
"người không có trong Cấp nào của quy trình thì không phải approver");
|
||||||
|
|
||||||
|
// Ném rồi mà vẫn ghi còn tệ hơn không rào: phiếu p3 phải NGUYÊN VẸN.
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, p3), ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||||
|
"hai lần bị chặn ⇒ con-trỏ p3 bất động");
|
||||||
|
(await ReadOpinionsAsync(f, p3)).Should().BeEmpty("bị chặn thì KHÔNG được để lại chữ ký");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (T2) Con-trỏ ĐÔI — acceptance #2 (spec §③-B `:29`).
|
||||||
|
//
|
||||||
|
// 3 Bước × 2 Cấp, chạy trọn 6 lượt duyệt. Hai điểm dễ hỏng nhất:
|
||||||
|
// • hết Cấp trong Bước ⇒ sang Bước kế **và Cấp phải RESET về 1** (quên reset thì Bước 2
|
||||||
|
// bắt đầu từ Cấp 2, Cấp 1 của Bước 2 bị bỏ qua vĩnh viễn — mất một chữ ký thật);
|
||||||
|
// • `CurrentWorkflowStepIndex` là **INDEX** vào list đã sort, KHÔNG phải `Step.Order`.
|
||||||
|
// Seed cố ý đặt Order = 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn;
|
||||||
|
// mã W2 đã đọc theo INDEX (`ContractSigningPlanFeatures.cs:256-260`) nên lệch semantic
|
||||||
|
// ở đây = inbox mù ngay lập tức.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_NewStep_ResetsLevelOrderToOne()
|
||||||
|
{
|
||||||
|
using var f = new KhkkWorkflowFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
var s1c1 = await AddUserAsync(f, "PMH cấp 1");
|
||||||
|
var s1c2 = await AddUserAsync(f, "PMH cấp 2");
|
||||||
|
var s2c1 = await AddUserAsync(f, "CCM cấp 1");
|
||||||
|
var s2c2 = await AddUserAsync(f, "CCM cấp 2");
|
||||||
|
var s3c1 = await AddUserAsync(f, "Thư ký CEO");
|
||||||
|
var s3c2 = await AddUserAsync(f, "CEO");
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-3TRAM", steps:
|
||||||
|
[
|
||||||
|
[[s1c1.Id], [s1c2.Id]], // Bước 1 (PMH)
|
||||||
|
[[s2c1.Id], [s2c2.Id]], // Bước 2 (CCM)
|
||||||
|
[[s3c1.Id], [s3c2.Id]], // Bước 3 (CEO)
|
||||||
|
],
|
||||||
|
stepOrderStart: 5, stepOrderStride: 10); // Order 5/15/25 ≠ index 0/1/2
|
||||||
|
|
||||||
|
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/010",
|
||||||
|
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 900m, Proposed: 800m)]);
|
||||||
|
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1, "sanity seed");
|
||||||
|
|
||||||
|
// 1) Trong cùng Bước 1: Cấp 1 → Cấp 2.
|
||||||
|
var r1 = await ActAsync(f, As(s1c1), planId, "approve", "Bước 1 Cấp 1");
|
||||||
|
var p1 = await ReadPlanAsync(f, planId);
|
||||||
|
ShouldBeAt(p1, ContractSigningPlanPhase.ChoDuyet, 0, 2, "còn Cấp trong Bước ⇒ chỉ tăng Cấp, Bước giữ nguyên");
|
||||||
|
ResultShouldMirror(r1, p1);
|
||||||
|
|
||||||
|
// 2) Hết Cấp Bước 1 ⇒ Bước 2 (index 1) + Cấp RESET về 1.
|
||||||
|
var r2 = await ActAsync(f, As(s1c2), planId, "approve", "Bước 1 Cấp 2");
|
||||||
|
var p2 = await ReadPlanAsync(f, planId);
|
||||||
|
ShouldBeAt(p2, ContractSigningPlanPhase.ChoDuyet, 1, 1,
|
||||||
|
"sang Bước MỚI thì Cấp phải reset về 1; giữ nguyên 2 là Cấp 1 của Bước 2 không bao giờ được ký");
|
||||||
|
ResultShouldMirror(r2, p2);
|
||||||
|
|
||||||
|
// 3) Bước 2: Cấp 1 → Cấp 2.
|
||||||
|
await ActAsync(f, As(s2c1), planId, "approve", "Bước 2 Cấp 1");
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 2, "trong Bước 2");
|
||||||
|
|
||||||
|
// 4) Reset LẦN HAI — một lần đúng có thể do hardcode, hai lần thì không.
|
||||||
|
await ActAsync(f, As(s2c2), planId, "approve", "Bước 2 Cấp 2");
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 2, 1,
|
||||||
|
"reset Cấp lần 2 (Bước 3) — chứng đây là quy tắc, không phải trùng hợp ở Bước 2");
|
||||||
|
|
||||||
|
// 5) Bước 3 Cấp 1 → Cấp 2: chưa xong, TUYỆT ĐỐI chưa DaDuyet.
|
||||||
|
await ActAsync(f, As(s3c1), planId, "approve", "Bước 3 Cấp 1");
|
||||||
|
var p5 = await ReadPlanAsync(f, planId);
|
||||||
|
ShouldBeAt(p5, ContractSigningPlanPhase.ChoDuyet, 2, 2,
|
||||||
|
"còn Cấp cuối chưa ký ⇒ chưa được kết thúc (W3 đường THƯỜNG: CEO ký thật, không kết-thúc-sớm)");
|
||||||
|
|
||||||
|
// 6) Cấp cuối Bước cuối ⇒ terminal, HAI con-trỏ về null.
|
||||||
|
var r6 = await ActAsync(f, As(s3c2), planId, "approve", "CEO duyệt");
|
||||||
|
var p6 = await ReadPlanAsync(f, planId);
|
||||||
|
ShouldBeAt(p6, ContractSigningPlanPhase.DaDuyet, null, null,
|
||||||
|
"hết Bước cuối ⇒ DaDuyet và cả 2 con-trỏ = null (`ContractSigningPlan.cs:34` 'Null khi terminal'); "
|
||||||
|
+ "để sót con-trỏ thì phiếu đã duyệt vẫn nằm trong hộp thư chờ của ai đó");
|
||||||
|
ResultShouldMirror(r6, p6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (T3) Finalize chốt giá — acceptance #3 (spec §③-B `:30`).
|
||||||
|
//
|
||||||
|
// Hai thứ được đo cùng lúc:
|
||||||
|
// • `AND PlanId = @id`: phiếu CHỨNG NHÂN (planB, cùng quy trình, cùng đang chờ) phải
|
||||||
|
// BẤT ĐỘNG. Bug thật của lớp này là `Where(l => l.ApprovedAmount == null)` quên vế
|
||||||
|
// PlanId — nó chốt giá cho mọi phiếu đang mở trong hệ thống, im lặng và không hoàn lại được.
|
||||||
|
// • **2 WRITE-PATH** (spec F-B1): duyệt-cấp-cuối THƯỜNG *và* Admin duyệt-thay (bypass
|
||||||
|
// danh sách approver — khuôn nguồn copy `ContractWorkflowService.cs:255` `if (!isAdmin…)`).
|
||||||
|
// Helper `ApplyApprovedValuesOnFinalize` phải được gọi ở CẢ HAI; gọi một chỗ thì
|
||||||
|
// phiếu do Admin chốt sẽ ở DaDuyet mà không có giá — GĐ3 sinh HĐ 0đ.
|
||||||
|
//
|
||||||
|
// Bẫy nguồn số: `ProposedAmount` ≠ `PeReferenceAmount` ở mọi dòng ⇒ lấy nhầm cột là lộ
|
||||||
|
// ngay (`ApprovedAmount ??= ProposedAmount` — spec §②-3a). Dòng thứ 3 đã có giá sẵn ⇒
|
||||||
|
// đo đúng ngữ nghĩa `??=` (giữ, KHÔNG đè).
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan()
|
||||||
|
{
|
||||||
|
using var f = new KhkkWorkflowFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
var approver = await AddUserAsync(f, "CEO ký duyệt");
|
||||||
|
var adminUser = await AddUserAsync(f, "Quản trị hệ thống");
|
||||||
|
var admin = As(adminUser, AppRoles.Admin); // KHÔNG có chân trong quy trình
|
||||||
|
|
||||||
|
// 1 Bước × 1 Cấp ⇒ một lượt duyệt là terminal.
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-1CAP", steps: [[[approver.Id]]]);
|
||||||
|
|
||||||
|
var planA = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/020",
|
||||||
|
ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||||
|
[
|
||||||
|
new(PeReference: 999m, Proposed: 111m),
|
||||||
|
new(PeReference: 888m, Proposed: 222m),
|
||||||
|
new(PeReference: 777m, Proposed: 333m, Approved: 300m), // đã có giá chốt sẵn
|
||||||
|
]);
|
||||||
|
var planB = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/021",
|
||||||
|
ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||||
|
[new(PeReference: 555m, Proposed: 55m), new(PeReference: 444m, Proposed: 44m)]);
|
||||||
|
var planC = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/022",
|
||||||
|
ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||||
|
[new(PeReference: 222m, Proposed: 22m), new(PeReference: 111m, Proposed: 11m)]);
|
||||||
|
|
||||||
|
// ---- WRITE-PATH 1: approver Cấp cuối duyệt.
|
||||||
|
await ActAsync(f, As(approver), planA, "approve", "Chốt giá theo đề xuất BCH");
|
||||||
|
|
||||||
|
var a = await ReadPlanAsync(f, planA);
|
||||||
|
a.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Cấp cuối Bước cuối ⇒ terminal");
|
||||||
|
a.Lines.Should().NotContain(l => l.ApprovedAmount == null,
|
||||||
|
"acceptance: `Lines WHERE PlanId=@id AND ApprovedAmount IS NULL` = 0");
|
||||||
|
|
||||||
|
var byProposed = a.Lines.ToDictionary(l => l.ProposedAmount);
|
||||||
|
byProposed[111m].ApprovedAmount.Should().Be(111m,
|
||||||
|
"giá chốt lấy từ `ProposedAmount` (BCH đề xuất), KHÔNG phải `PeReferenceAmount` 999 — "
|
||||||
|
+ "lấy nhầm cột thì phiếu chốt bằng số tham chiếu cũ của phiếu Duyệt NCC");
|
||||||
|
byProposed[222m].ApprovedAmount.Should().Be(222m);
|
||||||
|
byProposed[333m].ApprovedAmount.Should().Be(300m,
|
||||||
|
"`??=` GIỮ giá đã chốt sẵn (300), không đè bằng 333 — đè là xoá số người có thẩm quyền đã sửa");
|
||||||
|
|
||||||
|
// ---- Chứng nhân: phiếu hàng xóm BẤT ĐỘNG (vế `AND PlanId=@id`).
|
||||||
|
var bAfter1 = await ReadPlanAsync(f, planB);
|
||||||
|
bAfter1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||||
|
"phiếu KHÁC tuyệt đối không được dính giá chốt — thiếu vế PlanId là chốt giá cả hệ thống");
|
||||||
|
bAfter1.Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet, "và cũng không được nhảy phase");
|
||||||
|
|
||||||
|
// ---- WRITE-PATH 2: Admin duyệt thay (không nằm trong Cấp nào) — spec F-B1.
|
||||||
|
admin.Roles.Should().Contain(AppRoles.Admin, "actor của write-path 2 phải THỰC SỰ mang role Admin");
|
||||||
|
await ActAsync(f, admin, planC, "approve", "Admin duyệt thay CEO");
|
||||||
|
|
||||||
|
var c = await ReadPlanAsync(f, planC);
|
||||||
|
c.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Admin bypass danh sách approver (khuôn `ContractWorkflowService.cs:255`)");
|
||||||
|
c.Lines.Should().NotContain(l => l.ApprovedAmount == null,
|
||||||
|
"đường Admin cũng PHẢI đi qua choke-point chốt giá — đây là write-path thứ 2 mà "
|
||||||
|
+ "grep literal không nhìn thấy (spec F-B1)");
|
||||||
|
c.Lines.Single(l => l.ProposedAmount == 22m).ApprovedAmount.Should().Be(22m);
|
||||||
|
|
||||||
|
// Chứng nhân lần 2 — sau CẢ HAI write-path vẫn phải sạch.
|
||||||
|
(await ReadPlanAsync(f, planB)).Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||||
|
"qua 2 đường finalize, phiếu chứng nhân vẫn không được đụng tới");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (T4) Inbox — acceptance #4 (spec §③-B `:31`).
|
||||||
|
//
|
||||||
|
// 🔴 Test DUY NHẤT trong 6 cái KHÔNG chạm service ⇒ **XANH ngay đợt này** (query handler
|
||||||
|
// W2 đã wire: `ContractSigningPlanFeatures.cs:725-752`). Cố ý không ép đỏ. Nó gánh thêm
|
||||||
|
// vai trò lưới an toàn cho 5 test kia: dùng CHUNG bộ seed helper, nên nếu seed sai FK
|
||||||
|
// hay sai con-trỏ thì test này đỏ TRƯỚC và ta biết ngay là lỗi test chứ không phải lỗi sản phẩm.
|
||||||
|
//
|
||||||
|
// Ba lớp phủ: đúng-Cấp (A, B thấy — OR-of-N) · khác-Cấp (C KHÔNG thấy phiếu Cấp 1) ·
|
||||||
|
// ngoài-quy-trình + người-soạn (rỗng). Cộng phiếu nháp: KHÔNG vào hộp thư của ai.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task Inbox_OnlyPendingLevelApprover_SeesPlan()
|
||||||
|
{
|
||||||
|
using var f = new KhkkWorkflowFixture();
|
||||||
|
var drafterUser = await AddUserAsync(f, "BCH lập kế hoạch");
|
||||||
|
var drafter = As(drafterUser);
|
||||||
|
var a = await AddUserAsync(f, "NV Cung ứng A");
|
||||||
|
var b = await AddUserAsync(f, "NV Cung ứng B");
|
||||||
|
var c = await AddUserAsync(f, "Trưởng phòng C");
|
||||||
|
var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-INBOX", steps:
|
||||||
|
[
|
||||||
|
[[a.Id, b.Id], [c.Id]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
|
||||||
|
// Tên biến = Cấp mà phiếu ĐANG CHỜ: `pendingLevel1` đứng ở Cấp 1 (việc của A/B),
|
||||||
|
// `pendingLevel2` ở Cấp 2 (việc của C) — có ĐỦ CẢ HAI thì ca ÂM "cấp khác không thấy"
|
||||||
|
// mới đo được cả hai chiều.
|
||||||
|
// 🔧 [W3 đợt-3 sửa SEED] Dòng đầu trước đó là `0, 2` kèm chú "FAULT-INJECT tạm" — vết
|
||||||
|
// của lượt tự-thử-lưới còn sót, làm CẢ HAI phiếu cùng đứng Cấp 2 ⇒ hộp thư A/B rỗng và
|
||||||
|
// C thấy 2 phiếu. Đỏ vì SEED, KHÔNG phải vì sản phẩm (query handler W2 không đổi một
|
||||||
|
// dòng nào ở đợt này). Mọi assert giữ NGUYÊN CHỮ.
|
||||||
|
var pendingLevel1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/030",
|
||||||
|
ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
|
||||||
|
var pendingLevel2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/031",
|
||||||
|
ContractSigningPlanPhase.ChoDuyet, 0, 2, oneLine);
|
||||||
|
var draft = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/032",
|
||||||
|
ContractSigningPlanPhase.DangSoanThao, null, null, oneLine);
|
||||||
|
|
||||||
|
// A và B cùng Cấp 1 (OR-of-N) ⇒ CẢ HAI cùng thấy đúng một phiếu.
|
||||||
|
(await InboxAsync(f, As(a))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
|
||||||
|
"approver của Cấp ĐANG CHỜ thấy phiếu; phiếu đang ở Cấp 2 thì không phải việc của A");
|
||||||
|
(await InboxAsync(f, As(b))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
|
||||||
|
"OR-of-N: người thứ hai cùng Cấp cũng phải thấy, nếu không thì cả Cấp phụ thuộc 1 người");
|
||||||
|
|
||||||
|
// C ở Cấp 2 — thấy ĐÚNG phiếu đang chờ Cấp 2, KHÔNG thấy phiếu đang ở Cấp 1 (ca ÂM).
|
||||||
|
(await InboxAsync(f, As(c))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel2 },
|
||||||
|
"cấp khác KHÔNG được thấy phiếu chưa tới lượt mình — thấy = duyệt vượt lượt ngay từ hộp thư");
|
||||||
|
|
||||||
|
(await InboxAsync(f, As(outsider))).Should().BeEmpty("người ngoài quy trình không có việc gì ở đây");
|
||||||
|
(await InboxAsync(f, drafter)).Should().BeEmpty(
|
||||||
|
"người SOẠN không phải người DUYỆT — hộp thư là 'chờ tôi duyệt', không phải 'phiếu của tôi'");
|
||||||
|
|
||||||
|
// Phiếu nháp không xuất hiện ở bất kỳ hộp thư nào (con-trỏ null).
|
||||||
|
var allInboxIds = (await InboxAsync(f, As(a))).Concat(await InboxAsync(f, As(b)))
|
||||||
|
.Concat(await InboxAsync(f, As(c))).Select(i => i.Id).ToList();
|
||||||
|
allInboxIds.Should().NotContain(draft, "phiếu Nháp chưa trình thì chưa chờ ai duyệt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (T5) UPSERT ý kiến — acceptance #5 (spec §③-B `:32`).
|
||||||
|
//
|
||||||
|
// "Duyệt 2 lần CÙNG MỘT CẤP" chỉ xảy ra theo đúng một đường ở W3 đường-THƯỜNG: ký →
|
||||||
|
// bị trả lại → gửi lại → ký lại. Dùng CHÍNH một người cho cả hai lượt để `LevelId` không
|
||||||
|
// đổi, nhờ vậy phép đo "1 row" là rõ nghĩa dưới mọi cách đọc.
|
||||||
|
//
|
||||||
|
// Nếu đợt-3 viết `Add(...)` thẳng thay vì UPSERT: hoặc nổ UNIQUE (PlanId, LevelId), hoặc
|
||||||
|
// đẻ dòng thứ hai — cả hai đều bị bắt ở đây. Nếu ghi đúng 1 row nhưng quên cập nhật
|
||||||
|
// `SignedByUserId`/`SignedByFullName`/`Comment` thì đó là chữ ký cũ đứng tên cho lần duyệt mới.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow()
|
||||||
|
{
|
||||||
|
using var f = new KhkkWorkflowFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
var a = await AddUserAsync(f, "NV Cung ứng A");
|
||||||
|
var b = await AddUserAsync(f, "NV Cung ứng B");
|
||||||
|
var c = await AddUserAsync(f, "Trưởng phòng C");
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-UPSERT", steps:
|
||||||
|
[
|
||||||
|
[[a.Id, b.Id], [c.Id]],
|
||||||
|
]);
|
||||||
|
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/040",
|
||||||
|
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 500m, Proposed: 480m)]);
|
||||||
|
var levelOfA = await LevelIdOfAsync(f, a.Id);
|
||||||
|
|
||||||
|
// ---- Lượt 1: A ký, KHÔNG nhập ý kiến.
|
||||||
|
await ActAsync(f, As(a), planId, "approve", comment: null);
|
||||||
|
|
||||||
|
var first = await ReadOpinionsAsync(f, planId);
|
||||||
|
first.Should().ContainSingle("lượt ký đầu tiên tạo 1 dòng");
|
||||||
|
first[0].ApprovalWorkflowLevelId.Should().Be(levelOfA);
|
||||||
|
first[0].SignedAt.Should().Be(Clock2026,
|
||||||
|
"thời điểm ký lấy từ `IDateTime` được bơm vào service — dùng `DateTime.UtcNow` trực tiếp "
|
||||||
|
+ "là không test được và lệch giờ máy chủ");
|
||||||
|
first[0].Comment.Should().NotBeNullOrWhiteSpace(
|
||||||
|
"ý kiến rỗng phải thành chữ thay thế (`ContractSigningPlanLevelOpinion.cs:26` 'hoặc placeholder', "
|
||||||
|
+ "khuôn nguồn copy `ContractWorkflowService.cs:295-297`) — để trống thì màn Ý kiến hiện ô câm, "
|
||||||
|
+ "không phân biệt được 'đã ký không ý kiến' với 'chưa ký'");
|
||||||
|
|
||||||
|
// ---- Trả lại rồi gửi lại ⇒ con-trỏ quay về Cấp 1, tới lượt A LẦN NỮA.
|
||||||
|
await ActAsync(f, As(c), planId, "return", "Thiếu bảng chiết tính — trả lại BCH");
|
||||||
|
(await ReadPlanAsync(f, planId)).Phase.Should().Be(ContractSigningPlanPhase.TraLai);
|
||||||
|
|
||||||
|
await ActAsync(f, drafter, planId, "submit", "Đã bổ sung chiết tính");
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||||
|
"gửi lại thì chạy LẠI từ Bước 1 Cấp 1 ⇒ đúng lượt A");
|
||||||
|
|
||||||
|
// ---- Lượt 2: CHÍNH A ký lại CÙNG Cấp đó, lần này có ý kiến.
|
||||||
|
await ActAsync(f, As(a), planId, "approve", "Đã rà lại đơn giá — thống nhất ký");
|
||||||
|
|
||||||
|
var second = await ReadOpinionsAsync(f, planId);
|
||||||
|
second.Should().ContainSingle(
|
||||||
|
"duyệt lần 2 cùng Cấp là CẬP NHẬT dòng cũ (UNIQUE PlanId × LevelId), KHÔNG đẻ dòng thứ 2");
|
||||||
|
second[0].ApprovalWorkflowLevelId.Should().Be(levelOfA, "vẫn là ý kiến của đúng Cấp đó");
|
||||||
|
second[0].Comment.Should().Be("Đã rà lại đơn giá — thống nhất ký",
|
||||||
|
"nội dung phải là ý kiến MỚI NHẤT (latest-write-wins) — giữ chữ cũ là hồ sơ nói sai sự thật");
|
||||||
|
second[0].SignedByUserId.Should().Be(a.Id, "người ký THẬT của lượt sau");
|
||||||
|
second[0].SignedByFullName.Should().Be(a.FullName,
|
||||||
|
"tên là snapshot chụp lúc ký (`ContractSigningPlanLevelOpinion.cs:29`) — bỏ trống thì bản in "
|
||||||
|
+ "tờ FO-002.01 ra ô tên rỗng");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// (T6) Trả lại → gửi lại — acceptance #6 (spec §③-B `:33`).
|
||||||
|
//
|
||||||
|
// Mấu chốt: phiếu bị trả lại khi ĐÃ ĐI QUA HẾT Bước 1 và đang đứng ở Bước 2. Nếu đợt-3
|
||||||
|
// hiểu "gửi lại" là *nối tiếp chỗ đang dở* thì con-trỏ sẽ về (Bước 2, Cấp 1) — trông rất
|
||||||
|
// hợp lý, và hậu quả là bản sửa của BCH KHÔNG được Bước 1 xem lại lần nào. Vì thế phải
|
||||||
|
// trả lại đầu quy trình: (Bước 1, Cấp 1).
|
||||||
|
//
|
||||||
|
// Lượt duyệt số 6 (A ký lại được) là vế chứng minh vòng chạy lại là THẬT, không phải chỉ
|
||||||
|
// đặt lại hai con số rồi khoá.
|
||||||
|
// ===================================================================
|
||||||
|
[Fact]
|
||||||
|
public async Task SendBack_Resubmit_RestartsFromStepOneLevelOne()
|
||||||
|
{
|
||||||
|
using var f = new KhkkWorkflowFixture();
|
||||||
|
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||||
|
var a = await AddUserAsync(f, "PMH cấp 1");
|
||||||
|
var b = await AddUserAsync(f, "PMH cấp 2");
|
||||||
|
var c = await AddUserAsync(f, "CCM Bước 2");
|
||||||
|
|
||||||
|
var wf = await SeedWorkflowAsync(f, "QT-KHKK-TRALAI", steps:
|
||||||
|
[
|
||||||
|
[[a.Id], [b.Id]], // Bước 1: 2 Cấp
|
||||||
|
[[c.Id]], // Bước 2: 1 Cấp
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Phiếu bắt đầu ở NHÁP, con-trỏ trống — đúng trạng thái sau khi W2 lập phiếu.
|
||||||
|
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/050",
|
||||||
|
ContractSigningPlanPhase.DangSoanThao, null, null, [new(PeReference: 700m, Proposed: 650m)]);
|
||||||
|
|
||||||
|
// 1) Trình lần đầu: đặt con-trỏ về (Bước 1, Cấp 1).
|
||||||
|
var rSubmit = await ActAsync(f, drafter, planId, "submit", "Trình duyệt kế hoạch ký kết");
|
||||||
|
var afterSubmit = await ReadPlanAsync(f, planId);
|
||||||
|
ShouldBeAt(afterSubmit, ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||||
|
"trình = mở quy trình từ Bước 1 Cấp 1");
|
||||||
|
ResultShouldMirror(rSubmit, afterSubmit);
|
||||||
|
|
||||||
|
// 2-3) Đi hết Bước 1 để phiếu ĐANG ĐỨNG Ở BƯỚC 2 lúc bị trả lại.
|
||||||
|
await ActAsync(f, As(a), planId, "approve", "Bước 1 Cấp 1 duyệt");
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2, "sang Cấp 2");
|
||||||
|
await ActAsync(f, As(b), planId, "approve", "Bước 1 Cấp 2 duyệt");
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 1,
|
||||||
|
"hết Bước 1 ⇒ đang chờ Bước 2 Cấp 1 (đây là mốc để câu hỏi 'gửi lại về đâu' có nghĩa)");
|
||||||
|
|
||||||
|
// 4) Bước 2 trả lại.
|
||||||
|
var rReturn = await ActAsync(f, As(c), planId, "return", "Sai khối lượng hạng mục 3 — trả lại");
|
||||||
|
var afterReturn = await ReadPlanAsync(f, planId);
|
||||||
|
ShouldBeAt(afterReturn, ContractSigningPlanPhase.TraLai, null, null,
|
||||||
|
"Trả lại toàn phần: phase TraLai + XOÁ con-trỏ (`ContractSigningPlanWorkflowService.cs:116`) — "
|
||||||
|
+ "để sót con-trỏ thì phiếu vừa nằm ở tay người soạn vừa nằm trong hộp thư người duyệt");
|
||||||
|
ResultShouldMirror(rReturn, afterReturn);
|
||||||
|
afterReturn.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||||
|
"bị trả lại thì không có giá nào được chốt");
|
||||||
|
|
||||||
|
// 5) 🔴 Gửi lại: PHẢI về đầu quy trình, KHÔNG nối tiếp Bước 2.
|
||||||
|
await ActAsync(f, drafter, planId, "submit", "Đã sửa khối lượng, trình lại");
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||||
|
"gửi lại chạy LẠI TỪ ĐẦU (Bước 1 Cấp 1); về (1,1) tức Bước 1 không hề xem bản sửa");
|
||||||
|
|
||||||
|
// 6) Vòng mới là thật: A có lượt lại và duyệt được.
|
||||||
|
await ActAsync(f, As(a), planId, "approve", "Xem lại bản sửa — đồng ý");
|
||||||
|
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2,
|
||||||
|
"A ký được lần nữa ⇒ vòng duyệt chạy lại thật sự, không phải con-trỏ bị đặt lại rồi khoá");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user