<?php
namespace App\Controller;
use App\Entity\CarSale;
use App\Entity\CarSaleDocument;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
#[Route('/car/sale/documents')]
class CarSaleDocumentController extends AbstractController
{
private const ALLOWED_MIME = [
'application/pdf',
];
private const MAX_SIZE_BYTES = 15 * 1024 * 1024; // 15 MB
public function __construct(
private EntityManagerInterface $em,
) {}
// ============ MODAL (HTML) ============
#[Route('/car-sale/{id}/documents/modal', name: 'car_sale_documents_modal', methods: ['GET'])]
public function modal(CarSale $carSale, EntityManagerInterface $em): Response
{
$docs = $em->getRepository(\App\Entity\CarSaleDocument::class)->findBy(
['carSale' => $carSale],
['type' => 'ASC', 'uploadedAt' => 'DESC']
);
return $this->render('car_sale/documents_modal.html.twig', [
'car_sale' => $carSale,
'documents' => $docs,
'typeChoices' => ['CERTIFICADO_TRANSFERENCIA','MANDATO','DNI_COMPRADOR','DNI_VENDEDOR','TITULO','CEDULA','POLIZA','FORM_08','OTRO'],
]);
}
// ============ LISTAR + FORMULARIO ============
#[Route('/car-sale/{id}/documents', name: 'car_sale_documents_index', methods: ['GET'])]
public function index(Request $request, CarSale $carSale, EntityManagerInterface $em): Response
{
$docs = $em->getRepository(\App\Entity\CarSaleDocument::class)->findBy(
['carSale' => $carSale],
['type' => 'ASC', 'uploadedAt' => 'DESC']
);
$wantsJson = $request->isXmlHttpRequest()
|| str_contains((string) $request->headers->get('accept'), 'application/json');
if ($wantsJson) {
$items = array_map(function (\App\Entity\CarSaleDocument $d) use ($carSale) {
return [
'id' => $d->getId(),
'type' => $d->getType(),
'fileName' => $d->getFileName(),
'filePath' => $d->getFilePath(),
'sizeBytes' => $d->getSizeBytes(),
'sizeHuman' => $d->getSizeBytes() ? (string)round($d->getSizeBytes()/1024).' KB' : null,
'uploadedAt' => $d->getUploadedAt()
? $d->getUploadedAt()
->setTimezone(new \DateTimeZone('America/Argentina/Buenos_Aires'))
->format('Y-m-d H:i:s') : null,
'status' => $d->getStatus(),
'version' => $d->getVersion(),
'downloadUrl' => $this->generateUrl('car_sale_documents_download', ['id' => $d->getId()]),
'deleteUrl' => $this->generateUrl('car_sale_documents_delete', ['id' => $d->getId()]),
'viewUrl' => $d->getFilePath(),
'csrfDelete' => $this->container->get('security.csrf.token_manager')
->getToken('delete_document_'.$d->getId())->getValue(),
];
}, $docs);
return $this->json([
'status' => 'success',
'saleId' => $carSale->getId(),
'documents' => $items,
]);
}
return $this->render('document_sale.html.twig', [
'car_sale' => $carSale,
'isCreated' => (null !== $carSale->getId()),
'documents' => $docs,
'typeChoices' => ['CERTIFICADO_TRANSFERENCIA','MANDATO','DNI_COMPRADOR','DNI_VENDEDOR','TITULO','CEDULA','POLIZA','FORM_08','OTRO'],
]);
}
// ============ SUBIR DESDE FORM HTML ============
#[Route('/car-sale/{id}/documents', name: 'car_sale_documents_upload', methods: ['POST'])]
public function upload(Request $request, CarSale $carSale, EntityManagerInterface $em): JsonResponse
{
if (!$this->canManageDocuments()) {
return new JsonResponse(['status' => 'error', 'message' => 'No tiene permisos para cargar documentos.'], 403);
}
$type = $this->sanitizeType((string) $request->request->get('type', ''));
$enforceSingleActive = $request->request->get('enforceSingleActive', '1') === '1';
if ($type === '') {
return new JsonResponse(['status' => 'error', 'message' => 'El campo "Tipo" es obligatorio.'], 400);
}
/** @var UploadedFile[]|null $files */
$files = $request->files->all('files');
if (!$files || count($files) === 0) {
$file = $request->files->get('file');
if ($file instanceof UploadedFile) $files = [$file];
}
if (!$files || count($files) === 0) {
return new JsonResponse(['status' => 'error', 'message' => 'No se recibió ningún archivo.'], 400);
}
$savedCount = 0;
foreach ($files as $file) {
if (!$file instanceof UploadedFile || !$file->isValid()) {
// archivo inválido: seguimos con el resto
continue;
}
$mime = $file->getClientMimeType() ?: $file->getMimeType();
$size = (int) $file->getSize();
if (!in_array($mime, self::ALLOWED_MIME, true)) {
return new JsonResponse(['status' => 'error', 'message' => sprintf('Tipo no permitido: %s', $mime)], 400);
}
if ($size > self::MAX_SIZE_BYTES) {
return new JsonResponse(['status' => 'error', 'message' => 'El archivo excede el tamaño máximo permitido (15 MB).'], 400);
}
// Nombre cliente (first + last) seguro para filename
$first = $carSale->getClient()?->getFirstName() ?? '';
$last = $carSale->getClient()?->getLastName() ?? '';
$clientName = trim($first.' '.$last) ?: 'SIN_CLIENTE';
[$publicPath, $absolutePath, $finalFileName] = $this->resolveTargetPaths(
$carSale->getId(),
$type,
$file,
$clientName
);
$fs = new Filesystem();
$fs->mkdir(\dirname($absolutePath));
$file->move(\dirname($absolutePath), $finalFileName);
$sha256 = @hash_file('sha256', $absolutePath) ?: null;
// Versionado y archivado de activos previos del mismo tipo
$version = 1;
if ($enforceSingleActive) {
$currentActives = $em->getRepository(CarSaleDocument::class)->findBy([
'carSale' => $carSale,
'type' => $type,
'status' => 'ACTIVE',
]);
foreach ($currentActives as $active) {
$active->setStatus('ARCHIVED');
$em->persist($active);
$version = max($version, $active->getVersion() + 1);
}
} else {
$allOfType = $em->getRepository(CarSaleDocument::class)->findBy([
'carSale' => $carSale,
'type' => $type,
]);
foreach ($allOfType as $docOfType) {
$version = max($version, $docOfType->getVersion() + 1);
}
}
$doc = new CarSaleDocument();
$doc->setCarSale($carSale);
$doc->setType($type);
$doc->setFileName($finalFileName);
$doc->setFilePath($publicPath); // ej: /uploads/car_sale_docs/123/CONTRATO-Juan_Perez-123.pdf
$doc->setMimeType($mime ?? 'application/pdf');
$doc->setSizeBytes($size);
$doc->setSha256($sha256);
$doc->setStatus('ACTIVE');
$doc->setVersion($version);
$em->persist($doc);
$savedCount++;
}
$em->flush();
if ($savedCount > 0) {
return new JsonResponse(['status' => 'success', 'message' => sprintf('Se subieron %d archivo(s) correctamente.', $savedCount)]);
}
return new JsonResponse(['status' => 'warning', 'message' => 'No se pudo subir ningún archivo.'], 400);
}
/** Normaliza el tipo a MAYUSCULAS_SIN_ESPACIOS */
private function sanitizeType(string $type): string
{
$type = \trim($type);
$type = \preg_replace('/\s+/', '_', $type);
$type = \preg_replace('/[^A-Za-z0-9_\-]/', '', $type);
return \strtoupper($type);
}
/**
* Genera rutas destino y nombre final:
* file: {TYPE}-{CLIENTE}-{SALEID}.pdf
* path pub: /uploads/car_sale_docs/{SALEID}/{file}
* path abs: {project}/public/uploads/car_sale_docs/{SALEID}/{file}
*
* @return array{0:string,1:string,2:string} [publicPath, absolutePath, finalFileName]
*/
private function resolveTargetPaths(int $saleId, string $type, UploadedFile $file, ?string $clientName): array
{
$safeClient = \preg_replace('/[^A-Za-z0-9_\-]/', '_', $clientName ?: 'SIN_CLIENTE');
$safeType = \preg_replace('/[^A-Za-z0-9_\-]/', '_', $type);
$finalFileName = sprintf('%s-%s-%d.pdf', $safeType, $safeClient, $saleId);
$basePublicDir = '/uploads/car_sale_docs/'.$saleId.'/';
$baseAbsolute = $this->getParameter('kernel.project_dir') . '/public' . $basePublicDir;
return [$basePublicDir . $finalFileName, $baseAbsolute . $finalFileName, $finalFileName];
}
// ============ DESCARGAR ============
#[Route('/car-sale-documents/{id}/download', name: 'car_sale_documents_download', methods: ['GET'])]
public function download(CarSaleDocument $document): Response
{
$absolute = $this->toAbsolutePath($document->getFilePath());
if (!is_file($absolute)) {
$this->addFlash('danger', 'Archivo no encontrado en disco.');
return $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
}
$response = new BinaryFileResponse($absolute);
$response->headers->set('Content-Type', $document->getMimeType() ?? 'application/pdf');
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$document->getFileName()
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
// ============ ACTIVAR UNA VERSIÓN ============
#[Route('/car-sale-documents/{id}/activate', name: 'car_sale_documents_activate', methods: ['POST'])]
public function activateVersion(Request $request, CarSaleDocument $document): Response
{
if (!$this->isCsrfTokenValid('activate_doc_'.$document->getId(), (string)$request->request->get('_token'))) {
$this->addFlash('danger', 'Token inválido.');
return $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
}
$sale = $document->getCarSale();
$type = $document->getType();
$repo = $this->em->getRepository(CarSaleDocument::class);
$all = $repo->findBy(['carSale' => $sale, 'type' => $type]);
$maxVersion = 0;
foreach ($all as $d) {
$d->setStatus('ARCHIVED');
$this->em->persist($d);
$maxVersion = max($maxVersion, $d->getVersion());
}
$document->setStatus('ACTIVE');
if ($document->getVersion() < $maxVersion) {
$document->setVersion($maxVersion + 1);
}
$this->em->persist($document);
$this->em->flush();
$this->addFlash('success', sprintf('Se activó la versión del documento #%d.', $document->getId()));
return $this->redirectToRoute('car_sale_documents_index', ['id' => $sale->getId()]);
}
// ============ ELIMINAR ============
#[Route('/car-sale-documents/{id}', name: 'car_sale_documents_delete', methods: ['POST','DELETE'])]
public function delete(Request $request, CarSaleDocument $document, EntityManagerInterface $em): Response
{
if (!$this->canManageDocuments()) {
$message = ['status' => 'error', 'message' => 'No tiene permisos para eliminar documentos.'];
return $request->isXmlHttpRequest()
? $this->json($message, 403)
: $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
}
$isAjax = $request->isXmlHttpRequest() || str_contains((string)$request->headers->get('accept'), 'application/json');
$token = (string)($request->request->get('_token') ?? $request->headers->get('X-CSRF-TOKEN', ''));
if (!$this->isCsrfTokenValid('delete_document_'.$document->getId(), $token)) {
return $isAjax
? $this->json(['status' => 'error', 'message' => 'Token inválido.'], 400)
: $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
}
$abs = $this->getParameter('kernel.project_dir').'/public/'.ltrim($document->getFilePath(), '/');
if (is_file($abs)) @unlink($abs);
$em->remove($document);
$em->flush();
return $isAjax
? $this->json(['status' => 'success', 'message' => 'Documento eliminado.'])
: $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
}
private function canManageDocuments(): bool
{
return $this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_AGENCY');
}
private function toAbsolutePath(string $publicPath): string
{
$projectDir = $this->getParameter('kernel.project_dir');
return rtrim($projectDir, '/') . '/public' . $publicPath;
}
}