src/Controller/CarSaleDocumentController.php line 51

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\CarSale;
  4. use App\Entity\CarSaleDocument;
  5. use Doctrine\ORM\EntityManagerInterface;
  6. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  7. use Symfony\Component\Filesystem\Filesystem;
  8. use Symfony\Component\HttpFoundation\BinaryFileResponse;
  9. use Symfony\Component\HttpFoundation\File\UploadedFile;
  10. use Symfony\Component\HttpFoundation\Request;
  11. use Symfony\Component\HttpFoundation\Response;
  12. use Symfony\Component\HttpFoundation\ResponseHeaderBag;
  13. use Symfony\Component\Routing\Annotation\Route;
  14. use Symfony\Component\HttpFoundation\JsonResponse;
  15. #[Route('/car/sale/documents')]
  16. class CarSaleDocumentController extends AbstractController
  17. {
  18.     private const ALLOWED_MIME = [
  19.         'application/pdf',
  20.     ];
  21.     private const MAX_SIZE_BYTES 15 1024 1024// 15 MB
  22.     public function __construct(
  23.         private EntityManagerInterface $em,
  24.     ) {}
  25.     // ============ MODAL (HTML) ============
  26.     #[Route('/car-sale/{id}/documents/modal'name'car_sale_documents_modal'methods: ['GET'])]
  27.     public function modal(CarSale $carSaleEntityManagerInterface $em): Response
  28.     {
  29.         $docs $em->getRepository(\App\Entity\CarSaleDocument::class)->findBy(
  30.             ['carSale' => $carSale],
  31.             ['type' => 'ASC''uploadedAt' => 'DESC']
  32.         );
  33.         return $this->render('car_sale/documents_modal.html.twig', [
  34.             'car_sale'    => $carSale,
  35.             'documents'   => $docs,
  36.             'typeChoices' => ['CERTIFICADO_TRANSFERENCIA','MANDATO','DNI_COMPRADOR','DNI_VENDEDOR','TITULO','CEDULA','POLIZA','FORM_08','OTRO'],
  37.         ]);
  38.     }
  39.     // ============ LISTAR + FORMULARIO ============
  40.     #[Route('/car-sale/{id}/documents'name'car_sale_documents_index'methods: ['GET'])]
  41.     public function index(Request $requestCarSale $carSaleEntityManagerInterface $em): Response
  42.     {
  43.         $docs $em->getRepository(\App\Entity\CarSaleDocument::class)->findBy(
  44.             ['carSale' => $carSale],
  45.             ['type' => 'ASC''uploadedAt' => 'DESC']
  46.         );
  47.         $wantsJson $request->isXmlHttpRequest()
  48.             || str_contains((string) $request->headers->get('accept'), 'application/json');
  49.         if ($wantsJson) {
  50.             $items array_map(function (\App\Entity\CarSaleDocument $d) use ($carSale) {
  51.                 return [
  52.                     'id'          => $d->getId(),
  53.                     'type'        => $d->getType(),
  54.                     'fileName'    => $d->getFileName(),
  55.                     'filePath'    => $d->getFilePath(),
  56.                     'sizeBytes'   => $d->getSizeBytes(),
  57.                     'sizeHuman'   => $d->getSizeBytes() ? (string)round($d->getSizeBytes()/1024).' KB' null,
  58.                     'uploadedAt' => $d->getUploadedAt()
  59.                                     ? $d->getUploadedAt()
  60.                                         ->setTimezone(new \DateTimeZone('America/Argentina/Buenos_Aires'))
  61.                                         ->format('Y-m-d H:i:s') : null,
  62.                     'status'      => $d->getStatus(),
  63.                     'version'     => $d->getVersion(),
  64.                     'downloadUrl' => $this->generateUrl('car_sale_documents_download', ['id' => $d->getId()]),
  65.                     'deleteUrl'   => $this->generateUrl('car_sale_documents_delete',   ['id' => $d->getId()]),
  66.                     'viewUrl'     => $d->getFilePath(), 
  67.                     'csrfDelete'  => $this->container->get('security.csrf.token_manager')
  68.                          ->getToken('delete_document_'.$d->getId())->getValue(),
  69.                 ];
  70.             }, $docs);
  71.             return $this->json([
  72.                 'status'    => 'success',
  73.                 'saleId'    => $carSale->getId(),
  74.                 'documents' => $items,
  75.             ]);
  76.         }
  77.         return $this->render('document_sale.html.twig', [
  78.             'car_sale'    => $carSale,
  79.             'isCreated'   => (null !== $carSale->getId()),
  80.             'documents'   => $docs,      
  81.             'typeChoices' => ['CERTIFICADO_TRANSFERENCIA','MANDATO','DNI_COMPRADOR','DNI_VENDEDOR','TITULO','CEDULA','POLIZA','FORM_08','OTRO'],
  82.         ]);
  83.     }
  84.     // ============ SUBIR DESDE FORM HTML ============
  85.     #[Route('/car-sale/{id}/documents'name'car_sale_documents_upload'methods: ['POST'])]
  86.     public function upload(Request $requestCarSale $carSaleEntityManagerInterface $em): JsonResponse
  87.     {
  88.         if (!$this->canManageDocuments()) {
  89.             return new JsonResponse(['status' => 'error''message' => 'No tiene permisos para cargar documentos.'], 403);
  90.         }
  91.         $type $this->sanitizeType((string) $request->request->get('type'''));
  92.         $enforceSingleActive $request->request->get('enforceSingleActive''1') === '1';
  93.         if ($type === '') {
  94.             return new JsonResponse(['status' => 'error''message' => 'El campo "Tipo" es obligatorio.'], 400);
  95.         }
  96.         /** @var UploadedFile[]|null $files */
  97.         $files $request->files->all('files');
  98.         if (!$files || count($files) === 0) {
  99.             $file $request->files->get('file');
  100.             if ($file instanceof UploadedFile$files = [$file];
  101.         }
  102.         if (!$files || count($files) === 0) {
  103.             return new JsonResponse(['status' => 'error''message' => 'No se recibió ningún archivo.'], 400);
  104.         }
  105.         $savedCount 0;
  106.         foreach ($files as $file) {
  107.             if (!$file instanceof UploadedFile || !$file->isValid()) {
  108.                 // archivo inválido: seguimos con el resto
  109.                 continue;
  110.             }
  111.             $mime $file->getClientMimeType() ?: $file->getMimeType();
  112.             $size = (int) $file->getSize();
  113.             if (!in_array($mimeself::ALLOWED_MIMEtrue)) {
  114.                 return new JsonResponse(['status' => 'error''message' => sprintf('Tipo no permitido: %s'$mime)], 400);
  115.             }
  116.             if ($size self::MAX_SIZE_BYTES) {
  117.                 return new JsonResponse(['status' => 'error''message' => 'El archivo excede el tamaño máximo permitido (15 MB).'], 400);
  118.             }
  119.             // Nombre cliente (first + last) seguro para filename
  120.             $first $carSale->getClient()?->getFirstName() ?? '';
  121.             $last  $carSale->getClient()?->getLastName() ?? '';
  122.             $clientName trim($first.' '.$last) ?: 'SIN_CLIENTE';
  123.             [$publicPath$absolutePath$finalFileName] = $this->resolveTargetPaths(
  124.                 $carSale->getId(),
  125.                 $type,
  126.                 $file,
  127.                 $clientName
  128.             );
  129.             $fs = new Filesystem();
  130.             $fs->mkdir(\dirname($absolutePath));
  131.             $file->move(\dirname($absolutePath), $finalFileName);
  132.             $sha256 = @hash_file('sha256'$absolutePath) ?: null;
  133.             // Versionado y archivado de activos previos del mismo tipo
  134.             $version 1;
  135.             if ($enforceSingleActive) {
  136.                 $currentActives $em->getRepository(CarSaleDocument::class)->findBy([
  137.                     'carSale' => $carSale,
  138.                     'type'    => $type,
  139.                     'status'  => 'ACTIVE',
  140.                 ]);
  141.                 foreach ($currentActives as $active) {
  142.                     $active->setStatus('ARCHIVED');
  143.                     $em->persist($active);
  144.                     $version max($version$active->getVersion() + 1);
  145.                 }
  146.             } else {
  147.                 $allOfType $em->getRepository(CarSaleDocument::class)->findBy([
  148.                     'carSale' => $carSale,
  149.                     'type'    => $type,
  150.                 ]);
  151.                 foreach ($allOfType as $docOfType) {
  152.                     $version max($version$docOfType->getVersion() + 1);
  153.                 }
  154.             }
  155.             $doc = new CarSaleDocument();
  156.             $doc->setCarSale($carSale);
  157.             $doc->setType($type);
  158.             $doc->setFileName($finalFileName);
  159.             $doc->setFilePath($publicPath);                 // ej: /uploads/car_sale_docs/123/CONTRATO-Juan_Perez-123.pdf
  160.             $doc->setMimeType($mime ?? 'application/pdf');
  161.             $doc->setSizeBytes($size);
  162.             $doc->setSha256($sha256);
  163.             $doc->setStatus('ACTIVE');
  164.             $doc->setVersion($version);
  165.             $em->persist($doc);
  166.             $savedCount++;
  167.         }
  168.         $em->flush();
  169.         if ($savedCount 0) {
  170.             return new JsonResponse(['status' => 'success''message' => sprintf('Se subieron %d archivo(s) correctamente.'$savedCount)]);
  171.         }
  172.         return new JsonResponse(['status' => 'warning''message' => 'No se pudo subir ningún archivo.'], 400);
  173.     }
  174.     /** Normaliza el tipo a MAYUSCULAS_SIN_ESPACIOS */
  175.     private function sanitizeType(string $type): string
  176.     {
  177.         $type \trim($type);
  178.         $type \preg_replace('/\s+/''_'$type);
  179.         $type \preg_replace('/[^A-Za-z0-9_\-]/'''$type);
  180.         return \strtoupper($type);
  181.     }
  182.     /**
  183.      * Genera rutas destino y nombre final:
  184.      *  file: {TYPE}-{CLIENTE}-{SALEID}.pdf
  185.      *  path pub:  /uploads/car_sale_docs/{SALEID}/{file}
  186.      *  path abs:  {project}/public/uploads/car_sale_docs/{SALEID}/{file}
  187.      *
  188.      * @return array{0:string,1:string,2:string} [publicPath, absolutePath, finalFileName]
  189.      */
  190.     private function resolveTargetPaths(int $saleIdstring $typeUploadedFile $file, ?string $clientName): array
  191.     {
  192.         $safeClient \preg_replace('/[^A-Za-z0-9_\-]/''_'$clientName ?: 'SIN_CLIENTE');
  193.         $safeType   \preg_replace('/[^A-Za-z0-9_\-]/''_'$type);
  194.         $finalFileName sprintf('%s-%s-%d.pdf'$safeType$safeClient$saleId);
  195.         $basePublicDir '/uploads/car_sale_docs/'.$saleId.'/';
  196.         $baseAbsolute  $this->getParameter('kernel.project_dir') . '/public' $basePublicDir;
  197.         return [$basePublicDir $finalFileName$baseAbsolute $finalFileName$finalFileName];
  198.     }
  199.     // ============ DESCARGAR ============
  200.     #[Route('/car-sale-documents/{id}/download'name'car_sale_documents_download'methods: ['GET'])]
  201.     public function download(CarSaleDocument $document): Response
  202.     {
  203.         $absolute $this->toAbsolutePath($document->getFilePath());
  204.         if (!is_file($absolute)) {
  205.             $this->addFlash('danger''Archivo no encontrado en disco.');
  206.             return $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
  207.         }
  208.         $response = new BinaryFileResponse($absolute);
  209.         $response->headers->set('Content-Type'$document->getMimeType() ?? 'application/pdf');
  210.         $disposition $response->headers->makeDisposition(
  211.             ResponseHeaderBag::DISPOSITION_INLINE,
  212.             $document->getFileName()
  213.         );
  214.         $response->headers->set('Content-Disposition'$disposition);
  215.         return $response;
  216.     }
  217.     // ============ ACTIVAR UNA VERSIÓN ============
  218.     #[Route('/car-sale-documents/{id}/activate'name'car_sale_documents_activate'methods: ['POST'])]
  219.     public function activateVersion(Request $requestCarSaleDocument $document): Response
  220.     {
  221.         if (!$this->isCsrfTokenValid('activate_doc_'.$document->getId(), (string)$request->request->get('_token'))) {
  222.             $this->addFlash('danger''Token inválido.');
  223.             return $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
  224.         }
  225.         $sale $document->getCarSale();
  226.         $type $document->getType();
  227.         $repo $this->em->getRepository(CarSaleDocument::class);
  228.         $all  $repo->findBy(['carSale' => $sale'type' => $type]);
  229.         $maxVersion 0;
  230.         foreach ($all as $d) {
  231.             $d->setStatus('ARCHIVED');
  232.             $this->em->persist($d);
  233.             $maxVersion max($maxVersion$d->getVersion());
  234.         }
  235.         $document->setStatus('ACTIVE');
  236.         if ($document->getVersion() < $maxVersion) {
  237.             $document->setVersion($maxVersion 1);
  238.         }
  239.         $this->em->persist($document);
  240.         $this->em->flush();
  241.         $this->addFlash('success'sprintf('Se activó la versión del documento #%d.'$document->getId()));
  242.         return $this->redirectToRoute('car_sale_documents_index', ['id' => $sale->getId()]);
  243.     }
  244.     // ============ ELIMINAR ============
  245.     #[Route('/car-sale-documents/{id}'name'car_sale_documents_delete'methods: ['POST','DELETE'])]
  246.     public function delete(Request $requestCarSaleDocument $documentEntityManagerInterface $em): Response
  247.     {
  248.         if (!$this->canManageDocuments()) {
  249.             $message = ['status' => 'error''message' => 'No tiene permisos para eliminar documentos.'];
  250.             return $request->isXmlHttpRequest()
  251.                 ? $this->json($message403)
  252.                 : $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
  253.         }
  254.         $isAjax $request->isXmlHttpRequest() || str_contains((string)$request->headers->get('accept'), 'application/json');
  255.         $token  = (string)($request->request->get('_token') ?? $request->headers->get('X-CSRF-TOKEN'''));
  256.         if (!$this->isCsrfTokenValid('delete_document_'.$document->getId(), $token)) {
  257.             return $isAjax
  258.                 $this->json(['status' => 'error''message' => 'Token inválido.'], 400)
  259.                 : $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
  260.         }
  261.         $abs $this->getParameter('kernel.project_dir').'/public/'.ltrim($document->getFilePath(), '/');
  262.         if (is_file($abs)) @unlink($abs);
  263.         $em->remove($document);
  264.         $em->flush();
  265.         return $isAjax
  266.             $this->json(['status' => 'success''message' => 'Documento eliminado.'])
  267.             : $this->redirectToRoute('car_sale_documents_index', ['id' => $document->getCarSale()->getId()]);
  268.     }
  269.     private function canManageDocuments(): bool
  270.     {
  271.         return $this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_AGENCY');
  272.     }
  273.     private function toAbsolutePath(string $publicPath): string
  274.     {
  275.         $projectDir $this->getParameter('kernel.project_dir');
  276.         return rtrim($projectDir'/') . '/public' $publicPath;
  277.     }
  278. }