[CLAUDE] PE: upload file dinh kem per-NCC (doi chieu bao gia)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m9s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m9s
User request: 'cho cac NCC 1,2 va 3 thi cho them cho upload file dinh
kem cho tung NCC de co the doi chieu'.
Entity PurchaseEvaluationAttachment + PurchaseEvaluationSupplierId nullable
da thiet ke san tu migration 12 — gio wire up BE + FE.
BE (Application/Api):
- PurchaseEvaluationAttachmentFeatures: Upload (multipart + supplierRowId
optional) + Download + Delete. Reuse IFileStorage + LocalFileStorage.
Validator 20MB + MIME whitelist (pdf/doc/docx/xls/xlsx/png/jpg/webp).
- Upload log vao PurchaseEvaluationChangelogs (Attachment + Insert).
- PurchaseEvaluationAttachmentDto + them field Attachments vao bundle.
- GetPurchaseEvaluationQueryHandler Include(x => x.Attachments) +
OrderByDescending(a => a.CreatedAt) projection.
- PurchaseEvaluationsController 3 endpoint:
POST /attachments (IFormFile + [FromForm] supplierRowId/purpose/note)
GET /attachments/{attId}/download (File stream)
DELETE /attachments/{attId}
- Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
FE (fe-admin + fe-user):
- Type PeAttachment + PeAttachmentPurpose/Label (QuoteDocument default)
- PeDetailBundle.attachments: PeAttachment[]
- SuppliersTab thay column Hien thi + Ghi chu bang column File dinh kem
(per-NCC upload + list N attachments + download + delete).
- SupplierAttachmentsCell component: <input type=file> hidden + [+ Them
file] button + inline list attachments voi Paperclip icon + filename
(click tai ve) + size + purpose chip + Trash2 delete.
This commit is contained in:
@ -2,11 +2,11 @@
|
||||
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
||||
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||
// → PeApprovalsSection + PeHistorySection).
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Check, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
@ -16,10 +16,13 @@ import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
PeAttachmentPurpose,
|
||||
PeAttachmentPurposeLabel,
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeAttachment,
|
||||
type PeChangelog,
|
||||
type PeDetailBundle,
|
||||
type PeDetailRow,
|
||||
@ -265,25 +268,33 @@ function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
|
||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">NCC</th>
|
||||
<th className="px-3 py-2 text-left">Hiển thị</th>
|
||||
<th className="px-3 py-2 text-left">Liên hệ</th>
|
||||
<th className="px-3 py-2 text-left">Điều khoản TT</th>
|
||||
<th className="px-3 py-2 text-left">Ghi chú</th>
|
||||
<th className="px-3 py-2 text-left">File đính kèm</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{ev.suppliers.map(s => (
|
||||
<tr key={s.id} className={cn(ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
||||
<td className="px-3 py-2 font-medium text-slate-900">{s.supplierName}</td>
|
||||
<td className="px-3 py-2">{s.displayName ?? '—'}</td>
|
||||
<tr key={s.id} className={cn('align-top', ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-slate-900">{s.supplierName}</div>
|
||||
{s.displayName && <div className="text-[11px] text-slate-500">{s.displayName}</div>}
|
||||
{s.note && <div className="mt-0.5 text-[11px] text-amber-600">{s.note}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[12px] text-slate-600">
|
||||
{s.contactName && <div>{s.contactName}</div>}
|
||||
{s.contactPhone && <div>{s.contactPhone}</div>}
|
||||
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-[12px] text-slate-600">{s.note ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<SupplierAttachmentsCell
|
||||
evaluationId={ev.id}
|
||||
supplierRowId={s.id}
|
||||
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
@ -709,3 +720,121 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) {
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Cell upload file đính kèm per-NCC =====
|
||||
// 1 row = 1 NCC. User upload file báo giá (purpose=QuoteDocument mặc định) →
|
||||
// POST multipart với supplierRowId. List N file hiện có + Download/Delete inline.
|
||||
// Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
|
||||
function SupplierAttachmentsCell({
|
||||
evaluationId,
|
||||
supplierRowId,
|
||||
attachments,
|
||||
}: {
|
||||
evaluationId: string
|
||||
supplierRowId: string
|
||||
attachments: PeAttachment[]
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('supplierRowId', supplierRowId)
|
||||
fd.append('purpose', String(PeAttachmentPurpose.QuoteDocument))
|
||||
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã tải lên.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: async (attId: string) =>
|
||||
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xóa.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
async function download(att: PeAttachment) {
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
const url = window.URL.createObjectURL(res.data as Blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = att.fileName
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e))
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) upload.mutate(f)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const fmtSize = (b: number) =>
|
||||
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{attachments.length === 0 && (
|
||||
<div className="text-[11px] italic text-slate-400">Chưa có file</div>
|
||||
)}
|
||||
{attachments.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
|
||||
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
|
||||
title={a.fileName}
|
||||
>
|
||||
{a.fileName}
|
||||
</button>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</span>
|
||||
<span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
|
||||
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
|
||||
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
||||
onChange={onPick}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={upload.isPending}
|
||||
className="inline-flex items-center gap-1 rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11px] text-slate-500 hover:border-brand-300 hover:text-brand-700 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
{upload.isPending ? 'Đang tải…' : '+ Thêm file'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -105,6 +105,32 @@ export type PeDetailRow = {
|
||||
quotes: PeQuote[]
|
||||
}
|
||||
|
||||
export type PeAttachment = {
|
||||
id: string
|
||||
purchaseEvaluationSupplierId: string | null
|
||||
fileName: string
|
||||
storagePath: string
|
||||
fileSize: number
|
||||
contentType: string
|
||||
purpose: number
|
||||
note: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const PeAttachmentPurpose = {
|
||||
QuoteDocument: 1,
|
||||
RequirementSpec: 2,
|
||||
DecisionExport: 3,
|
||||
Other: 99,
|
||||
} as const
|
||||
|
||||
export const PeAttachmentPurposeLabel: Record<number, string> = {
|
||||
1: 'Báo giá',
|
||||
2: 'Yêu cầu KT',
|
||||
3: 'Phiếu duyệt',
|
||||
99: 'Khác',
|
||||
}
|
||||
|
||||
export type PeApproval = {
|
||||
id: string
|
||||
fromPhase: number
|
||||
@ -161,5 +187,6 @@ export type PeDetailBundle = {
|
||||
suppliers: PeSupplier[]
|
||||
details: PeDetailRow[]
|
||||
approvals: PeApproval[]
|
||||
attachments: PeAttachment[]
|
||||
workflow: PeWorkflowSummary
|
||||
}
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
||||
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||
// → PeApprovalsSection + PeHistorySection).
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Check, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
@ -16,10 +16,13 @@ import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
PeAttachmentPurpose,
|
||||
PeAttachmentPurposeLabel,
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeAttachment,
|
||||
type PeChangelog,
|
||||
type PeDetailBundle,
|
||||
type PeDetailRow,
|
||||
@ -265,25 +268,33 @@ function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
|
||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">NCC</th>
|
||||
<th className="px-3 py-2 text-left">Hiển thị</th>
|
||||
<th className="px-3 py-2 text-left">Liên hệ</th>
|
||||
<th className="px-3 py-2 text-left">Điều khoản TT</th>
|
||||
<th className="px-3 py-2 text-left">Ghi chú</th>
|
||||
<th className="px-3 py-2 text-left">File đính kèm</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{ev.suppliers.map(s => (
|
||||
<tr key={s.id} className={cn(ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
||||
<td className="px-3 py-2 font-medium text-slate-900">{s.supplierName}</td>
|
||||
<td className="px-3 py-2">{s.displayName ?? '—'}</td>
|
||||
<tr key={s.id} className={cn('align-top', ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-slate-900">{s.supplierName}</div>
|
||||
{s.displayName && <div className="text-[11px] text-slate-500">{s.displayName}</div>}
|
||||
{s.note && <div className="mt-0.5 text-[11px] text-amber-600">{s.note}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[12px] text-slate-600">
|
||||
{s.contactName && <div>{s.contactName}</div>}
|
||||
{s.contactPhone && <div>{s.contactPhone}</div>}
|
||||
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-[12px] text-slate-600">{s.note ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<SupplierAttachmentsCell
|
||||
evaluationId={ev.id}
|
||||
supplierRowId={s.id}
|
||||
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
@ -709,3 +720,121 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) {
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Cell upload file đính kèm per-NCC =====
|
||||
// 1 row = 1 NCC. User upload file báo giá (purpose=QuoteDocument mặc định) →
|
||||
// POST multipart với supplierRowId. List N file hiện có + Download/Delete inline.
|
||||
// Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
|
||||
function SupplierAttachmentsCell({
|
||||
evaluationId,
|
||||
supplierRowId,
|
||||
attachments,
|
||||
}: {
|
||||
evaluationId: string
|
||||
supplierRowId: string
|
||||
attachments: PeAttachment[]
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('supplierRowId', supplierRowId)
|
||||
fd.append('purpose', String(PeAttachmentPurpose.QuoteDocument))
|
||||
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã tải lên.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: async (attId: string) =>
|
||||
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xóa.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
async function download(att: PeAttachment) {
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
const url = window.URL.createObjectURL(res.data as Blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = att.fileName
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e))
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) upload.mutate(f)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const fmtSize = (b: number) =>
|
||||
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{attachments.length === 0 && (
|
||||
<div className="text-[11px] italic text-slate-400">Chưa có file</div>
|
||||
)}
|
||||
{attachments.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
|
||||
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
|
||||
title={a.fileName}
|
||||
>
|
||||
{a.fileName}
|
||||
</button>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</span>
|
||||
<span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
|
||||
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
|
||||
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
||||
onChange={onPick}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={upload.isPending}
|
||||
className="inline-flex items-center gap-1 rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11px] text-slate-500 hover:border-brand-300 hover:text-brand-700 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
{upload.isPending ? 'Đang tải…' : '+ Thêm file'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -105,6 +105,32 @@ export type PeDetailRow = {
|
||||
quotes: PeQuote[]
|
||||
}
|
||||
|
||||
export type PeAttachment = {
|
||||
id: string
|
||||
purchaseEvaluationSupplierId: string | null
|
||||
fileName: string
|
||||
storagePath: string
|
||||
fileSize: number
|
||||
contentType: string
|
||||
purpose: number
|
||||
note: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const PeAttachmentPurpose = {
|
||||
QuoteDocument: 1,
|
||||
RequirementSpec: 2,
|
||||
DecisionExport: 3,
|
||||
Other: 99,
|
||||
} as const
|
||||
|
||||
export const PeAttachmentPurposeLabel: Record<number, string> = {
|
||||
1: 'Báo giá',
|
||||
2: 'Yêu cầu KT',
|
||||
3: 'Phiếu duyệt',
|
||||
99: 'Khác',
|
||||
}
|
||||
|
||||
export type PeApproval = {
|
||||
id: string
|
||||
fromPhase: number
|
||||
@ -161,5 +187,6 @@ export type PeDetailBundle = {
|
||||
suppliers: PeSupplier[]
|
||||
details: PeDetailRow[]
|
||||
approvals: PeApproval[]
|
||||
attachments: PeAttachment[]
|
||||
workflow: PeWorkflowSummary
|
||||
}
|
||||
|
||||
@ -150,6 +150,42 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<PurchaseEvaluationChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
|
||||
=> await mediator.Send(new ListPurchaseEvaluationChangelogsQuery(id), ct);
|
||||
|
||||
// ========== Attachments (per-supplier hoặc general) ==========
|
||||
|
||||
// Upload file đính kèm — gắn với NCC cụ thể (supplierRowId) hoặc phiếu tổng.
|
||||
[HttpPost("{id:guid}/attachments")]
|
||||
[RequestSizeLimit(25_000_000)]
|
||||
public async Task<ActionResult<PurchaseEvaluationAttachmentDto>> UploadAttachment(
|
||||
Guid id,
|
||||
IFormFile file,
|
||||
[FromForm] Guid? supplierRowId = null,
|
||||
[FromForm] PurchaseEvaluationAttachmentPurpose purpose = PurchaseEvaluationAttachmentPurpose.QuoteDocument,
|
||||
[FromForm] string? note = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { detail = "Chưa chọn file." });
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var dto = await mediator.Send(new UploadPurchaseEvaluationAttachmentCommand(
|
||||
id, supplierRowId, file.FileName, file.ContentType, file.Length, stream, purpose, note), ct);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/attachments/{attId:guid}/download")]
|
||||
public async Task<IActionResult> DownloadAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||
{
|
||||
var f = await mediator.Send(new DownloadPurchaseEvaluationAttachmentQuery(id, attId), ct);
|
||||
return File(f.Content, f.ContentType, f.FileName);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/attachments/{attId:guid}")]
|
||||
public async Task<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeletePurchaseEvaluationAttachmentCommand(id, attId), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ========== Kế thừa HĐ ==========
|
||||
|
||||
// List phiếu đã DaDuyet chưa gen HĐ — dùng cho modal "Tạo HĐ từ phiếu"
|
||||
|
||||
@ -83,6 +83,17 @@ public record PurchaseEvaluationWorkflowSummaryDto(
|
||||
List<PurchaseEvaluationPhase> ActivePhases,
|
||||
List<PurchaseEvaluationPhase> NextPhases);
|
||||
|
||||
public record PurchaseEvaluationAttachmentDto(
|
||||
Guid Id,
|
||||
Guid? PurchaseEvaluationSupplierId,
|
||||
string FileName,
|
||||
string StoragePath,
|
||||
long FileSize,
|
||||
string ContentType,
|
||||
PurchaseEvaluationAttachmentPurpose Purpose,
|
||||
string? Note,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record PurchaseEvaluationDetailBundleDto(
|
||||
Guid Id,
|
||||
string? MaPhieu,
|
||||
@ -107,4 +118,5 @@ public record PurchaseEvaluationDetailBundleDto(
|
||||
List<PurchaseEvaluationSupplierDto> Suppliers,
|
||||
List<PurchaseEvaluationDetailDto> Details,
|
||||
List<PurchaseEvaluationApprovalDto> Approvals,
|
||||
List<PurchaseEvaluationAttachmentDto> Attachments,
|
||||
PurchaseEvaluationWorkflowSummaryDto Workflow);
|
||||
|
||||
@ -0,0 +1,184 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.PurchaseEvaluations.Dtos;
|
||||
using SolutionErp.Domain.Contracts; // ChangelogAction
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||
|
||||
// Mirror ContractAttachmentFeatures. Key khác: `PurchaseEvaluationSupplierId`
|
||||
// nullable → upload file gắn với 1 NCC cụ thể (để đối chiếu báo giá) hoặc
|
||||
// tổng quan phiếu. Reuse IFileStorage + LocalFileStorage.
|
||||
|
||||
// ========== UPLOAD ==========
|
||||
|
||||
public record UploadPurchaseEvaluationAttachmentCommand(
|
||||
Guid PurchaseEvaluationId,
|
||||
Guid? PurchaseEvaluationSupplierId,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long FileSize,
|
||||
Stream Content,
|
||||
PurchaseEvaluationAttachmentPurpose Purpose,
|
||||
string? Note) : IRequest<PurchaseEvaluationAttachmentDto>;
|
||||
|
||||
public class UploadPurchaseEvaluationAttachmentCommandValidator
|
||||
: AbstractValidator<UploadPurchaseEvaluationAttachmentCommand>
|
||||
{
|
||||
private const long MaxBytes = 20L * 1024 * 1024;
|
||||
|
||||
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
};
|
||||
|
||||
public UploadPurchaseEvaluationAttachmentCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PurchaseEvaluationId).NotEmpty();
|
||||
RuleFor(x => x.FileName).NotEmpty().MaximumLength(255);
|
||||
RuleFor(x => x.FileSize).GreaterThan(0).LessThanOrEqualTo(MaxBytes)
|
||||
.WithMessage("File vượt quá 20 MB.");
|
||||
RuleFor(x => x.ContentType).Must(c => AllowedContentTypes.Contains(c))
|
||||
.WithMessage("Định dạng file không được hỗ trợ. Cho phép: pdf, doc(x), xls(x), png, jpg, webp.");
|
||||
RuleFor(x => x.Purpose).IsInEnum();
|
||||
RuleFor(x => x.Note).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
|
||||
public class UploadPurchaseEvaluationAttachmentCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
IFileStorage storage,
|
||||
ICurrentUser currentUser) : IRequestHandler<UploadPurchaseEvaluationAttachmentCommand, PurchaseEvaluationAttachmentDto>
|
||||
{
|
||||
public async Task<PurchaseEvaluationAttachmentDto> Handle(
|
||||
UploadPurchaseEvaluationAttachmentCommand request, CancellationToken ct)
|
||||
{
|
||||
var ev = await db.PurchaseEvaluations.FirstOrDefaultAsync(e => e.Id == request.PurchaseEvaluationId, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
||||
|
||||
// Verify supplier-row thuộc cùng phiếu (nếu gắn)
|
||||
if (request.PurchaseEvaluationSupplierId is Guid sid)
|
||||
{
|
||||
var supOk = await db.PurchaseEvaluationSuppliers
|
||||
.AnyAsync(s => s.Id == sid && s.PurchaseEvaluationId == ev.Id, ct);
|
||||
if (!supOk) throw new NotFoundException("PurchaseEvaluationSupplier", sid);
|
||||
}
|
||||
|
||||
var attId = Guid.NewGuid();
|
||||
var safeName = SanitizeFileName(request.FileName);
|
||||
var relativePath = $"purchase-evaluations/{ev.Id}/{attId}_{safeName}";
|
||||
|
||||
await storage.SaveAsync(relativePath, request.Content, ct);
|
||||
|
||||
var entity = new PurchaseEvaluationAttachment
|
||||
{
|
||||
Id = attId,
|
||||
PurchaseEvaluationId = ev.Id,
|
||||
PurchaseEvaluationSupplierId = request.PurchaseEvaluationSupplierId,
|
||||
FileName = request.FileName,
|
||||
StoragePath = relativePath,
|
||||
FileSize = request.FileSize,
|
||||
ContentType = request.ContentType,
|
||||
Purpose = request.Purpose,
|
||||
Note = request.Note,
|
||||
};
|
||||
db.PurchaseEvaluationAttachments.Add(entity);
|
||||
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = ev.Id,
|
||||
EntityType = PurchaseEvaluationEntityType.Attachment,
|
||||
EntityId = attId,
|
||||
Action = ChangelogAction.Insert,
|
||||
PhaseAtChange = ev.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
Summary = $"Tải lên file: {entity.FileName}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new PurchaseEvaluationAttachmentDto(
|
||||
entity.Id, entity.PurchaseEvaluationSupplierId, entity.FileName, entity.StoragePath,
|
||||
entity.FileSize, entity.ContentType, entity.Purpose, entity.Note, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
var baseName = Path.GetFileName(name);
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
baseName = baseName.Replace(c, '_');
|
||||
baseName = baseName.TrimStart('.');
|
||||
if (string.IsNullOrWhiteSpace(baseName)) baseName = "file";
|
||||
return baseName.Length > 200 ? baseName[^200..] : baseName;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== DOWNLOAD ==========
|
||||
|
||||
public record DownloadPurchaseEvaluationAttachmentQuery(Guid PurchaseEvaluationId, Guid AttachmentId)
|
||||
: IRequest<PurchaseEvaluationAttachmentFile>;
|
||||
|
||||
public record PurchaseEvaluationAttachmentFile(Stream Content, string FileName, string ContentType);
|
||||
|
||||
public class DownloadPurchaseEvaluationAttachmentQueryHandler(
|
||||
IApplicationDbContext db,
|
||||
IFileStorage storage) : IRequestHandler<DownloadPurchaseEvaluationAttachmentQuery, PurchaseEvaluationAttachmentFile>
|
||||
{
|
||||
public async Task<PurchaseEvaluationAttachmentFile> Handle(
|
||||
DownloadPurchaseEvaluationAttachmentQuery request, CancellationToken ct)
|
||||
{
|
||||
var att = await db.PurchaseEvaluationAttachments.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||
?? throw new NotFoundException("Attachment", request.AttachmentId);
|
||||
|
||||
var stream = await storage.OpenReadAsync(att.StoragePath, ct);
|
||||
return new PurchaseEvaluationAttachmentFile(stream, att.FileName, att.ContentType);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== DELETE ==========
|
||||
|
||||
public record DeletePurchaseEvaluationAttachmentCommand(Guid PurchaseEvaluationId, Guid AttachmentId) : IRequest;
|
||||
|
||||
public class DeletePurchaseEvaluationAttachmentCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
IFileStorage storage,
|
||||
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationAttachmentCommand>
|
||||
{
|
||||
public async Task Handle(DeletePurchaseEvaluationAttachmentCommand request, CancellationToken ct)
|
||||
{
|
||||
var att = await db.PurchaseEvaluationAttachments
|
||||
.FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||
?? throw new NotFoundException("Attachment", request.AttachmentId);
|
||||
var ev = await db.PurchaseEvaluations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == request.PurchaseEvaluationId, ct);
|
||||
|
||||
db.PurchaseEvaluationAttachments.Remove(att);
|
||||
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||
EntityType = PurchaseEvaluationEntityType.Attachment,
|
||||
EntityId = att.Id,
|
||||
Action = ChangelogAction.Delete,
|
||||
PhaseAtChange = ev?.Phase ?? PurchaseEvaluationPhase.DangSoanThao,
|
||||
UserId = currentUser.UserId,
|
||||
Summary = $"Xóa file: {att.FileName}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
try { await storage.DeleteAsync(att.StoragePath, ct); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
@ -312,6 +312,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
.Include(x => x.Suppliers)
|
||||
.Include(x => x.Details).ThenInclude(d => d.Quotes)
|
||||
.Include(x => x.Approvals)
|
||||
.Include(x => x.Attachments)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||
|
||||
@ -390,6 +391,12 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
a.ApproverUserId is Guid uid && users.TryGetValue(uid, out var an) ? an : null,
|
||||
a.Decision, a.Comment, a.ApprovedAt))
|
||||
.ToList(),
|
||||
e.Attachments
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Select(a => new PurchaseEvaluationAttachmentDto(
|
||||
a.Id, a.PurchaseEvaluationSupplierId, a.FileName, a.StoragePath,
|
||||
a.FileSize, a.ContentType, a.Purpose, a.Note, a.CreatedAt))
|
||||
.ToList(),
|
||||
new PurchaseEvaluationWorkflowSummaryDto(
|
||||
policy.Name, policy.Description,
|
||||
policy.ActivePhases.ToList(),
|
||||
|
||||
Reference in New Issue
Block a user