From d1090843a2c79545f3171c8d0fee1ef5e67ec8b9 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 24 Apr 2026 12:44:08 +0700 Subject: [PATCH] [CLAUDE] PE: upload file dinh kem per-NCC (doi chieu bao gia) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: hidden + [+ Them file] button + inline list attachments voi Paperclip icon + filename (click tai ve) + size + purpose chip + Trash2 delete. --- fe-admin/src/components/pe/PeDetailTabs.tsx | 145 +++++++++++++- fe-admin/src/types/purchaseEvaluation.ts | 27 +++ fe-user/src/components/pe/PeDetailTabs.tsx | 145 +++++++++++++- fe-user/src/types/purchaseEvaluation.ts | 27 +++ .../PurchaseEvaluationsController.cs | 36 ++++ .../Dtos/PurchaseEvaluationDtos.cs | 12 ++ .../PurchaseEvaluationAttachmentFeatures.cs | 184 ++++++++++++++++++ .../PurchaseEvaluationFeatures.cs | 7 + 8 files changed, 567 insertions(+), 16 deletions(-) create mode 100644 src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationAttachmentFeatures.cs diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index ec92f04..a6c9e41 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -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 }) { NCC - Hiển thị Liên hệ Điều khoản TT - Ghi chú + File đính kèm {ev.suppliers.map(s => ( - - {s.supplierName} - {s.displayName ?? '—'} + + +
{s.supplierName}
+ {s.displayName &&
{s.displayName}
} + {s.note &&
{s.note}
} + {s.contactName &&
{s.contactName}
} {s.contactPhone &&
{s.contactPhone}
} {s.contactEmail &&
{s.contactEmail}
} {s.paymentTermText ?? '—'} - {s.note ?? '—'} + + a.purchaseEvaluationSupplierId === s.id)} + /> +
+ {fmtSize(a.fileSize)} + + {PeAttachmentPurposeLabel[a.purpose] ?? ''} + + +
+ ))} +
+ + +
+ + ) +} diff --git a/fe-admin/src/types/purchaseEvaluation.ts b/fe-admin/src/types/purchaseEvaluation.ts index 9bfa481..0db568b 100644 --- a/fe-admin/src/types/purchaseEvaluation.ts +++ b/fe-admin/src/types/purchaseEvaluation.ts @@ -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 = { + 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 } diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index ec92f04..a6c9e41 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -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 }) { NCC - Hiển thị Liên hệ Điều khoản TT - Ghi chú + File đính kèm {ev.suppliers.map(s => ( - - {s.supplierName} - {s.displayName ?? '—'} + + +
{s.supplierName}
+ {s.displayName &&
{s.displayName}
} + {s.note &&
{s.note}
} + {s.contactName &&
{s.contactName}
} {s.contactPhone &&
{s.contactPhone}
} {s.contactEmail &&
{s.contactEmail}
} {s.paymentTermText ?? '—'} - {s.note ?? '—'} + + a.purchaseEvaluationSupplierId === s.id)} + /> +
+ {fmtSize(a.fileSize)} + + {PeAttachmentPurposeLabel[a.purpose] ?? ''} + + +
+ ))} +
+ + +
+ + ) +} diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts index 9bfa481..0db568b 100644 --- a/fe-user/src/types/purchaseEvaluation.ts +++ b/fe-user/src/types/purchaseEvaluation.ts @@ -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 = { + 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 } diff --git a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs index 3f06ac8..e7172b5 100644 --- a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs @@ -150,6 +150,42 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase public async Task> 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> 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 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 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" diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs index 00de179..0f18805 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs @@ -83,6 +83,17 @@ public record PurchaseEvaluationWorkflowSummaryDto( List ActivePhases, List 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 Suppliers, List Details, List Approvals, + List Attachments, PurchaseEvaluationWorkflowSummaryDto Workflow); diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationAttachmentFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationAttachmentFeatures.cs new file mode 100644 index 0000000..7e56600 --- /dev/null +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationAttachmentFeatures.cs @@ -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; + +public class UploadPurchaseEvaluationAttachmentCommandValidator + : AbstractValidator +{ + private const long MaxBytes = 20L * 1024 * 1024; + + private static readonly HashSet 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 +{ + public async Task 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; + +public record PurchaseEvaluationAttachmentFile(Stream Content, string FileName, string ContentType); + +public class DownloadPurchaseEvaluationAttachmentQueryHandler( + IApplicationDbContext db, + IFileStorage storage) : IRequestHandler +{ + public async Task 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 +{ + 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 */ } + } +} diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index a15a83b..51ba12e 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -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(),