[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:
@ -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