feat(documents) : filesystem storage, server-side pagination and PDF compression
- Add DocumentStorageService for file-based storage (replaces Base64 in DB) - Add DocumentServeController with /file and /download endpoints - Add DocumentUploadProcessor using FormData + filesystem storage - Add DocumentNormalizer exposing fileUrl/downloadUrl on all responses - Add DocumentFileCleanupListener for automatic file deletion - Add MigrateDocumentsToFilesystemCommand (Base64 → files, memory-safe) - Add ApiFilter (SearchFilter, ExistsFilter, OrderFilter) on Document entity - Add PdfCompressorService + refactor CompressPdfCommand for batch processing - Fix TypeMachine PUT: deserialize=false + validate=false to prevent UniqueEntity false positive and writableLink collection interference - Update CHANGELOG for v1.8.0 - Update frontend submodule Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [1.8.0] - 2026-03-03
|
||||
|
||||
### Ajouts
|
||||
- **Stockage documents sur disque** : les documents sont desormais stockes en fichiers sur le systeme de fichiers au lieu de Base64 en base de donnees. Les endpoints `/api/documents/{id}/file` et `/api/documents/{id}/download` servent les fichiers directement.
|
||||
- **Commande de migration** `app:migrate-documents-to-filesystem` : migre les documents existants (Base64 → fichiers) avec dry-run, batch-size et limit.
|
||||
- **Pagination serveur sur la page Documents** : recherche, tri (date/nom/taille), filtre par rattachement (site/machine/composant/piece/produit), selecteur par page (20/50/100).
|
||||
- **Compression PDF automatique** : les documents PDF uploades sont compresses automatiquement via Ghostscript. Commande `app:compress-pdf` pour compresser les PDFs existants.
|
||||
- **Nettoyage automatique des fichiers** : suppression du fichier sur disque lors de la suppression d'un document.
|
||||
- **Champ description** sur les entites Piece et Composant, visible dans les catalogues avec popover au survol.
|
||||
|
||||
### Corrections
|
||||
- Fix normalisation des documents : `fileUrl` et `downloadUrl` toujours exposes dans l'API (meme sans `path` dans le groupe de serialisation).
|
||||
- Fix recursion infinie dans `DocumentNormalizer` (`getSupportedTypes` retourne `false` pour desactiver le cache).
|
||||
- Fix edition de squelettes machines : `deserialize: false` + `validate: false` sur le PUT pour eviter le conflit UniqueEntity et l'interference du deserialiseur avec les collections writableLink.
|
||||
- Fix sites : ajout operation PATCH et correction migration contrainte.
|
||||
- Retrocompatibilite : le controleur de service gere transparentement les anciens documents Base64 et les nouveaux fichiers.
|
||||
|
||||
### Migration requise
|
||||
```bash
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate
|
||||
docker compose exec php php bin/console app:migrate-documents-to-filesystem
|
||||
```
|
||||
|
||||
## [1.7.0] - 2026-03-02
|
||||
|
||||
### Ajouts
|
||||
|
||||
Submodule Inventory_frontend updated: 546cc37a09...e88ed5b8f2
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Document;
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use App\Service\PdfCompressorService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -13,15 +16,20 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function count;
|
||||
use function strlen;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:compress-pdf',
|
||||
description: 'Compress all PDF documents stored in database without quality loss',
|
||||
description: 'Compress all PDF documents without quality loss',
|
||||
)]
|
||||
class CompressPdfCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documentRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly PdfCompressorService $pdfCompressor,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -61,87 +69,13 @@ class CompressPdfCommand extends Command
|
||||
$compressed = 0;
|
||||
|
||||
foreach ($documents as $document) {
|
||||
$base64Data = $document->getPath();
|
||||
$path = $document->getPath();
|
||||
|
||||
// Remove data URI prefix if present
|
||||
if (str_contains($base64Data, ',')) {
|
||||
$base64Data = explode(',', $base64Data, 2)[1];
|
||||
}
|
||||
|
||||
$pdfContent = base64_decode($base64Data, true);
|
||||
if (false === $pdfContent) {
|
||||
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalSize = strlen($pdfContent);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would compress: %s (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create temp files
|
||||
$tempInput = tempnam(sys_get_temp_dir(), 'pdf_in_');
|
||||
$tempOutput = tempnam(sys_get_temp_dir(), 'pdf_out_');
|
||||
|
||||
file_put_contents($tempInput, $pdfContent);
|
||||
|
||||
// Compress with qpdf (lossless)
|
||||
$command = sprintf(
|
||||
'qpdf --linearize --object-streams=generate %s %s 2>&1',
|
||||
escapeshellarg($tempInput),
|
||||
escapeshellarg($tempOutput)
|
||||
);
|
||||
|
||||
exec($command, $cmdOutput, $returnCode);
|
||||
|
||||
if (0 !== $returnCode || !file_exists($tempOutput)) {
|
||||
$io->warning(sprintf('Failed to compress: %s', $document->getName()));
|
||||
@unlink($tempInput);
|
||||
@unlink($tempOutput);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$compressedContent = file_get_contents($tempOutput);
|
||||
$compressedSize = strlen($compressedContent);
|
||||
|
||||
// Only update if we actually saved space
|
||||
if ($compressedSize < $originalSize) {
|
||||
$saved = $originalSize - $compressedSize;
|
||||
$totalSaved += $saved;
|
||||
++$compressed;
|
||||
|
||||
// Rebuild base64 with data URI prefix
|
||||
$newBase64 = 'data:application/pdf;base64,'.base64_encode($compressedContent);
|
||||
$document->setPath($newBase64);
|
||||
$document->setSize($compressedSize);
|
||||
|
||||
$io->text(sprintf(
|
||||
' ✓ %s: %s → %s (-%s, -%.1f%%)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize),
|
||||
$this->formatBytes($compressedSize),
|
||||
$this->formatBytes($saved),
|
||||
($saved / $originalSize) * 100
|
||||
));
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
$this->compressBase64Document($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
||||
} else {
|
||||
$io->text(sprintf(
|
||||
' - %s: Already optimal (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
$this->compressFileDocument($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
||||
}
|
||||
|
||||
@unlink($tempInput);
|
||||
@unlink($tempOutput);
|
||||
}
|
||||
|
||||
if (!$dryRun && $compressed > 0) {
|
||||
@@ -161,6 +95,115 @@ class CompressPdfCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function compressBase64Document(
|
||||
Document $document,
|
||||
string $path,
|
||||
bool $dryRun,
|
||||
SymfonyStyle $io,
|
||||
int &$totalSaved,
|
||||
int &$compressed,
|
||||
): void {
|
||||
$base64Data = $path;
|
||||
if (str_contains($base64Data, ',')) {
|
||||
$base64Data = explode(',', $base64Data, 2)[1];
|
||||
}
|
||||
|
||||
$pdfContent = base64_decode($base64Data, true);
|
||||
if (false === $pdfContent) {
|
||||
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$originalSize = strlen($pdfContent);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would compress (base64): %s (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->pdfCompressor->compressBase64Pdf($path);
|
||||
if (null !== $result) {
|
||||
$document->setPath($result['path']);
|
||||
$document->setSize($result['size']);
|
||||
$totalSaved += $result['saved'];
|
||||
++$compressed;
|
||||
|
||||
$io->text(sprintf(
|
||||
' OK %s: %s → %s (-%s, -%.1f%%)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($result['originalSize']),
|
||||
$this->formatBytes($result['size']),
|
||||
$this->formatBytes($result['saved']),
|
||||
($result['saved'] / $result['originalSize']) * 100
|
||||
));
|
||||
} else {
|
||||
$io->text(sprintf(
|
||||
' - %s: Already optimal (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function compressFileDocument(
|
||||
Document $document,
|
||||
string $path,
|
||||
bool $dryRun,
|
||||
SymfonyStyle $io,
|
||||
int &$totalSaved,
|
||||
int &$compressed,
|
||||
): void {
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
if (!file_exists($absolutePath)) {
|
||||
$io->warning(sprintf('File not found: %s (%s)', $document->getName(), $path));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$originalSize = filesize($absolutePath);
|
||||
if (false === $originalSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would compress (file): %s (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->pdfCompressor->compressFile($absolutePath);
|
||||
if (null !== $result) {
|
||||
$document->setSize($result['size']);
|
||||
$totalSaved += $result['saved'];
|
||||
++$compressed;
|
||||
|
||||
$io->text(sprintf(
|
||||
' OK %s: %s → %s (-%s, -%.1f%%)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($result['originalSize']),
|
||||
$this->formatBytes($result['size']),
|
||||
$this->formatBytes($result['saved']),
|
||||
($result['saved'] / $result['originalSize']) * 100
|
||||
));
|
||||
} else {
|
||||
$io->text(sprintf(
|
||||
' - %s: Already optimal (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
218
src/Command/MigrateDocumentsToFilesystemCommand.php
Normal file
218
src/Command/MigrateDocumentsToFilesystemCommand.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
use function count;
|
||||
use function strlen;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:migrate-documents-to-filesystem',
|
||||
description: 'Migrate document storage from Base64 in DB to filesystem',
|
||||
)]
|
||||
class MigrateDocumentsToFilesystemCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documentRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be migrated without making changes')
|
||||
->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Number of documents to process before flushing', '50')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Max documents to migrate (for testing)', '0')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
$batchSize = (int) $input->getOption('batch-size');
|
||||
$limit = (int) $input->getOption('limit');
|
||||
|
||||
$io->title('Document Storage Migration: Base64 → Filesystem');
|
||||
|
||||
// Verify storage directory is writable
|
||||
$storageDir = $this->storageService->getStorageDir();
|
||||
if (!$dryRun) {
|
||||
if (!is_dir($storageDir)) {
|
||||
mkdir($storageDir, 0o775, true);
|
||||
}
|
||||
if (!is_writable($storageDir)) {
|
||||
$io->error("Storage directory is not writable: {$storageDir}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$io->text("Storage directory: {$storageDir}");
|
||||
}
|
||||
|
||||
// Step 1: fetch only IDs of Base64 documents (no heavy path column loaded)
|
||||
$conn = $this->em->getConnection();
|
||||
$ids = $conn->fetchFirstColumn("SELECT id FROM documents WHERE path LIKE 'data:%'");
|
||||
$total = count($ids);
|
||||
$migrated = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
$totalBytes = 0;
|
||||
|
||||
$io->text(sprintf('Found %d documents with Base64 data to migrate', $total));
|
||||
|
||||
if (0 === $total) {
|
||||
$io->success('Nothing to migrate — all documents are already file-based.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Step 2: process one document at a time to avoid memory exhaustion
|
||||
foreach ($ids as $index => $docId) {
|
||||
if ($limit > 0 && $migrated >= $limit) {
|
||||
$io->text("Reached limit of {$limit} documents.");
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Fetch single row with raw SQL to keep memory flat
|
||||
$row = $conn->fetchAssociative(
|
||||
'SELECT id, name, filename, path, mimetype, size FROM documents WHERE id = ?',
|
||||
[$docId]
|
||||
);
|
||||
|
||||
if (!$row) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $row['path'];
|
||||
if (!$this->storageService->isBase64DataUri($path)) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$docName = $row['name'] ?: $row['filename'];
|
||||
$filename = $row['filename'] ?: $row['name'];
|
||||
$mimeType = $row['mimetype'] ?? 'application/octet-stream';
|
||||
|
||||
// Extract binary content from data URI
|
||||
$parts = explode(',', $path, 2);
|
||||
$base64 = $parts[1] ?? '';
|
||||
$content = base64_decode($base64, true);
|
||||
|
||||
// Free the raw row immediately
|
||||
unset($row, $path, $base64, $parts);
|
||||
|
||||
if (false === $content || '' === $content) {
|
||||
$io->warning(sprintf('[%d/%d] Cannot decode: %s (id: %s)', $index + 1, $total, $docName, $docId));
|
||||
++$errors;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileSize = strlen($content);
|
||||
$extension = $this->storageService->extensionFromFilename(
|
||||
$filename ?: ('file.'.$this->storageService->extensionFromMimeType($mimeType))
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would migrate: %s (%s)',
|
||||
$docName,
|
||||
$this->formatBytes($fileSize)
|
||||
));
|
||||
++$migrated;
|
||||
$totalBytes += $fileSize;
|
||||
unset($content);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$relativePath = $this->storageService->store($content, $docId, $extension);
|
||||
unset($content);
|
||||
|
||||
// Update DB directly — avoid loading entity with huge path
|
||||
$conn->executeStatement(
|
||||
'UPDATE documents SET path = ?, size = ? WHERE id = ?',
|
||||
[$relativePath, $fileSize, $docId]
|
||||
);
|
||||
|
||||
++$migrated;
|
||||
$totalBytes += $fileSize;
|
||||
|
||||
$io->text(sprintf(
|
||||
' [OK] %s → %s (%s)',
|
||||
$docName,
|
||||
$relativePath,
|
||||
$this->formatBytes($fileSize)
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
unset($content);
|
||||
$io->error(sprintf(
|
||||
' [FAIL] %s: %s',
|
||||
$docName,
|
||||
$e->getMessage()
|
||||
));
|
||||
++$errors;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (0 === $migrated % $batchSize) {
|
||||
$io->text(sprintf(' ... %d migrated so far', $migrated));
|
||||
}
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Total documents', (string) $total],
|
||||
['Migrated', (string) $migrated],
|
||||
['Skipped (already file-based)', (string) $skipped],
|
||||
['Errors', (string) $errors],
|
||||
['Total bytes written', $this->formatBytes($totalBytes)],
|
||||
]
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->info('Dry run completed. No changes were made.');
|
||||
} elseif ($errors > 0) {
|
||||
$io->warning(sprintf('Migration completed with %d errors.', $errors));
|
||||
} else {
|
||||
$io->success('Migration completed successfully.');
|
||||
}
|
||||
|
||||
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$i = 0;
|
||||
while ($bytes >= 1024 && $i < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
++$i;
|
||||
}
|
||||
|
||||
return round($bytes, 2).' '.$units[$i];
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,8 @@ class DocumentQueryController extends AbstractController
|
||||
'id' => $document->getId(),
|
||||
'name' => $document->getName(),
|
||||
'filename' => $document->getFilename(),
|
||||
'path' => $document->getPath(),
|
||||
'fileUrl' => '/api/documents/'.$document->getId().'/file',
|
||||
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
|
||||
'mimeType' => $document->getMimeType(),
|
||||
'size' => $document->getSize(),
|
||||
'siteId' => $document->getSite()?->getId(),
|
||||
|
||||
109
src/Controller/DocumentServeController.php
Normal file
109
src/Controller/DocumentServeController.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use function strlen;
|
||||
|
||||
#[Route('/api/documents')]
|
||||
class DocumentServeController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documentRepository,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}/file', name: 'document_serve_file', methods: ['GET'])]
|
||||
public function serve(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$document = $this->documentRepository->find($id);
|
||||
if (!$document) {
|
||||
return $this->json(['error' => 'Document not found.'], 404);
|
||||
}
|
||||
|
||||
$path = $document->getPath();
|
||||
|
||||
// Backward compatibility: serve Base64 data URIs from DB
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
$parts = explode(',', $path, 2);
|
||||
$content = base64_decode($parts[1] ?? '', true);
|
||||
if (false === $content) {
|
||||
return $this->json(['error' => 'Invalid document data.'], 500);
|
||||
}
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => $document->getMimeType(),
|
||||
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_INLINE.'; filename="'.$document->getFilename().'"',
|
||||
'Content-Length' => (string) strlen($content),
|
||||
'Cache-Control' => 'private, max-age=3600',
|
||||
]);
|
||||
}
|
||||
|
||||
// File-based path: serve from disk
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
if (!file_exists($absolutePath)) {
|
||||
return $this->json(['error' => 'File not found on disk.'], 404);
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($absolutePath);
|
||||
$response->headers->set('Content-Type', $document->getMimeType());
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||
$document->getFilename()
|
||||
);
|
||||
$response->headers->set('Cache-Control', 'private, max-age=3600');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/{id}/download', name: 'document_download_file', methods: ['GET'])]
|
||||
public function download(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$document = $this->documentRepository->find($id);
|
||||
if (!$document) {
|
||||
return $this->json(['error' => 'Document not found.'], 404);
|
||||
}
|
||||
|
||||
$path = $document->getPath();
|
||||
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
$parts = explode(',', $path, 2);
|
||||
$content = base64_decode($parts[1] ?? '', true);
|
||||
if (false === $content) {
|
||||
return $this->json(['error' => 'Invalid document data.'], 500);
|
||||
}
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_ATTACHMENT.'; filename="'.$document->getFilename().'"',
|
||||
'Content-Length' => (string) strlen($content),
|
||||
]);
|
||||
}
|
||||
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
if (!file_exists($absolutePath)) {
|
||||
return $this->json(['error' => 'File not found on disk.'], 404);
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($absolutePath);
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$document->getFilename()
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -11,6 +15,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\State\DocumentUploadProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -19,6 +24,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
|
||||
#[ORM\Table(name: 'documents')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', 'filename' => 'partial'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
@@ -29,12 +37,18 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
security: "is_granted('ROLE_VIEWER')",
|
||||
normalizationContext: ['groups' => ['document:list', 'document:detail']],
|
||||
),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_GESTIONNAIRE')",
|
||||
processor: DocumentUploadProcessor::class,
|
||||
deserialize: false,
|
||||
inputFormats: ['multipart' => ['multipart/form-data']],
|
||||
),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
paginationMaximumItemsPerPage: 500,
|
||||
order: ['createdAt' => 'DESC']
|
||||
)]
|
||||
class Document
|
||||
{
|
||||
|
||||
@@ -31,7 +31,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: TypeMachinePutProcessor::class),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: TypeMachinePutProcessor::class, deserialize: false, validate: false),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
|
||||
44
src/EventListener/DocumentFileCleanupListener.php
Normal file
44
src/EventListener/DocumentFileCleanupListener.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\Document;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
||||
use Doctrine\ORM\Events;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[AsEntityListener(event: Events::postRemove, method: 'postRemove', entity: Document::class)]
|
||||
class DocumentFileCleanupListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentStorageService $storageService,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
public function postRemove(Document $document): void
|
||||
{
|
||||
$path = $document->getPath();
|
||||
|
||||
// Do not attempt file deletion for Base64 data URIs
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = $this->storageService->delete($path);
|
||||
|
||||
if ($deleted) {
|
||||
$this->logger?->info('Document file deleted from disk', [
|
||||
'documentId' => $document->getId(),
|
||||
'path' => $path,
|
||||
]);
|
||||
} else {
|
||||
$this->logger?->warning('Document file not found on disk during cleanup', [
|
||||
'documentId' => $document->getId(),
|
||||
'path' => $path,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\Document;
|
||||
use App\Service\DocumentStorageService;
|
||||
use App\Service\PdfCompressorService;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
||||
use Doctrine\ORM\Events;
|
||||
@@ -16,6 +17,7 @@ class DocumentPdfCompressorListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PdfCompressorService $pdfCompressor,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
@@ -35,15 +37,26 @@ class DocumentPdfCompressorListener
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->pdfCompressor->compressBase64Pdf($document->getPath());
|
||||
$path = $document->getPath();
|
||||
|
||||
if (null === $result) {
|
||||
return;
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
// Legacy Base64 path
|
||||
$result = $this->pdfCompressor->compressBase64Pdf($path);
|
||||
if (null === $result) {
|
||||
return;
|
||||
}
|
||||
$document->setPath($result['path']);
|
||||
$document->setSize($result['size']);
|
||||
} else {
|
||||
// File-based path
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
$result = $this->pdfCompressor->compressFile($absolutePath);
|
||||
if (null === $result) {
|
||||
return;
|
||||
}
|
||||
$document->setSize($result['size']);
|
||||
}
|
||||
|
||||
$document->setPath($result['path']);
|
||||
$document->setSize($result['size']);
|
||||
|
||||
$this->logger?->info('PDF compressed', [
|
||||
'document' => $document->getName(),
|
||||
'originalSize' => $result['originalSize'],
|
||||
|
||||
61
src/Serializer/DocumentNormalizer.php
Normal file
61
src/Serializer/DocumentNormalizer.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Serializer;
|
||||
|
||||
use App\Entity\Document;
|
||||
use ArrayObject;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
use function is_array;
|
||||
|
||||
class DocumentNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
|
||||
private const ALREADY_CALLED = 'DOCUMENT_NORMALIZER_ALREADY_CALLED';
|
||||
|
||||
/**
|
||||
* @return null|array<string, mixed>|ArrayObject<int|string, mixed>|bool|float|int|string
|
||||
*/
|
||||
public function normalize(mixed $data, ?string $format = null, array $context = []): array|ArrayObject|bool|float|int|string|null
|
||||
{
|
||||
$context[self::ALREADY_CALLED] = true;
|
||||
|
||||
/** @var null|array<string, mixed> $normalized */
|
||||
$normalized = $this->normalizer->normalize($data, $format, $context);
|
||||
|
||||
if (is_array($normalized) && $data instanceof Document && $data->getId()) {
|
||||
// Remove raw 'path' if present (never expose it to the client)
|
||||
unset($normalized['path']);
|
||||
|
||||
// Always provide URL-based access
|
||||
$normalized['fileUrl'] = '/api/documents/'.$data->getId().'/file';
|
||||
$normalized['downloadUrl'] = '/api/documents/'.$data->getId().'/download';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
if (isset($context[self::ALREADY_CALLED])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $data instanceof Document;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string, bool>
|
||||
*/
|
||||
public function getSupportedTypes(?string $format): array
|
||||
{
|
||||
return [
|
||||
Document::class => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
141
src/Service/DocumentStorageService.php
Normal file
141
src/Service/DocumentStorageService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
use function dirname;
|
||||
|
||||
class DocumentStorageService
|
||||
{
|
||||
private readonly string $storageDir;
|
||||
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
) {
|
||||
$this->storageDir = $this->kernel->getProjectDir().'/var/storage/documents';
|
||||
}
|
||||
|
||||
public function getStorageDir(): string
|
||||
{
|
||||
return $this->storageDir;
|
||||
}
|
||||
|
||||
public function getAbsolutePath(string $relativePath): string
|
||||
{
|
||||
return $this->storageDir.'/'.$relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store binary content and return the relative path.
|
||||
* Path format: {year}/{month}/{documentId}.{ext}.
|
||||
*/
|
||||
public function store(string $content, string $documentId, string $extension): string
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$subDir = $now->format('Y').'/'.$now->format('m');
|
||||
$relativePath = $subDir.'/'.$documentId.'.'.$extension;
|
||||
$absolutePath = $this->storageDir.'/'.$relativePath;
|
||||
|
||||
$dir = dirname($absolutePath);
|
||||
if (!is_dir($dir)) {
|
||||
if (!mkdir($dir, 0o775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException(sprintf('Cannot create directory "%s"', $dir));
|
||||
}
|
||||
}
|
||||
|
||||
$bytesWritten = file_put_contents($absolutePath, $content);
|
||||
if (false === $bytesWritten) {
|
||||
throw new RuntimeException(sprintf('Cannot write file "%s"', $absolutePath));
|
||||
}
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a file from a given source path (e.g., temp upload).
|
||||
*/
|
||||
public function storeFromPath(string $sourcePath, string $documentId, string $extension): string
|
||||
{
|
||||
$content = file_get_contents($sourcePath);
|
||||
if (false === $content) {
|
||||
throw new RuntimeException(sprintf('Cannot read source file "%s"', $sourcePath));
|
||||
}
|
||||
|
||||
return $this->store($content, $documentId, $extension);
|
||||
}
|
||||
|
||||
public function read(string $relativePath): string
|
||||
{
|
||||
$absolutePath = $this->getAbsolutePath($relativePath);
|
||||
if (!file_exists($absolutePath)) {
|
||||
throw new RuntimeException(sprintf('File not found: "%s"', $absolutePath));
|
||||
}
|
||||
|
||||
$content = file_get_contents($absolutePath);
|
||||
if (false === $content) {
|
||||
throw new RuntimeException(sprintf('Cannot read file "%s"', $absolutePath));
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function delete(string $relativePath): bool
|
||||
{
|
||||
$absolutePath = $this->getAbsolutePath($relativePath);
|
||||
if (!file_exists($absolutePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return @unlink($absolutePath);
|
||||
}
|
||||
|
||||
public function exists(string $relativePath): bool
|
||||
{
|
||||
return file_exists($this->getAbsolutePath($relativePath));
|
||||
}
|
||||
|
||||
public function isBase64DataUri(string $path): bool
|
||||
{
|
||||
return str_starts_with($path, 'data:');
|
||||
}
|
||||
|
||||
public function extensionFromMimeType(string $mimeType): string
|
||||
{
|
||||
$map = [
|
||||
'application/pdf' => 'pdf',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/svg+xml' => 'svg',
|
||||
'image/bmp' => 'bmp',
|
||||
'text/plain' => 'txt',
|
||||
'text/csv' => 'csv',
|
||||
'application/json' => 'json',
|
||||
'application/xml' => 'xml',
|
||||
'application/zip' => 'zip',
|
||||
'audio/mpeg' => 'mp3',
|
||||
'audio/ogg' => 'ogg',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/webm' => 'webm',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
];
|
||||
|
||||
return $map[$mimeType] ?? 'bin';
|
||||
}
|
||||
|
||||
public function extensionFromFilename(string $filename): string
|
||||
{
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
|
||||
return '' !== $ext ? strtolower($ext) : 'bin';
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,63 @@ namespace App\Service;
|
||||
|
||||
class PdfCompressorService
|
||||
{
|
||||
/**
|
||||
* Compress an actual PDF file on disk. Returns metadata or null if no gain.
|
||||
*
|
||||
* @return null|array{size: int, originalSize: int, saved: int}
|
||||
*/
|
||||
public function compressFile(string $absolutePath): ?array
|
||||
{
|
||||
exec('which qpdf', $qpdfPath, $returnCode);
|
||||
if (0 !== $returnCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!file_exists($absolutePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$originalSize = filesize($absolutePath);
|
||||
if (false === $originalSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tempOutput = tempnam(sys_get_temp_dir(), 'pdf_out_');
|
||||
|
||||
$command = sprintf(
|
||||
'qpdf --linearize --object-streams=generate %s %s 2>&1',
|
||||
escapeshellarg($absolutePath),
|
||||
escapeshellarg($tempOutput)
|
||||
);
|
||||
|
||||
exec($command, $cmdOutput, $returnCode);
|
||||
|
||||
if (0 !== $returnCode || !file_exists($tempOutput)) {
|
||||
@unlink($tempOutput);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$compressedSize = filesize($tempOutput);
|
||||
if (false === $compressedSize || $compressedSize >= $originalSize) {
|
||||
@unlink($tempOutput);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!rename($tempOutput, $absolutePath)) {
|
||||
@unlink($tempOutput);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'size' => $compressedSize,
|
||||
'originalSize' => $originalSize,
|
||||
'saved' => $originalSize - $compressedSize,
|
||||
];
|
||||
}
|
||||
|
||||
public function compressBase64Pdf(string $base64Data): ?array
|
||||
{
|
||||
// Check if qpdf is available
|
||||
|
||||
128
src/State/DocumentUploadProcessor.php
Normal file
128
src/State/DocumentUploadProcessor.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Document;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
final class DocumentUploadProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $decorated,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (null === $request) {
|
||||
throw new BadRequestHttpException('No request available.');
|
||||
}
|
||||
|
||||
$contentType = $request->headers->get('Content-Type', '');
|
||||
|
||||
// Multipart/form-data → file upload
|
||||
if (str_contains($contentType, 'multipart/form-data')) {
|
||||
return $this->handleMultipartUpload($operation, $uriVariables, $context, $request);
|
||||
}
|
||||
|
||||
// Fallback to default processor for legacy JSON/Base64 requests
|
||||
if (!$data instanceof Document) {
|
||||
return $this->decorated->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
return $this->decorated->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
private function handleMultipartUpload(
|
||||
Operation $operation,
|
||||
array $uriVariables,
|
||||
array $context,
|
||||
Request $request,
|
||||
): Document {
|
||||
/** @var null|UploadedFile $file */
|
||||
$file = $request->files->get('file');
|
||||
if (null === $file || !$file->isValid()) {
|
||||
throw new BadRequestHttpException('A valid file is required in the "file" field.');
|
||||
}
|
||||
|
||||
$document = new Document();
|
||||
|
||||
// Metadata from form fields
|
||||
$name = $request->request->get('name', $file->getClientOriginalName());
|
||||
$filename = $file->getClientOriginalName();
|
||||
$mimeType = $file->getMimeType() ?: $request->request->get('mimeType', 'application/octet-stream');
|
||||
$size = $file->getSize();
|
||||
|
||||
$document->setName($name);
|
||||
$document->setFilename($filename);
|
||||
$document->setMimeType($mimeType);
|
||||
$document->setSize((int) $size);
|
||||
|
||||
// Handle entity relations from form fields
|
||||
$this->setRelationsFromRequest($document, $request);
|
||||
|
||||
// Generate CUID early so we can use it for the filename on disk
|
||||
$documentId = 'cl'.bin2hex(random_bytes(12));
|
||||
$document->setId($documentId);
|
||||
|
||||
// Store file on disk
|
||||
$extension = $this->storageService->extensionFromFilename($filename);
|
||||
$relativePath = $this->storageService->storeFromPath(
|
||||
$file->getPathname(),
|
||||
$documentId,
|
||||
$extension,
|
||||
);
|
||||
$document->setPath($relativePath);
|
||||
|
||||
// Persist via decorated processor (triggers prePersist for timestamps)
|
||||
return $this->decorated->process($document, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
private function setRelationsFromRequest(Document $document, Request $request): void
|
||||
{
|
||||
$relationMap = [
|
||||
'machineId' => 'Machine',
|
||||
'composantId' => 'Composant',
|
||||
'pieceId' => 'Piece',
|
||||
'productId' => 'Product',
|
||||
'siteId' => 'Site',
|
||||
];
|
||||
|
||||
foreach ($relationMap as $field => $entityName) {
|
||||
$value = $request->request->get($field);
|
||||
if (!$value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accept both raw ID and IRI format
|
||||
$id = $value;
|
||||
if (str_contains($value, '/')) {
|
||||
$parts = explode('/', rtrim($value, '/'));
|
||||
$id = end($parts);
|
||||
}
|
||||
|
||||
$entityClass = 'App\Entity\\'.$entityName;
|
||||
$entity = $this->em->getReference($entityClass, $id);
|
||||
$setter = 'set'.$entityName;
|
||||
$document->{$setter}($entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user