[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

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:
pqhuy1987
2026-04-24 12:44:08 +07:00
parent 68938a521a
commit d1090843a2
8 changed files with 567 additions and 16 deletions

View File

@ -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 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>
)
}

View File

@ -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
}

View File

@ -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 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>
)
}

View File

@ -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
}

View File

@ -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"

View File

@ -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);

View File

@ -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 */ }
}
}

View File

@ -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(),