[CLAUDE] Supplier: import v2 — Mig 64 publish/draft + dedup-MST + template + file mẫu
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m14s

- Mig 64 AddSupplierPublishState: +IsPublic (draft/public); backfill 22 prod->public; filtered-unique doi [Code]<>'' (cho phep nhieu nhap Code=''); no new table (89).
- Import v2: dedup MST-primary + Code-backstop (chong 500 unique-violation); re-bake 30 token header THAT byte-exact (LayoutValid khop file that); nhap Ma NCC per-row (=Code); thieu Ma NCC->nhap (IsPublic=false); MST thieu->canh bao mem (MstMissing).
- PublishSupplierCommand rieng (ne #73 clobber); GET /suppliers/import/template (BE-gen xlsx 30-col).
- FE 2-app SHA-mirror: badge Public/Nhap; nut Cong bo; filter; nut Tai file mau; cot Ma editable; canh bao MST. Picker PE + tao HD loc published=true (an nhap -> ma HD khong dinh Code rong). CreateSupplier set IsPublic=true.
- authz D3: import/preview/confirm/template/publish = Policy Suppliers.Update (khop FE PermissionGuard, het 403).
- Tests +19 (477 PASS): dedup T1-T7, publish-guard, list-filter, all-or-nothing, LayoutValid. Fix null-Code NRE (path R4 loi).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-12 19:03:37 +07:00
parent 41f29acf68
commit 5fa11b588a
28 changed files with 7659 additions and 105 deletions

View File

@ -1,9 +1,10 @@
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 { AlertTriangle, Download, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
@ -58,6 +59,8 @@ export type SupplierImportRow = {
status: RowImportStatus
messages: string[]
existingSupplierId: string | null
// Thiếu MST → cảnh báo mềm (không kiểm được trùng), KHÔNG chặn nhập (S113)
mstMissing: boolean
}
export type SupplierImportPreview = {
@ -145,6 +148,27 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
onError: err => toast.error(getErrorMessage(err)),
})
// Tải file mẫu .xlsx trống (BE dựng bằng ClosedXML — single-source layout, 0 drift).
const templateMut = useMutation({
mutationFn: async () => {
const res = await api.get('/suppliers/import/template', { responseType: 'blob' })
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = 'Mau-Database-NCC.xlsx'
a.click()
window.URL.revokeObjectURL(url)
},
onError: err => toast.error(getErrorMessage(err)),
})
// Sửa Mã NCC per-dòng ngay trên lưới preview (round-trip verbatim sang confirm).
function updateRow(rowIndex: number, patch: Partial<SupplierImportRow>) {
setPreview(p =>
p ? { ...p, rows: p.rows.map(x => (x.rowIndex === rowIndex ? { ...x, ...patch } : x)) } : p,
)
}
function pickFile(files: FileList | null) {
const file = files?.[0]
if (!file) return
@ -228,6 +252,21 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
<input ref={inputRef} type="file" accept=".xlsx" onChange={onPick} className="hidden" />
</div>
{/* Tải file mẫu — lấy layout chuẩn trước khi nhập */}
<div className="flex items-center justify-between gap-3 rounded-lg bg-slate-50 px-3 py-2">
<p className="text-xs text-slate-500">Chưa file? Tải mẫu chuẩn rồi điền dữ liệu NCC theo đúng cột.</p>
<Button
variant="outline"
size="sm"
onClick={() => templateMut.mutate()}
disabled={templateMut.isPending}
className="shrink-0"
>
<Download className="h-3.5 w-3.5" />
{templateMut.isPending ? 'Đang tải…' : 'Tải file mẫu'}
</Button>
</div>
{/* Đang phân tích */}
{previewMut.isPending && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-slate-500">
@ -281,6 +320,11 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
</div>
)}
<p className="rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-700">
Nhập <b> NCC</b> cho từng dòng cột "Mã". NCC chưa nhập NCC sẽ lưu trạng thái{' '}
<b>Nháp (n)</b>, chưa hiển thị ra ngoài.
</p>
<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">
@ -300,16 +344,24 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
<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">
<Input
value={r.code ?? ''}
onChange={e => updateRow(r.rowIndex, { code: e.target.value })}
placeholder="Mã NCC"
className="h-7 font-mono text-xs"
/>
</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',
'space-y-0.5 px-2 py-1.5',
r.status === RowImportStatus.Error ? 'text-red-600' : 'text-amber-600',
)}
>
{r.messages.length > 0 ? r.messages.join('; ') : ''}
{r.mstMissing && <div className="text-amber-600"> Chưa MST, thể bị trùng</div>}
{r.messages.length > 0 && <div>{r.messages.join('; ')}</div>}
</td>
</tr>
))}

View File

@ -2219,7 +2219,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
const qc = useQueryClient()
const suppliers = useQuery({
queryKey: ['all-suppliers'],
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000, published: true } })).data.items, // S113 R4: chỉ NCC đã công bố (ẩn nháp)
})
const [form, setForm] = useState({
supplierId: '',

View File

@ -318,7 +318,7 @@ function ContractHeaderForm({
const suppliers = useQuery({
queryKey: ['suppliers-all'],
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200, published: true } })).data.items, // S113 R4: chỉ NCC đã công bố (ẩn nháp → mã HĐ không dính Code rỗng)
})
const projects = useQuery({
queryKey: ['projects-all'],

View File

@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { EyeOff, Globe, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable'
@ -77,11 +77,24 @@ export function SuppliersPage() {
const [form, setForm] = useState<FormState>(emptyForm)
const isEdit = !!form.id
// Bộ lọc công bố: '' = tất cả · 'true' = đã công bố · 'false' = nháp (ẩn)
const [published, setPublished] = useState('')
// Dialog công bố: nhập Mã NCC trước khi NCC hiển thị ra ngoài
const [publishTarget, setPublishTarget] = useState<Supplier | null>(null)
const [maNcc, setMaNcc] = useState('')
const list = useQuery({
queryKey: ['suppliers', { page, search, sortBy, sortDesc }],
queryKey: ['suppliers', { page, search, sortBy, sortDesc, published }],
queryFn: async () => {
const res = await api.get<Paged<Supplier>>('/suppliers', {
params: { page, pageSize: 20, search: search || undefined, sortBy, sortDesc },
params: {
page,
pageSize: 20,
search: search || undefined,
sortBy,
sortDesc,
published: published === '' ? undefined : published === 'true',
},
})
return res.data
},
@ -143,6 +156,24 @@ export function SuppliersPage() {
onError: err => toast.error(getErrorMessage(err)),
})
// Công bố / ẩn NCC — KHÔNG qua UpdateSupplier (chỉ đổi Mã NCC + IsPublic).
const publish = useMutation({
mutationFn: async ({ id, maNcc, doPublish }: { id: string; maNcc?: string; doPublish: boolean }) =>
await api.post(`/suppliers/${id}/publish`, { maNcc: maNcc || undefined, publish: doPublish }),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ['suppliers'] })
toast.success(vars.doPublish ? 'Đã công bố NCC' : 'Đã ẩn NCC')
setPublishTarget(null)
setMaNcc('')
},
onError: err => toast.error(getErrorMessage(err)),
})
function openPublish(s: Supplier) {
setPublishTarget(s)
setMaNcc(s.code ?? '')
}
function openNew() {
setForm(emptyForm)
setOpen(true)
@ -177,13 +208,47 @@ export function SuppliersPage() {
{ key: 'type', header: 'Loại', sortable: true, width: 'w-36', render: s => SupplierTypeLabel[s.type] },
{ key: 'taxCode', header: 'MST', render: s => s.taxCode ?? '—' },
{ key: 'phone', header: 'Điện thoại', render: s => s.phone ?? '—' },
{
key: 'isPublic',
header: 'Trạng thái',
width: 'w-32',
render: s =>
s.isPublic ? (
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700 ring-1 ring-inset ring-emerald-600/20">
Đã công bố
</span>
) : (
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-600 ring-1 ring-inset ring-slate-500/20">
Nháp (n)
</span>
),
},
{
key: 'actions',
header: '',
align: 'right',
width: 'w-32',
width: 'w-40',
render: s => (
<div className="flex justify-end gap-1">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
{s.isPublic ? (
<Button
size="sm"
variant="ghost"
title="Ẩn NCC (bỏ công bố)"
onClick={() => {
if (confirm(`Ẩn NCC "${s.name}"? NCC sẽ không hiển thị ra ngoài.`))
publish.mutate({ id: s.id, doPublish: false })
}}
>
<EyeOff className="h-3.5 w-3.5 text-slate-500" />
</Button>
) : (
<Button size="sm" variant="ghost" title="Công bố NCC" onClick={() => openPublish(s)}>
<Globe className="h-3.5 w-3.5 text-emerald-600" />
</Button>
)}
</PermissionGuard>
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
<Button size="sm" variant="ghost" onClick={() => openEdit(s)}>
<Pencil className="h-3.5 w-3.5" />
@ -212,7 +277,7 @@ export function SuppliersPage() {
description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư"
actions={
<div className="flex items-center gap-2">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
<Button variant="outline" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4" />
Import Excel NCC
@ -238,6 +303,18 @@ export function SuppliersPage() {
}}
className="max-w-sm"
/>
<Select
value={published}
onChange={e => {
setPublished(e.target.value)
setPage(1)
}}
className="max-w-[190px]"
>
<option value="">Tất cả trạng thái</option>
<option value="true">Đã công bố</option>
<option value="false">Nháp (n)</option>
</Select>
</div>
<DataTable
@ -424,6 +501,40 @@ export function SuppliersPage() {
</form>
</Dialog>
<Dialog
open={publishTarget !== null}
onClose={() => setPublishTarget(null)}
title="Công bố nhà cung cấp"
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setPublishTarget(null)}>
Hủy
</Button>
<Button
onClick={() =>
publishTarget && publish.mutate({ id: publishTarget.id, maNcc: maNcc.trim(), doPublish: true })
}
disabled={!maNcc.trim() || publish.isPending}
>
{publish.isPending ? 'Đang công bố…' : 'Công bố'}
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm text-slate-600">
Nhập <b> NCC</b> đ công bố "{publishTarget?.name}". Sau khi công bố, NCC sẽ hiển thị các màn chọn
NCC (duyệt NCC, hợp đng).
</p>
<div className="space-y-1.5">
<Label> NCC *</Label>
<Input value={maNcc} onChange={e => setMaNcc(e.target.value)} placeholder="VD: NCC001" autoFocus />
</div>
<p className="text-xs text-slate-400">NCC chưa NCC sẽ trạng thái Nháp (n), chưa hiển thị ra ngoài.</p>
</div>
</Dialog>
<SupplierImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div>
)

View File

@ -75,6 +75,8 @@ export type Supplier = {
ownerPmh: string | null
// Tình trạng
status: SupplierStatus | null
// Công bố: true = hiển thị ra ngoài (picker NCC), false = nháp/ẩn (S113)
isPublic: boolean
createdAt: string
updatedAt: string | null
}

View File

@ -1,9 +1,10 @@
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 { AlertTriangle, Download, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
@ -58,6 +59,8 @@ export type SupplierImportRow = {
status: RowImportStatus
messages: string[]
existingSupplierId: string | null
// Thiếu MST → cảnh báo mềm (không kiểm được trùng), KHÔNG chặn nhập (S113)
mstMissing: boolean
}
export type SupplierImportPreview = {
@ -145,6 +148,27 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
onError: err => toast.error(getErrorMessage(err)),
})
// Tải file mẫu .xlsx trống (BE dựng bằng ClosedXML — single-source layout, 0 drift).
const templateMut = useMutation({
mutationFn: async () => {
const res = await api.get('/suppliers/import/template', { responseType: 'blob' })
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = 'Mau-Database-NCC.xlsx'
a.click()
window.URL.revokeObjectURL(url)
},
onError: err => toast.error(getErrorMessage(err)),
})
// Sửa Mã NCC per-dòng ngay trên lưới preview (round-trip verbatim sang confirm).
function updateRow(rowIndex: number, patch: Partial<SupplierImportRow>) {
setPreview(p =>
p ? { ...p, rows: p.rows.map(x => (x.rowIndex === rowIndex ? { ...x, ...patch } : x)) } : p,
)
}
function pickFile(files: FileList | null) {
const file = files?.[0]
if (!file) return
@ -228,6 +252,21 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
<input ref={inputRef} type="file" accept=".xlsx" onChange={onPick} className="hidden" />
</div>
{/* Tải file mẫu — lấy layout chuẩn trước khi nhập */}
<div className="flex items-center justify-between gap-3 rounded-lg bg-slate-50 px-3 py-2">
<p className="text-xs text-slate-500">Chưa file? Tải mẫu chuẩn rồi điền dữ liệu NCC theo đúng cột.</p>
<Button
variant="outline"
size="sm"
onClick={() => templateMut.mutate()}
disabled={templateMut.isPending}
className="shrink-0"
>
<Download className="h-3.5 w-3.5" />
{templateMut.isPending ? 'Đang tải…' : 'Tải file mẫu'}
</Button>
</div>
{/* Đang phân tích */}
{previewMut.isPending && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-slate-500">
@ -281,6 +320,11 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
</div>
)}
<p className="rounded-lg bg-blue-50 px-3 py-2 text-xs text-blue-700">
Nhập <b> NCC</b> cho từng dòng cột "Mã". NCC chưa nhập NCC sẽ lưu trạng thái{' '}
<b>Nháp (n)</b>, chưa hiển thị ra ngoài.
</p>
<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">
@ -300,16 +344,24 @@ export function SupplierImportDialog({ open, onClose }: { open: boolean; onClose
<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">
<Input
value={r.code ?? ''}
onChange={e => updateRow(r.rowIndex, { code: e.target.value })}
placeholder="Mã NCC"
className="h-7 font-mono text-xs"
/>
</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',
'space-y-0.5 px-2 py-1.5',
r.status === RowImportStatus.Error ? 'text-red-600' : 'text-amber-600',
)}
>
{r.messages.length > 0 ? r.messages.join('; ') : ''}
{r.mstMissing && <div className="text-amber-600"> Chưa MST, thể bị trùng</div>}
{r.messages.length > 0 && <div>{r.messages.join('; ')}</div>}
</td>
</tr>
))}

View File

@ -2219,7 +2219,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
const qc = useQueryClient()
const suppliers = useQuery({
queryKey: ['all-suppliers'],
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000, published: true } })).data.items, // S113 R4: chỉ NCC đã công bố (ẩn nháp)
})
const [form, setForm] = useState({
supplierId: '',

View File

@ -318,7 +318,7 @@ function ContractHeaderForm({
const suppliers = useQuery({
queryKey: ['suppliers-all'],
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200, published: true } })).data.items, // S113 R4: chỉ NCC đã công bố (ẩn nháp → mã HĐ không dính Code rỗng)
})
const projects = useQuery({
queryKey: ['projects-all'],

View File

@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { EyeOff, Globe, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable'
@ -77,11 +77,24 @@ export function SuppliersPage() {
const [form, setForm] = useState<FormState>(emptyForm)
const isEdit = !!form.id
// Bộ lọc công bố: '' = tất cả · 'true' = đã công bố · 'false' = nháp (ẩn)
const [published, setPublished] = useState('')
// Dialog công bố: nhập Mã NCC trước khi NCC hiển thị ra ngoài
const [publishTarget, setPublishTarget] = useState<Supplier | null>(null)
const [maNcc, setMaNcc] = useState('')
const list = useQuery({
queryKey: ['suppliers', { page, search, sortBy, sortDesc }],
queryKey: ['suppliers', { page, search, sortBy, sortDesc, published }],
queryFn: async () => {
const res = await api.get<Paged<Supplier>>('/suppliers', {
params: { page, pageSize: 20, search: search || undefined, sortBy, sortDesc },
params: {
page,
pageSize: 20,
search: search || undefined,
sortBy,
sortDesc,
published: published === '' ? undefined : published === 'true',
},
})
return res.data
},
@ -143,6 +156,24 @@ export function SuppliersPage() {
onError: err => toast.error(getErrorMessage(err)),
})
// Công bố / ẩn NCC — KHÔNG qua UpdateSupplier (chỉ đổi Mã NCC + IsPublic).
const publish = useMutation({
mutationFn: async ({ id, maNcc, doPublish }: { id: string; maNcc?: string; doPublish: boolean }) =>
await api.post(`/suppliers/${id}/publish`, { maNcc: maNcc || undefined, publish: doPublish }),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ['suppliers'] })
toast.success(vars.doPublish ? 'Đã công bố NCC' : 'Đã ẩn NCC')
setPublishTarget(null)
setMaNcc('')
},
onError: err => toast.error(getErrorMessage(err)),
})
function openPublish(s: Supplier) {
setPublishTarget(s)
setMaNcc(s.code ?? '')
}
function openNew() {
setForm(emptyForm)
setOpen(true)
@ -177,13 +208,47 @@ export function SuppliersPage() {
{ key: 'type', header: 'Loại', sortable: true, width: 'w-36', render: s => SupplierTypeLabel[s.type] },
{ key: 'taxCode', header: 'MST', render: s => s.taxCode ?? '—' },
{ key: 'phone', header: 'Điện thoại', render: s => s.phone ?? '—' },
{
key: 'isPublic',
header: 'Trạng thái',
width: 'w-32',
render: s =>
s.isPublic ? (
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700 ring-1 ring-inset ring-emerald-600/20">
Đã công bố
</span>
) : (
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-600 ring-1 ring-inset ring-slate-500/20">
Nháp (n)
</span>
),
},
{
key: 'actions',
header: '',
align: 'right',
width: 'w-32',
width: 'w-40',
render: s => (
<div className="flex justify-end gap-1">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
{s.isPublic ? (
<Button
size="sm"
variant="ghost"
title="Ẩn NCC (bỏ công bố)"
onClick={() => {
if (confirm(`Ẩn NCC "${s.name}"? NCC sẽ không hiển thị ra ngoài.`))
publish.mutate({ id: s.id, doPublish: false })
}}
>
<EyeOff className="h-3.5 w-3.5 text-slate-500" />
</Button>
) : (
<Button size="sm" variant="ghost" title="Công bố NCC" onClick={() => openPublish(s)}>
<Globe className="h-3.5 w-3.5 text-emerald-600" />
</Button>
)}
</PermissionGuard>
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
<Button size="sm" variant="ghost" onClick={() => openEdit(s)}>
<Pencil className="h-3.5 w-3.5" />
@ -212,7 +277,7 @@ export function SuppliersPage() {
description="Quản lý NCC / Thầu phụ / Tổ đội / Đơn vị dịch vụ / Chủ đầu tư"
actions={
<div className="flex items-center gap-2">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Create">
<PermissionGuard menuKey={MenuKeys.Suppliers} action="Update">
<Button variant="outline" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4" />
Import Excel NCC
@ -238,6 +303,18 @@ export function SuppliersPage() {
}}
className="max-w-sm"
/>
<Select
value={published}
onChange={e => {
setPublished(e.target.value)
setPage(1)
}}
className="max-w-[190px]"
>
<option value="">Tất cả trạng thái</option>
<option value="true">Đã công bố</option>
<option value="false">Nháp (n)</option>
</Select>
</div>
<DataTable
@ -424,6 +501,40 @@ export function SuppliersPage() {
</form>
</Dialog>
<Dialog
open={publishTarget !== null}
onClose={() => setPublishTarget(null)}
title="Công bố nhà cung cấp"
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setPublishTarget(null)}>
Hủy
</Button>
<Button
onClick={() =>
publishTarget && publish.mutate({ id: publishTarget.id, maNcc: maNcc.trim(), doPublish: true })
}
disabled={!maNcc.trim() || publish.isPending}
>
{publish.isPending ? 'Đang công bố…' : 'Công bố'}
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm text-slate-600">
Nhập <b> NCC</b> đ công bố "{publishTarget?.name}". Sau khi công bố, NCC sẽ hiển thị các màn chọn
NCC (duyệt NCC, hợp đng).
</p>
<div className="space-y-1.5">
<Label> NCC *</Label>
<Input value={maNcc} onChange={e => setMaNcc(e.target.value)} placeholder="VD: NCC001" autoFocus />
</div>
<p className="text-xs text-slate-400">NCC chưa NCC sẽ trạng thái Nháp (n), chưa hiển thị ra ngoài.</p>
</div>
</Dialog>
<SupplierImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
</div>
)

View File

@ -75,6 +75,8 @@ export type Supplier = {
ownerPmh: string | null
// Tình trạng
status: SupplierStatus | null
// Công bố: true = hiển thị ra ngoài (picker NCC), false = nháp/ẩn (S113)
isPublic: boolean
createdAt: string
updatedAt: string | null
}

View File

@ -5,9 +5,11 @@ 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.PublishSupplier;
using SolutionErp.Application.Master.Suppliers.Commands.UpdateSupplier;
using SolutionErp.Application.Master.Suppliers.Dtos;
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplierImportTemplate;
using SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
using SolutionErp.Domain.Master;
@ -18,13 +20,14 @@ namespace SolutionErp.Api.Controllers;
[Authorize]
public class SuppliersController(IMediator mediator) : ControllerBase
{
// published: null = tất cả (admin quản lý thấy cả nháp) · true = chỉ NCC đã công bố (picker/consumer) · false = chỉ nháp.
[HttpGet]
public async Task<ActionResult<PagedResult<SupplierDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] string? sortBy = null, [FromQuery] bool sortDesc = true,
[FromQuery] SupplierType? type = null,
[FromQuery] SupplierType? type = null, [FromQuery] bool? published = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListSuppliersQuery(type) { Page = page, PageSize = pageSize, Search = search, SortBy = sortBy, SortDesc = sortDesc }, ct));
=> Ok(await mediator.Send(new ListSuppliersQuery(type, published) { Page = page, PageSize = pageSize, Search = search, SortBy = sortBy, SortDesc = sortDesc }, ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<SupplierDto>> Get(Guid id, CancellationToken ct)
@ -59,9 +62,28 @@ public class SuppliersController(IMediator mediator) : ControllerBase
return NoContent();
}
// ========== Import Excel "Database NCC" (Supplier Phase B, Approach A — layout-locked) ==========
// [S113 D3] Công bố / ẩn 1 NCC (import v2). NHÁP (thiếu Mã NCC) → publish tay ở đây.
// KHÔNG dùng UpdateSupplierCommand (blind absolute-set → clobber #73). Body { maNcc?, publish }.
[Authorize(Policy = "Suppliers.Update")]
[HttpPost("{id:guid}/publish")]
public async Task<IActionResult> Publish(Guid id, [FromBody] PublishSupplierBody body, CancellationToken ct)
{
await mediator.Send(new PublishSupplierCommand(id, body.MaNcc, body.Publish), ct);
return NoContent();
}
// ========== Import Excel "Database NCC" (Supplier import v2 — layout-locked ROW 4, 30 cột) ==========
// [S113 D3] authz: policy quyền Suppliers Update (danh mục), khớp FE PermissionGuard (hết 403 non-admin).
// Tải file mẫu (.xlsx trống, 30 token header ROW 4). Single-source ExpectedHeaderTokens (0 drift).
[Authorize(Policy = "Suppliers.Update")]
[HttpGet("import/template")]
public async Task<IActionResult> ImportTemplate(CancellationToken ct)
=> File(await mediator.Send(new GetSupplierImportTemplateQuery(), ct),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Mau-Database-NCC.xlsx");
// Preview: parse + phân loại từng hàng (KHÔNG ghi DB). Header sai layout → LayoutValid=false.
[Authorize(Roles = "Admin,CatalogManager")]
[Authorize(Policy = "Suppliers.Update")]
[HttpPost("import/preview")]
[RequestSizeLimit(25_000_000)]
public async Task<ActionResult<SupplierImportPreviewDto>> ImportPreview(IFormFile file, CancellationToken ct)
@ -72,9 +94,9 @@ public class SuppliersController(IMediator mediator) : ControllerBase
return Ok(await mediator.Send(new SupplierImportPreviewCommand(stream), ct));
}
// Confirm: ALL-OR-NOTHING upsert (New → insert; existing case-insensitive-Code → fill-nulls).
// Confirm: ALL-OR-NOTHING upsert (dedup MST-primary + Code-backstop; blank Code = draft ẩn).
// Body JSON = { "rows": [ ... ] } (mảng SupplierImportRowDto round-trip từ preview).
[Authorize(Roles = "Admin,CatalogManager")]
[Authorize(Policy = "Suppliers.Update")]
[HttpPost("import/confirm")]
public async Task<ActionResult<SupplierImportResultDto>> ImportConfirm(
[FromBody] SupplierImportConfirmCommand cmd, CancellationToken ct)

View File

@ -112,6 +112,7 @@ public class CreateSupplierCommandHandler : IRequestHandler<CreateSupplierComman
ReferralSource = request.ReferralSource,
OwnerPmh = request.OwnerPmh,
Status = request.Status,
IsPublic = true, // S113: "Thêm NCC" tay luôn có Code (validator NotEmpty) → công bố ngay (R7 badge đúng + hiện trong picker); CHỈ import mới landing nháp
};
_db.Suppliers.Add(entity);
await _db.SaveChangesAsync(ct);

View File

@ -0,0 +1,57 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
namespace SolutionErp.Application.Master.Suppliers.Commands.PublishSupplier;
/// <summary>
/// Công bố / ẩn 1 NCC (Supplier import v2, S113). KHÔNG dùng UpdateSupplierCommand (blind absolute-set
/// mọi field → clobber data nháp, gotcha #73). Chỉ set Mã NCC (nếu gửi) + IsPublic.
/// <para>Gate D2: publish=true bắt buộc Code non-empty (thiếu Mã NCC → không công bố được).</para>
/// </summary>
/// <param name="Id">Supplier cần công bố/ẩn.</param>
/// <param name="MaNcc">Mã NCC mới (null = giữ nguyên Code hiện tại). Nếu gửi non-empty → unique-CI-check.</param>
/// <param name="Publish">true = công bố · false = ẩn (đưa về nháp).</param>
public sealed record PublishSupplierCommand(Guid Id, string? MaNcc, bool Publish) : IRequest;
/// <summary>Body cho endpoint POST /suppliers/{id}/publish (Id lấy từ route).</summary>
public sealed record PublishSupplierBody(string? MaNcc, bool Publish);
public sealed class PublishSupplierCommandValidator : AbstractValidator<PublishSupplierCommand>
{
public PublishSupplierCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.MaNcc).MaximumLength(50); // khớp Supplier.Code HasMaxLength(50)
}
}
public sealed class PublishSupplierCommandHandler(IApplicationDbContext db)
: IRequestHandler<PublishSupplierCommand>
{
public async Task Handle(PublishSupplierCommand request, CancellationToken ct)
{
var entity = await db.Suppliers.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Supplier", request.Id);
// Đặt Mã NCC nếu gửi (blank = xóa Code → ẩn). Unique-CI-check chỉ khi non-empty
// (Code="" bị loại khỏi filtered-unique index nên không cần check).
if (request.MaNcc is not null)
{
var newCode = request.MaNcc.Trim();
if (newCode.Length > 0 && newCode != entity.Code &&
await db.Suppliers.AnyAsync(x => x.Code == newCode && x.Id != entity.Id, ct))
throw new ConflictException($"Mã NCC '{newCode}' đã tồn tại.");
entity.Code = newCode;
}
// Gate D2: công bố bắt buộc có Mã NCC.
if (request.Publish && string.IsNullOrWhiteSpace(entity.Code))
throw new ConflictException("Cần Mã NCC để công bố nhà cung cấp.");
entity.IsPublic = request.Publish;
await db.SaveChangesAsync(ct);
}
}

View File

@ -35,4 +35,6 @@ public record SupplierDto(
DateTime? SourceUpdatedAt,
string? SourceUpdatedBy,
DateTime CreatedAt,
DateTime? UpdatedAt);
DateTime? UpdatedAt,
// Publish state (Mig 64, S113) — append CUỐI positional record. false = nháp/ẩn (thiếu Mã NCC).
bool IsPublic);

View File

@ -11,10 +11,10 @@ namespace SolutionErp.Application.Master.Suppliers.Dtos;
/// <summary>Phân loại mỗi hàng sau khi preview classify.</summary>
public enum RowImportStatus
{
New = 0, // Code chưa trong DB (case-insensitive) → sẽ INSERT
Update = 1, // Code đã có (case-insensitive) → sẽ FILL-NULLS (không đè non-null)
New = 0, // MST + Code đều chưa match trong DB → sẽ INSERT (blank Code = draft ẩn)
Update = 1, // match theo MST (ưu tiên) hoặc Code → 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
Error = 3, // thiếu Name → không import được (v2: blank Code KHÔNG còn là lỗi, = draft hợp lệ)
}
/// <summary>
@ -62,6 +62,7 @@ public sealed record SupplierImportRowDto
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")
public bool MstMissing { get; set; } // true khi cột 12 (MST) rỗng — cảnh báo mềm (không chặn), FE render ⚠️
}
/// <summary>Kết quả preview (KHÔNG ghi DB). LayoutValid=false khi header-fingerprint không khớp.</summary>
@ -82,6 +83,7 @@ public sealed record SupplierImportResultDto
public int Inserted { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public int DraftCount { get; set; } // số NCC MỚI lưu nháp (blank Code → IsPublic=false) trong lần import này
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

@ -16,9 +16,16 @@ public interface ISupplierExcelImportService
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).
/// ALL-OR-NOTHING: re-validate; nếu có hard-error (thiếu Name) → trả Errors, KHÔNG ghi gì.
/// Ngược lại upsert (dedup MST-primary + Code-backstop; New → insert, blank Code = draft IsPublic=false;
/// match → 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);
/// <summary>
/// Sinh file .xlsx mẫu trống đúng layout (30 token header vào ROW 4, freeze row 4) từ
/// single-source ExpectedHeaderTokens → 0 drift với validator. Người dùng tải về, điền, upload lại.
/// </summary>
byte[] BuildTemplate();
}

View File

@ -23,6 +23,6 @@ public class GetSupplierQueryHandler : IRequestHandler<GetSupplierQuery, Supplie
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);
x.CreatedAt, x.UpdatedAt, x.IsPublic);
}
}

View File

@ -0,0 +1,17 @@
using MediatR;
using SolutionErp.Application.Master.Suppliers.Import;
namespace SolutionErp.Application.Master.Suppliers.Queries.GetSupplierImportTemplate;
/// <summary>
/// Tải file .xlsx mẫu "Database NCC" (30 cột, header ROW 4) — Supplier import v2 (S113).
/// Bytes sinh từ ISupplierExcelImportService.BuildTemplate() (single-source token, 0 drift).
/// </summary>
public sealed record GetSupplierImportTemplateQuery : IRequest<byte[]>;
public sealed class GetSupplierImportTemplateQueryHandler(ISupplierExcelImportService importService)
: IRequestHandler<GetSupplierImportTemplateQuery, byte[]>
{
public Task<byte[]> Handle(GetSupplierImportTemplateQuery request, CancellationToken ct)
=> Task.FromResult(importService.BuildTemplate());
}

View File

@ -7,7 +7,8 @@ using SolutionErp.Domain.Master;
namespace SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
public record ListSuppliersQuery(SupplierType? Type = null) : PagedRequest, IRequest<PagedResult<SupplierDto>>;
// Published: null = tất cả (admin quản lý, thấy cả nháp) · true = chỉ NCC đã công bố (picker/consumer) · false = chỉ nháp.
public record ListSuppliersQuery(SupplierType? Type = null, bool? Published = null) : PagedRequest, IRequest<PagedResult<SupplierDto>>;
public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, PagedResult<SupplierDto>>
{
@ -22,6 +23,12 @@ public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, Pag
if (request.Type is not null)
query = query.Where(x => x.Type == request.Type);
if (request.Published is not null)
{
var pub = request.Published.Value; // local → EF dịch được (tránh nullable bool trong expr)
query = query.Where(x => x.IsPublic == pub);
}
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
@ -51,7 +58,7 @@ public class ListSuppliersQueryHandler : IRequestHandler<ListSuppliersQuery, Pag
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))
x.CreatedAt, x.UpdatedAt, x.IsPublic))
.ToListAsync(ct);
return new PagedResult<SupplierDto>(items, total, request.Page, request.PageSize);

View File

@ -38,4 +38,9 @@ public class Supplier : AuditableEntity
// 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
// ---- Publish state (Supplier import v2, Mig 64 — S113) ----
// Cổng công bố: NCC nhập từ import thiếu Mã NCC → lưu NHÁP (IsPublic=false, ẩn khỏi picker).
// Publish qua PublishSupplierCommand (gate Code non-empty). KHÁC SupplierStatus (business-status c27).
public bool IsPublic { get; set; } // default false; backfill prod = true (Mig 64)
}

View File

@ -44,7 +44,13 @@ public class SupplierConfiguration : IEntityTypeConfiguration<Supplier>
// 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
// Publish state (Mig 64, S113) — bool NOT NULL default false. Code IsRequired giữ, nhưng "" hợp lệ
// cho draft-row (import thiếu Mã NCC) → unique-index đổi filter loại "" (bên dưới).
b.Property(x => x.IsPublic).HasDefaultValue(false);
// Mig 64: unique Code filtered `[IsDeleted]=0 AND [Code]<>''` — draft-row Code="" coexist, chỉ
// enforce unique cho Code non-empty (đã publish/publish-able). Was `[IsDeleted]=0` (Mig 47).
b.HasIndex(x => x.Code).IsUnique().HasFilter("[IsDeleted] = 0 AND [Code] <> ''");
b.HasIndex(x => x.Type);
b.HasQueryFilter(x => !x.IsDeleted);

View File

@ -2543,6 +2543,7 @@ public static class DbInitializer
foreach (var s in suppliersToSeed)
{
if (existingSupplierCodes.Contains(s.Code)) continue;
s.IsPublic = true; // Mig 64 (S113) — sample NCC đều có Code → công bố
db.Suppliers.Add(s);
addedSuppliers++;
}
@ -2811,9 +2812,11 @@ public static class DbInitializer
if (e.ReferralSource is null && s.ReferralSource is not null) { e.ReferralSource = s.ReferralSource; changed = true; }
if (e.OwnerPmh is null && s.OwnerPmh is not null) { e.OwnerPmh = s.OwnerPmh; changed = true; }
if (e.Status is null && s.Status is not null) { e.Status = s.Status; changed = true; }
if (!e.IsPublic) { e.IsPublic = true; changed = true; } // Mig 64 (S113) — 4 NCC real live, luôn công bố
if (changed) filledSuppliers++;
continue;
}
s.IsPublic = true; // Mig 64 (S113) — 4 NCC real đều có Code → công bố
db.Suppliers.Add(s);
addedSuppliers++;
}

View File

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddSupplierPublishState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Suppliers_Code",
table: "Suppliers");
migrationBuilder.AddColumn<bool>(
name: "IsPublic",
table: "Suppliers",
type: "bit",
nullable: false,
defaultValue: false);
// Backfill: NCC hiện có (22 prod) đang live — PE/Contract tham chiếu → công bố hết
// (nếu để nháp sẽ ẩn NCC đang dùng + vỡ picker). Idempotent (set 1 mọi hàng).
migrationBuilder.Sql("UPDATE Suppliers SET IsPublic = 1;");
migrationBuilder.CreateIndex(
name: "IX_Suppliers_Code",
table: "Suppliers",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0 AND [Code] <> ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Suppliers_Code",
table: "Suppliers");
migrationBuilder.DropColumn(
name: "IsPublic",
table: "Suppliers");
migrationBuilder.CreateIndex(
name: "IX_Suppliers_Code",
table: "Suppliers",
column: "Code",
unique: true,
filter: "[IsDeleted] = 0");
}
}
}

View File

@ -3266,6 +3266,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsPublic")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<string>("LegalRepTitle")
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
@ -3354,7 +3359,7 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasIndex("Code")
.IsUnique()
.HasFilter("[IsDeleted] = 0");
.HasFilter("[IsDeleted] = 0 AND [Code] <> ''");
b.HasIndex("Type");

View File

@ -32,38 +32,40 @@ public sealed class SupplierExcelImportService(
// 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.
// 30 token row-4 BYTE-EXACT theo file "Database NCC" thật (S113 re-bake, spec §②). Giữ NGUYÊN
// ký tự \n trong ô + trailing-space (c7) → single-source cho CẢ validator (NormalizeHeader collapse
// \n→space) LẪN BuildTemplate() (ghi \n vào cell = header xuống dòng giống hệt file gốc, 0 drift).
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
"PHÂN LOẠI\n(NTP/NCC/Cả hai)", // 3 → Type
"TÊN VIẾT TẮT\n(Dùng trong HĐ)", // 4 → Code (upsert key)
"TÊN CÔNG TY\n(Đầy đủ, đúng pháp lý)", // 5 → Name
"ĐỊA CHỈ XUẤT HÓA ĐƠN\n(Địa chỉ đăng ký kinh doanh)", // 6 → Address
"ĐỊA CHỈ VĂN PHÒNG \n(nếu có)", // 7 → OfficeAddress (trailing-space trước \n)
"SỐ ĐIỆN THOẠI\nCÔ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
"SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG\n(Đầy đủ, đúng pháp lý)", // 10 → BankAccount
"SỐ TK PHỤ\n(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
"NGƯỜI ĐẠI DIỆN\nPHÁP LUẬT", // 13 → LegalRepresentative
"CHỨC VỤ\nĐẠI DIỆN", // 14 → LegalRepTitle
"GIẤY ỦY QUYỀN\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
"NGƯỜI LIÊN HỆ\nCHÍNH", // 19 → ContactPerson
"CHỨC VỤ\nNGƯỜ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 NHẬN THƯ/\nSDT", // 24 → MailRecipient
"NGUỒN\nGIỚI THIỆU", // 25 → ReferralSource
"NGƯỜI PHỤ TRÁCH\n(PMH)", // 26 → OwnerPmh
"TÌNH TRẠNG\nHIỆN TẠI", // 27 → Status
"GHI CHÚ / LÝ DO\nBLACKLIST", // 28 → Note
"NGÀY CẬP NHẬT\nCUỐI", // 29 → SourceUpdatedAt
"NGƯỜI CẬP NHẬT", // 30 → SourceUpdatedBy
};
@ -90,28 +92,44 @@ public sealed class SupplierExcelImportService(
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.
// Load TẤT CẢ NCC hiện có → 2 CI dict (MST + Code). Dataset nhỏ ~vài chục, collation-independent.
var existing = await db.Suppliers.AsNoTracking().ToListAsync(ct);
var existingByCode = BuildCiIndex(existing);
var existingByMst = BuildMstIndex(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)
// v2: CHỈ Name bắt buộc. Blank Code KHÔNG còn là lỗi (= draft ẩn, publish tay sau).
if (string.IsNullOrWhiteSpace(row.Name))
{
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.");
row.Messages.Add("Thiếu 'Tên nhà cung cấp' (Name) — bắt buộc.");
continue;
}
if (existingByCode.TryGetValue(codeKey, out var ex))
var mstKey = NormalizeMst(row.TaxCode);
if (mstKey is null)
{
row.MstMissing = true; // cảnh báo MỀM — KHÔNG chặn (R6 mềm)
row.Messages.Add("⚠️ Chưa có MST — không kiểm tra được trùng, có thể bị trùng.");
}
var codeKey = string.IsNullOrWhiteSpace(row.Code) ? null : row.Code!.Trim();
if (codeKey is null)
row.Messages.Add("Chưa nhập Mã NCC — sẽ lưu nháp (ẩn), công bố sau.");
// Dedup precedence: MST (ưu tiên) → Code (backstop) → New.
if (mstKey is not null && existingByMst.TryGetValue(mstKey, out var exM))
{
row.Status = RowImportStatus.Update;
row.ExistingSupplierId = ex.Id;
row.ExistingSupplierId = exM.Id;
}
else if (codeKey is not null && existingByCode.TryGetValue(codeKey, out var exC))
{
row.Status = RowImportStatus.Update;
row.ExistingSupplierId = exC.Id;
}
else
{
@ -135,65 +153,93 @@ public sealed class SupplierExcelImportService(
{
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.
// Pass 1 — hard-error scan (CHỈ Name; v2 blank Code = draft hợp lệ). Hàng rỗng = skip. Bất kỳ lỗi → 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 (string.IsNullOrWhiteSpace(row.Name))
hardErrors.Add($"Dòng {row.RowIndex}: thiếu Tên nhà cung cấp.");
}
if (hardErrors.Count > 0)
{
result.Errors = hardErrors;
result.Committed = false; // commit nothing
result.Committed = false; // commit nothing (all-or-nothing)
return result;
}
// Load existing TRACKED (để fill-nulls mutate trực tiếp). Query filter loại IsDeleted.
// Load existing TRACKED (fill-nulls mutate trực tiếp). Query filter loại IsDeleted. 2 index: MST + Code.
var existing = await db.Suppliers.ToListAsync(ct);
var existingByCode = BuildCiIndex(existing);
var existingByMst = BuildMstIndex(existing);
var batchByCode = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var batchByMst = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
var countedUpdates = new HashSet<Supplier>(); // existing đã tính Updated → tránh đếm 2 lần khi 2 dòng cùng match
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();
NormalizeLengths(row); // safety-net: clamp maxlen (chống 500 nếu client bỏ qua preview)
var codeKey = string.IsNullOrWhiteSpace(row.Code) ? null : row.Code!.Trim();
var mstKey = NormalizeMst(row.TaxCode);
if (existingByCode.TryGetValue(key, out var ex))
// Resolve target theo precedence: existing-MST → existing-Code → batch-MST → batch-Code.
Supplier? target = null;
var isExisting = false;
if (mstKey is not null && existingByMst.TryGetValue(mstKey, out var exM)) { target = exM; isExisting = true; }
else if (codeKey is not null && existingByCode.TryGetValue(codeKey, out var exC)) { target = exC; isExisting = true; }
else if (mstKey is not null && batchByMst.TryGetValue(mstKey, out var bM)) { target = bM; }
else if (codeKey is not null && batchByCode.TryGetValue(codeKey, out var bC)) { target = bC; }
if (target is not null)
{
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;
FillNulls(target, row); // #73-safe: chỉ điền field đang null, KHÔNG đè non-null (không đụng IsPublic)
if (isExisting)
{
if (countedUpdates.Add(target)) result.Updated++;
else result.Skipped++; // existing đã tính Updated ở dòng trước → gộp, không đếm lại
}
if (batchByCode.TryGetValue(key, out var added))
{
FillNulls(added, row);
result.Skipped++; // Code trùng bản ghi vừa thêm trong batch
else result.Skipped++; // gộp vào bản ghi vừa thêm trong batch
continue;
}
// New. Blank Code (thiếu Mã NCC) → lưu NHÁP IsPublic=false (R4); có Code → public-able.
var entity = NewSupplier(row);
entity.IsPublic = !string.IsNullOrWhiteSpace(row.Code);
db.Suppliers.Add(entity);
batchByCode[key] = entity;
seen.Add(key);
if (codeKey is not null) batchByCode[codeKey] = entity;
if (mstKey is not null) batchByMst[mstKey] = entity; // blank-MST KHÔNG vào batchByMst → 2 blank-MST = 2 insert
result.Inserted++;
if (!entity.IsPublic) result.DraftCount++;
}
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);
"Supplier Excel import by {Actor}: inserted={Inserted}, updated={Updated}, skipped={Skipped}, draft={Draft}",
actor, result.Inserted, result.Updated, result.Skipped, result.DraftCount);
return result;
}
// ========================================================================
// TEMPLATE — .xlsx mẫu trống (30 token header ROW 4, freeze row 4). Single-source ExpectedHeaderTokens (0 drift).
// ========================================================================
public byte[] BuildTemplate()
{
using var wb = new XLWorkbook();
var ws = wb.Worksheets.Add("Sheet1");
for (int c = 1; c <= ColumnCount; c++)
ws.Cell(HeaderRow, c).Value = ExpectedHeaderTokens[c - 1]; // ROW 4; row 1-3 để trống (giống file gốc)
var header = ws.Row(HeaderRow);
header.Style.Font.Bold = true;
header.Style.Alignment.WrapText = true; // \n trong ô hiện xuống dòng
ws.SheetView.FreezeRows(HeaderRow);
using var ms = new MemoryStream();
wb.SaveAs(ms);
return ms.ToArray();
}
// ========================================================================
// PARSE
// ========================================================================
@ -413,7 +459,7 @@ public sealed class SupplierExcelImportService(
private static Supplier NewSupplier(SupplierImportRowDto r) => new()
{
Code = r.Code!.Trim(), // store trimmed, giữ hoa/thường gốc để hiển thị
Code = (r.Code ?? string.Empty).Trim(), // null-safe (S113): ô Mã NCC trống → parser null → "" (nháp R4), tránh NRE-abort-batch; hoa/thường gốc giữ
Name = r.Name!,
Type = r.Type,
TaxCode = r.TaxCode,
@ -450,12 +496,34 @@ public sealed class SupplierExcelImportService(
foreach (var s in suppliers)
{
var key = s.Code?.Trim();
if (string.IsNullOrEmpty(key)) continue;
if (string.IsNullOrEmpty(key)) continue; // Code="" (draft) KHÔNG index → không dedup theo Code rỗng
dict[key] = s; // last-wins; dup CI không xảy ra do filtered-unique index
}
return dict;
}
// MST index cho dedup MST-primary (R5). NormalizeMst = Trim + bỏ MỌI whitespace nội, CI.
// Blank MST → KHÔNG index (2 NCC không-MST không coi là trùng nhau).
private static Dictionary<string, Supplier> BuildMstIndex(IEnumerable<Supplier> suppliers)
{
var dict = new Dictionary<string, Supplier>(StringComparer.OrdinalIgnoreCase);
foreach (var s in suppliers)
{
var key = NormalizeMst(s.TaxCode);
if (key is null) continue;
dict[key] = s; // last-wins (TaxCode KHÔNG có unique constraint — dup hiếm, không crash)
}
return dict;
}
// Chuẩn hóa MST: "0312 251 859" → "0312251859" (khớp dù file ghi có/không dấu cách). null nếu rỗng.
private static string? NormalizeMst(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var stripped = Regex.Replace(raw, @"\s+", "");
return stripped.Length == 0 ? null : stripped;
}
private static bool IsRowEmpty(SupplierImportRowDto r) =>
string.IsNullOrWhiteSpace(r.Code) && string.IsNullOrWhiteSpace(r.Name) &&
string.IsNullOrWhiteSpace(r.PackageCategory) && string.IsNullOrWhiteSpace(r.Address) &&

View File

@ -0,0 +1,155 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Master.Suppliers.Commands.PublishSupplier;
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
using SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// ============================================================================
// Supplier import v2 (S113) — PublishSupplierCommand + ListSuppliers/GetSupplier IsPublic.
// Publish-gate = SECURITY/correctness (cổng công bố NCC) → test-before-merge (docs/rules.md §7).
// Test theo CODE trên đĩa (S34) — handlers chỉ cần IApplicationDbContext → new trực tiếp với fix.Db.
//
// 🔑 PublishSupplierCommand (KHÔNG dùng UpdateSupplier, tránh #73 clobber):
// - MaNcc gửi (non-null): set Code=Trim; unique-check `x.Code == newCode && x.Id != id` (khi len>0
// & khác Code cũ) → ConflictException nếu trùng.
// - Gate D2: Publish=true ∧ Code rỗng → ConflictException "Cần Mã NCC...".
// - IsPublic = Publish.
//
// ⚠️ Unique-check publish dùng `x.Code == newCode` (DB collation), KHÁC import service
// (OrdinalIgnoreCase in-mem). SQLite BINARY → chỉ bắt trùng ĐÚNG hoa/thường. Prod SQL Server
// CI-collation mới bắt CI-dup. Test T9b dùng trùng ĐÚNG-case (deterministic trên SQLite).
// (Observation, KHÔNG phải bug — prod collation CI xử lý; flag để biết asymmetry.)
// ============================================================================
public class SupplierPublishAndListTests
{
private static Supplier NewSupplier(string code, string name, bool isPublic,
SupplierType type = SupplierType.NhaCungCap) =>
new() { Id = Guid.NewGuid(), Code = code, Name = name, Type = type, IsPublic = isPublic };
// ---- T9 — publish có Mã NCC → set Code + IsPublic=true ----
[Fact]
public async Task PublishSupplier_WithMaNcc_SetsCode_AndIsPublicTrue()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var draft = NewSupplier("", "Draft NCC", isPublic: false); // nháp, chưa có Mã NCC
db.Suppliers.Add(draft);
await db.SaveChangesAsync(CancellationToken.None);
await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(draft.Id, MaNcc: "NEW01", Publish: true), CancellationToken.None);
var reloaded = await db.Suppliers.SingleAsync(x => x.Id == draft.Id);
reloaded.Code.Should().Be("NEW01", "MaNcc gửi → set Code");
reloaded.IsPublic.Should().BeTrue("Publish=true + có Code → công bố");
}
// ---- T9b — publish với Mã NCC trùng (đúng hoa/thường) NCC khác → ConflictException ----
[Fact]
public async Task PublishSupplier_MaNccCollidesExactCaseWithOther_ThrowsConflict_NoChange()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var other = NewSupplier("TAKEN", "Other", isPublic: true);
var draft = NewSupplier("", "Draft", isPublic: false);
db.Suppliers.AddRange(other, draft);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(draft.Id, MaNcc: "TAKEN", Publish: true), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*đã tồn tại*", "Mã NCC trùng NCC khác → chặn (unique-check)");
var reloaded = await db.Suppliers.SingleAsync(x => x.Id == draft.Id);
reloaded.Code.Should().Be("", "throw trước khi gán → draft KHÔNG đổi");
reloaded.IsPublic.Should().BeFalse();
}
// ---- T10 — publish khi Code rỗng (không gửi MaNcc) → ConflictException "Cần Mã NCC" ----
[Fact]
public async Task PublishSupplier_PublishTrue_ButCodeEmpty_ThrowsConflict_CanMaNcc()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var draft = NewSupplier("", "Draft NCC", isPublic: false);
db.Suppliers.Add(draft);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(draft.Id, MaNcc: null, Publish: true), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Cần Mã NCC*", "gate D2: công bố bắt buộc có Mã NCC");
(await db.Suppliers.SingleAsync(x => x.Id == draft.Id)).IsPublic
.Should().BeFalse("gate chặn → vẫn nháp");
}
// ---- T11 — unpublish (Publish=false) LUÔN cho, IsPublic=false, giữ Code ----
[Fact]
public async Task PublishSupplier_Unpublish_AlwaysAllowed_SetsIsPublicFalse_KeepsCode()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pub = NewSupplier("KEEP", "Public NCC", isPublic: true);
db.Suppliers.Add(pub);
await db.SaveChangesAsync(CancellationToken.None);
await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(pub.Id, MaNcc: null, Publish: false), CancellationToken.None);
var reloaded = await db.Suppliers.SingleAsync(x => x.Id == pub.Id);
reloaded.IsPublic.Should().BeFalse("ẩn luôn được, KHÔNG cần Mã NCC");
reloaded.Code.Should().Be("KEEP", "unpublish KHÔNG đụng Code");
}
// ---- T12 — ListSuppliers Published filter: true / false / null=all ----
[Fact]
public async Task ListSuppliers_PublishedFilter_FiltersByIsPublic_NullReturnsAll()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
db.Suppliers.AddRange(
NewSupplier("P1", "Pub 1", isPublic: true),
NewSupplier("P2", "Pub 2", isPublic: true),
NewSupplier("", "Draft 1", isPublic: false));
await db.SaveChangesAsync(CancellationToken.None);
var handler = new ListSuppliersQueryHandler(db);
var published = await handler.Handle(new ListSuppliersQuery(Published: true) { PageSize = 100 }, CancellationToken.None);
published.Total.Should().Be(2, "Published=true → chỉ NCC đã công bố");
published.Items.Should().OnlyContain(x => x.IsPublic);
var drafts = await handler.Handle(new ListSuppliersQuery(Published: false) { PageSize = 100 }, CancellationToken.None);
drafts.Total.Should().Be(1, "Published=false → chỉ nháp");
drafts.Items.Should().OnlyContain(x => !x.IsPublic);
var all = await handler.Handle(new ListSuppliersQuery(Published: null) { PageSize = 100 }, CancellationToken.None);
all.Total.Should().Be(3, "null → tất cả (admin quản lý thấy cả nháp)");
}
// ---- T13 — SupplierDto.IsPublic project đúng (GetSupplier single-item projection) ----
[Fact]
public async Task GetSupplier_ProjectsIsPublic_ForBothPublicAndDraft()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pub = NewSupplier("G1", "Public", isPublic: true);
var draft = NewSupplier("", "Draft", isPublic: false);
db.Suppliers.AddRange(pub, draft);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new GetSupplierQueryHandler(db);
(await handler.Handle(new GetSupplierQuery(pub.Id), CancellationToken.None)).IsPublic
.Should().BeTrue("DTO project IsPublic=true cho NCC công bố");
(await handler.Handle(new GetSupplierQuery(draft.Id), CancellationToken.None)).IsPublic
.Should().BeFalse("DTO project IsPublic=false cho nháp");
}
}

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 import v2 (S113, owner anh Kiệt) — dedup MST-primary + Code-backstop,
// draft-on-import (IsPublic), all-or-nothing, re-baked header tokens.
// TEST-BEFORE-MERGE class: dedup = CRITICAL-ALGO (chống 500 unique-violation) +
// case-collation BUG-CLASS (S112 lesson). Test theo CODE trên đĩa (S34 single-source),
// KHÔNG sửa production — bug lộ ra → REPORT em-main.
//
// 🔑 Dedup precedence (SupplierExcelImportService.ConfirmAsync + PreviewAsync):
// existing-MST → existing-Code → batch-MST → batch-Code → New.
// - NormalizeMst = Trim + strip MỌI whitespace nội, OrdinalIgnoreCase. Blank MST → null
// (KHÔNG index) ⇒ 2 blank-MST KHÔNG coi là trùng nhau.
// - Code index OrdinalIgnoreCase, blank Code → không index. Update = FillNulls (#73-safe,
// KHÔNG đè non-null, KHÔNG đụng Code/Name/Type/IsPublic).
//
// ⚠️ SQLite fixture = BINARY collation (case-SENSITIVE). Dedup CI phải nằm ở SERVICE
// (in-memory OrdinalIgnoreCase), KHÔNG dựa DB. Test T5 (code-backstop CI) xanh = chứng
// minh service KHÔNG dựa DB collation (nếu dựa → prod SQL Server CI-unique nổ 500).
//
// ✅ FIXED S113 (NRE null-Code): NewSupplier cũ `Code = r.Code!.Trim()` NRE khi row.Code == null
// (path R4 "thiếu Mã NCC → nháp": parser sinh Code=null cho ô rỗng; preview classify New/draft).
// Fix em-main = `Code = (r.Code ?? "").Trim()` → null + "" đồng nhất → Code="" + IsPublic=false
// (draft). Test cuối `...NullCode_SavedAsDraft` chốt hành vi ĐÚNG (NotThrow + nháp).
// ============================================================================
public class SupplierImportV2DedupTests
{
private const int HeaderRow = 4;
private static SupplierExcelImportService NewService(TestApplicationDbContext db)
=> new(db, NullLogger<SupplierExcelImportService>.Instance);
private static SupplierImportRowDto Row(int idx, string? code, string name,
string? taxCode = null, string? phone = null) =>
new() { RowIndex = idx, Code = code, Name = name, TaxCode = taxCode, Phone = phone };
private static void SeedExisting(TestApplicationDbContext db, string code, string name,
string? taxCode = null, string? phone = null, bool isPublic = true)
{
db.Suppliers.Add(new Supplier
{
Id = Guid.NewGuid(), Code = code, Name = name, Type = SupplierType.NhaCungCap,
TaxCode = taxCode, Phone = phone, IsPublic = isPublic,
});
}
// ========================================================================
// T1 — dedup by MST-exact: Update existing, KHÔNG insert (dù Code khác).
// ========================================================================
[Fact]
public async Task ConfirmAsync_MstExactMatch_UpdatesExisting_NoInsert_EvenWhenCodeDiffers()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "EXA", name: "Existing A", taxCode: "0311111111", phone: null);
await db.SaveChangesAsync(CancellationToken.None);
// Row có Code KHÁC ("ZZZ") nhưng MST TRÙNG → dedup phải bắt theo MST.
var rows = new List<SupplierImportRowDto> { Row(5, "ZZZ", "Import row", taxCode: "0311111111", phone: "0909") };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeTrue();
result.Updated.Should().Be(1, "MST trùng → Update existing");
result.Inserted.Should().Be(0, "KHÔNG insert bản ghi thứ 2 (dedup MST-primary)");
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
var only = await db.Suppliers.SingleAsync();
only.Code.Should().Be("EXA", "FillNulls KHÔNG đụng Code — match qua MST, giữ Code gốc");
only.Phone.Should().Be("0909", "fill-null: Phone đang null → điền (chứng minh đúng row cũ bị update)");
}
// ========================================================================
// T2 — NormalizeMst: "0312 251 859" (có dấu cách) khớp "0312251859" trong DB.
// ========================================================================
[Fact]
public async Task ConfirmAsync_MstWithInnerSpaces_NormalizedMatch_UpdatesExisting()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "EXB", name: "Existing B", taxCode: "0312251859");
await db.SaveChangesAsync(CancellationToken.None);
var rows = new List<SupplierImportRowDto> { Row(5, "Y", "Import", taxCode: " 0312 251 859 ") };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Updated.Should().Be(1, "MST strip-whitespace → '0312251859' khớp → Update");
result.Inserted.Should().Be(0);
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
}
// ========================================================================
// T3 — 2 row đều blank-MST → 2 insert (KHÔNG merge). Blank MST không phải khóa dedup.
// ========================================================================
[Fact]
public async Task ConfirmAsync_TwoBlankMstRows_DistinctCode_InsertsBoth_NoMerge()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "AA", "Alpha", taxCode: null), // blank MST
Row(6, "BB", "Beta", taxCode: " "), // whitespace → NormalizeMst = null (blank)
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Inserted.Should().Be(2, "2 row blank-MST + Code khác nhau → KHÔNG gộp, insert cả 2");
result.Updated.Should().Be(0);
result.Skipped.Should().Be(0);
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(2,
"blank-MST KHÔNG vào batchByMst → 2 NCC không-MST KHÔNG bị coi trùng nhau");
}
// ========================================================================
// T4 — 2 row CÙNG MST non-blank trong 1 batch → 1 insert + row sau fill-nulls (skip).
// ========================================================================
[Fact]
public async Task ConfirmAsync_TwoRowsSameMstInBatch_SingleInsert_SecondFillsNulls()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "C1", "First", taxCode: "MST9", phone: null), // insert, Phone null
Row(6, "C2", "Second", taxCode: "MST9", phone: "0999"), // cùng MST → gộp vào row1
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Inserted.Should().Be(1, "cùng MST trong batch → chỉ 1 insert");
result.Skipped.Should().Be(1, "row thứ 2 gộp vào bản ghi vừa thêm (fill-nulls), KHÔNG đếm Updated");
result.Updated.Should().Be(0, "gộp trong batch (chưa persist) → không tính Updated");
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
var only = await db.Suppliers.SingleAsync();
only.Code.Should().Be("C1", "giữ Code của row đầu (FillNulls không đụng Code)");
only.Phone.Should().Be("0999", "row2 fill-null Phone vào bản ghi batch → chứng minh gộp thật");
}
// ========================================================================
// T5 — no-MST + Code khớp existing (CI) → Update-by-Code backstop (chống 500).
// SQLite BINARY: "backstop" != "BACKSTOP" ở DB → dedup phải ở SERVICE (OrdinalIgnoreCase).
// ========================================================================
[Fact]
public async Task ConfirmAsync_NoMst_CodeMatchesExistingCaseInsensitive_UpdatesByBackstop_NoInsert()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "BACKSTOP", name: "Existing", taxCode: null);
await db.SaveChangesAsync(CancellationToken.None);
var rows = new List<SupplierImportRowDto> { Row(5, "backstop", "Import", taxCode: null) };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Updated.Should().Be(1, "Code khớp CI (service OrdinalIgnoreCase) → Update backstop");
result.Inserted.Should().Be(0,
"KHÔNG insert 'backstop' cạnh 'BACKSTOP' — nếu dựa DB collation SQLite thì sẽ insert (prod SQL Server CI nổ 500)");
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
}
// ========================================================================
// T6 — MST→A nhưng Code→B (khác entity): MST-precedence WINS. Update A, B untouched.
// KHÔNG silent-clobber: FillNulls không đụng Code nên A.Code giữ nguyên; B không bị chạm.
// ========================================================================
[Fact]
public async Task ConfirmAsync_MstMatchesA_CodeMatchesB_MstPrecedenceWins_UpdatesA_BUntouched()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "AAA", name: "Alpha", taxCode: "MST-AAA", phone: null);
SeedExisting(db, code: "BBB", name: "Beta", taxCode: "MST-BBB", phone: null);
await db.SaveChangesAsync(CancellationToken.None);
// Row: Code 'bbb' (CI→B) nhưng MST 'MST-AAA' (→A). Precedence MST → target A.
var rows = new List<SupplierImportRowDto> { Row(5, "bbb", "Import", taxCode: "MST-AAA", phone: "P-NEW") };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Updated.Should().Be(1, "MST-precedence → chỉ A được Update");
result.Inserted.Should().Be(0, "không tạo entity mới");
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(2, "vẫn đúng 2 NCC (A + B)");
var a = await db.Suppliers.SingleAsync(x => x.TaxCode == "MST-AAA");
a.Code.Should().Be("AAA", "A.Code KHÔNG bị đổi thành 'bbb' — FillNulls không đụng Code (no silent-clobber)");
a.Phone.Should().Be("P-NEW", "A (khớp MST) nhận fill-null Phone");
var b = await db.Suppliers.SingleAsync(x => x.TaxCode == "MST-BBB");
b.Code.Should().Be("BBB", "B KHÔNG bị chạm dù Code row khớp B (MST thắng)");
b.Phone.Should().BeNull("B untouched — không nhận Phone của row");
}
// ========================================================================
// T6-preview — cùng kịch bản qua PreviewAsync: classify Update + trỏ ExistingSupplierId=A.
// ========================================================================
[Fact]
public async Task PreviewAsync_MstMatchesA_CodeMatchesB_ClassifiesUpdateTargetingA()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var aId = Guid.NewGuid();
db.Suppliers.Add(new Supplier { Id = aId, Code = "AAA", Name = "Alpha", Type = SupplierType.NhaCungCap, TaxCode = "MST-AAA", IsPublic = true });
db.Suppliers.Add(new Supplier { Id = Guid.NewGuid(), Code = "BBB", Name = "Beta", Type = SupplierType.NhaCungCap, TaxCode = "MST-BBB", IsPublic = true });
await db.SaveChangesAsync(CancellationToken.None);
using var stream = BuildWorkbook(ReflectExpectedHeaderTokens(), ws =>
{
ws.Cell(5, 4).Value = "bbb"; // Code → B
ws.Cell(5, 5).Value = "Import";
ws.Cell(5, 12).Value = "MST-AAA"; // MST → A
});
var preview = await NewService(db).PreviewAsync(stream, CancellationToken.None);
preview.LayoutValid.Should().BeTrue();
var row = preview.Rows.Single();
row.Status.Should().Be(RowImportStatus.Update, "MST-precedence → Update");
row.ExistingSupplierId.Should().Be(aId, "trỏ tới A (khớp MST), KHÔNG phải B (khớp Code)");
}
// ========================================================================
// T7 — MST→A ∧ Code→A (cùng entity): 1 Update, nhất quán, no conflict.
// ========================================================================
[Fact]
public async Task ConfirmAsync_MstAndCodeBothMatchSameEntity_SingleUpdate_NoInsert()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
SeedExisting(db, code: "SAME", name: "Existing", taxCode: "MST-S", phone: null);
await db.SaveChangesAsync(CancellationToken.None);
var rows = new List<SupplierImportRowDto> { Row(5, "same", "Import", taxCode: "MST-S", phone: "P7") };
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Updated.Should().Be(1);
result.Inserted.Should().Be(0);
(await db.Suppliers.IgnoreQueryFilters().CountAsync()).Should().Be(1);
(await db.Suppliers.SingleAsync()).Phone.Should().Be("P7");
}
// ========================================================================
// T8 — draft-on-import: blank Code → IsPublic=false (nháp) + DraftCount; có Code → IsPublic=true.
// Blank Code dùng "" (empty) — path được service HỖ TRỢ (null NREs, xem test bug bên dưới).
// ========================================================================
[Fact]
public async Task ConfirmAsync_BlankCodeRow_IsDraft_CodedRow_IsPublic()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "", "Draft NCC"), // blank Code → nháp
Row(6, "PUB1", "Public NCC"), // có Code → publish-able
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeTrue();
result.Inserted.Should().Be(2);
result.DraftCount.Should().Be(1, "chỉ row blank-Code tính là nháp");
var draft = await db.Suppliers.SingleAsync(x => x.Name == "Draft NCC");
draft.IsPublic.Should().BeFalse("thiếu Mã NCC → IsPublic=false (R4)");
var pub = await db.Suppliers.SingleAsync(x => x.Name == "Public NCC");
pub.IsPublic.Should().BeTrue("có Mã NCC → IsPublic=true");
}
// ========================================================================
// T14 — blank Code KHÔNG còn là hard-error (v2 đổi S112): batch commit, row thành nháp.
// ========================================================================
[Fact]
public async Task ConfirmAsync_BlankCode_IsNotHardError_CommitsAsDraft()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "", "Only-name NCC"), // KHÔNG Code, CÓ Name → hợp lệ (draft)
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeTrue("blank Code KHÔNG abort batch (v2)");
result.Errors.Should().BeEmpty();
result.Inserted.Should().Be(1);
(await db.Suppliers.SingleAsync()).IsPublic.Should().BeFalse();
}
// ========================================================================
// T14b — blank Name VẪN là hard-error → all-or-nothing abort (kể cả draft row hợp lệ cùng batch).
// ========================================================================
[Fact]
public async Task ConfirmAsync_BlankName_StillHardError_AbortsWholeBatch_IncludingValidDraftRow()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto>
{
Row(5, "", "Valid draft"), // hợp lệ (blank Code OK)
Row(6, "HASCODE", " "), // hard-error: thiếu Name
};
var result = await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
result.Committed.Should().BeFalse("1 hard-error (thiếu Name) → all-or-nothing abort");
result.Errors.Should().NotBeEmpty();
result.Inserted.Should().Be(0);
(await db.Suppliers.IgnoreQueryFilters().CountAsync())
.Should().Be(0, "kể cả 'Valid draft' cũng KHÔNG ghi (all-or-nothing)");
}
// ========================================================================
// T15 — ExpectedHeaderTokens re-baked = ĐÚNG 30 token; header dựng từ chính tokens (reflection)
// → LayoutValid=true. Real-file tokens (dạng \n→space) cũng LayoutValid=true (normalize collapse).
// ========================================================================
[Fact]
public async Task PreviewAsync_ReBakedExpectedTokens_ThirtyTokens_LayoutValidTrue_And_RealTokensAlsoValid()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var expected = ReflectExpectedHeaderTokens();
expected.Should().HaveCount(30, "re-bake phải đúng 30 token row-4");
// (a) header = chính ExpectedHeaderTokens (service phải chấp nhận file có header khớp hệt).
using var streamExpected = BuildWorkbook(expected, ws =>
{
ws.Cell(5, 4).Value = "RT-01";
ws.Cell(5, 5).Value = "NCC token thật";
});
var pExpected = await NewService(db).PreviewAsync(streamExpected, CancellationToken.None);
pExpected.LayoutValid.Should().BeTrue("header = ExpectedHeaderTokens → LayoutValid=true");
pExpected.Rows.Should().ContainSingle();
// (b) faithful real-file tokens (\n thay bằng space) — NormalizeHeader collapse \n → phải khớp.
var real = RealFileHeaderTokensSingleSpace();
NormalizeJoinFaithful(real).Should().Be(NormalizeJoinFaithful(expected),
"sau NormalizeHeader (collapse \\n/space + FormC), real == expected → re-bake khớp file thật");
using var streamReal = BuildWorkbook(real, ws =>
{
ws.Cell(5, 4).Value = "RT-02";
ws.Cell(5, 5).Value = "NCC real";
});
var pReal = await NewService(db).PreviewAsync(streamReal, CancellationToken.None);
pReal.LayoutValid.Should().BeTrue("real tokens (space form) normalize khớp → LayoutValid=true");
}
// ========================================================================
// ✅ FIXED S113 — new-row Code=null → lưu NHÁP, KHÔNG NRE.
// Path R4 "thiếu Mã NCC → nháp": parser sinh null cho ô cột-4 rỗng; preview classify New/draft.
// NewSupplier cũ `Code = r.Code!.Trim()` → NRE-abort-batch (500). Fix: `(r.Code ?? "").Trim()`.
// null + "" giờ đồng nhất → Code="" + IsPublic=false (draft, ẩn khỏi filtered-unique [Code]<>'').
// ========================================================================
[Fact]
public async Task ConfirmAsync_NewRow_NullCode_SavedAsDraft()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var rows = new List<SupplierImportRowDto> { Row(5, null, "NCC thiếu Mã NCC") };
var act = async () => await NewService(db).ConfirmAsync(rows, "tester", CancellationToken.None);
await act.Should().NotThrowAsync("S113 fix: Code=null → \"\" → nháp, KHÔNG còn NRE");
var saved = await db.Suppliers.IgnoreQueryFilters().SingleAsync();
saved.Code.Should().BeEmpty("null Code → \"\" sau trim");
saved.IsPublic.Should().BeFalse("thiếu Mã NCC → nháp/ẩn (R4)");
}
// ========================================================================
// Helpers
// ========================================================================
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)");
return (string[])f!.GetValue(null)!;
}
private static MemoryStream BuildWorkbook(string[] headerRow4, Action<IXLWorksheet> writeData)
{
var ms = new MemoryStream();
using (var wb = new XLWorkbook())
{
var ws = wb.AddWorksheet("Sheet1");
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;
}
// Faithful replicate SupplierExcelImportService.NormalizeHeader (INCLUDING .Normalize(FormC),
// khác test S112 cũ thiếu FormC) → so-khớp deterministic, không mis-predict.
private static string NormalizeJoinFaithful(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().Normalize(System.Text.NormalizationForm.FormC);
}
return string.Join(" | ", tokens.Select(Norm));
}
// 30 token row-4 file "Database NCC" thật — dạng single-space (\n gốc thay bằng space).
// NormalizeHeader collapse \n→space nên phải khớp ExpectedHeaderTokens (dạng \n) sau normalize.
private static string[] RealFileHeaderTokensSingleSpace() =>
[
"STT",
"GÓI THẦU",
"PHÂN LOẠI (NTP/NCC/Cả hai)",
"TÊN VIẾT TẮT (Dùng trong HĐ)",
"TÊN CÔNG TY (Đầy đủ, đúng pháp lý)",
"ĐỊA CHỈ XUẤT HÓA ĐƠN (Địa chỉ đăng ký kinh doanh)",
"ĐỊA CHỈ VĂN PHÒNG (nếu có)",
"SỐ ĐIỆN THOẠI CÔNG TY",
"FAX",
"SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG (Đầy đủ, đúng pháp lý)",
"SỐ TK PHỤ (nếu có)",
"MÃ SỐ THUẾ",
"NGƯỜI ĐẠI DIỆN PHÁP LUẬT",
"CHỨC VỤ ĐẠI DIỆN",
"GIẤY ỦY QUYỀN (số, ngày, người ủy quyền)",
"Link GUQ",
"Link GPKD",
"Link HSNL",
"NGƯỜI LIÊN HỆ CHÍNH",
"CHỨC VỤ NGƯỜI LH",
"SĐT CHÍNH",
"EMAIL",
"ĐỊA CHỈ GỬI THƯ",
"NGƯỜI NHẬN THƯ/ SDT",
"NGUỒN GIỚI THIỆU",
"NGƯỜI PHỤ TRÁCH (PMH)",
"TÌNH TRẠNG HIỆN TẠI",
"GHI CHÚ / LÝ DO BLACKLIST",
"NGÀY CẬP NHẬT CUỐI",
"NGƯỜI CẬP NHẬT",
];
}