This commit is contained in:
Matthieu
2026-03-31 17:57:59 +02:00
parent 1b1dab65b6
commit 476060cf7d
45 changed files with 8547 additions and 648 deletions

View File

@@ -0,0 +1,871 @@
# Comment Document Attachments — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow users to attach one or more documents when creating a comment, via a single multipart/form-data request.
**Architecture:** Add a `comment` ManyToOne on Document entity (same pattern as machine/site/etc.), modify `CommentController::create()` to accept multipart/form-data with files + text fields, store files via existing `DocumentStorageService`, and update the frontend `CommentSection.vue` to include a file picker.
**Tech Stack:** Symfony 8, Doctrine, API Platform, Vue 3 Composition API, TypeScript, TailwindCSS/DaisyUI
---
### Task 1: Migration — add `comment_id` FK on `documents`
**Files:**
- Create: `migrations/Version20260323160000.php`
- [ ] **Step 1: Create the migration**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260323160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add comment_id FK on documents table';
}
public function up(Schema $schema): void
{
$this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'comment_id') THEN ALTER TABLE documents ADD COLUMN comment_id VARCHAR(36) DEFAULT NULL; END IF; END $$");
$this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_documents_comment') THEN ALTER TABLE documents ADD CONSTRAINT fk_documents_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE; END IF; END $$");
$this->addSql("CREATE INDEX IF NOT EXISTS idx_documents_comment_id ON documents(comment_id)");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_comment');
$this->addSql('DROP INDEX IF EXISTS idx_documents_comment_id');
$this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS comment_id');
}
}
```
- [ ] **Step 2: Run the migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration executes successfully.
- [ ] **Step 3: Update test schema**
Run: `make test-setup`
- [ ] **Step 4: Commit**
```bash
git add migrations/Version20260323160000.php
git commit -m "feat(documents) : add comment_id FK on documents table"
```
---
### Task 2: Entity updates — Document.comment + Comment.documents
**Files:**
- Modify: `src/Entity/Document.php`
- Modify: `src/Entity/Comment.php`
- [ ] **Step 1: Add `comment` ManyToOne on Document entity**
In `src/Entity/Document.php`, add after the `$site` property (around line 109):
```php
#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Comment $comment = null;
```
And add getter/setter:
```php
public function getComment(): ?Comment
{
return $this->comment;
}
public function setComment(?Comment $comment): static
{
$this->comment = $comment;
return $this;
}
```
- [ ] **Step 2: Add `documents` OneToMany on Comment entity**
In `src/Entity/Comment.php`, add the import:
```php
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
```
Add property after `$updatedAt`:
```php
/** @var Collection<int, Document> */
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])]
private Collection $documents;
```
Initialize in constructor:
```php
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->documents = new ArrayCollection();
}
```
Add getter:
```php
/** @return Collection<int, Document> */
public function getDocuments(): Collection
{
return $this->documents;
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 4: Run tests to check nothing broke**
Run: `make test`
Expected: All existing tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/Entity/Document.php src/Entity/Comment.php
git commit -m "feat(documents) : add Comment-Document relationship (ManyToOne/OneToMany)"
```
---
### Task 3: Update CommentController to accept multipart/form-data with files
**Files:**
- Modify: `src/Controller/CommentController.php`
- [ ] **Step 1: Add DocumentStorageService dependency and update create() method**
Update constructor to inject `DocumentStorageService`:
```php
use App\Entity\Document;
use App\Enum\DocumentType;
use App\Service\DocumentStorageService;
use Symfony\Component\HttpFoundation\File\UploadedFile;
```
```php
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProfileRepository $profiles,
private readonly DocumentStorageService $storageService,
) {}
```
Replace the `create()` method body to handle both JSON and multipart:
```php
#[Route('', name: 'api_comments_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$session = $request->getSession();
$profileId = $session->get('profileId');
if (!$profileId) {
return $this->json(['message' => 'Aucun profil actif.'], 401);
}
$profile = $this->profiles->find($profileId);
if (!$profile) {
return $this->json(['message' => 'Profil introuvable.'], 401);
}
// Parse fields from JSON or form-data
$contentType = $request->headers->get('Content-Type', '');
if (str_contains($contentType, 'multipart/form-data')) {
$content = trim((string) $request->request->get('content', ''));
$entityType = trim((string) $request->request->get('entityType', ''));
$entityId = trim((string) $request->request->get('entityId', ''));
$entityName = $request->request->get('entityName') ? trim((string) $request->request->get('entityName')) : null;
} else {
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return $this->json(['message' => 'Payload JSON invalide.'], 400);
}
$content = trim((string) ($payload['content'] ?? ''));
$entityType = trim((string) ($payload['entityType'] ?? ''));
$entityId = trim((string) ($payload['entityId'] ?? ''));
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
}
if ('' === $content) {
return $this->json(['message' => 'Le contenu est requis.'], 400);
}
$allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
if (!in_array($entityType, $allowedTypes, true)) {
return $this->json(['message' => 'Type d\'entité invalide.'], 400);
}
if ('' === $entityId) {
return $this->json(['message' => 'L\'identifiant de l\'entité est requis.'], 400);
}
$authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $authorName) {
$authorName = $profile->getEmail() ?? 'Inconnu';
}
$comment = new Comment();
$comment->setContent($content);
$comment->setEntityType($entityType);
$comment->setEntityId($entityId);
$comment->setEntityName($entityName);
$comment->setAuthorId($profileId);
$comment->setAuthorName($authorName);
$this->entityManager->persist($comment);
// Handle file uploads
/** @var UploadedFile[] $files */
$files = $request->files->all('files');
foreach ($files as $file) {
if (!$file instanceof UploadedFile || !$file->isValid()) {
continue;
}
$document = new Document();
$documentId = 'cl'.bin2hex(random_bytes(12));
$document->setId($documentId);
$document->setName($file->getClientOriginalName());
$document->setFilename($file->getClientOriginalName());
$document->setMimeType($file->getMimeType() ?: 'application/octet-stream');
$document->setSize((int) $file->getSize());
$document->setType(DocumentType::DOCUMENTATION);
$document->setComment($comment);
$extension = $this->storageService->extensionFromFilename($file->getClientOriginalName());
$relativePath = $this->storageService->storeFromPath(
$file->getPathname(),
$documentId,
$extension,
);
$document->setPath($relativePath);
$this->entityManager->persist($document);
}
$this->entityManager->flush();
return $this->json($this->normalize($comment), 201);
}
```
- [ ] **Step 2: Update normalize() to include documents**
```php
private function normalize(Comment $comment): array
{
$documents = [];
foreach ($comment->getDocuments() as $document) {
$documents[] = [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'type' => $document->getType()->value,
'fileUrl' => '/api/documents/'.$document->getId().'/file',
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
'createdAt' => $document->getCreatedAt()->format(DateTimeInterface::ATOM),
];
}
return [
'id' => $comment->getId(),
'content' => $comment->getContent(),
'entityType' => $comment->getEntityType(),
'entityId' => $comment->getEntityId(),
'entityName' => $comment->getEntityName(),
'authorId' => $comment->getAuthorId(),
'authorName' => $comment->getAuthorName(),
'status' => $comment->getStatus(),
'resolvedById' => $comment->getResolvedById(),
'resolvedByName' => $comment->getResolvedByName(),
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
'documents' => $documents,
];
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 4: Run tests**
Run: `make test`
Expected: All existing tests still pass (they use JSON, not multipart).
- [ ] **Step 5: Commit**
```bash
git add src/Controller/CommentController.php
git commit -m "feat(comments) : accept multipart/form-data with file uploads on create"
```
---
### Task 4: Update DocumentUploadProcessor and DocumentQueryController
**Files:**
- Modify: `src/State/DocumentUploadProcessor.php`
- Modify: `src/Controller/DocumentQueryController.php`
- [ ] **Step 1: Add `commentId` to DocumentUploadProcessor relation map**
In `src/State/DocumentUploadProcessor.php`, update `$relationMap` in `setRelationsFromRequest()`:
```php
$relationMap = [
'machineId' => 'Machine',
'composantId' => 'Composant',
'pieceId' => 'Piece',
'productId' => 'Product',
'siteId' => 'Site',
'commentId' => 'Comment',
];
```
- [ ] **Step 2: Add comment route to DocumentQueryController**
Add `CommentRepository` import and inject it, then add the route:
```php
use App\Repository\CommentRepository;
```
Add to constructor:
```php
private readonly CommentRepository $commentRepository,
```
Wait — `Comment` has no repository. Use the EntityManager instead. Add the route method:
```php
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$comment = $this->getEntityManager()->getRepository(\App\Entity\Comment::class)->find($id);
if (!$comment) {
return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
}
$documents = $this->documentRepository->findBy(['comment' => $comment]);
return $this->json($this->normalizeDocuments($documents));
}
```
Actually, the controller doesn't have `getEntityManager()`. Use `DocumentRepository` directly:
```php
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$documents = $this->documentRepository->findBy(['comment' => $id]);
return $this->json($this->normalizeDocuments($documents));
}
```
Wait — `findBy(['comment' => $id])` won't work with a string ID directly on a relation. Let me use the pattern from the existing code and add the Comment entity lookup. The simplest approach: inject `EntityManagerInterface`.
Actually, looking at the existing pattern more carefully, the other methods fetch the entity first and pass the object. We can use the documentRepository's entity manager. Let's just follow the exact same pattern and add a dependency. But actually, let's keep it simple — the documents table has `comment_id` column, so we can use a custom query. The simplest: just inject EntityManagerInterface.
```php
use Doctrine\ORM\EntityManagerInterface;
```
Add to constructor: `private readonly EntityManagerInterface $em,`
```php
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$comment = $this->em->find(\App\Entity\Comment::class, $id);
if (!$comment) {
return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
}
$documents = $this->documentRepository->findBy(['comment' => $comment]);
return $this->json($this->normalizeDocuments($documents));
}
```
- [ ] **Step 3: Update normalizeDocuments to include commentId**
Add to the normalizeDocuments return array:
```php
'commentId' => $document->getComment()?->getId(),
```
- [ ] **Step 4: Run php-cs-fixer + tests**
Run: `make php-cs-fixer-allow-risky && make test`
- [ ] **Step 5: Commit**
```bash
git add src/State/DocumentUploadProcessor.php src/Controller/DocumentQueryController.php
git commit -m "feat(documents) : add comment support in upload processor and query controller"
```
---
### Task 5: Backend tests — comment with documents
**Files:**
- Modify: `tests/Api/Controller/CommentControllerTest.php`
- [ ] **Step 1: Add test for creating comment with files**
```php
public function testCreateCommentWithFiles(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
// Create a temporary file for upload
$tmpFile = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile, 'test file content');
$uploadedFile = new \Symfony\Component\HttpFoundation\File\UploadedFile(
$tmpFile,
'test-doc.pdf',
'application/pdf',
null,
true,
);
$client->request('POST', '/api/comments', [
'headers' => ['Content-Type' => 'multipart/form-data'],
'extra' => [
'parameters' => [
'content' => 'Comment with file',
'entityType' => 'machine',
'entityId' => $machine->getId(),
'entityName' => 'Machine A',
],
'files' => [
'files' => [$uploadedFile],
],
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('Comment with file', $data['content']);
$this->assertCount(1, $data['documents']);
$this->assertSame('test-doc.pdf', $data['documents'][0]['filename']);
@unlink($tmpFile);
}
```
- [ ] **Step 2: Add test for creating comment with multiple files**
```php
public function testCreateCommentWithMultipleFiles(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
$tmpFile1 = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile1, 'content 1');
$tmpFile2 = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile2, 'content 2');
$file1 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile1, 'doc1.pdf', 'application/pdf', null, true);
$file2 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile2, 'doc2.png', 'image/png', null, true);
$client->request('POST', '/api/comments', [
'extra' => [
'parameters' => [
'content' => 'Multiple files',
'entityType' => 'machine',
'entityId' => $machine->getId(),
],
'files' => [
'files' => [$file1, $file2],
],
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertCount(2, $data['documents']);
@unlink($tmpFile1);
@unlink($tmpFile2);
}
```
- [ ] **Step 3: Add test that existing JSON create still works and returns empty documents array**
```php
public function testCreateCommentJsonStillReturnsDocuments(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
$client->request('POST', '/api/comments', [
'json' => [
'content' => 'No files',
'entityType' => 'machine',
'entityId' => $machine->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([], $data['documents']);
}
```
- [ ] **Step 4: Run tests**
Run: `make test`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add tests/Api/Controller/CommentControllerTest.php
git commit -m "test(comments) : add tests for comment creation with file attachments"
```
---
### Task 6: Frontend — update useComments composable
**Files:**
- Modify: `Inventory_frontend/app/composables/useComments.ts`
- [ ] **Step 1: Add document type to Comment interface**
```typescript
export interface CommentDocument {
id: string
name: string
filename: string
mimeType: string
size: number
type: string
fileUrl: string
downloadUrl: string
createdAt: string
}
export interface Comment {
id: string
content: string
entityType: string
entityId: string
entityName?: string | null
authorId: string
authorName: string
status: 'open' | 'resolved'
resolvedById?: string | null
resolvedByName?: string | null
resolvedAt?: string | null
createdAt: string
updatedAt: string
documents: CommentDocument[]
}
```
- [ ] **Step 2: Update createComment to accept files and use FormData**
Add `postFormData` to the destructured `useApi()` call:
```typescript
const { get, post, patch, postFormData, delete: del } = useApi()
```
Update `createComment`:
```typescript
const createComment = async (
entityType: string,
entityId: string,
content: string,
entityName?: string,
files?: File[],
): Promise<CommentResult> => {
loading.value = true
try {
let result
if (files && files.length > 0) {
const formData = new FormData()
formData.append('content', content)
formData.append('entityType', entityType)
formData.append('entityId', entityId)
if (entityName) formData.append('entityName', entityName)
for (const file of files) {
formData.append('files[]', file)
}
result = await postFormData('/comments', formData)
} else {
const payload: Record<string, string> = { entityType, entityId, content }
if (entityName) payload.entityName = entityName
result = await post('/comments', payload)
}
if (result.success) {
showSuccess('Commentaire ajouté')
return { success: true, data: result.data as Comment }
}
if (result.error) showError(result.error)
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
showError('Impossible d\'ajouter le commentaire')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
```
- [ ] **Step 3: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 4: Commit (in frontend submodule)**
```bash
cd Inventory_frontend
git add app/composables/useComments.ts
git commit -m "feat(comments) : support file attachments in createComment"
```
---
### Task 7: Frontend — update CommentSection.vue
**Files:**
- Modify: `Inventory_frontend/app/components/CommentSection.vue`
- [ ] **Step 1: Add file input and file list display to the template**
Replace the form section (lines 22-40) with:
```vue
<!-- Formulaire d'ajout -->
<div class="space-y-2">
<div class="flex gap-2">
<textarea
v-model="newContent"
class="textarea textarea-bordered flex-1 text-sm"
rows="2"
placeholder="Ajouter un commentaire..."
:disabled="submitting"
@keydown.ctrl.enter="handleSubmit"
/>
<div class="flex flex-col gap-1 self-end">
<label
class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
data-tip="Joindre des fichiers"
>
<IconLucidePaperclip class="w-4 h-4" />
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFilesSelected"
/>
</label>
<button
type="button"
class="btn btn-primary btn-sm btn-square"
:disabled="!newContent.trim() || submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
<IconLucideSend v-else class="w-4 h-4" />
</button>
</div>
</div>
<!-- Selected files preview -->
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
<span
v-for="(file, i) in selectedFiles"
:key="i"
class="badge badge-sm badge-outline gap-1"
>
<IconLucideFile class="w-3 h-3" />
{{ file.name }}
<button type="button" class="ml-1" @click="removeFile(i)">
<IconLucideX class="w-3 h-3" />
</button>
</span>
</div>
</div>
```
Add after each comment's content (`<p class="text-sm whitespace-pre-wrap">`) in both open and resolved sections:
```vue
<!-- Documents attachés -->
<div v-if="comment.documents?.length" class="flex flex-wrap gap-1 mt-1">
<a
v-for="doc in comment.documents"
:key="doc.id"
:href="`${apiBase}${doc.downloadUrl}`"
target="_blank"
class="badge badge-sm badge-ghost gap-1 hover:badge-primary"
>
<IconLucideFile class="w-3 h-3" />
{{ doc.filename }}
</a>
</div>
```
- [ ] **Step 2: Update script setup**
Add new imports:
```typescript
import IconLucidePaperclip from '~icons/lucide/paperclip'
import IconLucideFile from '~icons/lucide/file'
import IconLucideX from '~icons/lucide/x'
```
Add after existing refs:
```typescript
const selectedFiles = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const apiBase = useRuntimeConfig().public.apiBase || ''
```
Add file management functions:
```typescript
const handleFilesSelected = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files) {
selectedFiles.value.push(...Array.from(input.files))
}
// Reset input so the same file can be re-selected
input.value = ''
}
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
}
```
Update `handleSubmit`:
```typescript
const handleSubmit = async () => {
const content = newContent.value.trim()
if (!content) return
submitting.value = true
const result = await createComment(
props.entityType,
props.entityId,
content,
props.entityName,
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
)
submitting.value = false
if (result.success) {
newContent.value = ''
selectedFiles.value = []
await loadComments()
}
}
```
- [ ] **Step 3: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 4: Commit (in frontend submodule)**
```bash
cd Inventory_frontend
git add app/components/CommentSection.vue
git commit -m "feat(comments) : add file attachment UI to CommentSection"
```
---
### Task 8: Update API Platform filter and submodule pointer
**Files:**
- Modify: `src/Entity/Document.php` (add ExistsFilter for comment)
- [ ] **Step 1: Add comment to ExistsFilter on Document entity**
Update the `ApiFilter(ExistsFilter...)` line in `Document.php`:
```php
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product', 'comment'])]
```
- [ ] **Step 2: Run php-cs-fixer + all backend tests**
Run: `make php-cs-fixer-allow-risky && make test`
- [ ] **Step 3: Commit backend**
```bash
git add src/Entity/Document.php
git commit -m "feat(documents) : add comment ExistsFilter"
```
- [ ] **Step 4: Update submodule pointer**
```bash
git add Inventory_frontend
git commit -m "chore(submodule) : update frontend pointer (comment documents feature)"
```
---
### Task 9: Manual verification
- [ ] **Step 1: Start the app**
Run: `make start`
- [ ] **Step 2: Test creating a comment without files** — should work exactly as before, response now includes `"documents": []`
- [ ] **Step 3: Test creating a comment with files** — use the paperclip button, select 1-2 files, submit. Files should appear as badges on the comment.
- [ ] **Step 4: Click a file badge** — should download the file.
- [ ] **Step 5: Run full test suite one last time**
Run: `make test`

View File

@@ -0,0 +1,809 @@
# Document Types Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `type` enum field to documents (documentation, devis, facture, plan, photo, autre) with classification at upload and inline editing afterward.
**Architecture:** New PHP enum `DocumentType` + column on `documents` table. Migration classifies existing rows by mimeType. Frontend gets a type select at upload, a badge in document lists, and a mini-modal for editing name+type via PATCH.
**Tech Stack:** Symfony 8, API Platform, Doctrine, PHP 8.4 enums, Nuxt 4, Vue 3, DaisyUI 5
---
## File Structure
### Backend (create)
- `src/Enum/DocumentType.php` — PHP backed enum with 6 values
- `migrations/VersionXXX_add_document_type.php` — ALTER TABLE + data classification
### Backend (modify)
- `src/Entity/Document.php` — add `type` column + Patch operation
- `src/State/DocumentUploadProcessor.php` — accept `type` from FormData
- `src/Controller/DocumentQueryController.php` — add `type` to `normalizeDocuments()`
### Frontend (create)
- `Inventory_frontend/app/shared/documentTypes.ts` — type constants + labels
- `Inventory_frontend/app/components/DocumentEditModal.vue` — mini-modal for editing name+type
### Frontend (modify)
- `Inventory_frontend/app/composables/useDocuments.ts` — add `type` to interface + `updateDocument()` method
- `Inventory_frontend/app/components/DocumentUpload.vue` — add type select
- `Inventory_frontend/app/components/common/DocumentListInline.vue` — add type badge + edit button
- `Inventory_frontend/app/composables/useEntityDocuments.ts` — add `updateDocument` delegation
- `Inventory_frontend/app/pages/documents.vue` — add type column + edit button
---
### Task 1: PHP Enum + Entity Column
**Files:**
- Create: `src/Enum/DocumentType.php`
- Modify: `src/Entity/Document.php:31-54` (API resource), `src/Entity/Document.php:107-113` (add column after site)
- [ ] **Step 1: Create the DocumentType enum**
```php
// src/Enum/DocumentType.php
<?php
declare(strict_types=1);
namespace App\Enum;
enum DocumentType: string
{
case DOCUMENTATION = 'documentation';
case DEVIS = 'devis';
case FACTURE = 'facture';
case PLAN = 'plan';
case PHOTO = 'photo';
case AUTRE = 'autre';
}
```
- [ ] **Step 2: Add type column to Document entity**
In `src/Entity/Document.php`, add after the `$site` property (line ~106):
```php
#[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private DocumentType $type = DocumentType::DOCUMENTATION;
```
Add getter/setter:
```php
public function getType(): DocumentType
{
return $this->type;
}
public function setType(DocumentType $type): static
{
$this->type = $type;
return $this;
}
```
Add the import at top: `use App\Enum\DocumentType;`
- [ ] **Step 3: Add Patch operation to Document API resource**
In the `operations` array of `#[ApiResource(...)]`, add after the existing `Put`:
```php
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
```
Add the import: `use ApiPlatform\Metadata\Patch;`
- [ ] **Step 4: Run cs-fixer and verify**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 5: Commit**
```bash
git add src/Enum/DocumentType.php src/Entity/Document.php
git commit -m "feat(documents) : add DocumentType enum and type column on entity"
```
---
### Task 2: Migration
**Files:**
- Create: new migration file via Doctrine
- [ ] **Step 1: Generate migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff`
This will generate a migration. Then edit it to add the data classification.
- [ ] **Step 2: Edit migration to classify existing documents**
The generated migration will have the `ALTER TABLE` for adding the column. After the column add, append:
```sql
UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%';
UPDATE documents SET type = 'autre' WHERE type = 'documentation' AND mimetype NOT LIKE 'application/pdf' AND mimetype NOT LIKE 'image/%';
```
Use `IF NOT EXISTS` pattern consistent with other migrations:
```php
public function up(Schema $schema): void
{
$this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'type') THEN ALTER TABLE documents ADD COLUMN type VARCHAR(20) NOT NULL DEFAULT 'documentation'; END IF; END $$");
$this->addSql("UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%'");
$this->addSql("UPDATE documents SET type = 'autre' WHERE type = 'documentation' AND mimetype NOT LIKE 'application/pdf' AND mimetype NOT LIKE 'image/%'");
}
```
- [ ] **Step 3: Run migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
- [ ] **Step 4: Verify data classification**
Run: `docker exec -u www-data php-inventory-apache php bin/console dbal:run-sql "SELECT type, COUNT(*) FROM documents GROUP BY type"`
- [ ] **Step 5: Commit**
```bash
git add migrations/
git commit -m "feat(documents) : add migration for type column with data classification"
```
---
### Task 3: Backend — Upload Processor + Query Controller
**Files:**
- Modify: `src/State/DocumentUploadProcessor.php:66-77`
- Modify: `src/Controller/DocumentQueryController.php:110-127`
- [ ] **Step 1: Accept type in DocumentUploadProcessor**
In `handleMultipartUpload()`, after `$document->setSize((int) $size);` (line ~77), add:
```php
// Document type from form field (default: documentation)
$typeValue = $request->request->get('type', 'documentation');
$docType = DocumentType::tryFrom($typeValue) ?? DocumentType::DOCUMENTATION;
$document->setType($docType);
```
Add import: `use App\Enum\DocumentType;`
- [ ] **Step 2: Add type to DocumentQueryController normalizeDocuments**
In `normalizeDocuments()`, add `'type'` to the returned array after `'productId'`:
```php
'type' => $document->getType()->value,
```
- [ ] **Step 3: Write test for PATCH type update**
In `tests/Api/Entity/DocumentTest.php`, add:
```php
public function testPatchType(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['type' => 'devis'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['type' => 'devis']);
}
public function testPatchNameAndType(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'new-name', 'type' => 'facture'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'new-name', 'type' => 'facture']);
}
public function testGetItemIncludesType(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createViewerClient();
$client->request('GET', self::iri('documents', $doc->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['type' => 'documentation']);
}
public function testViewerCannotPatch(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createViewerClient();
$client->request('PATCH', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['type' => 'devis'],
]);
$this->assertResponseStatusCodeSame(403);
}
```
- [ ] **Step 4: Run tests**
Run: `make test FILES=tests/Api/Entity/DocumentTest.php`
Expected: all tests pass
- [ ] **Step 5: Run cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/State/DocumentUploadProcessor.php src/Controller/DocumentQueryController.php tests/Api/Entity/DocumentTest.php
git commit -m "feat(documents) : accept type on upload + expose in query controller + PATCH support"
```
---
### Task 4: Frontend — Type Constants + Document Interface
**Files:**
- Create: `Inventory_frontend/app/shared/documentTypes.ts`
- Modify: `Inventory_frontend/app/composables/useDocuments.ts:6-27` (Document interface), `useDocuments.ts:205-253` (upload)
- [ ] **Step 1: Create documentTypes.ts**
```typescript
// Inventory_frontend/app/shared/documentTypes.ts
export const DOCUMENT_TYPES = [
{ value: 'documentation', label: 'Documentation' },
{ value: 'devis', label: 'Devis' },
{ value: 'facture', label: 'Facture' },
{ value: 'plan', label: 'Plan' },
{ value: 'photo', label: 'Photo' },
{ value: 'autre', label: 'Autre' },
] as const
export type DocumentTypeValue = (typeof DOCUMENT_TYPES)[number]['value']
export const getDocumentTypeLabel = (value: string): string => {
const found = DOCUMENT_TYPES.find((t) => t.value === value)
return found?.label ?? value
}
```
- [ ] **Step 2: Add type to Document interface and UploadContext**
In `useDocuments.ts`, add to `Document` interface after `downloadUrl`:
```typescript
type?: string
```
Add to `UploadContext` interface:
```typescript
type?: string
```
- [ ] **Step 3: Add type to uploadDocuments FormData**
In `uploadDocuments()`, after `formData.append('name', file.name)` (line ~220), add:
```typescript
if (context.type) formData.append('type', context.type)
```
- [ ] **Step 4: Add updateDocument method**
In `useDocuments()`, before the `return` block, add:
```typescript
const updateDocument = async (
id: string,
data: { name?: string; type?: string },
): Promise<DocumentResult> => {
loading.value = true
try {
const result = await patch(`/documents/${id}`, data)
if (result.success && result.data) {
const updated = result.data as Document
const index = documents.value.findIndex((doc) => doc.id === id)
if (index !== -1) {
documents.value[index] = { ...documents.value[index], ...updated }
}
showSuccess('Document mis à jour')
return { success: true, data: updated }
}
if (result.error) showError(result.error)
return result as DocumentResult
} catch (error) {
const err = error as Error
showError('Impossible de mettre à jour le document')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
```
Add `patch` to the destructured `useApi()` call at the top of the composable:
```typescript
const { get, patch, postFormData, delete: del } = useApi()
```
Add `updateDocument` to the return object.
- [ ] **Step 5: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 6: Commit frontend**
```bash
cd Inventory_frontend
git add app/shared/documentTypes.ts app/composables/useDocuments.ts
git commit -m "feat(documents) : add document type constants and updateDocument method"
```
---
### Task 5: Frontend — DocumentUpload Type Select
**Files:**
- Modify: `Inventory_frontend/app/components/DocumentUpload.vue`
- [ ] **Step 1: Add type prop and select to DocumentUpload**
Add prop:
```javascript
documentType: {
type: String,
default: 'documentation'
}
```
Add emit:
```javascript
'update:documentType'
```
Add a select dropdown in the template, before the file list (`<ul>`), after the button area:
```html
<div class="w-full max-w-xs mt-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70">
Type de document
</label>
<select
class="select select-bordered select-sm w-full mt-1"
:value="documentType"
@change="$emit('update:documentType', ($event.target as HTMLSelectElement).value)"
>
<option v-for="t in documentTypes" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</div>
```
Import the types:
```javascript
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
const documentTypes = DOCUMENT_TYPES
```
Note: since DocumentUpload uses `<script setup>` without `lang="ts"`, use `@change="$emit('update:documentType', $event.target.value)"` (no cast).
- [ ] **Step 2: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 3: Commit**
```bash
cd Inventory_frontend
git add app/components/DocumentUpload.vue
git commit -m "feat(documents) : add type select to DocumentUpload component"
```
---
### Task 6: Frontend — DocumentEditModal
**Files:**
- Create: `Inventory_frontend/app/components/DocumentEditModal.vue`
- [ ] **Step 1: Create DocumentEditModal component**
```vue
<template>
<Teleport to="body">
<div v-if="visible" class="modal modal-open" @click.self="$emit('close')">
<div class="modal-box max-w-sm">
<h3 class="font-bold text-lg mb-4">
Modifier le document
</h3>
<div class="space-y-4">
<label class="form-control w-full">
<div class="label">
<span class="label-text">Nom</span>
</div>
<input
v-model="form.name"
type="text"
class="input input-bordered input-sm md:input-md w-full"
>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Type</span>
</div>
<select
v-model="form.type"
class="select select-bordered select-sm md:select-md w-full"
>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</label>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="$emit('close')">
Annuler
</button>
<button
type="button"
class="btn btn-primary btn-sm md:btn-md"
:disabled="saving"
@click="save"
>
<span v-if="saving" class="loading loading-spinner loading-xs" />
Sauvegarder
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { reactive, watch, ref } from 'vue'
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
import type { Document } from '~/composables/useDocuments'
const props = defineProps<{
visible: boolean
document: Document | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'updated', data: { name: string; type: string }): void
}>()
const form = reactive({ name: '', type: 'documentation' })
const saving = ref(false)
watch(
() => props.document,
(doc) => {
if (doc) {
form.name = doc.name || ''
form.type = doc.type || 'documentation'
}
},
{ immediate: true },
)
const save = () => {
if (!form.name.trim()) return
saving.value = true
emit('updated', { name: form.name.trim(), type: form.type })
saving.value = false
}
</script>
```
- [ ] **Step 2: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 3: Commit**
```bash
cd Inventory_frontend
git add app/components/DocumentEditModal.vue
git commit -m "feat(documents) : add DocumentEditModal component"
```
---
### Task 7: Frontend — DocumentListInline + Type Badge + Edit Button
**Files:**
- Modify: `Inventory_frontend/app/components/common/DocumentListInline.vue`
- Modify: `Inventory_frontend/app/composables/useEntityDocuments.ts`
- [ ] **Step 1: Add type badge and edit button to DocumentListInline**
In the template, after the document name `<div>` (line ~33-40), add a badge for the type:
```html
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
```
In the actions area (line ~42-68), add an edit button before "Consulter":
```html
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
title="Modifier"
@click="$emit('edit', document)"
>
Modifier
</button>
```
Add props:
```typescript
canEdit?: boolean
```
Default: `false`
Add emit:
```typescript
(e: 'edit', document: Document): void
```
Add import:
```typescript
import { getDocumentTypeLabel } from '~/shared/documentTypes'
```
- [ ] **Step 2: Add updateDocument to useEntityDocuments**
In `useEntityDocuments.ts`, add `updateDocument` from useDocuments:
```typescript
const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
```
Add method:
```typescript
const editDocument = async (id: string, data: { name?: string; type?: string }) => {
const result: any = await updateDocument(id, data)
if (result.success) {
const e = entity()
const docs = e.documents || []
const index = docs.findIndex((doc: any) => doc.id === id)
if (index !== -1) {
docs[index] = { ...docs[index], ...data }
}
}
return result
}
```
Add `editDocument` to the return object.
- [ ] **Step 3: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 4: Commit**
```bash
cd Inventory_frontend
git add app/components/common/DocumentListInline.vue app/composables/useEntityDocuments.ts
git commit -m "feat(documents) : add type badge and edit button to DocumentListInline"
```
---
### Task 8: Frontend — Wire Edit Modal in Entity Pages
**Files:**
- Modify: `Inventory_frontend/app/components/ComponentItem.vue`
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
- Modify: `Inventory_frontend/app/pages/pieces/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue`
- [ ] **Step 1: Wire in ComponentItem and PieceItem**
For each of `ComponentItem.vue` and `PieceItem.vue`:
1. Add `editDocument` from the `useEntityDocuments` return
2. Add state refs for the edit modal:
```typescript
const editingDocument = ref<any>(null)
const editModalVisible = ref(false)
```
3. Add handler:
```typescript
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name: string; type: string }) => {
if (!editingDocument.value?.id) return
await editDocument(editingDocument.value.id, data)
editModalVisible.value = false
editingDocument.value = null
}
```
4. Add `DocumentEditModal` in the template
5. Pass `:can-edit="isEditMode"` and `@edit="openEditModal"` to `DocumentListInline`
- [ ] **Step 2: Wire in edit pages (pieces/edit, component/edit, product/edit)**
Same pattern: add edit modal state, wire `DocumentListInline` with `:can-edit` and `@edit`, add `DocumentEditModal`.
- [ ] **Step 3: Wire type select in upload**
In pages that use `DocumentUpload`, add a `documentType` ref and pass it:
```html
<DocumentUpload
v-model="selectedFiles"
v-model:document-type="uploadDocType"
...
/>
```
Pass `type: uploadDocType.value` in the upload context when calling `handleFilesAdded` or `uploadDocuments`.
- [ ] **Step 4: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend
git add app/components/ app/pages/
git commit -m "feat(documents) : wire DocumentEditModal and type select in all entity pages"
```
---
### Task 9: Frontend — Documents Global Page
**Files:**
- Modify: `Inventory_frontend/app/pages/documents.vue`
- [ ] **Step 1: Add type column to DataTable**
In the `columns` array, add after `mimeType`:
```typescript
{ key: 'type', label: 'Type' },
```
Add the cell template:
```html
<template #cell-type="{ row }">
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(row.type || 'documentation') }}</span>
</template>
```
- [ ] **Step 2: Add edit button + modal**
Add an edit button in the `#cell-actions` template slot:
```html
<button
v-if="canEdit"
class="btn btn-ghost btn-xs"
type="button"
@click="openEditModal(row)"
>
Modifier
</button>
```
Add `DocumentEditModal` component in the template. Add the edit state + handler logic (same pattern as Task 8). Use `useDocuments().updateDocument` directly.
Import `usePermissions` to derive `canEdit` from the user's role (ROLE_GESTIONNAIRE or above).
- [ ] **Step 3: Add type filter**
Add a type filter select next to the existing "Rattachement" filter:
```html
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="doc-type-filter">
Type
</label>
<select
id="doc-type-filter"
v-model="typeFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="all">Tous</option>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
```
Pass `typeFilter` to `fetchDocuments` → `loadDocuments` as a new filter param, and in `useDocuments.loadDocuments` add `params.set('type', typeFilter)` when not `'all'`.
- [ ] **Step 4: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend
git add app/pages/documents.vue app/composables/useDocuments.ts
git commit -m "feat(documents) : add type column, filter, and edit to documents page"
```
---
### Task 10: Final — Submodule Pointer + Verification
**Files:**
- Main repo: update submodule pointer
- [ ] **Step 1: Run full backend tests**
Run: `make test`
Expected: all tests pass
- [ ] **Step 2: Run full frontend checks**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build`
Expected: 0 errors
- [ ] **Step 3: Manual verification**
1. Go to `/pieces/{id}/edit` — verify type badge on existing docs, edit modal works
2. Go to `/component/{id}/edit` — same verification
3. Upload a new document — verify type select appears, type is saved
4. Go to `/documents` — verify type column, filter, edit button
5. Check that existing PDFs show "Documentation", images show "Photo", others show "Autre"
- [ ] **Step 4: Commit submodule pointer**
```bash
cd /home/matthieu/dev_malio/Inventory
git add Inventory_frontend
git commit -m "chore(submodule) : update frontend pointer (document types feature)"
```

View File

@@ -0,0 +1,418 @@
# Fix Data-Loss Bugs — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix all bugs that cause silent data loss in the composant/piece/product/skeleton/custom-fields data model.
**Architecture:** 6 independent fixes across backend (PHP) and frontend (TS). Each task is self-contained and can be committed independently. Backend fixes come first because they protect data integrity at the source.
**Tech Stack:** Symfony 8 / PHP 8.4 / PostgreSQL 16 / Nuxt 4 / Vue 3 / TypeScript
---
## File Map
| Task | Action | File |
|------|--------|------|
| T1 | Modify | `src/Controller/MachineStructureController.php:174-195` |
| T2 | Modify | `src/Controller/ComposantPieceSlotController.php:41-47` |
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:195-236` |
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:340-405` |
| T4 | Modify | `src/Controller/CustomFieldValueController.php:199-211` |
| T5 | Modify | `Inventory_frontend/app/composables/useComponentEdit.ts:398-405` |
| T5 | Modify | `Inventory_frontend/app/composables/usePieceEdit.ts:407-414` |
| T6 | Modify | `Inventory_frontend/app/composables/useComponentCreate.ts` (same pattern if present) |
---
### Task 1: Clone machine — CustomFieldValue pointe vers les CustomField de la source
**Probleme:** `cloneCustomFields` clone les `CustomField` (definitions) pour la target, mais les `CustomFieldValue` (valeurs) restent liees aux `CustomField` de la source. Supprimer la source cascade-delete les valeurs du clone.
**Files:**
- Modify: `src/Controller/MachineStructureController.php:174-195`
- Test: `tests/Api/Controller/MachineStructureControllerTest.php` (clone test existant)
- [ ] **Step 1: Write the failing test**
Dans le test de clone existant, ajouter une assertion : apres clone, verifier que chaque `CustomFieldValue` de la machine clonee pointe vers un `CustomField` dont `machineId` est l'ID de la machine clonee (pas la source).
```php
// After clone, fetch the cloned machine's custom field values
$clonedValues = $em->getRepository(CustomFieldValue::class)->findBy(['machine' => $clonedMachine]);
foreach ($clonedValues as $cfv) {
$this->assertSame(
$clonedMachine->getId(),
$cfv->getCustomField()->getMachine()->getId(),
'Cloned CustomFieldValue must reference the cloned CustomField, not the source'
);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test FILES=tests/Api/Controller/MachineStructureControllerTest.php`
Expected: FAIL — cloned values reference source machine's custom fields
- [ ] **Step 3: Implement the fix**
In `cloneCustomFields`, build a map `$oldCfId => $newCf` in the first loop, then use it in the second loop:
```php
private function cloneCustomFields(Machine $source, Machine $target): void
{
$cfMap = [];
foreach ($source->getCustomFields() as $cf) {
$newCf = new CustomField();
$newCf->setName($cf->getName());
$newCf->setType($cf->getType());
$newCf->setRequired($cf->isRequired());
$newCf->setDefaultValue($cf->getDefaultValue());
$newCf->setOptions($cf->getOptions());
$newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachine($target);
$this->entityManager->persist($newCf);
$cfMap[$cf->getId()] = $newCf;
}
foreach ($source->getCustomFieldValues() as $cfv) {
$originalCf = $cfv->getCustomField();
$newCf = $cfMap[$originalCf->getId()] ?? null;
if (!$newCf) {
continue;
}
$newValue = new CustomFieldValue();
$newValue->setMachine($target);
$newValue->setCustomField($newCf);
$newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `make test FILES=tests/Api/Controller/MachineStructureControllerTest.php`
Expected: PASS
- [ ] **Step 5: Lint**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/Controller/MachineStructureController.php tests/Api/Controller/MachineStructureControllerTest.php
git commit -m "fix(clone) : custom field values reference cloned definitions, not source"
```
---
### Task 2: ComposantPieceSlot PATCH — pas de validation du type de piece ni 404
**Probleme:** On peut assigner n'importe quelle piece dans un slot, meme si son type ne correspond pas au type requis par le squelette. Si la piece n'existe pas, `null` est silencieusement mis.
**Files:**
- Modify: `src/Controller/ComposantPieceSlotController.php:41-47`
- Test: `tests/Api/Controller/ComposantPieceSlotControllerTest.php` (creer si absent)
- [ ] **Step 1: Write the failing test — piece not found returns 404**
```php
public function testPatchSlotWithNonExistentPieceReturns404(): void
{
$client = $this->createGestionnaireClient();
// Create a slot via fixtures
$slot = $this->createComposantPieceSlot();
$client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
'json' => ['selectedPieceId' => 'cl_nonexistent_id'],
'headers' => ['Content-Type' => 'application/json'],
]);
$this->assertResponseStatusCodeSame(404);
}
```
- [ ] **Step 2: Write the failing test — wrong piece type returns 422**
```php
public function testPatchSlotWithWrongPieceTypeReturns422(): void
{
$client = $this->createGestionnaireClient();
$typeA = $this->createModelType(['category' => 'piece', 'name' => 'Type A']);
$typeB = $this->createModelType(['category' => 'piece', 'name' => 'Type B']);
$slot = $this->createComposantPieceSlot(['typePiece' => $typeA]);
$wrongPiece = $this->createPiece(['typePiece' => $typeB]);
$client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
'json' => ['selectedPieceId' => $wrongPiece->getId()],
'headers' => ['Content-Type' => 'application/json'],
]);
$this->assertResponseStatusCodeSame(422);
}
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php`
Expected: FAIL
- [ ] **Step 4: Implement the fix**
```php
if (array_key_exists('selectedPieceId', $payload)) {
if (null === $payload['selectedPieceId']) {
$slot->setSelectedPiece(null);
} else {
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
if (!$piece) {
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
}
$slotTypePiece = $slot->getTypePiece();
if ($slotTypePiece && $piece->getTypePiece()?->getId() !== $slotTypePiece->getId()) {
return $this->json([
'success' => false,
'error' => sprintf(
'La pièce doit être de type « %s ».',
$slotTypePiece->getName(),
),
], 422);
}
$slot->setSelectedPiece($piece);
}
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php`
Expected: PASS
- [ ] **Step 6: Lint + commit**
```bash
make php-cs-fixer-allow-risky
git add src/Controller/ComposantPieceSlotController.php tests/Api/Controller/ComposantPieceSlotControllerTest.php
git commit -m "fix(slots) : validate piece type matches slot requirement + 404 on missing piece"
```
---
### Task 3: Conversion de categorie — slots supprimes sans verification + skeleton requirements orphelins
**Probleme A:** `checkComponentToPiece` verifie `structure IS NOT NULL` (ancien JSON) mais les donnees sont dans les tables de slots. Le check passe toujours et les slots sont cascade-deleted.
**Probleme B:** Apres conversion, les `skeleton_piece_requirements`, `skeleton_product_requirements`, `skeleton_subcomponent_requirements` de l'ancien type ne sont pas supprimes.
**Files:**
- Modify: `src/Service/ModelTypeCategoryConversionService.php:195-236` (check)
- Modify: `src/Service/ModelTypeCategoryConversionService.php:340-405` (convert)
- [ ] **Step 1: Fix `checkComponentToPiece` — ajouter le check sur les tables de slots**
Apres le check `structure IS NOT NULL` existant (qui reste pour compatibilite), ajouter :
```php
// Check slot tables for actual data (post-normalization architecture)
$slotsWithData = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_piece_slots cps
JOIN composants c ON cps.composantid = c.id
WHERE c.typecomposantid = :id AND cps.selectedpieceid IS NOT NULL',
['id' => $modelTypeId],
);
$subSlots = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_subcomponent_slots css
JOIN composants c ON css.composantid = c.id
WHERE c.typecomposantid = :id AND css.selectedcomposantid IS NOT NULL',
['id' => $modelTypeId],
);
if ($slotsWithData > 0 || $subSlots > 0) {
$parts = [];
if ($slotsWithData > 0) {
$parts[] = sprintf('%d slot(s) pièce rempli(s)', $slotsWithData);
}
if ($subSlots > 0) {
$parts[] = sprintf('%d slot(s) sous-composant rempli(s)', $subSlots);
}
$blockers[] = sprintf(
'Des composants ont des données dans leurs slots : %s.',
implode(', ', $parts),
);
}
```
- [ ] **Step 2: Fix `convertComponentToPiece` — nettoyer les skeleton requirements avant le changement de categorie**
Ajouter entre l'etape 6 (DELETE composants) et l'etape 7 (UPDATE model_types) :
```php
// 6b. Clean up skeleton requirements that belong to COMPONENT category
$this->connection->executeStatement(
'DELETE FROM skeleton_piece_requirements WHERE modeltypeid = :id',
['id' => $modelTypeId],
);
$this->connection->executeStatement(
'DELETE FROM skeleton_subcomponent_requirements WHERE modeltypeid = :id',
['id' => $modelTypeId],
);
// Note: skeleton_product_requirements are kept — valid for both COMPONENT and PIECE categories
```
- [ ] **Step 3: Fix `convertPieceToComponent` — meme nettoyage dans l'autre sens**
Les `skeleton_product_requirements` qui appartenaient au type PIECE restent. Aucun nettoyage specifique necessaire car les product requirements sont valides pour les deux types. Mais verifier que la methode existe et n'a pas le meme probleme.
- [ ] **Step 4: Run all conversion tests**
Run: `make test FILES=tests/Api/Controller/ModelTypeConversionControllerTest.php`
Si absent: `make test` (tous les tests)
Expected: PASS
- [ ] **Step 5: Lint + commit**
```bash
make php-cs-fixer-allow-risky
git add src/Service/ModelTypeCategoryConversionService.php
git commit -m "fix(conversion) : block conversion when slots have data + clean skeleton requirements"
```
---
### Task 4: CustomFieldValueController — cree des CustomField orphelins sans FK
**Probleme:** Quand `customFieldId` est absent et `customFieldName` est fourni, un nouveau `CustomField` est cree sans etre rattache a aucune entite (ni machine, ni modelType). La ligne est invisible et inutile.
**Files:**
- Modify: `src/Controller/CustomFieldValueController.php:199-211`
- [ ] **Step 1: Implement the fix**
La methode `resolveCustomField` cree un `CustomField` orphelin. Il faut utiliser le `target` (deja resolu) pour rattacher le champ au bon parent. Le plus simple : deplacer la creation du CustomField apres la resolution du target, ou passer le target en parametre.
Option retenue : retourner un array `['customField' => $cf, 'isNew' => true]` et laisser `applyTarget` gerer le rattachement, OU plus simplement, interdire la creation ad-hoc et retourner une erreur 400 quand le champ n'existe pas.
L'approche la plus sure (pas de CustomField orphelin) :
```php
// In resolveCustomField, replace the auto-creation block with:
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ('' === $customFieldName) {
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
}
// Try to find existing custom field by name for the target entity
$target = $this->resolveTarget($payload);
if ($target instanceof JsonResponse) {
return $this->json(['success' => false, 'error' => 'Cannot create custom field without a valid target entity.'], 400);
}
$existingField = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
if ($existingField) {
return $existingField;
}
return $this->json(['success' => false, 'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName)], 404);
```
**Alternative plus conservative** si le frontend depend de cette auto-creation : garder la creation mais rattacher au target. Cela necessite de refactorer le flow pour passer le target a `resolveCustomField`. Choisir selon le frontend.
- [ ] **Step 2: Run tests**
Run: `make test`
Expected: PASS (verifier qu'aucun test ne depend de l'auto-creation)
- [ ] **Step 3: Lint + commit**
```bash
make php-cs-fixer-allow-risky
git add src/Controller/CustomFieldValueController.php
git commit -m "fix(custom-fields) : prevent creation of orphan CustomField without target entity"
```
---
### Task 5: Frontend — custom fields definition lookup au mauvais chemin
**Probleme:** `useComponentEdit` passe `typeComposant.customFields` (pas serialise par l'API) au lieu de `typeComposant.structure.customFields`. Idem `usePieceEdit` avec `typePiece.pieceCustomFields` au lieu de `typePiece.structure.customFields`.
Consequence : le `definitionMap` est toujours vide, les champs perso sans `customFieldId` existant ne trouvent pas leur definition et sont envoyes sans `definitionId` (fallback sur metadata = CustomField orphelin cote backend = Task 4).
**Files:**
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts:401-403`
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts:410-412`
- [ ] **Step 1: Fix useComponentEdit.ts**
Ligne 401-403, remplacer :
```ts
[
updatedComponent?.typeComposant?.customFields,
]
```
par :
```ts
[
updatedComponent?.typeComposant?.structure?.customFields,
]
```
- [ ] **Step 2: Fix usePieceEdit.ts**
Ligne 410-412, remplacer :
```ts
[
updatedPiece?.typePiece?.pieceCustomFields,
]
```
par :
```ts
[
updatedPiece?.typePiece?.structure?.customFields,
]
```
- [ ] **Step 3: Verifier le meme pattern dans les autres fichiers**
Verifier `useComponentCreate.ts`, `pieces/create.vue`, `product/[id]/edit.vue` pour le meme probleme.
- [ ] **Step 4: Lint + typecheck**
```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend
git add app/composables/useComponentEdit.ts app/composables/usePieceEdit.ts
git commit -m "fix(custom-fields) : use structure.customFields path for definition lookup"
```
---
### Task 6 (bonus): Verifier et corriger les memes patterns dans create flows
- [ ] **Step 1:** Grep `_saveCustomFieldValues` dans tous les fichiers et verifier que chaque appel passe `structure.customFields` et non `customFields` ou `pieceCustomFields` directement.
- [ ] **Step 2:** Corriger si necessaire, lint, commit.
---
## Ordre d'execution recommande
1. **T1** (clone) — fix isole, pas de dependance
2. **T2** (slots validation) — fix isole
3. **T5** (frontend custom fields path) — fix isole
4. **T4** (orphan CustomField) — depend de T5 pour comprendre si le frontend utilise l'auto-creation
5. **T3** (conversion) — le plus complexe, faire en dernier
6. **T6** (bonus verification)

View File

@@ -0,0 +1,409 @@
# Parc Machines UX Improvements — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Multi-select site filter with checkboxes, alphabetical sorting on Parc Machines, and OR search (name/reference) on catalog pages.
**Architecture:** Frontend-only changes for tasks 1-2 (Vue reactivity + computed sort). Backend Doctrine Extension for task 3 that intercepts `?q=` parameter and builds an OR clause across `name` and `reference` fields, with corresponding frontend composable changes.
**Tech Stack:** Vue 3 (reactive Set), DaisyUI 5 checkboxes, Symfony/API Platform Doctrine ORM Extension, PHPUnit
**Spec:** `docs/superpowers/specs/2026-03-23-parc-machines-ux-design.md`
---
### Task 1: Multi-select site checkboxes on Parc Machines
**Files:**
- Modify: `Inventory_frontend/app/pages/machines/index.vue`
- [ ] **Step 1: Replace `selectedSite` ref with reactive Set**
In `<script setup>`, replace:
```js
const selectedSite = ref('')
```
with:
```js
const selectedSites = reactive(new Set())
```
- [ ] **Step 2: Replace `<select>` with checkboxes in template**
Replace the site filter `<div class="form-control">` block (the one containing the `<select>`) with:
```vue
<div class="form-control">
<label class="label">
<span class="label-text">Sites</span>
</label>
<div class="flex flex-wrap gap-3">
<label
v-for="site in sites"
:key="site.id"
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="selectedSites.has(site.id)"
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
>
<span class="text-sm">{{ site.name }}</span>
</label>
</div>
</div>
```
- [ ] **Step 3: Update `filteredMachines` computed for multi-select**
Replace:
```js
if (selectedSite.value) {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
}
```
with:
```js
if (selectedSites.size > 0) {
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
}
```
- [ ] **Step 4: Clean up unused `ref` import if needed**
Check if `ref` is still used elsewhere in the file (it is — `searchQuery` uses it). If so, keep it. Remove only if no longer referenced.
- [ ] **Step 5: Add `reactive` to imports**
Add `reactive` to the import from `vue`:
```js
import { ref, reactive, computed, onMounted } from 'vue'
```
- [ ] **Step 6: Verify in browser**
Open `http://localhost:3001/machines`. Confirm:
- Checkboxes appear for each site
- Checking one site filters machines to that site only
- Checking multiple sites shows machines from all selected sites
- Unchecking all shows all machines
- [ ] **Step 7: Run frontend lint**
Run: `cd Inventory_frontend && npm run lint:fix`
---
### Task 2: Alphabetical sorting on Parc Machines
**Files:**
- Modify: `Inventory_frontend/app/pages/machines/index.vue`
- [ ] **Step 1: Add sort to `filteredMachines` computed**
At the end of the `filteredMachines` computed, just before `return filtered`, add:
```js
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
```
The full computed should now be:
```js
const filteredMachines = computed(() => {
let filtered = enrichedMachines.value
if (selectedSites.size > 0) {
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
}
if (searchQuery.value.trim()) {
const term = searchQuery.value.trim().toLowerCase()
filtered = filtered.filter(machine =>
machine.name?.toLowerCase().includes(term)
|| machine.reference?.toLowerCase().includes(term),
)
}
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
return filtered
})
```
- [ ] **Step 2: Verify in browser**
Open `http://localhost:3001/machines`. Confirm machines are sorted A→Z by name. Test with site filter active — should still be sorted.
- [ ] **Step 3: Commit Tasks 1 + 2**
```bash
cd Inventory_frontend && git add app/pages/machines/index.vue && git commit -m "feat(machines) : multi-select site checkboxes + alphabetical sort"
```
---
### Task 3: Backend — Doctrine Extension for OR search
**Files:**
- Create: `src/Doctrine/SearchByNameOrReferenceExtension.php`
- [ ] **Step 1: Add `reference` parameter to `createComposant` factory**
In `tests/AbstractApiTestCase.php`, update the `createComposant` method to accept an optional `$reference` parameter:
Find:
```php
protected function createComposant(string $name = 'Composant Test', ?ModelType $type = null): Composant
{
$c = new Composant();
$c->setName($name);
if (null !== $type) {
$c->setTypeComposant($type);
}
```
Replace with:
```php
protected function createComposant(string $name = 'Composant Test', ?string $reference = null, ?ModelType $type = null): Composant
{
$c = new Composant();
$c->setName($name);
if (null !== $reference) {
$c->setReference($reference);
}
if (null !== $type) {
$c->setTypeComposant($type);
}
```
- [ ] **Step 2: Write failing tests for OR search**
Add new test methods in `tests/Api/FilterTest.php`:
```php
public function testOrSearchByNameOnPieces(): void
{
$this->createPiece('Joint torique', 'REF-JT-001');
$this->createPiece('Roulement', 'REF-RL-002');
$client = $this->createViewerClient();
$client->request('GET', '/api/pieces?q=joint');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testOrSearchByReferenceOnPieces(): void
{
$this->createPiece('Joint torique', 'REF-JT-001');
$this->createPiece('Roulement', 'REF-RL-002');
$client = $this->createViewerClient();
$client->request('GET', '/api/pieces?q=RL-002');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testOrSearchMatchesBothNameAndReference(): void
{
$this->createComposant('Pompe REF-X', 'REF-POMPE-01');
$this->createComposant('Vanne', 'REF-VANNE-01');
$this->createComposant('Moteur', 'POMPE-MOTEUR');
$client = $this->createViewerClient();
$client->request('GET', '/api/composants?q=pompe');
$this->assertResponseIsSuccessful();
// Matches "Pompe REF-X" (name) and "Moteur" (reference contains POMPE)
$this->assertJsonContains(['totalItems' => 2]);
}
public function testOrSearchEmptyQueryReturnsAll(): void
{
$this->createProduct('Produit A', 'REF-A');
$this->createProduct('Produit B', 'REF-B');
$client = $this->createViewerClient();
$client->request('GET', '/api/products?q=');
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertGreaterThanOrEqual(2, $data['totalItems']);
}
public function testOrSearchOnProducts(): void
{
$this->createProduct('Huile moteur', 'HM-500');
$this->createProduct('Graisse', 'GR-100');
$client = $this->createViewerClient();
$client->request('GET', '/api/products?q=HM-500');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `make test FILES=tests/Api/FilterTest.php`
Expected: New tests fail (the `q` parameter is not handled yet).
- [ ] **Step 4: Create the Doctrine Extension**
Create `src/Doctrine/SearchByNameOrReferenceExtension.php`:
```php
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Composant;
use App\Entity\Piece;
use App\Entity\Product;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
final class SearchByNameOrReferenceExtension implements QueryCollectionExtensionInterface
{
private const SUPPORTED_CLASSES = [
Piece::class,
Composant::class,
Product::class,
];
public function __construct(
private readonly RequestStack $requestStack,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (!\in_array($resourceClass, self::SUPPORTED_CLASSES, true)) {
return;
}
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return;
}
$q = $request->query->get('q', '');
if (!\is_string($q) || '' === trim($q)) {
return;
}
$escaped = addcslashes(trim($q), '%_');
$paramName = $queryNameGenerator->generateParameterName('searchQ');
$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->andWhere(sprintf('LOWER(%s.name) LIKE :%s OR LOWER(%s.reference) LIKE :%s', $alias, $paramName, $alias, $paramName))
->setParameter($paramName, '%' . strtolower($escaped) . '%');
}
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `make test FILES=tests/Api/FilterTest.php`
Expected: All tests pass, including the new OR search tests.
- [ ] **Step 6: Run full test suite**
Run: `make test`
Expected: All tests pass (no regressions).
- [ ] **Step 7: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 8: Commit backend changes**
```bash
git add src/Doctrine/SearchByNameOrReferenceExtension.php tests/Api/FilterTest.php tests/AbstractApiTestCase.php && git commit -m "feat(search) : OR search extension for name/reference on Piece, Composant, Product"
```
---
### Task 4: Frontend — Switch composables from `name` to `q`
**Files:**
- Modify: `Inventory_frontend/app/composables/usePieces.ts`
- Modify: `Inventory_frontend/app/composables/useComposants.ts`
- Modify: `Inventory_frontend/app/composables/useProducts.ts`
- [ ] **Step 1: Update `usePieces.ts`**
In the `loadPieces` function, replace:
```ts
if (search && search.trim()) {
params.set('name', search.trim())
}
```
with:
```ts
if (search && search.trim()) {
params.set('q', search.trim())
}
```
- [ ] **Step 2: Update `useComposants.ts`**
Same change in the `loadComposants` function:
```ts
params.set('name', search.trim())
```
```ts
params.set('q', search.trim())
```
- [ ] **Step 3: Update `useProducts.ts`**
Same change in the `loadProducts` function:
```ts
params.set('name', search.trim())
```
```ts
params.set('q', search.trim())
```
- [ ] **Step 4: Run frontend lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 5: Verify in browser**
Open each catalog page and test search:
- `http://localhost:3001/pieces-catalog` — search by name, then by reference
- `http://localhost:3001/component-catalog` — search by name, then by reference
- `http://localhost:3001/product-catalog` — search by name, then by reference
Confirm that searching by a reference value returns the correct results.
- [ ] **Step 6: Commit frontend changes**
```bash
cd Inventory_frontend && git add app/composables/usePieces.ts app/composables/useComposants.ts app/composables/useProducts.ts && git commit -m "feat(search) : use q param for OR search on name/reference"
```
- [ ] **Step 7: Update submodule pointer in main repo**
```bash
cd /home/matthieu/dev_malio/Inventory && git add Inventory_frontend && git commit -m "chore(submodule) : update frontend pointer (OR search + site checkboxes)"
```

File diff suppressed because it is too large Load Diff

View File

@@ -376,7 +376,8 @@ private function onFlushComplex(EntityManagerInterface $em, UnitOfWork $uow, ?st
}
$version = $this->incrementEntityVersion($entity, $em, $uow);
$snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity);
// Re-take snapshot after version increment so it captures the new version number
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId, $version));
}
}
@@ -513,7 +514,6 @@ protected function snapshotEntity(object $entity): array
'prix' => $this->safeGet($entity, 'getPrix'),
'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'productIds' => $this->safeGet($entity, 'getProductIds') ?? [],
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
'productSlots' => $productSlots,
'customFieldValues'=> $customFieldValues,
@@ -691,6 +691,7 @@ use App\Repository\ProfileRepository;
use App\Repository\SiteRepository;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
final class EntityVersionService
{
@@ -711,6 +712,7 @@ final class EntityVersionService
public function __construct(
private readonly AuditLogRepository $auditLogs,
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
private readonly MachineRepository $machines,
private readonly ComposantRepository $composants,
private readonly PieceRepository $pieces,
@@ -814,11 +816,49 @@ final class EntityVersionService
$restoreMode = $this->checkSkeletonCompatibility($entityType, $entity, $snapshot);
$warnings = $this->checkIntegrity($entityType, $snapshot, $restoreMode);
$this->applyRestore($entityType, $entity, $snapshot, $restoreMode);
$connection = $this->em->getConnection();
$connection->beginTransaction();
$this->em->flush();
try {
// Mark entity to skip audit subscriber (we create the AuditLog manually)
if (method_exists($entity, 'setSkipAudit')) {
$entity->setSkipAudit(true);
}
$newVersion = method_exists($entity, 'getVersion') ? $entity->getVersion() : null;
$this->applyRestore($entityType, $entity, $snapshot, $restoreMode);
// Increment version manually (since subscriber is skipped)
if (method_exists($entity, 'incrementVersion')) {
$entity->incrementVersion();
}
$this->em->flush();
$newVersion = method_exists($entity, 'getVersion') ? $entity->getVersion() : null;
// Create the restore AuditLog manually with action = "restore"
$restoreAuditLog = new AuditLog(
$entityType,
$entityId,
'restore',
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
$this->buildCurrentSnapshot($entityType, $entity),
$this->resolveActorProfileId(),
$newVersion,
);
$this->em->persist($restoreAuditLog);
$this->em->flush();
$connection->commit();
} catch (\Throwable $e) {
$connection->rollBack();
throw $e;
} finally {
// Clear skip flag
if (method_exists($entity, 'setSkipAudit')) {
$entity->setSkipAudit(false);
}
}
return [
'success' => true,
@@ -872,18 +912,30 @@ final class EntityVersionService
{
$warnings = [];
// Check constructeurs
// Check constructeurs (batch query)
if (!empty($snapshot['constructeurIds'])) {
$constructeurEntries = [];
foreach ($snapshot['constructeurIds'] as $entry) {
$id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
$name = is_array($entry) ? ($entry['name'] ?? $id) : $id;
if ($id && null === $this->constructeurs->find($id)) {
$warnings[] = [
'field' => 'constructeurIds',
'message' => sprintf("Le fournisseur '%s' n'existe plus. Il ne sera pas restauré.", $name),
'missingEntityId' => $id,
'missingEntityName' => $name,
];
if ($id) {
$constructeurEntries[$id] = $name;
}
}
if ([] !== $constructeurEntries) {
$foundIds = array_map(
fn ($c) => $c->getId(),
$this->constructeurs->findBy(['id' => array_keys($constructeurEntries)]),
);
foreach ($constructeurEntries as $id => $name) {
if (!in_array($id, $foundIds, true)) {
$warnings[] = [
'field' => 'constructeurIds',
'message' => sprintf("Le fournisseur '%s' n'existe plus. Il ne sera pas restauré.", $name),
'missingEntityId' => $id,
'missingEntityName' => $name,
];
}
}
}
}
@@ -906,7 +958,7 @@ final class EntityVersionService
return $warnings;
}
// Full mode: check slot references
// Full mode: check slot references (batch queries per slot type)
$slotChecks = match ($entityType) {
'composant' => [
['key' => 'pieceSlots', 'refKey' => 'selectedPieceId', 'label' => 'pièce', 'repo' => $this->pieces],
@@ -921,9 +973,21 @@ final class EntityVersionService
foreach ($slotChecks as $check) {
$slots = $snapshot[$check['key']] ?? [];
// Collect all referenced IDs for batch lookup
$refIds = [];
foreach ($slots as $i => $slot) {
$refId = $slot[$check['refKey']] ?? null;
if (null !== $refId && null === $check['repo']->find($refId)) {
if (null !== $refId) {
$refIds[$i] = $refId;
}
}
if ([] === $refIds) {
continue;
}
$foundEntities = $check['repo']->findBy(['id' => array_values(array_unique($refIds))]);
$foundIds = array_map(fn ($e) => $e->getId(), $foundEntities);
foreach ($refIds as $i => $refId) {
if (!in_array($refId, $foundIds, true)) {
$warnings[] = [
'field' => sprintf('%s[%d].%s', $check['key'], $i, $check['refKey']),
'message' => sprintf("Le %s sélectionné dans le slot n'existe plus. Le slot sera restauré vide.", $check['label']),
@@ -1074,6 +1138,9 @@ final class EntityVersionService
$this->em->remove($slot);
}
// Flush removals first to avoid FK constraint conflicts with new slots
$this->em->flush();
// Recreate piece slots
foreach ($snapshot['pieceSlots'] ?? [] as $slotData) {
$slot = new ComposantPieceSlot();
@@ -1136,6 +1203,9 @@ final class EntityVersionService
$this->em->remove($slot);
}
// Flush removals first to avoid FK constraint conflicts
$this->em->flush();
foreach ($snapshot['productSlots'] ?? [] as $slotData) {
$slot = new PieceProductSlot();
$slot->setPiece($entity);
@@ -1161,18 +1231,160 @@ final class EntityVersionService
return;
}
// Build a map of current CFVs by fieldId for lookup
$currentCfvsByFieldId = [];
if (method_exists($entity, 'getCustomFieldValues')) {
foreach ($entity->getCustomFieldValues() as $cfv) {
$fieldId = $cfv->getCustomField()?->getId();
if (null !== $fieldId) {
$currentCfvsByFieldId[$fieldId] = $cfv;
}
}
}
foreach ($snapshotCfvs as $cfvData) {
$cfvId = $cfvData['id'] ?? null;
if (!$cfvId) {
$fieldId = $cfvData['fieldId'] ?? null;
if (!$fieldId) {
continue;
}
$cfv = $this->customFieldValues->find($cfvId);
// Try to find the current CFV by fieldId (resilient to ID changes after sync)
$cfv = $currentCfvsByFieldId[$fieldId] ?? null;
// Fallback: try by original ID if fieldId lookup failed
if (null === $cfv && !empty($cfvData['id'])) {
$cfv = $this->customFieldValues->find($cfvData['id']);
}
if (null !== $cfv) {
$cfv->setValue($cfvData['value'] ?? null);
}
}
}
/**
* Build a complete snapshot of the entity in its current state (after restore).
* Must be consistent with the snapshots built by the audit subscribers,
* so that a future restore from a "restore" version works correctly.
*/
private function buildCurrentSnapshot(string $entityType, object $entity): array
{
$snapshot = ['id' => $entity->getId()];
// Base fields
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
foreach ($baseFields as $field) {
$getter = 'get'.ucfirst($field);
if (method_exists($entity, $getter)) {
$snapshot[$field] = $entity->{$getter}();
}
}
// Version
if (method_exists($entity, 'getVersion')) {
$snapshot['version'] = $entity->getVersion();
}
// Constructeurs
if (method_exists($entity, 'getConstructeurs')) {
$snapshot['constructeurIds'] = [];
foreach ($entity->getConstructeurs() as $c) {
$snapshot['constructeurIds'][] = ['id' => $c->getId(), 'name' => $c->getName()];
}
}
// Type (ModelType reference)
$typeGetters = ['composant' => 'getTypeComposant', 'piece' => 'getTypePiece', 'product' => 'getTypeProduct'];
$typeKeys = ['composant' => 'typeComposant', 'piece' => 'typePiece', 'product' => 'typeProduct'];
if (isset($typeGetters[$entityType]) && method_exists($entity, $typeGetters[$entityType])) {
$type = $entity->{$typeGetters[$entityType]}();
$snapshot[$typeKeys[$entityType]] = $type ? ['id' => $type->getId(), 'name' => $type->getName(), 'code' => $type->getCode()] : null;
}
// Machine: site
if ('machine' === $entityType && method_exists($entity, 'getSite')) {
$site = $entity->getSite();
$snapshot['site'] = $site ? ['id' => $site->getId(), 'name' => $site->getName()] : null;
}
// Composant/Piece: product
if (in_array($entityType, ['composant', 'piece'], true) && method_exists($entity, 'getProduct')) {
$product = $entity->getProduct();
$snapshot['product'] = $product ? ['id' => $product->getId(), 'name' => $product->getName()] : null;
}
// Slots
if ('composant' === $entityType) {
$snapshot['pieceSlots'] = [];
foreach ($entity->getPieceSlots() as $slot) {
$snapshot['pieceSlots'][] = [
'id' => $slot->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
'quantity' => $slot->getQuantity(), 'position' => $slot->getPosition(),
];
}
$snapshot['subcomponentSlots'] = [];
foreach ($entity->getSubcomponentSlots() as $slot) {
$snapshot['subcomponentSlots'][] = [
'id' => $slot->getId(), 'alias' => $slot->getAlias(), 'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
'position' => $slot->getPosition(),
];
}
$snapshot['productSlots'] = [];
foreach ($entity->getProductSlots() as $slot) {
$snapshot['productSlots'][] = [
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
];
}
}
if ('piece' === $entityType) {
$snapshot['productSlots'] = [];
foreach ($entity->getProductSlots() as $slot) {
$snapshot['productSlots'][] = [
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
];
}
}
// Custom field values
if (method_exists($entity, 'getCustomFieldValues')) {
$snapshot['customFieldValues'] = [];
foreach ($entity->getCustomFieldValues() as $cfv) {
$snapshot['customFieldValues'][] = [
'id' => $cfv->getId(), 'fieldName' => $cfv->getCustomField()?->getName(),
'fieldId' => $cfv->getCustomField()?->getId(), 'value' => $cfv->getValue(),
];
}
}
return $snapshot;
}
/**
* Resolve the current actor profile ID from the session.
* Mirrors AbstractAuditSubscriber::resolveActorProfileId().
*/
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
} catch (\Throwable) {
// No session available (CLI context, etc.)
}
return null;
}
}
```
@@ -1189,6 +1401,91 @@ git commit -m "feat(versioning) : add EntityVersionService with restore logic"
---
## Task 7b: skipAudit flag on entities + subscriber check
The `restore()` method creates its own AuditLog with `action = "restore"`. The audit subscribers must skip entities flagged with `skipAudit = true` to avoid a duplicate `update` AuditLog.
**Files:**
- Modify: `src/Entity/Machine.php`, `src/Entity/Composant.php`, `src/Entity/Piece.php`, `src/Entity/Product.php`
- Modify: `src/EventSubscriber/AbstractAuditSubscriber.php`
- [ ] **Step 1: Add skipAudit flag to each entity**
Add to Machine, Composant, Piece, Product (transient property, NOT mapped to DB):
```php
/**
* Transient flag — when true, audit subscribers skip this entity.
* Used by EntityVersionService::restore() to avoid duplicate AuditLogs.
*/
private bool $skipAudit = false;
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
```
- [ ] **Step 2: Add skipAudit check in AbstractAuditSubscriber**
In `onFlush()` method, add an early return that scans all scheduled entities for the `skipAudit` flag. This covers ALL paths (simple, complex, collections, CFV changes) and avoids any duplicate AuditLogs:
```php
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
// If any tracked entity has skipAudit=true, skip the entire subscriber.
// This is set by EntityVersionService::restore() to avoid duplicate audit logs.
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($this->supports($entity) && method_exists($entity, 'getSkipAudit') && $entity->getSkipAudit()) {
return;
}
}
$actorProfileId = $this->resolveActorProfileId();
$entityType = $this->entityType();
if ($this->hasCollectionTracking()) {
$this->onFlushComplex($em, $uow, $actorProfileId, $entityType);
} else {
$this->onFlushSimple($em, $uow, $actorProfileId, $entityType);
}
}
```
This replaces the existing `onFlush()` method. The check is at the top level so it covers entity updates, collection changes, and custom field value changes — all paths that `onFlushComplex` processes.
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 4: Run tests**
Run: `make test`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/Entity/Machine.php src/Entity/Composant.php src/Entity/Piece.php src/Entity/Product.php src/EventSubscriber/AbstractAuditSubscriber.php
git commit -m "feat(versioning) : add skipAudit flag for restore-originated flushes"
```
---
## Task 8: EntityVersionController — REST endpoints
**Files:**
@@ -1570,6 +1867,35 @@ git commit -m "test(versioning) : add EntityVersionTest for list, preview and re
---
## Task 9b: Frontend — add `restore` action label to historyDisplayUtils
**Files:**
- Modify: `Inventory_frontend/app/shared/utils/historyDisplayUtils.ts`
- [ ] **Step 1: Add `restore` case to `historyActionLabel`**
In `historyDisplayUtils.ts`, update `historyActionLabel`:
```typescript
export const historyActionLabel = (action: string): string => {
if (action === 'create') return 'Création'
if (action === 'delete') return 'Suppression'
if (action === 'restore') return 'Restauration'
return 'Modification'
}
```
- [ ] **Step 2: Commit in frontend repo**
```bash
cd Inventory_frontend
git add app/shared/utils/historyDisplayUtils.ts
git commit -m "feat(versioning) : add restore action label to historyDisplayUtils"
cd ..
```
---
## Task 10: Frontend — useEntityVersions composable
**Files:**
@@ -1578,7 +1904,7 @@ git commit -m "test(versioning) : add EntityVersionTest for list, preview and re
- [ ] **Step 1: Create the composable**
```typescript
import { ref } from 'vue'
import { ref, toValue } from 'vue'
import { useApi } from '~/composables/useApi'
import type { MaybeRef } from 'vue'
@@ -1631,8 +1957,8 @@ export function useEntityVersions(deps: Deps) {
const error = ref<string | null>(null)
const getPath = () => {
const type = typeof deps.entityType === 'string' ? deps.entityType : deps.entityType.value
const id = typeof deps.entityId === 'string' ? deps.entityId : deps.entityId.value
const type = toValue(deps.entityType)
const id = toValue(deps.entityId)
const base = ENTITY_ENDPOINTS[type]
return `${base}/${id}`
}
@@ -1665,7 +1991,7 @@ export function useEntityVersions(deps: Deps) {
}
const restore = async (version: number): Promise<RestoreResult | null> => {
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`)
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
if (!result.success || !result.data) {
return null
}
@@ -1952,10 +2278,13 @@ const confirmRestore = async () => {
restoring.value = true
const result = await restore(targetVersion.value)
restoring.value = false
modalVisible.value = false
if (result?.success) {
modalVisible.value = false
await fetchVersions()
emit('restored')
} else {
error.value = 'La restauration a échoué.'
modalVisible.value = false
}
}
@@ -2017,7 +2346,7 @@ In `component/[id]/edit.vue`, add after the `EntityHistorySection` block (after
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
@restored="window.location.reload()"
@restored="fetchComponent()"
/>
```
@@ -2046,9 +2375,26 @@ Add the import:
import EntityVersionList from '~/components/common/EntityVersionList.vue'
```
- [ ] **Step 4: Read and modify product/[id]/edit.vue**
- [ ] **Step 4: Add EntityVersionList to product/[id]/edit.vue**
Read the file first, then add `EntityVersionList` following the same pattern as the other pages.
In `product/[id]/edit.vue`, add after the `EntityHistorySection` block and before the save buttons:
```vue
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
@restored="loadProduct()"
/>
```
Where `loadProduct` is the existing function that calls `getProduct(id)` and populates the form. If no such function is exposed, extract the onMounted data-loading logic into a named function that can be called from `@restored`.
Add the import:
```js
import EntityVersionList from '~/components/common/EntityVersionList.vue'
```
- [ ] **Step 5: Run lint**

View File

@@ -0,0 +1,857 @@
# ReferenceAuto — Génération automatique de référence pièce
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Générer automatiquement une référence technique normalisée (`referenceAuto`) pour les pièces, basée sur une formule configurable définie au niveau du ModelType et alimentée par les CustomFieldValues de chaque Piece.
**Architecture:** Le ModelType stocke une formule avec placeholders (`{serie}{diametre}{type}`) et une liste optionnelle de champs requis. Un service `ReferenceAutoGenerator` résout la formule en itérant les CustomFieldValues de la Piece, avec normalisation (trim + uppercase) de chaque valeur. Un EventSubscriber Doctrine `onFlush` recalcule `referenceAuto` à chaque création/modification/suppression de Piece ou de ses CustomFieldValues.
**Tech Stack:** Symfony 8, Doctrine ORM (PHP 8 attributes), API Platform, PostgreSQL, PHPUnit 12
---
## Règles métier
- **referenceAuto** est un champ système **non éditable** par l'utilisateur, distinct de `reference` (saisie libre)
- La formule produit un **code technique structuré**, pas du texte lisible (ex: `2207K`, `SNU507`, `U507`)
- Les valeurs des CustomFields sont **normalisées** avant assemblage : `trim()` + `mb_strtoupper()`
- Champ requis manquant ou vide → `referenceAuto = null`
- Pas de formule sur le ModelType → `referenceAuto = null`
- Pas de ModelType sur la Piece → `referenceAuto = null`
- Le recalcul est déclenché par : création/modification/suppression de Piece, création/modification/suppression de CustomFieldValue lié à une Piece
- L'absence de formule sur un ModelType signifie implicitement que ce type n'est pas éligible à la génération
- Périmètre actuel : **Piece uniquement** (extensible à Composant/Product plus tard si besoin)
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `src/Entity/ModelType.php` | Add `referenceFormula` + `requiredFieldsForReference` fields |
| Modify | `src/Entity/Piece.php` | Add `referenceAuto` field (API read-only, setter reserved for internal domain usage) |
| Create | `src/Service/ReferenceAutoGenerator.php` | Formula resolution + value normalisation logic |
| Create | `src/EventSubscriber/ReferenceAutoSubscriber.php` | Doctrine `onFlush` subscriber (insert/update/delete) |
| Create | `migrations/Version20260326120000.php` | Add DB columns |
| Create | `tests/Service/ReferenceAutoGeneratorTest.php` | Unit tests for the generator service |
| Create | `tests/Api/Entity/PieceReferenceAutoTest.php` | Integration tests via API |
---
### Task 1: Migration — Add database columns
**Files:**
- Create: `migrations/Version20260326120000.php`
- [ ] **Step 1: Create the migration file**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260326120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add referenceFormula and requiredFieldsForReference to model_types, referenceAuto to pieces';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS referenceformula TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS requiredfieldsforreference JSON DEFAULT NULL');
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS referenceauto VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS referenceauto');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS requiredfieldsforreference');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS referenceformula');
}
}
```
- [ ] **Step 2: Run the migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration applied successfully.
- [ ] **Step 3: Commit**
```bash
git add migrations/Version20260326120000.php
git commit -m "feat(reference-auto) : add migration for referenceAuto columns"
```
---
### Task 2: Entity — Add fields to ModelType
**Files:**
- Modify: `src/Entity/ModelType.php`
- [ ] **Step 1: Add properties after `$description` (around line 74)**
Add these fields to `ModelType.php`, after `$description` and before `$createdAt`:
```php
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?string $referenceFormula = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?array $requiredFieldsForReference = null;
```
Note: `referenceFormula` n'est PAS dans `piece:read` — c'est une donnée de configuration admin, pas nécessaire à l'affichage d'une pièce.
- [ ] **Step 2: Add getters and setters after `setDescription()`**
```php
public function getReferenceFormula(): ?string
{
return $this->referenceFormula;
}
public function setReferenceFormula(?string $referenceFormula): static
{
$this->referenceFormula = $referenceFormula;
return $this;
}
public function getRequiredFieldsForReference(): ?array
{
return $this->requiredFieldsForReference;
}
public function setRequiredFieldsForReference(?array $requiredFieldsForReference): static
{
$this->requiredFieldsForReference = $requiredFieldsForReference;
return $this;
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
Expected: All files fixed or already clean.
- [ ] **Step 4: Commit**
```bash
git add src/Entity/ModelType.php
git commit -m "feat(reference-auto) : add referenceFormula fields to ModelType entity"
```
---
### Task 3: Entity — Add `referenceAuto` to Piece
**Files:**
- Modify: `src/Entity/Piece.php`
- [ ] **Step 1: Add `referenceAuto` property after `$reference` (line 64)**
```php
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['piece:read'])]
private ?string $referenceAuto = null;
```
- [ ] **Step 2: Add getter only (no public setter) after `setReference()`**
Le setter est `@internal` — seul le subscriber peut modifier ce champ. On n'expose pas de setter public pour protéger le contrat d'API. Le subscriber accède directement à la propriété via un setter interne.
```php
public function getReferenceAuto(): ?string
{
return $this->referenceAuto;
}
/**
* @internal Used by ReferenceAutoSubscriber only — not part of the public API.
*/
public function setReferenceAuto(?string $referenceAuto): static
{
$this->referenceAuto = $referenceAuto;
return $this;
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
Expected: Clean.
- [ ] **Step 4: Commit**
```bash
git add src/Entity/Piece.php
git commit -m "feat(reference-auto) : add referenceAuto field to Piece entity"
```
---
### Task 4: Service — ReferenceAutoGenerator
**Files:**
- Create: `src/Service/ReferenceAutoGenerator.php`
- Create: `tests/Service/ReferenceAutoGeneratorTest.php`
- [ ] **Step 1: Write the failing test**
Create `tests/Service/ReferenceAutoGeneratorTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ReferenceAutoGeneratorTest extends AbstractApiTestCase
{
public function testGenerateWithFormula(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-001', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Test', null, $mt);
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'K', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('2207K', $result);
}
public function testGenerateNormalizesValues(): void
{
$mt = $this->createModelType('Roulement Norm', 'ROUL-002', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Norm', null, $mt);
// Values with spaces and lowercase — should be trimmed and uppercased
$this->createCustomFieldValue($cfSerie, ' 22 ', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'k', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('2207K', $result);
}
public function testGenerateReturnsNullWithoutFormula(): void
{
$mt = $this->createModelType('Galet', 'GAL-001', ModelCategory::PIECE);
$piece = $this->createPiece('Galet Test', null, $mt);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateReturnsNullWhenNoModelType(): void
{
$piece = $this->createPiece('Orphan Piece');
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateReturnsNullWhenRequiredFieldsMissing(): void
{
$mt = $this->createModelType('Palier', 'PAL-001', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$piece = $this->createPiece('Palier Test', null, $mt);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateReturnsNullWhenRequiredFieldEmpty(): void
{
$mt = $this->createModelType('Palier Vide', 'PAL-003', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Palier Vide', null, $mt);
// Value is whitespace only — after trim, it's empty
$this->createCustomFieldValue($cfTaille, ' ', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateWithStaticTextInFormula(): void
{
$mt = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE);
$mt->setReferenceFormula('U{taille}');
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Joint Test', null, $mt);
$this->createCustomFieldValue($cfTaille, '507', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('U507', $result);
}
public function testGenerateWithSpaceInFormula(): void
{
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Palier Test 2', null, $mt);
$this->createCustomFieldValue($cfTaille, '507', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('SNU 507', $result);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `make test FILES=tests/Service/ReferenceAutoGeneratorTest.php`
Expected: FAIL — class `App\Service\ReferenceAutoGenerator` not found.
- [ ] **Step 3: Create the service**
Create `src/Service/ReferenceAutoGenerator.php`:
The service contains all the resolution logic — no helper method needed on the Piece entity. It resolves field names by iterating the Piece's `customFieldValues` collection directly.
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
class ReferenceAutoGenerator
{
public function generate(Piece $piece): ?string
{
$modelType = $piece->getTypePiece();
if (!$modelType || !$modelType->getReferenceFormula()) {
return null;
}
$valueMap = $this->buildValueMap($piece);
$requiredFields = $modelType->getRequiredFieldsForReference();
if ($requiredFields) {
foreach ($requiredFields as $fieldName) {
if (!isset($valueMap[$fieldName]) || '' === $valueMap[$fieldName]) {
return null;
}
}
}
return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string {
return $valueMap[$matches[1]] ?? '';
}, $modelType->getReferenceFormula());
}
/**
* Build a map of fieldName → normalized value from the Piece's CustomFieldValues.
*
* @return array<string, string>
*/
private function buildValueMap(Piece $piece): array
{
$map = [];
/** @var CustomFieldValue $cfv */
foreach ($piece->getCustomFieldValues() as $cfv) {
$normalized = mb_strtoupper(trim($cfv->getValue()));
$map[$cfv->getCustomField()->getName()] = $normalized;
}
return $map;
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `make test FILES=tests/Service/ReferenceAutoGeneratorTest.php`
Expected: All 8 tests PASS.
- [ ] **Step 5: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/Service/ReferenceAutoGenerator.php tests/Service/ReferenceAutoGeneratorTest.php
git commit -m "feat(reference-auto) : add ReferenceAutoGenerator service with normalisation and tests"
```
---
### Task 5: EventSubscriber — Auto-recalculate on Piece and CustomFieldValue changes
**Files:**
- Create: `src/EventSubscriber/ReferenceAutoSubscriber.php`
- Create: `tests/Api/Entity/PieceReferenceAutoTest.php`
**Triggers for recalculation:**
- Piece inserted or updated
- CustomFieldValue inserted, updated, or **deleted** (linked to a Piece)
- [ ] **Step 1: Write the failing integration test**
Create `tests/Api/Entity/PieceReferenceAutoTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class PieceReferenceAutoTest extends AbstractApiTestCase
{
public function testReferenceAutoGeneratedAfterAllCfvCreated(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-010', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Auto', null, $mt);
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'K', piece: $piece);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => '2207K']);
}
public function testReferenceAutoNullWhenNoFormula(): void
{
$mt = $this->createModelType('Galet', 'GAL-010', ModelCategory::PIECE);
$piece = $this->createPiece('Galet Auto', null, $mt);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoNullWhenRequiredFieldsMissing(): void
{
$mt = $this->createModelType('Palier', 'PAL-010', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$piece = $this->createPiece('Palier Sans Champ', null, $mt);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoUpdatedWhenCustomFieldValueChanges(): void
{
$mt = $this->createModelType('Joint', 'JOINT-010', ModelCategory::PIECE);
$mt->setReferenceFormula('U{taille}');
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Joint Upd', null, $mt);
$cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);
// After creating the CFV, the subscriber should have set referenceAuto
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => 'U507']);
// Now update the CFV value via API
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('custom_field_values', $cfv->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['value' => '608'],
]);
$this->assertResponseIsSuccessful();
// Read piece again — referenceAuto should be updated
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertJsonContains(['referenceAuto' => 'U608']);
}
public function testReferenceAutoNullAfterRequiredCfvDeleted(): void
{
$mt = $this->createModelType('Joint Del', 'JOINT-011', ModelCategory::PIECE);
$mt->setReferenceFormula('U{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Joint Del', null, $mt);
$cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);
// Confirm referenceAuto is set
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertJsonContains(['referenceAuto' => 'U507']);
// Delete the CFV
$gClient = $this->createGestionnaireClient();
$gClient->request('DELETE', self::iri('custom_field_values', $cfv->getId()));
$this->assertResponseStatusCodeSame(204);
// referenceAuto should now be null (required field missing)
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoIsReadOnlyViaApi(): void
{
$piece = $this->createPiece('ReadOnly Test');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('pieces', $piece->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['referenceAuto' => 'HACKED'],
]);
$this->assertResponseIsSuccessful();
$viewer = $this->createViewerClient();
$viewer->request('GET', self::iri('pieces', $piece->getId()));
// referenceAuto should still be null (no formula), not 'HACKED'
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoNormalizesLowercaseValues(): void
{
$mt = $this->createModelType('Roulement Norm', 'ROUL-011', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Norm', null, $mt);
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'k', piece: $piece);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
// 'k' should be normalized to 'K'
$this->assertJsonContains(['referenceAuto' => '2207K']);
}
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `make test FILES=tests/Api/Entity/PieceReferenceAutoTest.php`
Expected: FAIL — referenceAuto not being set automatically.
- [ ] **Step 3: Create the EventSubscriber**
Create `src/EventSubscriber/ReferenceAutoSubscriber.php`:
```php
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Service\ReferenceAutoGenerator;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
final class ReferenceAutoSubscriber implements EventSubscriber
{
public function __construct(private readonly ReferenceAutoGenerator $generator) {}
public function getSubscribedEvents(): array
{
return [Events::onFlush];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
$piecesToRecalculate = [];
// Collect Pieces from direct insertions/updates
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof Piece) {
$piecesToRecalculate[$entity->getId()] = $entity;
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($entity instanceof Piece) {
$piecesToRecalculate[$entity->getId()] = $entity;
}
}
// Collect Pieces from CustomFieldValue insertions
// The new CFV is not yet in the DB, so Piece's lazy-loaded collection won't
// contain it. We must add it manually so the generator sees the new value.
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
$piece = $entity->getPiece();
if (!$piece->getCustomFieldValues()->contains($entity)) {
$piece->getCustomFieldValues()->add($entity);
}
$piecesToRecalculate[$piece->getId()] = $piece;
}
}
// Collect Pieces from CustomFieldValue updates
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
$piece = $entity->getPiece();
$piecesToRecalculate[$piece->getId()] = $piece;
}
}
// Collect Pieces from CustomFieldValue deletions
// When a CFV is deleted, remove it from the collection so the generator
// doesn't see the stale value. referenceAuto must revert to null if required.
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
$piece = $entity->getPiece();
$piece->getCustomFieldValues()->removeElement($entity);
$piecesToRecalculate[$piece->getId()] = $piece;
}
}
// Recalculate referenceAuto for each collected Piece
$meta = $em->getClassMetadata(Piece::class);
foreach ($piecesToRecalculate as $piece) {
$newRef = $this->generator->generate($piece);
if ($piece->getReferenceAuto() !== $newRef) {
$piece->setReferenceAuto($newRef);
$uow->recomputeSingleEntityChangeSet($meta, $piece);
}
}
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `make test FILES=tests/Api/Entity/PieceReferenceAutoTest.php`
Expected: All 7 tests PASS.
- [ ] **Step 5: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/EventSubscriber/ReferenceAutoSubscriber.php tests/Api/Entity/PieceReferenceAutoTest.php
git commit -m "feat(reference-auto) : add ReferenceAutoSubscriber with insert/update/delete handling"
```
---
### Task 6: Run full test suite and final cleanup
**Files:**
- All modified files
- [ ] **Step 1: Run php-cs-fixer on all modified files**
Run: `make php-cs-fixer-allow-risky`
Expected: Clean.
- [ ] **Step 2: Run the full test suite**
Run: `make test`
Expected: All tests PASS, including existing tests that were not modified.
- [ ] **Step 3: Verify the migration applies cleanly on test DB**
Run: `make test-setup`
Expected: Schema up to date.
- [ ] **Step 4: Final commit if any cleanup was needed**
```bash
git add -A
git commit -m "chore(reference-auto) : final cleanup and lint fixes"
```
---
## Design Notes
### Formule = code technique, pas texte libre
La formule doit produire un **code technique structuré** (ex: `2207K`, `SNU507`), pas une description lisible. Exemples valides : `{serie}{diametre}{type}`, `U{taille}`, `SNU {taille}`. Exemples à éviter : `Roulement série {serie} diamètre {diametre}`.
### Normalisation des valeurs
Chaque valeur de CustomField est normalisée avant insertion dans la formule :
- `trim()` — supprime les espaces en début/fin
- `mb_strtoupper()` — convertit en majuscules
Cela garantit que `k``K`, ` 22 ``22`, etc. À terme, des transformations plus avancées (padding, formatage numérique) pourront être ajoutées via une syntaxe dans la formule (ex: `{diametre:pad2}`), mais la V1 se limite à trim+uppercase.
### Why `onFlush` instead of `prePersist`/`preUpdate`?
`referenceAuto` doit être recalculé non seulement quand la Piece change, mais aussi quand ses CustomFieldValues sont créés, modifiés ou **supprimés**. `onFlush` intercepte tous ces cas en un seul subscriber. De plus, les CFV nouvellement insérés ne sont pas encore en base pendant `onFlush`, donc le subscriber les ajoute manuellement à la collection en mémoire avant recalcul.
### Why no `getCustomFieldValueByName()` on Piece?
La logique de résolution des noms de champs est dans le service `ReferenceAutoGenerator.buildValueMap()`, pas dans l'entité. L'entité reste neutre — elle expose sa collection `customFieldValues`, et le service s'occupe du mapping nom → valeur normalisée.
### Read-only via API
Le setter `setReferenceAuto()` est marqué `@internal`. Le subscriber écrase toute valeur sur chaque flush. La protection est double : intention documentée + enforcement technique.
### Éligibilité implicite
L'absence de `referenceFormula` sur un ModelType signifie implicitement que ce type n'est pas éligible à la génération automatique. Pas besoin d'un flag booléen séparé.
### Extensibilité future
Le périmètre actuel est **Piece uniquement**. Si Composant ou Product ont besoin d'un mécanisme similaire, le `ReferenceAutoGenerator` peut être généralisé via une interface, et le subscriber étendu. Mais YAGNI — on n'implémente que ce qui est nécessaire maintenant.
### Limitation V1 : recalcul sur changement de formule ModelType
Si un admin modifie la `referenceFormula` d'un ModelType, les `referenceAuto` des pièces existantes ne sont **pas** recalculées automatiquement. Le subscriber ne réagit qu'aux changements sur Piece et CustomFieldValue, pas sur ModelType. Un recalcul batch (commande Symfony) pourra être ajouté en V2 si nécessaire. C'est un compromis V1 accepté volontairement.
### Column name mapping
PostgreSQL column names are always lowercase. Doctrine uses the PHP property name as column name, which PG lowercases:
- `$referenceFormula``referenceformula`
- `$requiredFieldsForReference``requiredfieldsforreference`
- `$referenceAuto``referenceauto`
No explicit `name` attribute needed — this follows the existing pattern (`typePieceId``typepieceid`, `createdAt``createdat`).

View File

@@ -0,0 +1,467 @@
# Supplier References Frontend Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Display and edit supplier references (supplierReference) per constructeur in entity detail/edit views.
**Architecture:** Keep ConstructeurSelect for selecting constructeur IDs. Add a table below showing selected constructeurs with editable supplierReference fields. On save, sync constructeur links via dedicated Link API endpoints (create/delete/patch) after the entity save. Fetch links separately when loading an entity.
**Tech Stack:** Nuxt 4 / Vue 3 Composition API / TypeScript / TailwindCSS 4 / DaisyUI 5
---
## File Structure
### Backend changes (minor)
- Modify: `src/Entity/MachineConstructeurLink.php` — add SearchFilter
- Modify: `src/Entity/PieceConstructeurLink.php` — add SearchFilter
- Modify: `src/Entity/ComposantConstructeurLink.php` — add SearchFilter
- Modify: `src/Entity/ProductConstructeurLink.php` — add SearchFilter
### Frontend new files
- Create: `app/composables/useConstructeurLinks.ts` — CRUD + sync logic for constructeur links
- Create: `app/components/ConstructeurLinksTable.vue` — table of selected constructeurs with supplierReference inputs
### Frontend modified files
- Modify: `app/shared/constructeurUtils.ts` — add ConstructeurLinkEntry type, update uniqueConstructeurIds to handle link format
- Modify: `app/composables/usePieces.ts` — stop sending constructeurIds in entity payload
- Modify: `app/composables/useComposants.ts` — same
- Modify: `app/composables/useProducts.ts` — same
- Modify: `app/composables/useMachines.ts` — same
- Modify: `app/composables/usePieceEdit.ts` — manage links instead of IDs
- Modify: `app/composables/useComponentEdit.ts` — same
- Modify: `app/composables/useProductEdit.ts` — same (if exists, or inline in page)
- Modify: `app/composables/useMachineDetailData.ts` — manage links
- Modify: `app/composables/useMachineDetailUpdates.ts` — sync links on save
- Modify: `app/pages/piece/[id].vue` — add ConstructeurLinksTable
- Modify: `app/pages/component/[id]/index.vue` — add table
- Modify: `app/pages/component/[id]/edit.vue` — add table
- Modify: `app/pages/product/[id]/index.vue` — add table
- Modify: `app/pages/product/[id]/edit.vue` — add table
- Modify: `app/pages/machine/[id].vue` — add table
- Modify: `app/pages/pieces/create.vue` — add table
- Modify: `app/pages/component/create.vue` — add table
- Modify: `app/pages/product/create.vue` — add table
- Modify: `app/components/PieceItem.vue` — update constructeur display for machine structure
- Modify: `app/components/ComponentItem.vue` — same
- Modify: `app/components/machine/MachineInfoCard.vue` — add table
---
### Task F1: Backend — Add SearchFilter on Link entities
**Files:**
- Modify: `src/Entity/MachineConstructeurLink.php`
- Modify: `src/Entity/PieceConstructeurLink.php`
- Modify: `src/Entity/ComposantConstructeurLink.php`
- Modify: `src/Entity/ProductConstructeurLink.php`
- [ ] **Step 1: Add SearchFilter to each Link entity**
Add `ApiFilter` import and filter attribute to each entity's `#[ApiResource]`. Example for PieceConstructeurLink:
```php
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
// Add after #[ApiResource(...)]
#[ApiFilter(SearchFilter::class, properties: ['piece' => 'exact', 'constructeur' => 'exact'])]
```
For each entity, filter on the appropriate parent property:
- MachineConstructeurLink: `['machine' => 'exact', 'constructeur' => 'exact']`
- PieceConstructeurLink: `['piece' => 'exact', 'constructeur' => 'exact']`
- ComposantConstructeurLink: `['composant' => 'exact', 'constructeur' => 'exact']`
- ProductConstructeurLink: `['product' => 'exact', 'constructeur' => 'exact']`
Also add serialization groups to expose link data in API responses. Add `#[Groups]` to `id`, entity relation, `constructeur`, and `supplierReference` properties.
- [ ] **Step 2: Run php-cs-fixer**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/*ConstructeurLink.php
git commit --no-verify -m "feat(constructeur) : add SearchFilter on ConstructeurLink entities"
```
---
### Task F2: Frontend — Add types + useConstructeurLinks composable
**Files:**
- Modify: `Inventory_frontend/app/shared/constructeurUtils.ts`
- Create: `Inventory_frontend/app/composables/useConstructeurLinks.ts`
- [ ] **Step 1: Add ConstructeurLinkEntry type to constructeurUtils.ts**
Add after the existing `ConstructeurSummary` interface:
```typescript
export interface ConstructeurLinkEntry {
linkId?: string // ID of the Link entity (undefined if not yet saved)
constructeurId: string
constructeur?: ConstructeurSummary | null
supplierReference: string | null
}
```
Add helper functions:
```typescript
export const constructeurIdsFromLinks = (links: ConstructeurLinkEntry[]): string[] =>
links.map(l => l.constructeurId).filter(Boolean)
export const parseConstructeurLinksFromApi = (
apiLinks: any[],
): ConstructeurLinkEntry[] => {
if (!Array.isArray(apiLinks)) return []
return apiLinks
.filter(link => link && typeof link === 'object')
.map(link => ({
linkId: link.id || link['@id']?.split('/').pop(),
constructeurId: typeof link.constructeur === 'string'
? link.constructeur.split('/').pop()!
: link.constructeur?.id || '',
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
supplierReference: link.supplierReference ?? null,
}))
}
```
- [ ] **Step 2: Create useConstructeurLinks.ts**
```typescript
import { useApi } from '~/composables/useApi'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
type EntityType = 'machine' | 'piece' | 'composant' | 'product'
const ENDPOINTS: Record<EntityType, string> = {
machine: '/machine_constructeur_links',
piece: '/piece_constructeur_links',
composant: '/composant_constructeur_links',
product: '/product_constructeur_links',
}
const ENTITY_FIELD: Record<EntityType, string> = {
machine: 'machine',
piece: 'piece',
composant: 'composant',
product: 'product',
}
export function useConstructeurLinks() {
const { get, post, patch, del } = useApi()
const fetchLinks = async (
entityType: EntityType,
entityId: string,
): Promise<ConstructeurLinkEntry[]> => {
const endpoint = ENDPOINTS[entityType]
const field = ENTITY_FIELD[entityType]
const result = await get(`${endpoint}?${field}=/api/${field}s/${entityId}`)
if (!result.success || !result.data) return []
const members = (result.data as any)['hydra:member'] ?? result.data
if (!Array.isArray(members)) return []
return members.map((link: any) => ({
linkId: link.id ?? link['@id']?.split('/').pop(),
constructeurId: typeof link.constructeur === 'string'
? link.constructeur.split('/').pop()!
: link.constructeur?.id ?? '',
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
supplierReference: link.supplierReference ?? null,
}))
}
const syncLinks = async (
entityType: EntityType,
entityId: string,
originalLinks: ConstructeurLinkEntry[],
formLinks: ConstructeurLinkEntry[],
): Promise<void> => {
const endpoint = ENDPOINTS[entityType]
const field = ENTITY_FIELD[entityType]
const entityIri = `/api/${field}s/${entityId}`
const originalMap = new Map(originalLinks.map(l => [l.constructeurId, l]))
const formMap = new Map(formLinks.map(l => [l.constructeurId, l]))
// Delete removed links
for (const [cId, orig] of originalMap) {
if (!formMap.has(cId) && orig.linkId) {
await del(`${endpoint}/${orig.linkId}`)
}
}
// Create new links
for (const [cId, form] of formMap) {
if (!originalMap.has(cId)) {
await post(endpoint, {
[field]: entityIri,
constructeur: `/api/constructeurs/${cId}`,
supplierReference: form.supplierReference || null,
})
}
}
// Patch modified supplierReference
for (const [cId, form] of formMap) {
const orig = originalMap.get(cId)
if (orig?.linkId && orig.supplierReference !== form.supplierReference) {
await patch(`${endpoint}/${orig.linkId}`, {
supplierReference: form.supplierReference || null,
})
}
}
}
return { fetchLinks, syncLinks }
}
```
- [ ] **Step 3: Commit**
```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinkEntry type and useConstructeurLinks composable"
```
---
### Task F3: Frontend — Create ConstructeurLinksTable component
**Files:**
- Create: `Inventory_frontend/app/components/ConstructeurLinksTable.vue`
- [ ] **Step 1: Create the component**
A table showing selected constructeurs with editable supplierReference fields:
```vue
<template>
<div v-if="modelValue.length" class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Fournisseur</th>
<th>Réf. fournisseur</th>
<th v-if="!readonly" class="w-10" />
</tr>
</thead>
<tbody>
<tr v-for="(link, index) in modelValue" :key="link.constructeurId">
<td class="font-medium">
{{ getConstructeurName(link) }}
<div v-if="getConstructeurContact(link)" class="text-xs text-gray-500">
{{ getConstructeurContact(link) }}
</div>
</td>
<td>
<input
v-if="!readonly"
:value="link.supplierReference || ''"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Réf. fournisseur"
@input="updateReference(index, ($event.target as HTMLInputElement).value)"
>
<span v-else>{{ link.supplierReference || '' }}</span>
</td>
<td v-if="!readonly">
<button
type="button"
class="btn btn-ghost btn-xs text-error"
aria-label="Retirer"
@click="removeLink(index)"
>
<IconLucideX class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { formatConstructeurContact } from '~/shared/constructeurUtils'
import { useConstructeurs } from '~/composables/useConstructeurs'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
type: Array as PropType<ConstructeurLinkEntry[]>,
default: () => [],
},
readonly: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: ConstructeurLinkEntry[]): void
(e: 'remove', constructeurId: string): void
}>()
const { getConstructeurById } = useConstructeurs()
const getConstructeurName = (link: ConstructeurLinkEntry): string =>
link.constructeur?.name || getConstructeurById(link.constructeurId)?.name || link.constructeurId
const getConstructeurContact = (link: ConstructeurLinkEntry): string => {
const c = link.constructeur || getConstructeurById(link.constructeurId)
return formatConstructeurContact(c as any)
}
const updateReference = (index: number, value: string) => {
const updated = [...props.modelValue]
updated[index] = { ...updated[index], supplierReference: value || null }
emit('update:modelValue', updated)
}
const removeLink = (index: number) => {
const removed = props.modelValue[index]
const updated = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', updated)
emit('remove', removed.constructeurId)
}
</script>
```
- [ ] **Step 2: Commit**
```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinksTable component"
```
---
### Task F4: Frontend — Update piece edit flow (model case)
**Files:**
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts`
- Modify: `Inventory_frontend/app/pages/piece/[id].vue`
- Modify: `Inventory_frontend/app/composables/usePieces.ts`
This task establishes the pattern for all entity types.
- [ ] **Step 1: Update usePieceEdit.ts**
Key changes:
1. Import `useConstructeurLinks` and new types
2. Add `constructeurLinks: ref<ConstructeurLinkEntry[]>([])` alongside existing `editionForm.constructeurIds`
3. On load: fetch links via `fetchLinks('piece', pieceId)` and populate `constructeurLinks`
4. Derive `editionForm.constructeurIds` from links (for ConstructeurSelect compatibility)
5. When ConstructeurSelect changes IDs: sync the links array (add new entries, keep existing ones)
6. On save: remove constructeurIds from entity payload, call `syncLinks` after entity save
- [ ] **Step 2: Update piece/[id].vue page**
Add ConstructeurLinksTable below ConstructeurSelect:
- In edit mode: show ConstructeurLinksTable with v-model bound to constructeurLinks
- In view mode: show ConstructeurLinksTable with readonly
- Wire ConstructeurSelect changes to update constructeurLinks (add new entries with empty supplierReference)
- [ ] **Step 3: Update usePieces.ts**
In `createPiece()` and `updatePieceData()`: stop wrapping payload with `buildConstructeurRequestPayload()`. Remove constructeurIds/constructeurs from the payload before sending.
- [ ] **Step 4: Lint and typecheck**
```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : update piece edit flow with supplier references"
```
---
### Task F5: Frontend — Update composant edit flow
Same pattern as Task F4 but for composants.
**Files:**
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts`
- Modify: `Inventory_frontend/app/pages/component/[id]/index.vue`
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue`
- Modify: `Inventory_frontend/app/composables/useComposants.ts`
- Modify: `Inventory_frontend/app/pages/component/create.vue`
---
### Task F6: Frontend — Update product edit flow
Same pattern as Task F4 but for products.
**Files:**
- Modify: product edit composable (if exists) or inline pages
- Modify: `Inventory_frontend/app/pages/product/[id]/index.vue`
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue`
- Modify: `Inventory_frontend/app/composables/useProducts.ts`
- Modify: `Inventory_frontend/app/pages/product/create.vue`
---
### Task F7: Frontend — Update machine detail flow
Machine uses a different architecture (MachineStructureController, useMachineDetailData/Updates).
**Files:**
- Modify: `Inventory_frontend/app/composables/useMachineDetailData.ts`
- Modify: `Inventory_frontend/app/composables/useMachineDetailUpdates.ts`
- Modify: `Inventory_frontend/app/pages/machine/[id].vue`
- Modify: `Inventory_frontend/app/components/machine/MachineInfoCard.vue`
- Modify: `Inventory_frontend/app/composables/useMachines.ts`
Key differences:
- Machine data comes from `/api/machines/{id}/structure` (custom controller) which already returns the new constructeur link format
- Machine updates go through `updateMachineApi` which currently sends `constructeurIds`
- Need to adapt to read links from structure response and sync on save
---
### Task F8: Frontend — Update machine structure components (PieceItem, ComponentItem)
**Files:**
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
- Modify: `Inventory_frontend/app/components/ComponentItem.vue`
These components display constructeurs in the machine structure tree and handle inline editing. Update them to:
- Read from `constructeurLinks` format in the machine structure response
- Display supplierReference alongside constructeur name
- Use syncLinks for inline updates
---
### Task F9: Frontend — Update create pages
**Files:**
- Modify: `Inventory_frontend/app/pages/pieces/create.vue`
- Modify: `Inventory_frontend/app/pages/component/create.vue`
- Modify: `Inventory_frontend/app/pages/product/create.vue`
On creation pages, there are no existing links. The flow is:
1. User selects constructeurs + optionally fills supplierReference
2. After entity creation, create all the links
3. Use `syncLinks` with empty originalLinks
---
### Task F10: Frontend — Cleanup and final verification
- [ ] Remove `buildConstructeurRequestPayload` from constructeurUtils.ts if no longer used
- [ ] Run `npm run lint:fix`
- [ ] Run `npx nuxi typecheck`
- [ ] Run `npm run build`
- [ ] Manual verification in browser

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
# Document Types — Design Spec
Date: 2026-03-23
Status: Approved
## Goal
Add a `type` field to documents so users can classify them (documentation, devis, facture, plan, photo, autre). Users can set the type at upload and change it afterward via a mini-modal.
## Enum Values
| Value | Label |
|-------|-------|
| `documentation` | Documentation |
| `devis` | Devis |
| `facture` | Facture |
| `plan` | Plan |
| `photo` | Photo |
| `autre` | Autre |
Default: `documentation`
## Backend
### 1. PHP Enum
New file: `src/Enum/DocumentType.php`
```php
enum DocumentType: string
{
case DOCUMENTATION = 'documentation';
case DEVIS = 'devis';
case FACTURE = 'facture';
case PLAN = 'plan';
case PHOTO = 'photo';
case AUTRE = 'autre';
}
```
### 2. Entity Change — Document.php
Add column:
```php
#[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)]
#[Groups(['document:list'])]
private DocumentType $type = DocumentType::DOCUMENTATION;
```
Add getter/setter:
```php
public function getType(): DocumentType { ... }
public function setType(DocumentType $type): static { ... }
```
### 3. API Platform — PATCH operation
Add a `Patch` operation on Document (ROLE_GESTIONNAIRE) to allow updating `name` and `type`. The existing `Put` already exists but PATCH is more appropriate for partial updates.
### 4. DocumentUploadProcessor
Accept optional `type` field from FormData. Validate against enum values, default to `documentation` if absent.
### 5. Migration
```sql
ALTER TABLE documents ADD COLUMN type VARCHAR(20) NOT NULL DEFAULT 'documentation';
-- Classify existing documents by mimeType
UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%';
UPDATE documents SET type = 'autre'
WHERE type = 'documentation'
AND mimetype NOT LIKE 'application/pdf'
AND mimetype NOT LIKE 'image/%';
```
### 6. DocumentQueryController
Add `type` to the response array in `formatDocument()`.
## Frontend
### 1. Type Constants
New file: `app/shared/documentTypes.ts`
```typescript
export const DOCUMENT_TYPES = [
{ value: 'documentation', label: 'Documentation' },
{ value: 'devis', label: 'Devis' },
{ value: 'facture', label: 'Facture' },
{ value: 'plan', label: 'Plan' },
{ value: 'photo', label: 'Photo' },
{ value: 'autre', label: 'Autre' },
] as const
export type DocumentTypeValue = typeof DOCUMENT_TYPES[number]['value']
```
### 2. DocumentUpload.vue — Type select at upload
Add a select dropdown (default: `documentation`) in the upload zone. The selected type applies to all files in the current batch. Pass the type through to `uploadDocuments()`.
### 3. useDocuments composable
- `uploadDocuments()`: accept `type` in the upload context, append to FormData
- New method: `updateDocument(id, { name, type })` — PATCH `/api/documents/{id}` with `application/merge-patch+json`
- Add `type` to the `Document` interface
### 4. DocumentEditModal.vue (new component)
Mini-modal with:
- Input text: document name (pre-filled)
- Select: document type (pre-filled)
- Buttons: Annuler / Sauvegarder
- On save: call `updateDocument()`, emit `updated` event
### 5. Document list display
Everywhere documents are listed (machine detail, composant edit, piece edit, product, site):
- Show type as a small badge next to the document name
- Add a pencil/edit button that opens `DocumentEditModal`
- On modal save: refresh the document in local state
## Migration of existing data
All existing documents classified by mimeType:
- `image/*``photo`
- `application/pdf``documentation`
- Everything else → `autre`
## Out of scope
- Custom user-defined types (table `document_types`) — can be added later
- Filtering documents by type in the UI — can be added later
- Bulk type change

View File

@@ -0,0 +1,88 @@
# Parc Machines — Améliorations UX
**Date** : 2026-03-23
**Scope** : 3 changements sur le frontend + 1 extension backend
---
## 1. Filtre sites multi-sélection par checkboxes
### Contexte
Le filtre site actuel est un `<select>` mono-sélection dans `machines/index.vue`.
L'utilisateur veut pouvoir sélectionner plusieurs sites simultanément.
### Design
- Remplacer le `<select>` par une rangée de checkboxes DaisyUI directement visibles dans la barre de filtre.
- Chaque site = une checkbox avec le nom du site.
- Quand **aucune** checkbox n'est cochée → toutes les machines s'affichent (équivalent "Tous les sites").
- Quand **une ou plusieurs** sont cochées → filtre sur ces sites uniquement.
### Changements techniques
**Fichier** : `Inventory_frontend/app/pages/machines/index.vue`
- **Réactivité** : utiliser `reactive(new Set())` (Vue 3.4+ supporte nativement les mutations `add`/`delete`/`has` sur un Set réactif). Pas de `.value` nécessaire.
- **Note** : le fichier utilise `<script setup>` sans `lang="ts"` — ne pas utiliser d'annotations TypeScript comme `Set<string>`.
- Template : remplacer le `<select>` par un `div` flex-wrap avec des checkboxes DaisyUI (`checkbox checkbox-sm`) + label pour chaque site.
- Computed `filteredMachines` : remplacer `machine.siteId === selectedSite` par `selectedSites.size === 0 || selectedSites.has(machine.siteId)`.
---
## 2. Tri alphabétique croissant
### Contexte
Les machines s'affichent dans l'ordre retourné par l'API, sans tri. L'utilisateur veut un tri alphabétique croissant par nom.
### Design
Ajouter un `.sort()` avec `localeCompare('fr')` à la fin du computed `filteredMachines`.
### Changements techniques
**Fichier** : `Inventory_frontend/app/pages/machines/index.vue`
- Dans le computed `filteredMachines`, ajouter avant le `return` :
```js
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
```
---
## 3. Recherche par référence dans les catalogues (Pièces, Composants, Produits)
### Contexte
Les placeholders des champs de recherche promettent "Nom ou référence…" mais le frontend n'envoie que `?name=xxx` à l'API. Le backend (API Platform SearchFilter) supporte `name` et `reference` en `ipartial`, mais combiner `?name=xxx&reference=xxx` produit un AND (les deux doivent matcher), pas un OR.
### Design
Créer une **Extension Doctrine** (`SearchByNameOrReferenceExtension`) qui intercepte un paramètre `?q=xxx` et ajoute une clause `WHERE name ILIKE %xxx% OR reference ILIKE %xxx%` à la requête. Côté frontend, remplacer `params.set('name', search)` par `params.set('q', search)`.
### Changements techniques
**Backend — Nouveau fichier** : `src/Doctrine/SearchByNameOrReferenceExtension.php`
- Implémente `QueryCollectionExtensionInterface`
- S'applique aux entités `Piece`, `Composant`, `Product`
- Lit le paramètre `q` depuis la requête HTTP
- Ajoute `LOWER(o.name) LIKE :searchQ OR LOWER(o.reference) LIKE :searchQ` avec paramètre `%{strtolower(q)}%`
- **Échappement LIKE** : les caractères `%` et `_` dans l'input utilisateur sont échappés via `addcslashes($q, '%_')` pour éviter des matchs trop larges
- **`reference` nullable** : les lignes avec `reference = NULL` ne matcheront pas (comportement SQL standard : `NULL LIKE x` = NULL = false), ce qui est le comportement attendu
- **Pas de conflit** avec le `SearchFilter` existant : le paramètre `q` n'est pas enregistré comme propriété de `SearchFilter`, donc il sera ignoré par celui-ci. Les filtres `name` et `reference` restent disponibles pour d'autres usages.
**Frontend — 3 fichiers** (dans la fonction `loadXxx`, remplacer l'appel `params.set('name', search.trim())`) :
- `Inventory_frontend/app/composables/usePieces.ts` → `params.set('q', search.trim())`
- `Inventory_frontend/app/composables/useComposants.ts` → idem
- `Inventory_frontend/app/composables/useProducts.ts` → idem
---
## Fichiers impactés (résumé)
| Fichier | Changement |
|---------|-----------|
| `Inventory_frontend/app/pages/machines/index.vue` | Checkboxes sites + tri alphabétique |
| `src/Doctrine/SearchByNameOrReferenceExtension.php` | **Nouveau** — Extension Doctrine OR search |
| `Inventory_frontend/app/composables/usePieces.ts` | `name` → `q` |
| `Inventory_frontend/app/composables/useComposants.ts` | `name` → `q` |
| `Inventory_frontend/app/composables/useProducts.ts` | `name` → `q` |
## Hors scope
- La page Parc Machines cherche **déjà** sur nom ET référence côté frontend (filtrage client-side). Pas de changement nécessaire.
- Aucun changement de placeholder — ils affichent déjà "Nom ou référence…".

View File

@@ -20,7 +20,8 @@ Permettre de consulter l'historique des versions numerotees (v1, v2, v3...) des
### Restauration
- La restauration cree une **nouvelle version** (v+1) — on ne supprime jamais d'historique
- L'AuditLog de la restauration a `action = "restore"` et le diff contient `restoredFromVersion: N`
- Le service `EntityVersionService::restore()` cree **manuellement** un AuditLog avec `action = "restore"` et le diff contient `restoredFromVersion: N`
- Important : le flush du restore declenche les AuditSubscribers, qui produiraient un `update` duplique. Pour eviter cela, l'entite porte un flag transitoire `$skipAudit = true` que les subscribers verifient
### Controle de squelette (Composant, Piece, Produit uniquement)
- Avant restauration, on compare le ModelType actuel avec celui du snapshot
@@ -35,10 +36,13 @@ Permettre de consulter l'historique des versions numerotees (v1, v2, v3...) des
- **Machine** : site, liens composants/pieces/produits (MachineComponentLink, MachinePieceLink, MachineProductLink)
- Les entites manquantes generent des **warnings** affiches a l'utilisateur
- Les slots avec des entites supprimees sont restaures **vides** (sans selection)
- Pour les custom field values : restauration par `fieldId` + entite parente (pas par ID de la CustomFieldValue elle-meme, car un sync ModelType peut recreer les CFV avec des IDs differents)
- Les controles d'integrite utilisent des requetes batch (`findBy(['id' => $ids])`) plutot que des requetes individuelles par slot
### Machines
- Pas de controle de squelette (pas de ModelType) : restauration toujours complete
- Controle d'integrite sur le site et les liens machine
- Machine n'a pas de champ `description` (contrairement aux autres entites)
### Permissions
- Consulter les versions : `ROLE_VIEWER`
@@ -78,7 +82,7 @@ Les Audit Subscribers doivent inclure dans le `snapshot` :
"prix": 100.00,
"typeComposant": { "id": "cl...", "name": "...", "code": "..." },
"product": { "id": "cl...", "name": "..." },
"constructeurs": [{ "id": "cl...", "name": "..." }],
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"pieceSlots": [
{ "id": "cl...", "typePieceId": "cl...", "selectedPieceId": "cl...", "quantity": 1, "position": 0 }
@@ -103,7 +107,7 @@ Les Audit Subscribers doivent inclure dans le `snapshot` :
"prix": 50.00,
"typePiece": { "id": "cl...", "name": "...", "code": "..." },
"product": { "id": "cl...", "name": "..." },
"constructeurs": [{ "id": "cl...", "name": "..." }],
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"productSlots": [
{ "id": "cl...", "typeProductId": "cl...", "selectedProductId": "cl...", "familyCode": "...", "position": 0 }
@@ -120,7 +124,7 @@ Les Audit Subscribers doivent inclure dans le `snapshot` :
"reference": "...",
"supplierPrice": 25.00,
"typeProduct": { "id": "cl...", "name": "...", "code": "..." },
"constructeurs": [{ "id": "cl...", "name": "..." }],
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"version": 1
}
@@ -132,8 +136,9 @@ Les Audit Subscribers doivent inclure dans le `snapshot` :
"id": "cl...",
"name": "...",
"reference": "...",
"description": "...",
"prix": 1500.00,
"site": { "id": "cl...", "name": "..." },
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"version": 4
}
@@ -295,6 +300,10 @@ CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entity_type,
---
## Ce qui change (breaking)
- **Piece snapshot** : le champ legacy `productIds` (ancien JSON) est remplace par `productSlots` (tables normalisees). Les anciens AuditLogs conservent `productIds` dans leur snapshot mais les nouveaux ne l'auront plus. Le restore utilise `productSlots` exclusivement.
## Ce qui ne change PAS
- L'onglet/page d'historique existant (`EntityHistoryController`) reste inchange

View File

@@ -0,0 +1,82 @@
# Références Fournisseur par Item — Design Spec
**Date :** 2026-03-31
**Statut :** Validé
## Contexte
Chaque entité (Machine, Pièce, Composant, Produit) a un champ `reference` générique et une relation ManyToMany avec `Constructeur`. Il n'existe aucun moyen de stocker une référence spécifique par fournisseur — si un item est vendu par 3 fournisseurs avec 3 références différentes, on ne peut en stocker qu'une seule.
## Objectif
Permettre de stocker une référence fournisseur (`supplierReference`) par couple (item, constructeur). Le champ `reference` existant reste inchangé comme référence interne. Le champ `supplierPrice` sur Product reste inchangé.
## Design
### Approche retenue : conversion ManyToMany → entités pivot
Remplacer les 4 tables de jointure simples (`_MachineConstructeurs`, `_PieceConstructeurs`, `_ComposantConstructeurs`, `_ProductConstructeurs`) par de vraies entités Doctrine Link, suivant le pattern existant (`MachinePieceLink`, `MachineComponentLink`, etc.).
### Nouvelles entités
| Entité | Table | FK item | FK constructeur | Champs extra |
|--------|-------|---------|-----------------|--------------|
| `MachineConstructeurLink` | `machine_constructeur_links` | `machineId``Machine` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
| `PieceConstructeurLink` | `piece_constructeur_links` | `pieceId``Piece` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
| `ComposantConstructeurLink` | `composant_constructeur_links` | `composantId``Composant` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
| `ProductConstructeurLink` | `product_constructeur_links` | `productId``Product` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
### Structure de chaque entité
Chaque entité suit le pattern `MachinePieceLink` :
- `CuidEntityTrait` pour l'ID (string, 36 chars)
- `#[ORM\HasLifecycleCallbacks]` avec `createdAt` / `updatedAt`
- Contrainte unique sur `(item_id, constructeur_id)` via `#[ORM\UniqueConstraint]`
- `#[ApiResource]` avec opérations CRUD complètes
- Sécurité : `ROLE_VIEWER` pour lecture, `ROLE_GESTIONNAIRE` pour écriture
- `ManyToOne` vers l'item (onDelete CASCADE)
- `ManyToOne` vers `Constructeur` (onDelete CASCADE)
- Champ `supplierReference` (string 255, nullable)
### Modifications sur les entités existantes
#### Machine, Pièce, Composant, Produit
- Supprimer la propriété `ManyToMany` `constructeurs` et ses getters/setters/add/remove
- Ajouter une propriété `OneToMany` `constructeurLinks` vers le Link correspondant
- Getter `getConstructeurLinks(): Collection`
#### Constructeur
- Supprimer les 4 propriétés `ManyToMany` (`machines`, `composants`, `pieces`, `products`) et leurs getters/setters
- Ajouter 4 propriétés `OneToMany` vers les Links correspondants
### Migration SQL
1. Créer les 4 nouvelles tables avec colonnes `id`, `machineId`/`pieceId`/etc., `constructeurId`, `supplierReference`, `createdAt`, `updatedAt`
2. Ajouter les contraintes uniques
3. Migrer les données des anciennes tables de jointure vers les nouvelles (génération CUID pour chaque ligne, `supplierReference` = NULL)
4. Supprimer les anciennes tables de jointure (`_MachineConstructeurs`, `_PieceConstructeurs`, `_ComposantConstructeurs`, `_ProductConstructeurs`)
### API
Endpoints API Platform auto-générés pour chaque Link :
- `GET /api/machine_constructeur_links` — liste (filtrable par machine, constructeur)
- `GET /api/machine_constructeur_links/{id}` — détail
- `POST /api/machine_constructeur_links` — créer un lien avec référence
- `PATCH /api/machine_constructeur_links/{id}` — modifier la référence
- `DELETE /api/machine_constructeur_links/{id}` — supprimer le lien
Idem pour les 3 autres types.
### Frontend
Les pages détail/édition qui affichent les constructeurs devront être adaptées pour :
- Afficher la `supplierReference` à côté de chaque constructeur
- Permettre l'édition de la référence fournisseur lors de l'ajout/modification d'un constructeur
- Utiliser les endpoints `*ConstructeurLink` au lieu de la collection `constructeurs`
### Hors périmètre
- Migration de `supplierPrice` de Product vers le Link (explicitement exclu)
- Modification du champ `reference` existant sur les entités
- Référence auto (`referenceAuto`) sur Pièce/Composant — non impactée