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

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

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

View File

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

View File

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