diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 619dd6f..505265f 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -73,14 +73,10 @@ const NCC_PALETTES = [ // nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn // NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá. function computeGiaChaoThau(ev: PeDetailBundle): number | null { - const winnerSupplierRowId = ev.selectedSupplierId - ? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null - : null - if (winnerSupplierRowId === null) return null - return ev.details - .flatMap(d => d.quotes) - .filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId) - .reduce((sum, q) => sum + q.thanhTien, 0) + // [Mig 58] Multi-winner: null khi CHƯA chọn đơn vị nào; else = winnerQuoteTotal (BE + // SUM ThanhTien MỌI đơn vị isWinner — single hoặc liên-danh ≥2). 0 = winner chưa báo giá. + if (!ev.suppliers.some(s => s.isWinner)) return null + return ev.winnerQuoteTotal } // Main detail content — flat render 3 section không tabs. @@ -194,8 +190,9 @@ export function PeDetailTabs({ // Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch. const missingForApproval = useMemo(() => { const missing: string[] = [] - // 1. Chưa chọn Đơn vị NCC/TP - if (evaluation.selectedSupplierId == null) { + // 1. Chưa chọn Đơn vị NCC/TP ([Mig 58] multi-winner: ≥1 đơn vị isWinner; selectedSupplierId + // null khi liên-danh ≥2 nên KHÔNG dùng nó — mirror BE submit-guard IsWinner). + if (!evaluation.suppliers.some(s => s.isWinner)) { missing.push("Chưa chọn Đơn vị NCC/TP") } else { // 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0). @@ -207,8 +204,10 @@ export function PeDetailTabs({ // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0. // [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) — // mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được). + // [Mig 58 — anh Kiệt FDC] NS = 0 là HỢP LỆ (gói cho-không / vật tư cấp sẵn). Chỉ + // block khi CHƯA khai (null). Mirror BE submit-guard nới lỏng cùng đợt. const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null - if (effBudget == null || effBudget <= 0) { + if (effBudget == null) { missing.push("Chưa nhập Ngân sách kỳ này") } // 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3). @@ -227,14 +226,14 @@ export function PeDetailTabs({ // [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan // các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút. const submitChecklist = useMemo(() => { - const supplierOk = evaluation.selectedSupplierId != null + const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner const gia = supplierOk ? computeGiaChaoThau(evaluation) : null const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null return [ { label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null }, { label: 'Đơn vị NCC/TP được chọn', ok: supplierOk }, { label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 }, - { label: 'Ngân sách kỳ này', ok: effBudget != null && effBudget > 0 }, + { label: 'Ngân sách kỳ này', ok: effBudget != null }, { label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) }, ] }, [evaluation]) @@ -929,30 +928,40 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo ) } -// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) ===== -// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3 -// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)". -// Save dùng POST /pe/:id/select-winner endpoint hiện có. +// ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] ===== +// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng +// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner). +// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`; +// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner). function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { const canEdit = !readOnly && isEditablePhase(ev.phase) const qc = useQueryClient() - const setWinner = useMutation({ - mutationFn: async (supplierId: string | null) => - api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), + // gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker. + const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0 + const setWinners = useMutation({ + mutationFn: async (supplierIds: string[]) => + api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }), onSuccess: () => { - toast.success('Đã cập nhật NCC được chọn.') + toast.success('Đã cập nhật đơn vị trúng thầu.') qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) qc.invalidateQueries({ queryKey: ['pe-list'] }) }, onError: e => toast.error(getErrorMessage(e)), }) + const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId) + const toggle = (supplierId: string, on: boolean) => + setWinners.mutate(on + ? [...winnerIds, supplierId] + : winnerIds.filter(id => id !== supplierId)) + const busy = setWinners.isPending || peFetching if (!canEdit) { + const winners = ev.suppliers.filter(s => s.isWinner) return ( ✓ {ev.selectedSupplierName} + value={winners.length > 0 + ? ✓ {winners.map(s => s.supplierName).join(', ')} : — (chưa chọn)} /> ) @@ -962,30 +971,49 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
a. NCC / TP được chọn
- - {/* Loading spinner inline khi save có delay (user 2026-05-07) */} - {setWinner.isPending && ( -
-
- Đang chọn NCC + sync cột giá Section 4… + {ev.suppliers.length === 0 ? ( +

+ Thêm NCC ở Section 3 trước rồi mới chọn trúng thầu. +

+ ) : ( +
+ {ev.suppliers.map(s => ( + + ))}
)} - {ev.suppliers.length === 0 && ( -

- Thêm NCC ở Section 3 trước rồi mới chọn winner. -

+ {/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */} + {winnerIds.length > 0 && ( +
+ Tổng chi phí đã chọn ({winnerIds.length} đơn vị): + {fmtMoney(ev.winnerQuoteTotal)} đ +
+ )} + {busy && ( +
+
+ Đang cập nhật trúng thầu + sync cột giá Section 4… +
)}
@@ -1483,7 +1511,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: label="4. Đề xuất kỳ này — Tên thầu phụ / NCC" value={ - {ev.selectedSupplierName ?? — (chưa chọn)} + {ev.suppliers.some(s => s.isWinner) + ? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') + : — (chưa chọn)} } /> @@ -1793,14 +1823,12 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo // ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) ===== function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { - const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId + const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner) const [createOpen, setCreateOpen] = useState(false) // c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper // module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt. - const winnerSupplierRowId = ev.selectedSupplierId - ? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null - : null + const hasWinner = ev.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner const giaChaoThau = computeGiaChaoThau(ev) // d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi @@ -1819,7 +1847,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly — (chọn NCC/TP ở (a) trước) ) : giaChaoThau === 0 ? ( — (chưa nhập báo giá ở Section 4) @@ -2049,7 +2077,7 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun >

- NCC: {evaluation.selectedSupplierName} · Dự án: {evaluation.projectName} + NCC: {evaluation.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') || evaluation.selectedSupplierName} · Dự án: {evaluation.projectName}

@@ -2436,12 +2464,19 @@ function HangMucCard({ onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onError: e => toast.error(getErrorMessage(e)), }) - const setWinner = useMutation({ - mutationFn: async (supplierId: string | null) => - api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), - onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, + // [Mig 58 anh Kiệt FDC] Multi-winner: gửi TOÀN BỘ tập ID trúng thầu (chọn ≥2 = liên + // danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render. + const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId) + const setWinners = useMutation({ + mutationFn: async (supplierIds: string[]) => + api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }), + onSuccess: () => { toast.success('Đã cập nhật đơn vị trúng thầu.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onError: e => toast.error(getErrorMessage(e)), }) + const toggleWinner = (supplierId: string, on: boolean) => + setWinners.mutate(on + ? [...winnerIds, supplierId] + : winnerIds.filter(id => id !== supplierId)) return (
@@ -2537,7 +2572,7 @@ function HangMucCard({ {ev.suppliers.map((s, idx) => { const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null - const isWinner = ev.selectedSupplierId === s.supplierId + const isWinner = s.isWinner const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id)) const canDelete = !isWinner && !hasQuotes const openQuote = () => setQuoteEdit({ supplier: s, existing: q }) @@ -2606,12 +2641,13 @@ function HangMucCard({
diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index 155ed90..5c91e8d 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -110,6 +110,10 @@ export function PeWorkflowPanel({ const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1] const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1] const currentIsFinalApprover = lastFlowLevel?.status === 'Current' + // [Mig 58 item 4] Cấp Approver hiện tại được cấu hình KẾT THÚC trong Quy trình duyệt + // (admin bật per-slot ở Designer). Duyệt tới cấp này = TỰ kết thúc (BE auto theo cờ cấp, + // KHÔNG cần ô-tích). FE dùng cờ này để: (1) hiện banner cảnh báo + (2) bắt chọn giá chốt. + const approverFinalizeEligible = levelOptions?.allowApproverFinalize === true // Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null). const priceCandidates = ([ { source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal }, @@ -149,8 +153,9 @@ export function PeWorkflowPanel({ // Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai - // [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done. - const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm) + // [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done + // HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá). + const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) // [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi // chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE). // File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side). @@ -430,7 +435,7 @@ export function PeWorkflowPanel({ : `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}` // [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn. const isApproveAction = !isCancel && !isSendBack - const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm) + const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) // [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates // rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh // người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge). @@ -588,6 +593,16 @@ export function PeWorkflowPanel({
)} + {/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt: + BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */} + {isApproveAction && approverFinalizeEligible && ( +
+ ✅ Cấp này KẾT THÚC quy trình + + Được cấu hình sẵn trong Quy trình duyệt — bạn duyệt xong là phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt. + +
+ )} {/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done. "Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */} {shouldPickPrice && ( diff --git a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx index 4af0e45..235fb40 100644 --- a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx +++ b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx @@ -51,6 +51,7 @@ type LevelDto = { allowApproverEditDetails: boolean allowApproverEditBudget: boolean allowApproverSkipToFinal: boolean + allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot Duyệt = KẾT THÚC (không trình CEO) // [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role). canEditProBudget: boolean canEditCcmBudget: boolean @@ -106,6 +107,7 @@ type EditLevelEntry = { allowApproverEditDetails: boolean allowApproverEditBudget: boolean allowApproverSkipToFinal: boolean + allowApproverFinalize: boolean } type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] } @@ -154,6 +156,7 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] { allowApproverEditDetails: l.allowApproverEditDetails ?? false, allowApproverEditBudget: l.allowApproverEditBudget ?? false, allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false, + allowApproverFinalize: l.allowApproverFinalize ?? false, })), })) } @@ -171,6 +174,7 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL allowApproverEditDetails: false, allowApproverEditBudget: false, allowApproverSkipToFinal: false, + allowApproverFinalize: false, } } @@ -598,6 +602,7 @@ function Designer({ allowApproverEditDetails: e.allowApproverEditDetails, allowApproverEditBudget: e.allowApproverEditBudget, allowApproverSkipToFinal: e.allowApproverSkipToFinal, + allowApproverFinalize: e.allowApproverFinalize, })), })), }) @@ -989,6 +994,17 @@ function Designer({ /> Cho phép duyệt thẳng Cấp cuối khi đang duyệt + {/* [Mig 58 CCM item 4 — anh Kiệt FDC] Duyệt là KẾT THÚC: NV slot + này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương). */} +
) diff --git a/fe-admin/src/types/purchaseEvaluation.ts b/fe-admin/src/types/purchaseEvaluation.ts index 2d174ff..98048a3 100644 --- a/fe-admin/src/types/purchaseEvaluation.ts +++ b/fe-admin/src/types/purchaseEvaluation.ts @@ -150,6 +150,10 @@ export type PeSupplier = { paymentTermText: string | null note: string | null order: number + // [Mig 58 — anh Kiệt FDC] Multi-winner: đơn vị này có thuộc tập đơn vị trúng thầu + // (≥1 đơn vị được chọn) không. Nguồn-sự-thật cho checkbox "Chọn trúng thầu" — KHÔNG + // dùng selectedSupplierId (null khi joint ≥2 winner). winnerQuoteTotal = SUM mọi isWinner. + isWinner: boolean } export type PeQuote = { @@ -399,6 +403,7 @@ export type ApprovalWorkflowOptions = { allowApproverEditDetails: boolean allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối + allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO) } // Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index 619dd6f..505265f 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -73,14 +73,10 @@ const NCC_PALETTES = [ // nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn // NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá. function computeGiaChaoThau(ev: PeDetailBundle): number | null { - const winnerSupplierRowId = ev.selectedSupplierId - ? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null - : null - if (winnerSupplierRowId === null) return null - return ev.details - .flatMap(d => d.quotes) - .filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId) - .reduce((sum, q) => sum + q.thanhTien, 0) + // [Mig 58] Multi-winner: null khi CHƯA chọn đơn vị nào; else = winnerQuoteTotal (BE + // SUM ThanhTien MỌI đơn vị isWinner — single hoặc liên-danh ≥2). 0 = winner chưa báo giá. + if (!ev.suppliers.some(s => s.isWinner)) return null + return ev.winnerQuoteTotal } // Main detail content — flat render 3 section không tabs. @@ -194,8 +190,9 @@ export function PeDetailTabs({ // Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch. const missingForApproval = useMemo(() => { const missing: string[] = [] - // 1. Chưa chọn Đơn vị NCC/TP - if (evaluation.selectedSupplierId == null) { + // 1. Chưa chọn Đơn vị NCC/TP ([Mig 58] multi-winner: ≥1 đơn vị isWinner; selectedSupplierId + // null khi liên-danh ≥2 nên KHÔNG dùng nó — mirror BE submit-guard IsWinner). + if (!evaluation.suppliers.some(s => s.isWinner)) { missing.push("Chưa chọn Đơn vị NCC/TP") } else { // 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0). @@ -207,8 +204,10 @@ export function PeDetailTabs({ // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0. // [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) — // mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được). + // [Mig 58 — anh Kiệt FDC] NS = 0 là HỢP LỆ (gói cho-không / vật tư cấp sẵn). Chỉ + // block khi CHƯA khai (null). Mirror BE submit-guard nới lỏng cùng đợt. const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null - if (effBudget == null || effBudget <= 0) { + if (effBudget == null) { missing.push("Chưa nhập Ngân sách kỳ này") } // 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3). @@ -227,14 +226,14 @@ export function PeDetailTabs({ // [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan // các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút. const submitChecklist = useMemo(() => { - const supplierOk = evaluation.selectedSupplierId != null + const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner const gia = supplierOk ? computeGiaChaoThau(evaluation) : null const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null return [ { label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null }, { label: 'Đơn vị NCC/TP được chọn', ok: supplierOk }, { label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 }, - { label: 'Ngân sách kỳ này', ok: effBudget != null && effBudget > 0 }, + { label: 'Ngân sách kỳ này', ok: effBudget != null }, { label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) }, ] }, [evaluation]) @@ -929,30 +928,40 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo ) } -// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) ===== -// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3 -// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)". -// Save dùng POST /pe/:id/select-winner endpoint hiện có. +// ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] ===== +// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng +// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner). +// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`; +// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner). function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { const canEdit = !readOnly && isEditablePhase(ev.phase) const qc = useQueryClient() - const setWinner = useMutation({ - mutationFn: async (supplierId: string | null) => - api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), + // gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker. + const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0 + const setWinners = useMutation({ + mutationFn: async (supplierIds: string[]) => + api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }), onSuccess: () => { - toast.success('Đã cập nhật NCC được chọn.') + toast.success('Đã cập nhật đơn vị trúng thầu.') qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) qc.invalidateQueries({ queryKey: ['pe-list'] }) }, onError: e => toast.error(getErrorMessage(e)), }) + const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId) + const toggle = (supplierId: string, on: boolean) => + setWinners.mutate(on + ? [...winnerIds, supplierId] + : winnerIds.filter(id => id !== supplierId)) + const busy = setWinners.isPending || peFetching if (!canEdit) { + const winners = ev.suppliers.filter(s => s.isWinner) return ( ✓ {ev.selectedSupplierName} + value={winners.length > 0 + ? ✓ {winners.map(s => s.supplierName).join(', ')} : — (chưa chọn)} /> ) @@ -962,30 +971,49 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
a. NCC / TP được chọn
- - {/* Loading spinner inline khi save có delay (user 2026-05-07) */} - {setWinner.isPending && ( -
-
- Đang chọn NCC + sync cột giá Section 4… + {ev.suppliers.length === 0 ? ( +

+ Thêm NCC ở Section 3 trước rồi mới chọn trúng thầu. +

+ ) : ( +
+ {ev.suppliers.map(s => ( + + ))}
)} - {ev.suppliers.length === 0 && ( -

- Thêm NCC ở Section 3 trước rồi mới chọn winner. -

+ {/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */} + {winnerIds.length > 0 && ( +
+ Tổng chi phí đã chọn ({winnerIds.length} đơn vị): + {fmtMoney(ev.winnerQuoteTotal)} đ +
+ )} + {busy && ( +
+
+ Đang cập nhật trúng thầu + sync cột giá Section 4… +
)}
@@ -1483,7 +1511,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: label="4. Đề xuất kỳ này — Tên thầu phụ / NCC" value={ - {ev.selectedSupplierName ?? — (chưa chọn)} + {ev.suppliers.some(s => s.isWinner) + ? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') + : — (chưa chọn)} } /> @@ -1793,14 +1823,12 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo // ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) ===== function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { - const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId + const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner) const [createOpen, setCreateOpen] = useState(false) // c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper // module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt. - const winnerSupplierRowId = ev.selectedSupplierId - ? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null - : null + const hasWinner = ev.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner const giaChaoThau = computeGiaChaoThau(ev) // d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi @@ -1819,7 +1847,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly — (chọn NCC/TP ở (a) trước) ) : giaChaoThau === 0 ? ( — (chưa nhập báo giá ở Section 4) @@ -2049,7 +2077,7 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun >

- NCC: {evaluation.selectedSupplierName} · Dự án: {evaluation.projectName} + NCC: {evaluation.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') || evaluation.selectedSupplierName} · Dự án: {evaluation.projectName}

@@ -2436,12 +2464,19 @@ function HangMucCard({ onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onError: e => toast.error(getErrorMessage(e)), }) - const setWinner = useMutation({ - mutationFn: async (supplierId: string | null) => - api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), - onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, + // [Mig 58 anh Kiệt FDC] Multi-winner: gửi TOÀN BỘ tập ID trúng thầu (chọn ≥2 = liên + // danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render. + const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId) + const setWinners = useMutation({ + mutationFn: async (supplierIds: string[]) => + api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }), + onSuccess: () => { toast.success('Đã cập nhật đơn vị trúng thầu.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onError: e => toast.error(getErrorMessage(e)), }) + const toggleWinner = (supplierId: string, on: boolean) => + setWinners.mutate(on + ? [...winnerIds, supplierId] + : winnerIds.filter(id => id !== supplierId)) return (
@@ -2537,7 +2572,7 @@ function HangMucCard({ {ev.suppliers.map((s, idx) => { const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null - const isWinner = ev.selectedSupplierId === s.supplierId + const isWinner = s.isWinner const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id)) const canDelete = !isWinner && !hasQuotes const openQuote = () => setQuoteEdit({ supplier: s, existing: q }) @@ -2606,12 +2641,13 @@ function HangMucCard({
diff --git a/fe-user/src/components/pe/PeWorkflowPanel.tsx b/fe-user/src/components/pe/PeWorkflowPanel.tsx index 155ed90..5c91e8d 100644 --- a/fe-user/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-user/src/components/pe/PeWorkflowPanel.tsx @@ -110,6 +110,10 @@ export function PeWorkflowPanel({ const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1] const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1] const currentIsFinalApprover = lastFlowLevel?.status === 'Current' + // [Mig 58 item 4] Cấp Approver hiện tại được cấu hình KẾT THÚC trong Quy trình duyệt + // (admin bật per-slot ở Designer). Duyệt tới cấp này = TỰ kết thúc (BE auto theo cờ cấp, + // KHÔNG cần ô-tích). FE dùng cờ này để: (1) hiện banner cảnh báo + (2) bắt chọn giá chốt. + const approverFinalizeEligible = levelOptions?.allowApproverFinalize === true // Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null). const priceCandidates = ([ { source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal }, @@ -149,8 +153,9 @@ export function PeWorkflowPanel({ // Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai - // [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done. - const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm) + // [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done + // HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá). + const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) // [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi // chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE). // File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side). @@ -430,7 +435,7 @@ export function PeWorkflowPanel({ : `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}` // [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn. const isApproveAction = !isCancel && !isSendBack - const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm) + const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) // [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates // rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh // người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge). @@ -588,6 +593,16 @@ export function PeWorkflowPanel({
)} + {/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt: + BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */} + {isApproveAction && approverFinalizeEligible && ( +
+ ✅ Cấp này KẾT THÚC quy trình + + Được cấu hình sẵn trong Quy trình duyệt — bạn duyệt xong là phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt. + +
+ )} {/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done. "Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */} {shouldPickPrice && ( diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts index 19ad0b3..2454329 100644 --- a/fe-user/src/types/purchaseEvaluation.ts +++ b/fe-user/src/types/purchaseEvaluation.ts @@ -154,6 +154,10 @@ export type PeSupplier = { paymentTermText: string | null note: string | null order: number + // [Mig 58 — anh Kiệt FDC] Multi-winner: đơn vị này có thuộc tập đơn vị trúng thầu + // (≥1 đơn vị được chọn) không. Nguồn-sự-thật cho checkbox "Chọn trúng thầu" — KHÔNG + // dùng selectedSupplierId (null khi joint ≥2 winner). winnerQuoteTotal = SUM mọi isWinner. + isWinner: boolean } export type PeQuote = { @@ -401,6 +405,7 @@ export type ApprovalWorkflowOptions = { allowApproverEditDetails: boolean allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối + allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO) } // Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE diff --git a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs index d945e07..ea6eb0a 100644 --- a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs @@ -167,7 +167,7 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase [HttpPost("{id:guid}/select-winner")] public async Task SelectWinner(Guid id, [FromBody] SelectWinnerBody body, CancellationToken ct) { - await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierId), ct); + await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierIds ?? Array.Empty()), ct); return NoContent(); } @@ -345,7 +345,7 @@ public record AddSupplierBody( string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone, string? PaymentTermText, string? Note); -public record SelectWinnerBody(Guid? SupplierId); // null = bỏ chọn NCC (cho phép xóa + điền lại — anh Kiệt FDC C5) +public record SelectWinnerBody(IReadOnlyList? SupplierIds); // [Mig 58] multi-winner: tập đơn vị NCC/TP thắng; [] hoặc null = bỏ chọn (anh Kiệt FDC C5 + CCM multi) public record DetailBody( string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh, diff --git a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs index 60d688c..fb68f2d 100644 --- a/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs +++ b/src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs @@ -38,6 +38,7 @@ public record AwLevelDto( bool AllowApproverEditDetails, bool AllowApproverEditBudget, bool AllowApproverSkipToFinal, + bool AllowApproverFinalize, // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO) // [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy // từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO); // CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer. @@ -184,7 +185,7 @@ public class GetAwAdminOverviewQueryHandler( return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email, l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee, l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget, - l.AllowApproverSkipToFinal, + l.AllowApproverSkipToFinal, l.AllowApproverFinalize, proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId)); }).ToList() )).ToList()); @@ -225,7 +226,8 @@ public record CreateAwLevelInput( bool AllowReturnToDrafter = true, bool AllowApproverEditDetails = false, bool AllowApproverEditBudget = false, - bool AllowApproverSkipToFinal = false); + bool AllowApproverSkipToFinal = false, + bool AllowApproverFinalize = false); public record CreateAwStepInput( int Order, @@ -363,6 +365,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db) AllowApproverEditDetails = l.AllowApproverEditDetails, AllowApproverEditBudget = l.AllowApproverEditBudget, AllowApproverSkipToFinal = l.AllowApproverSkipToFinal, + AllowApproverFinalize = l.AllowApproverFinalize, }).ToList(), }) .ToList(), diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs index 42570be..1c9f03e 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs @@ -51,7 +51,8 @@ public record PurchaseEvaluationSupplierDto( string? ContactPhone, string? PaymentTermText, string? Note, - int Order); + int Order, + bool IsWinner); // [Mig 58] đơn vị này có thuộc tập winner không (multi) public record PurchaseEvaluationQuoteDto( Guid Id, @@ -115,7 +116,8 @@ public record ApprovalWorkflowOptionsDto( bool AllowReturnToDrafter, bool AllowApproverEditDetails, bool AllowApproverEditBudget, - bool AllowApproverSkipToFinal); + bool AllowApproverSkipToFinal, + bool AllowApproverFinalize); // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO) public record PurchaseEvaluationWorkflowSummaryDto( string PolicyName, diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs index 9dd235c..9e1cef5 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs @@ -385,11 +385,17 @@ public class DeletePurchaseEvaluationQuoteCommandHandler( } } -// ========== Select winner (NCC được chọn tổng thể) ========== +// ========== Select winner (NCC/TP được chọn tổng thể — MULTI) ========== -// SupplierId nullable [anh Kiệt FDC C5]: null = BỎ CHỌN (xóa đơn vị đã chọn, điền -// lại) — un-select winner về none; có giá trị = chọn/đổi NCC (logic cũ giữ 100%). -public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid? SupplierId) : IRequest; +// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC] Multi-winner: chọn NHIỀU đơn vị NCC/TP cùng +// trúng (whole-supplier multi-select). SupplierIds = tập đơn vị thắng: +// - List rỗng [] = BỎ CHỌN tất cả (un-select về none) — thay null cũ (anh Kiệt C5). +// - 1 phần tử = 1 winner (SelectedSupplierId sync = đơn vị đó — backward compat). +// - ≥2 phần tử = joint winner (SelectedSupplierId = null vì không còn "1 winner"). +// IsWinner trên supplier-row là nguồn-sự-thật mới; winnerQuoteTotal = SUM ThanhTien +// MỌI winner-row. Mỗi id phải nằm trong danh sách NCC tham gia của phiếu. +public record SelectPurchaseEvaluationWinnerCommand( + Guid PurchaseEvaluationId, IReadOnlyList SupplierIds) : IRequest; public class SelectPurchaseEvaluationWinnerCommandHandler( IApplicationDbContext db, @@ -400,28 +406,30 @@ public class SelectPurchaseEvaluationWinnerCommandHandler( var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); - if (request.SupplierId is Guid supplierId) - { - // ── Chọn / đổi NCC trúng thầu (logic cũ giữ nguyên 100%) ── - _ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == supplierId, ct) - ?? throw new NotFoundException("Supplier", supplierId); + // Load mọi supplier-row của phiếu (tracking — để set IsWinner). + var rows = await db.PurchaseEvaluationSuppliers + .Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId) + .ToListAsync(ct); - // Verify supplier nằm trong danh sách phiếu - var hasSupplier = await db.PurchaseEvaluationSuppliers - .AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == supplierId, ct); - if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá."); + // Distinct các id được chọn (FE có thể gửi trùng). + var selectedIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet(); - entity.SelectedSupplierId = supplierId; - } - else + // Mỗi id chọn phải là 1 đơn vị tham gia phiếu (chặn forge/stale). + var participatingSupplierIds = rows.Select(r => r.SupplierId).ToHashSet(); + foreach (var sid in selectedIds) { - // ── Bỏ chọn (clear về none) — KHÔNG validate participating list ── - // Row 4 "Giá trị kỳ này" + winnerQuoteTotal tính-khi-đọc theo - // SelectedSupplierId (PurchaseEvaluationFeatures GetDetail) → tự về 0. - // ApprovedPriceAmount/Source là cột chốt giai-đoạn DaDuyet, KHÔNG đụng ở đây. - entity.SelectedSupplierId = null; + if (!participatingSupplierIds.Contains(sid)) + throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá."); } + // Set IsWinner per-row: true nếu nằm trong tập chọn, false phần còn lại. + foreach (var r in rows) + r.IsWinner = selectedIds.Contains(r.SupplierId); + + // Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng + // tạo HĐ + DTO cũ); ≥2 hoặc 0 → null. + entity.SelectedSupplierId = selectedIds.Count == 1 ? selectedIds.First() : null; + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog { PurchaseEvaluationId = entity.Id, @@ -430,7 +438,9 @@ public class SelectPurchaseEvaluationWinnerCommandHandler( PhaseAtChange = entity.Phase, UserId = currentUser.UserId, UserName = currentUser.FullName ?? currentUser.Email, - Summary = request.SupplierId is null ? "Bỏ chọn NCC trúng thầu" : "Chọn NCC trúng thầu", + Summary = selectedIds.Count == 0 + ? "Bỏ chọn đơn vị NCC/TP trúng thầu" + : $"Chọn {selectedIds.Count} đơn vị NCC/TP trúng thầu", }); await db.SaveChangesAsync(ct); diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index 7a850e3..6267fe1 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -52,7 +52,7 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator x.HoSoLink).MaximumLength(1000); // [S61] >0 khi có nhập — KHÔNG bắt buộc lúc tạo, submit guard mới chặn. - RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0) + RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0) // [Mig 58 item 2] 0 hợp lệ .When(x => x.BudgetPeriodAmount.HasValue) .WithMessage("Ngân sách kỳ này phải lớn hơn 0."); } @@ -230,7 +230,7 @@ public class UpdatePurchaseEvaluationDraftCommandValidator : AbstractValidator x.MoTa).MaximumLength(2000); // [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson). RuleFor(x => x.HoSoLink).MaximumLength(1000); - RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0) + RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0) // [Mig 58 item 2] 0 hợp lệ .When(x => x.BudgetPeriodAmount.HasValue) .WithMessage("Ngân sách kỳ này phải lớn hơn 0."); } @@ -336,12 +336,13 @@ public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator< { public AdjustPurchaseEvaluationBudgetCommandValidator() { - RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0).When(x => x.BudgetPeriodAmount.HasValue); + RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetPeriodAmount.HasValue); // [Mig 58 item 2] 0 hợp lệ // [S62] "Giá trị thực hiện dự kiến còn lại" (row 8) CHO PHÉP ÂM. Khi giá trị NCC // vượt ngân sách → số dư còn lại tự ra âm; nghiệp vụ (anh Kiệt FDC 2026-06-13) // yêu cầu VẪN lưu được, chỉ cảnh báo (FE banner "Vượt ngân sách"). Gỡ ràng buộc // GreaterThanOrEqualTo(0) cũ — mirror tiền lệ LeaveBalance (cho phép số dư âm). - // BudgetPeriodAmount vẫn phải > 0; submit guard "đã nhập NS kỳ này" không đổi. + // [Mig 58 item 2 CCM] BudgetPeriodAmount cho phép = 0 (SSG ngân sách 0 hợp lệ); submit + // guard chỉ chặn khi NULL (chưa nhập gì), KHÔNG chặn 0 cố ý. } } @@ -958,7 +959,8 @@ public class GetPurchaseEvaluationQueryHandler( curLevel.AllowReturnToDrafter, curLevel.AllowApproverEditDetails, curLevel.AllowApproverEditBudget, - curLevel.AllowApproverSkipToFinal); + curLevel.AllowApproverSkipToFinal, + curLevel.AllowApproverFinalize); } } @@ -1083,7 +1085,7 @@ public class GetPurchaseEvaluationQueryHandler( // Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi // chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO". var winnerSupplierRowIds = e.Suppliers - .Where(s => s.SupplierId == e.SelectedSupplierId) + .Where(s => s.IsWinner) // [Mig 58] multi-winner: tổng MỌI đơn vị IsWinner .Select(s => s.Id) .ToHashSet(); var winnerQuoteTotal = e.Details @@ -1119,7 +1121,7 @@ public class GetPurchaseEvaluationQueryHandler( s.Id, s.SupplierId, suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "", s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone, - s.PaymentTermText, s.Note, s.Order)) + s.PaymentTermText, s.Note, s.Order, s.IsWinner)) .ToList(), e.Details .OrderBy(d => d.Order) diff --git a/src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs b/src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs index d3868cc..ea53268 100644 --- a/src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs +++ b/src/Backend/SolutionErp.Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs @@ -130,5 +130,16 @@ public class ApprovalWorkflowLevel : BaseEntity /// reinforced 3× cumulative. NO BACKFILL — 4 prod user lose value cũ per bro Option A. public bool AllowApproverSkipToFinal { get; set; } + /// F5 (Mig 58 2026-06-24 — CCM/anh Kiệt FDC) — Cho phép NV slot này, khi duyệt + /// ở cấp của mình, KẾT THÚC quy trình luôn (Phase=DaDuyet) KHÔNG cần trình CEO/ + /// các Bước-Cấp sau. Admin opt-in per slot trong Workflow Designer ("Duyệt là + /// kết thúc (không trình CEO)"). Default false. KHÁC AllowApproverSkipToFinal + /// (skip = advance pointer tới Cấp cuối, CEO vẫn ký thật): F5 = chính NV này + /// approve = terminal NGAY. Service ApproveV2Async TỰ finalize khi + /// matchingLevel.AllowApproverFinalize=true (AUTO theo cấu hình quy trình, anh Kiệt + /// chốt 2026-06-24 — KHÔNG ô-tích runtime; admin per-level designation = cổng duy + /// nhất, KHÔNG threshold/role check). Mirror F2/F3/F4 admin opt-in per-slot pattern. + public bool AllowApproverFinalize { get; set; } + public ApprovalWorkflowStep? Step { get; set; } } diff --git a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationSupplier.cs b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationSupplier.cs index b4db8f7..99ab80c 100644 --- a/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationSupplier.cs +++ b/src/Backend/SolutionErp.Domain/PurchaseEvaluations/PurchaseEvaluationSupplier.cs @@ -18,5 +18,14 @@ public class PurchaseEvaluationSupplier : BaseEntity public string? Note { get; set; } // Chip trạng thái so sánh public int Order { get; set; } // Thứ tự cột trong bảng so sánh + // [Mig 58 2026-06-24 — CCM/anh Kiệt FDC] Multi-winner: chọn NHIỀU đơn vị NCC/TP + // cùng trúng (whole-supplier multi-select). IsWinner=true → báo giá của đơn vị + // này tính vào "Giá trị kỳ này" (winnerQuoteTotal = SUM ThanhTien của MỌI + // winner). Thay tiêu chí cũ "SupplierId == SelectedSupplierId" (1 winner) bằng + // IsWinner trên supplier-row = nguồn-sự-thật mới. SelectedSupplierId vẫn sync: + // = winner duy nhất khi đúng 1, null khi ≥2 (multi). Backfill Mig 58: + // IsWinner=1 cho row khớp SelectedSupplierId cũ (single-winner phiếu UAT giữ). + public bool IsWinner { get; set; } + public PurchaseEvaluation? PurchaseEvaluation { get; set; } } diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ApprovalWorkflowConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ApprovalWorkflowConfiguration.cs index c4ca197..52323be 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ApprovalWorkflowConfiguration.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ApprovalWorkflowConfiguration.cs @@ -93,5 +93,10 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration x.AllowApproverSkipToFinal).HasDefaultValue(false); + + // Mig 58 (2026-06-24 — CCM/anh Kiệt FDC) — F5 per-NV: cho phép NV slot này + // duyệt = KẾT THÚC quy trình (Phase=DaDuyet) miễn trình CEO. Default false + // (admin opt-in). Khác F2 (skip = advance tới Cấp cuối): F5 = terminal ngay. + e.Property(x => x.AllowApproverFinalize).HasDefaultValue(false); } } diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs index 2611e60..23bb702 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs @@ -82,6 +82,9 @@ public class PurchaseEvaluationSupplierConfiguration : IEntityTypeConfiguration< b.Property(x => x.PaymentTermText).HasMaxLength(200); b.Property(x => x.Note).HasMaxLength(500); + // [Mig 58] Multi-winner flag — default false (mirror per-slot bool pattern). + b.Property(x => x.IsWinner).HasDefaultValue(false); + b.HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique(); b.HasIndex(x => x.SupplierId); } diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260624071509_AddPeMultiWinnerAndLevelFinalize.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260624071509_AddPeMultiWinnerAndLevelFinalize.Designer.cs new file mode 100644 index 0000000..e2a3a66 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260624071509_AddPeMultiWinnerAndLevelFinalize.Designer.cs @@ -0,0 +1,6269 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SolutionErp.Infrastructure.Persistence; + +#nullable disable + +namespace SolutionErp.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260624071509_AddPeMultiWinnerAndLevelFinalize")] + partial class AddPeMultiWinnerAndLevelFinalize + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("ApplicableType") + .HasColumnType("int"); + + b.Property("CeoApprovalThreshold") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsUserSelectable") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ApplicableType", "IsActive"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.ToTable("ApprovalWorkflows", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AllowApproverEditBudget") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowApproverEditDetails") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowApproverFinalize") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowApproverSkipToFinal") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowReturnOneLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowReturnOneStep") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowReturnToAssignee") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("AllowReturnToDrafter") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ApprovalWorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("ApprovalWorkflowStepId", "Order"); + + b.ToTable("ApprovalWorkflowLevels", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("ApprovalWorkflowId", "Order"); + + b.ToTable("ApprovalWorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetManualAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("BudgetManualName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BypassProcurementAndCCM") + .HasColumnType("bit"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("CurrentWorkflowStepIndex") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("DraftData") + .HasColumnType("nvarchar(max)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("GiaTri") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaHopDong") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("NoiDung") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("RejectedAtStepIndex") + .HasColumnType("int"); + + b.Property("RejectedFromPhase") + .HasColumnType("int"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("TemplateId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenHopDong") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowId"); + + b.HasIndex("MaHopDong") + .IsUnique() + .HasFilter("[MaHopDong] IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SlaDeadline"); + + b.HasIndex("SupplierId"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("Contracts", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "ApprovedAt"); + + b.ToTable("ContractApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Purpose") + .HasColumnType("int"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId"); + + b.ToTable("ContractAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "CreatedAt"); + + b.HasIndex("ContractId", "EntityType"); + + b.ToTable("ContractChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("ContractCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "CreatedAt"); + + b.ToTable("ContractComments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverRoleSnapshot") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsBypassed") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PhaseAtApproval") + .HasColumnType("int"); + + b.Property("Stage") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("ContractId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("ContractId", "PhaseAtApproval", "DepartmentId", "Stage") + .IsUnique() + .HasDatabaseName("UX_ContractDeptApprovals_Contract_Phase_Dept_Stage"); + + b.ToTable("ContractDepartmentApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("ContractId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("ContractLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DenNgay") + .HasColumnType("datetime2"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaDichVu") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenDichVu") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGian") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TuNgay") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("DichVuDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("KhoiLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MaCongViec") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenCongViec") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianHoanThanh") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("YeuCauKyThuat") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("GiaoKhoanDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.MuaBanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaSP") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SoLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThueVAT") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("XuatXu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("MuaBanDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacDvDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGiaToiDa") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonGiaToiThieu") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LoaiDichVu") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PhamViDichVu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("SLA") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TenDichVu") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NguyenTacDvDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacNccDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DieuKienGiaoHang") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DieuKienThanhToan") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DonGiaToiDa") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonGiaToiThieu") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("NhomSP") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NguyenTacNccDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NhaCungCapDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MaSP") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SoLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TenSP") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianGiao") + .HasColumnType("datetime2"); + + b.Property("ThongSoKyThuat") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("XuatXu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("NhaCungCapDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.ThauPhuDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGia") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("HangMuc") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("KhoiLuong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ThoiGianHoanThanh") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractId", "Order"); + + b.ToTable("ThauPhuDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.HasIndex("ContractType", "IsActive"); + + b.ToTable("WorkflowDefinitions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("PositionLevel") + .HasColumnType("int"); + + b.Property("SlaDays") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("WorkflowDefinitionId", "Order"); + + b.ToTable("WorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowStepId"); + + b.ToTable("WorkflowStepApprovers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractType") + .IsUnique(); + + b.ToTable("WorkflowTypeAssignments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("ContractClauses", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Forms.ContractTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContractType") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FieldSpec") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FormCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ContractType"); + + b.HasIndex("FormCode") + .IsUnique(); + + b.ToTable("ContractTemplates", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.Driver", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LicenseClass") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LicenseNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Drivers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("EmployeeCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DocumentType") + .HasColumnType("int"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssueDate") + .HasColumnType("date"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DocumentType"); + + b.HasIndex("EmployeeProfileId"); + + b.ToTable("EmployeeDocuments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeEducation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CertificateIssueDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DegreeLevel") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EducationMode") + .HasColumnType("int"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("FromDate") + .HasColumnType("date"); + + b.Property("GradeLevel") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Major") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SchoolName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ToDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeProfileId"); + + b.ToTable("EmployeeEducations", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeFamilyRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BirthYear") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Occupation") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Relationship") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeProfileId"); + + b.ToTable("EmployeeFamilyRelations", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcademicTitle") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AnnualLeaveDays") + .HasColumnType("decimal(5,2)"); + + b.Property("BankAccount") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("BankBranch") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BankName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaseSalary") + .HasColumnType("decimal(18,2)"); + + b.Property("BirthPlace") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("nvarchar(5)"); + + b.Property("CommunistPartyJoinDate") + .HasColumnType("date"); + + b.Property("CompensatoryLeaveDays") + .HasColumnType("decimal(5,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EmergencyContactAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("EmployeeCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EmployeeStatus") + .HasColumnType("int"); + + b.Property("EmployeeType") + .HasColumnType("int"); + + b.Property("Ethnicity") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HeightCm") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("Hometown") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IdCardIssueDate") + .HasColumnType("date"); + + b.Property("IdCardIssuePlace") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IdCardNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("InternalPhone") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsCommunistParty") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsTradeUnion") + .HasColumnType("bit"); + + b.Property("IsYouthUnion") + .HasColumnType("bit"); + + b.Property("MaritalStatus") + .HasColumnType("int"); + + b.Property("MedicalRegistrationPlace") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nationality") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PassportNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PermanentAddressText") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PermanentDistrictId") + .HasColumnType("uniqueidentifier"); + + b.Property("PermanentProvinceId") + .HasColumnType("uniqueidentifier"); + + b.Property("PermanentWardId") + .HasColumnType("uniqueidentifier"); + + b.Property("PersonalEmail") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PhotoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Qualification") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Religion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RemainingLeaveDays") + .HasColumnType("decimal(5,2)"); + + b.Property("ResignDate") + .HasColumnType("date"); + + b.Property("SeniorityLeaveDays") + .HasColumnType("decimal(5,2)"); + + b.Property("SocialInsuranceNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SocialInsuranceStartDate") + .HasColumnType("date"); + + b.Property("StreetAddressPermanent") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StreetAddressTemporary") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TaxCode") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TemporaryAddressText") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("TemporaryDistrictId") + .HasColumnType("uniqueidentifier"); + + b.Property("TemporaryProvinceId") + .HasColumnType("uniqueidentifier"); + + b.Property("TemporaryWardId") + .HasColumnType("uniqueidentifier"); + + b.Property("TimekeepingCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TotalSalary") + .HasColumnType("decimal(18,2)"); + + b.Property("TradeUnionJoinDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WeightKg") + .HasColumnType("int"); + + b.Property("WorkLocation") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("YouthUnionJoinDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeCode") + .IsUnique(); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Phone"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("EmployeeProfiles", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("LanguageId") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Level") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeProfileId"); + + b.HasIndex("Kind"); + + b.ToTable("EmployeeSkills", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeWorkHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CompanyAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EmployeeProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("FromDate") + .HasColumnType("date"); + + b.Property("Industry") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobDescription") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("JobTitle") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResignReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ToDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeProfileId"); + + b.ToTable("EmployeeWorkHistories", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.Holiday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsPaid") + .HasColumnType("bit"); + + b.Property("IsRecurring") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Year", "Date") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Holidays", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveBalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AdjustmentDays") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntitledDays") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LeaveTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UsedDays") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "LeaveTypeId", "Year") + .IsUnique(); + + b.ToTable("LeaveBalances", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DaysPerYear") + .HasColumnType("decimal(5,2)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsPaid") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresAttachment") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("LeaveTypes", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.OtPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaxHoursPerDay") + .HasColumnType("int"); + + b.Property("MaxHoursPerMonth") + .HasColumnType("int"); + + b.Property("MaxHoursPerYear") + .HasColumnType("int"); + + b.Property("MultiplierHoliday") + .HasColumnType("decimal(4,2)"); + + b.Property("MultiplierWeekday") + .HasColumnType("decimal(4,2)"); + + b.Property("MultiplierWeekend") + .HasColumnType("decimal(4,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("OtPolicies", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.ShiftPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BreakMinutes") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EndTime") + .HasColumnType("time"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkDays") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("ShiftPatterns", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LicensePlate") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SeatCount") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Vehicles", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.Property("Key") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DisplayLabel") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsVisible") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("ParentKey") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Key"); + + b.HasIndex("ParentKey"); + + b.ToTable("MenuItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CanCreate") + .HasColumnType("bit"); + + b.Property("CanDelete") + .HasColumnType("bit"); + + b.Property("CanRead") + .HasColumnType("bit"); + + b.Property("CanUpdate") + .HasColumnType("bit"); + + b.Property("MenuKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MenuKey"); + + b.HasIndex("RoleId", "MenuKey") + .IsUnique(); + + b.ToTable("Permissions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ShortName") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CanBypassReview") + .HasColumnType("bit"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("Position") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PositionLevel") + .HasColumnType("int"); + + b.Property("RefreshToken") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.MaterialItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OriginCountry") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Specification") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("MaterialItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.ServiceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("ServiceItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("UnitsOfMeasure", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Catalogs.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DefaultUnit") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("WorkItems", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ManagerUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ParentId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.HasIndex("ParentId"); + + b.ToTable("Departments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BudgetTotal") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("Investor") + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Location") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ManagerUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Package") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("Projects", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Master.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ContactPerson") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("TaxCode") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.HasIndex("Type"); + + b.ToTable("Suppliers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Href") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ReadAt") + .HasColumnType("datetime2"); + + b.Property("RefId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId", "ReadAt"); + + b.ToTable("Notifications", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.Attendance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AttendanceDate") + .HasColumnType("datetime2"); + + b.Property("CheckInAccuracy") + .HasColumnType("decimal(8,2)"); + + b.Property("CheckInAt") + .HasColumnType("datetime2"); + + b.Property("CheckInLatitude") + .HasColumnType("decimal(10,7)"); + + b.Property("CheckInLongitude") + .HasColumnType("decimal(10,7)"); + + b.Property("CheckOutAccuracy") + .HasColumnType("decimal(8,2)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime2"); + + b.Property("CheckOutLatitude") + .HasColumnType("decimal(10,7)"); + + b.Property("CheckOutLongitude") + .HasColumnType("decimal(10,7)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IpAddressIn") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IpAddressOut") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("OtHours") + .HasColumnType("decimal(5,2)"); + + b.Property("SourceIn") + .HasColumnType("int"); + + b.Property("SourceOut") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkHours") + .HasColumnType("decimal(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AttendanceDate") + .IsUnique(); + + b.ToTable("Attendances", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ItTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedToFullName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AssignedToUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaTicket") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Resolution") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetime2"); + + b.Property("SlaBreached") + .HasColumnType("bit"); + + b.Property("SlaDueAt") + .HasColumnType("datetime2"); + + b.Property("SlaWarnedSent") + .HasColumnType("bit"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserId"); + + b.HasIndex("Category"); + + b.HasIndex("MaTicket") + .IsUnique() + .HasFilter("[MaTicket] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.ToTable("ItTickets", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LeaveTypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("MaDonTu") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("NumDays") + .HasColumnType("decimal(5,2)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MaDonTu") + .IsUnique() + .HasFilter("[MaDonTu] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.ToTable("LeaveRequests", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequestLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LeaveRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("LeaveRequestId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("LeaveRequestLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BookedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("EndAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RoomId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookedByUserId"); + + b.HasIndex("RoomId", "StartAt"); + + b.ToTable("MeetingBookings", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBookingAttendee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookingId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BookingId", "UserId") + .IsUnique(); + + b.ToTable("MeetingBookingAttendees", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Equipment") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Location") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("MeetingRooms", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.OtRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("time"); + + b.Property("Hours") + .HasColumnType("decimal(5,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaDonTu") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("OtDate") + .HasColumnType("datetime2"); + + b.Property("OtPolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MaDonTu") + .IsUnique() + .HasFilter("[MaDonTu] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.ToTable("OtRequests", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.OtRequestLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("OtRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("OtRequestId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("OtRequestLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AmountEstimate") + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaDeXuat") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("DrafterUserId"); + + b.HasIndex("MaDeXuat") + .IsUnique() + .HasFilter("[MaDeXuat] IS NOT NULL"); + + b.HasIndex("Status"); + + b.ToTable("Proposals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MimeType") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ProposalId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UploadedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UploadedByUserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ProposalId"); + + b.ToTable("ProposalAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("ProposalCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ProposalId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("ProposalId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("ProposalLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Destination") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EstimatedCost") + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaDonTu") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("NumDays") + .HasColumnType("int"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("MaDonTu") + .IsUnique() + .HasFilter("[MaDonTu] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.ToTable("TravelRequests", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequestLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("TravelRequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("TravelRequestId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("TravelRequestLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Destination") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("DriverName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EndAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaDonTu") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RejectedFromStatus") + .HasColumnType("int"); + + b.Property("RequesterFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequesterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("VehicleLicense") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VehicleName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("MaDonTu") + .IsUnique() + .HasFilter("[MaDonTu] IS NOT NULL"); + + b.HasIndex("RequesterUserId"); + + b.HasIndex("Status"); + + b.HasIndex("VehicleLicense", "StartAt"); + + b.ToTable("VehicleBookings", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBookingLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("VehicleBookingId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("VehicleBookingId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("VehicleBookingLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.WorkflowAppCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("WorkflowAppCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PeWorkItemBudget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AdjustmentAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CcmNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("InitialAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ProAdjustmentAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProEstimateAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProInitialAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkItemId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ProjectId", "WorkItemId") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("PeWorkItemBudgets", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowId") + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedPriceAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApprovedPriceSource") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("BudgetPeriodAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CcmSuggestedPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CcmSuggestedPriceNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ContractId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentApprovalLevelOrder") + .HasColumnType("int"); + + b.Property("CurrentWorkflowStepIndex") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("DiaDiem") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DrafterUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpectedRemainingAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("HoSoLink") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsUrgentByCcm") + .HasColumnType("bit"); + + b.Property("IsUrgentByPro") + .HasColumnType("bit"); + + b.Property("MaPhieu") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MoTa") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PaymentTerms") + .HasColumnType("nvarchar(max)"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("ProSuggestedMaxPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProSuggestedMinPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProSuggestedPriceNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProjectId") + .HasColumnType("uniqueidentifier"); + + b.Property("RejectedAtStepIndex") + .HasColumnType("int"); + + b.Property("RejectedFromPhase") + .HasColumnType("int"); + + b.Property("SelectedSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDeadline") + .HasColumnType("datetime2"); + + b.Property("SlaWarningSent") + .HasColumnType("bit"); + + b.Property("TenGoiThau") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("WorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowId"); + + b.HasIndex("ContractId"); + + b.HasIndex("MaPhieu") + .IsUnique() + .HasFilter("[MaPhieu] IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SlaDeadline"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("WorkflowDefinitionId"); + + b.HasIndex("Phase", "IsDeleted"); + + b.ToTable("PurchaseEvaluations", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Decision") + .HasColumnType("int"); + + b.Property("FromPhase") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ToPhase") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "ApprovedAt"); + + b.ToTable("PurchaseEvaluationApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("Purpose") + .HasColumnType("int"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationSupplierId"); + + b.ToTable("PurchaseEvaluationAttachments", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("ContextNote") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasColumnType("int"); + + b.Property("FieldChangesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("PhaseAtChange") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Summary") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "CreatedAt"); + + b.HasIndex("PurchaseEvaluationId", "EntityType"); + + b.ToTable("PurchaseEvaluationChangelogs", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationCodeSequence", b => + { + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastSeq") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Prefix"); + + b.ToTable("PurchaseEvaluationCodeSequences", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApproverRoleSnapshot") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApproverUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsBypassed") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PhaseAtApproval") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Stage") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationId", "PhaseAtApproval", "DepartmentId", "Stage") + .IsUnique() + .HasDatabaseName("UX_PEDeptApprovals_PE_Phase_Dept_Stage"); + + b.ToTable("PurchaseEvaluationDepartmentApprovals", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("Opinion") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "Kind") + .IsUnique(); + + b.ToTable("PurchaseEvaluationDepartmentOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DonGiaNganSach") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DonViTinh") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GhiChu") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("GroupCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ItemCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("KhoiLuongNganSach") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("KhoiLuongThiCong") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NoiDung") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ThanhTienNganSach") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId", "Order"); + + b.ToTable("PurchaseEvaluationDetails", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationLevelOpinion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalWorkflowLevelId") + .HasColumnType("uniqueidentifier"); + + b.Property("Comment") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignedByFullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SignedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalWorkflowLevelId"); + + b.HasIndex("PurchaseEvaluationId", "ApprovalWorkflowLevelId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationLevelOpinions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BgVat") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ChuaVat") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("IsSelected") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PurchaseEvaluationDetailId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("PurchaseEvaluationSupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("ThanhTien") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationId"); + + b.HasIndex("PurchaseEvaluationSupplierId"); + + b.HasIndex("PurchaseEvaluationDetailId", "PurchaseEvaluationSupplierId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationQuotes", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContactEmail") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IsWinner") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("PaymentTermText") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PurchaseEvaluationId") + .HasColumnType("uniqueidentifier"); + + b.Property("SupplierId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.HasIndex("PurchaseEvaluationId", "SupplierId") + .IsUnique(); + + b.ToTable("PurchaseEvaluationSuppliers", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivatedAt") + .HasColumnType("datetime2"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("EvaluationType") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Code", "Version") + .IsUnique(); + + b.HasIndex("EvaluationType", "IsActive"); + + b.ToTable("PurchaseEvaluationWorkflowDefinitions", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("DepartmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Phase") + .HasColumnType("int"); + + b.Property("PositionLevel") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationWorkflowDefinitionId") + .HasColumnType("uniqueidentifier"); + + b.Property("SlaDays") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PurchaseEvaluationWorkflowDefinitionId", "Order"); + + b.ToTable("PurchaseEvaluationWorkflowSteps", (string)null); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignmentValue") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .HasColumnType("int"); + + b.Property("PurchaseEvaluationWorkflowStepId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseEvaluationWorkflowStepId"); + + b.ToTable("PurchaseEvaluationWorkflowStepApprovers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SolutionErp.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SolutionErp.Domain.Identity.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", "Step") + .WithMany("Levels") + .HasForeignKey("ApprovalWorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ApproverUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", "ApprovalWorkflow") + .WithMany("Steps") + .HasForeignKey("ApprovalWorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApprovalWorkflow"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null) + .WithMany() + .HasForeignKey("ApprovalWorkflowId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Approvals") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Attachments") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractChangelog", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Changelogs") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("Comments") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractDepartmentApproval", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("DepartmentApprovals") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("LevelOpinions") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + + b.Navigation("Level"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.DichVuDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("DichVuDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.GiaoKhoanDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("GiaoKhoanDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.MuaBanDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("MuaBanDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacDvDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NguyenTacDvDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NguyenTacNccDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NguyenTacNccDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.NhaCungCapDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("NhaCungCapDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Details.ThauPhuDetail", b => + { + b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract") + .WithMany("ThauPhuDetails") + .HasForeignKey("ContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contract"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SolutionErp.Domain.Contracts.WorkflowDefinition", "WorkflowDefinition") + .WithMany("Steps") + .HasForeignKey("WorkflowDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkflowDefinition"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStepApprover", b => + { + b.HasOne("SolutionErp.Domain.Contracts.WorkflowStep", "Step") + .WithMany("Approvers") + .HasForeignKey("WorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeDocument", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("Documents") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeEducation", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("Educations") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeFamilyRelation", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("FamilyRelations") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeProfile", b => + { + b.HasOne("SolutionErp.Domain.Identity.User", "User") + .WithOne() + .HasForeignKey("SolutionErp.Domain.Hrm.EmployeeProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeSkill", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("Skills") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeWorkHistory", b => + { + b.HasOne("SolutionErp.Domain.Hrm.EmployeeProfile", "EmployeeProfile") + .WithMany("WorkHistories") + .HasForeignKey("EmployeeProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmployeeProfile"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.LeaveBalance", b => + { + b.HasOne("SolutionErp.Domain.Hrm.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent") + .WithMany("Children") + .HasForeignKey("ParentKey") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.Permission", b => + { + b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Menu") + .WithMany("Permissions") + .HasForeignKey("MenuKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Identity.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Menu"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.User", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequestLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.LeaveRequest", "LeaveRequest") + .WithMany("LevelOpinions") + .HasForeignKey("LeaveRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LeaveRequest"); + + b.Navigation("Level"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b => + { + b.HasOne("SolutionErp.Domain.Office.MeetingRoom", "Room") + .WithMany() + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBookingAttendee", b => + { + b.HasOne("SolutionErp.Domain.Office.MeetingBooking", "Booking") + .WithMany("Attendees") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.OtRequestLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.OtRequest", "OtRequest") + .WithMany("LevelOpinions") + .HasForeignKey("OtRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("OtRequest"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalAttachment", b => + { + b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal") + .WithMany("Attachments") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.ProposalLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.Proposal", "Proposal") + .WithMany("LevelOpinions") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequestLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.TravelRequest", "TravelRequest") + .WithMany("LevelOpinions") + .HasForeignKey("TravelRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("TravelRequest"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBookingLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.Office.VehicleBooking", "VehicleBooking") + .WithMany("LevelOpinions") + .HasForeignKey("VehicleBookingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("VehicleBooking"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null) + .WithMany() + .HasForeignKey("ApprovalWorkflowId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationApproval", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Approvals") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationAttachment", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Attachments") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationChangelog", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Changelogs") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentApproval", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("DepartmentApprovals") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDepartmentOpinion", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("DepartmentOpinions") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Details") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationLevelOpinion", b => + { + b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowLevel", "Level") + .WithMany() + .HasForeignKey("ApprovalWorkflowLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("LevelOpinions") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Level"); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationQuote", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", "Detail") + .WithMany("Quotes") + .HasForeignKey("PurchaseEvaluationDetailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", null) + .WithMany("Quotes") + .HasForeignKey("PurchaseEvaluationId"); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", "Supplier") + .WithMany() + .HasForeignKey("PurchaseEvaluationSupplierId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Detail"); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationSupplier", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", "PurchaseEvaluation") + .WithMany("Suppliers") + .HasForeignKey("PurchaseEvaluationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PurchaseEvaluation"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.HasOne("SolutionErp.Domain.Master.Department", null) + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", "Definition") + .WithMany("Steps") + .HasForeignKey("PurchaseEvaluationWorkflowDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Definition"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStepApprover", b => + { + b.HasOne("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", "Step") + .WithMany("Approvers") + .HasForeignKey("PurchaseEvaluationWorkflowStepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflowStep", b => + { + b.Navigation("Levels"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b => + { + b.Navigation("Approvals"); + + b.Navigation("Attachments"); + + b.Navigation("Changelogs"); + + b.Navigation("Comments"); + + b.Navigation("DepartmentApprovals"); + + b.Navigation("DichVuDetails"); + + b.Navigation("GiaoKhoanDetails"); + + b.Navigation("LevelOpinions"); + + b.Navigation("MuaBanDetails"); + + b.Navigation("NguyenTacDvDetails"); + + b.Navigation("NguyenTacNccDetails"); + + b.Navigation("NhaCungCapDetails"); + + b.Navigation("ThauPhuDetails"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowDefinition", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowStep", b => + { + b.Navigation("Approvers"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Hrm.EmployeeProfile", b => + { + b.Navigation("Documents"); + + b.Navigation("Educations"); + + b.Navigation("FamilyRelations"); + + b.Navigation("Skills"); + + b.Navigation("WorkHistories"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b => + { + b.Navigation("Children"); + + b.Navigation("Permissions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequest", b => + { + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b => + { + b.Navigation("Attendees"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.OtRequest", b => + { + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b => + { + b.Navigation("Attachments"); + + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequest", b => + { + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b => + { + b.Navigation("LevelOpinions"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b => + { + b.Navigation("Approvals"); + + b.Navigation("Attachments"); + + b.Navigation("Changelogs"); + + b.Navigation("DepartmentApprovals"); + + b.Navigation("DepartmentOpinions"); + + b.Navigation("Details"); + + b.Navigation("LevelOpinions"); + + b.Navigation("Quotes"); + + b.Navigation("Suppliers"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b => + { + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowDefinition", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationWorkflowStep", b => + { + b.Navigation("Approvers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260624071509_AddPeMultiWinnerAndLevelFinalize.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260624071509_AddPeMultiWinnerAndLevelFinalize.cs new file mode 100644 index 0000000..d79a39e --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260624071509_AddPeMultiWinnerAndLevelFinalize.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SolutionErp.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPeMultiWinnerAndLevelFinalize : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsWinner", + table: "PurchaseEvaluationSuppliers", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "AllowApproverFinalize", + table: "ApprovalWorkflowLevels", + type: "bit", + nullable: false, + defaultValue: false); + + // [Mig 58] Backfill IsWinner từ winner đơn lẻ cũ (SelectedSupplierId) — + // phiếu UAT đang chạy giữ winner đã chọn dưới mô hình multi-winner mới. + // Chạy SAU AddColumn (cột đã tồn tại). Idempotent: chỉ set 1 (default 0 + // không đụng) → re-run no-op. KHÔNG un-backfill ở Down (DropColumn xoá hẳn). + migrationBuilder.Sql(@" +UPDATE pes SET IsWinner = 1 +FROM PurchaseEvaluationSuppliers pes +INNER JOIN PurchaseEvaluations pe ON pe.Id = pes.PurchaseEvaluationId +WHERE pe.SelectedSupplierId IS NOT NULL AND pes.SupplierId = pe.SelectedSupplierId;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsWinner", + table: "PurchaseEvaluationSuppliers"); + + migrationBuilder.DropColumn( + name: "AllowApproverFinalize", + table: "ApprovalWorkflowLevels"); + } + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 476b6d8..050bab4 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -202,6 +202,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations .HasColumnType("bit") .HasDefaultValue(false); + b.Property("AllowApproverFinalize") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + b.Property("AllowApproverSkipToFinal") .ValueGeneratedOnAdd() .HasColumnType("bit") @@ -5275,6 +5280,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations .HasMaxLength(200) .HasColumnType("nvarchar(200)"); + b.Property("IsWinner") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + b.Property("Note") .HasMaxLength(500) .HasColumnType("nvarchar(500)"); diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index 4d7b9f2..3dbccf8 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -172,28 +172,25 @@ public class PurchaseEvaluationWorkflowService( // missingForApproval (FE KHÔNG check Purpose enum → BE cũng không, // tránh mismatch 2 tầng). var missing = new List(); - if (evaluation.SelectedSupplierId is not Guid winnerId) + // [Mig 58 CCM item 1] Multi-winner: winner = các supplier-row IsWinner (≥1). Tổng giá + // gói = SUM ThanhTien MỌI winner-row (vd 3 đơn vị 37.8+9+10=56.8tr). 0 winner → + // "chưa chọn"; có winner nhưng tổng ≤ 0 → "chưa có giá chào thầu" (item 2 chỉ nới + // NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá). + var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking() + .Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner) + .Select(s => s.Id) + .ToListAsync(ct); + if (winnerRowIds.Count == 0) { missing.Add("chưa chọn Đơn vị NCC/TP"); } else { - var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking() - .Where(s => s.PurchaseEvaluationId == evaluation.Id && s.SupplierId == winnerId) - .Select(s => s.Id) - .ToListAsync(ct); - if (winnerRowIds.Count == 0) - { - missing.Add("Đơn vị được chọn không còn trong danh sách NCC tham gia"); - } - else - { - var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking() - .Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId)) - .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m; - if (winnerQuoteTotal <= 0) - missing.Add("Đơn vị được chọn chưa có giá chào thầu"); - } + var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking() + .Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId)) + .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m; + if (winnerQuoteTotal <= 0) + missing.Add("Đơn vị được chọn chưa có giá chào thầu"); } // [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này" // (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval @@ -202,15 +199,18 @@ public class PurchaseEvaluationWorkflowService( // (PeWorkItemBudget.ProInitialAmount per Dự-án×Hạng-mục). Effective = BudgetPeriodAmount // ?? ProInitialAmount → phiếu chưa nhập tay row3 nhưng đã có ban-hành PRO vẫn gửi-duyệt được // (FE missingForApproval mirror cùng fallback — đổi đồng bộ 2 tầng). + // [Mig 58 item 2 CCM] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn gửi duyệt được). Chỉ + // fallback ProInitial khi drafter CHƯA nhập (null) — KHÔNG override số 0 cố ý thành + // ProInitial rồi vô tình null-block. Block cuối chỉ fire khi null; 0 (cố ý) đi qua. decimal? effectiveBudget = evaluation.BudgetPeriodAmount; - if ((effectiveBudget is null || effectiveBudget <= 0) && evaluation.WorkItemId is Guid wiId) + if (effectiveBudget is null && evaluation.WorkItemId is Guid wiId) { effectiveBudget = await db.PeWorkItemBudgets.AsNoTracking() .Where(b => b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiId) .Select(b => b.ProInitialAmount) .FirstOrDefaultAsync(ct); } - if (effectiveBudget is null || effectiveBudget <= 0) + if (effectiveBudget is null) { missing.Add("chưa nhập Ngân sách kỳ này"); } @@ -851,6 +851,31 @@ public class PurchaseEvaluationWorkflowService( // (winnerQuoteTotal = SUM báo giá NCC được chọn) < ngưỡng. Tích mà KHÔNG đủ điều // kiện → Conflict/Forbidden. KHÔNG tích → bỏ block, advance bình thường lên CEO. // Opinion + approval row đã ghi ở trên → finalize giữ đủ vết. ① giá chốt: ApplyApprovedPriceOnFinalize. + // [Mig 58 2026-06-24 — CCM/anh Kiệt FDC item 4] Cấp được cấu hình KẾT THÚC trong Quy + // trình duyệt (admin bật AllowApproverFinalize cho slot, vd Cấp anh Chương). Duyệt tới + // cấp này = TỰ kết thúc (DaDuyet), bỏ qua MỌI Bước/Cấp còn lại (kể cả CEO) — quy-trình- + // driven, KHÔNG cần ô-tích runtime (anh Kiệt chốt 2026-06-24 "cho bật trong quy trình + // hay hơn"). KHÔNG threshold/role-gate (admin chỉ-định slot = đủ thẩm quyền). ① giá chốt: + // người duyệt phải chọn (Conflict nếu thiếu, isSystem miễn). Đặt SAU skipToFinal, TRƯỚC + // ccm-delegation + advance. + if (matchingLevel.AllowApproverFinalize) + { + ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource); + evaluation.Phase = PurchaseEvaluationPhase.DaDuyet; + evaluation.CurrentWorkflowStepIndex = null; + evaluation.CurrentApprovalLevelOrder = null; + evaluation.SlaDeadline = null; + await LogTransitionAsync( + evaluation, + PurchaseEvaluationPhase.ChoDuyet, + PurchaseEvaluationPhase.DaDuyet, + actorUserId, + ApprovalDecision.Approve, + $"[Duyệt KẾT THÚC tại Bước {currentIdx + 1} Cấp {currentLevelOrder} — cấu hình quy trình, không trình CEO] {comment ?? ""}".Trim(), + ct); + return; + } + if (finalizeByCcmDelegation) { if (aw.CeoApprovalThreshold is not decimal ceoThreshold) @@ -861,8 +886,7 @@ public class PurchaseEvaluationWorkflowService( "Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO."); var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking() - .Where(s => s.PurchaseEvaluationId == evaluation.Id - && s.SupplierId == evaluation.SelectedSupplierId) + .Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner) // [Mig 58] multi-winner .Select(s => s.Id) .ToListAsync(ct); var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeMultiWinnerTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeMultiWinnerTests.cs new file mode 100644 index 0000000..55de93c --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeMultiWinnerTests.cs @@ -0,0 +1,231 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.PurchaseEvaluations; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Master; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC — test-before/after] WinnerQuoteTotal multi-winner. +// item 1: winnerQuoteTotal = SUM(báo giá ThanhTien) MỌI supplier-row IsWinner — KHÔNG còn +// theo SelectedSupplierId (nguồn-sự-thật mới = IsWinner). Verify qua GetDetail read DTO +// (GetPurchaseEvaluationQueryHandler → PurchaseEvaluationDetailBundleDto.WinnerQuoteTotal, +// PurchaseEvaluationFeatures.cs ~1090-1097): build winnerSupplierRowIds = Suppliers IsWinner +// → SUM Details.Quotes thuộc các row đó. +// +// Harness mirror PeWorkItemBudgetTests (IdentityFixture + SQLite + GetPurchaseEvaluationQuery +// stack). KHÔNG touch production — test theo CODE đã land (S34). Query handler 3-dep +// (db + UserManager + ICurrentUser); dùng Admin để bỏ qua authz scope. +public class PeMultiWinnerTests +{ + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid? UserId { get; init; } = Guid.NewGuid(); + public string? Email { get; init; } = "actor@test.local"; + public string? FullName { get; init; } = "Actor Test"; + public IReadOnlyList Roles { get; init; } = Array.Empty(); + public bool IsAuthenticated => UserId is not null; + } + + private static FakeCurrentUser AsRoles(params string[] roles) => new() { Roles = roles }; + + private static GetPurchaseEvaluationQueryHandler BuildQueryHandler( + IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser) + => new(db, fix.Services.GetRequiredService>(), currentUser); + + private static async Task SeedPeAsync(TestApplicationDbContext db, string code = "PE-MW") + { + var pe = new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.DangSoanThao, + MaPhieu = code, + TenGoiThau = "Gói thầu multi-winner", + ProjectId = Guid.NewGuid(), + DrafterUserId = Guid.NewGuid(), + }; + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + return pe; + } + + // 1 detail dùng chung cho mọi NCC (mỗi NCC 1 quote thuộc detail này). + private static async Task SeedDetailAsync(TestApplicationDbContext db, PurchaseEvaluation pe) + { + var detail = new PurchaseEvaluationDetail + { + Id = Guid.NewGuid(), + PurchaseEvaluationId = pe.Id, + GroupCode = "A.I", + GroupName = "Bê tông", + NoiDung = "Concrete M100", + Order = 0, + }; + db.PurchaseEvaluationDetails.Add(detail); + await db.SaveChangesAsync(CancellationToken.None); + return detail.Id; + } + + // 1 NCC tham gia (master + PES row) + 1 quote ThanhTien thuộc detail. isWinner pin + // IsWinner trên PES row. Return supplierId master. + private static async Task SeedSupplierWithQuoteAsync( + TestApplicationDbContext db, PurchaseEvaluation pe, Guid detailId, + string code, decimal thanhTien, bool isWinner, int order) + { + var supplier = new Supplier { Id = Guid.NewGuid(), Code = code, Name = "NCC " + code }; + db.Suppliers.Add(supplier); + var pes = new PurchaseEvaluationSupplier + { + Id = Guid.NewGuid(), + PurchaseEvaluationId = pe.Id, + SupplierId = supplier.Id, + Order = order, + IsWinner = isWinner, + }; + db.PurchaseEvaluationSuppliers.Add(pes); + db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote + { + Id = Guid.NewGuid(), + PurchaseEvaluationDetailId = detailId, + PurchaseEvaluationSupplierId = pes.Id, + ThanhTien = thanhTien, + }); + await db.SaveChangesAsync(CancellationToken.None); + return supplier.Id; + } + + [Fact] + public async Task WinnerQuoteTotal_ThreeWinners_SumsAllThreeThanhTien() + { + // ⭐ TRỤC CỐT LÕI item 1: 3 NCC cùng trúng (IsWinner=true) → WinnerQuoteTotal = + // SUM cả 3 báo giá (37.8 + 9 + 10 = 56.8tr), KHÔNG chỉ 1 đơn vị. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = await SeedPeAsync(db, "PE-MW-3WIN"); + var detailId = await SeedDetailAsync(db, pe); + + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-A", 37_800_000m, isWinner: true, order: 0); + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-B", 9_000_000m, isWinner: true, order: 1); + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-C", 10_000_000m, isWinner: true, order: 2); + + var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin)); + var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None); + + bundle.WinnerQuoteTotal.Should().Be(56_800_000m, "tổng giá gói = SUM báo giá MỌI đơn vị IsWinner"); + bundle.SelectedSupplierId.Should().BeNull("≥2 winner → SelectedSupplierId không sync 1 đơn vị"); + } + + [Fact] + public async Task WinnerQuoteTotal_ExcludesNonWinnerSuppliers() + { + // 4 NCC tham gia, chỉ 2 IsWinner=true → WinnerQuoteTotal CHỈ tính 2 winner + // (100 + 50 = 150tr), bỏ qua 2 NCC không trúng (200 + 300tr KHÔNG cộng). + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = await SeedPeAsync(db, "PE-MW-EXCL"); + var detailId = await SeedDetailAsync(db, pe); + + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-W1", 100_000_000m, isWinner: true, order: 0); + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-W2", 50_000_000m, isWinner: true, order: 1); + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-L1", 200_000_000m, isWinner: false, order: 2); + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-L2", 300_000_000m, isWinner: false, order: 3); + + var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin)); + var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None); + + bundle.WinnerQuoteTotal.Should().Be(150_000_000m, "chỉ SUM winner — NCC không trúng bị loại"); + } + + [Fact] + public async Task WinnerQuoteTotal_SingleWinner_EqualsThatSupplierQuote_SelectedSupplierIdSet() + { + // 1 winner (single-winner backward compat) → WinnerQuoteTotal = đúng báo giá đơn + // vị đó + SelectedSupplierId sync = winner (handler select-winner set khi count==1). + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = await SeedPeAsync(db, "PE-MW-1WIN"); + var detailId = await SeedDetailAsync(db, pe); + + var winner = await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-SOLO", 75_000_000m, isWinner: true, order: 0); + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-OTHER", 80_000_000m, isWinner: false, order: 1); + // Mô phỏng handler select-winner sync SelectedSupplierId khi đúng 1 winner. + pe.SelectedSupplierId = winner; + await db.SaveChangesAsync(CancellationToken.None); + + var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin)); + var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None); + + bundle.WinnerQuoteTotal.Should().Be(75_000_000m, "1 winner → tổng = đúng báo giá đơn vị đó"); + bundle.SelectedSupplierId.Should().Be(winner); + } + + [Fact] + public async Task WinnerQuoteTotal_NoWinner_IsZero() + { + // Chưa chọn winner nào (mọi PES IsWinner=false) → WinnerQuoteTotal = 0 (KHÔNG + // lẫn báo giá các NCC tham gia chưa-trúng). + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = await SeedPeAsync(db, "PE-MW-NONE"); + var detailId = await SeedDetailAsync(db, pe); + + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-X", 40_000_000m, isWinner: false, order: 0); + await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-Y", 60_000_000m, isWinner: false, order: 1); + + var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin)); + var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None); + + bundle.WinnerQuoteTotal.Should().Be(0m, "0 winner → tổng gói = 0"); + } + + [Fact] + public async Task WinnerQuoteTotal_MultipleQuotesPerWinner_SumsAllRows() + { + // 1 winner nhưng có 2 detail (2 dòng báo giá thuộc cùng PES winner) → SUM cả 2 + // dòng (20 + 30 = 50tr). Verify SelectMany Details.Quotes gộp mọi dòng của winner-row. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = await SeedPeAsync(db, "PE-MW-MULTQ"); + + // 2 detail rời. + var detail1 = await SeedDetailAsync(db, pe); + var detail2 = new PurchaseEvaluationDetail + { + Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id, + GroupCode = "A.II", GroupName = "Thép", NoiDung = "Steel D10", Order = 1, + }; + db.PurchaseEvaluationDetails.Add(detail2); + await db.SaveChangesAsync(CancellationToken.None); + + // 1 winner PES + 2 quote (mỗi detail 1 dòng). + var supplier = new Supplier { Id = Guid.NewGuid(), Code = "NCC-2Q", Name = "NCC 2 dòng" }; + db.Suppliers.Add(supplier); + var pes = new PurchaseEvaluationSupplier + { + Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id, SupplierId = supplier.Id, + Order = 0, IsWinner = true, + }; + db.PurchaseEvaluationSuppliers.Add(pes); + db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote + { + Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail1, + PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 20_000_000m, + }); + db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote + { + Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id, + PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 30_000_000m, + }); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin)); + var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None); + + bundle.WinnerQuoteTotal.Should().Be(50_000_000m, "SUM mọi dòng báo giá của winner-row qua nhiều detail"); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeSelectWinnerClearTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeSelectWinnerClearTests.cs index 48f01c3..05905fe 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/PeSelectWinnerClearTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeSelectWinnerClearTests.cs @@ -10,15 +10,21 @@ using SolutionErp.Infrastructure.Tests.Common; namespace SolutionErp.Infrastructure.Tests.Application; -// [anh Kiệt FDC C5 — test-after] SelectPurchaseEvaluationWinnerCommand: NCC winner -// nullable hoá. Command record `(Guid PurchaseEvaluationId, Guid? SupplierId)`. -// Handler (PurchaseEvaluationDetailFeatures.cs ~388-438) 2 nhánh theo SupplierId: -// • `SupplierId is Guid x` → CHỌN/ĐỔI (logic cũ giữ 100%): NotFound nếu supplier -// không có ở master + Conflict nếu supplier KHÔNG thuộc participating-list của -// phiếu (PurchaseEvaluationSuppliers) + set entity.SelectedSupplierId = x. -// • `SupplierId == null` → BỎ CHỌN (clear về none): set SelectedSupplierId = null, -// BỎ QUA validate participating-list (nhánh MỚI — trục test quan trọng nhất). -// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary phân theo null vs có-giá-trị. +// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC — test-after] SelectPurchaseEvaluationWinnerCommand +// MULTI-WINNER. Command record ĐỔI `(Guid PurchaseEvaluationId, Guid? SupplierId)` → +// `(Guid PurchaseEvaluationId, IReadOnlyList SupplierIds)`. +// Handler (PurchaseEvaluationDetailFeatures.cs ~404-447): +// • Load MỌI supplier-row của phiếu (tracking) → set IsWinner = (row.SupplierId ∈ +// selectedIds), false phần còn lại. IsWinner trên supplier-row = nguồn-sự-thật mới. +// • selectedIds rỗng [] = BỎ CHỌN tất cả (IsWinner=false hết) — thay null cũ (anh Kiệt C5). +// • Mỗi id chọn PHẢI thuộc participating-list (PurchaseEvaluationSuppliers) → Conflict. +// • Distinct id (FE có thể gửi trùng). +// • Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng tạo HĐ + +// DTO cũ); ≥2 HOẶC 0 → null. +// • KHÔNG còn check master Suppliers existence (logic mới chỉ validate participating-list) +// — NotFound master-existence bỏ; chỉ Conflict khi id không thuộc list của phiếu. +// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary "Bỏ chọn đơn vị NCC/TP trúng thầu" +// (count==0) vs "Chọn {n} đơn vị NCC/TP trúng thầu". // // Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ // (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name. @@ -67,74 +73,97 @@ public class PeSelectWinnerClearTests } // Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu. - private static async Task AddParticipatingAsync( + // Return PES row Id để assert IsWinner. + private static async Task AddParticipatingAsync( TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0) { - db.PurchaseEvaluationSuppliers.Add(new PurchaseEvaluationSupplier + var row = new PurchaseEvaluationSupplier { Id = Guid.NewGuid(), PurchaseEvaluationId = peId, SupplierId = supplierId, Order = order, - }); + }; + db.PurchaseEvaluationSuppliers.Add(row); await db.SaveChangesAsync(CancellationToken.None); + return row.Id; } + // Re-read IsWinner cho 1 supplier-row (theo SupplierId) — AsNoTracking để né cache. + private static async Task IsWinnerAsync( + TestApplicationDbContext db, Guid peId, Guid supplierId) + => await db.PurchaseEvaluationSuppliers.AsNoTracking() + .Where(s => s.PurchaseEvaluationId == peId && s.SupplierId == supplierId) + .Select(s => s.IsWinner) + .FirstAsync(); + // ============================================================ - // 1. CLEAR branch (MỚI — trục quan trọng nhất) + // 1. CLEAR branch ([] = bỏ chọn tất cả — trục quan trọng nhất) // ============================================================ [Fact] - public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull() + public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull_AllRowsNotWinner() { - // ⭐ Phiếu đang có SelectedSupplierId = winner (s1) + s1 VẪN nằm trong - // participating-list → gọi SupplierId=null → clear về null, KHÔNG ném. + // ⭐ Phiếu đang có 1 winner (s1, IsWinner=true) + s1 trong participating-list → + // gọi SupplierIds=[] → clear: SelectedSupplierId=null + s1.IsWinner=false. using var fix = new SqliteDbFixture(); var db = fix.Db; var s1 = await SeedSupplierAsync(db, "NCC-A"); var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); await AddParticipatingAsync(db, pe.Id, s1.Id); + // Pre-set s1 = winner (mô phỏng phiếu đã chọn trước). + var row = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id); + row.IsWinner = true; + await db.SaveChangesAsync(CancellationToken.None); var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null); + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty()); await handler.Handle(cmd, CancellationToken.None); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); reloaded.SelectedSupplierId.Should().BeNull("bỏ chọn = clear winner về none"); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear → mọi supplier-row IsWinner=false"); } [Fact] - public async Task Clear_WinnerNotInParticipatingList_StillClears_NoConflict() + public async Task Clear_EmptyList_AllRowsNotWinner_NoConflict() { - // Trục cốt lõi nhánh clear: SKIP validate participating-list. Phiếu có - // SelectedSupplierId = s1 (winner cũ) nhưng s1 KHÔNG còn trong list (vd bị - // gỡ khỏi bảng so sánh). Clear vẫn phải thành công, KHÔNG ném Conflict/NotFound. + // Trục cốt lõi nhánh clear: SKIP validate participating-list (list rỗng = không + // có id cần kiểm). Phiếu có 2 NCC tham gia, s1 đang là winner → clear [] → + // KHÔNG ném + cả 2 row IsWinner=false + SelectedSupplierId=null. using var fix = new SqliteDbFixture(); var db = fix.Db; - var s1 = await SeedSupplierAsync(db, "NCC-GONE"); + var s1 = await SeedSupplierAsync(db, "NCC-1"); + var s2 = await SeedSupplierAsync(db, "NCC-2"); var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); - // CỐ Ý không AddParticipating → s1 không thuộc list của phiếu. + await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); + await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); + var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id); + r1.IsWinner = true; + await db.SaveChangesAsync(CancellationToken.None); var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null); + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty()); var act = async () => await handler.Handle(cmd, CancellationToken.None); - await act.Should().NotThrowAsync("clear KHÔNG validate participating-list"); + await act.Should().NotThrowAsync("clear [] KHÔNG validate participating-list"); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); reloaded.SelectedSupplierId.Should().BeNull(); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse(); + (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse(); } [Fact] public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow() { - // Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null) → clear lần nữa = - // idempotent, vẫn null, không ném. Empty participating-list cũng không cản. + // Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null, không row IsWinner) → + // clear [] lần nữa = idempotent, vẫn null, không ném. Empty list cũng không cản. using var fix = new SqliteDbFixture(); var db = fix.Db; var pe = await SeedPeAsync(db, selectedSupplierId: null); var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null); + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty()); var act = async () => await handler.Handle(cmd, CancellationToken.None); await act.Should().NotThrowAsync(); @@ -145,34 +174,35 @@ public class PeSelectWinnerClearTests [Fact] public async Task Clear_WritesChangelog_WithBoChonSummary() { - // Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn NCC - // trúng thầu" (phân biệt với "Chọn NCC trúng thầu" của nhánh select). + // Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn đơn vị + // NCC/TP trúng thầu" (phân biệt với "Chọn {n} đơn vị..." của nhánh select). using var fix = new SqliteDbFixture(); var db = fix.Db; var s1 = await SeedSupplierAsync(db, "NCC-A"); var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); + await AddParticipatingAsync(db, pe.Id, s1.Id); var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null); + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty()); await handler.Handle(cmd, CancellationToken.None); var log = await db.PurchaseEvaluationChangelogs.AsNoTracking() .Where(c => c.PurchaseEvaluationId == pe.Id) .SingleAsync(); - log.Summary.Should().Be("Bỏ chọn NCC trúng thầu"); + log.Summary.Should().Be("Bỏ chọn đơn vị NCC/TP trúng thầu"); log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header); log.Action.Should().Be(ChangelogAction.Update); } // ============================================================ - // 2. SELECT branch (regression — logic cũ giữ nguyên 100%) + // 2. SELECT branch — 1 winner (backward compat single-winner) // ============================================================ [Fact] - public async Task Select_SupplierInParticipatingList_SetsSelectedSupplierId() + public async Task Select_OneSupplierInList_SetsSelectedSupplierId_RowIsWinner() { - // Happy-path cũ: supplier hợp lệ (tồn tại master + thuộc participating-list) - // → set SelectedSupplierId = đúng id. + // Happy-path single-winner: 1 supplier hợp lệ (thuộc participating-list) → + // SelectedSupplierId = đúng id (count==1 sync) + row IsWinner=true. using var fix = new SqliteDbFixture(); var db = fix.Db; var s1 = await SeedSupplierAsync(db, "NCC-WIN"); @@ -180,18 +210,19 @@ public class PeSelectWinnerClearTests await AddParticipatingAsync(db, pe.Id, s1.Id); var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s1.Id); + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id }); await handler.Handle(cmd, CancellationToken.None); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); - reloaded.SelectedSupplierId.Should().Be(s1.Id); + reloaded.SelectedSupplierId.Should().Be(s1.Id, "đúng 1 winner → SelectedSupplierId sync"); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue(); } [Fact] - public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2() + public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2_OnlyS2IsWinner() { - // Đổi winner: phiếu đang chọn s1 → chọn s2 (cả 2 trong list) → SelectedSupplierId - // chuyển s2 (chứng minh nhánh select overwrite, không chỉ set-from-null). + // Đổi winner: phiếu đang chọn s1 → chọn [s2] (cả 2 trong list) → SelectedSupplierId + // chuyển s2 + s2.IsWinner=true + s1.IsWinner=false (set false phần còn lại). using var fix = new SqliteDbFixture(); var db = fix.Db; var s1 = await SeedSupplierAsync(db, "NCC-1"); @@ -199,68 +230,217 @@ public class PeSelectWinnerClearTests var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); + // s1 đang là winner. + var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id); + r1.IsWinner = true; + await db.SaveChangesAsync(CancellationToken.None); var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s2.Id); + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s2.Id }); await handler.Handle(cmd, CancellationToken.None); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); reloaded.SelectedSupplierId.Should().Be(s2.Id); + (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue("s2 là winner mới"); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("winner cũ s1 bị set false"); } [Fact] public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate() { - // Supplier tồn tại ở master NHƯNG chưa được thêm vào phiếu → Conflict. - // entity.SelectedSupplierId giữ nguyên (throw TRƯỚC khi gán + SaveChanges). + // Supplier không thuộc participating-list của phiếu → Conflict. IsWinner + + // SelectedSupplierId giữ nguyên (throw TRƯỚC khi set + SaveChanges). using var fix = new SqliteDbFixture(); var db = fix.Db; var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list var outsider = await SeedSupplierAsync(db, "NCC-OUT"); // không trong list var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); await AddParticipatingAsync(db, pe.Id, s1.Id); + var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id); + r1.IsWinner = true; + await db.SaveChangesAsync(CancellationToken.None); var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: outsider.Id); + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { outsider.Id }); var act = async () => await handler.Handle(cmd, CancellationToken.None); await act.Should().ThrowAsync() .WithMessage("*NCC chưa được thêm vào phiếu*"); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); - reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC gán → winner cũ s1 giữ nguyên"); + reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC mutate → winner cũ s1 giữ nguyên"); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("Conflict → IsWinner s1 không bị set false"); } [Fact] - public async Task Select_SupplierNotInMaster_ThrowsNotFound() + public async Task Select_OneIdInListOneOutside_ThrowsConflict_NoPartialMutate() { - // Supplier Guid không tồn tại trong Suppliers master → NotFound (check master - // existence TRƯỚC participating-list). + // Multi-select với 1 id ngoài list (s1 trong, outsider ngoài) → Conflict (mỗi id + // phải thuộc list). KHÔNG partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ + // (validate TRƯỚC khi set IsWinner cho toàn bộ rows). using var fix = new SqliteDbFixture(); var db = fix.Db; + var s1 = await SeedSupplierAsync(db, "NCC-OK"); + var outsider = await SeedSupplierAsync(db, "NCC-FORGE"); var pe = await SeedPeAsync(db, selectedSupplierId: null); + await AddParticipatingAsync(db, pe.Id, s1.Id); var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: Guid.NewGuid()); + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, outsider.Id }); var act = async () => await handler.Handle(cmd, CancellationToken.None); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync() + .WithMessage("*NCC chưa được thêm vào phiếu*"); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("Conflict → KHÔNG set winner cho id hợp lệ trong cùng lệnh"); } // ============================================================ - // 3. PE existence — chung cả 2 nhánh (check TRƯỚC khi rẽ nhánh SupplierId) + // 3. SELECT branch — MULTI winner (≥2 → joint winner, SelectedSupplierId=null) + // [TASK 2.1] regression cho hành vi multi-winner mới (Mig 58). + // ============================================================ + + [Fact] + public async Task Select_TwoSuppliers_BothIsWinner_SelectedSupplierIdNull() + { + // Chọn 2 đơn vị cùng trúng → cả 2 IsWinner=true, SelectedSupplierId=null (≥2 → + // không còn "1 winner" duy nhất). Trục multi-winner cốt lõi. + using var fix = new SqliteDbFixture(); + var db = fix.Db; + var s1 = await SeedSupplierAsync(db, "NCC-J1"); + var s2 = await SeedSupplierAsync(db, "NCC-J2"); + var pe = await SeedPeAsync(db, selectedSupplierId: null); + await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); + await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); + var handler = BuildHandler(db); + + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }); + await handler.Handle(cmd, CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.SelectedSupplierId.Should().BeNull("≥2 winner → không sync 1 SelectedSupplierId"); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue(); + (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue(); + } + + [Fact] + public async Task Select_ThreeOfFour_ThreeIsWinner_NonSelectedIsFalse_SelectedSupplierIdNull() + { + // 4 NCC tham gia, chọn 3 → đúng 3 row IsWinner=true, NCC thứ 4 IsWinner=false, + // SelectedSupplierId=null. Verify "set false phần còn lại" với nhiều row. + using var fix = new SqliteDbFixture(); + var db = fix.Db; + var s1 = await SeedSupplierAsync(db, "NCC-Q1"); + var s2 = await SeedSupplierAsync(db, "NCC-Q2"); + var s3 = await SeedSupplierAsync(db, "NCC-Q3"); + var s4 = await SeedSupplierAsync(db, "NCC-Q4"); + var pe = await SeedPeAsync(db, selectedSupplierId: null); + await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); + await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); + await AddParticipatingAsync(db, pe.Id, s3.Id, order: 2); + await AddParticipatingAsync(db, pe.Id, s4.Id, order: 3); + var handler = BuildHandler(db); + + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id, s3.Id }); + await handler.Handle(cmd, CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.SelectedSupplierId.Should().BeNull(); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue(); + (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue(); + (await IsWinnerAsync(db, pe.Id, s3.Id)).Should().BeTrue(); + (await IsWinnerAsync(db, pe.Id, s4.Id)).Should().BeFalse("NCC không được chọn → IsWinner=false"); + } + + [Fact] + public async Task Select_DuplicateIds_Deduped_TreatedAsSingleWinner_SelectedSupplierIdSet() + { + // FE gửi trùng id [s1, s1] → Distinct → count==1 → đối xử single-winner: + // s1.IsWinner=true + SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng). + using var fix = new SqliteDbFixture(); + var db = fix.Db; + var s1 = await SeedSupplierAsync(db, "NCC-DUP"); + var pe = await SeedPeAsync(db, selectedSupplierId: null); + await AddParticipatingAsync(db, pe.Id, s1.Id); + var handler = BuildHandler(db); + + var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s1.Id }); + await handler.Handle(cmd, CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.SelectedSupplierId.Should().Be(s1.Id, "trùng id Distinct → count==1 → sync SelectedSupplierId"); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue(); + } + + [Fact] + public async Task Select_MultiThenClear_RoundTrip_AllWinnersReset() + { + // Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false + + // SelectedSupplierId=null. Chứng minh clear reset đúng kể cả từ trạng thái multi. + using var fix = new SqliteDbFixture(); + var db = fix.Db; + var s1 = await SeedSupplierAsync(db, "NCC-R1"); + var s2 = await SeedSupplierAsync(db, "NCC-R2"); + var pe = await SeedPeAsync(db, selectedSupplierId: null); + await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); + await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); + var handler = BuildHandler(db); + + await handler.Handle( + new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }), + CancellationToken.None); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("sau chọn 2"); + (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue(); + + await handler.Handle( + new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty()), + CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.SelectedSupplierId.Should().BeNull(); + (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear sau multi → false hết"); + (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse(); + } + + [Fact] + public async Task Select_MultiWinner_WritesChangelog_WithChonNSummary() + { + // Multi-winner ghi Changelog Summary "Chọn {n} đơn vị NCC/TP trúng thầu" (n = số + // đơn vị chọn). Verify count chính xác trong message. + using var fix = new SqliteDbFixture(); + var db = fix.Db; + var s1 = await SeedSupplierAsync(db, "NCC-CL1"); + var s2 = await SeedSupplierAsync(db, "NCC-CL2"); + var pe = await SeedPeAsync(db, selectedSupplierId: null); + await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); + await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); + var handler = BuildHandler(db); + + await handler.Handle( + new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }), + CancellationToken.None); + + var log = await db.PurchaseEvaluationChangelogs.AsNoTracking() + .Where(c => c.PurchaseEvaluationId == pe.Id) + .SingleAsync(); + log.Summary.Should().Be("Chọn 2 đơn vị NCC/TP trúng thầu"); + log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header); + log.Action.Should().Be(ChangelogAction.Update); + } + + // ============================================================ + // 4. PE existence — chung mọi nhánh (check TRƯỚC khi rẽ nhánh SupplierIds) // ============================================================ [Fact] public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch() { // PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler, TRƯỚC - // khi xét SupplierId (kể cả nhánh clear null cũng không bỏ qua existence). + // khi xét SupplierIds (kể cả nhánh clear [] cũng không bỏ qua existence). using var fix = new SqliteDbFixture(); var db = fix.Db; var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: null); + var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierIds: Array.Empty()); var act = async () => await handler.Handle(cmd, CancellationToken.None); await act.Should().ThrowAsync(); @@ -274,7 +454,7 @@ public class PeSelectWinnerClearTests var s1 = await SeedSupplierAsync(db, "NCC-A"); var handler = BuildHandler(db); - var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: s1.Id); + var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierIds: new[] { s1.Id }); var act = async () => await handler.Handle(cmd, CancellationToken.None); await act.Should().ThrowAsync(); diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs index d507a45..965d6c7 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs @@ -857,13 +857,25 @@ public class PeWorkItemBudgetTests } [Fact] - public void AdjustBudget_Validator_BudgetPeriodZero_FailsValidation() + public void AdjustBudget_Validator_BudgetPeriodZero_PassesValidation() { - // GreaterThan(0) when HasValue → 0 không hợp lệ. + // [Mig 58 item 2 — SPEC CHANGE] GreaterThanOrEqualTo(0) when HasValue → 0 HỢP LỆ + // (trước GreaterThan(0) chặn 0). SSG ngân sách 0 vẫn lưu/gửi duyệt được (anh Kiệt FDC). var validator = new AdjustPurchaseEvaluationBudgetCommandValidator(); var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 0m, null)); - result.IsValid.Should().BeFalse(); + result.IsValid.Should().BeTrue("ngân sách kỳ này = 0 hợp lệ sau Mig 58"); + } + + [Fact] + public void AdjustBudget_Validator_BudgetPeriodNegative_FailsValidation() + { + // [Mig 58 item 2] Boundary: 0 hợp lệ NHƯNG ÂM vẫn KHÔNG (GreaterThanOrEqualTo(0) + // = >= 0, loại số âm). Chứng minh nới chỉ tới 0, không mở cho âm. + var validator = new AdjustPurchaseEvaluationBudgetCommandValidator(); + var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), -1m, null)); + + result.IsValid.Should().BeFalse("ngân sách kỳ này ÂM không hợp lệ"); result.Errors.Should().Contain(e => e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.BudgetPeriodAmount)); } diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs new file mode 100644 index 0000000..8fd660c --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeApproverFinalizeTests.cs @@ -0,0 +1,319 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE) +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// ===== item 4 (Mig 58 2026-06-24 — CCM/anh Kiệt FDC) — PER-LEVEL finalize "Duyệt là +// KẾT THÚC, không trình CEO" — AUTO theo cấu hình quy trình (anh Kiệt chốt 2026-06-24 +// "cho bật trong quy trình hay hơn") — test theo CODE (S34 rule, KHÔNG touch production). +// +// SPEC item 4 (AUTO, KHÔNG ô-tích runtime): ApprovalWorkflowLevel +AllowApproverFinalize +// (bool, Mig 58). Trong ApproveV2Async, SAU opinion UPSERT + skipToFinal, TRƯỚC +// ccm-delegation + advance: +// if (matchingLevel.AllowApproverFinalize) { +// ApplyApprovedPriceOnFinalize(...); // human PHẢI chọn giá chốt (Conflict nếu null) +// Phase = DaDuyet; clear pointers (StepIdx/LevelOrder/SlaDeadline = null); return; +// } +// ⚠️ Quy-trình-driven: cấp được cấu hình KẾT THÚC → duyệt tới đó là TỰ done, KHÔNG cần +// cờ runtime. KHÔNG threshold, KHÔNG role-gate (admin chỉ-định slot = đủ thẩm quyền). Bỏ +// qua MỌI Bước/Cấp còn lại (kể cả CEO ở step sau). Wrong-actor vẫn Forbidden TRƯỚC finalize +// (actor-match guard chạy trước). +// +// Mirror harness PeCcmThresholdFinalizeTests.cs cùng folder (IdentityFixture + SQLite + +// SeedWorkflowAsync + BuildPeAtApprovalSlot + NoOpNotificationService). Dựng PE TRỰC TIẾP +// ở ChoDuyet tại slot NON-terminal (còn Step/CEO sau) → drive 1 Approve. +public class PeApproverFinalizeTests +{ + private const decimal ValidApprovedPrice = 400_000_000m; + private const string ValidApprovedSource = "Ncc"; + + private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, + TestApplicationDbContext db, FixedDateTime clock) CreateService() + { + var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var clock = new FixedDateTime(new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um); + return (svc, fix, db, clock); + } + + private static PurchaseEvaluation BuildPeAtApprovalSlot( + Guid approvalWorkflowId, int stepIdx, int levelOrder, string code = "PE-AF-001") + => new() + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.ChoDuyet, + MaPhieu = code, + TenGoiThau = "Test item 4 — per-level finalize (auto)", + ProjectId = Guid.NewGuid(), + DrafterUserId = Guid.NewGuid(), + ApprovalWorkflowId = approvalWorkflowId, + CurrentWorkflowStepIndex = stepIdx, + CurrentApprovalLevelOrder = levelOrder, + SlaDeadline = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + }; + + // Seed workflow V2. stepApprovers[s][lvl] = (approverUserId, allowFinalize) cho Cấp + // (Order lvl+1) của Bước (Order s+1). 1 NV / cấp. Return ApprovalWorkflow đã persist. + private static async Task SeedWorkflowAsync( + TestApplicationDbContext db, (Guid user, bool allowFinalize)[][] stepApprovers) + { + var wf = new ApprovalWorkflow + { + Code = "QT-AF-V2", + Version = 1, + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + Name = "QT test item 4 per-level finalize (auto)", + IsActive = true, + IsUserSelectable = true, + }; + for (int s = 0; s < stepApprovers.Length; s++) + { + var step = new ApprovalWorkflowStep + { + ApprovalWorkflowId = wf.Id, + Order = s + 1, + Name = $"Bước {s + 1}", + }; + for (int lvl = 0; lvl < stepApprovers[s].Length; lvl++) + { + var (user, allow) = stepApprovers[s][lvl]; + step.Levels.Add(new ApprovalWorkflowLevel + { + ApprovalWorkflowStepId = step.Id, + Order = lvl + 1, + Name = $"Cấp {lvl + 1}", + ApproverUserId = user, + AllowApproverFinalize = allow, + }); + } + wf.Steps.Add(step); + } + db.ApprovalWorkflows.Add(wf); + await db.SaveChangesAsync(CancellationToken.None); + return wf; + } + + // AUTO model: KHÔNG còn finalizeByApprover param — finalize tự chạy theo cờ cấp. + private static Task ApproveAsync( + PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId, + string[] roles, + decimal? approvedPriceAmount = null, + string? approvedPriceSource = null) => + svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place + actorUserId: actorUserId, + actorRoles: roles, + decision: ApprovalDecision.Approve, + comment: null, + approvedPriceAmount: approvedPriceAmount, + approvedPriceSource: approvedPriceSource, + ct: CancellationToken.None); + + // ===================================================================== + // 1. ⭐ LOAD-BEARING — slot NON-terminal AllowApproverFinalize=true + approver duyệt + // (+ giá chốt) → TỰ DaDuyet, bỏ CEO (Step 2), pointers cleared. AUTO, không ô-tích. + // ===================================================================== + [Fact] + public async Task ApproveV2_LevelAllowsFinalize_AutoFinalizesDaDuyet_SkipsCeo_PointersCleared() + { + var (svc, fix, db, _) = CreateService(); + using (fix) + { + // Bước 1 Cấp 1 = approver (AllowApproverFinalize=TRUE, đang đứng đây). + // Bước 2 Cấp 1 = CEO (sẽ bị BỎ QUA hoàn toàn). + var approver = (await fix.CreateUserAsync("appr@af.test", "Approver", null, Array.Empty())).Id; + var ceo = (await fix.CreateUserAsync("ceo@af.test", "CEO", null, new[] { AppRoles.Director })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { (approver, true) }, // Bước 1 Cấp 1 = approver, finalize-allowed + new[] { (ceo, false) }, // Bước 2 Cấp 1 = CEO (bỏ qua) + }); + + var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await ApproveAsync(svc, pe, approver, Array.Empty(), + approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, + "slot AllowApproverFinalize → duyệt tới đây là TỰ kết thúc, bỏ CEO"); + pe.CurrentWorkflowStepIndex.Should().BeNull("terminal → step pointer null"); + pe.CurrentApprovalLevelOrder.Should().BeNull("terminal → level pointer null"); + pe.SlaDeadline.Should().BeNull("terminal → SLA null"); + pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice, "giá chốt bind khi finalize"); + pe.ApprovedPriceSource.Should().Be(ValidApprovedSource); + + // CEO KHÔNG được ghi opinion (bị bỏ qua — chỉ approver Bước 1 ký). + var opinions = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + var approverLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 1).Id; + opinions.Should().ContainSingle(o => o.ApprovalWorkflowLevelId == approverLevelId, + "chỉ slot approver ký, không ghi hộ CEO"); + + // 1 Approval Approve (của approver), finalize-branch return → KHÔNG double. + var approvals = await db.PurchaseEvaluationApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve) + .ToListAsync(); + approvals.Should().HaveCount(1, "1 lần duyệt = 1 Approval row, không double-finalize"); + } + } + + // ===================================================================== + // 2. CONFIG GATES IT — slot AllowApproverFinalize=FALSE + approver duyệt → KHÔNG + // finalize, advance bình thường lên CEO (Step 2). Chứng minh CHỈ cấp được cấu + // hình KẾT THÚC mới dừng; cấp thường vẫn trình tiếp. + // ===================================================================== + [Fact] + public async Task ApproveV2_LevelNotAllowed_AdvancesToCeo_PhaseStaysChoDuyet() + { + var (svc, fix, db, clock) = CreateService(); + using (fix) + { + var approver = (await fix.CreateUserAsync("appr2@af.test", "Approver", null, Array.Empty())).Id; + var ceo = (await fix.CreateUserAsync("ceo2@af.test", "CEO", null, new[] { AppRoles.Director })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { (approver, false) }, // ⭐ slot KHÔNG cấu hình KẾT THÚC + new[] { (ceo, false) }, + }); + + var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-002"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await ApproveAsync(svc, pe, approver, Array.Empty()); // không giá chốt — chưa terminal + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, + "slot không cấu hình kết thúc → advance bình thường lên CEO"); + pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)"); + pe.CurrentApprovalLevelOrder.Should().Be(1); + pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7), "SLA reset cho CEO"); + pe.ApprovedPriceAmount.Should().BeNull("chưa terminal → chưa bind giá chốt"); + } + } + + // ===================================================================== + // 3. PRICE REQUIRED — slot allow=true NHƯNG approvedPrice null (human) → + // Conflict("giá chốt"). Finalize cũng cần giá chốt như terminal cuối. + // ===================================================================== + [Fact] + public async Task ApproveV2_LevelAllowsFinalize_NullPrice_ThrowsConflict() + { + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var approver = (await fix.CreateUserAsync("appr3@af.test", "Approver", null, Array.Empty())).Id; + var ceo = (await fix.CreateUserAsync("ceo3@af.test", "CEO", null, new[] { AppRoles.Director })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { (approver, true) }, + new[] { (ceo, false) }, + }); + + var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-003"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + // slot allowed NHƯNG KHÔNG truyền giá chốt → finalize đòi giá chốt. + var act = async () => await ApproveAsync(svc, pe, approver, Array.Empty(), + approvedPriceAmount: null, approvedPriceSource: null); + + await act.Should().ThrowAsync() + .WithMessage("*giá chốt*"); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "thiếu giá chốt → không finalize"); + } + } + + // ===================================================================== + // 4. WRONG-ACTOR precedence — approver-match guard chạy TRƯỚC finalize-branch: + // NV KHÔNG thuộc Cấp hiện tại duyệt → Forbidden (không phải lượt), KHÔNG tới + // được finalize logic. Bảo vệ forge request. + // ===================================================================== + [Fact] + public async Task ApproveV2_ActorNotInLevel_ThrowsForbidden_BeforeFinalize() + { + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var realApprover = (await fix.CreateUserAsync("real4@af.test", "Real Approver", null, Array.Empty())).Id; + var intruder = (await fix.CreateUserAsync("intruder4@af.test", "Intruder", null, Array.Empty())).Id; + var ceo = (await fix.CreateUserAsync("ceo4@af.test", "CEO", null, new[] { AppRoles.Director })).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { (realApprover, true) }, // slot cấu hình kết thúc — nhưng intruder KHÔNG phải người này + new[] { (ceo, false) }, + }); + + var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-004"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + // intruder (không trong Cấp) duyệt → Forbidden TRƯỚC finalize logic. + var act = async () => await ApproveAsync(svc, pe, intruder, Array.Empty(), + approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource); + + await act.Should().ThrowAsync(); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "không phải lượt → không finalize"); + } + } + + // ===================================================================== + // 5. AT-LAST-SLOT — slot CUỐI (1 bước 1 cấp) + allow=true → DaDuyet qua finalize- + // branch (chạy TRƯỚC normal terminal advance). 1 Approval, không double. + // ===================================================================== + [Fact] + public async Task ApproveV2_AtLastSlot_LevelAllowsFinalize_FinalizesDaDuyet_NoDoubleApproval() + { + var (svc, fix, db, _) = CreateService(); + using (fix) + { + // 1 bước 1 cấp = approver (slot cuối luôn), allow finalize. + var approver = (await fix.CreateUserAsync("appr5@af.test", "Approver", null, Array.Empty())).Id; + + var wf = await SeedWorkflowAsync(db, new[] + { + new[] { (approver, true) }, + }); + + var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-005"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await ApproveAsync(svc, pe, approver, Array.Empty(), + approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, + "finalize ở slot cuối → DaDuyet qua finalize-branch (trước normal advance)"); + pe.CurrentWorkflowStepIndex.Should().BeNull(); + pe.CurrentApprovalLevelOrder.Should().BeNull(); + pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice); + + var approvals = await db.PurchaseEvaluationApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve) + .ToListAsync(); + approvals.Should().HaveCount(1, "1 lần duyệt = 1 Approval row, không double"); + } + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeCcmThresholdFinalizeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeCcmThresholdFinalizeTests.cs index 1916fdf..d621514 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeCcmThresholdFinalizeTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeCcmThresholdFinalizeTests.cs @@ -89,6 +89,7 @@ public class PeCcmThresholdFinalizeTests PurchaseEvaluationId = pe.Id, SupplierId = supplierId, Order = 0, + IsWinner = true, // [Mig 58] ccm-delegate winnerQuoteTotal đếm SUM theo IsWinner rows }; var detail = new PurchaseEvaluationDetail { diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs index 8625c4a..722eb10 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs @@ -79,6 +79,9 @@ public class PeSubmitGuardAndBypassTests // Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1 // quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref). + // [Mig 58] Winner = supplier-row IsWinner=true (nguồn-sự-thật mới — submit guard + + // ccm-delegate + read DTO SUM theo IsWinner, KHÔNG còn theo SelectedSupplierId). Set + // IsWinner=true + sync SelectedSupplierId trong caller để mirror handler select-winner. private static async Task SeedWinnerWithQuoteAsync( TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien) { @@ -88,6 +91,7 @@ public class PeSubmitGuardAndBypassTests PurchaseEvaluationId = pe.Id, SupplierId = supplierId, Order = 0, + IsWinner = true, // [Mig 58] đánh dấu winner — submit guard đếm IsWinner rows }; var detail = new PurchaseEvaluationDetail { @@ -257,15 +261,16 @@ public class PeSubmitGuardAndBypassTests } [Fact] - public async Task Submit_MissingBudget_BothNullAndManualZero_ThrowsConflict() + public async Task Submit_MissingBudget_NullBlocks_ThrowsConflict() { - // (4) Đủ winner + quote + comparison. Thiếu ngân sách: BudgetId null VÀ - // BudgetManualAmount = 0 → "chưa nhập Ngân sách" (cover cả nhánh manual=0, - // không chỉ null). + // (4) [Mig 58 item 2] Đủ winner + quote + comparison. Ngân sách = NULL (chưa nhập, + // và phiếu KHÔNG có WorkItemId fallback ProInitial) → "chưa nhập Ngân sách kỳ này". + // ĐỔI từ trước (block khi null||<=0): NAY chỉ NULL block, 0 hợp lệ (test 4b). Phiếu + // KHÔNG pin WorkItemId → effectiveBudget=null sau khi không fallback được. var (svc, fix, db, _) = CreateService(); using (fix) { - var pe = BuildPeNhap(budgetPeriodAmount: 0m); + var pe = BuildPeNhap(budgetPeriodAmount: null); // null = chưa nhập → block db.PurchaseEvaluations.Add(pe); await db.SaveChangesAsync(CancellationToken.None); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); @@ -283,6 +288,65 @@ public class PeSubmitGuardAndBypassTests } } + [Fact] + public async Task Submit_BudgetZero_IsValid_PassesBudgetGuard_ThenWorkflowRequired() + { + // (4b) [Mig 58 item 2] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn cho gửi duyệt). + // ĐỔI hành vi: trước `is null || <= 0` block; NAY chỉ `is null` block → 0 đi qua. + // Đủ winner + quote>0 + comparison + budget=0 → guard Section-3 PASS, KHÔNG còn + // "chưa nhập Ngân sách". Phiếu KHÔNG pin workflow → dừng ở require-workflow guard + // (chứng minh đã qua budget-guard: message NHẮC workflow chứ KHÔNG nhắc ngân sách). + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var pe = BuildPeNhap(budgetPeriodAmount: 0m); // 0 = cố ý → hợp lệ + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + + var act = () => SubmitAsync(svc, pe, Guid.NewGuid()); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().NotContain("Ngân sách", "ngân sách = 0 hợp lệ → qua budget-guard"); + ex.Which.Message.Should().Contain("quy trình duyệt", "dừng ở require-workflow (sau budget-guard)"); + pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao); + } + } + + [Fact] + public async Task Submit_BudgetZero_WithWorkflow_SucceedsToChoDuyet() + { + // (4c) [Mig 58 item 2] END-TO-END ngân sách 0 hợp lệ: đủ 4 Section-3 (winner + + // quote>0 + comparison + budget=0) + pin workflow V2 → submit THÀNH CÔNG, Phase + // ChoDuyet (KHÔNG ConflictException "chưa nhập Ngân sách"). Đây là trục item 2 cốt + // lõi: ngân sách 0 không cản gửi duyệt. Drafter ngoài chuỗi → KHÔNG bypass, start + // pointer Bước 1 Cấp 1. + var (svc, fix, db, clock) = CreateService(); + using (fix) + { + var approver = (await fix.CreateUserAsync("appr0@s60.test", "Approver", null, Array.Empty())).Id; + var wf = await SeedWorkflowSingleStepAsync(db, approver); + + var pe = BuildPeNhap(budgetPeriodAmount: 0m, approvalWorkflowId: wf.Id, code: "PE-S60-B0"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); + pe.SelectedSupplierId = supplierId; + SeedComparisonAttachment(db, pe); + await db.SaveChangesAsync(CancellationToken.None); + + await SubmitAsync(svc, pe, Guid.NewGuid()); // drafter random (ngoài chuỗi) + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "ngân sách 0 + đủ ĐK + workflow → gửi duyệt OK"); + pe.CurrentWorkflowStepIndex.Should().Be(0); + pe.CurrentApprovalLevelOrder.Should().Be(1); + pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7)); + } + } + [Fact] public async Task Submit_MissingComparisonTable_ThrowsConflict() {