[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
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:
@ -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 có 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>Mã NCC</b> cho từng dòng ở cột "Mã". NCC chưa nhập Mã 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 có MST, có thể bị trùng</div>}
|
||||
{r.messages.length > 0 && <div>{r.messages.join('; ')}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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>Mã 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>Mã 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 có Mã 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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 có 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>Mã NCC</b> cho từng dòng ở cột "Mã". NCC chưa nhập Mã 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 có MST, có thể bị trùng</div>}
|
||||
{r.messages.length > 0 && <div>{r.messages.join('; ')}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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>Mã 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>Mã 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 có Mã 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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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 có 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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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++;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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) &&
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user