Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d89c97f0a0 | ||
|
|
7a5dd0b555 | ||
|
|
44d69db560 | ||
|
|
453065c9f0 | ||
|
|
eb85323116 | ||
|
|
2dfa501a65 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# 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
|
## [1.7.0] - 2026-03-02
|
||||||
|
|
||||||
### Ajouts
|
### Ajouts
|
||||||
|
|||||||
168
CLAUDE.md
Normal file
168
CLAUDE.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# CLAUDE.md — Inventory Project
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
|
||||||
|
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Tech | Version |
|
||||||
|
|-------|------|---------|
|
||||||
|
| Backend | Symfony + API Platform | 8.0 / ^4.2 |
|
||||||
|
| PHP | PHP | >=8.4 |
|
||||||
|
| Database | PostgreSQL | 16 |
|
||||||
|
| Frontend | Nuxt (SPA, SSR off) | 4 |
|
||||||
|
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
|
||||||
|
| CSS | TailwindCSS 4 + DaisyUI 5 | |
|
||||||
|
| Auth | Session-based (cookies, pas JWT) | |
|
||||||
|
| Containers | Docker Compose | |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Inventory/ # Backend Symfony (repo principal)
|
||||||
|
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
|
||||||
|
├── src/Controller/ # Controllers custom (session, comments, audit…)
|
||||||
|
├── src/EventSubscriber/ # Audit subscribers (onFlush)
|
||||||
|
├── config/ # Config Symfony
|
||||||
|
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
|
||||||
|
├── docker/ # Dockerfile + .env.docker
|
||||||
|
├── scripts/ # release.sh, normalize-dump.py
|
||||||
|
├── fixtures/ # SQL fixtures
|
||||||
|
├── tests/ # PHPUnit
|
||||||
|
├── pre-commit, commit-msg # Git hooks
|
||||||
|
├── makefile # Commandes Docker/dev
|
||||||
|
├── VERSION # Source unique de version (semver)
|
||||||
|
├── Inventory_frontend/ # ← SUBMODULE GIT (repo séparé)
|
||||||
|
│ ├── app/pages/ # Pages Nuxt (file-based routing)
|
||||||
|
│ ├── app/components/ # Composants Vue (auto-imported)
|
||||||
|
│ ├── app/composables/ # Composables Vue
|
||||||
|
│ ├── app/shared/ # Types, utils, validation
|
||||||
|
│ ├── app/middleware/ # Auth middleware global
|
||||||
|
│ └── app/services/ # Service layer (wrappers useApi)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker
|
||||||
|
make start # Démarrer les containers
|
||||||
|
make stop # Arrêter
|
||||||
|
make shell # Shell dans le container PHP
|
||||||
|
make install # Install complet (composer + npm + build)
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
make test # PHPUnit
|
||||||
|
docker compose exec php vendor/bin/php-cs-fixer fix # Linter PHP
|
||||||
|
docker compose exec php php bin/console doctrine:migrations:migrate
|
||||||
|
|
||||||
|
# Frontend (dans Inventory_frontend/)
|
||||||
|
npm run dev # Dev server (port 3001)
|
||||||
|
npm run build # Build production
|
||||||
|
npm run lint:fix # ESLint fix
|
||||||
|
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
||||||
|
|
||||||
|
# Release
|
||||||
|
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git Conventions
|
||||||
|
|
||||||
|
### Branches
|
||||||
|
- `master` — production
|
||||||
|
- `develop` — branche principale de dev (cible des PR)
|
||||||
|
- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail
|
||||||
|
|
||||||
|
### Commit Message Format (enforced by hook)
|
||||||
|
```
|
||||||
|
<type>(<scope optionnel>) : <message>
|
||||||
|
```
|
||||||
|
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
|
||||||
|
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip`
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
- `feat(auth) : add login page`
|
||||||
|
- `fix(machines) : prevent null crash on skeleton creation`
|
||||||
|
|
||||||
|
### Pre-commit Hook
|
||||||
|
1. php-cs-fixer sur les fichiers PHP stagés
|
||||||
|
2. PHPUnit — bloque le commit si tests échouent
|
||||||
|
|
||||||
|
### Submodule Workflow
|
||||||
|
Le frontend est un submodule git. Lors d'un commit frontend :
|
||||||
|
1. Commit dans `Inventory_frontend/` d'abord
|
||||||
|
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
|
||||||
|
3. Push les deux repos
|
||||||
|
|
||||||
|
## Architecture Backend
|
||||||
|
|
||||||
|
### Entités Principales
|
||||||
|
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `TypeMachine`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`
|
||||||
|
|
||||||
|
### Patterns
|
||||||
|
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||||
|
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
|
||||||
|
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` avec `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`
|
||||||
|
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform
|
||||||
|
- **Audit** : Subscribers Doctrine `onFlush` capturent diff + snapshot complet
|
||||||
|
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
||||||
|
|
||||||
|
### Rôles (hiérarchie)
|
||||||
|
```
|
||||||
|
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL — ATTENTION
|
||||||
|
- Les noms de colonnes sont **TOUJOURS EN MINUSCULES** dans PG
|
||||||
|
- Doctrine utilise camelCase (`typePieceId`) mais PG stocke `typepieceid`
|
||||||
|
- Le SQL brut doit utiliser les noms lowercase
|
||||||
|
- Tables de jointure many-to-many : colonnes `a` et `b` (ex: `_piececonstructeurs`)
|
||||||
|
|
||||||
|
## Architecture Frontend
|
||||||
|
|
||||||
|
### Patterns
|
||||||
|
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
|
||||||
|
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
|
||||||
|
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
|
||||||
|
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
|
||||||
|
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
|
||||||
|
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
|
||||||
|
- **Auto-imports** : Nuxt auto-importe composants (`components/`) et composables (`composables/`)
|
||||||
|
|
||||||
|
### DaisyUI Classes
|
||||||
|
- Input : `input input-bordered input-sm md:input-md`
|
||||||
|
- Textarea : `textarea textarea-bordered textarea-sm md:textarea-md`
|
||||||
|
- Select : `select select-bordered select-sm md:select-md`
|
||||||
|
- Button : `btn btn-sm md:btn-md btn-primary`
|
||||||
|
|
||||||
|
## Règles Importantes
|
||||||
|
|
||||||
|
### Toujours faire AVANT de modifier du code
|
||||||
|
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
|
||||||
|
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
|
||||||
|
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
|
||||||
|
|
||||||
|
### Après chaque modification
|
||||||
|
1. Backend PHP : `docker compose exec php vendor/bin/php-cs-fixer fix`
|
||||||
|
2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés
|
||||||
|
|
||||||
|
### Ne jamais faire
|
||||||
|
- Ajouter des features non demandées, du code mort, ou des abstractions prématurées
|
||||||
|
- Utiliser `provide/inject` — le codebase utilise Props + Events
|
||||||
|
- Utiliser JWT/tokens — l'auth est session-based
|
||||||
|
- Écrire du SQL avec des noms camelCase — PostgreSQL = lowercase
|
||||||
|
- Committer sans que l'utilisateur le demande explicitement
|
||||||
|
- Force push sans confirmation explicite
|
||||||
|
- Modifier la config git
|
||||||
|
|
||||||
|
### Submodule — Synchronisation
|
||||||
|
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
|
||||||
|
- Main repo : `git checkout master && git merge develop && git push`
|
||||||
|
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
|
||||||
|
|
||||||
|
## URLs Locales
|
||||||
|
- API Symfony : `http://localhost:8081/api`
|
||||||
|
- Nuxt dev : `http://localhost:3001`
|
||||||
|
- Adminer (PG) : `http://localhost:5050`
|
||||||
|
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||||
Submodule Inventory_frontend updated: a98ab8c275...e88ed5b8f2
@@ -23,8 +23,8 @@ final class Version20260302103003 extends AbstractMigration
|
|||||||
$this->addSql('COMMENT ON COLUMN comments.created_at IS \'(DC2Type:datetime_immutable)\'');
|
$this->addSql('COMMENT ON COLUMN comments.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
$this->addSql('COMMENT ON COLUMN comments.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
$this->addSql('COMMENT ON COLUMN comments.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
|
||||||
// Piece: remove unique on name
|
// Piece: remove unique constraint on name (it's a constraint, not just an index)
|
||||||
$this->addSql('DROP INDEX IF EXISTS uniq_b92d74725e237e06');
|
$this->addSql('ALTER TABLE pieces DROP CONSTRAINT IF EXISTS uniq_b92d74725e237e06');
|
||||||
|
|
||||||
// Deduplicate piece references before adding unique constraint
|
// Deduplicate piece references before adding unique constraint
|
||||||
$this->addSql("
|
$this->addSql("
|
||||||
|
|||||||
28
migrations/Version20260302120000.php
Normal file
28
migrations/Version20260302120000.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260302120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add description column to pieces and composants tables';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS description');
|
||||||
|
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS description');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Document;
|
||||||
use App\Repository\DocumentRepository;
|
use App\Repository\DocumentRepository;
|
||||||
|
use App\Service\DocumentStorageService;
|
||||||
|
use App\Service\PdfCompressorService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
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\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
use function strlen;
|
||||||
|
|
||||||
#[AsCommand(
|
#[AsCommand(
|
||||||
name: 'app:compress-pdf',
|
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
|
class CompressPdfCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DocumentRepository $documentRepository,
|
private readonly DocumentRepository $documentRepository,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly PdfCompressorService $pdfCompressor,
|
||||||
|
private readonly DocumentStorageService $storageService,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -61,87 +69,13 @@ class CompressPdfCommand extends Command
|
|||||||
$compressed = 0;
|
$compressed = 0;
|
||||||
|
|
||||||
foreach ($documents as $document) {
|
foreach ($documents as $document) {
|
||||||
$base64Data = $document->getPath();
|
$path = $document->getPath();
|
||||||
|
|
||||||
// Remove data URI prefix if present
|
if ($this->storageService->isBase64DataUri($path)) {
|
||||||
if (str_contains($base64Data, ',')) {
|
$this->compressBase64Document($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
||||||
$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
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
$io->text(sprintf(
|
$this->compressFileDocument($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
||||||
' - %s: Already optimal (%s)',
|
|
||||||
$document->getName(),
|
|
||||||
$this->formatBytes($originalSize)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@unlink($tempInput);
|
|
||||||
@unlink($tempOutput);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$dryRun && $compressed > 0) {
|
if (!$dryRun && $compressed > 0) {
|
||||||
@@ -161,6 +95,115 @@ class CompressPdfCommand extends Command
|
|||||||
return Command::SUCCESS;
|
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
|
private function formatBytes(int $bytes): string
|
||||||
{
|
{
|
||||||
$units = ['B', 'KB', 'MB', 'GB'];
|
$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(),
|
'id' => $document->getId(),
|
||||||
'name' => $document->getName(),
|
'name' => $document->getName(),
|
||||||
'filename' => $document->getFilename(),
|
'filename' => $document->getFilename(),
|
||||||
'path' => $document->getPath(),
|
'fileUrl' => '/api/documents/'.$document->getId().'/file',
|
||||||
|
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
|
||||||
'mimeType' => $document->getMimeType(),
|
'mimeType' => $document->getMimeType(),
|
||||||
'size' => $document->getSize(),
|
'size' => $document->getSize(),
|
||||||
'siteId' => $document->getSite()?->getId(),
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ class Composant
|
|||||||
#[Groups(['composant:read'])]
|
#[Groups(['composant:read'])]
|
||||||
private ?string $reference = null;
|
private ?string $reference = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
#[Groups(['composant:read'])]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
|
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
|
||||||
#[Groups(['composant:read'])]
|
#[Groups(['composant:read'])]
|
||||||
private ?string $prix = null;
|
private ?string $prix = null;
|
||||||
@@ -175,6 +179,18 @@ class Composant
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getPrix(): ?string
|
public function getPrix(): ?string
|
||||||
{
|
{
|
||||||
return $this->prix;
|
return $this->prix;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Entity;
|
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\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
@@ -11,6 +15,7 @@ use ApiPlatform\Metadata\GetCollection;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Repository\DocumentRepository;
|
use App\Repository\DocumentRepository;
|
||||||
|
use App\State\DocumentUploadProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
@@ -19,6 +24,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
|
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
|
||||||
#[ORM\Table(name: 'documents')]
|
#[ORM\Table(name: 'documents')]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[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(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(
|
new GetCollection(
|
||||||
@@ -29,12 +37,18 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
security: "is_granted('ROLE_VIEWER')",
|
security: "is_granted('ROLE_VIEWER')",
|
||||||
normalizationContext: ['groups' => ['document:list', 'document:detail']],
|
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 Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
],
|
],
|
||||||
paginationClientItemsPerPage: true,
|
paginationClientItemsPerPage: true,
|
||||||
paginationMaximumItemsPerPage: 200
|
paginationMaximumItemsPerPage: 500,
|
||||||
|
order: ['createdAt' => 'DESC']
|
||||||
)]
|
)]
|
||||||
class Document
|
class Document
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ class Piece
|
|||||||
#[Groups(['piece:read'])]
|
#[Groups(['piece:read'])]
|
||||||
private ?string $reference = null;
|
private ?string $reference = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
#[Groups(['piece:read'])]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
|
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
|
||||||
#[Groups(['piece:read'])]
|
#[Groups(['piece:read'])]
|
||||||
private ?string $prix = null;
|
private ?string $prix = null;
|
||||||
@@ -177,6 +181,18 @@ class Piece
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getPrix(): ?string
|
public function getPrix(): ?string
|
||||||
{
|
{
|
||||||
return $this->prix;
|
return $this->prix;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\ApiResource;
|
|||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Repository\SiteRepository;
|
use App\Repository\SiteRepository;
|
||||||
@@ -28,6 +29,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
],
|
],
|
||||||
paginationClientItemsPerPage: true,
|
paginationClientItemsPerPage: true,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\GetCollection;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Repository\TypeMachineRepository;
|
use App\Repository\TypeMachineRepository;
|
||||||
|
use App\State\TypeMachinePutProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
@@ -30,7 +31,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: TypeMachinePutProcessor::class, deserialize: false, validate: false),
|
||||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
],
|
],
|
||||||
paginationClientItemsPerPage: true,
|
paginationClientItemsPerPage: true,
|
||||||
@@ -100,21 +101,21 @@ class TypeMachine
|
|||||||
/**
|
/**
|
||||||
* @var Collection<int, TypeMachineComponentRequirement>
|
* @var Collection<int, TypeMachineComponentRequirement>
|
||||||
*/
|
*/
|
||||||
#[ORM\OneToMany(targetEntity: TypeMachineComponentRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])]
|
#[ORM\OneToMany(targetEntity: TypeMachineComponentRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
#[ApiProperty(readableLink: true, writableLink: true)]
|
#[ApiProperty(readableLink: true, writableLink: true)]
|
||||||
private Collection $componentRequirements;
|
private Collection $componentRequirements;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, TypeMachinePieceRequirement>
|
* @var Collection<int, TypeMachinePieceRequirement>
|
||||||
*/
|
*/
|
||||||
#[ORM\OneToMany(targetEntity: TypeMachinePieceRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])]
|
#[ORM\OneToMany(targetEntity: TypeMachinePieceRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
#[ApiProperty(readableLink: true, writableLink: true)]
|
#[ApiProperty(readableLink: true, writableLink: true)]
|
||||||
private Collection $pieceRequirements;
|
private Collection $pieceRequirements;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, TypeMachineProductRequirement>
|
* @var Collection<int, TypeMachineProductRequirement>
|
||||||
*/
|
*/
|
||||||
#[ORM\OneToMany(targetEntity: TypeMachineProductRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])]
|
#[ORM\OneToMany(targetEntity: TypeMachineProductRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
#[ApiProperty(readableLink: true, writableLink: true)]
|
#[ApiProperty(readableLink: true, writableLink: true)]
|
||||||
private Collection $productRequirements;
|
private Collection $productRequirements;
|
||||||
|
|
||||||
@@ -319,11 +320,7 @@ class TypeMachine
|
|||||||
|
|
||||||
public function removeComponentRequirement(TypeMachineComponentRequirement $componentRequirement): static
|
public function removeComponentRequirement(TypeMachineComponentRequirement $componentRequirement): static
|
||||||
{
|
{
|
||||||
if ($this->componentRequirements->removeElement($componentRequirement)) {
|
$this->componentRequirements->removeElement($componentRequirement);
|
||||||
if ($componentRequirement->getTypeMachine() === $this) {
|
|
||||||
$componentRequirement->setTypeMachine(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -348,11 +345,7 @@ class TypeMachine
|
|||||||
|
|
||||||
public function removePieceRequirement(TypeMachinePieceRequirement $pieceRequirement): static
|
public function removePieceRequirement(TypeMachinePieceRequirement $pieceRequirement): static
|
||||||
{
|
{
|
||||||
if ($this->pieceRequirements->removeElement($pieceRequirement)) {
|
$this->pieceRequirements->removeElement($pieceRequirement);
|
||||||
if ($pieceRequirement->getTypeMachine() === $this) {
|
|
||||||
$pieceRequirement->setTypeMachine(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -377,11 +370,7 @@ class TypeMachine
|
|||||||
|
|
||||||
public function removeProductRequirement(TypeMachineProductRequirement $productRequirement): static
|
public function removeProductRequirement(TypeMachineProductRequirement $productRequirement): static
|
||||||
{
|
{
|
||||||
if ($this->productRequirements->removeElement($productRequirement)) {
|
$this->productRequirements->removeElement($productRequirement);
|
||||||
if ($productRequirement->getTypeMachine() === $this) {
|
|
||||||
$productRequirement->setTypeMachine(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
namespace App\EventListener;
|
||||||
|
|
||||||
use App\Entity\Document;
|
use App\Entity\Document;
|
||||||
|
use App\Service\DocumentStorageService;
|
||||||
use App\Service\PdfCompressorService;
|
use App\Service\PdfCompressorService;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
||||||
use Doctrine\ORM\Events;
|
use Doctrine\ORM\Events;
|
||||||
@@ -16,6 +17,7 @@ class DocumentPdfCompressorListener
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PdfCompressorService $pdfCompressor,
|
private readonly PdfCompressorService $pdfCompressor,
|
||||||
|
private readonly DocumentStorageService $storageService,
|
||||||
private readonly ?LoggerInterface $logger = null,
|
private readonly ?LoggerInterface $logger = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -35,15 +37,26 @@ class DocumentPdfCompressorListener
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->pdfCompressor->compressBase64Pdf($document->getPath());
|
$path = $document->getPath();
|
||||||
|
|
||||||
if (null === $result) {
|
if ($this->storageService->isBase64DataUri($path)) {
|
||||||
return;
|
// 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', [
|
$this->logger?->info('PDF compressed', [
|
||||||
'document' => $document->getName(),
|
'document' => $document->getName(),
|
||||||
'originalSize' => $result['originalSize'],
|
'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
|
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
|
public function compressBase64Pdf(string $base64Data): ?array
|
||||||
{
|
{
|
||||||
// Check if qpdf is available
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/State/TypeMachinePutProcessor.php
Normal file
211
src/State/TypeMachinePutProcessor.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\CustomField;
|
||||||
|
use App\Entity\ModelType;
|
||||||
|
use App\Entity\TypeMachine;
|
||||||
|
use App\Entity\TypeMachineComponentRequirement;
|
||||||
|
use App\Entity\TypeMachinePieceRequirement;
|
||||||
|
use App\Entity\TypeMachineProductRequirement;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
use function array_key_exists;
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
final class TypeMachinePutProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $uriVariables
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TypeMachine
|
||||||
|
{
|
||||||
|
$typeMachine = $this->em->getRepository(TypeMachine::class)->find($uriVariables['id']);
|
||||||
|
|
||||||
|
if (!$typeMachine) {
|
||||||
|
throw new NotFoundHttpException('Type de machine non trouvé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: cannot edit if machines are linked
|
||||||
|
if (!$typeMachine->getMachines()->isEmpty()) {
|
||||||
|
throw new HttpException(422, 'Ce type de machine ne peut pas être modifié car des machines y sont rattachées.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
$payload = json_decode($request->getContent(), true) ?? [];
|
||||||
|
|
||||||
|
$this->updateScalarProperties($typeMachine, $payload);
|
||||||
|
|
||||||
|
if (array_key_exists('customFields', $payload)) {
|
||||||
|
$this->replaceCustomFields($typeMachine, $payload['customFields'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('componentRequirements', $payload)) {
|
||||||
|
$this->replaceComponentRequirements($typeMachine, $payload['componentRequirements'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('pieceRequirements', $payload)) {
|
||||||
|
$this->replacePieceRequirements($typeMachine, $payload['pieceRequirements'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('productRequirements', $payload)) {
|
||||||
|
$this->replaceProductRequirements($typeMachine, $payload['productRequirements'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $typeMachine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateScalarProperties(TypeMachine $typeMachine, array $payload): void
|
||||||
|
{
|
||||||
|
if (isset($payload['name'])) {
|
||||||
|
$typeMachine->setName($payload['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('description', $payload)) {
|
||||||
|
$typeMachine->setDescription($payload['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('category', $payload)) {
|
||||||
|
$typeMachine->setCategory($payload['category']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('maintenanceFrequency', $payload)) {
|
||||||
|
$typeMachine->setMaintenanceFrequency($payload['maintenanceFrequency']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('components', $payload)) {
|
||||||
|
$typeMachine->setComponents($payload['components']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('criticalParts', $payload)) {
|
||||||
|
$typeMachine->setCriticalParts($payload['criticalParts']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('machinePieces', $payload)) {
|
||||||
|
$typeMachine->setMachinePieces($payload['machinePieces']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('specifications', $payload)) {
|
||||||
|
$typeMachine->setSpecifications($payload['specifications']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replaceCustomFields(TypeMachine $typeMachine, array $fieldsData): void
|
||||||
|
{
|
||||||
|
foreach ($typeMachine->getCustomFields()->toArray() as $old) {
|
||||||
|
$typeMachine->removeCustomField($old);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($fieldsData as $index => $data) {
|
||||||
|
$field = new CustomField();
|
||||||
|
$field->setName($data['name'] ?? '');
|
||||||
|
$field->setType($data['type'] ?? 'text');
|
||||||
|
$field->setRequired($data['required'] ?? false);
|
||||||
|
$field->setOptions($data['options'] ?? null);
|
||||||
|
$field->setOrderIndex($data['orderIndex'] ?? $index);
|
||||||
|
$typeMachine->addCustomField($field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replaceComponentRequirements(TypeMachine $typeMachine, array $requirementsData): void
|
||||||
|
{
|
||||||
|
foreach ($typeMachine->getComponentRequirements()->toArray() as $old) {
|
||||||
|
$typeMachine->removeComponentRequirement($old);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($requirementsData as $index => $data) {
|
||||||
|
$req = new TypeMachineComponentRequirement();
|
||||||
|
$req->setLabel($data['label'] ?? null);
|
||||||
|
$req->setMinCount($data['minCount'] ?? 1);
|
||||||
|
$req->setMaxCount($data['maxCount'] ?? null);
|
||||||
|
$req->setRequired($data['required'] ?? true);
|
||||||
|
$req->setAllowNewModels($data['allowNewModels'] ?? true);
|
||||||
|
$req->setOrderIndex($data['orderIndex'] ?? $index);
|
||||||
|
|
||||||
|
$modelType = $this->resolveModelType($data['typeComposant'] ?? null);
|
||||||
|
if ($modelType) {
|
||||||
|
$req->setTypeComposant($modelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeMachine->addComponentRequirement($req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replacePieceRequirements(TypeMachine $typeMachine, array $requirementsData): void
|
||||||
|
{
|
||||||
|
foreach ($typeMachine->getPieceRequirements()->toArray() as $old) {
|
||||||
|
$typeMachine->removePieceRequirement($old);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($requirementsData as $index => $data) {
|
||||||
|
$req = new TypeMachinePieceRequirement();
|
||||||
|
$req->setLabel($data['label'] ?? null);
|
||||||
|
$req->setMinCount($data['minCount'] ?? 0);
|
||||||
|
$req->setMaxCount($data['maxCount'] ?? null);
|
||||||
|
$req->setRequired($data['required'] ?? false);
|
||||||
|
$req->setAllowNewModels($data['allowNewModels'] ?? true);
|
||||||
|
$req->setOrderIndex($data['orderIndex'] ?? $index);
|
||||||
|
|
||||||
|
$modelType = $this->resolveModelType($data['typePiece'] ?? null);
|
||||||
|
if ($modelType) {
|
||||||
|
$req->setTypePiece($modelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeMachine->addPieceRequirement($req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replaceProductRequirements(TypeMachine $typeMachine, array $requirementsData): void
|
||||||
|
{
|
||||||
|
foreach ($typeMachine->getProductRequirements()->toArray() as $old) {
|
||||||
|
$typeMachine->removeProductRequirement($old);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($requirementsData as $index => $data) {
|
||||||
|
$req = new TypeMachineProductRequirement();
|
||||||
|
$req->setLabel($data['label'] ?? null);
|
||||||
|
$req->setMinCount($data['minCount'] ?? 0);
|
||||||
|
$req->setMaxCount($data['maxCount'] ?? null);
|
||||||
|
$req->setRequired($data['required'] ?? false);
|
||||||
|
$req->setAllowNewModels($data['allowNewModels'] ?? true);
|
||||||
|
$req->setOrderIndex($data['orderIndex'] ?? $index);
|
||||||
|
|
||||||
|
$modelType = $this->resolveModelType($data['typeProduct'] ?? null);
|
||||||
|
if ($modelType) {
|
||||||
|
$req->setTypeProduct($modelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeMachine->addProductRequirement($req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveModelType(mixed $value): ?ModelType
|
||||||
|
{
|
||||||
|
if (!$value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $value;
|
||||||
|
|
||||||
|
if (is_string($value) && preg_match('#/api/model_types/(.+)$#', $value, $matches)) {
|
||||||
|
$id = $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->em->getReference(ModelType::class, $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user