[CLAUDE] Supplier: Excel-import Phase B (upload NCC preview/confirm) + Mig 63
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m25s

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 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-12 16:13:46 +07:00
parent a829d0df99
commit e100ef065b
20 changed files with 8268 additions and 14 deletions

View File

@ -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, { label: string; cls: string }> = {
[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 (
<span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1 ring-inset', meta.cls)}>
{meta.label}
</span>
)
}
export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const qc = useQueryClient()
const inputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
const [fileName, setFileName] = useState('')
const [preview, setPreview] = useState<SupplierImportPreview | null>(null)
const [result, setResult] = useState<SupplierImportResult | null>(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<SupplierImportPreview>('/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<SupplierImportResult>('/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<HTMLDivElement>) {
e.preventDefault()
setDragging(false)
pickFile(e.dataTransfer.files)
}
function onPick(e: ChangeEvent<HTMLInputElement>) {
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 (
<Dialog
open={open}
onClose={onClose}
title="Import Excel — Database NCC"
size="lg"
footer={
<>
<Button variant="outline" onClick={onClose}>
Đóng
</Button>
{preview && preview.layoutValid && (
<Button
onClick={() => confirmMut.mutate(preview.rows)}
disabled={preview.errorCount > 0 || confirmMut.isPending}
>
{confirmMut.isPending ? 'Đang nhập…' : 'Xác nhận nhập'}
</Button>
)}
</>
}
>
<div className="space-y-4">
{/* Vùng chọn / kéo-thả file .xlsx */}
<div
onDragOver={e => {
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',
)}
>
<FileSpreadsheet className="mx-auto h-6 w-6 text-slate-400" />
<div className="mt-2 text-sm font-medium text-slate-600">
{fileName ? (
<span className="inline-flex items-center gap-1.5 text-brand-600">
<Upload className="h-3.5 w-3.5" />
{fileName}
</span>
) : (
<>
Kéo thả file Excel vào đây hoặc <span className="text-brand-600">chọn file</span>
</>
)}
</div>
<div className="mt-0.5 text-xs text-slate-400">Chỉ nhận file .xlsx đúng layout "Database NCC"</div>
<input ref={inputRef} type="file" accept=".xlsx" onChange={onPick} className="hidden" />
</div>
{/* Đang phân tích */}
{previewMut.isPending && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
Đang phân tích file
</div>
)}
{/* Sai layout → từ chối, ẩn nút xác nhận */}
{preview && !preview.layoutValid && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4 shrink-0" />
Sai layout file Excel không nhập đưc
</div>
{preview.warnings.length > 0 && (
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs">
{preview.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
)}
</div>
)}
{/* Layout hợp lệ → tóm tắt + cảnh báo + bảng */}
{preview && preview.layoutValid && (
<>
<div className="flex flex-wrap gap-2">
{counts.map(c => (
<span
key={c.label}
className={cn('inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold', c.cls)}
>
{c.label}: {c.value}
</span>
))}
</div>
{preview.warnings.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
<div className="mb-1 flex items-center gap-1.5 font-semibold">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Cảnh báo
</div>
<ul className="list-disc space-y-0.5 pl-5">
{preview.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
<div className="max-h-[380px] overflow-auto rounded-lg border border-slate-200">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-slate-50 text-slate-500">
<tr>
<th className="w-12 px-2 py-1.5 text-left font-medium">Dòng</th>
<th className="w-24 px-2 py-1.5 text-left font-medium">Trạng thái</th>
<th className="w-32 px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-left font-medium">Tên NCC</th>
<th className="w-36 px-2 py-1.5 text-left font-medium">Loại</th>
<th className="px-2 py-1.5 text-left font-medium">Thông báo</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{preview.rows.map(r => (
<tr key={r.rowIndex} className="align-top">
<td className="px-2 py-1.5 font-mono text-slate-400">{r.rowIndex}</td>
<td className="px-2 py-1.5">
<StatusBadge status={r.status} />
</td>
<td className="px-2 py-1.5 font-mono text-slate-700">{r.code || '—'}</td>
<td className="px-2 py-1.5 text-slate-700">{r.name || '—'}</td>
<td className="px-2 py-1.5 text-slate-600">{SupplierTypeLabel[r.type] ?? '—'}</td>
<td
className={cn(
'px-2 py-1.5',
r.status === RowImportStatus.Error ? 'text-red-600' : 'text-amber-600',
)}
>
{r.messages.length > 0 ? r.messages.join('; ') : ''}
</td>
</tr>
))}
{preview.rows.length === 0 && (
<tr>
<td colSpan={6} className="px-2 py-4 text-center text-slate-400">
Không dòng dữ liệu nào trong file.
</td>
</tr>
)}
</tbody>
</table>
</div>
{preview.errorCount > 0 && (
<div className="text-xs text-red-600">
Còn {preview.errorCount} dòng lỗi sửa file Excel rồi tải lại trước khi nhập.
</div>
)}
</>
)}
{/* Confirm trả hard-error → ALL-OR-NOTHING, chưa lưu gì */}
{result && !result.committed && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4 shrink-0" />
Không thể nhập chưa dữ liệu nào đưc lưu
</div>
{result.errors.length > 0 && (
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs">
{result.errors.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
)}
</div>
)}
</div>
</Dialog>
)
}

View File

@ -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<FormState>(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={
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Button onClick={openNew}>
<Plus className="h-4 w-4" />
Thêm NCC
</Button>
</PermissionGuard>
<div className="flex items-center gap-2">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Button variant="outline" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4" />
Import Excel NCC
</Button>
</PermissionGuard>
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Button onClick={openNew}>
<Plus className="h-4 w-4" />
Thêm NCC
</Button>
</PermissionGuard>
</div>
}
/>
@ -413,6 +423,8 @@ export function SuppliersPage() {
</div>
</form>
</Dialog>
<SupplierImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div>
)
}

View File

@ -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, { label: string; cls: string }> = {
[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 (
<span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1 ring-inset', meta.cls)}>
{meta.label}
</span>
)
}
export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const qc = useQueryClient()
const inputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
const [fileName, setFileName] = useState('')
const [preview, setPreview] = useState<SupplierImportPreview | null>(null)
const [result, setResult] = useState<SupplierImportResult | null>(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<SupplierImportPreview>('/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<SupplierImportResult>('/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<HTMLDivElement>) {
e.preventDefault()
setDragging(false)
pickFile(e.dataTransfer.files)
}
function onPick(e: ChangeEvent<HTMLInputElement>) {
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 (
<Dialog
open={open}
onClose={onClose}
title="Import Excel — Database NCC"
size="lg"
footer={
<>
<Button variant="outline" onClick={onClose}>
Đóng
</Button>
{preview && preview.layoutValid && (
<Button
onClick={() => confirmMut.mutate(preview.rows)}
disabled={preview.errorCount > 0 || confirmMut.isPending}
>
{confirmMut.isPending ? 'Đang nhập…' : 'Xác nhận nhập'}
</Button>
)}
</>
}
>
<div className="space-y-4">
{/* Vùng chọn / kéo-thả file .xlsx */}
<div
onDragOver={e => {
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',
)}
>
<FileSpreadsheet className="mx-auto h-6 w-6 text-slate-400" />
<div className="mt-2 text-sm font-medium text-slate-600">
{fileName ? (
<span className="inline-flex items-center gap-1.5 text-brand-600">
<Upload className="h-3.5 w-3.5" />
{fileName}
</span>
) : (
<>
Kéo thả file Excel vào đây hoặc <span className="text-brand-600">chọn file</span>
</>
)}
</div>
<div className="mt-0.5 text-xs text-slate-400">Chỉ nhận file .xlsx đúng layout "Database NCC"</div>
<input ref={inputRef} type="file" accept=".xlsx" onChange={onPick} className="hidden" />
</div>
{/* Đang phân tích */}
{previewMut.isPending && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
Đang phân tích file
</div>
)}
{/* Sai layout → từ chối, ẩn nút xác nhận */}
{preview && !preview.layoutValid && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4 shrink-0" />
Sai layout file Excel không nhập đưc
</div>
{preview.warnings.length > 0 && (
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs">
{preview.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
)}
</div>
)}
{/* Layout hợp lệ → tóm tắt + cảnh báo + bảng */}
{preview && preview.layoutValid && (
<>
<div className="flex flex-wrap gap-2">
{counts.map(c => (
<span
key={c.label}
className={cn('inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold', c.cls)}
>
{c.label}: {c.value}
</span>
))}
</div>
{preview.warnings.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
<div className="mb-1 flex items-center gap-1.5 font-semibold">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Cảnh báo
</div>
<ul className="list-disc space-y-0.5 pl-5">
{preview.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
<div className="max-h-[380px] overflow-auto rounded-lg border border-slate-200">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-slate-50 text-slate-500">
<tr>
<th className="w-12 px-2 py-1.5 text-left font-medium">Dòng</th>
<th className="w-24 px-2 py-1.5 text-left font-medium">Trạng thái</th>
<th className="w-32 px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-left font-medium">Tên NCC</th>
<th className="w-36 px-2 py-1.5 text-left font-medium">Loại</th>
<th className="px-2 py-1.5 text-left font-medium">Thông báo</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{preview.rows.map(r => (
<tr key={r.rowIndex} className="align-top">
<td className="px-2 py-1.5 font-mono text-slate-400">{r.rowIndex}</td>
<td className="px-2 py-1.5">
<StatusBadge status={r.status} />
</td>
<td className="px-2 py-1.5 font-mono text-slate-700">{r.code || '—'}</td>
<td className="px-2 py-1.5 text-slate-700">{r.name || '—'}</td>
<td className="px-2 py-1.5 text-slate-600">{SupplierTypeLabel[r.type] ?? '—'}</td>
<td
className={cn(
'px-2 py-1.5',
r.status === RowImportStatus.Error ? 'text-red-600' : 'text-amber-600',
)}
>
{r.messages.length > 0 ? r.messages.join('; ') : ''}
</td>
</tr>
))}
{preview.rows.length === 0 && (
<tr>
<td colSpan={6} className="px-2 py-4 text-center text-slate-400">
Không dòng dữ liệu nào trong file.
</td>
</tr>
)}
</tbody>
</table>
</div>
{preview.errorCount > 0 && (
<div className="text-xs text-red-600">
Còn {preview.errorCount} dòng lỗi sửa file Excel rồi tải lại trước khi nhập.
</div>
)}
</>
)}
{/* Confirm trả hard-error → ALL-OR-NOTHING, chưa lưu gì */}
{result && !result.committed && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4 shrink-0" />
Không thể nhập chưa dữ liệu nào đưc lưu
</div>
{result.errors.length > 0 && (
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs">
{result.errors.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
)}
</div>
)}
</div>
</Dialog>
)
}

View File

@ -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<FormState>(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={
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Button onClick={openNew}>
<Plus className="h-4 w-4" />
Thêm NCC
</Button>
</PermissionGuard>
<div className="flex items-center gap-2">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Button variant="outline" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4" />
Import Excel NCC
</Button>
</PermissionGuard>
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Button onClick={openNew}>
<Plus className="h-4 w-4" />
Thêm NCC
</Button>
</PermissionGuard>
</div>
}
/>
@ -413,6 +423,8 @@ export function SuppliersPage() {
</div>
</form>
</Dialog>
<SupplierImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div>
)
}

View File

@ -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<ActionResult<SupplierImportPreviewDto>> 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<ActionResult<SupplierImportResultDto>> ImportConfirm(
[FromBody] SupplierImportConfirmCommand cmd, CancellationToken ct)
=> Ok(await mediator.Send(cmd, ct));
}

View File

@ -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;
/// <summary>
/// Commit các hàng đã preview (ALL-OR-NOTHING). Body JSON = { "rows": [ ... ] }.
/// Actor resolve từ ICurrentUser (chỉ để log — audit CreatedBy/UpdatedBy do interceptor set).
/// </summary>
public sealed record SupplierImportConfirmCommand(IReadOnlyList<SupplierImportRowDto> Rows)
: IRequest<SupplierImportResultDto>;
public sealed class SupplierImportConfirmCommandHandler(
ISupplierExcelImportService importService,
ICurrentUser currentUser)
: IRequestHandler<SupplierImportConfirmCommand, SupplierImportResultDto>
{
public Task<SupplierImportResultDto> Handle(SupplierImportConfirmCommand request, CancellationToken ct)
{
var actor = currentUser.FullName
?? currentUser.Email
?? currentUser.UserId?.ToString()
?? "unknown";
return importService.ConfirmAsync(request.Rows ?? Array.Empty<SupplierImportRowDto>(), actor, ct);
}
}

View File

@ -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;
/// <summary>
/// Preview import NCC từ file Excel (KHÔNG ghi DB). Stream do controller mở từ IFormFile
/// (mirror UploadPurchaseEvaluationAttachmentCommand — command mang Stream).
/// </summary>
public sealed record SupplierImportPreviewCommand(Stream Xlsx) : IRequest<SupplierImportPreviewDto>;
public sealed class SupplierImportPreviewCommandHandler(ISupplierExcelImportService importService)
: IRequestHandler<SupplierImportPreviewCommand, SupplierImportPreviewDto>
{
public Task<SupplierImportPreviewDto> Handle(SupplierImportPreviewCommand request, CancellationToken ct)
=> importService.PreviewAsync(request.Xlsx, ct);
}

View File

@ -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);

View File

@ -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).
// ============================================================================
/// <summary>Phân loại mỗi hàng sau khi preview classify.</summary>
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
}
/// <summary>
/// 1 hàng Excel đã parse + map sang field Supplier (27) + provenance (2) + chẩn đoán.
/// <c>Status</c> = phân loại hàng (RowImportStatus). <c>SupplierStatus</c> = tình trạng NCC (cột 27).
/// </summary>
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<string> 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")
}
/// <summary>Kết quả preview (KHÔNG ghi DB). LayoutValid=false khi header-fingerprint không khớp.</summary>
public sealed record SupplierImportPreviewDto
{
public List<SupplierImportRowDto> 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<string> Warnings { get; set; } = new();
public bool LayoutValid { get; set; } = true; // false → file bị từ chối (sai layout), Rows rỗng
}
/// <summary>Kết quả confirm. Committed=false + Errors khi có hard-error (all-or-nothing, không ghi gì).</summary>
public sealed record SupplierImportResultDto
{
public int Inserted { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public List<string> Errors { get; set; } = new();
public bool Committed { get; set; } // true nếu đã SaveChanges; false nếu abort do hard-error
}

View File

@ -0,0 +1,24 @@
using SolutionErp.Application.Master.Suppliers.Dtos;
namespace SolutionErp.Application.Master.Suppliers.Import;
/// <summary>
/// Import NCC từ file Excel "Database NCC" (Supplier Phase B, Approach A — layout-locked).
/// Impl ở Infrastructure (ClosedXML). Inject IApplicationDbContext để testable.
/// </summary>
public interface ISupplierExcelImportService
{
/// <summary>
/// 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.
/// </summary>
Task<SupplierImportPreviewDto> PreviewAsync(Stream xlsx, CancellationToken ct = default);
/// <summary>
/// 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. <paramref name="actor"/> chỉ dùng để log (audit thật do interceptor set).
/// </summary>
Task<SupplierImportResultDto> ConfirmAsync(IReadOnlyList<SupplierImportRowDto> rows, string actor, CancellationToken ct = default);
}

View File

@ -22,6 +22,7 @@ public class GetSupplierQueryHandler : IRequestHandler<GetSupplierQuery, Supplie
x.PackageCategory, x.OfficeAddress, x.MailingAddress, x.Fax, x.BankAccount, x.SecondaryBankAccount,
x.LegalRepresentative, x.LegalRepTitle, x.AuthorizationNote, x.LinkGuq, x.LinkGpkd, x.LinkHsnl,
x.ContactTitle, x.ContactPhone, x.MailRecipient, x.ReferralSource, x.OwnerPmh, x.Status,
x.SourceUpdatedAt, x.SourceUpdatedBy,
x.CreatedAt, x.UpdatedAt);
}
}

View File

@ -50,6 +50,7 @@ public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, Pag
x.PackageCategory, x.OfficeAddress, x.MailingAddress, x.Fax, x.BankAccount, x.SecondaryBankAccount,
x.LegalRepresentative, x.LegalRepTitle, x.AuthorizationNote, x.LinkGuq, x.LinkGpkd, x.LinkHsnl,
x.ContactTitle, x.ContactPhone, x.MailRecipient, x.ReferralSource, x.OwnerPmh, x.Status,
x.SourceUpdatedAt, x.SourceUpdatedBy,
x.CreatedAt, x.UpdatedAt))
.ToListAsync(ct);

View File

@ -33,4 +33,9 @@ public class Supplier : AuditableEntity
public string? ReferralSource { get; set; } // Nguồn giới thiệu
public string? OwnerPmh { get; set; } // Người phụ trách (PMH)
public SupplierStatus? Status { get; set; } // Tình trạng hiện tại (nullable = chưa phân loại)
// ---- Import provenance (Supplier Phase B — Upload Excel "Database NCC") ----
// Nguồn cập nhật từ file Excel gốc (cột 29-30), KHÁC audit CreatedAt/UpdatedBy (do hệ thống set).
public DateTime? SourceUpdatedAt { get; set; } // "NGÀY CẬP NHẬT CUỐI" trên file Excel nguồn
public string? SourceUpdatedBy { get; set; } // "NGƯỜI CẬP NHẬT" trên file Excel nguồn
}

View File

@ -6,6 +6,7 @@ using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Application.Hrm.Services;
using SolutionErp.Application.Master.Suppliers.Import;
using SolutionErp.Application.Notifications;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Application.Reports.Services;
@ -41,6 +42,7 @@ public static class DependencyInjection
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IChangelogService, ChangelogService>();
services.AddScoped<ISupplierExcelImportService, SupplierExcelImportService>();
services.AddSingleton<IFileStorage, LocalFileStorage>();
// Phase 3 iteration 2 — SLA auto-approve background service

View File

@ -41,6 +41,9 @@ public class SupplierConfiguration : IEntityTypeConfiguration<Supplier>
b.Property(x => x.OwnerPmh).HasMaxLength(150);
b.Property(x => x.Status).HasConversion<int>(); // 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);

View File

@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddSupplierImportSourceFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "SourceUpdatedAt",
table: "Suppliers",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SourceUpdatedBy",
table: "Suppliers",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SourceUpdatedAt",
table: "Suppliers");
migrationBuilder.DropColumn(
name: "SourceUpdatedBy",
table: "Suppliers");
}
}
}

View File

@ -3327,6 +3327,13 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("SourceUpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("SourceUpdatedBy")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int?>("Status")
.HasColumnType("int");

View File

@ -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;
/// <summary>
/// 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ị.
/// </summary>
public sealed class SupplierExcelImportService(
IApplicationDbContext db,
ILogger<SupplierExcelImportService> 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<string> 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<SupplierImportPreviewDto> 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<SupplierImportResultDto> ConfirmAsync(
IReadOnlyList<SupplierImportRowDto> 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<string>();
foreach (var row in rows)
{
if (IsRowEmpty(row)) continue;
var missing = new List<string>();
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<string, Supplier>(StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(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<SupplierImportRowDto> Rows, List<string> Warnings) ParseWorkbook(Stream xlsx)
{
var warnings = new List<string>();
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<SupplierImportRowDto>(), 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<SupplierImportRowDto>();
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<string>();
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<DateTime>(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<string> 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<string> 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<string, Supplier> BuildCiIndex(IEnumerable<Supplier> suppliers)
{
var dict = new Dictionary<string, Supplier>(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;
}

View File

@ -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<SupplierExcelImportService>.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<SupplierImportRowDto>
{
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<SupplierImportRowDto>
{
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<SupplierImportRowDto>
{
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<IXLWorksheet> 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<string> 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
];
}