From e100ef065b590287499a20cf5b5869144128b487 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Sun, 12 Jul 2026 16:13:46 +0700 Subject: [PATCH] [CLAUDE] Supplier: Excel-import Phase B (upload NCC preview/confirm) + Mig 63 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import Excel 'Database NCC': upload→preview classify (New/Update/Skip/Error)→confirm all-or-nothing upsert-by-Code (OrdinalIgnoreCase dedup, fill-nulls-safe). Mig 63 +SourceUpdatedAt/By (2 nullable). Parser layout-locked header-fingerprint NFC-normalized + absolute-cell-index + #REF!-null + NAS-backslash-raw. Type-lạ→NhaCungCap. FE dialog fe-admin+fe-user byte-identical. 10 test SupplierExcelImportServiceTests. Co-Authored-By: Claude Fable 5 --- .../master/SupplierImportDialog.tsx | 354 + fe-admin/src/pages/master/SuppliersPage.tsx | 26 +- .../master/SupplierImportDialog.tsx | 354 + fe-user/src/pages/master/SuppliersPage.tsx | 26 +- .../Controllers/SuppliersController.cs | 22 + .../SupplierImportConfirmCommand.cs | 28 + .../SupplierImportPreviewCommand.cs | 18 + .../Master/Suppliers/Dtos/SupplierDto.cs | 3 + .../Suppliers/Dtos/SupplierImportDtos.cs | 87 + .../Import/ISupplierExcelImportService.cs | 24 + .../Queries/GetSupplier/GetSupplierQuery.cs | 1 + .../ListSuppliers/ListSuppliersQuery.cs | 1 + .../SolutionErp.Domain/Master/Supplier.cs | 5 + .../DependencyInjection.cs | 2 + .../Configurations/SupplierConfiguration.cs | 3 + ..._AddSupplierImportSourceFields.Designer.cs | 6355 +++++++++++++++++ ...712070117_AddSupplierImportSourceFields.cs | 40 + .../ApplicationDbContextModelSnapshot.cs | 7 + .../Services/SupplierExcelImportService.cs | 474 ++ .../SupplierExcelImportServiceTests.cs | 452 ++ 20 files changed, 8268 insertions(+), 14 deletions(-) create mode 100644 fe-admin/src/components/master/SupplierImportDialog.tsx create mode 100644 fe-user/src/components/master/SupplierImportDialog.tsx create mode 100644 src/Backend/SolutionErp.Application/Master/Suppliers/Commands/ImportSuppliers/SupplierImportConfirmCommand.cs create mode 100644 src/Backend/SolutionErp.Application/Master/Suppliers/Commands/ImportSuppliers/SupplierImportPreviewCommand.cs create mode 100644 src/Backend/SolutionErp.Application/Master/Suppliers/Dtos/SupplierImportDtos.cs create mode 100644 src/Backend/SolutionErp.Application/Master/Suppliers/Import/ISupplierExcelImportService.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260712070117_AddSupplierImportSourceFields.Designer.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260712070117_AddSupplierImportSourceFields.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Services/SupplierExcelImportService.cs create mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/SupplierExcelImportServiceTests.cs diff --git a/fe-admin/src/components/master/SupplierImportDialog.tsx b/fe-admin/src/components/master/SupplierImportDialog.tsx new file mode 100644 index 0000000..ba7c096 --- /dev/null +++ b/fe-admin/src/components/master/SupplierImportDialog.tsx @@ -0,0 +1,354 @@ +import { useEffect, useRef, useState, type ChangeEvent, type DragEvent } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { AlertTriangle, FileSpreadsheet, Loader2, Upload } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/Button' +import { Dialog } from '@/components/ui/Dialog' +import { api } from '@/lib/api' +import { getErrorMessage } from '@/lib/apiError' +import { cn } from '@/lib/cn' +import { SupplierTypeLabel, type SupplierStatus, type SupplierType } from '@/types/master' + +// ============================================================================ +// Supplier Phase B — Import Excel "Database NCC" (Approach A, layout-locked). +// 2 bước: preview (parse + phân loại, KHÔNG ghi DB) → confirm (ALL-OR-NOTHING). +// Round-trip preview.rows verbatim sang confirm (BE bind { rows: [...] }). +// Field camelCase mirror SupplierImportDtos.cs (30 field + classify). +// ============================================================================ + +export const RowImportStatus = { + New: 0, + Update: 1, + Skip: 2, + Error: 3, +} as const +export type RowImportStatus = typeof RowImportStatus[keyof typeof RowImportStatus] + +export type SupplierImportRow = { + rowIndex: number + packageCategory: string | null + type: SupplierType + code: string | null + name: string | null + address: string | null + officeAddress: string | null + phone: string | null + fax: string | null + bankAccount: string | null + secondaryBankAccount: string | null + taxCode: string | null + legalRepresentative: string | null + legalRepTitle: string | null + authorizationNote: string | null + linkGuq: string | null + linkGpkd: string | null + linkHsnl: string | null + contactPerson: string | null + contactTitle: string | null + contactPhone: string | null + email: string | null + mailingAddress: string | null + mailRecipient: string | null + referralSource: string | null + ownerPmh: string | null + supplierStatus: SupplierStatus | null + note: string | null + sourceUpdatedAt: string | null + sourceUpdatedBy: string | null + status: RowImportStatus + messages: string[] + existingSupplierId: string | null +} + +export type SupplierImportPreview = { + rows: SupplierImportRow[] + newCount: number + updateCount: number + skipCount: number + errorCount: number + warnings: string[] + layoutValid: boolean +} + +export type SupplierImportResult = { + inserted: number + updated: number + skipped: number + errors: string[] + committed: boolean +} + +// Full class literal (Pattern 14 — Tailwind JIT không purge dynamic string). +const STATUS_META: Record = { + [RowImportStatus.New]: { label: 'Thêm mới', cls: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20' }, + [RowImportStatus.Update]: { label: 'Cập nhật', cls: 'bg-blue-100 text-blue-700 ring-blue-600/20' }, + [RowImportStatus.Skip]: { label: 'Bỏ qua', cls: 'bg-slate-100 text-slate-600 ring-slate-500/20' }, + [RowImportStatus.Error]: { label: 'Lỗi', cls: 'bg-red-100 text-red-700 ring-red-600/20' }, +} + +function StatusBadge({ status }: { status: RowImportStatus }) { + const meta = STATUS_META[status] ?? STATUS_META[RowImportStatus.Skip] + return ( + + {meta.label} + + ) +} + +export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const qc = useQueryClient() + const inputRef = useRef(null) + const [dragging, setDragging] = useState(false) + const [fileName, setFileName] = useState('') + const [preview, setPreview] = useState(null) + const [result, setResult] = useState(null) + + // Reset toàn bộ state mỗi lần mở dialog (tránh giữ preview cũ từ lần import trước). + useEffect(() => { + if (open) { + setFileName('') + setPreview(null) + setResult(null) + setDragging(false) + } + }, [open]) + + const previewMut = useMutation({ + mutationFn: async (file: File) => { + const fd = new FormData() + fd.append('file', file) + const res = await api.post('/suppliers/import/preview', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return res.data + }, + onSuccess: data => setPreview(data), + onError: err => { + setPreview(null) + toast.error(getErrorMessage(err)) + }, + }) + + const confirmMut = useMutation({ + mutationFn: async (rows: SupplierImportRow[]) => { + const res = await api.post('/suppliers/import/confirm', { rows }) + return res.data + }, + onSuccess: data => { + setResult(data) + if (data.committed) { + qc.invalidateQueries({ queryKey: ['suppliers'] }) + toast.success(`Đã nhập: ${data.inserted} mới, ${data.updated} cập nhật, ${data.skipped} bỏ qua`) + onClose() + } + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + function pickFile(files: FileList | null) { + const file = files?.[0] + if (!file) return + setFileName(file.name) + setPreview(null) + setResult(null) + previewMut.mutate(file) + } + + function onDrop(e: DragEvent) { + e.preventDefault() + setDragging(false) + pickFile(e.dataTransfer.files) + } + + function onPick(e: ChangeEvent) { + pickFile(e.target.files) + e.target.value = '' // reset để chọn lại cùng file nếu cần + } + + const counts = preview + ? [ + { label: 'Thêm mới', value: preview.newCount, cls: 'bg-emerald-100 text-emerald-700' }, + { label: 'Cập nhật', value: preview.updateCount, cls: 'bg-blue-100 text-blue-700' }, + { label: 'Bỏ qua', value: preview.skipCount, cls: 'bg-slate-100 text-slate-600' }, + { label: 'Lỗi', value: preview.errorCount, cls: 'bg-red-100 text-red-700' }, + ] + : [] + + return ( + + + {preview && preview.layoutValid && ( + + )} + + } + > +
+ {/* Vùng chọn / kéo-thả file .xlsx */} +
{ + e.preventDefault() + setDragging(true) + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + onClick={() => inputRef.current?.click()} + className={cn( + 'cursor-pointer rounded-lg border-2 border-dashed px-4 py-6 text-center transition', + dragging ? 'border-brand-500 bg-brand-50' : 'border-slate-300 bg-slate-50/50 hover:bg-slate-50', + )} + > + +
+ {fileName ? ( + + + {fileName} + + ) : ( + <> + Kéo thả file Excel vào đây hoặc chọn file + + )} +
+
Chỉ nhận file .xlsx đúng layout "Database NCC"
+ +
+ + {/* Đang phân tích */} + {previewMut.isPending && ( +
+ + Đang phân tích file… +
+ )} + + {/* Sai layout → từ chối, ẩn nút xác nhận */} + {preview && !preview.layoutValid && ( +
+
+ + Sai layout file Excel — không nhập được +
+ {preview.warnings.length > 0 && ( +
    + {preview.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+ )} +
+ )} + + {/* Layout hợp lệ → tóm tắt + cảnh báo + bảng */} + {preview && preview.layoutValid && ( + <> +
+ {counts.map(c => ( + + {c.label}: {c.value} + + ))} +
+ + {preview.warnings.length > 0 && ( +
+
+ + Cảnh báo +
+
    + {preview.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+
+ )} + +
+ + + + + + + + + + + + + {preview.rows.map(r => ( + + + + + + + + + ))} + {preview.rows.length === 0 && ( + + + + )} + +
DòngTrạng tháiTên NCCLoạiThông báo
{r.rowIndex} + + {r.code || '—'}{r.name || '—'}{SupplierTypeLabel[r.type] ?? '—'} + {r.messages.length > 0 ? r.messages.join('; ') : ''} +
+ Không có dòng dữ liệu nào trong file. +
+
+ + {preview.errorCount > 0 && ( +
+ Còn {preview.errorCount} dòng lỗi — sửa file Excel rồi tải lại trước khi nhập. +
+ )} + + )} + + {/* Confirm trả hard-error → ALL-OR-NOTHING, chưa lưu gì */} + {result && !result.committed && ( +
+
+ + Không thể nhập — chưa có dữ liệu nào được lưu +
+ {result.errors.length > 0 && ( +
    + {result.errors.map((e, i) => ( +
  • {e}
  • + ))} +
+ )} +
+ )} +
+
+ ) +} diff --git a/fe-admin/src/pages/master/SuppliersPage.tsx b/fe-admin/src/pages/master/SuppliersPage.tsx index 0d79640..0126872 100644 --- a/fe-admin/src/pages/master/SuppliersPage.tsx +++ b/fe-admin/src/pages/master/SuppliersPage.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Pencil, Plus, Trash2 } from 'lucide-react' +import { Pencil, Plus, Trash2, Upload } from 'lucide-react' import { toast } from 'sonner' import { PageHeader } from '@/components/PageHeader' import { DataTable, Pagination, type Column } from '@/components/DataTable' @@ -12,6 +12,7 @@ import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' import { Dialog } from '@/components/ui/Dialog' import { PathLink } from '@/components/ui/PathLink' +import { SupplierImportDialog } from '@/components/master/SupplierImportDialog' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { MenuKeys } from '@/lib/menuKeys' @@ -72,6 +73,7 @@ export function SuppliersPage() { const [sortDesc, setSortDesc] = useState(true) const [open, setOpen] = useState(false) + const [importOpen, setImportOpen] = useState(false) const [form, setForm] = useState(emptyForm) const isEdit = !!form.id @@ -209,12 +211,20 @@ export function SuppliersPage() { title="Nhà cung cấp" description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư" actions={ - - - +
+ + + + + + +
} /> @@ -413,6 +423,8 @@ export function SuppliersPage() { + + setImportOpen(false)} /> ) } diff --git a/fe-user/src/components/master/SupplierImportDialog.tsx b/fe-user/src/components/master/SupplierImportDialog.tsx new file mode 100644 index 0000000..ba7c096 --- /dev/null +++ b/fe-user/src/components/master/SupplierImportDialog.tsx @@ -0,0 +1,354 @@ +import { useEffect, useRef, useState, type ChangeEvent, type DragEvent } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { AlertTriangle, FileSpreadsheet, Loader2, Upload } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/Button' +import { Dialog } from '@/components/ui/Dialog' +import { api } from '@/lib/api' +import { getErrorMessage } from '@/lib/apiError' +import { cn } from '@/lib/cn' +import { SupplierTypeLabel, type SupplierStatus, type SupplierType } from '@/types/master' + +// ============================================================================ +// Supplier Phase B — Import Excel "Database NCC" (Approach A, layout-locked). +// 2 bước: preview (parse + phân loại, KHÔNG ghi DB) → confirm (ALL-OR-NOTHING). +// Round-trip preview.rows verbatim sang confirm (BE bind { rows: [...] }). +// Field camelCase mirror SupplierImportDtos.cs (30 field + classify). +// ============================================================================ + +export const RowImportStatus = { + New: 0, + Update: 1, + Skip: 2, + Error: 3, +} as const +export type RowImportStatus = typeof RowImportStatus[keyof typeof RowImportStatus] + +export type SupplierImportRow = { + rowIndex: number + packageCategory: string | null + type: SupplierType + code: string | null + name: string | null + address: string | null + officeAddress: string | null + phone: string | null + fax: string | null + bankAccount: string | null + secondaryBankAccount: string | null + taxCode: string | null + legalRepresentative: string | null + legalRepTitle: string | null + authorizationNote: string | null + linkGuq: string | null + linkGpkd: string | null + linkHsnl: string | null + contactPerson: string | null + contactTitle: string | null + contactPhone: string | null + email: string | null + mailingAddress: string | null + mailRecipient: string | null + referralSource: string | null + ownerPmh: string | null + supplierStatus: SupplierStatus | null + note: string | null + sourceUpdatedAt: string | null + sourceUpdatedBy: string | null + status: RowImportStatus + messages: string[] + existingSupplierId: string | null +} + +export type SupplierImportPreview = { + rows: SupplierImportRow[] + newCount: number + updateCount: number + skipCount: number + errorCount: number + warnings: string[] + layoutValid: boolean +} + +export type SupplierImportResult = { + inserted: number + updated: number + skipped: number + errors: string[] + committed: boolean +} + +// Full class literal (Pattern 14 — Tailwind JIT không purge dynamic string). +const STATUS_META: Record = { + [RowImportStatus.New]: { label: 'Thêm mới', cls: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20' }, + [RowImportStatus.Update]: { label: 'Cập nhật', cls: 'bg-blue-100 text-blue-700 ring-blue-600/20' }, + [RowImportStatus.Skip]: { label: 'Bỏ qua', cls: 'bg-slate-100 text-slate-600 ring-slate-500/20' }, + [RowImportStatus.Error]: { label: 'Lỗi', cls: 'bg-red-100 text-red-700 ring-red-600/20' }, +} + +function StatusBadge({ status }: { status: RowImportStatus }) { + const meta = STATUS_META[status] ?? STATUS_META[RowImportStatus.Skip] + return ( + + {meta.label} + + ) +} + +export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const qc = useQueryClient() + const inputRef = useRef(null) + const [dragging, setDragging] = useState(false) + const [fileName, setFileName] = useState('') + const [preview, setPreview] = useState(null) + const [result, setResult] = useState(null) + + // Reset toàn bộ state mỗi lần mở dialog (tránh giữ preview cũ từ lần import trước). + useEffect(() => { + if (open) { + setFileName('') + setPreview(null) + setResult(null) + setDragging(false) + } + }, [open]) + + const previewMut = useMutation({ + mutationFn: async (file: File) => { + const fd = new FormData() + fd.append('file', file) + const res = await api.post('/suppliers/import/preview', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return res.data + }, + onSuccess: data => setPreview(data), + onError: err => { + setPreview(null) + toast.error(getErrorMessage(err)) + }, + }) + + const confirmMut = useMutation({ + mutationFn: async (rows: SupplierImportRow[]) => { + const res = await api.post('/suppliers/import/confirm', { rows }) + return res.data + }, + onSuccess: data => { + setResult(data) + if (data.committed) { + qc.invalidateQueries({ queryKey: ['suppliers'] }) + toast.success(`Đã nhập: ${data.inserted} mới, ${data.updated} cập nhật, ${data.skipped} bỏ qua`) + onClose() + } + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + function pickFile(files: FileList | null) { + const file = files?.[0] + if (!file) return + setFileName(file.name) + setPreview(null) + setResult(null) + previewMut.mutate(file) + } + + function onDrop(e: DragEvent) { + e.preventDefault() + setDragging(false) + pickFile(e.dataTransfer.files) + } + + function onPick(e: ChangeEvent) { + pickFile(e.target.files) + e.target.value = '' // reset để chọn lại cùng file nếu cần + } + + const counts = preview + ? [ + { label: 'Thêm mới', value: preview.newCount, cls: 'bg-emerald-100 text-emerald-700' }, + { label: 'Cập nhật', value: preview.updateCount, cls: 'bg-blue-100 text-blue-700' }, + { label: 'Bỏ qua', value: preview.skipCount, cls: 'bg-slate-100 text-slate-600' }, + { label: 'Lỗi', value: preview.errorCount, cls: 'bg-red-100 text-red-700' }, + ] + : [] + + return ( + + + {preview && preview.layoutValid && ( + + )} + + } + > +
+ {/* Vùng chọn / kéo-thả file .xlsx */} +
{ + e.preventDefault() + setDragging(true) + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + onClick={() => inputRef.current?.click()} + className={cn( + 'cursor-pointer rounded-lg border-2 border-dashed px-4 py-6 text-center transition', + dragging ? 'border-brand-500 bg-brand-50' : 'border-slate-300 bg-slate-50/50 hover:bg-slate-50', + )} + > + +
+ {fileName ? ( + + + {fileName} + + ) : ( + <> + Kéo thả file Excel vào đây hoặc chọn file + + )} +
+
Chỉ nhận file .xlsx đúng layout "Database NCC"
+ +
+ + {/* Đang phân tích */} + {previewMut.isPending && ( +
+ + Đang phân tích file… +
+ )} + + {/* Sai layout → từ chối, ẩn nút xác nhận */} + {preview && !preview.layoutValid && ( +
+
+ + Sai layout file Excel — không nhập được +
+ {preview.warnings.length > 0 && ( +
    + {preview.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+ )} +
+ )} + + {/* Layout hợp lệ → tóm tắt + cảnh báo + bảng */} + {preview && preview.layoutValid && ( + <> +
+ {counts.map(c => ( + + {c.label}: {c.value} + + ))} +
+ + {preview.warnings.length > 0 && ( +
+
+ + Cảnh báo +
+
    + {preview.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+
+ )} + +
+ + + + + + + + + + + + + {preview.rows.map(r => ( + + + + + + + + + ))} + {preview.rows.length === 0 && ( + + + + )} + +
DòngTrạng tháiTên NCCLoạiThông báo
{r.rowIndex} + + {r.code || '—'}{r.name || '—'}{SupplierTypeLabel[r.type] ?? '—'} + {r.messages.length > 0 ? r.messages.join('; ') : ''} +
+ Không có dòng dữ liệu nào trong file. +
+
+ + {preview.errorCount > 0 && ( +
+ Còn {preview.errorCount} dòng lỗi — sửa file Excel rồi tải lại trước khi nhập. +
+ )} + + )} + + {/* Confirm trả hard-error → ALL-OR-NOTHING, chưa lưu gì */} + {result && !result.committed && ( +
+
+ + Không thể nhập — chưa có dữ liệu nào được lưu +
+ {result.errors.length > 0 && ( +
    + {result.errors.map((e, i) => ( +
  • {e}
  • + ))} +
+ )} +
+ )} +
+
+ ) +} diff --git a/fe-user/src/pages/master/SuppliersPage.tsx b/fe-user/src/pages/master/SuppliersPage.tsx index 0d79640..0126872 100644 --- a/fe-user/src/pages/master/SuppliersPage.tsx +++ b/fe-user/src/pages/master/SuppliersPage.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Pencil, Plus, Trash2 } from 'lucide-react' +import { Pencil, Plus, Trash2, Upload } from 'lucide-react' import { toast } from 'sonner' import { PageHeader } from '@/components/PageHeader' import { DataTable, Pagination, type Column } from '@/components/DataTable' @@ -12,6 +12,7 @@ import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' import { Dialog } from '@/components/ui/Dialog' import { PathLink } from '@/components/ui/PathLink' +import { SupplierImportDialog } from '@/components/master/SupplierImportDialog' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { MenuKeys } from '@/lib/menuKeys' @@ -72,6 +73,7 @@ export function SuppliersPage() { const [sortDesc, setSortDesc] = useState(true) const [open, setOpen] = useState(false) + const [importOpen, setImportOpen] = useState(false) const [form, setForm] = useState(emptyForm) const isEdit = !!form.id @@ -209,12 +211,20 @@ export function SuppliersPage() { title="Nhà cung cấp" description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư" actions={ - - - +
+ + + + + + +
} /> @@ -413,6 +423,8 @@ export function SuppliersPage() { + + setImportOpen(false)} /> ) } diff --git a/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs b/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs index d9a8d06..9cc3ff9 100644 --- a/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/SuppliersController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using SolutionErp.Application.Common.Models; using SolutionErp.Application.Master.Suppliers.Commands.CreateSupplier; using SolutionErp.Application.Master.Suppliers.Commands.DeleteSupplier; +using SolutionErp.Application.Master.Suppliers.Commands.ImportSuppliers; using SolutionErp.Application.Master.Suppliers.Commands.UpdateSupplier; using SolutionErp.Application.Master.Suppliers.Dtos; using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier; @@ -57,4 +58,25 @@ public class SuppliersController(IMediator mediator) : ControllerBase await mediator.Send(new DeleteSupplierCommand(id), ct); return NoContent(); } + + // ========== Import Excel "Database NCC" (Supplier Phase B, Approach A — layout-locked) ========== + // Preview: parse + phân loại từng hàng (KHÔNG ghi DB). Header sai layout → LayoutValid=false. + [Authorize(Roles = "Admin,CatalogManager")] + [HttpPost("import/preview")] + [RequestSizeLimit(25_000_000)] + public async Task> ImportPreview(IFormFile file, CancellationToken ct) + { + if (file is null || file.Length == 0) + return BadRequest(new { detail = "Chưa chọn file." }); + await using var stream = file.OpenReadStream(); + return Ok(await mediator.Send(new SupplierImportPreviewCommand(stream), ct)); + } + + // Confirm: ALL-OR-NOTHING upsert (New → insert; existing case-insensitive-Code → fill-nulls). + // Body JSON = { "rows": [ ... ] } (mảng SupplierImportRowDto round-trip từ preview). + [Authorize(Roles = "Admin,CatalogManager")] + [HttpPost("import/confirm")] + public async Task> ImportConfirm( + [FromBody] SupplierImportConfirmCommand cmd, CancellationToken ct) + => Ok(await mediator.Send(cmd, ct)); } diff --git a/src/Backend/SolutionErp.Application/Master/Suppliers/Commands/ImportSuppliers/SupplierImportConfirmCommand.cs b/src/Backend/SolutionErp.Application/Master/Suppliers/Commands/ImportSuppliers/SupplierImportConfirmCommand.cs new file mode 100644 index 0000000..8fc31d8 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Master/Suppliers/Commands/ImportSuppliers/SupplierImportConfirmCommand.cs @@ -0,0 +1,28 @@ +using MediatR; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.Master.Suppliers.Dtos; +using SolutionErp.Application.Master.Suppliers.Import; + +namespace SolutionErp.Application.Master.Suppliers.Commands.ImportSuppliers; + +/// +/// Commit các hàng đã preview (ALL-OR-NOTHING). Body JSON = { "rows": [ ... ] }. +/// Actor resolve từ ICurrentUser (chỉ để log — audit CreatedBy/UpdatedBy do interceptor set). +/// +public sealed record SupplierImportConfirmCommand(IReadOnlyList Rows) + : IRequest; + +public sealed class SupplierImportConfirmCommandHandler( + ISupplierExcelImportService importService, + ICurrentUser currentUser) + : IRequestHandler +{ + public Task Handle(SupplierImportConfirmCommand request, CancellationToken ct) + { + var actor = currentUser.FullName + ?? currentUser.Email + ?? currentUser.UserId?.ToString() + ?? "unknown"; + return importService.ConfirmAsync(request.Rows ?? Array.Empty(), actor, ct); + } +} diff --git a/src/Backend/SolutionErp.Application/Master/Suppliers/Commands/ImportSuppliers/SupplierImportPreviewCommand.cs b/src/Backend/SolutionErp.Application/Master/Suppliers/Commands/ImportSuppliers/SupplierImportPreviewCommand.cs new file mode 100644 index 0000000..ab1854e --- /dev/null +++ b/src/Backend/SolutionErp.Application/Master/Suppliers/Commands/ImportSuppliers/SupplierImportPreviewCommand.cs @@ -0,0 +1,18 @@ +using MediatR; +using SolutionErp.Application.Master.Suppliers.Dtos; +using SolutionErp.Application.Master.Suppliers.Import; + +namespace SolutionErp.Application.Master.Suppliers.Commands.ImportSuppliers; + +/// +/// Preview import NCC từ file Excel (KHÔNG ghi DB). Stream do controller mở từ IFormFile +/// (mirror UploadPurchaseEvaluationAttachmentCommand — command mang Stream). +/// +public sealed record SupplierImportPreviewCommand(Stream Xlsx) : IRequest; + +public sealed class SupplierImportPreviewCommandHandler(ISupplierExcelImportService importService) + : IRequestHandler +{ + public Task Handle(SupplierImportPreviewCommand request, CancellationToken ct) + => importService.PreviewAsync(request.Xlsx, ct); +} diff --git a/src/Backend/SolutionErp.Application/Master/Suppliers/Dtos/SupplierDto.cs b/src/Backend/SolutionErp.Application/Master/Suppliers/Dtos/SupplierDto.cs index 2c8df6a..65e742b 100644 --- a/src/Backend/SolutionErp.Application/Master/Suppliers/Dtos/SupplierDto.cs +++ b/src/Backend/SolutionErp.Application/Master/Suppliers/Dtos/SupplierDto.cs @@ -31,5 +31,8 @@ public record SupplierDto( string? ReferralSource, string? OwnerPmh, SupplierStatus? Status, + // Import provenance (Supplier Phase B — cột 29-30 file Excel nguồn), KHÁC audit CreatedAt/UpdatedAt. + DateTime? SourceUpdatedAt, + string? SourceUpdatedBy, DateTime CreatedAt, DateTime? UpdatedAt); diff --git a/src/Backend/SolutionErp.Application/Master/Suppliers/Dtos/SupplierImportDtos.cs b/src/Backend/SolutionErp.Application/Master/Suppliers/Dtos/SupplierImportDtos.cs new file mode 100644 index 0000000..0cd2b9e --- /dev/null +++ b/src/Backend/SolutionErp.Application/Master/Suppliers/Dtos/SupplierImportDtos.cs @@ -0,0 +1,87 @@ +using SolutionErp.Domain.Master; + +namespace SolutionErp.Application.Master.Suppliers.Dtos; + +// ============================================================================ +// Supplier Phase B — Upload Excel "Database NCC" (Approach A, layout-locked). +// DTOs shared giữa preview + confirm. Records có property SETTABLE vì hàng import +// được parse → classify → round-trip FE → confirm (mutable trong toàn pipeline). +// ============================================================================ + +/// Phân loại mỗi hàng sau khi preview classify. +public enum RowImportStatus +{ + New = 0, // Code chưa có trong DB (case-insensitive) → sẽ INSERT + Update = 1, // Code đã có (case-insensitive) → sẽ FILL-NULLS (không đè non-null) + Skip = 2, // hàng rỗng hoàn toàn → bỏ qua + Error = 3, // thiếu Code hoặc Name → không import được +} + +/// +/// 1 hàng Excel đã parse + map sang field Supplier (27) + provenance (2) + chẩn đoán. +/// Status = phân loại hàng (RowImportStatus). SupplierStatus = tình trạng NCC (cột 27). +/// +public sealed record SupplierImportRowDto +{ + public int RowIndex { get; set; } // số dòng thật trên Excel (1-based) + + // ---- 27 field map sang Supplier (thứ tự theo cột Excel) ---- + public string? PackageCategory { get; set; } // col 2 + public SupplierType Type { get; set; } = SupplierType.NhaCungCap; // col 3 (unknown → NhaCungCap, decision 5) + public string? Code { get; set; } // col 4 "TÊN VIẾT TẮT" (upsert key, Trim) + public string? Name { get; set; } // col 5 + public string? Address { get; set; } // col 6 + public string? OfficeAddress { get; set; } // col 7 + public string? Phone { get; set; } // col 8 + public string? Fax { get; set; } // col 9 + public string? BankAccount { get; set; } // col 10 (raw composite) + public string? SecondaryBankAccount { get; set; } // col 11 + public string? TaxCode { get; set; } // col 12 (raw, nullable) + public string? LegalRepresentative { get; set; } // col 13 + public string? LegalRepTitle { get; set; } // col 14 + public string? AuthorizationNote { get; set; } // col 15 + public string? LinkGuq { get; set; } // col 16 (raw NAS backslash) + public string? LinkGpkd { get; set; } // col 17 + public string? LinkHsnl { get; set; } // col 18 + public string? ContactPerson { get; set; } // col 19 + public string? ContactTitle { get; set; } // col 20 + public string? ContactPhone { get; set; } // col 21 + public string? Email { get; set; } // col 22 + public string? MailingAddress { get; set; } // col 23 + public string? MailRecipient { get; set; } // col 24 (raw composite) + public string? ReferralSource { get; set; } // col 25 + public string? OwnerPmh { get; set; } // col 26 + public SupplierStatus? SupplierStatus { get; set; } // col 27 (unknown → null) + public string? Note { get; set; } // col 28 + + // ---- 2 import-provenance ---- + public DateTime? SourceUpdatedAt { get; set; } // col 29 (null nếu không parse được ngày) + public string? SourceUpdatedBy { get; set; } // col 30 + + // ---- classify + diagnostics ---- + public RowImportStatus Status { get; set; } + public List Messages { get; set; } = new(); // cảnh báo truncate / default type / lý do lỗi + public Guid? ExistingSupplierId { get; set; } // set khi Status=Update (hỗ trợ FE hiển thị "sẽ cập nhật") +} + +/// Kết quả preview (KHÔNG ghi DB). LayoutValid=false khi header-fingerprint không khớp. +public sealed record SupplierImportPreviewDto +{ + public List Rows { get; set; } = new(); + public int NewCount { get; set; } + public int UpdateCount { get; set; } + public int SkipCount { get; set; } + public int ErrorCount { get; set; } + public List Warnings { get; set; } = new(); + public bool LayoutValid { get; set; } = true; // false → file bị từ chối (sai layout), Rows rỗng +} + +/// Kết quả confirm. Committed=false + Errors khi có hard-error (all-or-nothing, không ghi gì). +public sealed record SupplierImportResultDto +{ + public int Inserted { get; set; } + public int Updated { get; set; } + public int Skipped { get; set; } + public List Errors { get; set; } = new(); + public bool Committed { get; set; } // true nếu đã SaveChanges; false nếu abort do hard-error +} diff --git a/src/Backend/SolutionErp.Application/Master/Suppliers/Import/ISupplierExcelImportService.cs b/src/Backend/SolutionErp.Application/Master/Suppliers/Import/ISupplierExcelImportService.cs new file mode 100644 index 0000000..eaa5e27 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Master/Suppliers/Import/ISupplierExcelImportService.cs @@ -0,0 +1,24 @@ +using SolutionErp.Application.Master.Suppliers.Dtos; + +namespace SolutionErp.Application.Master.Suppliers.Import; + +/// +/// Import NCC từ file Excel "Database NCC" (Supplier Phase B, Approach A — layout-locked). +/// Impl ở Infrastructure (ClosedXML). Inject IApplicationDbContext để testable. +/// +public interface ISupplierExcelImportService +{ + /// + /// Parse + validate + classify từng hàng (KHÔNG ghi DB). Match Code case-insensitive + /// (SQL Server unique-index CI) → existing = Update, mới = New, thiếu Code/Name = Error, + /// hàng rỗng = Skip. Header sai layout → PreviewDto.LayoutValid=false. + /// + Task PreviewAsync(Stream xlsx, CancellationToken ct = default); + + /// + /// ALL-OR-NOTHING: re-validate; nếu có hard-error (thiếu Code/Name) → trả Errors, KHÔNG ghi gì. + /// Ngược lại upsert (New → insert; existing case-insensitive-Code → fill-nulls-only) rồi + /// SaveChanges 1 lần. chỉ dùng để log (audit thật do interceptor set). + /// + Task ConfirmAsync(IReadOnlyList rows, string actor, CancellationToken ct = default); +} diff --git a/src/Backend/SolutionErp.Application/Master/Suppliers/Queries/GetSupplier/GetSupplierQuery.cs b/src/Backend/SolutionErp.Application/Master/Suppliers/Queries/GetSupplier/GetSupplierQuery.cs index fa3c514..80edeb0 100644 --- a/src/Backend/SolutionErp.Application/Master/Suppliers/Queries/GetSupplier/GetSupplierQuery.cs +++ b/src/Backend/SolutionErp.Application/Master/Suppliers/Queries/GetSupplier/GetSupplierQuery.cs @@ -22,6 +22,7 @@ public class GetSupplierQueryHandler : IRequestHandler(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); // Phase 3 iteration 2 — SLA auto-approve background service diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs index 010fda6..20d00d4 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/SupplierConfiguration.cs @@ -41,6 +41,9 @@ public class SupplierConfiguration : IEntityTypeConfiguration b.Property(x => x.OwnerPmh).HasMaxLength(150); b.Property(x => x.Status).HasConversion(); // nullable int enum + // Import provenance (Supplier Phase B). SourceUpdatedAt = DateTime? (no length). SourceUpdatedBy = text. + b.Property(x => x.SourceUpdatedBy).HasMaxLength(200); + b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // Mig 47 (gotcha #57 EXT) — soft-deleted slot reusable, khớp HasQueryFilter !IsDeleted app-check b.HasIndex(x => x.Type); diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260712070117_AddSupplierImportSourceFields.Designer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260712070117_AddSupplierImportSourceFields.Designer.cs new file mode 100644 index 0000000..426d7d2 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260712070117_AddSupplierImportSourceFields.Designer.cs @@ -0,0 +1,6355 @@ +// +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("20260712070117_AddSupplierImportSourceFields")] + partial class AddSupplierImportSourceFields + { + /// + 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("AuthorizationNote") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("BankAccount") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ContactPerson") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ContactPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ContactTitle") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + 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("Fax") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LegalRepTitle") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("LegalRepresentative") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LinkGpkd") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LinkGuq") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("LinkHsnl") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("MailRecipient") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MailingAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("OfficeAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("OwnerPmh") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("PackageCategory") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ReferralSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("SecondaryBankAccount") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SourceUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("SourceUpdatedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Status") + .HasColumnType("int"); + + 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("CcmBudgetPeriodAmount") + .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("EndedByLevelFinalize") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + 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/20260712070117_AddSupplierImportSourceFields.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260712070117_AddSupplierImportSourceFields.cs new file mode 100644 index 0000000..63300bf --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260712070117_AddSupplierImportSourceFields.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SolutionErp.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSupplierImportSourceFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SourceUpdatedAt", + table: "Suppliers", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceUpdatedBy", + table: "Suppliers", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SourceUpdatedAt", + table: "Suppliers"); + + migrationBuilder.DropColumn( + name: "SourceUpdatedBy", + table: "Suppliers"); + } + } +} diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 27470f4..cdb22eb 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -3327,6 +3327,13 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations .HasMaxLength(500) .HasColumnType("nvarchar(500)"); + b.Property("SourceUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("SourceUpdatedBy") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + b.Property("Status") .HasColumnType("int"); diff --git a/src/Backend/SolutionErp.Infrastructure/Services/SupplierExcelImportService.cs b/src/Backend/SolutionErp.Infrastructure/Services/SupplierExcelImportService.cs new file mode 100644 index 0000000..3e4a148 --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Services/SupplierExcelImportService.cs @@ -0,0 +1,474 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using ClosedXML.Excel; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.Master.Suppliers.Dtos; +using SolutionErp.Application.Master.Suppliers.Import; +using SolutionErp.Domain.Master; + +namespace SolutionErp.Infrastructure.Services; + +/// +/// Import NCC từ Excel "Database NCC" (Supplier Phase B, Approach A). Layout-locked THEO FILE NÀY. +/// ClosedXML (0.105, đã có). Đọc theo CHỈ SỐ CỘT TUYỆT ĐỐI (row.Cell(i)) — KHÔNG CellsUsed() +/// (bỏ ô rỗng → lệch cột). Header row = 4, data từ row 5. +/// +/// 🔴 CRITICAL: dedup Code phải CASE-INSENSITIVE (unique-index SQL Server = CI). Nếu so Ordinal, +/// "truonggiang" (file) vs "TRUONGGIANG" (DB) → miss → quyết INSERT → confirm nổ unique-violation +/// → 500 cả batch. Dùng StringComparer.OrdinalIgnoreCase ở CẢ preview + confirm; store Code = .Trim() +/// giữ nguyên hoa/thường để hiển thị. +/// +public sealed class SupplierExcelImportService( + IApplicationDbContext db, + ILogger logger) : ISupplierExcelImportService +{ + private const int HeaderRow = 4; + private const int DataStartRow = 5; + private const int ColumnCount = 30; + + // ⚠️⚠️ MUST-VERIFY: header row-4 chuẩn của file "Database NCC" thật (đang ở máy anh Kiệt, KHÔNG + // trong repo lúc scaffold). Đây là BEST-GUESS theo mapping cột. Khi upload thật lần đầu mà báo + // "Sai layout", COPY chuỗi "Header nhận được" trong Warnings dán vào đây (mỗi token 1 cột, đúng + // 30 phần tử). NormalizeHeader() sẽ upper + gộp-space nên viết thường/HOA đều được. + private static readonly string[] ExpectedHeaderTokens = + { + // 30 token row-4 THẬT của file "Database NCC" (bake close-review S112 = RealFileHeaderTokens trong test). + "STT", // 1 + "GÓI THẦU", // 2 → PackageCategory + "PHÂN LOẠI (NTP/NCC/Cả hai)", // 3 → Type + "TÊN VIẾT TẮT (Dùng trong HĐ)", // 4 → Code (upsert key) + "TÊN CÔNG TY (Đầy đủ, đúng pháp lý)", // 5 → Name + "ĐỊA CHỈ XUẤT HÓA ĐƠN (Địa chỉ đăng ký kinh doanh)", // 6 → Address + "ĐỊA CHỈ VĂN PHÒNG (nếu có)", // 7 → OfficeAddress + "SỐ ĐIỆN THOẠI CÔNG TY", // 8 → Phone + "FAX", // 9 → Fax + "SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG (Đầy đủ, đúng pháp lý)", // 10 → BankAccount + "SỐ TK PHỤ (nếu có)", // 11 → SecondaryBankAccount + "MÃ SỐ THUẾ", // 12 → TaxCode + "NGƯỜI ĐẠI DIỆN PHÁP LUẬT", // 13 → LegalRepresentative + "CHỨC VỤ ĐẠI DIỆN", // 14 → LegalRepTitle + "GIẤY ỦY QUYỀN (số, ngày, người ủy quyền)", // 15 → AuthorizationNote + "Link GUQ", // 16 → LinkGuq + "Link GPKD", // 17 → LinkGpkd + "Link HSNL", // 18 → LinkHsnl + "NGƯỜI LIÊN HỆ CHÍNH", // 19 → ContactPerson + "CHỨC VỤ NGƯỜI LH", // 20 → ContactTitle + "SĐT CHÍNH", // 21 → ContactPhone + "EMAIL", // 22 → Email + "ĐỊA CHỈ GỬI THƯ", // 23 → MailingAddress + "NGƯỜI NHẬN THƯ/ SDT", // 24 → MailRecipient + "NGUỒN GIỚI THIỆU", // 25 → ReferralSource + "NGƯỜI PHỤ TRÁCH (PMH)", // 26 → OwnerPmh + "TÌNH TRẠNG HIỆN TẠI", // 27 → Status + "GHI CHÚ / LÝ DO BLACKLIST", // 28 → Note + "NGÀY CẬP NHẬT CUỐI", // 29 → SourceUpdatedAt + "NGƯỜI CẬP NHẬT", // 30 → SourceUpdatedBy + }; + + private static readonly HashSet ErrorLiterals = new(StringComparer.OrdinalIgnoreCase) + { "#REF!", "#N/A", "#VALUE!", "#DIV/0!", "#NAME?", "#NULL!", "#NUM!" }; + + private static readonly string[] DateFormats = + { "dd/MM/yyyy", "d/M/yyyy", "dd/MM/yyyy HH:mm", "yyyy-MM-dd", "yyyy/MM/dd", "dd-MM-yyyy", "d-M-yyyy", "MM/dd/yyyy" }; + + // MaxLength per field (khớp SupplierConfiguration — EF = source of truth). + private const int MaxCode = 50, MaxName = 200, MaxTaxCode = 20, MaxPhone = 30, MaxEmail = 100, + MaxAddress = 500, MaxContactPerson = 200, MaxNote = 1000, MaxPackageCategory = 300, + MaxOfficeAddress = 500, MaxMailingAddress = 500, MaxFax = 50, MaxBankAccount = 500, + MaxSecondaryBankAccount = 500, MaxLegalRepresentative = 200, MaxLegalRepTitle = 150, + MaxAuthorizationNote = 1000, MaxLink = 1000, MaxContactTitle = 150, MaxContactPhone = 50, + MaxMailRecipient = 200, MaxReferralSource = 300, MaxOwnerPmh = 150, MaxSourceUpdatedBy = 200; + + // ======================================================================== + // PREVIEW — parse + validate + classify. KHÔNG ghi DB. + // ======================================================================== + public async Task PreviewAsync(Stream xlsx, CancellationToken ct = default) + { + var (layoutValid, rows, warnings) = ParseWorkbook(xlsx); + var preview = new SupplierImportPreviewDto { Warnings = warnings, LayoutValid = layoutValid }; + if (!layoutValid) return preview; // file bị từ chối (sai layout) — Rows rỗng + + // Load TẤT CẢ NCC hiện có → CI dict (collation-independent, dataset nhỏ ~vài chục). Chống critical-bug. + var existing = await db.Suppliers.AsNoTracking().ToListAsync(ct); + var existingByCode = BuildCiIndex(existing); + + foreach (var row in rows) + { + if (IsRowEmpty(row)) { row.Status = RowImportStatus.Skip; continue; } + + var codeKey = row.Code?.Trim(); + var nameBlank = string.IsNullOrWhiteSpace(row.Name); + if (string.IsNullOrWhiteSpace(codeKey) || nameBlank) + { + row.Status = RowImportStatus.Error; + if (string.IsNullOrWhiteSpace(codeKey)) row.Messages.Add("Thiếu 'Tên viết tắt' (Code) — bắt buộc."); + if (nameBlank) row.Messages.Add("Thiếu 'Tên nhà cung cấp' (Name) — bắt buộc."); + continue; + } + + if (existingByCode.TryGetValue(codeKey, out var ex)) + { + row.Status = RowImportStatus.Update; + row.ExistingSupplierId = ex.Id; + } + else + { + row.Status = RowImportStatus.New; + } + } + + preview.Rows = rows; + preview.NewCount = rows.Count(x => x.Status == RowImportStatus.New); + preview.UpdateCount = rows.Count(x => x.Status == RowImportStatus.Update); + preview.SkipCount = rows.Count(x => x.Status == RowImportStatus.Skip); + preview.ErrorCount = rows.Count(x => x.Status == RowImportStatus.Error); + return preview; + } + + // ======================================================================== + // CONFIRM — ALL-OR-NOTHING. Re-validate → hard-error abort / else upsert + 1 SaveChanges. + // ======================================================================== + public async Task ConfirmAsync( + IReadOnlyList rows, string actor, CancellationToken ct = default) + { + var result = new SupplierImportResultDto(); + + // Pass 1 — hard-error scan (Code/Name). Hàng rỗng = skip (không lỗi). Bất kỳ hard-error → abort. + var hardErrors = new List(); + foreach (var row in rows) + { + if (IsRowEmpty(row)) continue; + var missing = new List(); + if (string.IsNullOrWhiteSpace(row.Code)) missing.Add("Tên viết tắt"); + if (string.IsNullOrWhiteSpace(row.Name)) missing.Add("Tên nhà cung cấp"); + if (missing.Count > 0) hardErrors.Add($"Dòng {row.RowIndex}: thiếu {string.Join(" + ", missing)}."); + } + if (hardErrors.Count > 0) + { + result.Errors = hardErrors; + result.Committed = false; // commit nothing + return result; + } + + // Load existing TRACKED (để fill-nulls mutate trực tiếp). Query filter loại IsDeleted. + var existing = await db.Suppliers.ToListAsync(ct); + var existingByCode = BuildCiIndex(existing); + var batchByCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + if (IsRowEmpty(row)) { result.Skipped++; continue; } + + NormalizeLengths(row); // safety-net: clamp về maxlen (chống 500 nếu client bỏ qua preview) + var key = row.Code!.Trim(); + + if (existingByCode.TryGetValue(key, out var ex)) + { + FillNulls(ex, row); + if (seen.Add(key)) result.Updated++; + else result.Skipped++; // Code trùng lần 2 trong file → gộp fill-nulls, không đếm lại + continue; + } + if (batchByCode.TryGetValue(key, out var added)) + { + FillNulls(added, row); + result.Skipped++; // Code trùng bản ghi vừa thêm trong batch + continue; + } + + var entity = NewSupplier(row); + db.Suppliers.Add(entity); + batchByCode[key] = entity; + seen.Add(key); + result.Inserted++; + } + + await db.SaveChangesAsync(ct); // single atomic commit (interceptor set CreatedBy/UpdatedBy) + result.Committed = true; + logger.LogInformation( + "Supplier Excel import by {Actor}: inserted={Inserted}, updated={Updated}, skipped={Skipped}", + actor, result.Inserted, result.Updated, result.Skipped); + return result; + } + + // ======================================================================== + // PARSE + // ======================================================================== + private static (bool LayoutValid, List Rows, List Warnings) ParseWorkbook(Stream xlsx) + { + var warnings = new List(); + XLWorkbook wb; + try { wb = new XLWorkbook(xlsx); } + catch (Exception ex) + { + warnings.Add("Không đọc được file (.xlsx). Chi tiết: " + ex.Message); + return (false, new List(), warnings); + } + + using (wb) + { + IXLWorksheet ws; + try { ws = wb.Worksheet(1); } + catch { warnings.Add("File Excel không có sheet nào."); return (false, new(), warnings); } + + // Header fingerprint — row 4, 30 cột tuyệt đối. Mismatch → từ chối (KHÔNG map để tránh sai cột). + var actualTokens = new string[ColumnCount]; + for (int c = 1; c <= ColumnCount; c++) + actualTokens[c - 1] = NormalizeHeader(ws.Cell(HeaderRow, c).GetString()); + var actual = string.Join(" | ", actualTokens); + var expected = string.Join(" | ", ExpectedHeaderTokens.Select(NormalizeHeader)); + + if (!string.Equals(actual, expected, StringComparison.Ordinal)) + { + warnings.Add("Sai layout Excel — từ chối file (không map để tránh sai cột). " + + "Nếu đây LÀ file chuẩn, cập nhật ExpectedHeaderTokens theo 'Header nhận được' dưới đây."); + warnings.Add("Header nhận được: " + actual); + warnings.Add("Header mong đợi: " + expected); + return (false, new(), warnings); + } + + var rows = new List(); + int lastRow = ws.LastRowUsed()?.RowNumber() ?? (DataStartRow - 1); + for (int r = DataStartRow; r <= lastRow; r++) + rows.Add(MapRow(ws, r)); + return (true, rows, warnings); + } + } + + private static SupplierImportRowDto MapRow(IXLWorksheet ws, int r) + { + var m = new List(); + return new SupplierImportRowDto + { + RowIndex = r, + PackageCategory = Clamp(ReadRaw(ws, r, 2), MaxPackageCategory, "Gói thầu", r, m), + Type = MapType(ReadRaw(ws, r, 3), r, m), + Code = Clamp(ReadRaw(ws, r, 4)?.Trim(), MaxCode, "Tên viết tắt", r, m), + Name = Clamp(ReadRaw(ws, r, 5), MaxName, "Tên NCC", r, m), + Address = Clamp(ReadRaw(ws, r, 6), MaxAddress, "Địa chỉ", r, m), + OfficeAddress = Clamp(ReadRaw(ws, r, 7), MaxOfficeAddress, "Địa chỉ VP", r, m), + Phone = Clamp(ReadRaw(ws, r, 8), MaxPhone, "Điện thoại", r, m), + Fax = Clamp(ReadRaw(ws, r, 9), MaxFax, "Fax", r, m), + BankAccount = Clamp(ReadRaw(ws, r, 10), MaxBankAccount, "Số TK", r, m), + SecondaryBankAccount = Clamp(ReadRaw(ws, r, 11), MaxSecondaryBankAccount, "Số TK phụ", r, m), + TaxCode = Clamp(ReadRaw(ws, r, 12), MaxTaxCode, "MST", r, m), + LegalRepresentative = Clamp(ReadRaw(ws, r, 13), MaxLegalRepresentative, "Người đại diện", r, m), + LegalRepTitle = Clamp(ReadRaw(ws, r, 14), MaxLegalRepTitle, "Chức vụ đại diện", r, m), + AuthorizationNote = Clamp(ReadRaw(ws, r, 15), MaxAuthorizationNote, "Giấy ủy quyền", r, m), + LinkGuq = Clamp(ReadRaw(ws, r, 16), MaxLink, "Link GUQ", r, m), + LinkGpkd = Clamp(ReadRaw(ws, r, 17), MaxLink, "Link GPKD", r, m), + LinkHsnl = Clamp(ReadRaw(ws, r, 18), MaxLink, "Link HSNL", r, m), + ContactPerson = Clamp(ReadRaw(ws, r, 19), MaxContactPerson, "Người liên hệ", r, m), + ContactTitle = Clamp(ReadRaw(ws, r, 20), MaxContactTitle, "Chức vụ liên hệ", r, m), + ContactPhone = Clamp(ReadRaw(ws, r, 21), MaxContactPhone, "SĐT liên hệ", r, m), + Email = Clamp(ReadRaw(ws, r, 22), MaxEmail, "Email", r, m), + MailingAddress = Clamp(ReadRaw(ws, r, 23), MaxMailingAddress, "Địa chỉ gửi thư", r, m), + MailRecipient = Clamp(ReadRaw(ws, r, 24), MaxMailRecipient, "Người nhận thư", r, m), + ReferralSource = Clamp(ReadRaw(ws, r, 25), MaxReferralSource, "Nguồn giới thiệu", r, m), + OwnerPmh = Clamp(ReadRaw(ws, r, 26), MaxOwnerPmh, "Phụ trách", r, m), + SupplierStatus = MapStatus(ReadRaw(ws, r, 27)), + Note = Clamp(ReadRaw(ws, r, 28), MaxNote, "Ghi chú", r, m), + SourceUpdatedAt = ReadDate(ws, r, 29), + SourceUpdatedBy = Clamp(ReadRaw(ws, r, 30), MaxSourceUpdatedBy, "Người cập nhật", r, m), + Messages = m, + }; + } + + // ======================================================================== + // CELL READERS — absolute index, error-literal → null, giữ RAW (không normalize) cho path/composite. + // ======================================================================== + private static string? ReadRaw(IXLWorksheet ws, int row, int col) + { + var cell = ws.Cell(row, col); + if (cell.IsEmpty()) return null; + if (cell.DataType == XLDataType.Error) return null; + var s = cell.GetString(); // auto-decode shared-string + if (string.IsNullOrWhiteSpace(s)) return null; + if (ErrorLiterals.Contains(s.Trim())) return null; // literal #REF! / #N/A / ... + return s; // RAW: giữ NAS backslash + composite (không trim/upper) + } + + private static DateTime? ReadDate(IXLWorksheet ws, int row, int col) + { + var cell = ws.Cell(row, col); + if (cell.IsEmpty() || cell.DataType == XLDataType.Error) return null; + if (cell.DataType == XLDataType.DateTime && cell.TryGetValue(out var dt)) return dt; + var s = cell.GetString(); + if (string.IsNullOrWhiteSpace(s) || ErrorLiterals.Contains(s.Trim())) return null; + s = s.Trim(); + if (DateTime.TryParseExact(s, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var ex)) return ex; + if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var iv)) return iv; + return null; // unparseable → null (decision) + } + + private static string NormalizeHeader(string? raw) + { + if (string.IsNullOrEmpty(raw)) return string.Empty; + var collapsed = Regex.Replace(raw.Replace("\n", " ").Replace("\r", " "), @"\s+", " "); + // .Normalize(FormC) — de-risk file Excel thật lưu dấu tiếng Việt dạng NFD (close-review S112): + // đưa cả header kỳ-vọng lẫn header-thật về cùng dạng canonical trước khi so Ordinal. + return collapsed.Trim().ToUpperInvariant().Normalize(System.Text.NormalizationForm.FormC); + } + + // ======================================================================== + // FIELD MAPPING + // ======================================================================== + private static SupplierType MapType(string? raw, int rowIndex, List messages) + { + var t = (raw ?? string.Empty).Trim().ToLowerInvariant(); + if (t.Length == 0) return SupplierType.NhaCungCap; // trống → default (decision 5), im lặng + if (t.Contains("cả hai") || t.Contains("ca hai") || t.Contains("ntp/ncc") || t.Contains("ncc/ntp")) + return SupplierType.CaHai; + if (t == "ntp" || t.Contains("thầu phụ") || t.Contains("thau phu")) + return SupplierType.NhaThauPhu; + if (t == "ncc" || t.Contains("cung cấp") || t.Contains("cung cap")) + return SupplierType.NhaCungCap; + messages.Add($"Dòng {rowIndex}: PHÂN LOẠI '{raw}' không nhận dạng → mặc định NCC."); + return SupplierType.NhaCungCap; // unknown → default (decision 5, không reject) + } + + private static SupplierStatus? MapStatus(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var s = StripLeadingNonLetter(raw).Trim().ToLowerInvariant(); + if (s.Length == 0) return null; + if (s.Contains("đang hoạt động") || s.Contains("dang hoat dong") || s.Contains("hoạt động") || s.Contains("hoat dong")) + return SupplierStatus.DangHoatDong; + if (s.Contains("blacklist") || s.Contains("black")) + return SupplierStatus.Blacklist; + if (s.Contains("ngừng") || s.Contains("ngung")) + return SupplierStatus.NgungHopTac; + return null; // unknown → null (decision, không reject) + } + + private static string StripLeadingNonLetter(string v) + { + int i = 0; + while (i < v.Length && !char.IsLetter(v[i])) i++; // char.IsLetter true cho chữ Việt Unicode + return v.Substring(i); + } + + private static string? Clamp(string? v, int max, string field, int rowIndex, List messages) + { + if (v is null || v.Length <= max) return v; + messages.Add($"Dòng {rowIndex}: '{field}' dài {v.Length} ký tự > {max} — đã cắt bớt."); + return v.Substring(0, max); + } + + private static void NormalizeLengths(SupplierImportRowDto r) + { + r.Code = Cut(r.Code, MaxCode); r.Name = Cut(r.Name, MaxName); r.TaxCode = Cut(r.TaxCode, MaxTaxCode); + r.Phone = Cut(r.Phone, MaxPhone); r.Email = Cut(r.Email, MaxEmail); r.Address = Cut(r.Address, MaxAddress); + r.ContactPerson = Cut(r.ContactPerson, MaxContactPerson); r.Note = Cut(r.Note, MaxNote); + r.PackageCategory = Cut(r.PackageCategory, MaxPackageCategory); r.OfficeAddress = Cut(r.OfficeAddress, MaxOfficeAddress); + r.MailingAddress = Cut(r.MailingAddress, MaxMailingAddress); r.Fax = Cut(r.Fax, MaxFax); + r.BankAccount = Cut(r.BankAccount, MaxBankAccount); r.SecondaryBankAccount = Cut(r.SecondaryBankAccount, MaxSecondaryBankAccount); + r.LegalRepresentative = Cut(r.LegalRepresentative, MaxLegalRepresentative); r.LegalRepTitle = Cut(r.LegalRepTitle, MaxLegalRepTitle); + r.AuthorizationNote = Cut(r.AuthorizationNote, MaxAuthorizationNote); r.LinkGuq = Cut(r.LinkGuq, MaxLink); + r.LinkGpkd = Cut(r.LinkGpkd, MaxLink); r.LinkHsnl = Cut(r.LinkHsnl, MaxLink); + r.ContactTitle = Cut(r.ContactTitle, MaxContactTitle); r.ContactPhone = Cut(r.ContactPhone, MaxContactPhone); + r.MailRecipient = Cut(r.MailRecipient, MaxMailRecipient); r.ReferralSource = Cut(r.ReferralSource, MaxReferralSource); + r.OwnerPmh = Cut(r.OwnerPmh, MaxOwnerPmh); r.SourceUpdatedBy = Cut(r.SourceUpdatedBy, MaxSourceUpdatedBy); + } + + private static string? Cut(string? v, int max) => v is not null && v.Length > max ? v.Substring(0, max) : v; + + // ======================================================================== + // UPSERT HELPERS + // ======================================================================== + // FILL-NULLS (decision 4): chỉ set field ĐANG NULL trong DB, KHÔNG bao giờ đè non-null. + // Bỏ Code/Name/Type (identity/required non-null) — mirror DbInitializer fill-nulls pattern. + private static void FillNulls(Supplier e, SupplierImportRowDto r) + { + if (e.TaxCode is null && r.TaxCode is not null) e.TaxCode = r.TaxCode; + if (e.Phone is null && r.Phone is not null) e.Phone = r.Phone; + if (e.Email is null && r.Email is not null) e.Email = r.Email; + if (e.Address is null && r.Address is not null) e.Address = r.Address; + if (e.ContactPerson is null && r.ContactPerson is not null) e.ContactPerson = r.ContactPerson; + if (e.Note is null && r.Note is not null) e.Note = r.Note; + if (e.PackageCategory is null && r.PackageCategory is not null) e.PackageCategory = r.PackageCategory; + if (e.OfficeAddress is null && r.OfficeAddress is not null) e.OfficeAddress = r.OfficeAddress; + if (e.MailingAddress is null && r.MailingAddress is not null) e.MailingAddress = r.MailingAddress; + if (e.Fax is null && r.Fax is not null) e.Fax = r.Fax; + if (e.BankAccount is null && r.BankAccount is not null) e.BankAccount = r.BankAccount; + if (e.SecondaryBankAccount is null && r.SecondaryBankAccount is not null) e.SecondaryBankAccount = r.SecondaryBankAccount; + if (e.LegalRepresentative is null && r.LegalRepresentative is not null) e.LegalRepresentative = r.LegalRepresentative; + if (e.LegalRepTitle is null && r.LegalRepTitle is not null) e.LegalRepTitle = r.LegalRepTitle; + if (e.AuthorizationNote is null && r.AuthorizationNote is not null) e.AuthorizationNote = r.AuthorizationNote; + if (e.LinkGuq is null && r.LinkGuq is not null) e.LinkGuq = r.LinkGuq; + if (e.LinkGpkd is null && r.LinkGpkd is not null) e.LinkGpkd = r.LinkGpkd; + if (e.LinkHsnl is null && r.LinkHsnl is not null) e.LinkHsnl = r.LinkHsnl; + if (e.ContactTitle is null && r.ContactTitle is not null) e.ContactTitle = r.ContactTitle; + if (e.ContactPhone is null && r.ContactPhone is not null) e.ContactPhone = r.ContactPhone; + if (e.MailRecipient is null && r.MailRecipient is not null) e.MailRecipient = r.MailRecipient; + if (e.ReferralSource is null && r.ReferralSource is not null) e.ReferralSource = r.ReferralSource; + if (e.OwnerPmh is null && r.OwnerPmh is not null) e.OwnerPmh = r.OwnerPmh; + if (e.Status is null && r.SupplierStatus is not null) e.Status = r.SupplierStatus; + if (e.SourceUpdatedAt is null && r.SourceUpdatedAt is not null) e.SourceUpdatedAt = r.SourceUpdatedAt; + if (e.SourceUpdatedBy is null && r.SourceUpdatedBy is not null) e.SourceUpdatedBy = r.SourceUpdatedBy; + } + + private static Supplier NewSupplier(SupplierImportRowDto r) => new() + { + Code = r.Code!.Trim(), // store trimmed, giữ hoa/thường gốc để hiển thị + Name = r.Name!, + Type = r.Type, + TaxCode = r.TaxCode, + Phone = r.Phone, + Email = r.Email, + Address = r.Address, + ContactPerson = r.ContactPerson, + Note = r.Note, + PackageCategory = r.PackageCategory, + OfficeAddress = r.OfficeAddress, + MailingAddress = r.MailingAddress, + Fax = r.Fax, + BankAccount = r.BankAccount, + SecondaryBankAccount = r.SecondaryBankAccount, + LegalRepresentative = r.LegalRepresentative, + LegalRepTitle = r.LegalRepTitle, + AuthorizationNote = r.AuthorizationNote, + LinkGuq = r.LinkGuq, + LinkGpkd = r.LinkGpkd, + LinkHsnl = r.LinkHsnl, + ContactTitle = r.ContactTitle, + ContactPhone = r.ContactPhone, + MailRecipient = r.MailRecipient, + ReferralSource = r.ReferralSource, + OwnerPmh = r.OwnerPmh, + Status = r.SupplierStatus, + SourceUpdatedAt = r.SourceUpdatedAt, + SourceUpdatedBy = r.SourceUpdatedBy, + }; + + private static Dictionary BuildCiIndex(IEnumerable suppliers) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var s in suppliers) + { + var key = s.Code?.Trim(); + if (string.IsNullOrEmpty(key)) continue; + dict[key] = s; // last-wins; dup CI không xảy ra do filtered-unique index + } + return dict; + } + + private static bool IsRowEmpty(SupplierImportRowDto r) => + string.IsNullOrWhiteSpace(r.Code) && string.IsNullOrWhiteSpace(r.Name) && + string.IsNullOrWhiteSpace(r.PackageCategory) && string.IsNullOrWhiteSpace(r.Address) && + string.IsNullOrWhiteSpace(r.OfficeAddress) && string.IsNullOrWhiteSpace(r.Phone) && + string.IsNullOrWhiteSpace(r.Fax) && string.IsNullOrWhiteSpace(r.BankAccount) && + string.IsNullOrWhiteSpace(r.SecondaryBankAccount) && string.IsNullOrWhiteSpace(r.TaxCode) && + string.IsNullOrWhiteSpace(r.LegalRepresentative) && string.IsNullOrWhiteSpace(r.LegalRepTitle) && + string.IsNullOrWhiteSpace(r.AuthorizationNote) && string.IsNullOrWhiteSpace(r.LinkGuq) && + string.IsNullOrWhiteSpace(r.LinkGpkd) && string.IsNullOrWhiteSpace(r.LinkHsnl) && + string.IsNullOrWhiteSpace(r.ContactPerson) && string.IsNullOrWhiteSpace(r.ContactTitle) && + string.IsNullOrWhiteSpace(r.ContactPhone) && string.IsNullOrWhiteSpace(r.Email) && + string.IsNullOrWhiteSpace(r.MailingAddress) && string.IsNullOrWhiteSpace(r.MailRecipient) && + string.IsNullOrWhiteSpace(r.ReferralSource) && string.IsNullOrWhiteSpace(r.OwnerPmh) && + string.IsNullOrWhiteSpace(r.Note) && string.IsNullOrWhiteSpace(r.SourceUpdatedBy) && + r.SupplierStatus is null && r.SourceUpdatedAt is null; +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/SupplierExcelImportServiceTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/SupplierExcelImportServiceTests.cs new file mode 100644 index 0000000..ddd9afe --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/SupplierExcelImportServiceTests.cs @@ -0,0 +1,452 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using ClosedXML.Excel; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using SolutionErp.Application.Master.Suppliers.Dtos; +using SolutionErp.Domain.Master; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// ============================================================================ +// Supplier Phase B — SupplierExcelImportService (import NCC từ file "Database NCC"). +// TEST-BEFORE-MERGE: CRITICAL-ALGO (parser layout-locked + upsert) + BUG-FIX class +// (case-collation dedup) → tests bắt buộc trước commit (docs/rules.md §7). +// +// Test theo CODE trên đĩa (S34 single-source-of-truth). KHÔNG sửa production code — +// nếu test lộ bug prod thật → REPORT em main, không tự fix. +// +// 🔴 BUG-CLASS (Case 1): unique-index Code ở SQL Server = case-INSENSITIVE. Nếu service +// so Ordinal, "truonggiang" (file) ≠ "TRUONGGIANG" (DB) → miss → quyết INSERT → +// ConfirmAsync nổ unique-violation → 500 cả batch. Service dùng OrdinalIgnoreCase ở +// cả preview + confirm để chặn. Test chứng minh dedup CI hoạt động (chỉ 1 NCC, update +// tại chỗ, không insert hàng thứ 2). +// LƯU Ý collation: SQLite (fixture) mặc định BINARY (case-SENSITIVE) → DB-level unique +// KHÔNG bắt "truonggiang" vs "TRUONGGIANG". Đó chính là điểm test: dedup phải nằm ở +// SERVICE (in-memory OrdinalIgnoreCase), KHÔNG dựa DB — nếu dựa DB thì prod SQL Server +// sẽ ném 500. Test này xanh chứng tỏ service không dựa DB. +// +// ⚠️ SPEC-DRIFT đã biết (không phải bug tôi phát hiện): service.ExpectedHeaderTokens hiện +// là BEST-GUESS, CHƯA khớp 30 token row-4 thật của file (sẽ sửa ở close-review adjust). +// → Các test cần "valid layout" (Case 4-7) DỰNG header từ chính ExpectedHeaderTokens +// của service (đọc qua REFLECTION) nên LUÔN đồng bộ — không vỡ khi tokens được sửa. +// → Case 9 (PreviewAsync_RealFileRow4Tokens...) nạp 30 token THẬT và khẳng định +// PreviewAsync đồng ý với so-khớp-fingerprint trực tiếp: hiện tại lệch → LayoutValid=false; +// khi tokens sửa = token thật → tự động khẳng định LayoutValid=true (không phải sửa test). +// ============================================================================ +public class SupplierExcelImportServiceTests +{ + private const int HeaderRow = 4; + + private static SupplierExcelImportService NewService(TestApplicationDbContext db) + => new(db, NullLogger.Instance); + + // ======================================================================== + // Case 1 (NON-NEGOTIABLE) — case-collation dedup qua ConfirmAsync. + // ======================================================================== + [Fact] + public async Task ConfirmAsync_LowercaseCode_MatchesExistingUppercase_UpdatesInPlace_NoDuplicateInsert() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + db.Suppliers.Add(new Supplier + { + Code = "TRUONGGIANG", + Name = "Công ty TNHH Trường Giang", + Type = SupplierType.NhaCungCap, + Phone = null, // để trống → fill-nulls có việc làm, chứng minh cùng-1-row bị update + Address = null, + }); + await db.SaveChangesAsync(CancellationToken.None); + + var rows = new List + { + new() { RowIndex = 5, Code = "truonggiang", Name = "Trường Giang", Phone = "0909", Address = "123 Hà Nội" }, + }; + + var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None); + + result.Committed.Should().BeTrue(); + result.Inserted.Should().Be(0, "Code chữ thường khớp CI với NCC đã có → KHÔNG insert bản ghi mới"); + result.Updated.Should().Be(1, "khớp case-insensitive → fill-nulls update tại chỗ"); + + (await db.Suppliers.CountAsync()).Should().Be(1, + "chỉ đúng 1 NCC — dedup OrdinalIgnoreCase chặn hàng thứ 2 (nếu so Ordinal → prod SQL Server nổ unique-violation 500)"); + (await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1, "không có bản ghi phantom nào kể cả đã soft-delete"); + + var only = await db.Suppliers.SingleAsync(); + only.Code.Should().Be("TRUONGGIANG", "Code gốc giữ nguyên hoa/thường — update entity cũ, không thay bằng entity mới"); + only.Phone.Should().Be("0909", "fill-null: Phone đang null → được điền (chứng minh chính row cũ bị update)"); + only.Address.Should().Be("123 Hà Nội", "fill-null: Address đang null → được điền"); + } + + // ======================================================================== + // Case 1b — PreviewAsync phân loại hàng chữ thường là Update (không phải New). + // ======================================================================== + [Fact] + public async Task PreviewAsync_LowercaseCode_OfExistingUppercase_ClassifiedAsUpdate_NotNew() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + db.Suppliers.Add(new Supplier + { + Code = "TRUONGGIANG", + Name = "Công ty Trường Giang", + Type = SupplierType.NhaCungCap, + }); + await db.SaveChangesAsync(CancellationToken.None); + + using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws => + { + ws.Cell(5, 3).Value = "NCC"; + ws.Cell(5, 4).Value = "truonggiang"; + ws.Cell(5, 5).Value = "Trường Giang"; + }); + + var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None); + + preview.LayoutValid.Should().BeTrue(); + var row = preview.Rows.Single(); + row.Status.Should().Be(RowImportStatus.Update, "Code chữ thường khớp CI với 'TRUONGGIANG' → Update, KHÔNG phải New"); + row.ExistingSupplierId.Should().NotBeNull("Update phải trỏ tới NCC đã tồn tại"); + preview.UpdateCount.Should().Be(1); + preview.NewCount.Should().Be(0); + } + + // ======================================================================== + // Case 2 (NON-NEGOTIABLE) — fill-nulls SAFE (decision 4): không đè non-null, chỉ điền null. + // ======================================================================== + [Fact] + public async Task ConfirmAsync_FillNulls_DoesNotOverwriteNonNull_FillsOnlyNull() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + db.Suppliers.Add(new Supplier + { + Code = "X", + Name = "X Co", + Type = SupplierType.NhaCungCap, + Phone = "0900", // non-null → PHẢI giữ nguyên + Address = null, // null → được điền + }); + await db.SaveChangesAsync(CancellationToken.None); + + var rows = new List + { + new() { RowIndex = 5, Code = "X", Name = "X Co", Phone = "9999", Address = "Hà Nội" }, + }; + + var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None); + + result.Updated.Should().Be(1); + result.Committed.Should().BeTrue(); + + var x = await db.Suppliers.SingleAsync(); + x.Phone.Should().Be("0900", "Phone đang non-null KHÔNG bị đè bởi '9999' (fill-nulls SAFE)"); + x.Address.Should().Be("Hà Nội", "Address đang null → được điền"); + } + + // ======================================================================== + // Case 3 (NON-NEGOTIABLE) — all-or-nothing: 1 hard-error → abort cả batch, DB không đổi. + // ======================================================================== + [Fact] + public async Task ConfirmAsync_AnyHardErrorRow_AbortsWholeBatch_NothingWritten() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var rows = new List + { + new() { RowIndex = 5, Code = "A", Name = "Alpha" }, // hợp lệ + new() { RowIndex = 6, Code = "B", Name = " " }, // hard-error: thiếu Name + }; + + var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None); + + result.Committed.Should().BeFalse("có hard-error → all-or-nothing abort, KHÔNG ghi gì"); + result.Errors.Should().NotBeEmpty("phải liệt kê dòng lỗi"); + result.Inserted.Should().Be(0); + (await db.Suppliers.IgnoreQueryFilters().CountAsync()) + .Should().Be(0, "kể cả hàng hợp lệ 'A' cũng KHÔNG được ghi (all-or-nothing)"); + } + + // ======================================================================== + // Case 4 (decision 5) — PHÂN LOẠI lạ → Type mặc định NhaCungCap + cảnh báo; type biết → map đúng. + // Đi qua PARSE path (MapType private, chỉ chạy khi parse xlsx). + // ======================================================================== + [Fact] + public async Task PreviewAsync_UnrecognizedType_DefaultsToNhaCungCap_WithWarning_KnownTypeStillMapped() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws => + { + // row 5 — PHÂN LOẠI không nhận dạng + ws.Cell(5, 3).Value = "Loại không xác định"; + ws.Cell(5, 4).Value = "U1"; + ws.Cell(5, 5).Value = "NCC lạ"; + // row 6 — "Cả hai" (positive control: mapper KHÔNG phải luôn default) + ws.Cell(6, 3).Value = "Cả hai"; + ws.Cell(6, 4).Value = "U2"; + ws.Cell(6, 5).Value = "NCC cả hai"; + }); + + var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None); + + preview.LayoutValid.Should().BeTrue(); + preview.Rows.Should().HaveCount(2); + + var r5 = preview.Rows.Single(x => x.RowIndex == 5); + r5.Type.Should().Be(SupplierType.NhaCungCap, "PHÂN LOẠI lạ → mặc định NCC (decision 5, KHÔNG reject)"); + r5.Messages.Should().Contain(m => m.Contains("không nhận dạng"), + "có cảnh báo mềm — chứng minh đã đi nhánh unknown, không chỉ là default của DTO"); + + var r6 = preview.Rows.Single(x => x.RowIndex == 6); + r6.Type.Should().Be(SupplierType.CaHai, "positive control: 'Cả hai' map đúng CaHai"); + } + + // ======================================================================== + // Case 5 — strip emoji đầu chuỗi tình trạng: "✅ Đang hoạt động" → DangHoatDong (parse → persist). + // ======================================================================== + [Fact] + public async Task PreviewThenConfirm_StatusWithEmojiPrefix_StripsAndPersistsDangHoatDong() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws => + { + ws.Cell(5, 4).Value = "EMO1"; + ws.Cell(5, 5).Value = "NCC Emoji"; + ws.Cell(5, 27).Value = "✅ Đang hoạt động"; + }); + + var svc = NewService(db); + var preview = await svc.PreviewAsync(stream, CancellationToken.None); + + preview.LayoutValid.Should().BeTrue(); + var row = preview.Rows.Single(); + row.SupplierStatus.Should().Be(SupplierStatus.DangHoatDong, + "emoji + khoảng trắng đầu bị strip → còn 'Đang hoạt động'"); + + var result = await svc.ConfirmAsync(preview.Rows, "tester", CancellationToken.None); + result.Inserted.Should().Be(1); + + var persisted = await db.Suppliers.SingleAsync(); + persisted.Status.Should().Be(SupplierStatus.DangHoatDong, "tình trạng đã strip được persist đúng"); + } + + // ======================================================================== + // Case 6 — valid layout (header từ ExpectedHeaderTokens qua reflection) → parse field + // theo CHỈ SỐ CỘT TUYỆT ĐỐI; LinkGuq giữ RAW backslash NAS. + // ======================================================================== + [Fact] + public async Task PreviewAsync_ValidLayout_ParsesFieldsByAbsoluteIndex_IncludingRawBackslashLink() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + const string nasLink = @"\\NAS-SOL\GUQ\truong-giang.pdf"; + using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws => + { + ws.Cell(5, 2).Value = "Gói thầu A"; + ws.Cell(5, 3).Value = "NCC"; + ws.Cell(5, 4).Value = "TG-01"; + ws.Cell(5, 5).Value = "Công ty Trường Giang"; + ws.Cell(5, 16).Value = nasLink; + }); + + var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None); + + preview.LayoutValid.Should().BeTrue( + "header row-4 khớp ExpectedHeaderTokens (đọc qua reflection → luôn đồng bộ kể cả sau khi close-review sửa tokens)"); + preview.Rows.Should().ContainSingle(); + + var row = preview.Rows.Single(); + row.PackageCategory.Should().Be("Gói thầu A", "col 2 → PackageCategory"); + row.Type.Should().Be(SupplierType.NhaCungCap, "col 3 'NCC' → NhaCungCap"); + row.Code.Should().Be("TG-01", "col 4 → Code"); + row.Name.Should().Be("Công ty Trường Giang", "col 5 → Name"); + row.LinkGuq.Should().Be(nasLink, "col 16 → LinkGuq giữ RAW backslash NAS — KHÔNG trim/normalize/escape"); + row.Status.Should().Be(RowImportStatus.New, "DB rỗng → hàng mới"); + } + + // ======================================================================== + // Case 6b — header sai → từ chối file (LayoutValid=false, Rows rỗng, có cảnh báo). + // ======================================================================== + [Fact] + public async Task PreviewAsync_WrongHeader_RejectsFile_LayoutInvalid_NoRows_WithWarning() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var wrongHeader = Enumerable.Range(1, 30).Select(i => $"CỘT SAI {i}").ToArray(); + using var stream = BuildWorkbook(wrongHeader, ws => + { + ws.Cell(5, 4).Value = "X1"; + ws.Cell(5, 5).Value = "Sẽ bị bỏ vì layout sai"; + }); + + var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None); + + preview.LayoutValid.Should().BeFalse("fingerprint header không khớp → từ chối cả file"); + preview.Rows.Should().BeEmpty("KHÔNG map hàng nào để tránh lệch cột"); + preview.Warnings.Should().Contain(w => w.Contains("Sai layout"), "phải nêu lý do sai layout cho user"); + } + + // ======================================================================== + // Case 9 (spec-drift documentation) — 30 token row-4 THẬT của file "Database NCC". + // Khẳng định PreviewAsync ĐỒNG Ý với so-khớp-fingerprint trực tiếp (real vs ExpectedHeaderTokens + // hiện tại). Robust 2 chiều: hiện lệch → LayoutValid=false; sau khi tokens sửa = token thật → + // tự động khẳng định LayoutValid=true (KHÔNG cần sửa test). + // ======================================================================== + [Fact] + public async Task PreviewAsync_RealFileRow4Tokens_FingerprintConsistentWithCurrentExpected() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var real = RealFileHeaderTokens(); + var expected = ReflectExpectedHeaderTokens(); + real.Should().HaveCount(30, "sanity: đúng 30 cột row-4 file thật"); + + // shouldMatch tính bằng CHÍNH cách service normalize (replicate NormalizeHeader) → + // không thể mis-predict. + bool shouldMatch = NormalizeJoin(real) == NormalizeJoin(expected); + + using var stream = BuildWorkbook(real, ws => + { + ws.Cell(5, 4).Value = "RT-01"; + ws.Cell(5, 5).Value = "NCC token thật"; + }); + + var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None); + + preview.LayoutValid.Should().Be(shouldMatch, + "PreviewAsync PHẢI đồng ý với so-khớp-fingerprint trực tiếp. Hiện tại ExpectedHeaderTokens là BEST-GUESS " + + "≠ token thật → shouldMatch=false → LayoutValid=false. Khi close-review sửa ExpectedHeaderTokens = token thật " + + "→ shouldMatch=true → test tự chuyển sang khẳng định LayoutValid=true (không cần sửa test)."); + + if (!shouldMatch) + preview.Rows.Should().BeEmpty("layout bị từ chối → không map hàng nào"); + } + + // ======================================================================== + // Case 7 — chỉ số cột tuyệt đối: ô rỗng giữa (FAX col 9) KHÔNG làm dồn cột sau; literal #REF! → null. + // ======================================================================== + [Fact] + public async Task PreviewAsync_EmptyMiddleCell_NoColumnDrift_AndErrorLiteralBecomesNull() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws => + { + ws.Cell(5, 4).Value = "DRIFT-1"; + ws.Cell(5, 5).Value = "NCC drift"; + ws.Cell(5, 8).Value = "PHONE-0908"; // col 8 → Phone + // col 9 (FAX) BỎ TRỐNG cố ý + ws.Cell(5, 10).Value = "BANK-ALPHA"; // col 10 → BankAccount + ws.Cell(5, 11).Value = "BANK-BETA"; // col 11 → SecondaryBankAccount + ws.Cell(5, 12).Value = "TAX-3133"; // col 12 → TaxCode + ws.Cell(5, 22).Value = "#REF!"; // col 22 → Email (literal lỗi Excel) + }); + + var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None); + + preview.LayoutValid.Should().BeTrue(); + var row = preview.Rows.Single(); + + row.Phone.Should().Be("PHONE-0908", "col 8 → Phone"); + row.Fax.Should().BeNull("ô FAX (col 9) rỗng → null"); + row.BankAccount.Should().Be("BANK-ALPHA", "chỉ số cột tuyệt đối → col 10 KHÔNG dồn trái dù col 9 rỗng"); + row.SecondaryBankAccount.Should().Be("BANK-BETA", "col 11 vẫn đúng chỗ"); + row.TaxCode.Should().Be("TAX-3133", + "col 12 vẫn rơi đúng TaxCode — không lệch cột (bug-class 'CellsUse() bỏ ô rỗng' đã tránh)"); + row.Email.Should().BeNull("literal '#REF!' → null (ErrorLiterals guard)"); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + // Đọc ExpectedHeaderTokens (private static readonly string[]) qua reflection → dựng header + // đúng-với-service để test valid-layout luôn đồng bộ (kể cả sau khi tokens được sửa). + private static string[] ReflectExpectedHeaderTokens() + { + var f = typeof(SupplierExcelImportService) + .GetField("ExpectedHeaderTokens", BindingFlags.NonPublic | BindingFlags.Static); + f.Should().NotBeNull("service phải có static ExpectedHeaderTokens (đọc qua reflection để test đồng bộ header)"); + return (string[])f!.GetValue(null)!; + } + + // Dựng .xlsx in-memory: 30 header token ở row 4, data từ row 5 (writeData set từng ô tuyệt đối). + private static MemoryStream BuildWorkbook(string[] headerRow4, Action writeData) + { + var ms = new MemoryStream(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Database NCC"); + for (int c = 1; c <= headerRow4.Length; c++) + ws.Cell(HeaderRow, c).Value = headerRow4[c - 1]; + writeData(ws); + wb.SaveAs(ms); + } + ms.Position = 0; + return ms; + } + + // Replicate y hệt SupplierExcelImportService.NormalizeHeader + join fingerprint → so khớp + // deterministic (Case 9), không mis-predict hành vi service. + private static string NormalizeJoin(IEnumerable tokens) + { + static string Norm(string raw) + { + if (string.IsNullOrEmpty(raw)) return string.Empty; + var collapsed = Regex.Replace(raw.Replace("\n", " ").Replace("\r", " "), @"\s+", " "); + return collapsed.Trim().ToUpperInvariant(); + } + return string.Join(" | ", tokens.Select(Norm)); + } + + // 30 token row-4 THẬT của file "Database NCC" (theo brief close-review). EXACT strings. + private static string[] RealFileHeaderTokens() => + [ + "STT", // 1 + "GÓI THẦU", // 2 + "PHÂN LOẠI (NTP/NCC/Cả hai)", // 3 + "TÊN VIẾT TẮT (Dùng trong HĐ)", // 4 + "TÊN CÔNG TY (Đầy đủ, đúng pháp lý)", // 5 + "ĐỊA CHỈ XUẤT HÓA ĐƠN (Địa chỉ đăng ký kinh doanh)", // 6 + "ĐỊA CHỈ VĂN PHÒNG (nếu có)", // 7 + "SỐ ĐIỆN THOẠI CÔNG TY", // 8 + "FAX", // 9 + "SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG (Đầy đủ, đúng pháp lý)", // 10 + "SỐ TK PHỤ (nếu có)", // 11 + "MÃ SỐ THUẾ", // 12 + "NGƯỜI ĐẠI DIỆN PHÁP LUẬT", // 13 + "CHỨC VỤ ĐẠI DIỆN", // 14 + "GIẤY ỦY QUYỀN (số, ngày, người ủy quyền)", // 15 + "Link GUQ", // 16 + "Link GPKD", // 17 + "Link HSNL", // 18 + "NGƯỜI LIÊN HỆ CHÍNH", // 19 + "CHỨC VỤ NGƯỜI LH", // 20 + "SĐT CHÍNH", // 21 + "EMAIL", // 22 + "ĐỊA CHỈ GỬI THƯ", // 23 + "NGƯỜI NHẬN THƯ/ SDT", // 24 + "NGUỒN GIỚI THIỆU", // 25 + "NGƯỜI PHỤ TRÁCH (PMH)", // 26 + "TÌNH TRẠNG HIỆN TẠI", // 27 + "GHI CHÚ / LÝ DO BLACKLIST", // 28 + "NGÀY CẬP NHẬT CUỐI", // 29 + "NGƯỜI CẬP NHẬT", // 30 + ]; +}