[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 { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable' 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 { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { PathLink } from '@/components/ui/PathLink' import { PathLink } from '@/components/ui/PathLink'
import { SupplierImportDialog } from '@/components/master/SupplierImportDialog'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { MenuKeys } from '@/lib/menuKeys' import { MenuKeys } from '@/lib/menuKeys'
@ -72,6 +73,7 @@ export function SuppliersPage() {
const [sortDesc, setSortDesc] = useState(true) const [sortDesc, setSortDesc] = useState(true)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [form, setForm] = useState<FormState>(emptyForm) const [form, setForm] = useState<FormState>(emptyForm)
const isEdit = !!form.id const isEdit = !!form.id
@ -209,12 +211,20 @@ export function SuppliersPage() {
title="Nhà cung cấp" title="Nhà cung cấp"
description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư" description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư"
actions={ actions={
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create"> <div className="flex items-center gap-2">
<Button onClick={openNew}> <PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Plus className="h-4 w-4" /> <Button variant="outline" onClick={() => setImportOpen(true)}>
Thêm NCC <Upload className="h-4 w-4" />
</Button> Import Excel NCC
</PermissionGuard> </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> </div>
</form> </form>
</Dialog> </Dialog>
<SupplierImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div> </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 { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable' 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 { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { PathLink } from '@/components/ui/PathLink' import { PathLink } from '@/components/ui/PathLink'
import { SupplierImportDialog } from '@/components/master/SupplierImportDialog'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { MenuKeys } from '@/lib/menuKeys' import { MenuKeys } from '@/lib/menuKeys'
@ -72,6 +73,7 @@ export function SuppliersPage() {
const [sortDesc, setSortDesc] = useState(true) const [sortDesc, setSortDesc] = useState(true)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [form, setForm] = useState<FormState>(emptyForm) const [form, setForm] = useState<FormState>(emptyForm)
const isEdit = !!form.id const isEdit = !!form.id
@ -209,12 +211,20 @@ export function SuppliersPage() {
title="Nhà cung cấp" title="Nhà cung cấp"
description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư" description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư"
actions={ actions={
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create"> <div className="flex items-center gap-2">
<Button onClick={openNew}> <PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<Plus className="h-4 w-4" /> <Button variant="outline" onClick={() => setImportOpen(true)}>
Thêm NCC <Upload className="h-4 w-4" />
</Button> Import Excel NCC
</PermissionGuard> </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> </div>
</form> </form>
</Dialog> </Dialog>
<SupplierImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div> </div>
) )
} }

View File

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Common.Models; using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Master.Suppliers.Commands.CreateSupplier; using SolutionErp.Application.Master.Suppliers.Commands.CreateSupplier;
using SolutionErp.Application.Master.Suppliers.Commands.DeleteSupplier; 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.Commands.UpdateSupplier;
using SolutionErp.Application.Master.Suppliers.Dtos; using SolutionErp.Application.Master.Suppliers.Dtos;
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier; using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
@ -57,4 +58,25 @@ public class SuppliersController(IMediator mediator) : ControllerBase
await mediator.Send(new DeleteSupplierCommand(id), ct); await mediator.Send(new DeleteSupplierCommand(id), ct);
return NoContent(); 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? ReferralSource,
string? OwnerPmh, string? OwnerPmh,
SupplierStatus? Status, 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 CreatedAt,
DateTime? UpdatedAt); 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.PackageCategory, x.OfficeAddress, x.MailingAddress, x.Fax, x.BankAccount, x.SecondaryBankAccount,
x.LegalRepresentative, x.LegalRepTitle, x.AuthorizationNote, x.LinkGuq, x.LinkGpkd, x.LinkHsnl, 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.ContactTitle, x.ContactPhone, x.MailRecipient, x.ReferralSource, x.OwnerPmh, x.Status,
x.SourceUpdatedAt, x.SourceUpdatedBy,
x.CreatedAt, x.UpdatedAt); 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.PackageCategory, x.OfficeAddress, x.MailingAddress, x.Fax, x.BankAccount, x.SecondaryBankAccount,
x.LegalRepresentative, x.LegalRepTitle, x.AuthorizationNote, x.LinkGuq, x.LinkGpkd, x.LinkHsnl, 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.ContactTitle, x.ContactPhone, x.MailRecipient, x.ReferralSource, x.OwnerPmh, x.Status,
x.SourceUpdatedAt, x.SourceUpdatedBy,
x.CreatedAt, x.UpdatedAt)) x.CreatedAt, x.UpdatedAt))
.ToListAsync(ct); .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? ReferralSource { get; set; } // Nguồn giới thiệu
public string? OwnerPmh { get; set; } // Người phụ trách (PMH) 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) 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.Contracts.Services;
using SolutionErp.Application.Forms.Services; using SolutionErp.Application.Forms.Services;
using SolutionErp.Application.Hrm.Services; using SolutionErp.Application.Hrm.Services;
using SolutionErp.Application.Master.Suppliers.Import;
using SolutionErp.Application.Notifications; using SolutionErp.Application.Notifications;
using SolutionErp.Application.PurchaseEvaluations.Services; using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Application.Reports.Services; using SolutionErp.Application.Reports.Services;
@ -41,6 +42,7 @@ public static class DependencyInjection
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>(); services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();
services.AddScoped<INotificationService, NotificationService>(); services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IChangelogService, ChangelogService>(); services.AddScoped<IChangelogService, ChangelogService>();
services.AddScoped<ISupplierExcelImportService, SupplierExcelImportService>();
services.AddSingleton<IFileStorage, LocalFileStorage>(); services.AddSingleton<IFileStorage, LocalFileStorage>();
// Phase 3 iteration 2 — SLA auto-approve background service // 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.OwnerPmh).HasMaxLength(150);
b.Property(x => x.Status).HasConversion<int>(); // nullable int enum 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.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); 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) .HasMaxLength(500)
.HasColumnType("nvarchar(500)"); .HasColumnType("nvarchar(500)");
b.Property<DateTime?>("SourceUpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("SourceUpdatedBy")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int?>("Status") b.Property<int?>("Status")
.HasColumnType("int"); .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
];
}