Compare commits
1 Commits
03c2451990
...
feature/SI
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d5ce0d8e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,4 +49,3 @@ FEATURE_IDEAS.md
|
||||
###> wsl ###
|
||||
*:Zone.Identifier
|
||||
###< wsl ###
|
||||
config/reference.php
|
||||
|
||||
12
.mcp.json
12
.mcp.json
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"inventory": {
|
||||
"type": "http",
|
||||
"url": "http://inventory.malio-dev.fr/_mcp",
|
||||
"headers": {
|
||||
"X-Profile-Id": "admin-default-profile",
|
||||
"X-Profile-Password": "A123"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
CLAUDE.md
66
CLAUDE.md
@@ -18,9 +18,6 @@ Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
|
||||
| Auth | Session-based (cookies, pas JWT) | |
|
||||
| Containers | Docker Compose | |
|
||||
|
||||
## Glossaire Métier
|
||||
Voir `docs/GLOSSAIRE_METIER.md` — glossaire complet du domaine métier (concepts, workflows utilisateur, correspondance métier↔code). À consulter pour comprendre le "pourquoi" derrière le code.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -28,11 +25,6 @@ Inventory/ # Backend Symfony (repo principal)
|
||||
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
|
||||
├── src/Controller/ # Controllers custom (session, comments, audit…)
|
||||
├── src/EventSubscriber/ # Audit subscribers (onFlush)
|
||||
├── src/Service/ # Services métier (sync, conversion, storage…)
|
||||
├── src/Enum/ # Enums PHP (DocumentType, ModelCategory)
|
||||
├── src/DTO/ # Data Transfer Objects (sync workflow)
|
||||
├── src/Filter/ # Filtres API Platform custom
|
||||
├── src/Command/ # Commandes Symfony CLI (compress-pdf, create-profile…)
|
||||
├── config/ # Config Symfony
|
||||
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
|
||||
├── docker/ # Dockerfile + .env.docker
|
||||
@@ -72,14 +64,6 @@ npm run build # Build production
|
||||
npm run lint:fix # ESLint fix
|
||||
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
||||
|
||||
# Database / Fixtures
|
||||
make db-reset # Reset database (drop + recreate schema)
|
||||
make fixtures-dump # Dump la DB vers fixtures/data.sql
|
||||
make fixtures-load # Charger les fixtures SQL (désactive FK)
|
||||
make fixtures-reset # Reset DB + recharger fixtures
|
||||
make import-data # Importer les dumps SQL normalisés
|
||||
make cache-clear # Clear cache Symfony
|
||||
|
||||
# Release
|
||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||
```
|
||||
@@ -117,12 +101,6 @@ Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
### Entités Principales
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||
|
||||
#### Entités de normalisation (slots & skeleton requirements)
|
||||
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
|
||||
- **Slots composant** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
|
||||
- **Slots pièce** (données réelles d'une pièce) : `PieceProductSlot`
|
||||
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
||||
|
||||
### Patterns
|
||||
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
|
||||
@@ -132,51 +110,15 @@ Remplacent les anciennes colonnes JSON `structure` et `productIds` par des table
|
||||
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
||||
|
||||
### Custom Controllers (pas API Platform)
|
||||
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH), `/api/machines/{id}/clone` (POST) : hiérarchie complète machine avec normalisation JSON manuelle. Source principale de données pour la page détail machine.
|
||||
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH) : hiérarchie complète machine avec normalisation JSON manuelle (pas Symfony Serializer). Source principale de données pour la page détail machine.
|
||||
- `MachineCustomFieldsController` — `/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
|
||||
- `CustomFieldValueController` — `/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
|
||||
- `ComposantPieceSlotController` — `/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
|
||||
- `ComposantProductSlotController` — `/api/composant-product-slots/{id}` (PATCH) : mise à jour des slots produit d'un composant.
|
||||
- `ComposantSubcomponentSlotController` — `/api/composant-subcomponent-slots/{id}` (PATCH) : mise à jour des slots sous-composant d'un composant.
|
||||
- `SessionProfileController` — `/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
|
||||
- `SessionProfilesController` — `/api/session/profiles` (GET) : liste des profils disponibles pour la session.
|
||||
- `AdminProfileController` — `/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
|
||||
- `CommentController` — `/api/comments` : création, résolution, compteur non-résolus.
|
||||
- `ActivityLogController` — `/api/activity-logs` (GET) : journal d'activité global.
|
||||
- `EntityHistoryController` — `/api/{entity}/{id}/history` (GET) : historique audit par entité (machines, pièces, composants, produits).
|
||||
- `DocumentQueryController` — `/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
|
||||
- `DocumentServeController` — `/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
|
||||
- `ModelTypeConversionController` — `/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
|
||||
- `ModelTypeSyncController` — `/api/model_types/{id}/sync-preview|sync-confirm` (POST) : prévisualisation et application de sync ModelType→Composants.
|
||||
- `HealthCheckController` — `/api/health` (GET) : health check.
|
||||
|
||||
### Custom Fields — Architecture
|
||||
- **Composants/Pièces/Produits** : définitions dans les entités `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` du ModelType (anciennement JSON `structure`, normalisé en tables relationnelles). Les custom fields de ces entités sont définis dans `customFields` JSON sur chaque Skeleton*Requirement.
|
||||
- **Composants/Pièces/Produits** : définitions dans le JSON `structure` du ModelType
|
||||
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
|
||||
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
|
||||
|
||||
### Enums (`src/Enum/`)
|
||||
- `DocumentType` — types de documents (photo, schéma, facture, etc.)
|
||||
- `ModelCategory` — catégories de ModelType
|
||||
|
||||
### Services (`src/Service/`)
|
||||
- `ModelTypeSyncService` — synchronise les skeleton requirements d'un ModelType vers les composants existants
|
||||
- `ModelTypeCategoryConversionService` — conversion de catégorie d'un ModelType
|
||||
- `SkeletonStructureService` — gestion de la structure skeleton (requirements)
|
||||
- `DocumentStorageService` — stockage et gestion des fichiers documents
|
||||
- `PdfCompressorService` — compression des PDFs uploadés
|
||||
- `src/Service/Sync/` — stratégies de sync par type de slot (tagged `app.sync_strategy`)
|
||||
|
||||
### DTOs (`src/DTO/`)
|
||||
- `SyncConfirmation`, `SyncPreviewResult`, `SyncExecutionResult` — objets de transfert pour le workflow de sync ModelType
|
||||
|
||||
### Filters (`src/Filter/`)
|
||||
- `MultiSearchFilter` — filtre API Platform pour recherche OR sur plusieurs champs (ex: name + reference)
|
||||
|
||||
### EventSubscribers notables (non-audit)
|
||||
- `PieceProductSyncSubscriber` — sync automatique des PieceProductSlots
|
||||
- `UniqueConstraintSubscriber` — traduit les erreurs de contrainte unique PG en messages utilisateur lisibles
|
||||
|
||||
### Rôles (hiérarchie)
|
||||
```
|
||||
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
@@ -251,8 +193,8 @@ make test-setup # Créer/mettre à jour le schéma test
|
||||
### Pattern de test
|
||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
||||
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`
|
||||
|
||||
## URLs Locales
|
||||
- API Symfony : `http://localhost:8081/api`
|
||||
|
||||
Submodule Inventory_frontend updated: c82c21c0cd...5c31045e83
@@ -1,4 +1,4 @@
|
||||
# Inventory
|
||||
# InventoryTEST
|
||||
|
||||
Application de gestion d'inventaire industriel pour **Malio**. Gestion complète du parc machines, des pièces, composants, produits, fournisseurs et documents associés, avec traçabilité et contrôle d'accès par rôles.
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"doctrine/orm": "^3.6",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
@@ -23,10 +22,8 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/rate-limiter": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
|
||||
1033
composer.lock
generated
1033
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\AI\McpBundle\McpBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
@@ -23,5 +22,4 @@ return [
|
||||
ApiPlatformBundle::class => ['all' => true],
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
DAMADoctrineTestBundle::class => ['test' => true],
|
||||
McpBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
api_platform:
|
||||
title: Inventory API
|
||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||
version: 1.9.1
|
||||
version: 1.8.1
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
services:
|
||||
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||
|
||||
http_discovery.psr17_factory:
|
||||
class: Http\Discovery\Psr17Factory
|
||||
@@ -1,20 +0,0 @@
|
||||
mcp:
|
||||
app: 'inventory'
|
||||
version: '1.0.0'
|
||||
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
|
||||
instructions: |
|
||||
Serveur MCP pour gérer un inventaire industriel.
|
||||
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
|
||||
Utilisez search_inventory pour chercher dans toutes les entités.
|
||||
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
|
||||
Consultez la resource inventory://schema/entities pour voir le schéma complet.
|
||||
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
|
||||
client_transports:
|
||||
stdio: true
|
||||
http: true
|
||||
http:
|
||||
path: /_mcp
|
||||
session:
|
||||
store: file
|
||||
directory: '%kernel.cache_dir%/mcp-sessions'
|
||||
ttl: 3600
|
||||
@@ -1,6 +0,0 @@
|
||||
framework:
|
||||
rate_limiter:
|
||||
mcp_auth:
|
||||
policy: sliding_window
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
@@ -27,12 +27,6 @@ security:
|
||||
pattern: ^/api/session/profiles?$
|
||||
security: false
|
||||
|
||||
mcp:
|
||||
pattern: ^/_mcp
|
||||
stateless: true
|
||||
custom_authenticators:
|
||||
- App\Mcp\Security\McpHeaderAuthenticator
|
||||
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: false
|
||||
@@ -55,7 +49,6 @@ security:
|
||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/_mcp, roles: ROLE_USER }
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||
|
||||
1756
config/reference.php
Normal file
1756
config/reference.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,3 @@ api_login_check:
|
||||
|
||||
controllers:
|
||||
resource: routing.controllers
|
||||
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
|
||||
@@ -34,38 +34,7 @@ services:
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
App\Mcp\Security\McpHeaderAuthenticator:
|
||||
arguments:
|
||||
$mcpAuthLimiter: '@limiter.mcp_auth'
|
||||
|
||||
App\OpenApi\OpenApiDecorator:
|
||||
decorates: 'api_platform.openapi.factory'
|
||||
arguments:
|
||||
$decorated: '@.inner'
|
||||
|
||||
when@test:
|
||||
services:
|
||||
App\Service\Sync\ProductSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\Sync\ComposantSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\Sync\PieceSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\ModelTypeSyncService:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\ReferenceAutoGenerator:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# MCP Server — Inventory
|
||||
|
||||
Serveur MCP (Model Context Protocol) pour l'application Inventory. Permet aux assistants IA (Claude, ChatGPT, Codex) de consulter et gérer l'inventaire industriel.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Un profil actif avec rôle suffisant (ROLE_VIEWER pour lecture, ROLE_GESTIONNAIRE pour écriture)
|
||||
- Accès au tunnel pour les clients distants (Claude Desktop, ChatGPT Desktop)
|
||||
- Docker Compose démarré (`make start`)
|
||||
|
||||
## Configuration par client
|
||||
|
||||
### Claude Code (local, stdio)
|
||||
|
||||
Le fichier `.mcp.json` à la racine du projet est déjà configuré. Remplacez les placeholders :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"inventory": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"exec", "-i",
|
||||
"-e", "MCP_PROFILE_ID=VOTRE_PROFILE_ID",
|
||||
"-e", "MCP_PROFILE_PASSWORD=VOTRE_PASSWORD",
|
||||
"php-inventory-apache",
|
||||
"php", "bin/console", "mcp:server"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop (HTTP via tunnel)
|
||||
|
||||
Dans `claude_desktop_config.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"inventory": {
|
||||
"url": "https://inventory.company-tunnel.com/_mcp",
|
||||
"headers": {
|
||||
"X-Profile-Id": "VOTRE_PROFILE_ID",
|
||||
"X-Profile-Password": "VOTRE_PASSWORD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ChatGPT Desktop / Codex
|
||||
|
||||
Meme principe HTTP avec l'URL du tunnel + headers d'auth.
|
||||
|
||||
## Catalogue des Tools
|
||||
|
||||
### Tools de haut niveau
|
||||
|
||||
| Tool | Description | Role |
|
||||
|------|-------------|------|
|
||||
| `search_inventory` | Recherche globale (machines, pieces, composants, produits, sites, constructeurs) | VIEWER |
|
||||
| `get_machine_structure` | Hierarchie complete d'une machine | VIEWER |
|
||||
| `clone_machine` | Clone une machine avec toute sa structure | GESTIONNAIRE |
|
||||
| `get_dashboard_stats` | Statistiques globales | VIEWER |
|
||||
| `get_entity_history` | Historique d'audit d'une entite | VIEWER |
|
||||
| `get_activity_log` | Journal d'activite global | VIEWER |
|
||||
|
||||
### CRUD par entite
|
||||
|
||||
Pour chaque entite (Machine, Composant, Piece, Produit, Site, Constructeur) :
|
||||
|
||||
| Pattern | Exemple | Role |
|
||||
|---------|---------|------|
|
||||
| `list_{entite}s` | `list_machines` | VIEWER |
|
||||
| `get_{entite}` | `get_machine` | VIEWER |
|
||||
| `create_{entite}` | `create_machine` | GESTIONNAIRE |
|
||||
| `update_{entite}` | `update_machine` | GESTIONNAIRE |
|
||||
| `delete_{entite}` | `delete_machine` | GESTIONNAIRE |
|
||||
|
||||
### Slots
|
||||
|
||||
| Tool | Description | Role |
|
||||
|------|-------------|------|
|
||||
| `list_slots` | Lister les slots d'un composant ou piece | VIEWER |
|
||||
| `update_slots` | Remplir/vider les slots | GESTIONNAIRE |
|
||||
|
||||
### Machine Links
|
||||
|
||||
| Tool | Description | Role |
|
||||
|------|-------------|------|
|
||||
| `list_machine_links` | Liens composant/piece/produit d'une machine | VIEWER |
|
||||
| `add_machine_links` | Ajouter des liens | GESTIONNAIRE |
|
||||
| `update_machine_link` | Modifier un lien | GESTIONNAIRE |
|
||||
| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE |
|
||||
|
||||
### Commentaires
|
||||
|
||||
| Tool | Description | Role |
|
||||
|------|-------------|------|
|
||||
| `list_comments` | Lister les commentaires d'une entite | VIEWER |
|
||||
| `create_comment` | Creer un commentaire | VIEWER |
|
||||
| `resolve_comment` | Resoudre un commentaire | GESTIONNAIRE |
|
||||
| `get_unresolved_comments_count` | Nombre de commentaires non resolus | VIEWER |
|
||||
|
||||
### Custom Fields
|
||||
|
||||
| Tool | Description | Role |
|
||||
|------|-------------|------|
|
||||
| `list_custom_field_values` | Valeurs de champs perso d'une entite | VIEWER |
|
||||
| `upsert_custom_field_values` | Creer/mettre a jour des valeurs | GESTIONNAIRE |
|
||||
| `delete_custom_field_value` | Supprimer une valeur | GESTIONNAIRE |
|
||||
|
||||
### Documents
|
||||
|
||||
| Tool | Description | Role |
|
||||
|------|-------------|------|
|
||||
| `list_documents` | Lister les documents d'une entite | VIEWER |
|
||||
| `delete_document` | Supprimer un document | GESTIONNAIRE |
|
||||
|
||||
> **Limitation :** L'upload de documents n'est pas supporte via MCP (protocole JSON uniquement). Utilisez l'API REST `/api/documents` (POST multipart).
|
||||
|
||||
### ModelTypes
|
||||
|
||||
| Tool | Description | Role |
|
||||
|------|-------------|------|
|
||||
| `list_model_types` | Lister par categorie | VIEWER |
|
||||
| `get_model_type` | Detail avec skeleton requirements | VIEWER |
|
||||
| `create_model_type` | Creer | GESTIONNAIRE |
|
||||
| `update_model_type` | Modifier | GESTIONNAIRE |
|
||||
| `delete_model_type` | Supprimer | GESTIONNAIRE |
|
||||
| `sync_model_type` | Preview/sync skeleton | GESTIONNAIRE |
|
||||
|
||||
## Workflows guides
|
||||
|
||||
### Creer un composant complet
|
||||
|
||||
```
|
||||
1. list_model_types(category: "composant") -> choisir le type
|
||||
2. get_model_type(modelTypeId: "...") -> voir le skeleton
|
||||
3. create_composant(name, reference, modelTypeId) -> cree + slots auto
|
||||
4. search_inventory(query: "Roulement", types: "piece") -> trouver pieces
|
||||
5. update_slots(slots: [{slotId, selectedPieceId}]) -> remplir
|
||||
6. upsert_custom_field_values(entityType: "composant", entityId, fields: [...])
|
||||
```
|
||||
|
||||
### Creer une machine complete (bottom-up)
|
||||
|
||||
```
|
||||
1. Creer les produits necessaires
|
||||
2. Creer les pieces (avec produits dans les slots)
|
||||
3. Creer les composants (avec pieces dans les slots)
|
||||
4. list_sites -> choisir le site
|
||||
5. create_machine(name, siteId)
|
||||
6. add_machine_links(machineId, links: [{type: "composant", entityId, quantity}])
|
||||
7. upsert_custom_field_values(entityType: "machine", machineId, fields: [...])
|
||||
```
|
||||
|
||||
## Resources MCP
|
||||
|
||||
| URI | Description |
|
||||
|-----|-------------|
|
||||
| `inventory://schema/entities` | Schema de toutes les entites |
|
||||
| `inventory://roles` | Hierarchie des roles et permissions |
|
||||
| `inventory://stats` | Statistiques globales |
|
||||
|
||||
## Roles & Permissions
|
||||
|
||||
```
|
||||
ROLE_ADMIN > ROLE_GESTIONNAIRE > ROLE_VIEWER > ROLE_USER
|
||||
```
|
||||
|
||||
- **VIEWER** : lecture, recherche, commentaires
|
||||
- **GESTIONNAIRE** : ecriture (CRUD, slots, links, clone)
|
||||
- **ADMIN** : gestion profils (via API REST uniquement)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Erreur | Cause | Solution |
|
||||
|--------|-------|----------|
|
||||
| `401 Unauthorized` | Credentials invalides | Verifier X-Profile-Id et X-Profile-Password |
|
||||
| `Permission denied: ROLE_GESTIONNAIRE required` | Role insuffisant | Utiliser un profil avec le bon role |
|
||||
| `Rate limited` | Trop de tentatives echouees | Attendre 1 minute |
|
||||
| `Tool not found` | Tool non enregistre | Verifier que le cache est a jour (`cache:clear`) |
|
||||
| `Error while executing tool` | Erreur interne | Verifier les logs et les parametres |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,546 +0,0 @@
|
||||
# Piece Quantity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a quantity field to pieces — on `MachinePieceLink` for machine-direct pieces, and in `Composant.structure.pieces[]` JSON for composant pieces.
|
||||
|
||||
**Architecture:** Quantity lives on the relationship, not the catalogue entity. For machine-direct pieces, a new `quantity` integer column on `MachinePieceLink` (default 1). For composant pieces, a `quantity` key in the existing `structure.pieces[]` JSON (default 1). Display: "×N" after piece name, hidden when N=1.
|
||||
|
||||
**Tech Stack:** Symfony 8 / API Platform, Doctrine ORM, PostgreSQL, Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-12-piece-quantity-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Backend
|
||||
|
||||
### Task 1: Entity + Migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/MachinePieceLink.php`
|
||||
- Existing: `migrations/Version20260309150000.php` (already written, untracked)
|
||||
|
||||
- [ ] **Step 1: Add quantity field to MachinePieceLink entity**
|
||||
|
||||
In `src/Entity/MachinePieceLink.php`, add after the `prixOverride` field (line 69):
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Assert\GreaterThanOrEqual(1)]
|
||||
private int $quantity = 1;
|
||||
```
|
||||
|
||||
Add the import at the top if not present:
|
||||
```php
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
```
|
||||
|
||||
Add getter/setter after existing methods (before closing brace):
|
||||
|
||||
```php
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Stage the migration file**
|
||||
|
||||
The migration `migrations/Version20260309150000.php` already exists (untracked). Verify its content matches:
|
||||
|
||||
```php
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run php-cs-fixer**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
|
||||
- [ ] **Step 4: Run tests to verify nothing is broken**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All 167 tests pass (OK, with possible deprecation warnings)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/MachinePieceLink.php migrations/Version20260309150000.php
|
||||
git commit -m "feat(piece) : add quantity field to MachinePieceLink entity + migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: MachineStructureController — Normalization + PATCH + Clone
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/MachineStructureController.php`
|
||||
|
||||
- [ ] **Step 1: Add quantity to `normalizePieceLinks()`**
|
||||
|
||||
In `src/Controller/MachineStructureController.php`, method `normalizePieceLinks()` (line ~623-641).
|
||||
|
||||
Add `'quantity'` to the returned array, after `'overrides'`:
|
||||
|
||||
```php
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
```
|
||||
|
||||
Add a new private method after `normalizePieceLinks()`:
|
||||
|
||||
```php
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
if (!$parentLink) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
$composant = $parentLink->getComposant();
|
||||
$structure = $composant->getStructure();
|
||||
|
||||
if (!is_array($structure) || !isset($structure['pieces']) || !is_array($structure['pieces'])) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$piece = $link->getPiece();
|
||||
$typePiece = $piece->getTypePiece();
|
||||
$typePieceId = $typePiece?->getId();
|
||||
|
||||
foreach ($structure['pieces'] as $pieceDef) {
|
||||
if (!is_array($pieceDef)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($pieceDef['typePieceId']) && $pieceDef['typePieceId'] === $typePieceId) {
|
||||
return (int) ($pieceDef['quantity'] ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Matching is done by `typePieceId`. If a composant has two pieces of the same type, they will get the same quantity (first match). This is an acceptable limitation for now — duplicates of the same piece type in a composant are rare.
|
||||
|
||||
- [ ] **Step 2: Apply quantity in `applyPieceLinks()`**
|
||||
|
||||
In method `applyPieceLinks()` (line ~366-422), add quantity application after `$this->applyOverrides($link, $entry['overrides'] ?? null);` (line ~396):
|
||||
|
||||
```php
|
||||
if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) {
|
||||
$quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity();
|
||||
$link->setQuantity(max(1, $quantity));
|
||||
}
|
||||
```
|
||||
|
||||
**Key behavior:**
|
||||
- Only applies to direct machine pieces (no parent component link)
|
||||
- If `quantity` not in payload: preserves existing value
|
||||
- If `quantity` in payload: sets it, with floor of 1
|
||||
- For composant-child pieces: quantity is ignored (comes from composant structure)
|
||||
|
||||
- [ ] **Step 3: Copy quantity in `clonePieceLinks()`**
|
||||
|
||||
In method `clonePieceLinks()` (line ~233-256), add after `$newLink->setPrixOverride($link->getPrixOverride());` (line ~244):
|
||||
|
||||
```php
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run php-cs-fixer**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/MachineStructureController.php
|
||||
git commit -m "feat(piece) : add quantity to structure normalization, PATCH and clone"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Api/Entity/MachinePieceLinkTest.php`
|
||||
- Modify: `tests/AbstractApiTestCase.php` (factory method)
|
||||
|
||||
- [ ] **Step 1: Update factory method to support quantity**
|
||||
|
||||
In `tests/AbstractApiTestCase.php`, update `createMachinePieceLink()` to accept an optional quantity parameter:
|
||||
|
||||
```php
|
||||
protected function createMachinePieceLink(Machine $machine, Piece $piece, ?MachineComponentLink $parentLink = null, int $quantity = 1): MachinePieceLink
|
||||
{
|
||||
$link = new MachinePieceLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setPiece($piece);
|
||||
$link->setQuantity($quantity);
|
||||
if (null !== $parentLink) {
|
||||
$link->setParentLink($parentLink);
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($link);
|
||||
$em->flush();
|
||||
|
||||
return $link;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add test for POST with explicit quantity**
|
||||
|
||||
In `tests/Api/Entity/MachinePieceLinkTest.php`, add. Follow the existing test pattern — use `$this->assertJsonContains()` and include the `headers` key with `Content-Type`:
|
||||
|
||||
```php
|
||||
public function testPostWithQuantity(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$machine = $this->createMachine();
|
||||
$piece = $this->createPiece();
|
||||
|
||||
$client->request('POST', '/api/machine_piece_links', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'machine' => '/api/machines/' . $machine->getId(),
|
||||
'piece' => '/api/pieces/' . $piece->getId(),
|
||||
'quantity' => 5,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains(['quantity' => 5]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add test for POST without quantity (default = 1)**
|
||||
|
||||
```php
|
||||
public function testPostDefaultQuantity(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$machine = $this->createMachine();
|
||||
$piece = $this->createPiece();
|
||||
|
||||
$client->request('POST', '/api/machine_piece_links', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'machine' => '/api/machines/' . $machine->getId(),
|
||||
'piece' => '/api/pieces/' . $piece->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains(['quantity' => 1]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All tests pass including new ones
|
||||
|
||||
- [ ] **Step 5: Run php-cs-fixer**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Api/Entity/MachinePieceLinkTest.php tests/AbstractApiTestCase.php
|
||||
git commit -m "test(piece) : add quantity tests for MachinePieceLink"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Frontend
|
||||
|
||||
### Task 4: TypeScript Types + Sanitization + Hydration Functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/shared/types/inventory.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructure.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`
|
||||
|
||||
- [ ] **Step 1: Add `quantity` to `ComponentModelPiece` type**
|
||||
|
||||
In `Inventory_frontend/app/shared/types/inventory.ts`, add `quantity` to the `ComponentModelPiece` interface (after `role`, line ~23):
|
||||
|
||||
```typescript
|
||||
quantity?: number
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `quantity` to `validatePiece()` in same file**
|
||||
|
||||
In `Inventory_frontend/app/shared/types/inventory.ts`, in `validatePiece()` (line ~144-172):
|
||||
|
||||
After `const role = ensureString(value.role)` (line ~161), add:
|
||||
|
||||
```typescript
|
||||
const quantity = typeof value.quantity === 'number' && value.quantity >= 1 ? value.quantity : undefined
|
||||
```
|
||||
|
||||
And in the return object, add after the `role` spread:
|
||||
|
||||
```typescript
|
||||
...(quantity ? { quantity } : {}),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `sanitizePieces()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`, in `sanitizePieces()` (~line 130-188).
|
||||
|
||||
After the existing field extractions, add:
|
||||
|
||||
```typescript
|
||||
const quantity = typeof piece?.quantity === 'number' && piece.quantity >= 1 ? piece.quantity : undefined
|
||||
```
|
||||
|
||||
In the result object construction, add alongside existing fields (follow the `if (field) { result.field = field }` pattern used in this function):
|
||||
|
||||
```typescript
|
||||
if (quantity !== undefined) {
|
||||
result.quantity = quantity
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `normalizeStructureForSave()` to include quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructure.ts`, in `normalizeStructureForSave()` (~lines 164-179), add in the piece payload mapping after the `reference` check:
|
||||
|
||||
```typescript
|
||||
if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
|
||||
payload.quantity = (piece as any).quantity
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Always send quantity when defined (including 1), so the backend always has an explicit value.
|
||||
|
||||
- [ ] **Step 5: Update `hydratePieces()` and `mapComponentPieces()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`:
|
||||
|
||||
In `hydratePieces()` (line ~95-107), add to the mapped object:
|
||||
|
||||
```typescript
|
||||
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
|
||||
```
|
||||
|
||||
In `mapComponentPieces()` (line ~168-179), add to the mapped object:
|
||||
|
||||
```typescript
|
||||
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `sanitizePieceDefinition()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`, in `sanitizePieceDefinition()` (~lines 172-180), add to the `stripNullish()` object:
|
||||
|
||||
```typescript
|
||||
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
git add app/shared/types/inventory.ts app/shared/model/componentStructure.ts app/shared/model/componentStructureSanitize.ts app/shared/model/componentStructureHydrate.ts app/shared/utils/structureAssignmentHelpers.ts
|
||||
git commit -m "feat(piece) : add quantity field to piece types, sanitization and hydration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Composant Structure Editor — Quantity Input
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/StructureNodeEditor.vue` (piece section, lines ~229-299)
|
||||
- Modify: `Inventory_frontend/app/composables/useStructureNodeCrud.ts` (`addPiece()`, lines ~110-118)
|
||||
|
||||
**Context:** `StructureNodeEditor.vue` renders the composant structure editor. The piece section (lines ~236-293) currently shows only a `select` for `typePieceId` and a delete button. The `addPiece()` function in `useStructureNodeCrud.ts` creates new piece entries with default fields.
|
||||
|
||||
- [ ] **Step 1: Add default quantity to `addPiece()`**
|
||||
|
||||
In `Inventory_frontend/app/composables/useStructureNodeCrud.ts`, in `addPiece()` (line ~110-118), add `quantity: 1` to the pushed object:
|
||||
|
||||
```typescript
|
||||
const addPiece = () => {
|
||||
ensureArray('pieces')
|
||||
props.node.pieces!.push({
|
||||
typePieceId: '',
|
||||
typePieceLabel: '',
|
||||
reference: '',
|
||||
familyCode: '',
|
||||
role: '',
|
||||
quantity: 1,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add quantity input in `StructureNodeEditor.vue`**
|
||||
|
||||
In `Inventory_frontend/app/components/StructureNodeEditor.vue`, in the piece item rendering section (inside the `v-for` loop for pieces, line ~256-292), add a quantity input next to the existing piece type `select`. Place it after the select and before the delete button:
|
||||
|
||||
```vue
|
||||
<input
|
||||
v-model.number="piece.quantity"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
placeholder="Qté"
|
||||
class="input input-bordered input-sm md:input-md w-20"
|
||||
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
git add app/components/StructureNodeEditor.vue app/composables/useStructureNodeCrud.ts
|
||||
git commit -m "feat(piece) : add quantity input to composant structure editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Machine Detail Page — Display Quantity
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
|
||||
|
||||
**Context:** `PieceItem.vue` renders each piece in the machine structure view. The piece name is displayed at line ~26 in an `<h3>` tag. Quantity should appear as "×N" after the name, in secondary text. For direct pieces (no parent component), it should be editable. For composant pieces, read-only.
|
||||
|
||||
- [ ] **Step 1: Add quantity display to PieceItem**
|
||||
|
||||
In `Inventory_frontend/app/components/PieceItem.vue`, after the piece name in the `<h3>` tag (line ~26), add the quantity display:
|
||||
|
||||
```vue
|
||||
<span
|
||||
v-if="displayQuantity > 1"
|
||||
class="text-sm font-normal text-base-content/60 ml-1"
|
||||
>
|
||||
×{{ displayQuantity }}
|
||||
</span>
|
||||
```
|
||||
|
||||
Add to the component's setup:
|
||||
|
||||
```typescript
|
||||
const displayQuantity = computed(() => {
|
||||
return props.piece.quantity ?? 1
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add editable quantity for direct machine pieces**
|
||||
|
||||
For pieces directly on a machine (no `parentComponentLinkId`), add an editable quantity input in the piece's edit section, following the pattern of existing override fields (nameOverride, referenceOverride, prixOverride). Place it alongside the overrides form:
|
||||
|
||||
```vue
|
||||
<div v-if="!piece.parentComponentLinkId && isEditMode" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">Quantité</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="pieceData.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input input-bordered input-sm md:input-md w-24"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
Add `quantity` to the `pieceData` reactive object (line ~270-275):
|
||||
|
||||
```typescript
|
||||
quantity: props.piece.quantity ?? 1,
|
||||
```
|
||||
|
||||
Ensure this value is included in the data emitted when saving (follow the same pattern as `nameOverride`, `referenceOverride`, `prixOverride` in the save/emit logic).
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
git add app/components/PieceItem.vue
|
||||
git commit -m "feat(piece) : display and edit quantity on machine piece items"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Submodule Update + Final Verification
|
||||
|
||||
**Files:**
|
||||
- Update submodule pointer in main repo
|
||||
|
||||
- [ ] **Step 1: Push frontend commits**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git push
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update submodule pointer in main repo**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Inventory
|
||||
git add Inventory_frontend
|
||||
git commit -m "chore(frontend) : update submodule — piece quantity feature"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run all backend tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 4: Run migration on dev database**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Manual smoke test**
|
||||
|
||||
1. Open a composant edit page → verify quantity input appears on each piece in structure
|
||||
2. Set quantity to 4, save → reload → verify quantity persisted
|
||||
3. Open a machine with that composant → verify "×4" appears next to piece name (read-only)
|
||||
4. Add a piece directly to a machine → verify quantity input appears in edit mode
|
||||
5. Set quantity to 3, save → verify "×3" appears
|
||||
6. Clone the machine → verify cloned pieces have same quantities
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,140 +0,0 @@
|
||||
# Piece Quantity — Design Spec
|
||||
|
||||
## Context
|
||||
|
||||
L'application gère des machines composées de composants et de pièces. Une même pièce (catalogue) peut apparaître dans plusieurs contextes avec des quantités différentes. La quantité doit être portée par la **relation**, pas par l'entité catalogue.
|
||||
|
||||
## Scope
|
||||
|
||||
- Quantité sur les **pièces directement liées à une machine** (`MachinePieceLink`)
|
||||
- Quantité sur les **pièces d'un composant** (JSON `structure.pieces` du `Composant`)
|
||||
- **Hors scope** : quantité sur `MachineComponentLink`, `MachineProductLink`, override de quantité composant au niveau machine, audit logging
|
||||
|
||||
## Règles métier
|
||||
|
||||
| Contexte | Stockage | Éditable depuis | Visible sur machine |
|
||||
|----------|----------|-----------------|---------------------|
|
||||
| Pièce directement sur machine | `MachinePieceLink.quantity` | Page machine | Oui, éditable |
|
||||
| Pièce d'un composant | `Composant.structure.pieces[].quantity` | Page composant (création + édition) | Oui, lecture seule |
|
||||
|
||||
- Type : entier, valeur par défaut = 1, minimum = 1
|
||||
- Affichage : "×N" après le nom de la pièce, masqué si N = 1
|
||||
- Quantité = 0 n'est pas valide (utiliser la suppression du lien à la place)
|
||||
|
||||
## Backend
|
||||
|
||||
### 1. MachinePieceLink — Nouvelle colonne
|
||||
|
||||
Ajout d'un champ `quantity` sur l'entité `MachinePieceLink` :
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
private int $quantity = 1;
|
||||
```
|
||||
|
||||
Getter/setter standard. **Pas de `#[Groups]`** — cohérent avec les autres champs de l'entité qui n'en déclarent pas (l'entité n'a pas de `normalizationContext`).
|
||||
|
||||
Validation : `#[Assert\GreaterThanOrEqual(1)]`
|
||||
|
||||
### 2. Migration SQL
|
||||
|
||||
```sql
|
||||
ALTER TABLE machine_piece_link ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
Idempotente avec `IF NOT EXISTS`.
|
||||
|
||||
### 3. Composant.structure JSON
|
||||
|
||||
Le tableau `pieces` dans le JSON `structure` du Composant accepte une nouvelle clé `quantity`. Pas de migration DB nécessaire — c'est un champ JSON libre.
|
||||
|
||||
Avant :
|
||||
```json
|
||||
{ "typePieceId": "...", "role": "Filtration" }
|
||||
```
|
||||
|
||||
Après :
|
||||
```json
|
||||
{ "typePieceId": "...", "role": "Filtration", "quantity": 4 }
|
||||
```
|
||||
|
||||
Les entrées existantes sans `quantity` sont traitées comme `quantity = 1` (défaut côté frontend et backend).
|
||||
|
||||
### 4. MachineStructureController
|
||||
|
||||
#### Normalisation (GET)
|
||||
|
||||
`normalizePieceLinks()` : inclure `quantity` dans la réponse JSON :
|
||||
- **Pièce machine directe** (parentLink = null) : `quantity` depuis `MachinePieceLink.quantity`
|
||||
- **Pièce sous composant** : `quantity` depuis le `structure.pieces` du composant source. Résolution :
|
||||
1. Naviguer `MachinePieceLink` → `parentLink` (MachineComponentLink) → `composant` → `structure['pieces']`
|
||||
2. Matcher par index de position dans le tableau `pieces` (l'ordre des pièces dans la structure correspond à l'ordre de création des liens)
|
||||
3. Fallback : `quantity = 1` si non trouvé
|
||||
|
||||
#### PATCH structure
|
||||
|
||||
Dans `applyPieceLinks()`, accepter `quantity` au même niveau que `pieceId` dans le payload :
|
||||
|
||||
```json
|
||||
{
|
||||
"pieceLinks": [
|
||||
{ "pieceId": "cl...", "quantity": 4, "overrides": { "nameOverride": "..." } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `quantity` est appliqué uniquement pour les pièces directement sur la machine (pas de `parentComponentLinkId`)
|
||||
- Si `parentComponentLinkId` est présent, `quantity` est **ignoré silencieusement** (la valeur vient du composant)
|
||||
|
||||
#### Clone
|
||||
|
||||
`clonePieceLinks()` doit copier `quantity` depuis le lien source :
|
||||
|
||||
```php
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
```
|
||||
|
||||
Sans cela, les machines clonées perdraient les quantités (reset à 1).
|
||||
|
||||
### 5. Tests
|
||||
|
||||
Ajouter dans `MachinePieceLinkTest.php` :
|
||||
- POST avec `quantity` explicite → vérifier la valeur
|
||||
- POST sans `quantity` → vérifier défaut = 1
|
||||
- PATCH `quantity` sur pièce directe → vérifier mise à jour
|
||||
- GET structure → vérifier `quantity` dans la réponse normalisée
|
||||
- Clone → vérifier que `quantity` est préservé
|
||||
|
||||
## Frontend
|
||||
|
||||
### 1. Types TypeScript
|
||||
|
||||
Mise à jour de `ComponentModelPiece` dans `shared/types/inventory.ts` — ajout du champ `quantity` :
|
||||
|
||||
```typescript
|
||||
quantity?: number // défaut 1
|
||||
```
|
||||
|
||||
### 2. Fonctions de sanitization/normalisation à mettre à jour
|
||||
|
||||
Ces fonctions énumèrent explicitement les champs à conserver et doivent inclure `quantity` :
|
||||
|
||||
- `normalizeStructureForSave()` dans `shared/model/componentStructure.ts` — inclure `quantity` dans le payload backend des pièces
|
||||
- `sanitizePieceDefinition()` dans `shared/utils/structureAssignmentHelpers.ts` — préserver `quantity`
|
||||
- `sanitizePieces()` dans `shared/model/componentStructureSanitize.ts` — préserver `quantity` dans la sortie
|
||||
- `hydratePieces()` / `mapComponentPieces()` — préserver `quantity` lors de l'hydratation
|
||||
|
||||
### 3. Pages composant (création + édition)
|
||||
|
||||
Dans l'éditeur de structure, chaque pièce du tableau `structure.pieces` affiche un champ input :
|
||||
- Type : `number`, min = 1, step = 1
|
||||
- Valeur par défaut : 1
|
||||
- Style : `input input-bordered input-sm md:input-md` (DaisyUI)
|
||||
- Position : à côté des champs existants (reference, role)
|
||||
|
||||
### 4. Page machine (détail/structure)
|
||||
|
||||
- **Pièce directe** (parentLink = null) : affiche "×N" à côté du nom, quantité éditable (input entier)
|
||||
- **Pièce de composant** : affiche "×N" à côté du nom, lecture seule (pas d'input)
|
||||
- Si quantité = 1 : rien n'est affiché (pas de bruit visuel)
|
||||
- Style du label : texte secondaire (`text-base-content/60` ou classe équivalente)
|
||||
@@ -1,502 +0,0 @@
|
||||
# ModelType Sync — Design Spec
|
||||
|
||||
## Objectif
|
||||
|
||||
Quand un ModelType (catégorie) est modifié (structure, custom fields), propager automatiquement les changements à tous les items liés (Composants, Pièces, Produits). L'utilisateur voit un preview de l'impact et confirme avant que la sync ne s'exécute.
|
||||
|
||||
## Décisions
|
||||
|
||||
| Décision | Choix |
|
||||
|----------|-------|
|
||||
| Scope sync | Composants + Pièces + Produits |
|
||||
| Sync destructive | Avec confirmation (modal frontend) |
|
||||
| Custom fields — ajout | Créer `CustomFieldValue` vides |
|
||||
| Custom fields — suppression | Supprimer avec confirmation |
|
||||
| Custom fields — renommage | Propagation auto (label dans la définition) |
|
||||
| Custom fields — changement de type | Clear les valeurs avec confirmation |
|
||||
| Architecture backend | Strategy pattern (1 strategy par entity type) |
|
||||
| Déclenchement | En deux temps : preview séparé du sync |
|
||||
| Preview timing | AVANT le save (pas de rollback nécessaire) |
|
||||
| Pièces — produits liés | Nouvelle table `PieceProductSlot` remplace la M2M `piece_products` |
|
||||
| `restrictedMode` frontend | Supprimé complètement |
|
||||
| Versioning | `version` INT sur Composant, Pièce, Produit (incrémenté à chaque sync) |
|
||||
| Machines | Aucun changement — elles lisent les slots des composants, la sync met à jour ces slots |
|
||||
| Matching slots | Par `typeXxxId` + `position` (pas de FK vers skeleton requirement) |
|
||||
| Matching custom fields | Par `orderIndex` (propriété stable sur `CustomField`) |
|
||||
| Atomicité PATCH + sync | Wrappé dans une transaction DB côté controller |
|
||||
| Idempotence sync | `execute()` est idempotent — un double appel est un no-op |
|
||||
| Audit | Les opérations de sync sont capturées par les subscribers `onFlush` existants |
|
||||
|
||||
## Endpoints API
|
||||
|
||||
### `POST /api/model_types/{id}/sync-preview`
|
||||
|
||||
Calcule l'impact du diff entre le payload envoyé et l'état actuel des items liés. **Ne persiste rien.**
|
||||
|
||||
**Sécurité :** `ROLE_GESTIONNAIRE`
|
||||
|
||||
**Request body :**
|
||||
```json
|
||||
{
|
||||
"structure": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Le payload `structure` a le même format que celui envoyé au `PATCH /api/model_types/{id}`.
|
||||
|
||||
**Response :**
|
||||
```json
|
||||
{
|
||||
"modelTypeId": "cl...",
|
||||
"category": "COMPONENT",
|
||||
"itemCount": 12,
|
||||
"additions": {
|
||||
"pieceSlots": 12,
|
||||
"productSlots": 0,
|
||||
"subcomponentSlots": 24,
|
||||
"customFieldValues": 36
|
||||
},
|
||||
"deletions": {
|
||||
"pieceSlots": 0,
|
||||
"productSlots": 12,
|
||||
"subcomponentSlots": 0,
|
||||
"customFieldValues": 0
|
||||
},
|
||||
"modifications": {
|
||||
"customFieldTypeChanges": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Si `additions`, `deletions` et `modifications` sont tous à 0, le frontend skip la modal et sauvegarde directement.
|
||||
|
||||
**Erreurs :**
|
||||
- `404` — ModelType introuvable
|
||||
- `403` — droits insuffisants
|
||||
|
||||
### `POST /api/model_types/{id}/sync`
|
||||
|
||||
Exécute la propagation. Appelé **après** le `PATCH` du ModelType, dans la même requête frontend (PATCH + sync enchaînés).
|
||||
|
||||
**Sécurité :** `ROLE_GESTIONNAIRE`
|
||||
|
||||
**Request body :**
|
||||
```json
|
||||
{
|
||||
"confirmDeletions": true,
|
||||
"confirmTypeChanges": true
|
||||
}
|
||||
```
|
||||
|
||||
Si des suppressions sont nécessaires mais `confirmDeletions` est `false`, le sync **skip les suppressions** (applique uniquement les ajouts). Idem pour `confirmTypeChanges` et les clear de valeurs. Cela permet un sync partiel (ajouts only) sans confirmation.
|
||||
|
||||
**Response :** `200` avec résumé de l'exécution.
|
||||
|
||||
**Erreurs :**
|
||||
- `404` — ModelType introuvable
|
||||
- `403` — droits insuffisants
|
||||
|
||||
## Architecture Backend
|
||||
|
||||
### Strategy Pattern
|
||||
|
||||
```
|
||||
Service/
|
||||
├── ModelTypeSyncService.php # Orchestrateur
|
||||
└── Sync/
|
||||
├── SyncStrategyInterface.php # Interface
|
||||
├── ComposantSyncStrategy.php # Slots pièce/produit/sous-composant + custom fields
|
||||
├── PieceSyncStrategy.php # Slots produit + custom fields
|
||||
└── ProductSyncStrategy.php # Custom fields uniquement
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
```php
|
||||
interface SyncStrategyInterface
|
||||
{
|
||||
public function supports(ModelType $modelType): bool;
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult;
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult;
|
||||
}
|
||||
```
|
||||
|
||||
**Note sur `execute()` :** Cette méthode est appelée **après** le PATCH du ModelType, donc les skeleton requirements sont déjà mis à jour en base. La strategy compare les skeleton requirements actuels (fraîchement mis à jour) avec les slots existants des items liés. Pas besoin de recevoir `$newStructure` — les relations ORM reflètent déjà le nouvel état.
|
||||
|
||||
### Orchestrateur
|
||||
|
||||
```php
|
||||
class ModelTypeSyncService
|
||||
{
|
||||
/** @param iterable<SyncStrategyInterface> $strategies */
|
||||
public function __construct(private iterable $strategies) {}
|
||||
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->preview($modelType, $newStructure);
|
||||
}
|
||||
}
|
||||
throw new \LogicException('No strategy found for category: ' . $modelType->getCategory()->value);
|
||||
}
|
||||
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->execute($modelType, $confirmation);
|
||||
}
|
||||
}
|
||||
throw new \LogicException('No strategy found for category: ' . $modelType->getCategory()->value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Les strategies sont auto-injectées via `#[AutoconfigureTag('app.sync_strategy')]` et le tagged iterator de Symfony.
|
||||
|
||||
### DTOs
|
||||
|
||||
```php
|
||||
class SyncPreviewResult
|
||||
{
|
||||
public string $modelTypeId;
|
||||
public string $category;
|
||||
public int $itemCount;
|
||||
public array $additions; // ['pieceSlots' => int, 'productSlots' => int, ...]
|
||||
public array $deletions;
|
||||
public array $modifications;
|
||||
}
|
||||
|
||||
class SyncConfirmation
|
||||
{
|
||||
public bool $confirmDeletions = false;
|
||||
public bool $confirmTypeChanges = false;
|
||||
}
|
||||
|
||||
class SyncExecutionResult
|
||||
{
|
||||
public int $itemsUpdated;
|
||||
public array $additions;
|
||||
public array $deletions;
|
||||
public array $modifications;
|
||||
}
|
||||
```
|
||||
|
||||
### Controller
|
||||
|
||||
```php
|
||||
#[Route('/api/model_types/{id}')]
|
||||
class ModelTypeSyncController extends AbstractController
|
||||
{
|
||||
#[Route('/sync-preview', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_GESTIONNAIRE')]
|
||||
public function preview(ModelType $modelType, Request $request): JsonResponse
|
||||
{
|
||||
$structure = json_decode($request->getContent(), true)['structure'] ?? [];
|
||||
$result = $this->syncService->preview($modelType, $structure);
|
||||
return $this->json($result);
|
||||
}
|
||||
|
||||
#[Route('/sync', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_GESTIONNAIRE')]
|
||||
public function sync(ModelType $modelType, Request $request): JsonResponse
|
||||
{
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$confirmation = new SyncConfirmation();
|
||||
$confirmation->confirmDeletions = $body['confirmDeletions'] ?? false;
|
||||
$confirmation->confirmTypeChanges = $body['confirmTypeChanges'] ?? false;
|
||||
$result = $this->syncService->execute($modelType, $confirmation);
|
||||
return $this->json($result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Atomicité PATCH + Sync
|
||||
|
||||
Le frontend enchaîne `PATCH` puis `POST /sync` en deux requêtes HTTP. Le `POST /sync` wrappe toute l'opération dans une transaction DB (`$em->wrapInTransaction()`). Si le sync échoue, les modifications de slots sont rollback. Le PATCH du ModelType (skeleton requirements) reste committée — c'est acceptable car un re-sync est toujours possible.
|
||||
|
||||
En cas d'échec réseau entre le PATCH et le sync, le ModelType est à jour mais les items ne sont pas synchronisés. Le prochain save de la catégorie reproposera le sync-preview, qui détectera les différences.
|
||||
|
||||
## Logique de Diff / Sync
|
||||
|
||||
### Matching des slots
|
||||
|
||||
Pour chaque item lié, on compare ses slots actuels avec les skeleton requirements du ModelType. Le matching se fait par **`typeXxxId`** (le type référencé : `typePieceId`, `typeProductId`, `typeComposantId`) + **`position`**.
|
||||
|
||||
Il n'y a **pas de FK directe** entre un slot et un skeleton requirement. Le lien est implicite via le type + position.
|
||||
|
||||
**Pour le preview :** la strategy parse le `$newStructure` (payload JSON) et le compare aux slots actuels sans toucher à la DB.
|
||||
|
||||
**Pour l'execute :** la strategy lit les skeleton requirements actuels (déjà mis à jour par le PATCH) et les compare aux slots actuels.
|
||||
|
||||
### Règles — Slots (pièce, produit, sous-composant)
|
||||
|
||||
| Cas | Action |
|
||||
|-----|--------|
|
||||
| Skeleton requirement existe, pas de slot correspondant | **Ajouter** slot vide (type + position, `quantity = 1` pour pièces, pas de sélection) |
|
||||
| Slot existe, plus de skeleton requirement | **Supprimer** le slot (si `confirmDeletions`) — sélection perdue |
|
||||
| Les deux existent, position différente | **Mettre à jour** la position du slot |
|
||||
| Slot existe et matche | **Ne rien toucher** — sélection et quantité préservées |
|
||||
|
||||
### Règles — Custom fields
|
||||
|
||||
| Cas | Action |
|
||||
|-----|--------|
|
||||
| Nouveau custom field | **Créer** `CustomFieldValue` vides pour tous les items |
|
||||
| Custom field supprimé | **Supprimer** les `CustomFieldValue` (si `confirmDeletions`) |
|
||||
| Renommé (même `orderIndex`, nom différent) | **Propagation auto** — label dans la définition, valeurs intactes |
|
||||
| Type changé (même `orderIndex`, type différent) | **Clear** les valeurs (si `confirmTypeChanges`) — `CustomFieldValue` conservée, `value` vidée |
|
||||
|
||||
Le matching des custom fields se fait par **`orderIndex`** (propriété stable sur l'entité `CustomField`), pas par index de tableau. Cela évite les faux positifs lors de réordonnancement.
|
||||
|
||||
## Nouvelle Entité — `PieceProductSlot`
|
||||
|
||||
### Contexte — Remplacement de la M2M `piece_products`
|
||||
|
||||
Actuellement, les produits liés aux pièces passent par une relation M2M (`piece_products`, colonnes `a`/`b`). Cette table n'a pas de notion de `position`, `typeProductId`, ou `familyCode`.
|
||||
|
||||
`PieceProductSlot` **remplace** cette M2M pour uniformiser l'architecture avec les slots des Composants. La M2M existante sera conservée temporairement puis supprimée dans une migration future.
|
||||
|
||||
### Table `piece_product_slots`
|
||||
|
||||
| Colonne | Type | Contrainte |
|
||||
|---------|------|------------|
|
||||
| `id` | VARCHAR (CUID) | PK |
|
||||
| `pieceid` | VARCHAR | FK → `pieces.id` CASCADE |
|
||||
| `typeproductid` | VARCHAR | FK → `model_types.id` SET NULL, nullable |
|
||||
| `selectedproductid` | VARCHAR | FK → `products.id` SET NULL, nullable |
|
||||
| `familycode` | VARCHAR(255) | nullable |
|
||||
| `position` | INT | NOT NULL |
|
||||
| `createdat` | TIMESTAMP | NOT NULL |
|
||||
| `updatedat` | TIMESTAMP | NOT NULL |
|
||||
|
||||
### Entité PHP
|
||||
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'piece_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class PieceProductSlot
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: 'string')]
|
||||
private string $id;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Piece $piece;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### Relation sur Piece
|
||||
|
||||
```php
|
||||
#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
```
|
||||
|
||||
La relation M2M `$products` existante sur `Piece` sera marquée deprecated puis supprimée dans une migration future.
|
||||
|
||||
### Migration
|
||||
|
||||
1. Créer la table `piece_product_slots`
|
||||
2. Migrer les données existantes de `piece_products` (M2M) → chaque entrée devient un `PieceProductSlot` avec `selectedProductId` renseigné, `typeProductId` déduit du produit sélectionné (`product.typeProduct`), `position` auto-incrémentée
|
||||
3. Conserver `piece_products` temporairement (suppression dans une migration future)
|
||||
4. Mettre à jour le virtual getter `getStructure()` de Piece pour lire les `productSlots`
|
||||
|
||||
## Versioning
|
||||
|
||||
### Nouveau champ sur Composant, Piece, Product
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: 'integer', options: ['default' => 1])]
|
||||
#[Groups(['composant:read'])] // idem pour piece:read, product:read
|
||||
private int $version = 1;
|
||||
```
|
||||
|
||||
### Comportement
|
||||
|
||||
- **Création** d'un item → `version = 1`
|
||||
- **Sync** qui modifie les slots ou custom fields d'un item → `version += 1`
|
||||
- Si la sync n'a aucun impact sur un item particulier (ses slots matchent déjà le skeleton), sa version ne change pas
|
||||
|
||||
### Migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE composants ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE pieces ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
### Suppression du `restrictedMode`
|
||||
|
||||
**Fichiers à supprimer :**
|
||||
- `composables/useCategoryEditGuard.ts`
|
||||
- `tests/composables/useCategoryEditGuard.test.ts`
|
||||
|
||||
**Fichiers à modifier (retirer restrictedMode) :**
|
||||
- `pages/component-category/[id]/edit.vue`
|
||||
- `pages/piece-category/[id]/edit.vue`
|
||||
- `pages/product-category/[id]/edit.vue`
|
||||
- `components/StructureNodeEditor.vue`
|
||||
- `components/PieceModelStructureEditor.vue`
|
||||
- `components/ComponentModelStructureEditor.vue`
|
||||
- `components/model-types/ModelTypeForm.vue`
|
||||
- `composables/useStructureNodeCrud.ts`
|
||||
- `composables/useStructureNodeLogic.ts`
|
||||
- `composables/usePieceStructureEditorLogic.ts`
|
||||
- `tests/components/ModelTypeForm.test.ts` (si existant)
|
||||
- `tests/components/PieceModelStructureEditor.test.ts`
|
||||
|
||||
### Nouveau composant — `SyncConfirmationModal.vue`
|
||||
|
||||
Modal DaisyUI qui reçoit un `SyncPreviewResult` et affiche :
|
||||
|
||||
```
|
||||
Cette modification impacte X [composants|pièces|produits] :
|
||||
|
||||
Ajouts :
|
||||
• Y slots pièce à créer
|
||||
• Z valeurs de champs personnalisés à initialiser
|
||||
|
||||
Suppressions :
|
||||
• W slots produit à supprimer (les sélections seront perdues)
|
||||
|
||||
Modifications :
|
||||
• V valeurs de champs à réinitialiser (changement de type)
|
||||
|
||||
[Annuler] [Confirmer la synchronisation]
|
||||
```
|
||||
|
||||
### Nouveau service — `modelTypes.ts`
|
||||
|
||||
Ajout de deux fonctions au service existant :
|
||||
|
||||
```typescript
|
||||
export function syncPreview(id: string, structure: any) {
|
||||
return requestFetch(`/api/model_types/${id}/sync-preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ structure }),
|
||||
})
|
||||
}
|
||||
|
||||
export function syncExecute(id: string, confirmation: { confirmDeletions: boolean, confirmTypeChanges: boolean }) {
|
||||
return requestFetch(`/api/model_types/${id}/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(confirmation),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Flow dans les pages d'édition de catégorie
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async (payload) => {
|
||||
// 1. Preview (avant le save)
|
||||
const preview = await syncPreview(id, payload.structure)
|
||||
|
||||
const hasImpact = preview.itemCount > 0 && (
|
||||
Object.values(preview.additions).some(v => v > 0) ||
|
||||
Object.values(preview.deletions).some(v => v > 0) ||
|
||||
Object.values(preview.modifications).some(v => v > 0)
|
||||
)
|
||||
|
||||
// 2. Si impact, demander confirmation
|
||||
if (hasImpact) {
|
||||
pendingPayload.value = payload
|
||||
syncPreviewData.value = preview
|
||||
showSyncModal.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Pas d'impact → save direct (PATCH + sync)
|
||||
await saveAndSync(payload, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
}
|
||||
|
||||
const onSyncConfirmed = async () => {
|
||||
const preview = syncPreviewData.value
|
||||
const needsDeleteConfirm = Object.values(preview.deletions).some(v => v > 0)
|
||||
const needsTypeChangeConfirm = preview.modifications.customFieldTypeChanges > 0
|
||||
|
||||
await saveAndSync(pendingPayload.value, {
|
||||
confirmDeletions: needsDeleteConfirm,
|
||||
confirmTypeChanges: needsTypeChangeConfirm,
|
||||
})
|
||||
}
|
||||
|
||||
const saveAndSync = async (payload, confirmation) => {
|
||||
await updateModelType(id, payload)
|
||||
await syncExecute(id, confirmation)
|
||||
showSuccess('Catégorie mise à jour et synchronisée.')
|
||||
router.push('/...')
|
||||
}
|
||||
```
|
||||
|
||||
## Non-régression
|
||||
|
||||
### Machines
|
||||
|
||||
- Le `MachineStructureController` lit les slots des composants. La sync modifie ces slots → les machines affichent automatiquement la dernière version au prochain chargement.
|
||||
- Aucun changement dans le controller machine.
|
||||
|
||||
### Quantités
|
||||
|
||||
- La sync **ne touche jamais** aux slots qui matchent toujours un skeleton requirement. Les quantités (`ComposantPieceSlot.quantity`) et sélections existantes sont préservées.
|
||||
- Les nouveaux slots ajoutés par la sync ont `quantity = 1` par défaut.
|
||||
- Le `ComposantPieceSlotController` (PATCH quantity) reste inchangé.
|
||||
|
||||
### `PieceProductSlot` — pas de quantité
|
||||
|
||||
Cohérent avec `ComposantProductSlot` qui n'a pas de quantité non plus.
|
||||
|
||||
### Relation M2M `piece_products`
|
||||
|
||||
La M2M existante reste en base pendant la période de transition. Le code frontend/backend qui la lit devra être migré vers les `productSlots`. La M2M sera supprimée dans une migration future une fois que tout le code utilise les slots.
|
||||
|
||||
## Tests
|
||||
|
||||
### Backend — PHPUnit
|
||||
|
||||
- `ModelTypeSyncControllerTest` — tests des endpoints preview et sync (y compris erreurs 403/404, confirmations partielles)
|
||||
- `ComposantSyncStrategyTest` — logique de diff pour composants (ajout, suppression, position update, no-op)
|
||||
- `PieceSyncStrategyTest` — logique de diff pour pièces (ajout/suppression de product slots)
|
||||
- `ProductSyncStrategyTest` — logique de diff pour produits (custom fields only)
|
||||
- `PieceProductSlotTest` — CRUD de la nouvelle entité
|
||||
- Idempotence : vérifier qu'un double appel à `sync` est un no-op
|
||||
- Vérifier la non-régression : `MachineStructureControllerTest` existant doit passer sans modification
|
||||
|
||||
### Frontend — Tests
|
||||
|
||||
- Supprimer `tests/composables/useCategoryEditGuard.test.ts`
|
||||
- Mettre à jour `tests/components/PieceModelStructureEditor.test.ts` (retirer restrictedMode)
|
||||
- Ajouter tests pour le flow sync dans les pages d'édition (preview → modal → confirm → save)
|
||||
|
||||
## Performance
|
||||
|
||||
Pour le volume attendu (dizaines d'items par catégorie, pas de milliers), la sync en PHP avec l'ORM Doctrine est suffisante. Si le volume augmente significativement, les opérations de création/suppression de slots pourront être converties en batch SQL (INSERT ... SELECT, DELETE ... WHERE) sans changer l'architecture (la strategy encapsule la logique).
|
||||
@@ -1,669 +0,0 @@
|
||||
# MCP Server — Inventory Project — Design Spec
|
||||
|
||||
**Date :** 2026-03-16
|
||||
**Version projet :** 1.9.1
|
||||
**Statut :** Draft (post-review v2)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Exposer l'intégralité de l'API Inventory (machines, pièces, composants, produits, sites, constructeurs, custom fields, documents, commentaires, audit) via un serveur MCP (Model Context Protocol) intégré directement dans l'application Symfony.
|
||||
|
||||
Le serveur doit être compatible avec tous les clients MCP majeurs : Claude Code, Claude Desktop, ChatGPT Desktop, Codex, et tout client supportant le protocole MCP.
|
||||
|
||||
## 2. Contraintes
|
||||
|
||||
| Contrainte | Détail |
|
||||
|---|---|
|
||||
| **Réseau** | Machine hébergée sur un réseau fermé d'entreprise. Les clients distants (Claude Desktop, ChatGPT, Codex) accèdent via un tunnel chiffré (Cloudflare/WireGuard/SSH) |
|
||||
| **Auth** | Pass-through : chaque client fournit ses propres credentials (profileId + password). Le serveur MCP charge le profil correspondant et applique ses rôles. Les actions sont traçables par utilisateur dans l'audit log |
|
||||
| **Transport** | Dual : stdio pour usage local (Claude Code sur la même machine) + HTTP Streamable/SSE pour clients distants via tunnel |
|
||||
| **Stack** | PHP / Symfony 8.0 — le serveur MCP vit dans l'application existante, pas de service séparé |
|
||||
| **Scope** | Lecture + écriture complète — les outils couvrent tout le CRUD + les opérations métier |
|
||||
|
||||
## 3. Stack technique
|
||||
|
||||
| Composant | Choix |
|
||||
|---|---|
|
||||
| SDK MCP | `symfony/mcp-bundle` v0.6.0 + `mcp/sdk` ^0.4 (officiel Symfony + PHP Foundation + Anthropic) |
|
||||
| Transport stdio | `bin/console mcp:server` (dans le container Docker) |
|
||||
| Transport HTTP | Endpoint `/_mcp` sur le même port que l'API (8081) |
|
||||
| Auth HTTP | Custom Symfony Authenticator (`McpHeaderAuthenticator`) intégré au firewall Symfony |
|
||||
| Auth stdio | Token synthétique chargé depuis `$_ENV` au boot |
|
||||
| Rate limiting | `symfony/rate-limiter` sur les tentatives d'auth échouées |
|
||||
| Accès données | Repositories Doctrine directs (pas de hop HTTP interne) |
|
||||
|
||||
**Note :** Le bundle est expérimental et non couvert par la BC Promise de Symfony. L'implémentation inclut un spike/PoC initial (étape 1 du plan) pour valider la compatibilité de l'API réelle du bundle avec ce design.
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Docker Compose (réseau fermé entreprise) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ php-inventory-apache (Symfony 8) │ │
|
||||
│ │ │ │
|
||||
│ │ /api/* ← API REST existante │ │
|
||||
│ │ /_mcp ← Endpoint MCP HTTP (SSE) │ │
|
||||
│ │ bin/console mcp:server ← Transport stdio │ │
|
||||
│ │ │ │
|
||||
│ │ Firewall Symfony : │ │
|
||||
│ │ ^/api → SessionProfileAuthenticator │ │
|
||||
│ │ ^/_mcp → McpHeaderAuthenticator │ │
|
||||
│ │ │ │
|
||||
│ │ src/Mcp/Tool/ ← Tools MCP │ │
|
||||
│ │ src/Mcp/Resource/ ← Resources MCP │ │
|
||||
│ │ src/Mcp/Security/ ← Authenticator + Guard │ │
|
||||
│ └──────────┬───────────────────────────────────┘ │
|
||||
│ │ réseau Docker interne │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ PostgreSQL 16 │ │
|
||||
│ └─────────────────────┘ │
|
||||
└──────────────────┬──────────────────────────────────┘
|
||||
│ tunnel (chiffré)
|
||||
┌──────────────▼──────────────────┐
|
||||
│ Postes utilisateurs │
|
||||
│ - Claude Desktop → HTTP/SSE │
|
||||
│ - ChatGPT Desktop → HTTP/SSE │
|
||||
│ - Codex → HTTP/SSE │
|
||||
│ - Claude Code local → stdio │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
Le serveur MCP accède directement aux repositories Doctrine et aux services Symfony existants. Pas de double sérialisation — les tools appellent les mêmes repositories/services que les controllers REST.
|
||||
|
||||
## 5. Authentification pass-through
|
||||
|
||||
### 5.1 Firewall Symfony — intégration sécurité
|
||||
|
||||
Un firewall dédié pour `/_mcp` avec un authenticator custom. Cela garantit que `$security->getUser()` retourne le bon Profile, que la hiérarchie des rôles fonctionne via `is_granted()`, et que l'audit log trace le bon acteur.
|
||||
|
||||
```yaml
|
||||
# config/packages/security.yaml (ajout)
|
||||
security:
|
||||
firewalls:
|
||||
mcp:
|
||||
pattern: ^/_mcp
|
||||
stateless: true
|
||||
custom_authenticators:
|
||||
- App\Mcp\Security\McpHeaderAuthenticator
|
||||
```
|
||||
|
||||
Le `McpHeaderAuthenticator` implémente `AuthenticatorInterface` :
|
||||
1. Extrait `X-Profile-Id` et `X-Profile-Password` des headers
|
||||
2. Charge le profil via `ProfileRepository`
|
||||
3. Vérifie le password hash via `UserPasswordHasherInterface`
|
||||
4. Retourne un `Passport` avec le Profile comme User
|
||||
5. Symfony gère le reste (token, rôles, hiérarchie)
|
||||
|
||||
Cela permet à `AbstractAuditSubscriber.resolveActorProfileId()` de résoudre l'acteur via `$security->getUser()` sans aucune modification du code existant.
|
||||
|
||||
### 5.2 Transport stdio — token synthétique
|
||||
|
||||
Pour le transport stdio (pas de requête HTTP), un `EventSubscriber` sur `console.command` (quand la commande est `mcp:server`) :
|
||||
1. Lit `MCP_PROFILE_ID` et `MCP_PROFILE_PASSWORD` depuis `$_ENV`
|
||||
2. Valide les credentials
|
||||
3. Injecte un `UsernamePasswordToken` synthétique dans le `TokenStorage` avec le Profile
|
||||
|
||||
### 5.3 Rate limiting — protection brute-force
|
||||
|
||||
```yaml
|
||||
# config/packages/rate_limiter.yaml
|
||||
framework:
|
||||
rate_limiter:
|
||||
mcp_auth:
|
||||
policy: sliding_window
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
```
|
||||
|
||||
Le `McpHeaderAuthenticator` consomme le rate limiter sur chaque tentative échouée (clé = IP). Après 5 échecs en 1 minute, toute tentative est rejetée avec une erreur MCP `429 Too Many Requests`.
|
||||
|
||||
### 5.4 Vérification des rôles
|
||||
|
||||
Chaque tool déclare un rôle minimum. L'authenticator Symfony gère la hiérarchie :
|
||||
|
||||
| Rôle | Droits MCP |
|
||||
|---|---|
|
||||
| `ROLE_VIEWER` | Tous les tools de lecture (list, get, search, history) |
|
||||
| `ROLE_GESTIONNAIRE` | Lecture + écriture (create, update, delete, slots, clone) |
|
||||
| `ROLE_ADMIN` | Tout + gestion profils |
|
||||
|
||||
Les tools utilisent `$this->security->isGranted('ROLE_XXX')` pour vérifier, bénéficiant de la hiérarchie Symfony standard.
|
||||
|
||||
## 6. Catalogue des Tools MCP
|
||||
|
||||
### 6.1 Tools de haut niveau (métier)
|
||||
|
||||
| Tool | Description | Paramètres principaux | Rôle min |
|
||||
|---|---|---|---|
|
||||
| `search_inventory` | Recherche globale dans toutes les entités (machines, pièces, composants, produits, sites, constructeurs) | `query: string`, `types?: string[]`, `limit?: int` | VIEWER |
|
||||
| `get_machine_structure` | Hiérarchie complète d'une machine : composants, pièces, produits, custom fields, slots | `machineId: string` | VIEWER |
|
||||
| `clone_machine` | Clone une machine avec sa structure complète | `machineId: string`, `name: string`, `siteId: string`, `reference?: string` | GESTIONNAIRE |
|
||||
| `get_entity_history` | Historique d'audit d'une entité | `entityType: string`, `entityId: string` | VIEWER |
|
||||
| `get_activity_log` | Journal d'activité global | `page?: int`, `limit?: int`, `entityType?: string`, `action?: string` | VIEWER |
|
||||
| `get_dashboard_stats` | Compteurs globaux (machines, pièces, composants, produits, commentaires ouverts) | aucun | VIEWER |
|
||||
| `sync_model_type` | Preview ou exécution de la synchronisation skeleton d'un ModelType | `modelTypeId: string`, `action: "preview"\|"sync"`, `structure?: object` | GESTIONNAIRE |
|
||||
|
||||
### 6.2 Tools CRUD — Machines
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_machines` | Lister les machines avec filtres (nom, référence, site) et pagination | VIEWER |
|
||||
| `get_machine` | Détail d'une machine par ID | VIEWER |
|
||||
| `create_machine` | Créer une machine (nom, référence, siteId, constructeurs) | GESTIONNAIRE |
|
||||
| `update_machine` | Mise à jour partielle d'une machine | GESTIONNAIRE |
|
||||
| `delete_machine` | Supprimer une machine | GESTIONNAIRE |
|
||||
|
||||
### 6.3 Tools CRUD — Composants
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_composants` | Lister les composants avec filtres et pagination | VIEWER |
|
||||
| `get_composant` | Détail d'un composant par ID (incluant ses slots) | VIEWER |
|
||||
| `create_composant` | Créer un composant (nom, référence, modelTypeId, constructeurs). Retourne l'ID + les slots vides auto-générés | GESTIONNAIRE |
|
||||
| `update_composant` | Mise à jour partielle | GESTIONNAIRE |
|
||||
| `delete_composant` | Supprimer un composant | GESTIONNAIRE |
|
||||
|
||||
### 6.4 Tools CRUD — Pièces
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_pieces` | Lister les pièces avec filtres et pagination | VIEWER |
|
||||
| `get_piece` | Détail d'une pièce par ID (incluant ses product-slots) | VIEWER |
|
||||
| `create_piece` | Créer une pièce (nom, référence, modelTypeId, constructeurs). Retourne l'ID + product-slots auto-générés | GESTIONNAIRE |
|
||||
| `update_piece` | Mise à jour partielle | GESTIONNAIRE |
|
||||
| `delete_piece` | Supprimer une pièce | GESTIONNAIRE |
|
||||
|
||||
### 6.5 Tools CRUD — Produits
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_products` | Lister les produits avec filtres et pagination | VIEWER |
|
||||
| `get_product` | Détail d'un produit par ID | VIEWER |
|
||||
| `create_product` | Créer un produit (nom, référence, modelTypeId, prix (string), constructeurs) | GESTIONNAIRE |
|
||||
| `update_product` | Mise à jour partielle | GESTIONNAIRE |
|
||||
| `delete_product` | Supprimer un produit | GESTIONNAIRE |
|
||||
|
||||
### 6.6 Tools CRUD — Sites
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_sites` | Lister les sites | VIEWER |
|
||||
| `get_site` | Détail d'un site par ID | VIEWER |
|
||||
| `create_site` | Créer un site | GESTIONNAIRE |
|
||||
| `update_site` | Mise à jour partielle | GESTIONNAIRE |
|
||||
| `delete_site` | Supprimer un site | GESTIONNAIRE |
|
||||
|
||||
### 6.7 Tools CRUD — Constructeurs
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_constructeurs` | Lister les constructeurs/fournisseurs | VIEWER |
|
||||
| `get_constructeur` | Détail d'un constructeur par ID | VIEWER |
|
||||
| `create_constructeur` | Créer un constructeur | GESTIONNAIRE |
|
||||
| `update_constructeur` | Mise à jour partielle | GESTIONNAIRE |
|
||||
| `delete_constructeur` | Supprimer un constructeur | GESTIONNAIRE |
|
||||
|
||||
### 6.8 Tools — Commentaires (splittés)
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_comments` | Lister les commentaires d'une entité | VIEWER |
|
||||
| `create_comment` | Créer un commentaire sur une entité | VIEWER |
|
||||
| `resolve_comment` | Marquer un commentaire comme résolu | GESTIONNAIRE |
|
||||
| `get_unresolved_comments_count` | Nombre de commentaires non résolus | VIEWER |
|
||||
|
||||
### 6.9 Tools — Custom Fields (splittés)
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_custom_field_values` | Lister les custom field values d'une entité | VIEWER |
|
||||
| `upsert_custom_field_values` | Créer ou mettre à jour des custom field values | GESTIONNAIRE |
|
||||
| `delete_custom_field_value` | Supprimer une custom field value | GESTIONNAIRE |
|
||||
|
||||
### 6.10 Tools — Documents (splittés)
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_documents` | Lister les documents d'une entité | VIEWER |
|
||||
| `delete_document` | Supprimer un document | GESTIONNAIRE |
|
||||
|
||||
> **Limitation connue :** L'upload de documents n'est pas supporté via MCP. Le protocole MCP échange du JSON — l'upload de fichiers binaires (multipart/form-data) n'est pas compatible. Les uploads doivent se faire via l'API REST `/api/documents` (POST multipart). Cette limitation pourra être réévaluée si le protocole MCP ajoute un support binaire.
|
||||
|
||||
### 6.11 Tools — Machine Links (splittés)
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_machine_links` | Lister les liens composant/pièce/produit d'une machine | VIEWER |
|
||||
| `add_machine_links` | Ajouter des liens machine↔composant/pièce/produit | GESTIONNAIRE |
|
||||
| `update_machine_link` | Modifier un lien (quantité, overrides) | GESTIONNAIRE |
|
||||
| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE |
|
||||
|
||||
### 6.12 Tools — Slots
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_slots` | Lister les slots d'un composant ou pièce avec état (rempli/vide, requirement). Paramètre `entityType: "composant"\|"piece"` + `entityId` | VIEWER |
|
||||
| `update_slots` | Remplir un ou plusieurs slots. Paramètre `slots: [{slotId, selectedPieceId?\|selectedProductId?\|selectedComposantId?}]` | GESTIONNAIRE |
|
||||
|
||||
> **Note :** Un seul tool `list_slots` et un seul `update_slots` — ils acceptent un paramètre `entityType` pour dispatcher vers composant ou pièce. Un seul fichier d'implémentation par tool.
|
||||
|
||||
### 6.13 Tools — ModelTypes
|
||||
|
||||
| Tool | Description | Rôle min |
|
||||
|---|---|---|
|
||||
| `list_model_types` | Lister les ModelTypes par catégorie avec skeleton requirements | VIEWER |
|
||||
| `get_model_type` | Détail complet d'un ModelType (requirements + custom fields) | VIEWER |
|
||||
| `create_model_type` | Créer un ModelType | GESTIONNAIRE |
|
||||
| `update_model_type` | Modifier un ModelType | GESTIONNAIRE |
|
||||
| `delete_model_type` | Supprimer un ModelType | GESTIONNAIRE |
|
||||
|
||||
**Total : ~55 tools** (splittés pour des schémas JSON non-ambigus, meilleure compatibilité LLM)
|
||||
|
||||
> **Note :** Les tools d'administration des profils (`list_profiles`, `create_profile`, etc.) ne sont pas inclus — la gestion des profils reste exclusivement via l'API REST `/api/admin/profiles` (ROLE_ADMIN). Cela évite d'exposer la gestion des comptes/mots de passe via MCP.
|
||||
|
||||
## 7. Resources MCP
|
||||
|
||||
| URI | Description | Contenu |
|
||||
|---|---|---|
|
||||
| `inventory://schema/entities` | Schéma de toutes les entités | Nom, champs (nom, type, nullable, description) pour chaque entité |
|
||||
| `inventory://model-types/{category}` | ModelTypes par catégorie | Liste des ModelTypes avec leurs skeleton requirements et custom fields |
|
||||
| `inventory://roles` | Hiérarchie des rôles | Rôles et permissions associées pour guider le LLM |
|
||||
| `inventory://stats` | Statistiques globales | Compteurs de chaque entité, commentaires ouverts |
|
||||
|
||||
## 8. Workflows de création guidés
|
||||
|
||||
### 8.1 Créer un Composant complet
|
||||
|
||||
```
|
||||
1. list_model_types(category: "composant")
|
||||
→ Choisir le type de composant
|
||||
|
||||
2. get_model_type(modelTypeId)
|
||||
→ Voir les skeleton requirements : pièces, produits, sous-composants attendus
|
||||
→ Voir les custom fields de chaque requirement
|
||||
|
||||
3. create_composant(name, reference, modelTypeId, constructeurs)
|
||||
→ Reçoit: { id, slots: [{slotId, type, requirementName}, ...] }
|
||||
|
||||
4. search_inventory(query: "Roulement", types: ["piece"])
|
||||
→ Trouver les pièces candidates pour chaque slot
|
||||
|
||||
5. update_slots([{slotId, selectedPieceId}, {slotId, selectedProductId}, ...])
|
||||
→ Remplir les slots
|
||||
|
||||
6. upsert_custom_field_values(entityType: "composant", entityId,
|
||||
fields: [{name: "Tension", value: "220V"}, ...])
|
||||
→ Remplir les custom fields
|
||||
```
|
||||
|
||||
### 8.2 Créer une Pièce complète
|
||||
|
||||
```
|
||||
1. list_model_types(category: "piece")
|
||||
2. get_model_type(modelTypeId)
|
||||
3. create_piece(name, reference, modelTypeId, constructeurs)
|
||||
→ Reçoit: { id, productSlots: [{slotId, requirementName}, ...] }
|
||||
4. search_inventory(query: "...", types: ["product"])
|
||||
5. update_slots([{slotId, selectedProductId}, ...])
|
||||
6. upsert_custom_field_values(...)
|
||||
```
|
||||
|
||||
### 8.3 Créer un Produit
|
||||
|
||||
```
|
||||
1. list_model_types(category: "product")
|
||||
2. create_product(name, reference, modelTypeId, prix, constructeurs)
|
||||
3. upsert_custom_field_values(...)
|
||||
```
|
||||
|
||||
### 8.4 Créer une Machine complète (de bas en haut)
|
||||
|
||||
```
|
||||
1. Créer les produits nécessaires (§8.3)
|
||||
2. Créer les pièces avec les produits dans les slots (§8.2)
|
||||
3. Créer les composants avec les pièces dans les slots (§8.1)
|
||||
4. list_sites → choisir le site
|
||||
5. create_machine(name, reference, siteId, constructeurs)
|
||||
6. add_machine_links(machineId, links: [
|
||||
{type: "composant", entityId, quantity},
|
||||
{type: "piece", entityId, quantity},
|
||||
{type: "product", entityId}
|
||||
])
|
||||
7. upsert_custom_field_values(entityType: "machine", machineId, ...)
|
||||
```
|
||||
|
||||
## 9. Pagination
|
||||
|
||||
Toutes les tools `list_*` utilisent un contrat de pagination uniforme :
|
||||
|
||||
### Paramètres d'entrée
|
||||
|
||||
| Paramètre | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `page` | int | 1 | Numéro de page (1-indexed) |
|
||||
| `limit` | int | 30 | Nombre d'items par page (max 100) |
|
||||
|
||||
### Format de réponse
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"total": 142,
|
||||
"page": 1,
|
||||
"limit": 30,
|
||||
"pageCount": 5
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Format des erreurs
|
||||
|
||||
Toutes les erreurs MCP suivent un format uniforme via `isError: true` dans la réponse tool :
|
||||
|
||||
```json
|
||||
{
|
||||
"isError": true,
|
||||
"content": [{"type": "text", "text": "Permission denied: ROLE_GESTIONNAIRE required for create_machine"}]
|
||||
}
|
||||
```
|
||||
|
||||
### Catégories d'erreurs
|
||||
|
||||
| Code | Description | Exemple |
|
||||
|---|---|---|
|
||||
| `auth_error` | Credentials invalides ou manquants | "Authentication failed: invalid password" |
|
||||
| `permission_denied` | Rôle insuffisant pour l'opération | "Permission denied: ROLE_GESTIONNAIRE required" |
|
||||
| `not_found` | Entité introuvable | "Machine not found: cl4a8b..." |
|
||||
| `validation_error` | Données invalides | "Validation failed: name is required" |
|
||||
| `rate_limited` | Trop de tentatives d'auth échouées | "Rate limited: try again in 45 seconds" |
|
||||
| `internal_error` | Erreur serveur inattendue | "Internal error: database connection failed" |
|
||||
|
||||
Le champ `text` inclut toujours la catégorie en préfixe pour que le LLM puisse adapter son comportement.
|
||||
|
||||
## 11. Configuration
|
||||
|
||||
### 11.1 Symfony — config/packages/mcp.yaml
|
||||
|
||||
```yaml
|
||||
mcp:
|
||||
app: 'inventory'
|
||||
version: '%env(file:resolve:VERSION)%'
|
||||
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
|
||||
instructions: |
|
||||
Serveur MCP pour gérer un inventaire industriel.
|
||||
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
|
||||
Utilisez search_inventory pour chercher dans toutes les entités.
|
||||
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
|
||||
Consultez la resource inventory://schema/entities pour voir le schéma complet.
|
||||
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
|
||||
client_transports:
|
||||
stdio: true
|
||||
http: true
|
||||
http:
|
||||
path: /_mcp
|
||||
session:
|
||||
store: file
|
||||
directory: '%kernel.cache_dir%/mcp-sessions'
|
||||
ttl: 3600
|
||||
```
|
||||
|
||||
### 11.2 Security — config/packages/security.yaml (ajout firewall)
|
||||
|
||||
```yaml
|
||||
security:
|
||||
firewalls:
|
||||
# AVANT le firewall api existant
|
||||
mcp:
|
||||
pattern: ^/_mcp
|
||||
stateless: true
|
||||
custom_authenticators:
|
||||
- App\Mcp\Security\McpHeaderAuthenticator
|
||||
api:
|
||||
pattern: ^/api
|
||||
# ... existant ...
|
||||
```
|
||||
|
||||
### 11.3 Rate Limiter — config/packages/rate_limiter.yaml
|
||||
|
||||
```yaml
|
||||
framework:
|
||||
rate_limiter:
|
||||
mcp_auth:
|
||||
policy: sliding_window
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
```
|
||||
|
||||
### 11.4 Routes — config/routes.yaml (ajout)
|
||||
|
||||
```yaml
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
```
|
||||
|
||||
### 11.5 Logging — config/packages/monolog.yaml (ajout)
|
||||
|
||||
```yaml
|
||||
monolog:
|
||||
channels: ['mcp']
|
||||
handlers:
|
||||
mcp:
|
||||
type: rotating_file
|
||||
path: '%kernel.logs_dir%/mcp.log'
|
||||
level: info
|
||||
channels: ['mcp']
|
||||
max_files: 30
|
||||
```
|
||||
|
||||
## 12. Configuration des clients
|
||||
|
||||
### 12.1 Claude Code (local, stdio via Docker)
|
||||
|
||||
Fichier `.mcp.json` à la racine du projet :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"inventory": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"exec", "-i",
|
||||
"-e", "MCP_PROFILE_ID=<votre-profile-id>",
|
||||
"-e", "MCP_PROFILE_PASSWORD=<votre-password>",
|
||||
"php-inventory-apache",
|
||||
"php", "bin/console", "mcp:server"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note :** Les env vars sont passées via les flags `-e` de `docker exec` car le bloc `env` de `.mcp.json` ne les injecte pas dans le container Docker. Si PHP et les dépendances Composer sont disponibles directement sur l'hôte (hors Docker), on peut utiliser `"command": "php", "args": ["bin/console", "mcp:server"]` avec un bloc `env` standard.
|
||||
|
||||
### 12.2 Claude Desktop (distant, HTTP via tunnel)
|
||||
|
||||
Fichier `claude_desktop_config.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"inventory": {
|
||||
"url": "https://inventory.company-tunnel.com/_mcp",
|
||||
"headers": {
|
||||
"X-Profile-Id": "<votre-profile-id>",
|
||||
"X-Profile-Password": "<votre-password>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.3 ChatGPT Desktop (HTTP via tunnel)
|
||||
|
||||
Même principe HTTP : URL du tunnel + headers d'auth. Format de config selon la doc ChatGPT MCP.
|
||||
|
||||
### 12.4 Codex (HTTP via tunnel)
|
||||
|
||||
Même config HTTP que Claude Desktop.
|
||||
|
||||
## 13. Structure des fichiers
|
||||
|
||||
```
|
||||
src/
|
||||
└── Mcp/
|
||||
├── Tool/
|
||||
│ ├── SearchInventoryTool.php # search_inventory
|
||||
│ ├── DashboardStatsTool.php # get_dashboard_stats
|
||||
│ ├── ActivityLogTool.php # get_activity_log
|
||||
│ ├── EntityHistoryTool.php # get_entity_history
|
||||
│ ├── Machine/
|
||||
│ │ ├── ListMachinesTool.php # list_machines
|
||||
│ │ ├── GetMachineTool.php # get_machine
|
||||
│ │ ├── CreateMachineTool.php # create_machine
|
||||
│ │ ├── UpdateMachineTool.php # update_machine
|
||||
│ │ ├── DeleteMachineTool.php # delete_machine
|
||||
│ │ ├── MachineStructureTool.php # get_machine_structure
|
||||
│ │ ├── CloneMachineTool.php # clone_machine
|
||||
│ │ ├── ListMachineLinksTool.php # list_machine_links
|
||||
│ │ ├── AddMachineLinksTool.php # add_machine_links
|
||||
│ │ ├── UpdateMachineLinkTool.php # update_machine_link
|
||||
│ │ └── RemoveMachineLinkTool.php # remove_machine_link
|
||||
│ ├── Composant/
|
||||
│ │ ├── ListComposantsTool.php # list_composants
|
||||
│ │ ├── GetComposantTool.php # get_composant
|
||||
│ │ ├── CreateComposantTool.php # create_composant
|
||||
│ │ ├── UpdateComposantTool.php # update_composant
|
||||
│ │ └── DeleteComposantTool.php # delete_composant
|
||||
│ ├── Piece/
|
||||
│ │ ├── ListPiecesTool.php # list_pieces
|
||||
│ │ ├── GetPieceTool.php # get_piece
|
||||
│ │ ├── CreatePieceTool.php # create_piece
|
||||
│ │ ├── UpdatePieceTool.php # update_piece
|
||||
│ │ └── DeletePieceTool.php # delete_piece
|
||||
│ ├── Slot/
|
||||
│ │ ├── ListSlotsTool.php # list_slots (dispatche par entityType)
|
||||
│ │ └── UpdateSlotsTool.php # update_slots
|
||||
│ ├── Product/
|
||||
│ │ ├── ListProductsTool.php # list_products
|
||||
│ │ ├── GetProductTool.php # get_product
|
||||
│ │ ├── CreateProductTool.php # create_product
|
||||
│ │ ├── UpdateProductTool.php # update_product
|
||||
│ │ └── DeleteProductTool.php # delete_product
|
||||
│ ├── Site/
|
||||
│ │ ├── ListSitesTool.php # list_sites
|
||||
│ │ ├── GetSiteTool.php # get_site
|
||||
│ │ ├── CreateSiteTool.php # create_site
|
||||
│ │ ├── UpdateSiteTool.php # update_site
|
||||
│ │ └── DeleteSiteTool.php # delete_site
|
||||
│ ├── Constructeur/
|
||||
│ │ ├── ListConstructeursTool.php # list_constructeurs
|
||||
│ │ ├── GetConstructeurTool.php # get_constructeur
|
||||
│ │ ├── CreateConstructeurTool.php # create_constructeur
|
||||
│ │ ├── UpdateConstructeurTool.php # update_constructeur
|
||||
│ │ └── DeleteConstructeurTool.php # delete_constructeur
|
||||
│ ├── ModelType/
|
||||
│ │ ├── ListModelTypesTool.php # list_model_types
|
||||
│ │ ├── GetModelTypeTool.php # get_model_type
|
||||
│ │ ├── CreateModelTypeTool.php # create_model_type
|
||||
│ │ ├── UpdateModelTypeTool.php # update_model_type
|
||||
│ │ ├── DeleteModelTypeTool.php # delete_model_type
|
||||
│ │ └── SyncModelTypeTool.php # sync_model_type
|
||||
│ ├── CustomField/
|
||||
│ │ ├── ListCustomFieldValuesTool.php # list_custom_field_values
|
||||
│ │ ├── UpsertCustomFieldValuesTool.php # upsert_custom_field_values
|
||||
│ │ └── DeleteCustomFieldValueTool.php # delete_custom_field_value
|
||||
│ ├── Document/
|
||||
│ │ ├── ListDocumentsTool.php # list_documents
|
||||
│ │ └── DeleteDocumentTool.php # delete_document
|
||||
│ └── Comment/
|
||||
│ ├── ListCommentsTool.php # list_comments
|
||||
│ ├── CreateCommentTool.php # create_comment
|
||||
│ ├── ResolveCommentTool.php # resolve_comment
|
||||
│ └── UnresolvedCountTool.php # get_unresolved_comments_count
|
||||
├── Resource/
|
||||
│ ├── SchemaResource.php # inventory://schema/entities
|
||||
│ ├── ModelTypesResource.php # inventory://model-types/{category}
|
||||
│ ├── RolesResource.php # inventory://roles
|
||||
│ └── StatsResource.php # inventory://stats
|
||||
└── Security/
|
||||
└── McpHeaderAuthenticator.php # Symfony Authenticator pour firewall MCP
|
||||
|
||||
docs/
|
||||
└── mcp/
|
||||
└── README.md # Guide utilisateur complet
|
||||
```
|
||||
|
||||
## 14. Documentation utilisateur (docs/mcp/README.md)
|
||||
|
||||
Le guide contiendra :
|
||||
|
||||
1. **Introduction** — Qu'est-ce que le MCP Inventory, à quoi ça sert, quels clients sont supportés
|
||||
2. **Prérequis** — Profil avec rôle suffisant, accès au tunnel, client MCP compatible
|
||||
3. **Installation & configuration par client** — Exemples copier-coller pour :
|
||||
- Claude Code (stdio via Docker)
|
||||
- Claude Desktop (HTTP via tunnel)
|
||||
- ChatGPT Desktop (HTTP via tunnel)
|
||||
- Codex (HTTP via tunnel)
|
||||
4. **Catalogue des tools** — Tableau complet avec nom, description, paramètres, rôle requis
|
||||
5. **Workflows guidés** — Comment créer une machine, un composant, une pièce, un produit (étape par étape avec exemples d'appels)
|
||||
6. **Resources disponibles** — URIs et contenu exposé
|
||||
7. **Rôles & permissions** — Quel rôle permet quelles actions
|
||||
8. **Format des erreurs** — Catégories et exemples
|
||||
9. **Limitations connues** — Upload documents non supporté via MCP
|
||||
10. **Troubleshooting** — Erreurs courantes (auth failed, tunnel down, rôle insuffisant, rate limited)
|
||||
|
||||
## 15. Sécurité
|
||||
|
||||
| Mesure | Détail |
|
||||
|---|---|
|
||||
| **Firewall Symfony** | `/_mcp` a son propre firewall avec `McpHeaderAuthenticator` — intégré au système de sécurité standard |
|
||||
| **Vérification rôle** | Chaque tool vérifie via `$security->isGranted()` avec hiérarchie des rôles |
|
||||
| **Audit trail** | `AbstractAuditSubscriber.resolveActorProfileId()` fonctionne nativement car `$security->getUser()` retourne le Profile authentifié |
|
||||
| **Rate limiting** | 5 tentatives d'auth échouées par minute par IP → rejet |
|
||||
| **Transport chiffré** | Le tunnel assure le chiffrement en transit pour les clients distants |
|
||||
| **Pas de secrets dans le code** | Credentials dans env vars (stdio) ou headers (HTTP), jamais en dur |
|
||||
| **Sessions MCP** | TTL 1h, stockage fichier, nettoyage automatique |
|
||||
| **CORS** | Non nécessaire — les clients MCP sont des apps natives (pas des navigateurs). Le tunnel termine la connexion côté serveur. À réévaluer si un client browser-based apparaît |
|
||||
|
||||
## 16. Backward Compatibility
|
||||
|
||||
Les tools MCP suivent une politique additive :
|
||||
- **Ajouts** : nouveaux tools, nouveaux paramètres optionnels → toujours OK
|
||||
- **Suppressions** : marquer un tool comme deprecated pendant 1 version avant suppression
|
||||
- **Breaking changes** : changer le type/nom d'un paramètre requis → bumper la version MCP
|
||||
|
||||
Le champ `version` dans la config MCP (lu depuis `VERSION`) signale les changements.
|
||||
|
||||
## 17. Dépendances à installer
|
||||
|
||||
```bash
|
||||
composer require symfony/mcp-bundle symfony/rate-limiter
|
||||
```
|
||||
|
||||
Le bundle tire `mcp/sdk` automatiquement.
|
||||
|
||||
## 18. Tests
|
||||
|
||||
Les tools MCP seront testés via :
|
||||
|
||||
- **Tests unitaires** : chaque tool testé avec des mocks de repositories, vérification des paramètres et des réponses
|
||||
- **Tests d'intégration** : appels MCP stdio via `docker exec ... php bin/console mcp:server` avec des fixtures
|
||||
- **Tests de sécurité** : vérification que les tools rejettent les appels sans auth, avec rôle insuffisant, et après rate limiting
|
||||
- Pattern : hériter de `AbstractApiTestCase` pour réutiliser les factories existantes (`createProfile()`, `createMachine()`, etc.)
|
||||
|
||||
## 19. Spike / PoC initial
|
||||
|
||||
Avant l'implémentation complète, une étape de validation :
|
||||
|
||||
1. Installer `symfony/mcp-bundle` dans le projet
|
||||
2. Créer un tool minimal (`get_dashboard_stats`) avec l'attribut `#[McpTool]`
|
||||
3. Tester le transport stdio : `docker exec -i php-inventory-apache php bin/console mcp:server`
|
||||
4. Tester le transport HTTP : appel POST sur `/_mcp`
|
||||
5. Valider que l'authenticator custom fonctionne avec le firewall
|
||||
6. Confirmer que `$security->getUser()` retourne le bon Profile dans un tool
|
||||
|
||||
Si le PoC révèle des incompatibilités avec l'API du bundle, adapter le design avant de continuer.
|
||||
@@ -1,303 +0,0 @@
|
||||
# Entity Versioning — Design Spec
|
||||
|
||||
**Date :** 2026-03-25
|
||||
**Entites concernees :** Machine, Composant, Piece, Produit
|
||||
**Approche :** Extension du systeme AuditLog existant
|
||||
|
||||
---
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre de consulter l'historique des versions numerotees (v1, v2, v3...) des entites principales et de restaurer n'importe quelle version anterieure, afin de ne jamais perdre de donnees.
|
||||
|
||||
---
|
||||
|
||||
## Regles metier
|
||||
|
||||
### Creation de version
|
||||
- Chaque `create` ou `update` sur une entite incremente automatiquement le compteur `version` de l'entite
|
||||
- Le numero de version est enregistre dans l'AuditLog correspondant (nouvelle colonne `version`)
|
||||
|
||||
### 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`
|
||||
|
||||
### Controle de squelette (Composant, Piece, Produit uniquement)
|
||||
- Avant restauration, on compare le ModelType actuel avec celui du snapshot
|
||||
- **Meme squelette (ModelType)** : restore complet — champs de base + slots + custom fields
|
||||
- **Squelette different** : restore partiel — uniquement les champs de base (nom, description, reference, constructeurs, prix)
|
||||
|
||||
### Controle d'integrite
|
||||
- Avant restauration, on verifie que toutes les entites liees dans le snapshot existent encore en base :
|
||||
- **Composant** : pieces selectionnees dans les slots, produits, sous-composants, constructeurs
|
||||
- **Piece** : produits selectionnes dans les slots, constructeurs
|
||||
- **Produit** : constructeurs
|
||||
- **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)
|
||||
|
||||
### Machines
|
||||
- Pas de controle de squelette (pas de ModelType) : restauration toujours complete
|
||||
- Controle d'integrite sur le site et les liens machine
|
||||
|
||||
### Permissions
|
||||
- Consulter les versions : `ROLE_VIEWER`
|
||||
- Restaurer une version : `ROLE_GESTIONNAIRE` et au-dessus
|
||||
|
||||
---
|
||||
|
||||
## Modifications backend
|
||||
|
||||
### 1. Colonne `version` sur AuditLog
|
||||
|
||||
```sql
|
||||
ALTER TABLE audit_logs ADD COLUMN version INT DEFAULT NULL;
|
||||
```
|
||||
|
||||
Nullable car les AuditLogs existants n'ont pas de version.
|
||||
|
||||
### 2. Colonne `version` sur Machine
|
||||
|
||||
```sql
|
||||
ALTER TABLE machine ADD COLUMN version INT NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
Les entites Composant, Piece, Produit ont deja cette colonne.
|
||||
|
||||
### 3. Enrichissement des snapshots
|
||||
|
||||
Les Audit Subscribers doivent inclure dans le `snapshot` :
|
||||
|
||||
**Composant :**
|
||||
```json
|
||||
{
|
||||
"id": "cl...",
|
||||
"name": "...",
|
||||
"reference": "...",
|
||||
"description": "...",
|
||||
"prix": 100.00,
|
||||
"typeComposant": { "id": "cl...", "name": "...", "code": "..." },
|
||||
"product": { "id": "cl...", "name": "..." },
|
||||
"constructeurs": [{ "id": "cl...", "name": "..." }],
|
||||
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
|
||||
"pieceSlots": [
|
||||
{ "id": "cl...", "typePieceId": "cl...", "selectedPieceId": "cl...", "quantity": 1, "position": 0 }
|
||||
],
|
||||
"subcomponentSlots": [
|
||||
{ "id": "cl...", "alias": "...", "familyCode": "...", "typeComposantId": "cl...", "selectedComposantId": "cl...", "position": 0 }
|
||||
],
|
||||
"productSlots": [
|
||||
{ "id": "cl...", "typeProductId": "cl...", "selectedProductId": "cl...", "familyCode": "...", "position": 0 }
|
||||
],
|
||||
"version": 3
|
||||
}
|
||||
```
|
||||
|
||||
**Piece :**
|
||||
```json
|
||||
{
|
||||
"id": "cl...",
|
||||
"name": "...",
|
||||
"reference": "...",
|
||||
"description": "...",
|
||||
"prix": 50.00,
|
||||
"typePiece": { "id": "cl...", "name": "...", "code": "..." },
|
||||
"product": { "id": "cl...", "name": "..." },
|
||||
"constructeurs": [{ "id": "cl...", "name": "..." }],
|
||||
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
|
||||
"productSlots": [
|
||||
{ "id": "cl...", "typeProductId": "cl...", "selectedProductId": "cl...", "familyCode": "...", "position": 0 }
|
||||
],
|
||||
"version": 2
|
||||
}
|
||||
```
|
||||
|
||||
**Produit :**
|
||||
```json
|
||||
{
|
||||
"id": "cl...",
|
||||
"name": "...",
|
||||
"reference": "...",
|
||||
"supplierPrice": 25.00,
|
||||
"typeProduct": { "id": "cl...", "name": "...", "code": "..." },
|
||||
"constructeurs": [{ "id": "cl...", "name": "..." }],
|
||||
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Machine :**
|
||||
```json
|
||||
{
|
||||
"id": "cl...",
|
||||
"name": "...",
|
||||
"reference": "...",
|
||||
"description": "...",
|
||||
"site": { "id": "cl...", "name": "..." },
|
||||
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
|
||||
"version": 4
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Incrementation automatique de la version
|
||||
|
||||
Dans chaque Audit Subscriber, a chaque `create`/`update` :
|
||||
1. Appeler `$entity->incrementVersion()`
|
||||
2. Ecrire `$auditLog->setVersion($entity->getVersion())`
|
||||
|
||||
Pour Machine, ajouter la methode `incrementVersion()` et la propriete `version` a l'entite.
|
||||
|
||||
### 5. Nouveaux endpoints — `EntityVersionController`
|
||||
|
||||
| Methode | Route | Description | Role |
|
||||
|---------|-------|-------------|------|
|
||||
| GET | `/api/{entity}/{id}/versions` | Liste des versions | ROLE_VIEWER |
|
||||
| GET | `/api/{entity}/{id}/versions/{version}/preview` | Preview + controles avant restore | ROLE_GESTIONNAIRE |
|
||||
| POST | `/api/{entity}/{id}/versions/{version}/restore` | Execute la restauration | ROLE_GESTIONNAIRE |
|
||||
|
||||
`{entity}` = `machines`, `composants`, `pieces`, `products`
|
||||
|
||||
**GET versions — Response :**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"version": 3,
|
||||
"action": "update",
|
||||
"createdAt": "2026-03-25T14:30:00+00:00",
|
||||
"actor": { "id": "cl...", "label": "Jean Dupont" },
|
||||
"diff": { "name": { "from": "Ancien", "to": "Nouveau" } }
|
||||
}
|
||||
],
|
||||
"total": 3
|
||||
}
|
||||
```
|
||||
|
||||
**GET preview — Response :**
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"restoreMode": "full",
|
||||
"diff": {
|
||||
"name": { "current": "Nouveau", "restored": "Ancien" },
|
||||
"reference": { "current": "REF-002", "restored": "REF-001" }
|
||||
},
|
||||
"warnings": [
|
||||
{
|
||||
"field": "pieceSlots[0].selectedPieceId",
|
||||
"message": "La piece 'Roulement XY' (cl...) n'existe plus. Le slot sera restaure vide.",
|
||||
"missingEntityId": "cl...",
|
||||
"missingEntityName": "Roulement XY"
|
||||
}
|
||||
],
|
||||
"snapshot": { }
|
||||
}
|
||||
```
|
||||
|
||||
`restoreMode` : `"full"` (meme squelette) ou `"partial"` (squelette different, champs de base uniquement).
|
||||
|
||||
**POST restore — Response :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"newVersion": 6,
|
||||
"restoredFromVersion": 2,
|
||||
"restoreMode": "full",
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Service `EntityVersionService`
|
||||
|
||||
Service centralise pour la logique de versioning :
|
||||
|
||||
- `getVersions(string $entityType, string $entityId): array` — liste des versions depuis AuditLog
|
||||
- `getRestorePreview(string $entityType, string $entityId, int $version): array` — controles + diff
|
||||
- `restore(string $entityType, string $entityId, int $version): array` — execution du restore
|
||||
|
||||
Methodes internes :
|
||||
- `checkSkeletonCompatibility(object $entity, array $snapshot): string` — retourne `"full"` ou `"partial"`
|
||||
- `checkIntegrity(string $entityType, array $snapshot): array` — retourne les warnings
|
||||
- `applyRestore(object $entity, array $snapshot, string $mode): void` — applique les changements
|
||||
|
||||
---
|
||||
|
||||
## Modifications frontend
|
||||
|
||||
### 1. Composant `EntityVersionList.vue`
|
||||
|
||||
Composant reutilisable affiche dans un onglet "Versions" sur les pages de detail.
|
||||
|
||||
**Props :**
|
||||
- `entityType: 'machines' | 'composants' | 'pieces' | 'products'`
|
||||
- `entityId: string`
|
||||
|
||||
**Affichage :**
|
||||
- Tableau : version, date, auteur, action, diff resume
|
||||
- Badge "Actuelle" sur la version la plus recente
|
||||
- Bouton "Restaurer" sur chaque ligne (sauf version actuelle), visible uniquement pour ROLE_GESTIONNAIRE+
|
||||
|
||||
### 2. Composant `VersionRestoreModal.vue`
|
||||
|
||||
Modal de confirmation avec preview.
|
||||
|
||||
**Props :**
|
||||
- `entityType`, `entityId`, `version` (cible)
|
||||
- `previewData` (resultat du GET preview)
|
||||
|
||||
**Affichage :**
|
||||
- Indicateur de mode : "Restauration complete" ou "Restauration partielle"
|
||||
- Diff visuel : champs qui changent (valeur actuelle -> valeur restauree)
|
||||
- Warnings en alerte orange pour les entites manquantes
|
||||
- Boutons "Confirmer la restauration" / "Annuler"
|
||||
|
||||
### 3. Composable `useEntityVersions.ts`
|
||||
|
||||
```typescript
|
||||
interface Deps {
|
||||
entityType: MaybeRef<string>
|
||||
entityId: MaybeRef<string>
|
||||
}
|
||||
|
||||
export function useEntityVersions(deps: Deps) {
|
||||
// fetchVersions() — GET /api/{entity}/{id}/versions
|
||||
// fetchPreview(version) — GET /api/{entity}/{id}/versions/{version}/preview
|
||||
// restore(version) — POST /api/{entity}/{id}/versions/{version}/restore
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Integration dans les pages de detail
|
||||
|
||||
Ajouter un onglet "Versions" dans les pages :
|
||||
- `pages/machines/[id].vue`
|
||||
- `pages/composants/[id].vue`
|
||||
- `pages/pieces/[id].vue`
|
||||
- `pages/products/[id].vue`
|
||||
|
||||
L'onglet affiche `EntityVersionList` qui gere l'ouverture de `VersionRestoreModal`.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
Une seule migration PostgreSQL :
|
||||
|
||||
```sql
|
||||
-- Colonne version sur audit_logs
|
||||
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS version INT DEFAULT NULL;
|
||||
|
||||
-- Colonne version sur machine
|
||||
ALTER TABLE machine ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
|
||||
-- Index pour requetes par version
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entity_type, entity_id, version);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ce qui ne change PAS
|
||||
|
||||
- L'onglet/page d'historique existant (`EntityHistoryController`) reste inchange
|
||||
- Les AuditLogs existants (sans version) continuent de fonctionner
|
||||
- Le mecanisme d'audit automatique via les Subscribers reste identique, juste enrichi
|
||||
- Les documents ne sont pas versionnes (hors scope)
|
||||
@@ -1,117 +0,0 @@
|
||||
# Machine : Bouton Save Unique + Versioning des Liens
|
||||
|
||||
**Date :** 2026-03-26
|
||||
**Statut :** Approuvé
|
||||
|
||||
## Contexte
|
||||
|
||||
La page machine utilise actuellement un auto-save au blur pour chaque champ (info, custom fields, constructeurs). Les pages composant/pièce/produit utilisent un bouton unique "Enregistrer les modifications" en bas du formulaire. L'objectif est d'aligner la page machine sur ce pattern.
|
||||
|
||||
De plus, les ajouts/suppressions de liens composant/pièce/produit sur une machine ne sont pas tracés dans le versioning. Ils doivent l'être.
|
||||
|
||||
## Volet 1 : Bouton Save Unique
|
||||
|
||||
### Comportement cible
|
||||
|
||||
- En mode édition, tous les champs (info machine, custom field values, custom field definitions, constructeurs) sont modifiés localement sans appel API.
|
||||
- Un bouton "Enregistrer les modifications" en bas du formulaire sauvegarde tout d'un coup.
|
||||
- Un bouton "Annuler" réinitialise l'état local et sort du mode édition.
|
||||
- Les documents restent en upload/suppression immédiate (inchangé).
|
||||
- Les ajouts/suppressions de liens composant/pièce/produit restent immédiats via modales (inchangé).
|
||||
|
||||
### Changements frontend
|
||||
|
||||
#### MachineInfoCard.vue
|
||||
- Supprimer les `@blur` → `$emit('blur-field')` sur les inputs (nom, référence)
|
||||
- Supprimer le `@change` qui émet `blur-field` sur le select site
|
||||
- Supprimer les `@blur` → `$emit('update-custom-field', field)` sur tous les champs custom
|
||||
- Conserver `@input` / `@update:*` / `set-custom-field-value` pour la mise à jour de l'état local
|
||||
- Le `MachineCustomFieldDefEditor` perd son bouton save propre : l'état est collecté au submit global
|
||||
|
||||
#### machine/[id].vue
|
||||
- Supprimer le handler `@blur-field`
|
||||
- Supprimer le handler `@update-custom-field`
|
||||
- `@update:constructeur-ids` met à jour l'état local sans save
|
||||
- Ajouter le bloc boutons en bas (pattern identique à component/[id]/index.vue) :
|
||||
- "Annuler" (btn-ghost) → `cancelEdition()` : réinitialise depuis `machine.value` + sort du mode édition
|
||||
- "Enregistrer les modifications" (btn-primary, disabled si `!canSubmit`) → `submitEdition()`
|
||||
|
||||
#### useMachineDetailData.ts
|
||||
- Exposer `saving` ref
|
||||
- Exposer `submitEdition()` :
|
||||
1. `updateMachineInfo()` — PATCH machine (nom, ref, site, constructeurs)
|
||||
2. Batch save custom field values (tous les `visibleMachineCustomFields` avec valeur)
|
||||
3. Save custom field definitions si modifiées (`fieldDefs.saveDefinitions()`)
|
||||
4. `loadMachineData()` pour recharger
|
||||
5. Sortie du mode édition + toast succès
|
||||
- Exposer `cancelEdition()` :
|
||||
1. `initMachineFields()` — réinitialise nom, ref, site, constructeurs depuis `machine.value`
|
||||
2. `syncMachineCustomFields()` — réinitialise les custom fields
|
||||
3. Sort du mode édition
|
||||
|
||||
#### useMachineDetailUpdates.ts
|
||||
- `handleMachineConstructeurChange` ne déclenche plus `updateMachineInfo()`, met juste à jour le ref local
|
||||
|
||||
#### useMachineDetailCustomFields.ts
|
||||
- `updateMachineCustomField` n'est plus appelé au blur — sera appelé en batch par `submitEdition()`
|
||||
- Ajouter méthode `saveAllMachineCustomFields()` qui itère sur les champs visibles et sauvegarde ceux avec valeur
|
||||
|
||||
### Validation (`canSubmit`)
|
||||
- Machine existe
|
||||
- Nom non vide
|
||||
- Pas en cours de sauvegarde (`!saving.value`)
|
||||
- `canEdit` est true
|
||||
|
||||
## Volet 2 : Versioning des Liens Machine
|
||||
|
||||
### Comportement cible
|
||||
|
||||
Quand un composant, pièce ou produit est ajouté ou supprimé d'une machine, cela doit :
|
||||
1. Incrémenter la `version` de la Machine
|
||||
2. Créer une entrée `AuditLog` avec diff et snapshot
|
||||
|
||||
### Changements backend
|
||||
|
||||
#### MachineAuditSubscriber — enrichir le snapshot
|
||||
Ajouter au snapshot machine les liens :
|
||||
```php
|
||||
'componentLinks' => array_map(fn($link) => [
|
||||
'id' => $link->getId(),
|
||||
'composantId' => $link->getComposant()->getId(),
|
||||
'composantName' => $link->getComposant()->getName(),
|
||||
], $entity->getComponentLinks()->toArray()),
|
||||
'pieceLinks' => [...],
|
||||
'productLinks' => [...],
|
||||
```
|
||||
|
||||
#### Nouveau subscriber ou service : MachineLinkAuditService
|
||||
Écouter les événements Doctrine `postPersist` et `postRemove` sur les 3 entités link.
|
||||
Quand un lien est créé/supprimé :
|
||||
1. Récupérer la Machine parente
|
||||
2. Incrémenter `$machine->incrementVersion()`
|
||||
3. Créer un `AuditLog` :
|
||||
- `entityType: 'machine'`
|
||||
- `entityId: $machine->getId()`
|
||||
- `action: 'update'`
|
||||
- `diff: { addedComponent: {id, name} }` ou `{ removedPiece: {id, name} }`
|
||||
- `snapshot:` snapshot complet de la machine (avec liens mis à jour)
|
||||
- `version:` nouvelle version
|
||||
|
||||
### Labels pour le diff (frontend)
|
||||
Ajouter au `historyFieldLabels` de la page machine :
|
||||
```js
|
||||
addedComponent: 'Composant ajouté',
|
||||
removedComponent: 'Composant supprimé',
|
||||
addedPiece: 'Pièce ajoutée',
|
||||
removedPiece: 'Pièce supprimée',
|
||||
addedProduct: 'Produit ajouté',
|
||||
removedProduct: 'Produit supprimé',
|
||||
```
|
||||
|
||||
## Ce qui ne change PAS
|
||||
|
||||
- Upload/suppression de documents (immédiat)
|
||||
- Pattern read/edit toggle dans le header
|
||||
- L'affichage des sections composants/pièces/produits
|
||||
- Les modales d'ajout/suppression de liens (restent immédiates)
|
||||
- Le versioning des autres entités (composant, pièce, produit)
|
||||
@@ -1,60 +0,0 @@
|
||||
# Spec : Formula Builder interactif pour la référence auto
|
||||
|
||||
**Date** : 2026-03-31
|
||||
**Scope** : Frontend uniquement (pas de changement backend)
|
||||
**Fichier impacté** : `Inventory_frontend/app/components/model-types/ModelTypeForm.vue`
|
||||
|
||||
## Problème
|
||||
|
||||
L'utilisateur doit taper manuellement les noms exacts des custom fields dans la formule (`{serie}{diametre}{type}`) et re-lister les champs requis séparés par des virgules. C'est sujet aux erreurs de typo et peu ergonomique.
|
||||
|
||||
## Solution
|
||||
|
||||
Remplacer la section "Génération de référence automatique" du `ModelTypeForm` par un formula builder interactif.
|
||||
|
||||
### Composants UI
|
||||
|
||||
#### 1. Chips de champs disponibles
|
||||
|
||||
- Afficher une rangée de boutons-chips avec les noms des custom fields définis dans `pieceStructure.customFields`
|
||||
- Cliquer sur un chip insère `{nom_du_champ}` dans l'input formule à la position du curseur
|
||||
- Si `pieceStructure.customFields` est vide, afficher un message "Aucun champ personnalisé défini"
|
||||
|
||||
#### 2. Input formule
|
||||
|
||||
- Input texte classique (comme aujourd'hui) mais avec les chips comme aide à la saisie
|
||||
- L'utilisateur peut aussi taper du texte libre (séparateurs `-`, `/`, préfixes `SNU `, etc.)
|
||||
- Le format stocké reste `{nom_du_champ}` — aucun changement de format backend
|
||||
|
||||
#### 3. Suppression du champ "Champs requis"
|
||||
|
||||
- Le champ `requiredFieldsForReference` est calculé automatiquement au submit en extrayant tous les `{...}` de la formule
|
||||
- Suppression de l'input "Champs requis" et de la variable `requiredFieldsInput`
|
||||
- La logique : tous les champs présents dans la formule sont requis. Si un champ n'a pas de valeur → pas de référence générée
|
||||
|
||||
#### 4. Aperçu live
|
||||
|
||||
- Conserver l'aperçu existant mais l'améliorer : remplacer les placeholders par des valeurs d'exemple en majuscules
|
||||
- Exemples par type de champ : `text` → `VALEUR`, `number` → `123`, `select` → `OPTION`, `boolean` → `OUI`, `date` → `2026-01-01`
|
||||
|
||||
### Comportement
|
||||
|
||||
- **Insert au curseur** : quand l'utilisateur clique un chip, le placeholder est inséré à `selectionStart` de l'input, pas à la fin
|
||||
- **Formule vide** : si la formule est vide, pas de référence auto (comportement actuel conservé)
|
||||
- **Readonly** : les chips sont désactivés en mode readonly (comme l'input)
|
||||
- **Pas de custom fields** : si aucun champ n'est défini dans la structure, la section reste visible mais les chips sont remplacés par un message informatif. L'utilisateur peut quand même taper une formule manuellement (cas edge)
|
||||
|
||||
### Format de sortie (inchangé)
|
||||
|
||||
```typescript
|
||||
{
|
||||
referenceFormula: "SNU {serie}-{diametre}/{type}" | null,
|
||||
requiredFieldsForReference: ["serie", "diametre", "type"] | null // auto-calculé
|
||||
}
|
||||
```
|
||||
|
||||
### Pas de changement
|
||||
|
||||
- Backend (`ReferenceAutoGenerator`, `ReferenceAutoSubscriber`, entités) : aucun changement
|
||||
- Format de stockage de la formule : identique (`{placeholder}` strings)
|
||||
- API : identique
|
||||
@@ -105,114 +105,6 @@ INSERT INTO public.model_types (id, name, code, category, notes, createdat, upda
|
||||
INSERT INTO public.model_types (id, name, code, category, notes, createdat, updatedat, description, componentskeleton, pieceskeleton, productskeleton) VALUES ('cle48e33ef67853069badfc5f0', 'testcor', 'testcor', 'COMPONENT', 'nnd', '2026-01-25 11:27:47', '2026-01-25 11:27:47', 'nnd', '{"pieces": [{"typePieceId": "cmgs1sco0002k47056yq8eyfq", "typePieceLabel": "Bavette"}], "products": [{"familyCode": "lubrifiant", "typeProductId": "cmhn9zrm5000247s8ds3bmpaf"}], "customFields": [{"key": "uu", "value": {"type": "text", "required": false}}], "subcomponents": [{"alias": "Trémie d''alimentation", "familyCode": "Tremie", "typeComposantId": "cmgs0htn5002d4705tyxxiqb2"}]}', NULL, NULL);
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: skeleton_piece_requirements; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0939a6d5cf1e2f42ea338ed9', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrnu6zc000f470565mc8hha', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc8a5a3ffde2e012115f4a4a6', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrnxlx5000g47059oyj4yuw', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl19977b37375e768874a9bc21', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrohigo000z4705q8yvpih0', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('claa3bd610232237f37b5305f5', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrou6670011470586ipgylm', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl8cc6f7b1c0d2356200710cff', 'cmgrp0u0h00124705xxyt5fqu', 'cmgroij2f00104705t6y33enk', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl6ab668cf6c44971a6e8c15c1', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrnu6zc000f470565mc8hha', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl9d6c1d30a8f1f37d6f5919ca', 'cmgrzrlcc001t47054emo6cfb', 'cmgrzuwkj001u47057u8hej9u', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl89cc18036bb3a12d4d0cd427', 'cmgrzrlcc001t47054emo6cfb', 'cmgrzuwkj001u47057u8hej9u', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl6f6e8f47c6a731bcd9288bba', 'cmgrzrlcc001t47054emo6cfb', 'cmgrzuwkj001u47057u8hej9u', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc7fe6f18be36b9cd60089a28', 'cmgrzrlcc001t47054emo6cfb', 'cmgroij2f00104705t6y33enk', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl15ff333888d9f160a80a04e5', 'cmgrzrlcc001t47054emo6cfb', 'cmgroij2f00104705t6y33enk', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl82c521ac9e0cae5ba31d2880', 'cmgs0h5ze002c4705szh85svi', 'cmgroij2f00104705t6y33enk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl3eef5c0837bdf0aed9da51b3', 'cmgs0h5ze002c4705szh85svi', 'cmgs0kd5o002f47053b7n8tw6', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl2533d3d3f215df0c180b2c4a', 'cmgs0h5ze002c4705szh85svi', 'cmgrzuwkj001u47057u8hej9u', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl3ff44ebbbd85d8750328183c', 'cmgs0h5ze002c4705szh85svi', 'cmgroij2f00104705t6y33enk', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbc503ec073758ef2f4cd2a4f', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs13jjp002h4705rjqzz5lh', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl80631b5e0a1ab10d4af65f6f', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs1s4pv002j470567o60oqe', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc11a5ad82b02a76eca599117', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs1sco0002k47056yq8eyfq', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbd335edf794794fba25546a6', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs1sco0002k47056yq8eyfq', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl218404db04ffcd7c8e200f0a', 'cmgs0i4je002e4705ndrhe26e', 'cmgroij2f00104705t6y33enk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl31265ec0dcf2fc4e252c2181', 'cmgs0i4je002e4705ndrhe26e', 'cmgrzuwkj001u47057u8hej9u', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl107b717d92362218aa387539', 'cmgs0i4je002e4705ndrhe26e', 'cmgujpyjf002q4705j6hv1nkk', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5ee1bef2ef4b88f83a680526', 'cmgs0i4je002e4705ndrhe26e', 'cmgs1s4pv002j470567o60oqe', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl49eb01162df881cc87bc281f', 'cmgs0i4je002e4705ndrhe26e', 'cmgrnxlx5000g47059oyj4yuw', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl1369592934d41016018dbbbf', 'cmgs0i4je002e4705ndrhe26e', 'cmgujpyjf002q4705j6hv1nkk', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cld7bf0d8c46d0037cbb16cd69', 'cmgs0i4je002e4705ndrhe26e', 'cmgs1s4pv002j470567o60oqe', 6, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl75660a7f65559f164df06538', 'cmgs0i4je002e4705ndrhe26e', 'cm_motoreducteur_frein_01', 7, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl859ade5b78b6e833d1eb64f8', 'cmgs0i4je002e4705ndrhe26e', 'cm_motoreducteur_frein_01', 8, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cld23bbbeff05e238c6d189da0', 'cmgs0i4je002e4705ndrhe26e', 'cmgukvztv002s4705kqvqjtvg', 9, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5e7a3eeeb8a6283ce828edf4', 'cmgs0i4je002e4705ndrhe26e', 'cmgukvztv002s4705kqvqjtvg', 10, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl52149961cdaed3f614dd87c1', 'cmgs0i4je002e4705ndrhe26e', 'cmgukxw26002t4705qz4ul929', 11, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle1d646b02f65c7a5fd276ecf', 'cmgs0i4je002e4705ndrhe26e', 'cmgum1ih0000347ff7bsldmnv', 12, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl07b21cfdc0f2e9d8c11c79ed', 'cmgs0i4je002e4705ndrhe26e', 'cmgulzr7b000247ffpr2vsput', 13, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clb726dc1c94d371d8860ef92d', 'cmgs0i4je002e4705ndrhe26e', 'cmgum1wsl000447ffa109dtag', 14, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle5605528c6fbb3de1f72b8d8', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbd602414e77f0309cfeb92c6', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl604a3fa207118914f58f5ffb', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl016fd78557d8ac5ce883208b', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl29eeddd05188eec4e782aca3', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl7e7a30ba2126ecfed7f7501c', 'cmgujizjf002o4705kfdea5yw', 'cmgz0qu29004a47ffw1bmjr75', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clb893d014b683a1851425436e', 'cmgujizjf002o4705kfdea5yw', 'cmgz0qu29004a47ffw1bmjr75', 6, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0b0094ca0a92321f5fa2131c', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 7, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clea857995e00ebb10548095c2', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 8, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl1341b6e156f059e852f719e2', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 9, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle18462b08b605f31fec6cc14', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 10, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clf3f7ad1fcb6eb3937b96bb8e', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 11, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clfdd9dfcc06816afd8bf8503a', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 12, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl4af9588815f4a5a21cf4f979', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 13, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0e0d7053e7a0518b50f00481', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 14, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl4f64c44ed0ffc92a923edccc', 'cmgujizjf002o4705kfdea5yw', 'cmgz17bpz006t47ff58i3j1e1', 15, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clfbdc4656e36b47a8e6a5df68', 'cmgujizjf002o4705kfdea5yw', 'cmgz17bpz006t47ff58i3j1e1', 16, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl4860204e0478d9f4eb806b85', 'cmgujjmpo002p470523lbfqmp', 'cmgum1wsl000447ffa109dtag', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5e3367975bfef877130e41af', 'cmgujjmpo002p470523lbfqmp', 'cmgum1wsl000447ffa109dtag', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0c004dd01a3f4a978c8260c9', 'cmh0kmyh1000847s5ciu9agzo', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl96109b5962d80d95d279fd96', 'cmh0kmyh1000847s5ciu9agzo', 'cmgytewe0002447ffup09bscr', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5a6e124fa8ec83ae5ef21685', 'cmh20wuye001o47s5auvmq7s8', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clba2c60a3a4aab6a82eccad61', 'cmh20x49u001q47s5l9ahnvms', 'cmgs1sco0002k47056yq8eyfq', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbf346fb41ad28af45eb5e7aa', 'cmh20x49u001q47s5l9ahnvms', 'cmgs1sco0002k47056yq8eyfq', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl50ec69f08be67181d3cd5dc3', 'cmh20yrgb001v47s54uxvi6km', 'cmgs1sco0002k47056yq8eyfq', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl84cab14314d4932ef52e9d47', 'cmh20yrgb001v47s54uxvi6km', 'cmgujpyjf002q4705j6hv1nkk', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle258e4bb4ae12d90aeb49ae7', 'cmh20yrgb001v47s54uxvi6km', 'cmgujpyjf002q4705j6hv1nkk', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl697c36fe5e29f3cfdb209537', 'cmh20yrgb001v47s54uxvi6km', 'cmgujpyjf002q4705j6hv1nkk', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl112cc747d5ac2c68716bd6d4', 'cmh23pwbo002947s5ide6zx7g', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl62fe319d24840a318d24b682', 'cmkqpdc7a001o1eq6iwqvi3jk', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0051b0a075490c406f21213e', 'cmkqq45yq00251eq6k1z0x7kt', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc578afcad0f42694f05408f0', 'cmkqqdogo002l1eq6vy26j33g', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0a3a926192f808233c894c43', 'cmkqqdogo002l1eq6vy26j33g', 'cmh9bykt8001j47v7g0oej5dw', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl8a927a5de52db535ba8f062a', 'cmkqqdogo002l1eq6vy26j33g', 'cmhabzypq003h47v7jyjjxst1', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl8f60c43f25ec1c96560209b0', 'cmkqqdogo002l1eq6vy26j33g', 'cmgz0zs4m006447ffq5b20ch3', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5c71200fd7efe31c9764e5d8', 'cmkqqdogo002l1eq6vy26j33g', 'cmkdqtcpv001r1e2wptehmkxi', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clf6147721769314755e9ff6b8', 'cmkqqdogo002l1eq6vy26j33g', 'cmhbve5h30016475utwgpa32k', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: skeleton_product_requirements; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.skeleton_product_requirements (id, modeltypeid, typeproductid, familycode, position, createdat, updatedat) VALUES ('cle44c43c22390db28f97b1c17', 'cmkqqdogo002l1eq6vy26j33g', 'cmhn9ze17000147s81dlr4i3v', 'graisse', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: skeleton_subcomponent_requirements; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl9806dc8b4b42ea0c84b6832b', 'cmgs0i4je002e4705ndrhe26e', NULL, 'Kit', 'kit', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl67fc8aef811eb876a1cde9ed', 'cmgs0i4je002e4705ndrhe26e', NULL, 'Kit', 'kit', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl6cff73d32c4f16b5d4e909ab', 'cmgujjmpo002p470523lbfqmp', NULL, 'Kit', 'kit', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl7f83953a8f78f951f3a3628c', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cle79fbc1bedd4123874a35377', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Kit', 'kit', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl23c07d5454e5f2bc6d7703fa', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Kit', 'kit', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cla3530a303f28a12d97883d82', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clefbd652fc2561a180f38dd89', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Tête convoyeur à bande', 'tcb', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl70978104735f0e8addcbb494', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Pied convoyeur à bande', 'PCB', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl63db65c869d07fd36d0d3422', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Elément intermédiaire & coude', 'EIC', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clac7e30f33f20287f76197c9e', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Trémie d''alimentation', 'Tremie', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl081ba5565b6e9320be0c88b0', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Chariot Déverseur', 'Chariot', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl164ed242e2148aac3b30c391', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Commande moteur', 'commande-moteur', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl7aa47c4c4e4070ad1925ee27', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Déport de bande', 'deport-de-bande-et-controleur-de-rotation', 6, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl7c497e23a2deb7e85a2c2abe', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 7, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl4a4fc72af5a46c4d369f535e', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 8, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clce09e33e0f970116c87057c7', 'cmh0kmyh1000847s5ciu9agzo', 'cmh20z6g4001x47s5b5hturac', 'Paliers', 'paliers', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clb5e1e758aaed9688a4e7db60', 'cmh0kmyh1000847s5ciu9agzo', 'cmh4x8m4k000047nko0vwavbg', 'Roulement', 'roulement', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: products; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
@@ -286,111 +178,6 @@ INSERT INTO public.composants (id, name, reference, prix, createdat, updatedat,
|
||||
INSERT INTO public.composants (id, name, reference, prix, createdat, updatedat, typecomposantid, structure, productid) VALUES ('cl9b1583768c7c9fe6cfe93a11', 'testcor', 'll', 3.00, '2026-01-25 11:28:27', '2026-01-25 11:28:27', 'cle48e33ef67853069badfc5f0', '{"path":"root","definition":[],"pieces":[{"path":"root:piece-0","definition":{"typePieceId":"cmgs1sco0002k47056yq8eyfq","typePieceLabel":"Bavette"},"selectedPieceId":"cmgs1tfza002m4705mbl0kwok"}],"products":[{"path":"root:product-0","definition":{"typeProductId":"cmhn9zrm5000247s8ds3bmpaf","familyCode":"lubrifiant"},"selectedProductId":"cmkp97us6007k1e2ws0ogux1m"}],"subcomponents":[{"path":"root:sub-0","definition":{"alias":"Tr\u00e9mie d''alimentation","typeComposantId":"cmgs0htn5002d4705tyxxiqb2","familyCode":"Tremie"},"selectedComponentId":"cmgz5h2s0009v47ff6x26cqry"}]}', 'cmkp97us6007k1e2ws0ogux1m');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: composant_piece_slots; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clcb580f2bc13c2f6ffefdfe47', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrnu6zc000f470565mc8hha', 'cmgrnzbku000h4705qrj5eujb', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cleac90912bbe1571ec196c3c9', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrohigo000z4705q8yvpih0', 'cmgrp2sju00144705n8etw7im', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl7b2de7e0f607306317eb6e69', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrou6670011470586ipgylm', 'cmgrp1ry9001347052qn8q2yo', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8a1a4da80556a964e7267773', 'cmgz53uvt009s47ff9v0uklr6', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl51a23d93f8c8fbf0555d8d4e', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrnu6zc000f470565mc8hha', 'cmgz516t7009n47fft3nfyt34', 1, 5, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb7caab438a04ccdbe72c7662', 'cmgz5ef4h009t47ffmxveesp0', 'cmgrzuwkj001u47057u8hej9u', 'cmgs07df2001w4705ry79yvbo', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4e710917a9cc405aea45eae4', 'cmgz5ef4h009t47ffmxveesp0', 'cmgrzuwkj001u47057u8hej9u', 'cmgrzvdmo001v47050tvf2z88', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl0b03faf21c00093950c06434', 'cmgz5ef4h009t47ffmxveesp0', 'cmgrzuwkj001u47057u8hej9u', 'cmgs08kjb00234705wc5tytxg', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl2151fb8b1d31e27aeea1ba63', 'cmgz5ef4h009t47ffmxveesp0', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl1fb766a77cd4c06b0dd6f68c', 'cmgz5ef4h009t47ffmxveesp0', 'cmgroij2f00104705t6y33enk', 'cmgs0bive00274705zjmiuwzo', 1, 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clbb1e23854540497ce8a0cc04', 'cmgz5fsvz009u47ffkrardb1u', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl3410d53157d1426a7c0fee70', 'cmgz5fsvz009u47ffkrardb1u', 'cmgs0kd5o002f47053b7n8tw6', 'cmgs0nyk7002g4705rteyvw7x', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6a3fbc2af5b82193cb7eb86c', 'cmgz5fsvz009u47ffkrardb1u', 'cmgrzuwkj001u47057u8hej9u', 'cmgrzvdmo001v47050tvf2z88', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle60fa4fef139b2e86fc5bf47', 'cmgz5fsvz009u47ffkrardb1u', 'cmgroij2f00104705t6y33enk', 'cmgs0bive00274705zjmiuwzo', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('claf42f4acd9a024263ffb60c0', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs13jjp002h4705rjqzz5lh', 'cmgs14c7a002i4705t1w4qdfx', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl44cc1edd9d00de1a675810d6', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs1s4pv002j470567o60oqe', 'cmgs1swl0002l4705gpyg1yyn', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl0b8eb4a8defab5cde3bccd3d', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs1sco0002k47056yq8eyfq', 'cmgs1tfza002m4705mbl0kwok', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl86e3c1391165cd65555856c1', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs1sco0002k47056yq8eyfq', 'cmgs1tvrs002n4705gpym7vel', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clbc122534f74362e26882cfd1', 'cmgz79ivv009x47ffeh6of72i', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla3b28a687250b61e1344dcc3', 'cmgz79ivv009x47ffeh6of72i', 'cmgrzuwkj001u47057u8hej9u', 'cmgum5zm0000547ffzg8ofiqr', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8e42ccab069fa0ecd80a2cde', 'cmgz79ivv009x47ffeh6of72i', 'cmgujpyjf002q4705j6hv1nkk', 'cmgum9bn4000847fffbazanc5', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl786b5dc9712d2488ee7d1258', 'cmgz79ivv009x47ffeh6of72i', 'cmgs1s4pv002j470567o60oqe', 'cmgyruhgm000947ffmhhrqdrl', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl168e7708c9d3c91d5a404a5e', 'cmgz79ivv009x47ffeh6of72i', 'cmgujpyjf002q4705j6hv1nkk', 'cmgyrzrbc000h47ffd670wu8j', 1, 5, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl1fd495eaa3cec6667cfab566', 'cmgz79ivv009x47ffeh6of72i', 'cmgs1s4pv002j470567o60oqe', 'cmgys0mgx000i47ffxbftvqt4', 1, 6, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clbf85161d0d8ffef1d74220b6', 'cmgz79ivv009x47ffeh6of72i', 'cm_motoreducteur_frein_01', 'cmgys1osr000j47fftblpdpu2', 1, 7, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cld72b4dce93cb64a7909f3db6', 'cmgz79ivv009x47ffeh6of72i', 'cm_motoreducteur_frein_01', 'cmgys3ugw001147ffq33udxaw', 1, 8, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle12f0173310ab9c752f8fb00', 'cmgz79ivv009x47ffeh6of72i', 'cmgukvztv002s4705kqvqjtvg', 'cmgys4a2s001847ffhqhz7zcd', 1, 9, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl5782cb522ada0d1de03c4d86', 'cmgz79ivv009x47ffeh6of72i', 'cmgukvztv002s4705kqvqjtvg', 'cmgys6k6b001h47ffuq44ze37', 1, 10, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl5d8c4c8e361ccdbaa041e5b9', 'cmgz79ivv009x47ffeh6of72i', 'cmgukxw26002t4705qz4ul929', 'cmgys7anf001m47ff0ulcp092', 1, 11, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb1c4e1e8f6a00f1b11f24884', 'cmgz79ivv009x47ffeh6of72i', 'cmgum1ih0000347ff7bsldmnv', 'cmgys8mjl001r47ff5f8z85fs', 1, 12, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4f4f675958d1667659498076', 'cmgz79ivv009x47ffeh6of72i', 'cmgulzr7b000247ffpr2vsput', 'cmgysatbl001u47ffu55db8gg', 1, 13, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla3442d7af57410a430dd3c7e', 'cmgz79ivv009x47ffeh6of72i', 'cmgum1wsl000447ffa109dtag', 'cmgysj6wn002347ffgq3f98dr', 1, 14, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl0c706745220aa12658edc03c', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytmhw0002547ffzobsmpaa', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clca63f8c3aae40ebd68cc55b7', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytqtc8002u47ffum90ylo5', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle09fbef03d0edcd66b0a4a0c', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgyts8s9003747ffd1h8husf', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl57b44902fd7409abccdab4ae', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytuf06003k47ffdr8lvp13', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl47ee8065661468bc113d200a', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytx2ul003x47ffhdpurtx5', 1, 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6cbbd1f5046e9dfc1a89a358', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0w66p004w47ffvj6xcxmo', 1, 7, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle4cb39506f39efaf51af3e9e', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0xbx5005d47ffjkrafetg', 1, 8, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8e47336d9326ac471ae821e7', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0y2aw005m47ff4zkjczei', 1, 9, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl5ae18ec62736f96d273353ec', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0yt33005v47ffy4p8d28z', 1, 10, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl524831b7cfa901ddc72e1728', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz10g67006547ffj28sqequ', 1, 11, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4198321b8039385517c118ba', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz112hd006e47ffvg37mkoq', 1, 12, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl9fab43583266bb8abfe94d4d', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz11p1k006j47ffhjqgrnkp', 1, 13, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cled20136adaf599c72facf1a5', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz128lz006o47ffwfgtag7e', 1, 14, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clca666296d8db92cabc1299d6', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz17bpz006t47ff58i3j1e1', 'cmgz17w9w006u47ffg6db710j', 1, 15, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl565591562f915536f015cf0a', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz17bpz006t47ff58i3j1e1', 'cmgz18vw1007947ffr2wg86sa', 1, 16, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8a5cc24bf84edeaf24b87405', 'cmgz7igun009z47ffhea93fbw', 'cmgum1wsl000447ffa109dtag', 'cmgz1c9wx007h47ffr41untmr', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle6cc6a2990d51326b7a16ac2', 'cmgz7igun009z47ffhea93fbw', 'cmgum1wsl000447ffa109dtag', 'cmgz1erci007r47ffybtdepul', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clf8105cc1fa7b62c4616cf509', 'cmh314rnj002q47s5cr3n6445', 'cmgujpyjf002q4705j6hv1nkk', 'cmh313676002d47s5li4e6qt9', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla7987d4fe2fb2bf8ebe8ce48', 'cmh3jnikd002147zbmmnx2qw8', 'cmgujpyjf002q4705j6hv1nkk', 'cmh31ejnw003b47s548rtk8b1', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb39042e520687e6c6b51f788', 'cl10c0924d10135c5f515378ac', 'cmgujpyjf002q4705j6hv1nkk', 'cl7b3702f04d24d87e47232a14', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl50fa6580ea24bfbcfb018cac', 'cl10eedbb54a0d2cd0fa3ce9c6', 'cmgujpyjf002q4705j6hv1nkk', 'cle1db7051dbef91fc009073a6', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl2289dce68b996d2c0b56cd49', 'cl36d84884cad86fbc92dba133', 'cmgujpyjf002q4705j6hv1nkk', 'clbf9f0070ebd464b3c309c646', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla130de58dd731b934e1f9789', 'cl3dbac5194bc192a0589465ba', 'cmgujpyjf002q4705j6hv1nkk', 'cl50fe870a07e42759b37b511f', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl75bfb7413b5df226846ab367', 'cl4660bae41d2af254e6c3b726', 'cmgujpyjf002q4705j6hv1nkk', 'cl4e975566464253882018adcc', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl076c31311e25600aae5d07e2', 'cl54b1b4509971fde475572b29', 'cmgujpyjf002q4705j6hv1nkk', 'cl731386df55fcb9e6a01e0a63', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4874e08b827307a1313a71b5', 'cl5a8f9656aa7e14c012f30700', 'cmgujpyjf002q4705j6hv1nkk', 'cl5ee293dc7b61feba510082a4', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6935b95a12cd846b6a3bcbfc', 'cl5b5e336095de8d4ece81b2dc', 'cmgujpyjf002q4705j6hv1nkk', 'cldd656c6092225f53a22badc0', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clf30ffdeac20200df2d190c45', 'cl5e9c6b18bccd38517026dc1c', 'cmgujpyjf002q4705j6hv1nkk', 'clfa3147270e5e66f9b52c425e', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb8fa63511f6514497e8c1a5f', 'cl7df36c9e7391df3d4ff46102', 'cmgujpyjf002q4705j6hv1nkk', 'cl1406ef19de58fdd1adf40221', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl2251745f51b584dd8bd73aad', 'cl7f254c23161d9c853c3e6d92', 'cmgujpyjf002q4705j6hv1nkk', 'clf16e543545eddd01b20077df', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle99713d099f9a006931a7863', 'cl8b9b36f5a822aae21edb5a5f', 'cmgujpyjf002q4705j6hv1nkk', 'cl6667d159f6d07ba77fa79b39', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl469909161ea4061d1b514f06', 'cla833681664bb851ca61aca51', 'cmgujpyjf002q4705j6hv1nkk', 'cl8570d729efd017c12a2d5c3d', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clf9f5aaccf25ef3a54ace17ad', 'clba5633e840726188261145f9', 'cmgujpyjf002q4705j6hv1nkk', 'clafaa71cbf49777fbb8415f19', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cld23a4f8bfc3894842b531b47', 'clbd1e945fb222e1c56dd43941', 'cmgujpyjf002q4705j6hv1nkk', 'clc08fbdcd334ed869772d98ee', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl88ea31be5c6114d3267daaad', 'clbe710810fd7ccd09811957b3', 'cmgujpyjf002q4705j6hv1nkk', 'cmh313676002d47s5li4e6qt9', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cldb0304d3089db7aced359a4f', 'cldd7f161d2cd08ee54e79161e', 'cmgujpyjf002q4705j6hv1nkk', 'cl22c13dbc4d38a1f846323ae6', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6e857d06dd103028af452bf6', 'cle98225ad3a32f5d8531950ef', 'cmgujpyjf002q4705j6hv1nkk', 'cl531dde45c3fc64c1a3b16ca0', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: composant_subcomponent_slots; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl114d5a6febff03d69286b6b7', 'cmgz79ivv009x47ffeh6of72i', 'Kit', 'kit', NULL, 'cmgz49bm2009547ff7ham94wz', 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl1d648f7c5a3b0ee64b22e69b', 'cmgz79ivv009x47ffeh6of72i', 'Kit', 'kit', NULL, 'cmgz4bczt009647ffzidmc8tc', 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl710c40366df98d7aeb547457', 'cmgz7igun009z47ffhea93fbw', 'Kit', 'kit', NULL, 'cmgz4equ5009747ffq665rpeb', 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl23ac52af05e90d9c7d2daba2', 'cmh0d59v5000347s561ahbept', 'Tête convoyeur à bande', 'tcb', NULL, 'cmgz53uvt009s47ff9v0uklr6', 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl5c1f5ff5fb762ffb1c62d02c', 'cmh0d59v5000347s561ahbept', 'Pied convoyeur à bande', 'PCB', NULL, 'cmgz5ef4h009t47ffmxveesp0', 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl9a90d9876471267669c5d83b', 'cmh0d59v5000347s561ahbept', 'Elément intermédiaire & coude', 'EIC', NULL, 'cmgz5fsvz009u47ffkrardb1u', 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('claeaa69eb72eff09abe7ef3fb', 'cmh0d59v5000347s561ahbept', 'Trémie d''alimentation', 'Tremie', NULL, 'cmgz5h2s0009v47ff6x26cqry', 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('clfcb74e5cc092b753c65f74f8', 'cmh0d59v5000347s561ahbept', 'Chariot Déverseur', 'Chariot', NULL, 'cmgz79ivv009x47ffeh6of72i', 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl8bb434545f2bca42d73d588e', 'cmh0d59v5000347s561ahbept', 'Commande moteur', 'commande-moteur', NULL, 'cmgz7fd3l009y47fff1l4g0p0', 5, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cle71559358e6346f59657d3d5', 'cmh0d59v5000347s561ahbept', 'Déport de bande', 'deport-de-bande-et-controleur-de-rotation', NULL, 'cmgz7igun009z47ffhea93fbw', 6, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl4a7d21f525c63c0581af5fc6', 'cmh0d59v5000347s561ahbept', 'Contrôleur de rotation', 'controleur-de-rotation', NULL, 'cmgz4qzap009b47ffj7ch6th7', 7, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl9f39d9a471c6d1d9ae4c4654', 'cmh0d59v5000347s561ahbept', 'Contrôleur de rotation', 'controleur-de-rotation', NULL, 'cmgz4r99x009c47ffco9f2img', 8, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: composant_product_slots; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: piece_products; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: constructeurs; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
@@ -470,121 +257,121 @@ INSERT INTO public.machines (id, name, reference, prix, createdat, updatedat, si
|
||||
-- Data for Name: pieces; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp1ry9001347052qn8q2yo', 'Lame raclette', 'P40S069915', NULL, '2025-10-15 07:53:07.52', '2025-10-15 07:53:07.52', 'cmgrou6670011470586ipgylm', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp46ud001i4705nvphpv0f', 'Palier applique', 'X21000923', NULL, '2025-10-15 07:55:00.132', '2025-10-15 07:55:00.132', 'cmgrnxlx5000g47059oyj4yuw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp2sju00144705n8etw7im', 'Bras tendeur SE18', 'X56654', NULL, '2025-10-15 07:53:54.954', '2025-10-15 12:54:18.646', 'cmgrohigo000z4705q8yvpih0', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs08kjb00234705wc5tytxg', 'Cage d''écureuil de tension', 'W57719', NULL, '2025-10-15 13:06:20.278', '2025-10-15 13:06:20.278', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3h3c82001f47zbvfmcu17d', 'Auget tôle', NULL, NULL, '2025-10-23 13:43:37.634', '2025-10-23 13:43:37.634', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs0bive00274705zjmiuwzo', 'Rouleau1', 'X24001026', NULL, '2025-10-15 13:08:38.087', '2025-10-15 13:08:38.087', 'cmgroij2f00104705t6y33enk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs1tfza002m4705mbl0kwok', 'Bavette alimentation', 'P30W07069', NULL, '2025-10-15 13:50:33.766', '2025-10-15 13:50:33.766', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs1tvrs002n4705gpym7vel', 'Bavette centrage', 'P30W07052', NULL, '2025-10-15 13:50:54.232', '2025-10-15 13:50:54.232', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys8mjl001r47ff5f8z85fs', 'Attache rapide 19.05S', 'X10000565', NULL, '2025-10-20 06:56:49.185', '2025-10-20 06:56:49.185', 'cmgum1ih0000347ff7bsldmnv', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytmhw0002547ffzobsmpaa', 'Moteur éléctrique', 'X50001591', NULL, '2025-10-20 07:35:35.925', '2025-10-20 07:35:35.925', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyts8s9003747ffd1h8husf', 'Moteur éléctrique2', 'X50001596', NULL, '2025-10-20 07:40:04.088', '2025-10-20 07:40:04.088', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0rm8j004b47ffg2bh2ort', 'Réducteur1', 'X28896', NULL, '2025-10-20 10:55:32.179', '2025-10-20 10:55:32.179', 'cmgz0qu29004a47ffw1bmjr75', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0se74004o47ffnbbtu66b', 'Réducteur2', 'X15009329', NULL, '2025-10-20 10:56:08.416', '2025-10-20 10:56:08.416', 'cmgz0qu29004a47ffw1bmjr75', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh99o05y001547v7az12sk2n', 'Moteur à flasque', NULL, NULL, '2025-10-27 15:02:21.884', '2025-10-27 15:02:21.884', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0w66p004w47ffvj6xcxmo', 'Poulie1', 'X53433', NULL, '2025-10-20 10:59:04.657', '2025-10-20 10:59:26.075', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz17w9w006u47ffg6db710j', 'Courroie', 'X47067', NULL, '2025-10-20 11:08:11.684', '2025-10-20 11:08:11.684', 'cmgz17bpz006t47ff58i3j1e1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaesmf5003v47v7bub04g9p', 'Joint à lèvre', 'J41800-RLX', 44.00, '2025-10-28 10:13:41.607', '2025-10-28 10:13:41.607', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbtjqbt0000475ultd24cp0', 'Segment d''arrêt - Circlips', 'S41800-SA2', 37.00, '2025-10-29 09:54:27.209', '2025-10-29 09:54:27.209', 'cmhalh6sa004h47v7y6pnqok2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz18vw1007947ffr2wg86sa', 'Courroie2', 'X53480', NULL, '2025-10-20 11:08:57.84', '2025-10-20 11:08:57.84', 'cmgz17bpz006t47ff58i3j1e1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbv3kj9000t475uzbqpult7', 'Rondelle frein MB20', 'RDLMB20', 7.00, '2025-10-29 10:37:52.437', '2025-10-29 10:37:52.437', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd56h02002a475uunel89e0', 'Manille', NULL, NULL, '2025-10-30 08:07:50.161', '2025-10-30 08:07:50.161', 'cmhd55caa0029475u1t4vg1i2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhkr1ulr000147yv73imk2nx', 'Rondelle plate M4 RVS-A2', NULL, NULL, '2025-11-04 15:54:29.296', '2025-11-04 16:49:00.924', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh313676002d47s5li4e6qt9', 'Arbre', NULL, NULL, '2025-10-23 06:15:35.969', '2025-10-23 06:15:35.969', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3jsgfr002n47zbadkkds7r', 'Bavette2', NULL, NULL, '2025-10-23 14:59:08.727', '2025-10-23 14:59:08.727', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp3lhv00194705f1xp8j0m', 'Rouleau', 'X24001025', NULL, '2025-10-15 07:54:32.438', '2025-10-15 07:54:32.438', 'cmgroij2f00104705t6y33enk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs0nyk7002g4705rteyvw7x', 'Support rouleau inférieur', 'T30S06944', NULL, '2025-10-15 13:18:18.295', '2025-10-15 13:18:18.295', 'cmgs0kd5o002f47053b7n8tw6', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgum5zm0000547ffzg8ofiqr', 'Cage d''écureuil', 'W78517', NULL, '2025-10-17 08:55:43.753', '2025-10-17 08:55:43.753', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgum9bn4000847fffbazanc5', 'Arbre roue avant', 'H22907', NULL, '2025-10-17 08:58:19.312', '2025-10-17 08:58:19.312', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaf1nsb004347v75uv8gmsi', 'Axe rouleau Promill', NULL, NULL, '2025-10-28 10:20:43.281', '2025-10-28 10:20:43.281', 'cmhaex3ca004247v78ymfpvpd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbu3due0007475ung88xfpm', 'Cuvette pour roulement HH 228310', 'C41800-CO2', 498.00, '2025-10-29 10:09:44.122', '2025-10-29 10:09:44.122', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytqtc8002u47ffum90ylo5', 'Moteur éléctrique1', 'X50001593', NULL, '2025-10-20 07:38:57.415', '2025-10-20 07:38:57.415', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0xbx5005d47ffjkrafetg', 'Poulie2', 'X53446', NULL, '2025-10-20 10:59:58.743', '2025-10-20 10:59:58.743', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz1c9wx007h47ffr41untmr', 'Détecteur déport de bande', 'X23100', NULL, '2025-10-20 11:11:35.985', '2025-10-20 11:12:51.466', 'cmgum1wsl000447ffa109dtag', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbvyr210017475ubrey4eux', 'Ecrou de blocage KM20', 'ECRKM20A', 42.00, '2025-10-29 11:02:07.197', '2025-10-29 11:04:47.071', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh4xg7lb000347nkrtvqw3hi', 'Arbre Tapis émotteur', NULL, NULL, '2025-10-24 14:09:18.164', '2025-10-24 14:09:18.164', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaagmno003647v7sfrgsb5v', 'COQUILLE nid d''abeille Promill', 'E41800ASN1P', 574.00, '2025-10-28 08:12:23.603', '2025-10-28 08:12:23.603', 'cmhaa5la2003447v7do7w3s0i', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaf9o8j004947v7xomube6n', 'Flasque arrière rouleau Promill', 'F07700-001/5759701', 86.71, '2025-10-28 10:26:57.139', '2025-10-28 10:26:57.139', 'cmhaf6jaj004847v7cpq93sq5', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhafarpc004e47v7nqdy97xs', 'Flasque avant rouleau Promill', 'F07700-002/5759601', 115.30, '2025-10-28 10:27:48.288', '2025-10-28 10:27:48.288', 'cmhaf6jaj004847v7cpq93sq5', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbuapy5000e475utfiwfkcj', 'Cone pour roulement HH228340', 'C41800-CO2', 498.00, '2025-10-29 10:15:26.402', '2025-10-29 10:15:26.402', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd4jjw9001q475u8x4i63jw', 'Graisseur 1/4 rouleaux Promill', NULL, NULL, '2025-10-30 07:50:00.797', '2025-10-30 07:50:00.797', 'cmhd48ipe001p475ul7ejiutq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhdb5hon002p475uif5ri4vu', 'Douille de serrage', NULL, NULL, '2025-10-30 10:55:02.086', '2025-10-30 10:55:02.086', 'cmhdb4mgx002o475udcnbd71h', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh31ejnw003b47s548rtk8b1', 'Arbre de commande', NULL, NULL, '2025-10-23 06:24:26.634', '2025-11-06 13:36:45.099', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrzvdmo001v47050tvf2z88', 'Cage d''écureuil de pied', 'W78515', NULL, '2025-10-15 12:56:04.801', '2025-10-15 13:05:49.946', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs14c7a002i4705t1w4qdfx', 'rouleau amortisseur avec axe', 'E1RS07058', NULL, '2025-10-15 13:31:02.469', '2025-10-15 13:31:02.469', 'cmgs13jjp002h4705rjqzz5lh', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyruhgm000947ffmhhrqdrl', 'Galet avant chariot déverseur', 'H22698', NULL, '2025-10-20 06:45:49.386', '2025-10-20 06:45:49.386', 'cmgs1s4pv002j470567o60oqe', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyrxcgu000a47ffxvcyyuwm', 'Palier BPF5', 'X21000919', NULL, '2025-10-20 06:48:02.91', '2025-10-20 06:48:02.91', 'cmgrnxlx5000g47059oyj4yuw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyrzrbc000h47ffd670wu8j', 'Arbre roue arrière', 'H22908', NULL, '2025-10-20 06:49:55.463', '2025-10-20 06:49:55.463', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys0mgx000i47ffxbftvqt4', 'Galet arrière chariot déverseur', 'H22861', NULL, '2025-10-20 06:50:35.84', '2025-10-20 06:50:35.84', 'cmgs1s4pv002j470567o60oqe', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgysatbl001u47ffu55db8gg', 'Vérin éléctrique', 'X22754', NULL, '2025-10-20 06:58:31.282', '2025-10-20 06:58:31.282', 'cmgulzr7b000247ffpr2vsput', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytuf06003k47ffdr8lvp13', 'Moteur éléctrique3', 'X50001598', NULL, '2025-10-20 07:41:45.434', '2025-10-20 07:41:45.434', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0y2aw005m47ff4zkjczei', 'Poulie3', 'X53450', NULL, '2025-10-20 11:00:32.936', '2025-10-20 11:00:32.936', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz112hd006e47ffvg37mkoq', 'Moyeu amovible2', 'X11F00653', NULL, '2025-10-20 11:02:53.136', '2025-10-20 11:02:53.136', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz1erci007r47ffybtdepul', 'Détecteur déport de bande1', 'X53294', NULL, '2025-10-20 11:13:31.891', '2025-10-20 11:13:31.891', 'cmgum1wsl000447ffa109dtag', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3eadge001147zbworn1671', 'Bavette 2', NULL, NULL, '2025-10-23 12:25:06.974', '2025-10-23 12:25:06.974', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3e95j4000k47zbx53n4tqv', 'Bavette1', NULL, NULL, '2025-10-23 12:24:10.047', '2025-10-23 14:58:45.061', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrnzbku000h4705qrj5eujb', 'Tambour de tête', 'H57305', NULL, '2025-10-15 07:23:13.346', '2025-10-15 07:23:13.346', 'cmgrnu6zc000f470565mc8hha', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs07df2001w4705ry79yvbo', 'Cage d''écureuil de pied de tension', 'W58372', NULL, '2025-10-15 13:05:24.397', '2025-10-15 13:05:24.397', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs1swl0002l4705gpyg1yyn', 'Galet releveur complet', 'W32440', NULL, '2025-10-15 13:50:08.628', '2025-10-15 13:50:08.628', 'cmgs1s4pv002j470567o60oqe', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys4a2s001847ffhqhz7zcd', 'Pignon moteur', 'H38143', NULL, '2025-10-20 06:53:26.404', '2025-10-20 06:54:46.835', 'cmgukvztv002s4705kqvqjtvg', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys6k6b001h47ffuq44ze37', 'Pignon récepteur', 'H47381', NULL, '2025-10-20 06:55:12.803', '2025-10-20 06:55:12.803', 'cmgukvztv002s4705kqvqjtvg', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys7anf001m47ff0ulcp092', 'Chaîne 19.05S', 'X10000564', NULL, '2025-10-20 06:55:47.115', '2025-10-20 06:56:20.844', 'cmgukxw26002t4705qz4ul929', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgysj6wn002347ffgq3f98dr', 'Détecteur mécanique', 'X60001690', NULL, '2025-10-20 07:05:02.134', '2025-10-20 07:05:02.134', 'cmgum1wsl000447ffa109dtag', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytx2ul003x47ffhdpurtx5', 'Moteur éléctrique4', 'X50001600', NULL, '2025-10-20 07:43:49.676', '2025-10-20 07:43:49.676', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0yt33005v47ffy4p8d28z', 'Poulie4', 'X41745', NULL, '2025-10-20 11:01:07.646', '2025-10-20 11:01:07.646', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz10g67006547ffj28sqequ', 'Moyeu amovible1', 'X43888', NULL, '2025-10-20 11:02:24.223', '2025-10-20 11:02:24.223', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz11p1k006j47ffhjqgrnkp', 'Moyeu amovible3', 'X41739', NULL, '2025-10-20 11:03:22.375', '2025-10-20 11:03:22.375', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz128lz006o47ffwfgtag7e', 'Moyeu amovible4', 'X11F00765', NULL, '2025-10-20 11:03:47.735', '2025-10-20 11:03:47.735', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz516t7009n47fft3nfyt34', 'Tambour de tête1', 'H138830', NULL, '2025-10-20 12:54:57.211', '2025-10-20 12:54:57.211', 'cmgrnu6zc000f470565mc8hha', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhae5b7u003i47v7vb4qi81n', 'Joint torique R41', 'JTR41', 2.00, '2025-10-28 09:55:33.999', '2025-10-28 09:55:33.999', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhallrb1004i47v7855gvfpe', 'Segment d''étanchéïté', 'S41800-SA2', 37.00, '2025-10-28 13:24:18.658', '2025-10-28 13:24:56.572', 'cmhalh6sa004h47v7y6pnqok2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys1osr000j47fftblpdpu2', 'Motoréducteur frein', 'X33959', NULL, '2025-10-20 06:51:25.485', '2025-10-20 06:52:25.744', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys3ugw001147ffq33udxaw', 'Motoréducteur frein.', 'X108273', NULL, '2025-10-20 06:53:06.176', '2025-10-20 06:53:06.176', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbuq97o000l475uf73oiot0', 'Entretoise de roulements', 'E41800-000', 67.00, '2025-10-29 10:27:31.208', '2025-10-29 10:27:31.208', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd4s3u80020475uasnc0mqj', 'Crochet de levage', NULL, NULL, '2025-10-30 07:56:39.92', '2025-10-30 07:56:39.92', 'cmhd4r5bg001z475u0f4tm9yy', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd4syhb0025475u4sv87dyf', 'Crochet de levage avec manille', NULL, NULL, '2025-10-30 07:57:19.63', '2025-10-30 07:57:19.63', 'cmhd4r5bg001z475u0f4tm9yy', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhdiuklf002u475uadws3h04', 'Courroie XPC', NULL, NULL, '2025-10-30 14:30:29.569', '2025-10-30 14:30:29.569', 'cmgz17bpz006t47ff58i3j1e1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh99m3e6000847v75h2m7czn', 'Réducteur emo', NULL, NULL, '2025-10-27 15:00:52.781', '2025-10-27 15:00:52.781', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'lame de godet 82', NULL, 192.00, '2025-12-10 10:04:09.262', '2025-12-10 10:05:37.177', 'cmizu3st800001e2waysco15j', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmizv8nzu00081e2wen6ur31b', 'Tapis', 'PF0165295', 3730.67, '2025-12-10 10:28:00.762', '2025-12-10 10:39:59.943', 'cmizup8cv00061e2w2rulkxsn', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'VIS (lame de godet) 82 M14 60mm tête fraisé', NULL, NULL, '2025-12-19 07:04:35.979', '2025-12-19 07:04:35.979', 'cmj025vi7000z1e2wyn3x6msv', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'Ecrou Ø 14', NULL, NULL, '2025-12-19 10:05:07.973', '2025-12-19 10:05:07.973', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhdattcv002f475ugw514oj3', 'Poulie 8', NULL, NULL, '2025-10-30 10:45:57.343', '2026-01-14 08:04:14.29', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdqrkez001o1e2wtslqeazi', 'Carter presse', NULL, NULL, '2026-01-14 08:11:13.308', '2026-01-14 08:11:13.308', 'cmkdqqh8w001n1e2wzxkamd1m', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdqw1tq00231e2wxou4eu8z', 'Ecrou HM12', NULL, NULL, '2026-01-14 08:14:42.494', '2026-01-14 08:14:42.494', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdrc535002e1e2wjuucdweq', 'Moteur entrainement Presse Promill', NULL, NULL, '2026-01-14 08:27:13.217', '2026-01-14 08:27:13.217', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsbrz1002t1e2wqemldbr6', 'Vis HM 14x100', NULL, NULL, '2026-01-14 08:54:55.838', '2026-01-14 08:54:55.838', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsdqjh00301e2wu2g4ljg2', 'Rondelle plate M24', NULL, NULL, '2026-01-14 08:56:27.294', '2026-01-14 08:56:27.294', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsp16u003b1e2wetp787yf', 'Rondelle Grower W24', NULL, NULL, '2026-01-14 09:05:14.31', '2026-01-14 09:05:14.31', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsrew1003m1e2wcuky5m77', 'Ecrou HM24', NULL, NULL, '2026-01-14 09:07:05.377', '2026-01-14 09:07:05.377', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdqun4a001s1e2wx123zdy1', 'Vis HM 12x35', NULL, NULL, '2026-01-14 08:13:36.778', '2026-01-14 09:15:22.132', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdt82ma003x1e2w9gkgwybf', 'Vis HM 12x30', NULL, NULL, '2026-01-14 09:20:02.626', '2026-01-14 09:20:02.626', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdtdh5p00441e2w0g6ye4v5', 'Rondelle Grower W12', NULL, NULL, '2026-01-14 09:24:14.75', '2026-01-14 09:24:14.75', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmke1nimf004f1e2wsurzoeet', 'Vis HM 8x16', NULL, NULL, '2026-01-14 13:16:00.135', '2026-01-14 13:16:00.135', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmke3hgbc004o1e2w02his9k3', 'Vis HM 24x100', NULL, NULL, '2026-01-14 14:07:16.44', '2026-01-14 14:07:16.44', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp7xsz1006b1e2whn582enn', 'Arbre principal Presse Promill', 'APR 80 101101', NULL, '2026-01-22 08:57:25.741', '2026-01-22 08:57:25.741', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp80cfi006k1e2wha1v14vv', 'Joint Viton', NULL, NULL, '2026-01-22 08:59:24.27', '2026-01-22 08:59:24.27', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp84bs0006t1e2wkokejtyn', 'Roulement à rouleaux cylindriques', NULL, NULL, '2026-01-22 09:02:30.048', '2026-01-22 09:02:30.048', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp8mbo900751e2w2746k94i', 'Circlips E260', '390 01126000', NULL, '2026-01-22 09:16:29.721', '2026-01-22 09:16:29.721', 'cmhalh6sa004h47v7y6pnqok2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpkaj4400091eq6p6accs62', 'Moyeu central', 'APR 80 101103', NULL, '2026-01-22 14:43:14.884', '2026-01-22 14:43:14.884', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpkwfdh000g1eq6hpqdbqat', 'Roulement à rotule sur rouleaux', '320 41 220001', NULL, '2026-01-22 15:00:16.469', '2026-01-22 15:00:16.469', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpnw6er000s1eq6k57kcnl8', 'Vis CHC M14x40', NULL, NULL, '2026-01-22 16:24:03.699', '2026-01-22 16:24:03.699', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpo4gxz00121eq6o0ahmizg', 'Joint', '370 20 280000', NULL, '2026-01-22 16:30:30.6', '2026-01-22 16:30:30.6', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'Palier tête E1 17', 'SNU516613', NULL, '2026-01-23 15:09:10.414', '2026-01-23 15:09:44.182', 'cmgrnxlx5000g47059oyj4yuw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0owbv004l1eq6pzlatzlr', 'Vis HM 14x50', NULL, NULL, '2026-01-23 15:10:05.228', '2026-01-23 15:10:05.228', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'BANDE E1 17', NULL, NULL, '2026-01-23 14:39:05.914', '2026-01-23 14:48:36.329', 'cmknus46z00551e2wf7zy706v', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0fwuo003o1eq69b1idlpw', 'Entretoise porte 2 joints', 'APR 80 101108', NULL, '2026-01-23 15:03:06', '2026-01-23 15:03:06', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0koqb003y1eq6a8cx8poy', 'Flasque porte 2 joints', 'APR 25 0304..', NULL, '2026-01-23 15:06:48.755', '2026-01-23 15:06:48.755', 'cmhaf6jaj004847v7cpq93sq5', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0nmsv00431eq6rcnozdes', 'Rondelle Grower W14', NULL, NULL, '2026-01-23 15:09:06.224', '2026-01-23 15:09:06.224', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr173u9004z1eq6tn4gw3h3', 'Rondelle frein MB44', NULL, NULL, '2026-01-23 15:24:14.77', '2026-01-23 15:24:14.77', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'Roulement E1 17', NULL, NULL, '2026-01-23 15:46:59.302', '2026-01-23 15:46:59.302', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr253dx005k1eq65vn7evdy', 'Ecrou HM 44T', NULL, NULL, '2026-01-23 15:50:40.485', '2026-01-23 15:50:40.485', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'Manchon E1 17', NULL, NULL, '2026-01-23 15:51:20.125', '2026-01-23 15:51:20.125', 'cmkr24n3x005j1eq6s9xi6obl', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0qjw5004s1eq6pen63x7j', 'Arbre E1 17', NULL, NULL, '2026-01-23 15:11:22.422', '2026-01-23 15:55:47.227', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cl89d9641d47f52c5385f83d5c', 'test', 'four', 33.97, '2026-01-25 10:48:52', '2026-01-25 10:49:48', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz');
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cl280f805cc3e6ff4b8bde95e4', 'testjjj', NULL, NULL, '2026-01-25 11:20:44', '2026-01-25 11:20:44', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz');
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp1ry9001347052qn8q2yo', 'Lame raclette', 'P40S069915', NULL, '2025-10-15 07:53:07.52', '2025-10-15 07:53:07.52', 'cmgrou6670011470586ipgylm', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp46ud001i4705nvphpv0f', 'Palier applique', 'X21000923', NULL, '2025-10-15 07:55:00.132', '2025-10-15 07:55:00.132', 'cmgrnxlx5000g47059oyj4yuw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp2sju00144705n8etw7im', 'Bras tendeur SE18', 'X56654', NULL, '2025-10-15 07:53:54.954', '2025-10-15 12:54:18.646', 'cmgrohigo000z4705q8yvpih0', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs08kjb00234705wc5tytxg', 'Cage d''écureuil de tension', 'W57719', NULL, '2025-10-15 13:06:20.278', '2025-10-15 13:06:20.278', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3h3c82001f47zbvfmcu17d', 'Auget tôle', NULL, NULL, '2025-10-23 13:43:37.634', '2025-10-23 13:43:37.634', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs0bive00274705zjmiuwzo', 'Rouleau1', 'X24001026', NULL, '2025-10-15 13:08:38.087', '2025-10-15 13:08:38.087', 'cmgroij2f00104705t6y33enk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs1tfza002m4705mbl0kwok', 'Bavette alimentation', 'P30W07069', NULL, '2025-10-15 13:50:33.766', '2025-10-15 13:50:33.766', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs1tvrs002n4705gpym7vel', 'Bavette centrage', 'P30W07052', NULL, '2025-10-15 13:50:54.232', '2025-10-15 13:50:54.232', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys8mjl001r47ff5f8z85fs', 'Attache rapide 19.05S', 'X10000565', NULL, '2025-10-20 06:56:49.185', '2025-10-20 06:56:49.185', 'cmgum1ih0000347ff7bsldmnv', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytmhw0002547ffzobsmpaa', 'Moteur éléctrique', 'X50001591', NULL, '2025-10-20 07:35:35.925', '2025-10-20 07:35:35.925', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyts8s9003747ffd1h8husf', 'Moteur éléctrique2', 'X50001596', NULL, '2025-10-20 07:40:04.088', '2025-10-20 07:40:04.088', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0rm8j004b47ffg2bh2ort', 'Réducteur1', 'X28896', NULL, '2025-10-20 10:55:32.179', '2025-10-20 10:55:32.179', 'cmgz0qu29004a47ffw1bmjr75', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0se74004o47ffnbbtu66b', 'Réducteur2', 'X15009329', NULL, '2025-10-20 10:56:08.416', '2025-10-20 10:56:08.416', 'cmgz0qu29004a47ffw1bmjr75', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh99o05y001547v7az12sk2n', 'Moteur à flasque', NULL, NULL, '2025-10-27 15:02:21.884', '2025-10-27 15:02:21.884', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0w66p004w47ffvj6xcxmo', 'Poulie1', 'X53433', NULL, '2025-10-20 10:59:04.657', '2025-10-20 10:59:26.075', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz17w9w006u47ffg6db710j', 'Courroie', 'X47067', NULL, '2025-10-20 11:08:11.684', '2025-10-20 11:08:11.684', 'cmgz17bpz006t47ff58i3j1e1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaesmf5003v47v7bub04g9p', 'Joint à lèvre', 'J41800-RLX', 44.00, '2025-10-28 10:13:41.607', '2025-10-28 10:13:41.607', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbtjqbt0000475ultd24cp0', 'Segment d''arrêt - Circlips', 'S41800-SA2', 37.00, '2025-10-29 09:54:27.209', '2025-10-29 09:54:27.209', 'cmhalh6sa004h47v7y6pnqok2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz18vw1007947ffr2wg86sa', 'Courroie2', 'X53480', NULL, '2025-10-20 11:08:57.84', '2025-10-20 11:08:57.84', 'cmgz17bpz006t47ff58i3j1e1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbv3kj9000t475uzbqpult7', 'Rondelle frein MB20', 'RDLMB20', 7.00, '2025-10-29 10:37:52.437', '2025-10-29 10:37:52.437', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd56h02002a475uunel89e0', 'Manille', NULL, NULL, '2025-10-30 08:07:50.161', '2025-10-30 08:07:50.161', 'cmhd55caa0029475u1t4vg1i2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhkr1ulr000147yv73imk2nx', 'Rondelle plate M4 RVS-A2', NULL, NULL, '2025-11-04 15:54:29.296', '2025-11-04 16:49:00.924', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh313676002d47s5li4e6qt9', 'Arbre', NULL, NULL, '2025-10-23 06:15:35.969', '2025-10-23 06:15:35.969', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3jsgfr002n47zbadkkds7r', 'Bavette2', NULL, NULL, '2025-10-23 14:59:08.727', '2025-10-23 14:59:08.727', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp3lhv00194705f1xp8j0m', 'Rouleau', 'X24001025', NULL, '2025-10-15 07:54:32.438', '2025-10-15 07:54:32.438', 'cmgroij2f00104705t6y33enk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs0nyk7002g4705rteyvw7x', 'Support rouleau inférieur', 'T30S06944', NULL, '2025-10-15 13:18:18.295', '2025-10-15 13:18:18.295', 'cmgs0kd5o002f47053b7n8tw6', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgum5zm0000547ffzg8ofiqr', 'Cage d''écureuil', 'W78517', NULL, '2025-10-17 08:55:43.753', '2025-10-17 08:55:43.753', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgum9bn4000847fffbazanc5', 'Arbre roue avant', 'H22907', NULL, '2025-10-17 08:58:19.312', '2025-10-17 08:58:19.312', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaf1nsb004347v75uv8gmsi', 'Axe rouleau Promill', NULL, NULL, '2025-10-28 10:20:43.281', '2025-10-28 10:20:43.281', 'cmhaex3ca004247v78ymfpvpd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbu3due0007475ung88xfpm', 'Cuvette pour roulement HH 228310', 'C41800-CO2', 498.00, '2025-10-29 10:09:44.122', '2025-10-29 10:09:44.122', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytqtc8002u47ffum90ylo5', 'Moteur éléctrique1', 'X50001593', NULL, '2025-10-20 07:38:57.415', '2025-10-20 07:38:57.415', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0xbx5005d47ffjkrafetg', 'Poulie2', 'X53446', NULL, '2025-10-20 10:59:58.743', '2025-10-20 10:59:58.743', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz1c9wx007h47ffr41untmr', 'Détecteur déport de bande', 'X23100', NULL, '2025-10-20 11:11:35.985', '2025-10-20 11:12:51.466', 'cmgum1wsl000447ffa109dtag', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbvyr210017475ubrey4eux', 'Ecrou de blocage KM20', 'ECRKM20A', 42.00, '2025-10-29 11:02:07.197', '2025-10-29 11:04:47.071', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh4xg7lb000347nkrtvqw3hi', 'Arbre Tapis émotteur', NULL, NULL, '2025-10-24 14:09:18.164', '2025-10-24 14:09:18.164', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaagmno003647v7sfrgsb5v', 'COQUILLE nid d''abeille Promill', 'E41800ASN1P', 574.00, '2025-10-28 08:12:23.603', '2025-10-28 08:12:23.603', 'cmhaa5la2003447v7do7w3s0i', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaf9o8j004947v7xomube6n', 'Flasque arrière rouleau Promill', 'F07700-001/5759701', 86.71, '2025-10-28 10:26:57.139', '2025-10-28 10:26:57.139', 'cmhaf6jaj004847v7cpq93sq5', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhafarpc004e47v7nqdy97xs', 'Flasque avant rouleau Promill', 'F07700-002/5759601', 115.30, '2025-10-28 10:27:48.288', '2025-10-28 10:27:48.288', 'cmhaf6jaj004847v7cpq93sq5', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbuapy5000e475utfiwfkcj', 'Cone pour roulement HH228340', 'C41800-CO2', 498.00, '2025-10-29 10:15:26.402', '2025-10-29 10:15:26.402', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd4jjw9001q475u8x4i63jw', 'Graisseur 1/4 rouleaux Promill', NULL, NULL, '2025-10-30 07:50:00.797', '2025-10-30 07:50:00.797', 'cmhd48ipe001p475ul7ejiutq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhdb5hon002p475uif5ri4vu', 'Douille de serrage', NULL, NULL, '2025-10-30 10:55:02.086', '2025-10-30 10:55:02.086', 'cmhdb4mgx002o475udcnbd71h', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh31ejnw003b47s548rtk8b1', 'Arbre de commande', NULL, NULL, '2025-10-23 06:24:26.634', '2025-11-06 13:36:45.099', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrzvdmo001v47050tvf2z88', 'Cage d''écureuil de pied', 'W78515', NULL, '2025-10-15 12:56:04.801', '2025-10-15 13:05:49.946', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs14c7a002i4705t1w4qdfx', 'rouleau amortisseur avec axe', 'E1RS07058', NULL, '2025-10-15 13:31:02.469', '2025-10-15 13:31:02.469', 'cmgs13jjp002h4705rjqzz5lh', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyruhgm000947ffmhhrqdrl', 'Galet avant chariot déverseur', 'H22698', NULL, '2025-10-20 06:45:49.386', '2025-10-20 06:45:49.386', 'cmgs1s4pv002j470567o60oqe', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyrxcgu000a47ffxvcyyuwm', 'Palier BPF5', 'X21000919', NULL, '2025-10-20 06:48:02.91', '2025-10-20 06:48:02.91', 'cmgrnxlx5000g47059oyj4yuw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyrzrbc000h47ffd670wu8j', 'Arbre roue arrière', 'H22908', NULL, '2025-10-20 06:49:55.463', '2025-10-20 06:49:55.463', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys0mgx000i47ffxbftvqt4', 'Galet arrière chariot déverseur', 'H22861', NULL, '2025-10-20 06:50:35.84', '2025-10-20 06:50:35.84', 'cmgs1s4pv002j470567o60oqe', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgysatbl001u47ffu55db8gg', 'Vérin éléctrique', 'X22754', NULL, '2025-10-20 06:58:31.282', '2025-10-20 06:58:31.282', 'cmgulzr7b000247ffpr2vsput', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytuf06003k47ffdr8lvp13', 'Moteur éléctrique3', 'X50001598', NULL, '2025-10-20 07:41:45.434', '2025-10-20 07:41:45.434', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0y2aw005m47ff4zkjczei', 'Poulie3', 'X53450', NULL, '2025-10-20 11:00:32.936', '2025-10-20 11:00:32.936', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz112hd006e47ffvg37mkoq', 'Moyeu amovible2', 'X11F00653', NULL, '2025-10-20 11:02:53.136', '2025-10-20 11:02:53.136', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz1erci007r47ffybtdepul', 'Détecteur déport de bande1', 'X53294', NULL, '2025-10-20 11:13:31.891', '2025-10-20 11:13:31.891', 'cmgum1wsl000447ffa109dtag', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3eadge001147zbworn1671', 'Bavette 2', NULL, NULL, '2025-10-23 12:25:06.974', '2025-10-23 12:25:06.974', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3e95j4000k47zbx53n4tqv', 'Bavette1', NULL, NULL, '2025-10-23 12:24:10.047', '2025-10-23 14:58:45.061', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrnzbku000h4705qrj5eujb', 'Tambour de tête', 'H57305', NULL, '2025-10-15 07:23:13.346', '2025-10-15 07:23:13.346', 'cmgrnu6zc000f470565mc8hha', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs07df2001w4705ry79yvbo', 'Cage d''écureuil de pied de tension', 'W58372', NULL, '2025-10-15 13:05:24.397', '2025-10-15 13:05:24.397', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs1swl0002l4705gpyg1yyn', 'Galet releveur complet', 'W32440', NULL, '2025-10-15 13:50:08.628', '2025-10-15 13:50:08.628', 'cmgs1s4pv002j470567o60oqe', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys4a2s001847ffhqhz7zcd', 'Pignon moteur', 'H38143', NULL, '2025-10-20 06:53:26.404', '2025-10-20 06:54:46.835', 'cmgukvztv002s4705kqvqjtvg', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys6k6b001h47ffuq44ze37', 'Pignon récepteur', 'H47381', NULL, '2025-10-20 06:55:12.803', '2025-10-20 06:55:12.803', 'cmgukvztv002s4705kqvqjtvg', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys7anf001m47ff0ulcp092', 'Chaîne 19.05S', 'X10000564', NULL, '2025-10-20 06:55:47.115', '2025-10-20 06:56:20.844', 'cmgukxw26002t4705qz4ul929', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgysj6wn002347ffgq3f98dr', 'Détecteur mécanique', 'X60001690', NULL, '2025-10-20 07:05:02.134', '2025-10-20 07:05:02.134', 'cmgum1wsl000447ffa109dtag', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytx2ul003x47ffhdpurtx5', 'Moteur éléctrique4', 'X50001600', NULL, '2025-10-20 07:43:49.676', '2025-10-20 07:43:49.676', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0yt33005v47ffy4p8d28z', 'Poulie4', 'X41745', NULL, '2025-10-20 11:01:07.646', '2025-10-20 11:01:07.646', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz10g67006547ffj28sqequ', 'Moyeu amovible1', 'X43888', NULL, '2025-10-20 11:02:24.223', '2025-10-20 11:02:24.223', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz11p1k006j47ffhjqgrnkp', 'Moyeu amovible3', 'X41739', NULL, '2025-10-20 11:03:22.375', '2025-10-20 11:03:22.375', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz128lz006o47ffwfgtag7e', 'Moyeu amovible4', 'X11F00765', NULL, '2025-10-20 11:03:47.735', '2025-10-20 11:03:47.735', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz516t7009n47fft3nfyt34', 'Tambour de tête1', 'H138830', NULL, '2025-10-20 12:54:57.211', '2025-10-20 12:54:57.211', 'cmgrnu6zc000f470565mc8hha', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhae5b7u003i47v7vb4qi81n', 'Joint torique R41', 'JTR41', 2.00, '2025-10-28 09:55:33.999', '2025-10-28 09:55:33.999', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhallrb1004i47v7855gvfpe', 'Segment d''étanchéïté', 'S41800-SA2', 37.00, '2025-10-28 13:24:18.658', '2025-10-28 13:24:56.572', 'cmhalh6sa004h47v7y6pnqok2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys1osr000j47fftblpdpu2', 'Motoréducteur frein', 'X33959', NULL, '2025-10-20 06:51:25.485', '2025-10-20 06:52:25.744', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys3ugw001147ffq33udxaw', 'Motoréducteur frein.', 'X108273', NULL, '2025-10-20 06:53:06.176', '2025-10-20 06:53:06.176', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbuq97o000l475uf73oiot0', 'Entretoise de roulements', 'E41800-000', 67.00, '2025-10-29 10:27:31.208', '2025-10-29 10:27:31.208', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd4s3u80020475uasnc0mqj', 'Crochet de levage', NULL, NULL, '2025-10-30 07:56:39.92', '2025-10-30 07:56:39.92', 'cmhd4r5bg001z475u0f4tm9yy', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd4syhb0025475u4sv87dyf', 'Crochet de levage avec manille', NULL, NULL, '2025-10-30 07:57:19.63', '2025-10-30 07:57:19.63', 'cmhd4r5bg001z475u0f4tm9yy', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhdiuklf002u475uadws3h04', 'Courroie XPC', NULL, NULL, '2025-10-30 14:30:29.569', '2025-10-30 14:30:29.569', 'cmgz17bpz006t47ff58i3j1e1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh99m3e6000847v75h2m7czn', 'Réducteur emo', NULL, NULL, '2025-10-27 15:00:52.781', '2025-10-27 15:00:52.781', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'lame de godet 82', NULL, 192.00, '2025-12-10 10:04:09.262', '2025-12-10 10:05:37.177', 'cmizu3st800001e2waysco15j', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmizv8nzu00081e2wen6ur31b', 'Tapis', 'PF0165295', 3730.67, '2025-12-10 10:28:00.762', '2025-12-10 10:39:59.943', 'cmizup8cv00061e2w2rulkxsn', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'VIS (lame de godet) 82 M14 60mm tête fraisé', NULL, NULL, '2025-12-19 07:04:35.979', '2025-12-19 07:04:35.979', 'cmj025vi7000z1e2wyn3x6msv', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'Ecrou Ø 14', NULL, NULL, '2025-12-19 10:05:07.973', '2025-12-19 10:05:07.973', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhdattcv002f475ugw514oj3', 'Poulie 8', NULL, NULL, '2025-10-30 10:45:57.343', '2026-01-14 08:04:14.29', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdqrkez001o1e2wtslqeazi', 'Carter presse', NULL, NULL, '2026-01-14 08:11:13.308', '2026-01-14 08:11:13.308', 'cmkdqqh8w001n1e2wzxkamd1m', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdqw1tq00231e2wxou4eu8z', 'Ecrou HM12', NULL, NULL, '2026-01-14 08:14:42.494', '2026-01-14 08:14:42.494', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdrc535002e1e2wjuucdweq', 'Moteur entrainement Presse Promill', NULL, NULL, '2026-01-14 08:27:13.217', '2026-01-14 08:27:13.217', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsbrz1002t1e2wqemldbr6', 'Vis HM 14x100', NULL, NULL, '2026-01-14 08:54:55.838', '2026-01-14 08:54:55.838', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsdqjh00301e2wu2g4ljg2', 'Rondelle plate M24', NULL, NULL, '2026-01-14 08:56:27.294', '2026-01-14 08:56:27.294', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsp16u003b1e2wetp787yf', 'Rondelle Grower W24', NULL, NULL, '2026-01-14 09:05:14.31', '2026-01-14 09:05:14.31', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsrew1003m1e2wcuky5m77', 'Ecrou HM24', NULL, NULL, '2026-01-14 09:07:05.377', '2026-01-14 09:07:05.377', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdqun4a001s1e2wx123zdy1', 'Vis HM 12x35', NULL, NULL, '2026-01-14 08:13:36.778', '2026-01-14 09:15:22.132', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdt82ma003x1e2w9gkgwybf', 'Vis HM 12x30', NULL, NULL, '2026-01-14 09:20:02.626', '2026-01-14 09:20:02.626', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdtdh5p00441e2w0g6ye4v5', 'Rondelle Grower W12', NULL, NULL, '2026-01-14 09:24:14.75', '2026-01-14 09:24:14.75', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmke1nimf004f1e2wsurzoeet', 'Vis HM 8x16', NULL, NULL, '2026-01-14 13:16:00.135', '2026-01-14 13:16:00.135', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmke3hgbc004o1e2w02his9k3', 'Vis HM 24x100', NULL, NULL, '2026-01-14 14:07:16.44', '2026-01-14 14:07:16.44', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp7xsz1006b1e2whn582enn', 'Arbre principal Presse Promill', 'APR 80 101101', NULL, '2026-01-22 08:57:25.741', '2026-01-22 08:57:25.741', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp80cfi006k1e2wha1v14vv', 'Joint Viton', NULL, NULL, '2026-01-22 08:59:24.27', '2026-01-22 08:59:24.27', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp84bs0006t1e2wkokejtyn', 'Roulement à rouleaux cylindriques', NULL, NULL, '2026-01-22 09:02:30.048', '2026-01-22 09:02:30.048', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp8mbo900751e2w2746k94i', 'Circlips E260', '390 01126000', NULL, '2026-01-22 09:16:29.721', '2026-01-22 09:16:29.721', 'cmhalh6sa004h47v7y6pnqok2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpkaj4400091eq6p6accs62', 'Moyeu central', 'APR 80 101103', NULL, '2026-01-22 14:43:14.884', '2026-01-22 14:43:14.884', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpkwfdh000g1eq6hpqdbqat', 'Roulement à rotule sur rouleaux', '320 41 220001', NULL, '2026-01-22 15:00:16.469', '2026-01-22 15:00:16.469', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpnw6er000s1eq6k57kcnl8', 'Vis CHC M14x40', NULL, NULL, '2026-01-22 16:24:03.699', '2026-01-22 16:24:03.699', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpo4gxz00121eq6o0ahmizg', 'Joint', '370 20 280000', NULL, '2026-01-22 16:30:30.6', '2026-01-22 16:30:30.6', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'Palier tête E1 17', 'SNU516613', NULL, '2026-01-23 15:09:10.414', '2026-01-23 15:09:44.182', 'cmgrnxlx5000g47059oyj4yuw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0owbv004l1eq6pzlatzlr', 'Vis HM 14x50', NULL, NULL, '2026-01-23 15:10:05.228', '2026-01-23 15:10:05.228', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'BANDE E1 17', NULL, NULL, '2026-01-23 14:39:05.914', '2026-01-23 14:48:36.329', 'cmknus46z00551e2wf7zy706v', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0fwuo003o1eq69b1idlpw', 'Entretoise porte 2 joints', 'APR 80 101108', NULL, '2026-01-23 15:03:06', '2026-01-23 15:03:06', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0koqb003y1eq6a8cx8poy', 'Flasque porte 2 joints', 'APR 25 0304..', NULL, '2026-01-23 15:06:48.755', '2026-01-23 15:06:48.755', 'cmhaf6jaj004847v7cpq93sq5', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0nmsv00431eq6rcnozdes', 'Rondelle Grower W14', NULL, NULL, '2026-01-23 15:09:06.224', '2026-01-23 15:09:06.224', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr173u9004z1eq6tn4gw3h3', 'Rondelle frein MB44', NULL, NULL, '2026-01-23 15:24:14.77', '2026-01-23 15:24:14.77', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'Roulement E1 17', NULL, NULL, '2026-01-23 15:46:59.302', '2026-01-23 15:46:59.302', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr253dx005k1eq65vn7evdy', 'Ecrou HM 44T', NULL, NULL, '2026-01-23 15:50:40.485', '2026-01-23 15:50:40.485', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'Manchon E1 17', NULL, NULL, '2026-01-23 15:51:20.125', '2026-01-23 15:51:20.125', 'cmkr24n3x005j1eq6s9xi6obl', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0qjw5004s1eq6pen63x7j', 'Arbre E1 17', NULL, NULL, '2026-01-23 15:11:22.422', '2026-01-23 15:55:47.227', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cl89d9641d47f52c5385f83d5c', 'test', 'four', 33.97, '2026-01-25 10:48:52', '2026-01-25 10:49:48', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cl280f805cc3e6ff4b8bde95e4', 'testjjj', NULL, NULL, '2026-01-25 11:20:44', '2026-01-25 11:20:44', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz', '["cmko9bmrd005m1e2w81v07kiz","cmkpp4fb3001i1eq6qq74ul2i"]');
|
||||
|
||||
|
||||
--
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260309150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add quantity column to machine_piece_links table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS quantity');
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260312170000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create skeleton requirement tables (IF NOT EXISTS) and migrate JSON data from ModelType skeleton columns';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Table creation (idempotent) ──────────────────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS skeleton_piece_requirements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"modeltypeid" VARCHAR(36) NOT NULL,
|
||||
"typepieceid" VARCHAR(36) NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS skeleton_product_requirements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"modeltypeid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) NOT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS skeleton_subcomponent_requirements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"modeltypeid" VARCHAR(36) NOT NULL,
|
||||
alias VARCHAR(255) NOT NULL,
|
||||
"familycode" VARCHAR(255) NOT NULL,
|
||||
"typecomposantid" VARCHAR(36) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ─────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_piece_req_model ON skeleton_piece_requirements("modeltypeid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_piece_req_type ON skeleton_piece_requirements("typepieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_prod_req_model ON skeleton_product_requirements("modeltypeid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_prod_req_type ON skeleton_product_requirements("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_sub_req_model ON skeleton_subcomponent_requirements("modeltypeid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_sub_req_typecomp ON skeleton_subcomponent_requirements("typecomposantid")');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_piece_model') THEN
|
||||
ALTER TABLE skeleton_piece_requirements
|
||||
ADD CONSTRAINT fk_skel_piece_model
|
||||
FOREIGN KEY ("modeltypeid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_piece_type') THEN
|
||||
ALTER TABLE skeleton_piece_requirements
|
||||
ADD CONSTRAINT fk_skel_piece_type
|
||||
FOREIGN KEY ("typepieceid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_prod_model') THEN
|
||||
ALTER TABLE skeleton_product_requirements
|
||||
ADD CONSTRAINT fk_skel_prod_model
|
||||
FOREIGN KEY ("modeltypeid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_prod_type') THEN
|
||||
ALTER TABLE skeleton_product_requirements
|
||||
ADD CONSTRAINT fk_skel_prod_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_sub_model') THEN
|
||||
ALTER TABLE skeleton_subcomponent_requirements
|
||||
ADD CONSTRAINT fk_skel_sub_model
|
||||
FOREIGN KEY ("modeltypeid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_sub_typecomp') THEN
|
||||
ALTER TABLE skeleton_subcomponent_requirements
|
||||
ADD CONSTRAINT fk_skel_sub_typecomp
|
||||
FOREIGN KEY ("typecomposantid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Data migration: componentSkeleton.pieces → skeleton_piece_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_piece_requirements (id, "modeltypeid", "typepieceid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
(piece->>'typePieceId'),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.componentskeleton::jsonb->'pieces') WITH ORDINALITY AS t(piece, ordinality)
|
||||
WHERE mt.category = 'COMPONENT'
|
||||
AND mt.componentskeleton IS NOT NULL
|
||||
AND mt.componentskeleton::jsonb->'pieces' IS NOT NULL
|
||||
AND jsonb_array_length(mt.componentskeleton::jsonb->'pieces') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_piece_requirements spr WHERE spr."modeltypeid" = mt.id)
|
||||
AND EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (piece->>'typePieceId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: componentSkeleton.products → skeleton_product_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_product_requirements (id, "modeltypeid", "typeproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
(product->>'typeProductId'),
|
||||
(product->>'familyCode'),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.componentskeleton::jsonb->'products') WITH ORDINALITY AS t(product, ordinality)
|
||||
WHERE mt.category = 'COMPONENT'
|
||||
AND mt.componentskeleton IS NOT NULL
|
||||
AND mt.componentskeleton::jsonb->'products' IS NOT NULL
|
||||
AND jsonb_array_length(mt.componentskeleton::jsonb->'products') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_product_requirements spr WHERE spr."modeltypeid" = mt.id)
|
||||
AND EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (product->>'typeProductId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: pieceSkeleton.products → skeleton_product_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_product_requirements (id, "modeltypeid", "typeproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
(product->>'typeProductId'),
|
||||
(product->>'familyCode'),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.pieceskeleton::jsonb->'products') WITH ORDINALITY AS t(product, ordinality)
|
||||
WHERE mt.category = 'PIECE'
|
||||
AND mt.pieceskeleton IS NOT NULL
|
||||
AND mt.pieceskeleton::jsonb->'products' IS NOT NULL
|
||||
AND jsonb_array_length(mt.pieceskeleton::jsonb->'products') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_product_requirements spr WHERE spr."modeltypeid" = mt.id)
|
||||
AND EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (product->>'typeProductId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: componentSkeleton.subcomponents → skeleton_subcomponent_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_subcomponent_requirements (id, "modeltypeid", alias, "familycode", "typecomposantid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
COALESCE(sub->>'alias', ''),
|
||||
COALESCE(sub->>'familyCode', ''),
|
||||
NULLIF(sub->>'typeComposantId', ''),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.componentskeleton::jsonb->'subcomponents') WITH ORDINALITY AS t(sub, ordinality)
|
||||
WHERE mt.category = 'COMPONENT'
|
||||
AND mt.componentskeleton IS NOT NULL
|
||||
AND mt.componentskeleton::jsonb->'subcomponents' IS NOT NULL
|
||||
AND jsonb_array_length(mt.componentskeleton::jsonb->'subcomponents') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_subcomponent_requirements ssr WHERE ssr."modeltypeid" = mt.id)
|
||||
AND (NULLIF(sub->>'typeComposantId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (sub->>'typeComposantId')))
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS skeleton_subcomponent_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS skeleton_product_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS skeleton_piece_requirements');
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260312171810 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create piece_products join table and migrate data from Piece.productIds JSON column';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE IF NOT EXISTS piece_products (piece_id VARCHAR(36) NOT NULL, product_id VARCHAR(36) NOT NULL, PRIMARY KEY (piece_id, product_id))');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_87C835B5C40FCFA8 ON piece_products (piece_id)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_87C835B54584665A ON piece_products (product_id)');
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B5C40FCFA8');
|
||||
$this->addSql('ALTER TABLE piece_products ADD CONSTRAINT FK_87C835B5C40FCFA8 FOREIGN KEY (piece_id) REFERENCES pieces (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B54584665A');
|
||||
$this->addSql('ALTER TABLE piece_products ADD CONSTRAINT FK_87C835B54584665A FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
|
||||
// Migrate Piece.productIds JSON array → piece_products join table
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO piece_products (piece_id, product_id)
|
||||
SELECT DISTINCT p.id, pid.value
|
||||
FROM pieces p,
|
||||
LATERAL jsonb_array_elements_text(p.productids::jsonb) AS pid(value)
|
||||
WHERE p.productids IS NOT NULL
|
||||
AND p.productids::jsonb != '[]'::jsonb
|
||||
AND jsonb_array_length(p.productids::jsonb) > 0
|
||||
AND EXISTS (SELECT 1 FROM products pr WHERE pr.id = pid.value)
|
||||
AND NOT EXISTS (SELECT 1 FROM piece_products pp WHERE pp.piece_id = p.id AND pp.product_id = pid.value)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B5C40FCFA8');
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B54584665A');
|
||||
$this->addSql('DROP TABLE IF EXISTS piece_products');
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drop skeleton JSON columns from model_types — data now lives in
|
||||
* skeleton_piece_requirements, skeleton_product_requirements,
|
||||
* skeleton_subcomponent_requirements and custom_fields tables.
|
||||
*/
|
||||
final class Version20260312180000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop componentSkeleton, pieceSkeleton, productSkeleton JSON columns from model_types';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS componentskeleton');
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS pieceskeleton');
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS productskeleton');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS componentskeleton JSON DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS pieceskeleton JSON DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS productskeleton JSON DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Create composant slot tables and migrate existing JSON data from composant.structure.
|
||||
*/
|
||||
final class Version20260312190000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create composant_piece_slots, composant_subcomponent_slots, composant_product_slots tables and migrate data from composant.structure JSON';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Table creation (idempotent) ──────────────────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS composant_piece_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"composantid" VARCHAR(36) NOT NULL,
|
||||
"typepieceid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedpieceid" VARCHAR(36) DEFAULT NULL,
|
||||
quantity INT NOT NULL DEFAULT 1,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS composant_subcomponent_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"composantid" VARCHAR(36) NOT NULL,
|
||||
alias VARCHAR(255) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
"typecomposantid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedcomposantid" VARCHAR(36) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS composant_product_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"composantid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ─────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_piece_slot_composant ON composant_piece_slots("composantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_piece_slot_piece ON composant_piece_slots("selectedpieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_piece_slot_type ON composant_piece_slots("typepieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_sub_slot_composant ON composant_subcomponent_slots("composantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_sub_slot_typecomp ON composant_subcomponent_slots("typecomposantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_sub_slot_selected ON composant_subcomponent_slots("selectedcomposantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_prod_slot_composant ON composant_product_slots("composantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_prod_slot_type ON composant_product_slots("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_prod_slot_selected ON composant_product_slots("selectedproductid")');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ────────────────────────
|
||||
|
||||
// composant_piece_slots FKs
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_piece_slot_composant') THEN
|
||||
ALTER TABLE composant_piece_slots
|
||||
ADD CONSTRAINT fk_comp_piece_slot_composant
|
||||
FOREIGN KEY ("composantid") REFERENCES composants (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_piece_slot_type') THEN
|
||||
ALTER TABLE composant_piece_slots
|
||||
ADD CONSTRAINT fk_comp_piece_slot_type
|
||||
FOREIGN KEY ("typepieceid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_piece_slot_piece') THEN
|
||||
ALTER TABLE composant_piece_slots
|
||||
ADD CONSTRAINT fk_comp_piece_slot_piece
|
||||
FOREIGN KEY ("selectedpieceid") REFERENCES pieces (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// composant_subcomponent_slots FKs
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_sub_slot_composant') THEN
|
||||
ALTER TABLE composant_subcomponent_slots
|
||||
ADD CONSTRAINT fk_comp_sub_slot_composant
|
||||
FOREIGN KEY ("composantid") REFERENCES composants (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_sub_slot_typecomp') THEN
|
||||
ALTER TABLE composant_subcomponent_slots
|
||||
ADD CONSTRAINT fk_comp_sub_slot_typecomp
|
||||
FOREIGN KEY ("typecomposantid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_sub_slot_selected') THEN
|
||||
ALTER TABLE composant_subcomponent_slots
|
||||
ADD CONSTRAINT fk_comp_sub_slot_selected
|
||||
FOREIGN KEY ("selectedcomposantid") REFERENCES composants (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// composant_product_slots FKs
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_prod_slot_composant') THEN
|
||||
ALTER TABLE composant_product_slots
|
||||
ADD CONSTRAINT fk_comp_prod_slot_composant
|
||||
FOREIGN KEY ("composantid") REFERENCES composants (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_prod_slot_type') THEN
|
||||
ALTER TABLE composant_product_slots
|
||||
ADD CONSTRAINT fk_comp_prod_slot_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_prod_slot_selected') THEN
|
||||
ALTER TABLE composant_product_slots
|
||||
ADD CONSTRAINT fk_comp_prod_slot_selected
|
||||
FOREIGN KEY ("selectedproductid") REFERENCES products (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Data migration: composant.structure.pieces → composant_piece_slots ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_piece_slots (id, "composantid", "typepieceid", "selectedpieceid", quantity, position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
c.id,
|
||||
NULLIF(piece->'definition'->>'typePieceId', ''),
|
||||
NULLIF(piece->>'selectedPieceId', ''),
|
||||
1,
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM composants c,
|
||||
LATERAL jsonb_array_elements(c.structure::jsonb->'pieces') WITH ORDINALITY AS t(piece, ordinality)
|
||||
WHERE c.structure IS NOT NULL
|
||||
AND (c.structure::jsonb->'pieces') IS NOT NULL
|
||||
AND jsonb_array_length(c.structure::jsonb->'pieces') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM composant_piece_slots cps WHERE cps."composantid" = c.id)
|
||||
AND (NULLIF(piece->'definition'->>'typePieceId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types mt WHERE mt.id = piece->'definition'->>'typePieceId'))
|
||||
AND (NULLIF(piece->>'selectedPieceId', '') IS NULL OR EXISTS (SELECT 1 FROM pieces p WHERE p.id = piece->>'selectedPieceId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: composant.structure.subcomponents → composant_subcomponent_slots ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_subcomponent_slots (id, "composantid", alias, "familycode", "typecomposantid", "selectedcomposantid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
c.id,
|
||||
COALESCE(sub->'definition'->>'alias', ''),
|
||||
COALESCE(sub->'definition'->>'familyCode', ''),
|
||||
NULLIF(sub->'definition'->>'typeComposantId', ''),
|
||||
NULLIF(sub->>'selectedComponentId', ''),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM composants c,
|
||||
LATERAL jsonb_array_elements(c.structure::jsonb->'subcomponents') WITH ORDINALITY AS t(sub, ordinality)
|
||||
WHERE c.structure IS NOT NULL
|
||||
AND (c.structure::jsonb->'subcomponents') IS NOT NULL
|
||||
AND jsonb_array_length(c.structure::jsonb->'subcomponents') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM composant_subcomponent_slots css WHERE css."composantid" = c.id)
|
||||
AND (NULLIF(sub->'definition'->>'typeComposantId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types mt WHERE mt.id = sub->'definition'->>'typeComposantId'))
|
||||
AND (NULLIF(sub->>'selectedComponentId', '') IS NULL OR EXISTS (SELECT 1 FROM composants sc WHERE sc.id = sub->>'selectedComponentId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: composant.structure.products → composant_product_slots ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_product_slots (id, "composantid", "typeproductid", "selectedproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
c.id,
|
||||
NULLIF(prod->'definition'->>'typeProductId', ''),
|
||||
NULLIF(prod->>'selectedProductId', ''),
|
||||
prod->'definition'->>'familyCode',
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM composants c,
|
||||
LATERAL jsonb_array_elements(c.structure::jsonb->'products') WITH ORDINALITY AS t(prod, ordinality)
|
||||
WHERE c.structure IS NOT NULL
|
||||
AND (c.structure::jsonb->'products') IS NOT NULL
|
||||
AND jsonb_array_length(c.structure::jsonb->'products') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM composant_product_slots cps WHERE cps."composantid" = c.id)
|
||||
AND (NULLIF(prod->'definition'->>'typeProductId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types mt WHERE mt.id = prod->'definition'->>'typeProductId'))
|
||||
AND (NULLIF(prod->>'selectedProductId', '') IS NULL OR EXISTS (SELECT 1 FROM products p WHERE p.id = prod->>'selectedProductId'))
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS composant_product_slots');
|
||||
$this->addSql('DROP TABLE IF EXISTS composant_subcomponent_slots');
|
||||
$this->addSql('DROP TABLE IF EXISTS composant_piece_slots');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drop the legacy productIds JSON column from pieces table.
|
||||
* Data has been migrated to the piece_products join table.
|
||||
*/
|
||||
final class Version20260312200000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop legacy productIds JSON column from pieces table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS productids');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN productids JSON DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drop the legacy structure JSON column from composants table.
|
||||
* Data has been migrated to composant_piece_slots, composant_subcomponent_slots, composant_product_slots tables.
|
||||
*/
|
||||
final class Version20260312210000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop legacy structure JSON column from composants table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS structure');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN structure JSON DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Create piece_product_slots table (mirroring composant_product_slots)
|
||||
* and add version columns to composants, pieces, products.
|
||||
*/
|
||||
final class Version20260313124029 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create piece_product_slots table, add version columns to composants/pieces/products, migrate piece_products data';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Create piece_product_slots table (idempotent) ─────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS piece_product_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"pieceid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ──────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_piece ON piece_product_slots ("pieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_type ON piece_product_slots ("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_selected ON piece_product_slots ("selectedproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_product_slots_piece_pos ON piece_product_slots ("pieceid", position)');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ─────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_piece') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_piece
|
||||
FOREIGN KEY ("pieceid") REFERENCES pieces (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_type') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_selected') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_selected
|
||||
FOREIGN KEY ("selectedproductid") REFERENCES products (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Add version columns (idempotent) ─────────────────────────────────
|
||||
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
$this->addSql('ALTER TABLE products ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
|
||||
// ── Data migration: piece_products → piece_product_slots ─────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'piece_products') THEN
|
||||
INSERT INTO piece_product_slots (id, "pieceid", "typeproductid", "selectedproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
pp.piece_id,
|
||||
p.typeproductid,
|
||||
pp.product_id,
|
||||
NULL,
|
||||
ROW_NUMBER() OVER (PARTITION BY pp.piece_id ORDER BY pp.product_id) - 1,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM piece_products pp
|
||||
JOIN products p ON p.id = pp.product_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM piece_product_slots pps
|
||||
WHERE pps."pieceid" = pp.piece_id AND pps."selectedproductid" = pp.product_id
|
||||
);
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS piece_product_slots');
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS version');
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS version');
|
||||
$this->addSql('ALTER TABLE products DROP COLUMN IF EXISTS version');
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Scaffold missing composant slots for existing composants that have
|
||||
* a typeComposant with skeleton requirements but no corresponding slots.
|
||||
*/
|
||||
final class Version20260323100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Scaffold missing composant slots from skeleton requirements for existing composants';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Piece slots
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_piece_slots (id, "composantid", "typepieceid", quantity, position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
|
||||
c.id,
|
||||
spr."typepieceid",
|
||||
1,
|
||||
spr.position,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM composants c
|
||||
JOIN skeleton_piece_requirements spr ON spr."modeltypeid" = c."typecomposantid"
|
||||
WHERE c."typecomposantid" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM composant_piece_slots cps
|
||||
WHERE cps."composantid" = c.id AND cps."typepieceid" = spr."typepieceid"
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Product slots
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_product_slots (id, "composantid", "typeproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
|
||||
c.id,
|
||||
spr."typeproductid",
|
||||
spr."familycode",
|
||||
spr.position,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM composants c
|
||||
JOIN skeleton_product_requirements spr ON spr."modeltypeid" = c."typecomposantid"
|
||||
WHERE c."typecomposantid" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM composant_product_slots cps
|
||||
WHERE cps."composantid" = c.id AND cps."typeproductid" = spr."typeproductid"
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Subcomponent slots
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_subcomponent_slots (id, "composantid", alias, "familycode", "typecomposantid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
|
||||
c.id,
|
||||
spr.alias,
|
||||
spr."familycode",
|
||||
spr."typecomposantid",
|
||||
spr.position,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM composants c
|
||||
JOIN skeleton_subcomponent_requirements spr ON spr."modeltypeid" = c."typecomposantid"
|
||||
WHERE c."typecomposantid" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM composant_subcomponent_slots css
|
||||
WHERE css."composantid" = c.id
|
||||
AND COALESCE(css."typecomposantid", '') = COALESCE(spr."typecomposantid", '')
|
||||
AND COALESCE(css.alias, '') = COALESCE(spr.alias, '')
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// No-op: slots created by this migration are valid data
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260323141052 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add type column to documents table and classify existing documents by mimeType';
|
||||
}
|
||||
|
||||
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/%'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS type');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260325214500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Remove unique constraint on composants.name (uniqueness on reference is now enforced at application level via UniqueEntity)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_f95a31995e237e06');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f95a31995e237e06 ON composants (name)');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260326100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add version column to audit_logs and machines tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS version INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE machines ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entitytype, entityid, version)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_audit_entity_version');
|
||||
$this->addSql('ALTER TABLE audit_logs DROP COLUMN IF EXISTS version');
|
||||
$this->addSql('ALTER TABLE machines DROP COLUMN IF EXISTS version');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260331100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add referenceAuto to composants';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS referenceauto VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS referenceauto');
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo "--- Audit logs with customField deletions (to:null) ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT al.entityid, al.entitytype, al.diff::text as diff, al.createdat
|
||||
FROM audit_logs al
|
||||
WHERE al.diff::text LIKE '%customField%'
|
||||
AND al.diff::text LIKE '%\"to\":null%'
|
||||
ORDER BY al.createdat DESC
|
||||
LIMIT 20
|
||||
");
|
||||
echo sprintf("Found %d entries\n\n", count($rows));
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf("[%s] %s %s: %s\n", $r['createdat'], $r['entitytype'], $r['entityid'], substr($r['diff'], 0, 120));
|
||||
}
|
||||
|
||||
echo "\n--- Orphaned CFValues (pointing to CFs with no ModelType) ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT COUNT(*) as cnt,
|
||||
CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece'
|
||||
WHEN cfv.composantid IS NOT NULL THEN 'composant'
|
||||
WHEN cfv.productid IS NOT NULL THEN 'product'
|
||||
ELSE 'unknown' END as entity_type
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
|
||||
GROUP BY entity_type
|
||||
");
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" %s: %d orphaned values\n", $r['entity_type'], $r['cnt']);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo "--- ModelTypes with orphaned piece values (CFs lost) ---\n\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT mt.id, mt.name, mt.category,
|
||||
cf_orphan.name as lost_field,
|
||||
COUNT(*) as affected_pieces,
|
||||
COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as with_data
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid
|
||||
JOIN pieces p ON p.id = cfv.pieceid
|
||||
JOIN model_types mt ON mt.id = p.typepieceid
|
||||
WHERE cf_orphan.typecomposantid IS NULL
|
||||
AND cf_orphan.typepieceid IS NULL
|
||||
AND cf_orphan.typeproductid IS NULL
|
||||
GROUP BY mt.id, mt.name, mt.category, cf_orphan.name
|
||||
ORDER BY mt.name, cf_orphan.name
|
||||
");
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$status = $r['with_data'] > 0 ? 'HAS DATA' : 'empty';
|
||||
echo sprintf(" ModelType '%s' | field '%s' | %d pieces (%d with data) [%s]\n",
|
||||
$r['name'], $r['lost_field'], $r['affected_pieces'], $r['with_data'], $status);
|
||||
}
|
||||
|
||||
echo sprintf("\nTotal: %d ModelType/field combinations\n", count($rows));
|
||||
|
||||
// Check if these fields exist on the current ModelType
|
||||
echo "\n--- Current CFs on these ModelTypes ---\n\n";
|
||||
$mtIds = array_unique(array_column($rows, 'id'));
|
||||
foreach ($mtIds as $mtId) {
|
||||
$mtName = $conn->fetchOne("SELECT name FROM model_types WHERE id = ?", [$mtId]);
|
||||
$currentCfs = $conn->fetchAllAssociative(
|
||||
"SELECT name FROM custom_fields WHERE typepieceid = ? ORDER BY orderindex",
|
||||
[$mtId]
|
||||
);
|
||||
$cfNames = array_column($currentCfs, 'name');
|
||||
echo sprintf(" '%s': %s\n", $mtName, $cfNames ? implode(', ', $cfNames) : '(aucun CF)');
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
// Show a sample of orphaned CFValues for pieces
|
||||
echo "--- Sample orphaned piece CFValues ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT cfv.id as cfv_id, cfv.value, cfv.pieceid,
|
||||
cf.id as cf_id, cf.name as cf_name,
|
||||
cf.typecomposantid, cf.typepieceid, cf.typeproductid,
|
||||
p.name as piece_name, p.typepieceid as piece_modeltype
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
JOIN pieces p ON p.id = cfv.pieceid
|
||||
WHERE cfv.pieceid IS NOT NULL
|
||||
AND cf.typepieceid IS NULL
|
||||
ORDER BY p.name
|
||||
LIMIT 10
|
||||
");
|
||||
echo sprintf("Found %d (limited to 10)\n\n", count($rows));
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" Piece '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n",
|
||||
$r['piece_name'], $r['cf_name'], $r['value'],
|
||||
$r['typecomposantid'] ?? 'NULL',
|
||||
$r['typepieceid'] ?? 'NULL',
|
||||
$r['typeproductid'] ?? 'NULL'
|
||||
);
|
||||
}
|
||||
|
||||
// Show a sample of orphaned CFValues for composants
|
||||
echo "\n--- Sample orphaned composant CFValues ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT cfv.id as cfv_id, cfv.value, cfv.composantid,
|
||||
cf.id as cf_id, cf.name as cf_name,
|
||||
cf.typecomposantid, cf.typepieceid, cf.typeproductid,
|
||||
c.name as composant_name, c.typecomposantid as composant_modeltype
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
JOIN composants c ON c.id = cfv.composantid
|
||||
WHERE cfv.composantid IS NOT NULL
|
||||
AND cf.typecomposantid IS NULL
|
||||
ORDER BY c.name
|
||||
LIMIT 10
|
||||
");
|
||||
echo sprintf("Found %d (limited to 10)\n\n", count($rows));
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" Composant '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n",
|
||||
$r['composant_name'], $r['cf_name'], $r['value'],
|
||||
$r['typecomposantid'] ?? 'NULL',
|
||||
$r['typepieceid'] ?? 'NULL',
|
||||
$r['typeproductid'] ?? 'NULL'
|
||||
);
|
||||
}
|
||||
|
||||
// Check: are there CFs with ONLY typepieceid NULL but other FKs set?
|
||||
echo "\n--- Orphaned CF FK patterns ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT
|
||||
CASE WHEN typecomposantid IS NULL THEN 'NULL' ELSE 'SET' END as composant_fk,
|
||||
CASE WHEN typepieceid IS NULL THEN 'NULL' ELSE 'SET' END as piece_fk,
|
||||
CASE WHEN typeproductid IS NULL THEN 'NULL' ELSE 'SET' END as product_fk,
|
||||
COUNT(*) as cnt
|
||||
FROM custom_fields
|
||||
GROUP BY composant_fk, piece_fk, product_fk
|
||||
ORDER BY cnt DESC
|
||||
");
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" composant=%s piece=%s product=%s : %d CFs\n",
|
||||
$r['composant_fk'], $r['piece_fk'], $r['product_fk'], $r['cnt']);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo "--- Piece 'Arbre du palier pied E1' ---\n";
|
||||
$rows = $conn->fetchAllAssociative("SELECT p.name, cfv.value, cf.name as field_name FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE p.id = 'cl3d978dd4b071daff8fb185f7' ORDER BY cf.orderindex");
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']);
|
||||
}
|
||||
|
||||
echo "\n--- Composant 'Cage écureuil pied E8' ---\n";
|
||||
$rows = $conn->fetchAllAssociative("SELECT c.name, cfv.value, cf.name as field_name FROM composants c JOIN custom_field_values cfv ON cfv.composantid = c.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE c.id = 'cl5b5e336095de8d4ece81b2dc' ORDER BY cf.orderindex");
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']);
|
||||
}
|
||||
|
||||
echo "\n--- Count empty piece values (ModelType Arbre) ---\n";
|
||||
$count = $conn->fetchOne("SELECT COUNT(*) FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id WHERE p.typepieceid = 'cmgujpyjf002q4705j6hv1nkk' AND (cfv.value = '' OR cfv.value IS NULL)");
|
||||
echo sprintf(" Empty values: %d\n", $count);
|
||||
|
||||
echo "\n--- Count orphaned CustomField definitions ---\n";
|
||||
$count = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL');
|
||||
echo sprintf(" Orphaned CFs: %d\n", $count);
|
||||
@@ -1,199 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Combined fix script for prod:
|
||||
* 1. Migrate orphaned CFValues to current CFs (by name match)
|
||||
* 2. Restore deleted composant values from audit logs
|
||||
* 3. Clean up orphaned CF definitions
|
||||
*
|
||||
* Usage: php scripts/fix-prod-all.php [--dry-run]
|
||||
*/
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
|
||||
|
||||
$migratedCount = 0;
|
||||
$restoredCount = 0;
|
||||
$deletedOrphanedCfv = 0;
|
||||
$deletedOrphanedCf = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
// ============================================================
|
||||
// PART 1: Migrate orphaned CFValues to current CFs
|
||||
// ============================================================
|
||||
echo "--- PART 1: Migrate orphaned CFValues ---\n\n";
|
||||
|
||||
$entityTypes = [
|
||||
['label' => 'piece', 'entityTable' => 'pieces', 'cfvFk' => 'pieceid', 'modelTypeFk' => 'typepieceid', 'cfModelTypeFk' => 'typepieceid'],
|
||||
['label' => 'composant', 'entityTable' => 'composants', 'cfvFk' => 'composantid', 'modelTypeFk' => 'typecomposantid', 'cfModelTypeFk' => 'typecomposantid'],
|
||||
['label' => 'product', 'entityTable' => 'products', 'cfvFk' => 'productid', 'modelTypeFk' => 'typeproductid', 'cfModelTypeFk' => 'typeproductid'],
|
||||
];
|
||||
|
||||
foreach ($entityTypes as $et) {
|
||||
// Find orphaned CFValues: the CF has ALL 3 FKs NULL
|
||||
$orphanedValues = $conn->fetchAllAssociative("
|
||||
SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id,
|
||||
cf_old.id as old_cf_id, cf_old.name as field_name,
|
||||
e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid
|
||||
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
|
||||
WHERE cfv.{$et['cfvFk']} IS NOT NULL
|
||||
AND cf_old.typecomposantid IS NULL
|
||||
AND cf_old.typepieceid IS NULL
|
||||
AND cf_old.typeproductid IS NULL
|
||||
ORDER BY e.name, cf_old.name
|
||||
");
|
||||
|
||||
echo sprintf(" %ss: %d orphaned values\n", $et['label'], count($orphanedValues));
|
||||
|
||||
foreach ($orphanedValues as $ov) {
|
||||
if (!$ov['model_type_id']) {
|
||||
++$skippedCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentCf = $conn->fetchAssociative(
|
||||
"SELECT id FROM custom_fields WHERE {$et['cfModelTypeFk']} = ? AND name = ? LIMIT 1",
|
||||
[$ov['model_type_id'], $ov['field_name']]
|
||||
);
|
||||
|
||||
if (!$currentCf) {
|
||||
// No matching CF on current ModelType — skip but keep value
|
||||
++$skippedCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingValue = $conn->fetchAssociative(
|
||||
"SELECT id, value FROM custom_field_values WHERE {$et['cfvFk']} = ? AND customfieldid = ?",
|
||||
[$ov['entity_id'], $currentCf['id']]
|
||||
);
|
||||
|
||||
if ($existingValue) {
|
||||
if (('' === $existingValue['value'] || null === $existingValue['value']) && '' !== $ov['value'] && null !== $ov['value']) {
|
||||
echo sprintf(" MIGRATE: %s '%s' field '%s' = '%s'\n", $et['label'], $ov['entity_name'], $ov['field_name'], $ov['value']);
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$ov['value'], $existingValue['id']]);
|
||||
}
|
||||
++$migratedCount;
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
|
||||
}
|
||||
++$deletedOrphanedCfv;
|
||||
} else {
|
||||
echo sprintf(" REASSIGN: %s '%s' field '%s' = '%s'\n", $et['label'], $ov['entity_name'], $ov['field_name'], $ov['value']);
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', [$currentCf['id'], $ov['cfv_id']]);
|
||||
}
|
||||
++$migratedCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART 2: Restore composant values from audit logs
|
||||
// ============================================================
|
||||
echo "\n--- PART 2: Restore values from audit logs ---\n\n";
|
||||
|
||||
$deletionLogs = $conn->fetchAllAssociative("
|
||||
SELECT al.entityid, al.entitytype, al.diff::text as diff
|
||||
FROM audit_logs al
|
||||
WHERE al.diff::text LIKE '%customField%'
|
||||
AND al.diff::text LIKE '%\"to\":null%'
|
||||
ORDER BY al.createdat DESC
|
||||
");
|
||||
|
||||
echo sprintf(" Found %d audit entries with deleted values\n", count($deletionLogs));
|
||||
|
||||
foreach ($deletionLogs as $log) {
|
||||
$diff = json_decode($log['diff'], true);
|
||||
$entityType = $log['entitytype'];
|
||||
|
||||
$cfvFk = match ($entityType) {
|
||||
'piece' => 'pieceid',
|
||||
'composant' => 'composantid',
|
||||
'product' => 'productid',
|
||||
default => null,
|
||||
};
|
||||
if (!$cfvFk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($diff as $key => $change) {
|
||||
if (!str_starts_with($key, 'customField:')) {
|
||||
continue;
|
||||
}
|
||||
if (null !== $change['to']) {
|
||||
continue;
|
||||
}
|
||||
$oldValue = $change['from'];
|
||||
if (null === $oldValue || '' === $oldValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldName = substr($key, strlen('customField:'));
|
||||
|
||||
$cfv = $conn->fetchAssociative(
|
||||
"SELECT cfv.id, cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$cfvFk} = ? AND cf.name = ?",
|
||||
[$log['entityid'], $fieldName]
|
||||
);
|
||||
|
||||
if (!$cfv) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('' !== $cfv['value'] && null !== $cfv['value']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
echo sprintf(" RESTORE: %s %s field '%s' = '%s'\n", $entityType, $log['entityid'], $fieldName, $oldValue);
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$oldValue, $cfv['id']]);
|
||||
}
|
||||
++$restoredCount;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART 3: Clean orphaned CF definitions
|
||||
// ============================================================
|
||||
echo "\n--- PART 3: Clean orphaned CF definitions ---\n\n";
|
||||
|
||||
$orphanedCfs = $conn->fetchAllAssociative('
|
||||
SELECT cf.id FROM custom_fields cf
|
||||
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = cf.id)
|
||||
');
|
||||
|
||||
echo sprintf(" %d orphaned CF definitions to delete\n", count($orphanedCfs));
|
||||
foreach ($orphanedCfs as $cf) {
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]);
|
||||
}
|
||||
++$deletedOrphanedCf;
|
||||
}
|
||||
|
||||
echo sprintf("\n=== SUMMARY ===\n");
|
||||
echo sprintf("Values migrated/reassigned: %d\n", $migratedCount);
|
||||
echo sprintf("Values restored from audit: %d\n", $restoredCount);
|
||||
echo sprintf("Orphaned CFValues cleaned: %d\n", $deletedOrphanedCfv);
|
||||
echo sprintf("Orphaned CF definitions deleted: %d\n", $deletedOrphanedCf);
|
||||
echo sprintf("Skipped (no matching CF on ModelType): %d\n", $skippedCount);
|
||||
echo "=== DONE ===\n";
|
||||
@@ -1,266 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Full prod fix:
|
||||
* 1. Re-create missing CustomField definitions on ModelTypes (from orphaned CFs that still have values)
|
||||
* 2. Migrate orphaned CFValues to the newly created CFs
|
||||
* 3. Restore deleted values from audit logs
|
||||
* 4. Clean up orphaned CFs with no remaining values
|
||||
*
|
||||
* Usage: php scripts/fix-prod-recreate-and-migrate.php [--dry-run]
|
||||
*/
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
|
||||
|
||||
$createdCfCount = 0;
|
||||
$migratedCount = 0;
|
||||
$restoredCount = 0;
|
||||
$deletedOrphanedCfv = 0;
|
||||
$deletedOrphanedCf = 0;
|
||||
|
||||
$entityTypes = [
|
||||
['label' => 'piece', 'entityTable' => 'pieces', 'cfvFk' => 'pieceid', 'modelTypeFk' => 'typepieceid', 'cfFk' => 'typepieceid'],
|
||||
['label' => 'composant', 'entityTable' => 'composants', 'cfvFk' => 'composantid', 'modelTypeFk' => 'typecomposantid', 'cfFk' => 'typecomposantid'],
|
||||
['label' => 'product', 'entityTable' => 'products', 'cfvFk' => 'productid', 'modelTypeFk' => 'typeproductid', 'cfFk' => 'typeproductid'],
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// PART 1: Re-create missing CF definitions on ModelTypes
|
||||
// ============================================================
|
||||
echo "--- PART 1: Re-create missing CF definitions ---\n\n";
|
||||
|
||||
foreach ($entityTypes as $et) {
|
||||
// Find distinct (ModelType, field name, type) from orphaned CFs that have values
|
||||
$missingDefs = $conn->fetchAllAssociative("
|
||||
SELECT e.{$et['modelTypeFk']} as model_type_id,
|
||||
mt.name as model_type_name,
|
||||
cf_orphan.name as field_name,
|
||||
MIN(cf_orphan.type) as field_type,
|
||||
BOOL_OR(COALESCE(cf_orphan.required, false)) as field_required,
|
||||
MIN(cf_orphan.options::text) as field_options,
|
||||
MIN(cf_orphan.defaultvalue) as field_default
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid
|
||||
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
|
||||
JOIN model_types mt ON mt.id = e.{$et['modelTypeFk']}
|
||||
WHERE cfv.{$et['cfvFk']} IS NOT NULL
|
||||
AND cf_orphan.typecomposantid IS NULL
|
||||
AND cf_orphan.typepieceid IS NULL
|
||||
AND cf_orphan.typeproductid IS NULL
|
||||
GROUP BY e.{$et['modelTypeFk']}, mt.name, cf_orphan.name
|
||||
ORDER BY mt.name, cf_orphan.name
|
||||
");
|
||||
|
||||
foreach ($missingDefs as $def) {
|
||||
// Check if this CF already exists on the ModelType
|
||||
$exists = $conn->fetchOne(
|
||||
"SELECT COUNT(*) FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ?",
|
||||
[$def['model_type_id'], $def['field_name']]
|
||||
);
|
||||
|
||||
if ($exists > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get next orderIndex
|
||||
$maxOrder = $conn->fetchOne(
|
||||
"SELECT COALESCE(MAX(orderindex), -1) FROM custom_fields WHERE {$et['cfFk']} = ?",
|
||||
[$def['model_type_id']]
|
||||
);
|
||||
$nextOrder = ((int) $maxOrder) + 1;
|
||||
|
||||
// Generate CUID-like ID
|
||||
$newId = 'cl' . bin2hex(random_bytes(12));
|
||||
|
||||
echo sprintf(" CREATE CF: ModelType '%s' (%s) + field '%s' (type=%s)\n",
|
||||
$def['model_type_name'], $et['label'], $def['field_name'], $def['field_type']);
|
||||
|
||||
if (!$dryRun) {
|
||||
$options = $def['field_options'];
|
||||
if (null !== $options && 'null' === $options) {
|
||||
$options = null;
|
||||
}
|
||||
$required = !empty($def['field_required']) && 'f' !== $def['field_required'];
|
||||
$conn->executeStatement(
|
||||
"INSERT INTO custom_fields (id, name, type, required, options, defaultvalue, orderindex, {$et['cfFk']})
|
||||
VALUES (?, ?, ?, ?::boolean, ?::json, ?, ?, ?)",
|
||||
[
|
||||
$newId,
|
||||
$def['field_name'],
|
||||
$def['field_type'],
|
||||
$required ? 'true' : 'false',
|
||||
$options,
|
||||
$def['field_default'],
|
||||
$nextOrder,
|
||||
$def['model_type_id'],
|
||||
]
|
||||
);
|
||||
}
|
||||
++$createdCfCount;
|
||||
}
|
||||
}
|
||||
|
||||
echo sprintf("\n Created %d CF definitions\n\n", $createdCfCount);
|
||||
|
||||
// ============================================================
|
||||
// PART 2: Migrate orphaned CFValues to current CFs
|
||||
// ============================================================
|
||||
echo "--- PART 2: Migrate orphaned CFValues ---\n\n";
|
||||
|
||||
foreach ($entityTypes as $et) {
|
||||
$orphanedValues = $conn->fetchAllAssociative("
|
||||
SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id,
|
||||
cf_old.name as field_name,
|
||||
e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid
|
||||
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
|
||||
WHERE cfv.{$et['cfvFk']} IS NOT NULL
|
||||
AND cf_old.typecomposantid IS NULL
|
||||
AND cf_old.typepieceid IS NULL
|
||||
AND cf_old.typeproductid IS NULL
|
||||
ORDER BY e.name, cf_old.name
|
||||
");
|
||||
|
||||
$migrated = 0;
|
||||
$cleaned = 0;
|
||||
|
||||
foreach ($orphanedValues as $ov) {
|
||||
if (!$ov['model_type_id']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentCf = $conn->fetchAssociative(
|
||||
"SELECT id FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ? LIMIT 1",
|
||||
[$ov['model_type_id'], $ov['field_name']]
|
||||
);
|
||||
|
||||
if (!$currentCf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingValue = $conn->fetchAssociative(
|
||||
"SELECT id, value FROM custom_field_values WHERE {$et['cfvFk']} = ? AND customfieldid = ?",
|
||||
[$ov['entity_id'], $currentCf['id']]
|
||||
);
|
||||
|
||||
if ($existingValue) {
|
||||
if (('' === $existingValue['value'] || null === $existingValue['value']) && '' !== $ov['value'] && null !== $ov['value']) {
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$ov['value'], $existingValue['id']]);
|
||||
}
|
||||
++$migrated;
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
|
||||
}
|
||||
++$cleaned;
|
||||
} else {
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', [$currentCf['id'], $ov['cfv_id']]);
|
||||
}
|
||||
++$migrated;
|
||||
}
|
||||
}
|
||||
|
||||
echo sprintf(" %ss: %d migrated, %d cleaned\n", $et['label'], $migrated, $cleaned);
|
||||
$migratedCount += $migrated;
|
||||
$deletedOrphanedCfv += $cleaned;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PART 3: Restore values from audit logs
|
||||
// ============================================================
|
||||
echo "\n--- PART 3: Restore values from audit logs ---\n\n";
|
||||
|
||||
$deletionLogs = $conn->fetchAllAssociative("
|
||||
SELECT al.entityid, al.entitytype, al.diff::text as diff
|
||||
FROM audit_logs al
|
||||
WHERE al.diff::text LIKE '%customField%'
|
||||
AND al.diff::text LIKE '%\"to\":null%'
|
||||
ORDER BY al.createdat DESC
|
||||
");
|
||||
|
||||
foreach ($deletionLogs as $log) {
|
||||
$diff = json_decode($log['diff'], true);
|
||||
$cfvFk = match ($log['entitytype']) {
|
||||
'piece' => 'pieceid',
|
||||
'composant' => 'composantid',
|
||||
'product' => 'productid',
|
||||
default => null,
|
||||
};
|
||||
if (!$cfvFk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($diff as $key => $change) {
|
||||
if (!str_starts_with($key, 'customField:')) {
|
||||
continue;
|
||||
}
|
||||
if (null !== $change['to'] || null === $change['from'] || '' === $change['from']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldName = substr($key, strlen('customField:'));
|
||||
$cfv = $conn->fetchAssociative(
|
||||
"SELECT cfv.id, cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$cfvFk} = ? AND cf.name = ?",
|
||||
[$log['entityid'], $fieldName]
|
||||
);
|
||||
|
||||
if (!$cfv || ('' !== $cfv['value'] && null !== $cfv['value'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
echo sprintf(" RESTORE: %s %s field '%s' = '%s'\n", $log['entitytype'], $log['entityid'], $fieldName, $change['from']);
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$change['from'], $cfv['id']]);
|
||||
}
|
||||
++$restoredCount;
|
||||
}
|
||||
}
|
||||
|
||||
echo sprintf(" Restored: %d\n", $restoredCount);
|
||||
|
||||
// ============================================================
|
||||
// PART 4: Clean orphaned CFs with no values
|
||||
// ============================================================
|
||||
echo "\n--- PART 4: Clean orphaned CF definitions ---\n\n";
|
||||
|
||||
$orphanedCfs = $conn->fetchAllAssociative('
|
||||
SELECT id FROM custom_fields
|
||||
WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = id)
|
||||
');
|
||||
|
||||
echo sprintf(" %d orphaned CFs to delete\n", count($orphanedCfs));
|
||||
foreach ($orphanedCfs as $cf) {
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]);
|
||||
}
|
||||
++$deletedOrphanedCf;
|
||||
}
|
||||
|
||||
echo sprintf("\n=== SUMMARY ===\n");
|
||||
echo sprintf("CF definitions re-created: %d\n", $createdCfCount);
|
||||
echo sprintf("Values migrated: %d\n", $migratedCount);
|
||||
echo sprintf("Values restored from audit: %d\n", $restoredCount);
|
||||
echo sprintf("Orphaned CFValues cleaned: %d\n", $deletedOrphanedCfv);
|
||||
echo sprintf("Orphaned CFs deleted: %d\n", $deletedOrphanedCf);
|
||||
echo "=== DONE ===\n";
|
||||
@@ -1,233 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Migrate CustomFieldValues from orphaned CustomField definitions to current ones.
|
||||
*
|
||||
* When SkeletonStructureService::updateCustomFields() runs without IDs from the frontend,
|
||||
* it deletes old CustomField definitions and creates new ones. But the FK on custom_fields
|
||||
* is SET NULL (not CASCADE), so old CFs become orphaned (all type FKs = NULL) and their
|
||||
* CFValues still exist but point to the wrong CF.
|
||||
*
|
||||
* This script:
|
||||
* 1. For each entity (composant, piece, product) with CFValues pointing to orphaned CFs,
|
||||
* find the matching current CF by name on the entity's ModelType
|
||||
* 2. Reassign the CFValue to the current CF
|
||||
* 3. Delete orphaned CF definitions that no longer have any values
|
||||
*
|
||||
* Usage: php scripts/migrate-orphaned-custom-fields.php [--dry-run]
|
||||
*/
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
|
||||
$env = getenv('APP_ENV') ?: 'local';
|
||||
$conn = DriverManager::getConnection(match ($env) {
|
||||
'prod' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => getenv('DB_NAME') ?: 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
],
|
||||
default => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'db',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'root',
|
||||
'password' => 'root',
|
||||
],
|
||||
});
|
||||
|
||||
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
|
||||
|
||||
$migratedCount = 0;
|
||||
$deletedCfCount = 0;
|
||||
$skippedCount = 0;
|
||||
$conflictCount = 0;
|
||||
|
||||
// Process each entity type
|
||||
$entityTypes = [
|
||||
[
|
||||
'label' => 'composant',
|
||||
'entityTable' => 'composants',
|
||||
'cfvFk' => 'composantid',
|
||||
'modelTypeFk' => 'typecomposantid',
|
||||
'cfModelTypeFk' => 'typecomposantid',
|
||||
],
|
||||
[
|
||||
'label' => 'piece',
|
||||
'entityTable' => 'pieces',
|
||||
'cfvFk' => 'pieceid',
|
||||
'modelTypeFk' => 'typepieceid',
|
||||
'cfModelTypeFk' => 'typepieceid',
|
||||
],
|
||||
[
|
||||
'label' => 'product',
|
||||
'entityTable' => 'products',
|
||||
'cfvFk' => 'productid',
|
||||
'modelTypeFk' => 'typeproductid',
|
||||
'cfModelTypeFk' => 'typeproductid',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($entityTypes as $et) {
|
||||
echo sprintf("--- Processing %ss ---\n\n", $et['label']);
|
||||
|
||||
// Find all CFValues pointing to orphaned CFs for this entity type
|
||||
$orphanedValues = $conn->fetchAllAssociative("
|
||||
SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id,
|
||||
cf_old.id as old_cf_id, cf_old.name as field_name,
|
||||
e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid
|
||||
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
|
||||
WHERE cfv.{$et['cfvFk']} IS NOT NULL
|
||||
AND cf_old.{$et['cfModelTypeFk']} IS NULL
|
||||
AND cf_old.typecomposantid IS NULL
|
||||
AND cf_old.typepieceid IS NULL
|
||||
AND cf_old.typeproductid IS NULL
|
||||
ORDER BY e.name, cf_old.name
|
||||
");
|
||||
|
||||
echo sprintf(" Found %d orphaned custom field values.\n", count($orphanedValues));
|
||||
|
||||
foreach ($orphanedValues as $ov) {
|
||||
if (!$ov['model_type_id']) {
|
||||
echo sprintf(" SKIP: %s '%s' has no ModelType\n", $et['label'], $ov['entity_name']);
|
||||
++$skippedCount;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the current CF definition on the ModelType with the same name
|
||||
$currentCf = $conn->fetchAssociative("
|
||||
SELECT id FROM custom_fields
|
||||
WHERE {$et['cfModelTypeFk']} = ? AND name = ?
|
||||
LIMIT 1
|
||||
", [$ov['model_type_id'], $ov['field_name']]);
|
||||
|
||||
if (!$currentCf) {
|
||||
echo sprintf(
|
||||
" WARNING: No current CF '%s' on ModelType %s for %s '%s'\n",
|
||||
$ov['field_name'],
|
||||
$ov['model_type_id'],
|
||||
$et['label'],
|
||||
$ov['entity_name']
|
||||
);
|
||||
++$skippedCount;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if entity already has a CFValue for this current CF
|
||||
$existingValue = $conn->fetchAssociative("
|
||||
SELECT id, value FROM custom_field_values
|
||||
WHERE {$et['cfvFk']} = ? AND customfieldid = ?
|
||||
", [$ov['entity_id'], $currentCf['id']]);
|
||||
|
||||
if ($existingValue) {
|
||||
// Current CF already has a value for this entity
|
||||
if ('' !== $existingValue['value'] && null !== $existingValue['value']) {
|
||||
// Both have values — conflict, skip
|
||||
if ('' !== $ov['value'] && null !== $ov['value'] && $ov['value'] !== $existingValue['value']) {
|
||||
echo sprintf(
|
||||
" CONFLICT: %s '%s' field '%s': old='%s' vs current='%s' — keeping current\n",
|
||||
$et['label'],
|
||||
$ov['entity_name'],
|
||||
$ov['field_name'],
|
||||
$ov['value'],
|
||||
$existingValue['value']
|
||||
);
|
||||
++$conflictCount;
|
||||
}
|
||||
// Delete the orphaned value (current one is kept)
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
|
||||
}
|
||||
echo sprintf(
|
||||
" DELETE orphaned: %s '%s' field '%s' (current has value '%s')\n",
|
||||
$et['label'],
|
||||
$ov['entity_name'],
|
||||
$ov['field_name'],
|
||||
$existingValue['value']
|
||||
);
|
||||
} else {
|
||||
// Current value is empty, orphaned has data — update the current one and delete orphaned
|
||||
if ('' !== $ov['value'] && null !== $ov['value']) {
|
||||
echo sprintf(
|
||||
" MIGRATE: %s '%s' field '%s' = '%s'\n",
|
||||
$et['label'],
|
||||
$ov['entity_name'],
|
||||
$ov['field_name'],
|
||||
$ov['value']
|
||||
);
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement(
|
||||
'UPDATE custom_field_values SET value = ? WHERE id = ?',
|
||||
[$ov['value'], $existingValue['id']]
|
||||
);
|
||||
}
|
||||
++$migratedCount;
|
||||
}
|
||||
// Delete the orphaned CFV
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No current CFV exists — reassign the orphaned one to the current CF
|
||||
echo sprintf(
|
||||
" REASSIGN: %s '%s' field '%s' = '%s'\n",
|
||||
$et['label'],
|
||||
$ov['entity_name'],
|
||||
$ov['field_name'],
|
||||
$ov['value']
|
||||
);
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement(
|
||||
'UPDATE custom_field_values SET customfieldid = ? WHERE id = ?',
|
||||
[$currentCf['id'], $ov['cfv_id']]
|
||||
);
|
||||
}
|
||||
++$migratedCount;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Clean up orphaned CF definitions with no remaining values
|
||||
echo "--- Cleaning up orphaned CustomField definitions ---\n\n";
|
||||
|
||||
$orphanedCfs = $conn->fetchAllAssociative('
|
||||
SELECT cf.id, cf.name
|
||||
FROM custom_fields cf
|
||||
WHERE cf.typecomposantid IS NULL
|
||||
AND cf.typepieceid IS NULL
|
||||
AND cf.typeproductid IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = cf.id)
|
||||
ORDER BY cf.name
|
||||
');
|
||||
|
||||
echo sprintf("Found %d orphaned CustomField definitions with no values.\n", count($orphanedCfs));
|
||||
|
||||
foreach ($orphanedCfs as $cf) {
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]);
|
||||
}
|
||||
++$deletedCfCount;
|
||||
}
|
||||
|
||||
echo sprintf("\n=== SUMMARY ===\n");
|
||||
echo sprintf("Values migrated/reassigned: %d\n", $migratedCount);
|
||||
echo sprintf("Orphaned CF definitions deleted: %d\n", $deletedCfCount);
|
||||
echo sprintf("Skipped: %d\n", $skippedCount);
|
||||
echo sprintf("Conflicts (kept current): %d\n", $conflictCount);
|
||||
echo "=== DONE ===\n";
|
||||
@@ -1,196 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Script to restore custom field values lost during ModelType sync.
|
||||
*
|
||||
* Problem: SkeletonStructureService::updateCustomFields() deletes and recreates
|
||||
* CustomField definitions when frontend doesn't send IDs. This cascades to
|
||||
* deleting all CustomFieldValues.
|
||||
*
|
||||
* This script:
|
||||
* 1. Pieces: Restores values from audit_logs (the "from" values in deletion diffs)
|
||||
* 2. Composants: Removes duplicate empty CustomFieldValues created by sync
|
||||
*
|
||||
* Usage: php scripts/restore-custom-field-values.php [--dry-run]
|
||||
*/
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
|
||||
$env = getenv('APP_ENV') ?: 'local';
|
||||
$conn = DriverManager::getConnection(match ($env) {
|
||||
'prod' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => getenv('DB_NAME') ?: 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
],
|
||||
default => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'db',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'root',
|
||||
'password' => 'root',
|
||||
],
|
||||
});
|
||||
|
||||
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
|
||||
|
||||
// ============================================================
|
||||
// PART 1: Restore piece custom field values from audit logs
|
||||
// ============================================================
|
||||
echo "--- PART 1: Restoring piece custom field values ---\n\n";
|
||||
|
||||
// Find all deletion audit entries (where values went from X to null on 2026-03-13)
|
||||
$deletionLogs = $conn->fetchAllAssociative("
|
||||
SELECT al.entityid, al.diff::text as diff, p.name as piece_name
|
||||
FROM audit_logs al
|
||||
JOIN pieces p ON p.id = al.entityid
|
||||
WHERE al.entitytype = 'piece'
|
||||
AND al.action = 'update'
|
||||
AND al.diff::text LIKE '%\"to\":null%'
|
||||
AND al.diff::text LIKE '%customField%'
|
||||
AND al.createdat >= '2026-03-13'
|
||||
ORDER BY p.name
|
||||
");
|
||||
|
||||
echo sprintf("Found %d pieces with deleted custom field values.\n\n", count($deletionLogs));
|
||||
|
||||
$restoredCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
foreach ($deletionLogs as $log) {
|
||||
$pieceId = $log['entityid'];
|
||||
$pieceName = $log['piece_name'];
|
||||
$diff = json_decode($log['diff'], true);
|
||||
|
||||
foreach ($diff as $key => $change) {
|
||||
if (!str_starts_with($key, 'customField:')) {
|
||||
continue;
|
||||
}
|
||||
if (null !== $change['to']) {
|
||||
continue; // Not a deletion
|
||||
}
|
||||
$oldValue = $change['from'];
|
||||
if (null === $oldValue || '' === $oldValue) {
|
||||
continue; // Nothing to restore
|
||||
}
|
||||
|
||||
$fieldName = substr($key, strlen('customField:'));
|
||||
|
||||
// Find the current CustomFieldValue for this piece + field name
|
||||
$cfv = $conn->fetchAssociative('
|
||||
SELECT cfv.id, cfv.value, cf.name as field_name
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
WHERE cfv.pieceid = ?
|
||||
AND cf.name = ?
|
||||
', [$pieceId, $fieldName]);
|
||||
|
||||
if (!$cfv) {
|
||||
echo sprintf(" WARNING: No CustomFieldValue found for piece '%s' field '%s' — skipping\n", $pieceName, $fieldName);
|
||||
++$errorCount;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('' !== $cfv['value'] && null !== $cfv['value']) {
|
||||
echo sprintf(" SKIP: Piece '%s' field '%s' already has value '%s' (would restore '%s')\n", $pieceName, $fieldName, $cfv['value'], $oldValue);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
echo sprintf(" RESTORE: Piece '%s' field '%s' = '%s'\n", $pieceName, $fieldName, $oldValue);
|
||||
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement(
|
||||
'UPDATE custom_field_values SET value = ? WHERE id = ?',
|
||||
[$oldValue, $cfv['id']]
|
||||
);
|
||||
}
|
||||
++$restoredCount;
|
||||
}
|
||||
}
|
||||
|
||||
echo sprintf("\nPieces: %d values restored, %d errors.\n\n", $restoredCount, $errorCount);
|
||||
|
||||
// ============================================================
|
||||
// PART 2: Remove duplicate empty composant CustomFieldValues
|
||||
// ============================================================
|
||||
echo "--- PART 2: Cleaning duplicate composant custom field values ---\n\n";
|
||||
|
||||
// Find composants that have duplicate CFVs (same composantid + same field name, one with value and one empty)
|
||||
$duplicates = $conn->fetchAllAssociative("
|
||||
SELECT cfv_empty.id as empty_cfv_id, c.name as composant_name, cf_empty.name as field_name,
|
||||
cfv_filled.value as existing_value
|
||||
FROM custom_field_values cfv_empty
|
||||
JOIN custom_fields cf_empty ON cf_empty.id = cfv_empty.customfieldid
|
||||
JOIN composants c ON c.id = cfv_empty.composantid
|
||||
JOIN custom_field_values cfv_filled ON cfv_filled.composantid = cfv_empty.composantid
|
||||
JOIN custom_fields cf_filled ON cf_filled.id = cfv_filled.customfieldid
|
||||
WHERE cfv_empty.composantid IS NOT NULL
|
||||
AND cfv_empty.value = ''
|
||||
AND cf_empty.name = cf_filled.name
|
||||
AND cfv_filled.value != ''
|
||||
AND cfv_filled.id != cfv_empty.id
|
||||
ORDER BY c.name, cf_empty.name
|
||||
");
|
||||
|
||||
echo sprintf("Found %d duplicate empty custom field values on composants.\n\n", count($duplicates));
|
||||
|
||||
$deletedDuplicates = 0;
|
||||
foreach ($duplicates as $dup) {
|
||||
echo sprintf(
|
||||
" DELETE empty duplicate: Composant '%s' field '%s' (has value '%s' in other record)\n",
|
||||
$dup['composant_name'],
|
||||
$dup['field_name'],
|
||||
$dup['existing_value']
|
||||
);
|
||||
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$dup['empty_cfv_id']]);
|
||||
}
|
||||
++$deletedDuplicates;
|
||||
}
|
||||
|
||||
// Also find composants with duplicate empty CFVs (both empty, same field name - keep one, delete the other)
|
||||
$emptyDuplicates = $conn->fetchAllAssociative("
|
||||
SELECT cfv2.id as duplicate_id, c.name as composant_name, cf2.name as field_name
|
||||
FROM custom_field_values cfv1
|
||||
JOIN custom_fields cf1 ON cf1.id = cfv1.customfieldid
|
||||
JOIN custom_field_values cfv2 ON cfv2.composantid = cfv1.composantid AND cfv2.id > cfv1.id
|
||||
JOIN custom_fields cf2 ON cf2.id = cfv2.customfieldid
|
||||
JOIN composants c ON c.id = cfv1.composantid
|
||||
WHERE cfv1.composantid IS NOT NULL
|
||||
AND cfv1.value = ''
|
||||
AND cfv2.value = ''
|
||||
AND cf1.name = cf2.name
|
||||
ORDER BY c.name, cf2.name
|
||||
");
|
||||
|
||||
echo sprintf("\nFound %d duplicate empty-empty custom field values on composants.\n\n", count($emptyDuplicates));
|
||||
|
||||
foreach ($emptyDuplicates as $dup) {
|
||||
echo sprintf(
|
||||
" DELETE empty-empty duplicate: Composant '%s' field '%s'\n",
|
||||
$dup['composant_name'],
|
||||
$dup['field_name']
|
||||
);
|
||||
|
||||
if (!$dryRun) {
|
||||
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$dup['duplicate_id']]);
|
||||
}
|
||||
++$deletedDuplicates;
|
||||
}
|
||||
|
||||
echo sprintf("\nComposants: %d duplicate values removed.\n", $deletedDuplicates);
|
||||
|
||||
echo "\n=== DONE ===\n";
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo "=== CUSTOM FIELDS HEALTH CHECK ===\n\n";
|
||||
|
||||
// 1. Orphaned CFs (should be 0)
|
||||
$orphanedCfs = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL');
|
||||
echo sprintf("1. Orphaned CF definitions: %d %s\n", $orphanedCfs, 0 == $orphanedCfs ? '[OK]' : '[PROBLEM]');
|
||||
|
||||
// 2. Orphaned CFValues (pointing to orphaned CFs)
|
||||
$orphanedCfvs = $conn->fetchOne('
|
||||
SELECT COUNT(*) FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
|
||||
');
|
||||
echo sprintf("2. Orphaned CF values: %d %s\n", $orphanedCfvs, 0 == $orphanedCfvs ? '[OK]' : '[PROBLEM]');
|
||||
|
||||
// 3. Duplicate CFValues (same entity + same field name)
|
||||
$duplicatePieces = $conn->fetchOne("
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT cfv.pieceid, cf.name, COUNT(*) as cnt
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
WHERE cfv.pieceid IS NOT NULL
|
||||
GROUP BY cfv.pieceid, cf.name
|
||||
HAVING COUNT(*) > 1
|
||||
) t
|
||||
");
|
||||
$duplicateComposants = $conn->fetchOne("
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT cfv.composantid, cf.name, COUNT(*) as cnt
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
WHERE cfv.composantid IS NOT NULL
|
||||
GROUP BY cfv.composantid, cf.name
|
||||
HAVING COUNT(*) > 1
|
||||
) t
|
||||
");
|
||||
$totalDuplicates = $duplicatePieces + $duplicateComposants;
|
||||
echo sprintf("3. Duplicate CF values: %d %s\n", $totalDuplicates, 0 == $totalDuplicates ? '[OK]' : '[PROBLEM]');
|
||||
|
||||
// 4. Spot check known pieces
|
||||
echo "\n--- Spot checks ---\n";
|
||||
|
||||
$checks = [
|
||||
['Arbre du palier pied E1', 'cl3d978dd4b071daff8fb185f7', 'pieceid', 'diamètre', '50'],
|
||||
['Arbre du palier tête E1', 'cmkr0qjw5004s1eq6pen63x7j', 'pieceid', 'diamètre', '70'],
|
||||
['Cage écureuil pied E1', 'clbe710810fd7ccd09811957b3', 'composantid', 'Diamètre', ''],
|
||||
];
|
||||
|
||||
foreach ($checks as [$name, $id, $fk, $fieldName, $expectedValue]) {
|
||||
$row = $conn->fetchAssociative(
|
||||
"SELECT cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$fk} = ? AND cf.name = ?",
|
||||
[$id, $fieldName]
|
||||
);
|
||||
$value = $row ? $row['value'] : '(NOT FOUND)';
|
||||
$ok = '' === $expectedValue ? ('' !== $value && null !== $value) : ($value === $expectedValue);
|
||||
echo sprintf(" %s → %s = '%s' %s\n", $name, $fieldName, $value, $ok ? '[OK]' : '[CHECK]');
|
||||
}
|
||||
|
||||
// 5. Summary of empty vs filled values
|
||||
echo "\n--- Value fill rates ---\n";
|
||||
$stats = $conn->fetchAllAssociative("
|
||||
SELECT
|
||||
CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece'
|
||||
WHEN cfv.composantid IS NOT NULL THEN 'composant'
|
||||
WHEN cfv.productid IS NOT NULL THEN 'product'
|
||||
ELSE 'unknown' END as entity_type,
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as filled,
|
||||
COUNT(*) FILTER (WHERE cfv.value = '' OR cfv.value IS NULL) as empty
|
||||
FROM custom_field_values cfv
|
||||
GROUP BY entity_type
|
||||
ORDER BY entity_type
|
||||
");
|
||||
foreach ($stats as $s) {
|
||||
$pct = $s['total'] > 0 ? round(100 * $s['filled'] / $s['total']) : 0;
|
||||
echo sprintf(" %s: %d/%d filled (%d%%)\n", $s['entity_type'], $s['filled'], $s['total'], $pct);
|
||||
}
|
||||
|
||||
echo "\n=== DONE ===\n";
|
||||
@@ -1,327 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Piece;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_key_exists;
|
||||
use function array_slice;
|
||||
use function count;
|
||||
use function iconv;
|
||||
use function in_array;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use function preg_replace;
|
||||
use function sprintf;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:check-missing-custom-field-values',
|
||||
description: 'List missing or empty custom field values for pieces and composants',
|
||||
)]
|
||||
final class CheckMissingCustomFieldValuesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('entity', null, InputOption::VALUE_REQUIRED, 'piece, composant or all', 'all')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Audit entries inspected per entity', '200')
|
||||
->addOption('max-rows', null, InputOption::VALUE_REQUIRED, 'Maximum rows displayed in the final table', '300')
|
||||
->addOption('recoverable-only', null, InputOption::VALUE_NONE, 'Show only rows recoverable from audit')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$entityScope = (string) $input->getOption('entity');
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$maxRows = max(1, (int) $input->getOption('max-rows'));
|
||||
$recoverableOnly = (bool) $input->getOption('recoverable-only');
|
||||
|
||||
if (!in_array($entityScope, ['all', 'piece', 'composant'], true)) {
|
||||
$io->error('Invalid --entity value. Use: all, piece, composant');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$counts = [
|
||||
'piece' => 0,
|
||||
'composant' => 0,
|
||||
];
|
||||
|
||||
if ('all' === $entityScope || 'piece' === $entityScope) {
|
||||
foreach ($this->pieces->findAll() as $piece) {
|
||||
if (!$piece instanceof Piece) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pieceRows = $this->inspectPiece($piece, $limit, $recoverableOnly);
|
||||
$counts['piece'] += count($pieceRows);
|
||||
$rows = [...$rows, ...$pieceRows];
|
||||
}
|
||||
}
|
||||
|
||||
if ('all' === $entityScope || 'composant' === $entityScope) {
|
||||
foreach ($this->composants->findAll() as $composant) {
|
||||
if (!$composant instanceof Composant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$composantRows = $this->inspectComposant($composant, $limit, $recoverableOnly);
|
||||
$counts['composant'] += count($composantRows);
|
||||
$rows = [...$rows, ...$composantRows];
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $rows) {
|
||||
$io->success('No missing or empty custom field values found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$displayRows = array_slice($rows, 0, $maxRows);
|
||||
$io->table(
|
||||
['Entity', 'ID', 'Name', 'Reference', 'Category', 'Field', 'Issue', 'Recoverable', 'Audit value'],
|
||||
$displayRows,
|
||||
);
|
||||
|
||||
if (count($rows) > $maxRows) {
|
||||
$io->warning(sprintf('Output truncated: showing %d of %d row(s).', $maxRows, count($rows)));
|
||||
}
|
||||
|
||||
$io->note(sprintf(
|
||||
'Missing/empty values found: pieces=%d, composants=%d, total=%d.',
|
||||
$counts['piece'],
|
||||
$counts['composant'],
|
||||
count($rows),
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<int, string>>
|
||||
*/
|
||||
private function inspectPiece(Piece $piece, int $limit, bool $recoverableOnly): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
if (null === $type) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->inspectEntity(
|
||||
entityType: 'piece',
|
||||
entityId: (string) $piece->getId(),
|
||||
entityName: $piece->getName(),
|
||||
entityReference: $piece->getReference() ?? '',
|
||||
typeName: $type->getName(),
|
||||
definitions: $type->getPieceCustomFields(),
|
||||
currentValues: $piece->getCustomFieldValues(),
|
||||
limit: $limit,
|
||||
recoverableOnly: $recoverableOnly,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<int, string>>
|
||||
*/
|
||||
private function inspectComposant(Composant $composant, int $limit, bool $recoverableOnly): array
|
||||
{
|
||||
$type = $composant->getTypeComposant();
|
||||
if (null === $type) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->inspectEntity(
|
||||
entityType: 'composant',
|
||||
entityId: (string) $composant->getId(),
|
||||
entityName: $composant->getName(),
|
||||
entityReference: $composant->getReference() ?? '',
|
||||
typeName: $type->getName(),
|
||||
definitions: $type->getComponentCustomFields(),
|
||||
currentValues: $composant->getCustomFieldValues(),
|
||||
limit: $limit,
|
||||
recoverableOnly: $recoverableOnly,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<int, string>>
|
||||
*/
|
||||
private function inspectEntity(
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $entityName,
|
||||
string $entityReference,
|
||||
string $typeName,
|
||||
Collection $definitions,
|
||||
Collection $currentValues,
|
||||
int $limit,
|
||||
bool $recoverableOnly,
|
||||
): array {
|
||||
if (0 === $definitions->count()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currentValuesByFieldId = $this->indexCurrentValues($currentValues);
|
||||
$history = $this->auditLogs->findEntityHistory($entityType, $entityId, $limit);
|
||||
$historicalValues = $this->extractHistoricalValues($history);
|
||||
$rows = [];
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
if (!$definition instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
|
||||
$issue = null;
|
||||
|
||||
if (!$currentValue instanceof CustomFieldValue) {
|
||||
$issue = 'missing';
|
||||
} elseif ('' === trim($currentValue->getValue())) {
|
||||
$issue = 'empty';
|
||||
}
|
||||
|
||||
if (null === $issue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$auditCandidate = $historicalValues[$this->normalizeFieldName($definition->getName())] ?? null;
|
||||
if ($recoverableOnly && null === $auditCandidate) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
$entityType,
|
||||
$entityId,
|
||||
$entityName,
|
||||
$entityReference,
|
||||
$typeName,
|
||||
$definition->getName(),
|
||||
$issue,
|
||||
$auditCandidate ? 'yes' : 'no',
|
||||
$auditCandidate['value'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<AuditLog> $history
|
||||
*
|
||||
* @return array<string, array{value: string}>
|
||||
*/
|
||||
private function extractHistoricalValues(array $history): array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach ($history as $log) {
|
||||
$diff = $log->getDiff();
|
||||
if (!is_array($diff)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($diff as $field => $change) {
|
||||
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedName = $this->normalizeFieldName(trim(substr($field, 12)));
|
||||
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $this->extractCandidateValue($change);
|
||||
if (null === $candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$normalizedName] = ['value' => $candidate];
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{from?: mixed, to?: mixed} $change
|
||||
*/
|
||||
private function extractCandidateValue(array $change): ?string
|
||||
{
|
||||
$to = $change['to'] ?? null;
|
||||
if (is_string($to) && '' !== trim($to)) {
|
||||
return $to;
|
||||
}
|
||||
|
||||
$from = $change['from'] ?? null;
|
||||
if (is_string($from) && '' !== trim($from)) {
|
||||
return $from;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, CustomFieldValue>
|
||||
*/
|
||||
private function indexCurrentValues(Collection $customFieldValues): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($customFieldValues as $customFieldValue) {
|
||||
if (!$customFieldValue instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
private function normalizeFieldName(string $name): string
|
||||
{
|
||||
$normalized = trim($name);
|
||||
if ('' === $normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||||
if (false !== $transliterated) {
|
||||
$normalized = $transliterated;
|
||||
}
|
||||
|
||||
$normalized = strtolower($normalized);
|
||||
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', $normalized));
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Piece;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_key_exists;
|
||||
use function count;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use function ksort;
|
||||
use function preg_replace;
|
||||
use function sprintf;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:restore-piece-custom-field-values',
|
||||
description: 'Restore missing or empty piece custom field values from audit history',
|
||||
)]
|
||||
final class RestorePieceCustomFieldValuesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('pieceId', InputArgument::REQUIRED, 'Piece ID to restore')
|
||||
->addOption('apply', null, InputOption::VALUE_NONE, 'Persist restored values instead of dry-run mode')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of audit entries to inspect', '500')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$pieceId = (string) $input->getArgument('pieceId');
|
||||
$apply = (bool) $input->getOption('apply');
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
|
||||
$piece = $this->pieces->find($pieceId);
|
||||
if (!$piece instanceof Piece) {
|
||||
$io->error(sprintf('Piece not found: %s', $pieceId));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$type = $piece->getTypePiece();
|
||||
if (null === $type) {
|
||||
$io->error('This piece has no category (typePiece).');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$definitions = $type->getPieceCustomFields();
|
||||
if (0 === $definitions->count()) {
|
||||
$io->warning('This piece category has no current custom field definitions.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$history = $this->auditLogs->findEntityHistory('piece', $pieceId, $limit);
|
||||
if ([] === $history) {
|
||||
$io->warning('No audit history found for this piece.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$historicalValues = $this->extractHistoricalValues($history);
|
||||
if ([] === $historicalValues) {
|
||||
$io->warning('No historical custom field values were found in audit logs.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$currentValuesByFieldId = $this->indexCurrentValues($piece->getCustomFieldValues());
|
||||
$plannedRows = [];
|
||||
$changesCount = 0;
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
if (!$definition instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedName = $this->normalizeFieldName($definition->getName());
|
||||
if ('' === $normalizedName || !isset($historicalValues[$normalizedName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
|
||||
$shouldRestore = null === $currentValue || '' === trim($currentValue->getValue());
|
||||
if (!$shouldRestore) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $historicalValues[$normalizedName];
|
||||
$plannedRows[] = [
|
||||
$definition->getName(),
|
||||
$candidate['value'],
|
||||
$candidate['sourceDate'],
|
||||
$currentValue ? 'update-empty' : 'create-missing',
|
||||
];
|
||||
$changesCount++;
|
||||
|
||||
if (!$apply) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$currentValue instanceof CustomFieldValue) {
|
||||
$currentValue = new CustomFieldValue();
|
||||
$currentValue->setPiece($piece);
|
||||
$currentValue->setCustomField($definition);
|
||||
$this->em->persist($currentValue);
|
||||
}
|
||||
|
||||
$currentValue->setValue($candidate['value']);
|
||||
}
|
||||
|
||||
if (0 === $changesCount) {
|
||||
$io->success('No missing or empty custom field values needed restoration.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($apply) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->table(
|
||||
['Field', 'Restored value', 'Audit date', 'Action'],
|
||||
$plannedRows,
|
||||
);
|
||||
|
||||
if ($apply) {
|
||||
$io->success(sprintf('%d custom field value(s) restored.', $changesCount));
|
||||
} else {
|
||||
$io->note(sprintf(
|
||||
'Dry-run only. Re-run with --apply to persist %d restoration(s).',
|
||||
$changesCount,
|
||||
));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<AuditLog> $history
|
||||
*
|
||||
* @return array<string, array{value: string, sourceDate: string, sourceField: string}>
|
||||
*/
|
||||
private function extractHistoricalValues(array $history): array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach ($history as $log) {
|
||||
$diff = $log->getDiff();
|
||||
if (!is_array($diff)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($diff as $field => $change) {
|
||||
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawName = trim(substr($field, \strlen('customField:')));
|
||||
$normalizedName = $this->normalizeFieldName($rawName);
|
||||
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $this->extractCandidateValue($change);
|
||||
if (null === $candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$normalizedName] = [
|
||||
'value' => $candidate,
|
||||
'sourceDate' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'sourceField'=> $rawName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
ksort($values);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{from?: mixed, to?: mixed} $change
|
||||
*/
|
||||
private function extractCandidateValue(array $change): ?string
|
||||
{
|
||||
$to = $change['to'] ?? null;
|
||||
if (is_string($to) && '' !== trim($to)) {
|
||||
return $to;
|
||||
}
|
||||
|
||||
$from = $change['from'] ?? null;
|
||||
if (is_string($from) && '' !== trim($from)) {
|
||||
return $from;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, CustomFieldValue>
|
||||
*/
|
||||
private function indexCurrentValues(Collection $customFieldValues): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($customFieldValues as $customFieldValue) {
|
||||
if (!$customFieldValue instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
private function normalizeFieldName(string $name): string
|
||||
{
|
||||
$normalized = trim($name);
|
||||
if ('' === $normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||||
if (false !== $transliterated) {
|
||||
$normalized = $transliterated;
|
||||
}
|
||||
|
||||
$normalized = strtolower($normalized);
|
||||
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', $normalized));
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_key_exists;
|
||||
use function array_slice;
|
||||
use function count;
|
||||
use function iconv;
|
||||
use function in_array;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use function preg_replace;
|
||||
use function sprintf;
|
||||
use function str_starts_with;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:restore-recoverable-piece-custom-field-values',
|
||||
description: 'Restore all recoverable missing or empty custom field values for pieces',
|
||||
)]
|
||||
final class RestoreRecoverablePieceCustomFieldValuesCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('apply', null, InputOption::VALUE_NONE, 'Persist restored values instead of dry-run mode')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of audit entries to inspect per piece', '500')
|
||||
->addOption('category', null, InputOption::VALUE_REQUIRED, 'Only process pieces whose ModelType name matches this category')
|
||||
->addOption('piece-id', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Restrict to one or more piece IDs')
|
||||
->addOption('max-rows', null, InputOption::VALUE_REQUIRED, 'Maximum rows displayed in the preview table', '300')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$apply = (bool) $input->getOption('apply');
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$maxRows = max(1, (int) $input->getOption('max-rows'));
|
||||
$category = $this->normalizeOptionalString($input->getOption('category'));
|
||||
$pieceIdsRaw = $input->getOption('piece-id');
|
||||
$pieceIds = is_array($pieceIdsRaw) ? array_values(array_filter(array_map('strval', $pieceIdsRaw))) : [];
|
||||
|
||||
$rows = [];
|
||||
$changesCount = 0;
|
||||
$pieceCount = 0;
|
||||
|
||||
foreach ($this->pieces->findAll() as $piece) {
|
||||
if (!$piece instanceof Piece) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ([] !== $pieceIds && !in_array((string) $piece->getId(), $pieceIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $piece->getTypePiece();
|
||||
if (!$type instanceof ModelType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== $category && $this->normalizeFieldName($type->getName()) !== $this->normalizeFieldName($category)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pieceRows = $this->restorePiece($piece, $limit, $apply);
|
||||
if ([] === $pieceRows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pieceCount++;
|
||||
$changesCount += count($pieceRows);
|
||||
$rows = [...$rows, ...$pieceRows];
|
||||
}
|
||||
|
||||
if ([] === $rows) {
|
||||
$io->success('No recoverable piece custom field values found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$displayRows = array_slice($rows, 0, $maxRows);
|
||||
$io->table(
|
||||
['Piece ID', 'Name', 'Reference', 'Category', 'Field', 'Restored value', 'Audit date', 'Action'],
|
||||
$displayRows,
|
||||
);
|
||||
|
||||
if (count($rows) > $maxRows) {
|
||||
$io->warning(sprintf('Output truncated: showing %d of %d row(s).', $maxRows, count($rows)));
|
||||
}
|
||||
|
||||
if ($apply) {
|
||||
$this->em->flush();
|
||||
$io->success(sprintf('%d value(s) restored across %d piece(s).', $changesCount, $pieceCount));
|
||||
} else {
|
||||
$io->note(sprintf(
|
||||
'Dry-run only. %d value(s) recoverable across %d piece(s). Re-run with --apply to persist.',
|
||||
$changesCount,
|
||||
$pieceCount,
|
||||
));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<int, string>>
|
||||
*/
|
||||
private function restorePiece(Piece $piece, int $limit, bool $apply): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
if (!$type instanceof ModelType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$definitions = $type->getPieceCustomFields();
|
||||
if (0 === $definitions->count()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$history = $this->auditLogs->findEntityHistory('piece', (string) $piece->getId(), $limit);
|
||||
if ([] === $history) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$historicalValues = $this->extractHistoricalValues($history);
|
||||
if ([] === $historicalValues) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currentValuesByFieldId = $this->indexCurrentValues($piece->getCustomFieldValues());
|
||||
$rows = [];
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
if (!$definition instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedName = $this->normalizeFieldName($definition->getName());
|
||||
$candidate = $historicalValues[$normalizedName] ?? null;
|
||||
if (null === $candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
|
||||
$shouldRestore = null === $currentValue || '' === trim($currentValue->getValue());
|
||||
if (!$shouldRestore) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$action = $currentValue instanceof CustomFieldValue ? 'update-empty' : 'create-missing';
|
||||
$rows[] = [
|
||||
(string) $piece->getId(),
|
||||
$piece->getName(),
|
||||
$piece->getReference() ?? '',
|
||||
$type->getName(),
|
||||
$definition->getName(),
|
||||
$candidate['value'],
|
||||
$candidate['sourceDate'],
|
||||
$action,
|
||||
];
|
||||
|
||||
if (!$apply) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$currentValue instanceof CustomFieldValue) {
|
||||
$currentValue = new CustomFieldValue();
|
||||
$currentValue->setPiece($piece);
|
||||
$currentValue->setCustomField($definition);
|
||||
$this->em->persist($currentValue);
|
||||
}
|
||||
|
||||
$currentValue->setValue($candidate['value']);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<AuditLog> $history
|
||||
*
|
||||
* @return array<string, array{value: string, sourceDate: string}>
|
||||
*/
|
||||
private function extractHistoricalValues(array $history): array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach ($history as $log) {
|
||||
$diff = $log->getDiff();
|
||||
if (!is_array($diff)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($diff as $field => $change) {
|
||||
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedName = $this->normalizeFieldName(trim(substr($field, 12)));
|
||||
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $this->extractCandidateValue($change);
|
||||
if (null === $candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$normalizedName] = [
|
||||
'value' => $candidate,
|
||||
'sourceDate' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{from?: mixed, to?: mixed} $change
|
||||
*/
|
||||
private function extractCandidateValue(array $change): ?string
|
||||
{
|
||||
$to = $change['to'] ?? null;
|
||||
if (is_string($to) && '' !== trim($to)) {
|
||||
return $to;
|
||||
}
|
||||
|
||||
$from = $change['from'] ?? null;
|
||||
if (is_string($from) && '' !== trim($from)) {
|
||||
return $from;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, CustomFieldValue>
|
||||
*/
|
||||
private function indexCurrentValues(Collection $customFieldValues): array
|
||||
{
|
||||
$indexed = [];
|
||||
|
||||
foreach ($customFieldValues as $customFieldValue) {
|
||||
if (!$customFieldValue instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
private function normalizeFieldName(string $name): string
|
||||
{
|
||||
$normalized = trim($name);
|
||||
if ('' === $normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||||
if (false !== $transliterated) {
|
||||
$normalized = $transliterated;
|
||||
}
|
||||
|
||||
$normalized = strtolower($normalized);
|
||||
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', $normalized));
|
||||
}
|
||||
|
||||
private function normalizeOptionalString(mixed $value): ?string
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return '' === $trimmed ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Entity\Document;
|
||||
use App\Enum\DocumentType;
|
||||
use App\Repository\ProfileRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -24,7 +20,6 @@ final class CommentController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'api_comments_create', methods: ['POST'])]
|
||||
@@ -43,25 +38,16 @@ final class CommentController extends AbstractController
|
||||
return $this->json(['message' => 'Profil introuvable.'], 401);
|
||||
}
|
||||
|
||||
// Parse fields from JSON or form-data
|
||||
$contentType = $request->headers->get('Content-Type', '');
|
||||
$isFormData = str_contains($contentType, 'multipart/form-data') || $request->files->count() > 0 || $request->request->has('content');
|
||||
if ($isFormData) {
|
||||
$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;
|
||||
$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);
|
||||
}
|
||||
@@ -89,36 +75,6 @@ final class CommentController extends AbstractController
|
||||
$comment->setAuthorName($authorName);
|
||||
|
||||
$this->entityManager->persist($comment);
|
||||
|
||||
// Handle file uploads
|
||||
$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);
|
||||
$comment->getDocuments()->add($document);
|
||||
|
||||
$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);
|
||||
@@ -156,76 +112,6 @@ final class CommentController extends AbstractController
|
||||
return $this->json($this->normalize($comment));
|
||||
}
|
||||
|
||||
#[Route('/search/list', name: 'api_comments_list', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$qb = $this->entityManager->getRepository(Comment::class)->createQueryBuilder('c');
|
||||
|
||||
$status = $request->query->get('status');
|
||||
if ($status) {
|
||||
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
|
||||
$entityType = $request->query->get('entityType');
|
||||
if ($entityType) {
|
||||
$qb->andWhere('c.entityType = :entityType')->setParameter('entityType', $entityType);
|
||||
}
|
||||
|
||||
$entityName = $request->query->get('entityName');
|
||||
if ($entityName) {
|
||||
$qb->andWhere('LOWER(c.entityName) LIKE LOWER(:entityName)')->setParameter('entityName', '%'.$entityName.'%');
|
||||
}
|
||||
|
||||
// Count total before pagination
|
||||
$countQb = clone $qb;
|
||||
$total = (int) $countQb->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
// Sorting
|
||||
$sortField = $request->query->get('sort', 'createdAt');
|
||||
$sortDir = strtoupper($request->query->get('direction', 'DESC'));
|
||||
$allowedSortFields = ['createdAt', 'authorName', 'status'];
|
||||
if (!in_array($sortField, $allowedSortFields, true)) {
|
||||
$sortField = 'createdAt';
|
||||
}
|
||||
if (!in_array($sortDir, ['ASC', 'DESC'], true)) {
|
||||
$sortDir = 'DESC';
|
||||
}
|
||||
$qb->orderBy('c.'.$sortField, $sortDir);
|
||||
|
||||
// Pagination
|
||||
$itemsPerPage = min((int) $request->query->get('itemsPerPage', '30'), 200);
|
||||
$page = max((int) $request->query->get('page', '1'), 1);
|
||||
$qb->setMaxResults($itemsPerPage)->setFirstResult(($page - 1) * $itemsPerPage);
|
||||
|
||||
$comments = $qb->getQuery()->getResult();
|
||||
|
||||
return $this->json([
|
||||
'items' => array_map(fn (Comment $c) => $this->normalize($c), $comments),
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/by-entity/{entityType}/{entityId}', name: 'api_comments_by_entity', methods: ['GET'])]
|
||||
public function listByEntity(string $entityType, string $entityId, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$criteria = ['entityType' => $entityType, 'entityId' => $entityId];
|
||||
|
||||
$status = $request->query->get('status');
|
||||
if ($status) {
|
||||
$criteria['status'] = $status;
|
||||
}
|
||||
|
||||
$comments = $this->entityManager->getRepository(Comment::class)
|
||||
->findBy($criteria, ['createdAt' => 'DESC'])
|
||||
;
|
||||
|
||||
return $this->json(array_map(fn (Comment $c) => $this->normalize($c), $comments));
|
||||
}
|
||||
|
||||
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
|
||||
public function unresolvedCount(): JsonResponse
|
||||
{
|
||||
@@ -240,21 +126,6 @@ final class CommentController extends AbstractController
|
||||
|
||||
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(),
|
||||
@@ -269,7 +140,6 @@ final class CommentController extends AbstractController
|
||||
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
|
||||
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
|
||||
'documents' => $documents,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ComposantPieceSlot;
|
||||
use App\Entity\Piece;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-piece-slots')]
|
||||
class ComposantPieceSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_piece_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantPieceSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('quantity', $payload)) {
|
||||
$slot->setQuantity(max(1, (int) $payload['quantity']));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ComposantProductSlot;
|
||||
use App\Entity\Product;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-product-slots')]
|
||||
class ComposantProductSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_product_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantProductSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedProductId', $payload)) {
|
||||
if (null === $payload['selectedProductId']) {
|
||||
$slot->setSelectedProduct(null);
|
||||
} else {
|
||||
$product = $this->entityManager->find(Product::class, $payload['selectedProductId']);
|
||||
$slot->setSelectedProduct($product);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\ComposantSubcomponentSlot;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-subcomponent-slots')]
|
||||
class ComposantSubcomponentSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_subcomponent_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantSubcomponentSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedComposantId', $payload)) {
|
||||
if (null === $payload['selectedComposantId']) {
|
||||
$slot->setSelectedComposant(null);
|
||||
} else {
|
||||
$composant = $this->entityManager->find(Composant::class, $payload['selectedComposantId']);
|
||||
$slot->setSelectedComposant($composant);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -196,16 +196,19 @@ class CustomFieldValueController extends AbstractController
|
||||
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
|
||||
}
|
||||
|
||||
// Try to find an existing custom field by name instead of creating an orphan
|
||||
$existing = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
|
||||
if ($existing instanceof CustomField) {
|
||||
return $existing;
|
||||
$customField = new CustomField();
|
||||
$customField->setName($customFieldName);
|
||||
$customField->setType((string) ($payload['customFieldType'] ?? 'text'));
|
||||
$customField->setRequired((bool) ($payload['customFieldRequired'] ?? false));
|
||||
|
||||
$options = $payload['customFieldOptions'] ?? null;
|
||||
if (is_array($options)) {
|
||||
$customField->setOptions($options);
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'success' => false,
|
||||
'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName),
|
||||
], 404);
|
||||
$this->entityManager->persist($customField);
|
||||
|
||||
return $customField;
|
||||
}
|
||||
|
||||
private function resolveTarget(array $payload): array|JsonResponse
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Entity\Document;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\DocumentRepository;
|
||||
@@ -12,7 +11,6 @@ use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -27,7 +25,6 @@ class DocumentQueryController extends AbstractController
|
||||
private readonly ComposantRepository $composantRepository,
|
||||
private readonly PieceRepository $pieceRepository,
|
||||
private readonly ProductRepository $productRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
|
||||
@@ -105,21 +102,6 @@ class DocumentQueryController extends AbstractController
|
||||
return $this->json($this->normalizeDocuments($documents));
|
||||
}
|
||||
|
||||
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
|
||||
public function listByComment(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$comment = $this->em->find(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));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Document[] $documents
|
||||
*/
|
||||
@@ -139,8 +121,6 @@ class DocumentQueryController extends AbstractController
|
||||
'composantId' => $document->getComposant()?->getId(),
|
||||
'pieceId' => $document->getPiece()?->getId(),
|
||||
'productId' => $document->getProduct()?->getId(),
|
||||
'commentId' => $document->getComment()?->getId(),
|
||||
'type' => $document->getType()->value,
|
||||
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
|
||||
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
|
||||
];
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Service\EntityVersionService;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class EntityVersionController extends AbstractController
|
||||
{
|
||||
/** @var array<string, array{repo: object, label: string}> */
|
||||
private readonly array $entityConfig;
|
||||
|
||||
public function __construct(
|
||||
MachineRepository $machines,
|
||||
PieceRepository $pieces,
|
||||
ComposantRepository $composants,
|
||||
ProductRepository $products,
|
||||
private readonly EntityVersionService $versionService,
|
||||
) {
|
||||
$this->entityConfig = [
|
||||
'machine' => ['repo' => $machines, 'label' => 'Machine introuvable.'],
|
||||
'piece' => ['repo' => $pieces, 'label' => 'Pièce introuvable.'],
|
||||
'composant' => ['repo' => $composants, 'label' => 'Composant introuvable.'],
|
||||
'product' => ['repo' => $products, 'label' => 'Produit introuvable.'],
|
||||
];
|
||||
}
|
||||
|
||||
// ── Versions list ───────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/machines/{id}/versions', name: 'api_machine_versions', methods: ['GET'])]
|
||||
public function machineVersions(string $id): JsonResponse
|
||||
{
|
||||
return $this->listVersions('machine', $id);
|
||||
}
|
||||
|
||||
#[Route('/api/composants/{id}/versions', name: 'api_composant_versions', methods: ['GET'])]
|
||||
public function composantVersions(string $id): JsonResponse
|
||||
{
|
||||
return $this->listVersions('composant', $id);
|
||||
}
|
||||
|
||||
#[Route('/api/pieces/{id}/versions', name: 'api_piece_versions', methods: ['GET'])]
|
||||
public function pieceVersions(string $id): JsonResponse
|
||||
{
|
||||
return $this->listVersions('piece', $id);
|
||||
}
|
||||
|
||||
#[Route('/api/products/{id}/versions', name: 'api_product_versions', methods: ['GET'])]
|
||||
public function productVersions(string $id): JsonResponse
|
||||
{
|
||||
return $this->listVersions('product', $id);
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/machines/{id}/versions/{version}/preview', name: 'api_machine_version_preview', methods: ['GET'])]
|
||||
public function machineVersionPreview(string $id, int $version): JsonResponse
|
||||
{
|
||||
return $this->preview('machine', $id, $version);
|
||||
}
|
||||
|
||||
#[Route('/api/composants/{id}/versions/{version}/preview', name: 'api_composant_version_preview', methods: ['GET'])]
|
||||
public function composantVersionPreview(string $id, int $version): JsonResponse
|
||||
{
|
||||
return $this->preview('composant', $id, $version);
|
||||
}
|
||||
|
||||
#[Route('/api/pieces/{id}/versions/{version}/preview', name: 'api_piece_version_preview', methods: ['GET'])]
|
||||
public function pieceVersionPreview(string $id, int $version): JsonResponse
|
||||
{
|
||||
return $this->preview('piece', $id, $version);
|
||||
}
|
||||
|
||||
#[Route('/api/products/{id}/versions/{version}/preview', name: 'api_product_version_preview', methods: ['GET'])]
|
||||
public function productVersionPreview(string $id, int $version): JsonResponse
|
||||
{
|
||||
return $this->preview('product', $id, $version);
|
||||
}
|
||||
|
||||
// ── Restore ─────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/machines/{id}/versions/{version}/restore', name: 'api_machine_version_restore', methods: ['POST'])]
|
||||
public function machineVersionRestore(string $id, int $version): JsonResponse
|
||||
{
|
||||
return $this->restoreVersion('machine', $id, $version);
|
||||
}
|
||||
|
||||
#[Route('/api/composants/{id}/versions/{version}/restore', name: 'api_composant_version_restore', methods: ['POST'])]
|
||||
public function composantVersionRestore(string $id, int $version): JsonResponse
|
||||
{
|
||||
return $this->restoreVersion('composant', $id, $version);
|
||||
}
|
||||
|
||||
#[Route('/api/pieces/{id}/versions/{version}/restore', name: 'api_piece_version_restore', methods: ['POST'])]
|
||||
public function pieceVersionRestore(string $id, int $version): JsonResponse
|
||||
{
|
||||
return $this->restoreVersion('piece', $id, $version);
|
||||
}
|
||||
|
||||
#[Route('/api/products/{id}/versions/{version}/restore', name: 'api_product_version_restore', methods: ['POST'])]
|
||||
public function productVersionRestore(string $id, int $version): JsonResponse
|
||||
{
|
||||
return $this->restoreVersion('product', $id, $version);
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────
|
||||
|
||||
private function listVersions(string $entityType, string $entityId): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$config = $this->entityConfig[$entityType];
|
||||
if (!$config['repo']->find($entityId)) {
|
||||
return new JsonResponse(['message' => $config['label']], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return new JsonResponse($this->versionService->getVersions($entityType, $entityId));
|
||||
}
|
||||
|
||||
private function preview(string $entityType, string $entityId, int $version): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
try {
|
||||
$result = $this->versionService->getRestorePreview($entityType, $entityId, $version);
|
||||
|
||||
return new JsonResponse($result);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
private function restoreVersion(string $entityType, string $entityId, int $version): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
try {
|
||||
$result = $this->versionService->restore($entityType, $entityId, $version);
|
||||
|
||||
return new JsonResponse($result);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,9 @@ class MachineStructureController extends AbstractController
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
}
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
|
||||
|
||||
return $this->json($this->normalizeStructureResponse(
|
||||
$machine,
|
||||
@@ -159,9 +159,9 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine]);
|
||||
|
||||
return $this->json($this->normalizeStructureResponse(
|
||||
$newMachine,
|
||||
@@ -173,8 +173,6 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
private function cloneCustomFields(Machine $source, Machine $target): void
|
||||
{
|
||||
$cfMap = [];
|
||||
|
||||
foreach ($source->getCustomFields() as $cf) {
|
||||
$newCf = new CustomField();
|
||||
$newCf->setName($cf->getName());
|
||||
@@ -185,20 +183,12 @@ class MachineStructureController extends AbstractController
|
||||
$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->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$this->entityManager->persist($newValue);
|
||||
}
|
||||
@@ -209,7 +199,7 @@ class MachineStructureController extends AbstractController
|
||||
*/
|
||||
private function cloneComponentLinks(Machine $source, Machine $target): array
|
||||
{
|
||||
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links without parent relationships
|
||||
@@ -242,7 +232,7 @@ class MachineStructureController extends AbstractController
|
||||
*/
|
||||
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
|
||||
{
|
||||
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
foreach ($sourceLinks as $link) {
|
||||
@@ -252,7 +242,6 @@ class MachineStructureController extends AbstractController
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($componentLinkMap[$parent->getId()])) {
|
||||
@@ -276,7 +265,7 @@ class MachineStructureController extends AbstractController
|
||||
array $componentLinkMap,
|
||||
array $pieceLinkMap,
|
||||
): void {
|
||||
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links
|
||||
@@ -319,7 +308,7 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse
|
||||
{
|
||||
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
|
||||
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine]));
|
||||
$keepIds = [];
|
||||
$pendingParents = [];
|
||||
$links = [];
|
||||
@@ -376,7 +365,7 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse
|
||||
{
|
||||
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
|
||||
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine]));
|
||||
$componentIndex = $this->indexLinksById($componentLinks);
|
||||
$keepIds = [];
|
||||
$pendingParents = [];
|
||||
@@ -406,11 +395,6 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||
|
||||
if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) {
|
||||
$quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity();
|
||||
$link->setQuantity(max(1, $quantity));
|
||||
}
|
||||
|
||||
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||
'parentComponentLinkId',
|
||||
'parentLinkId',
|
||||
@@ -443,7 +427,7 @@ class MachineStructureController extends AbstractController
|
||||
array $componentLinks,
|
||||
array $pieceLinks,
|
||||
): array|JsonResponse {
|
||||
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
|
||||
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
|
||||
$componentIndex = $this->indexLinksById($componentLinks);
|
||||
$pieceIndex = $this->indexLinksById($pieceLinks);
|
||||
$keepIds = [];
|
||||
@@ -652,31 +636,10 @@ class MachineStructureController extends AbstractController
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
if (!$parentLink) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
$composant = $parentLink->getComposant();
|
||||
$piece = $link->getPiece();
|
||||
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
|
||||
return $slot->getQuantity();
|
||||
}
|
||||
}
|
||||
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
private function normalizeProductLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineProductLink $link): array {
|
||||
@@ -708,7 +671,6 @@ class MachineStructureController extends AbstractController
|
||||
'typeComposant' => $this->normalizeModelType($type),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'structure' => $this->buildStructureFromSlots($composant),
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
@@ -716,48 +678,6 @@ class MachineStructureController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
private function buildStructureFromSlots(Composant $composant): array
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
$pieceData = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
];
|
||||
if ($slot->getSelectedPiece()) {
|
||||
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
|
||||
}
|
||||
$pieces[] = $pieceData;
|
||||
}
|
||||
|
||||
$subcomponents = [];
|
||||
foreach ($composant->getSubcomponentSlots() as $slot) {
|
||||
$subcomponents[] = [
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($composant->getProductSlots() as $slot) {
|
||||
$products[] = [
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pieces' => $pieces,
|
||||
'subcomponents' => $subcomponents,
|
||||
'products' => $products,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePiece(Piece $piece): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
@@ -780,19 +700,16 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
private function normalizeProduct(Product $product): array
|
||||
{
|
||||
$type = $product->getTypeProduct();
|
||||
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $type?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($type),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $product->getTypeProduct()?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\Service\ModelTypeSyncService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/model_types/{id}')]
|
||||
final class ModelTypeSyncController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly ModelTypeSyncService $syncService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/sync-preview', name: 'api_model_type_sync_preview', methods: ['POST'])]
|
||||
public function preview(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Catégorie introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$structure = $body['structure'] ?? [];
|
||||
|
||||
$result = $this->syncService->preview($modelType, $structure);
|
||||
|
||||
return new JsonResponse($result);
|
||||
}
|
||||
|
||||
#[Route('/sync', name: 'api_model_type_sync', methods: ['POST'])]
|
||||
public function sync(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Catégorie introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$confirmation = new SyncConfirmation(
|
||||
confirmDeletions: $body['confirmDeletions'] ?? false,
|
||||
confirmTypeChanges: $body['confirmTypeChanges'] ?? false,
|
||||
);
|
||||
|
||||
$result = $this->em->wrapInTransaction(function () use ($modelType, $confirmation) {
|
||||
return $this->syncService->execute($modelType, $confirmation);
|
||||
});
|
||||
|
||||
return new JsonResponse($result);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
class SyncConfirmation
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $confirmDeletions = false,
|
||||
public readonly bool $confirmTypeChanges = false,
|
||||
) {}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class SyncExecutionResult implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $itemsUpdated,
|
||||
public readonly array $additions = [],
|
||||
public readonly array $deletions = [],
|
||||
public readonly array $modifications = [],
|
||||
) {}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'itemsUpdated' => $this->itemsUpdated,
|
||||
'additions' => $this->additions,
|
||||
'deletions' => $this->deletions,
|
||||
'modifications' => $this->modifications,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class SyncPreviewResult implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $modelTypeId,
|
||||
public readonly string $category,
|
||||
public readonly int $itemCount,
|
||||
public readonly array $additions = [],
|
||||
public readonly array $deletions = [],
|
||||
public readonly array $modifications = [],
|
||||
) {}
|
||||
|
||||
public function hasImpact(): bool
|
||||
{
|
||||
return array_sum($this->additions) > 0
|
||||
|| array_sum($this->deletions) > 0
|
||||
|| array_sum($this->modifications) > 0;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'modelTypeId' => $this->modelTypeId,
|
||||
'category' => $this->category,
|
||||
'itemCount' => $this->itemCount,
|
||||
'additions' => $this->additions,
|
||||
'deletions' => $this->deletions,
|
||||
'modifications' => $this->modifications,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?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;
|
||||
|
||||
use function in_array;
|
||||
use function is_string;
|
||||
|
||||
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).'%')
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,6 @@ class AuditLog
|
||||
#[ORM\Column(type: Types::STRING, length: 36, nullable: true)]
|
||||
private ?string $actorProfileId = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, nullable: true)]
|
||||
private ?int $version = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
@@ -51,7 +48,6 @@ class AuditLog
|
||||
?array $diff = null,
|
||||
?array $snapshot = null,
|
||||
?string $actorProfileId = null,
|
||||
?int $version = null,
|
||||
) {
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
@@ -59,7 +55,6 @@ class AuditLog
|
||||
$this->diff = $diff;
|
||||
$this->snapshot = $snapshot;
|
||||
$this->actorProfileId = $actorProfileId;
|
||||
$this->version = $version;
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
@@ -114,18 +109,6 @@ class AuditLog
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getVersion(): ?int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function setVersion(?int $version): static
|
||||
{
|
||||
$this->version = $version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
// Keep the same lightweight CUID-like strategy used across the project.
|
||||
|
||||
@@ -14,8 +14,6 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
@@ -81,15 +79,10 @@ class Comment
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
/** @var Collection<int, Document> */
|
||||
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])]
|
||||
private Collection $documents;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->documents = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
@@ -211,10 +204,4 @@ class Comment
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, Document> */
|
||||
public function getDocuments(): Collection
|
||||
{
|
||||
return $this->documents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,30 +15,25 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Filter\MultiSearchFilter;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\State\ComposantProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[UniqueEntity(fields: ['reference'], message: 'Un composant avec cette référence existe déjà.')]
|
||||
#[ORM\Entity(repositoryClass: ComposantRepository::class)]
|
||||
#[ORM\Table(name: 'composants')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])]
|
||||
#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiResource(
|
||||
description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ComposantProcessor::class),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
@@ -56,7 +51,7 @@ class Composant
|
||||
#[Groups(['composant:read', 'document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[Groups(['composant:read', 'document:list'])]
|
||||
private string $name;
|
||||
|
||||
@@ -64,10 +59,6 @@ class Composant
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $referenceAuto = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $description = null;
|
||||
@@ -76,6 +67,10 @@ class Composant
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $prix = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?array $structure = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'composants')]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
@@ -118,39 +113,6 @@ class Composant
|
||||
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: MachineComponentLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ComposantPieceSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ComposantPieceSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $pieceSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ComposantSubcomponentSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ComposantSubcomponentSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $subcomponentSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ComposantProductSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ComposantProductSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
|
||||
/**
|
||||
* Transient — holds the structure payload sent by the frontend during creation.
|
||||
* Not mapped to any column; consumed by ComposantProcessor.
|
||||
*/
|
||||
private ?array $pendingStructure = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['composant:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
private bool $skipAudit = false;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['composant:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -167,9 +129,6 @@ class Composant
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
$this->machineLinks = new ArrayCollection();
|
||||
$this->pieceSlots = new ArrayCollection();
|
||||
$this->subcomponentSlots = new ArrayCollection();
|
||||
$this->productSlots = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
@@ -196,21 +155,6 @@ class Composant
|
||||
return $this;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
@@ -235,6 +179,18 @@ class Composant
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
return $this->structure;
|
||||
}
|
||||
|
||||
public function setStructure(?array $structure): static
|
||||
{
|
||||
$this->structure = $structure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
@@ -314,183 +270,4 @@ class Composant
|
||||
{
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ComposantPieceSlot>
|
||||
*/
|
||||
public function getPieceSlots(): Collection
|
||||
{
|
||||
return $this->pieceSlots;
|
||||
}
|
||||
|
||||
public function addPieceSlot(ComposantPieceSlot $pieceSlot): static
|
||||
{
|
||||
if (!$this->pieceSlots->contains($pieceSlot)) {
|
||||
$this->pieceSlots->add($pieceSlot);
|
||||
$pieceSlot->setComposant($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removePieceSlot(ComposantPieceSlot $pieceSlot): static
|
||||
{
|
||||
$this->pieceSlots->removeElement($pieceSlot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ComposantSubcomponentSlot>
|
||||
*/
|
||||
public function getSubcomponentSlots(): Collection
|
||||
{
|
||||
return $this->subcomponentSlots;
|
||||
}
|
||||
|
||||
public function addSubcomponentSlot(ComposantSubcomponentSlot $subcomponentSlot): static
|
||||
{
|
||||
if (!$this->subcomponentSlots->contains($subcomponentSlot)) {
|
||||
$this->subcomponentSlots->add($subcomponentSlot);
|
||||
$subcomponentSlot->setComposant($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSubcomponentSlot(ComposantSubcomponentSlot $subcomponentSlot): static
|
||||
{
|
||||
$this->subcomponentSlots->removeElement($subcomponentSlot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ComposantProductSlot>
|
||||
*/
|
||||
public function getProductSlots(): Collection
|
||||
{
|
||||
return $this->productSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual property — rebuilds the legacy structure JSON from slot tables.
|
||||
*
|
||||
* @return null|array{pieces: list<array<string, mixed>>, products: list<array<string, mixed>>, subcomponents: list<array<string, mixed>>}
|
||||
*/
|
||||
#[Groups(['composant:read'])]
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($this->pieceSlots as $slot) {
|
||||
$selectedPiece = $slot->getSelectedPiece();
|
||||
$pieces[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'selectedPieceId' => $selectedPiece?->getId(),
|
||||
'selectedPieceName' => $selectedPiece?->getName(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($this->productSlots as $slot) {
|
||||
$selectedProduct = $slot->getSelectedProduct();
|
||||
$products[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $selectedProduct?->getId(),
|
||||
'selectedProductName' => $selectedProduct?->getName(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$subcomponents = [];
|
||||
foreach ($this->subcomponentSlots as $slot) {
|
||||
$selectedComposant = $slot->getSelectedComposant();
|
||||
$subcomponents[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $selectedComposant?->getId(),
|
||||
'selectedComponentName' => $selectedComposant?->getName(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($pieces) && empty($products) && empty($subcomponents)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'pieces' => $pieces,
|
||||
'products' => $products,
|
||||
'subcomponents' => $subcomponents,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by API Platform during denormalization — stores the frontend
|
||||
* structure payload so the ComposantProcessor can apply selections.
|
||||
*/
|
||||
public function setStructure(?array $structure): static
|
||||
{
|
||||
$this->pendingStructure = $structure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPendingStructure(): ?array
|
||||
{
|
||||
return $this->pendingStructure;
|
||||
}
|
||||
|
||||
public function clearPendingStructure(): void
|
||||
{
|
||||
$this->pendingStructure = null;
|
||||
}
|
||||
|
||||
public function addProductSlot(ComposantProductSlot $productSlot): static
|
||||
{
|
||||
if (!$this->productSlots->contains($productSlot)) {
|
||||
$this->productSlots->add($productSlot);
|
||||
$productSlot->setComposant($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProductSlot(ComposantProductSlot $productSlot): static
|
||||
{
|
||||
$this->productSlots->removeElement($productSlot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSkipAudit(): bool
|
||||
{
|
||||
return $this->skipAudit;
|
||||
}
|
||||
|
||||
public function setSkipAudit(bool $skipAudit): static
|
||||
{
|
||||
$this->skipAudit = $skipAudit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'composant_piece_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ComposantPieceSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'pieceSlots')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typePiece = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedPieceId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Piece $selectedPiece = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
private int $quantity = 1;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ?ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(?ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedPiece(): ?Piece
|
||||
{
|
||||
return $this->selectedPiece;
|
||||
}
|
||||
|
||||
public function setSelectedPiece(?Piece $selectedPiece): static
|
||||
{
|
||||
$this->selectedPiece = $selectedPiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'composant_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ComposantProductSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedProduct(): ?Product
|
||||
{
|
||||
return $this->selectedProduct;
|
||||
}
|
||||
|
||||
public function setSelectedProduct(?Product $selectedProduct): static
|
||||
{
|
||||
$this->selectedProduct = $selectedProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'composant_subcomponent_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ComposantSubcomponentSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'subcomponentSlots')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $alias = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeComposant = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Composant $selectedComposant = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAlias(): ?string
|
||||
{
|
||||
return $this->alias;
|
||||
}
|
||||
|
||||
public function setAlias(?string $alias): static
|
||||
{
|
||||
$this->alias = $alias;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedComposant(): ?Composant
|
||||
{
|
||||
return $this->selectedComposant;
|
||||
}
|
||||
|
||||
public function setSelectedComposant(?Composant $selectedComposant): static
|
||||
{
|
||||
$this->selectedComposant = $selectedComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -184,40 +184,4 @@ class CustomField
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ?ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(?ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,9 @@ use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Enum\DocumentType;
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\State\DocumentUploadProcessor;
|
||||
use DateTimeImmutable;
|
||||
@@ -28,7 +26,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Table(name: 'documents')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product', 'comment'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
|
||||
#[ApiResource(
|
||||
description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.',
|
||||
@@ -48,7 +46,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
inputFormats: ['multipart' => ['multipart/form-data']],
|
||||
),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
@@ -108,15 +105,6 @@ class Document
|
||||
#[Groups(['document:list'])]
|
||||
private ?Site $site = null;
|
||||
|
||||
#[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;
|
||||
|
||||
#[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;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['document:list'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -249,28 +237,4 @@ class Document
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): DocumentType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(DocumentType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComment(): ?Comment
|
||||
{
|
||||
return $this->comment;
|
||||
}
|
||||
|
||||
public function setComment(?Comment $comment): static
|
||||
{
|
||||
$this->comment = $comment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,15 +108,6 @@ class Machine
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
private int $version = 1;
|
||||
|
||||
/**
|
||||
* Transient flag — when true, audit subscribers skip this entity.
|
||||
* Used by EntityVersionService::restore() to avoid duplicate AuditLogs.
|
||||
*/
|
||||
private bool $skipAudit = false;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
@@ -274,28 +265,4 @@ class Machine
|
||||
{
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSkipAudit(): bool
|
||||
{
|
||||
return $this->skipAudit;
|
||||
}
|
||||
|
||||
public function setSkipAudit(bool $skipAudit): static
|
||||
{
|
||||
$this->skipAudit = $skipAudit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MachinePieceLinkRepository::class)]
|
||||
#[ORM\Table(name: 'machine_piece_links')]
|
||||
@@ -69,10 +68,6 @@ class MachinePieceLink
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true, name: 'prixOverride')]
|
||||
private ?string $prixOverride = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Assert\GreaterThanOrEqual(1)]
|
||||
private int $quantity = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
@@ -157,16 +152,4 @@ class MachinePieceLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\State\ModelTypeProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -36,9 +35,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
@@ -73,13 +72,17 @@ class ModelType
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[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, name: 'componentSkeleton')]
|
||||
#[Groups(['model_type:read', 'composant:read'])]
|
||||
private ?array $componentSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['model_type:read', 'model_type:write'])]
|
||||
private ?array $requiredFieldsForReference = null;
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'pieceSkeleton')]
|
||||
#[Groups(['model_type:read', 'piece:read'])]
|
||||
private ?array $pieceSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productSkeleton')]
|
||||
#[Groups(['model_type:read', 'product:read'])]
|
||||
private ?array $productSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['model_type:read'])]
|
||||
@@ -127,40 +130,16 @@ class ModelType
|
||||
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: CustomField::class)]
|
||||
private Collection $productCustomFields;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SkeletonPieceRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: SkeletonPieceRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $skeletonPieceRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SkeletonProductRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: SkeletonProductRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $skeletonProductRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SkeletonSubcomponentRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: SkeletonSubcomponentRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $skeletonSubcomponentRequirements;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
$this->skeletonPieceRequirements = new ArrayCollection();
|
||||
$this->skeletonProductRequirements = new ArrayCollection();
|
||||
$this->skeletonSubcomponentRequirements = new ArrayCollection();
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
@@ -196,6 +175,11 @@ class ModelType
|
||||
{
|
||||
$this->category = $category;
|
||||
|
||||
if (null !== $this->pendingStructure) {
|
||||
$this->applyStructureForCategory($this->pendingStructure, $category);
|
||||
$this->pendingStructure = null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -223,26 +207,38 @@ class ModelType
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReferenceFormula(): ?string
|
||||
public function getComponentSkeleton(): ?array
|
||||
{
|
||||
return $this->referenceFormula;
|
||||
return $this->componentSkeleton;
|
||||
}
|
||||
|
||||
public function setReferenceFormula(?string $referenceFormula): static
|
||||
public function setComponentSkeleton(?array $componentSkeleton): static
|
||||
{
|
||||
$this->referenceFormula = $referenceFormula;
|
||||
$this->componentSkeleton = $componentSkeleton;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRequiredFieldsForReference(): ?array
|
||||
public function getPieceSkeleton(): ?array
|
||||
{
|
||||
return $this->requiredFieldsForReference;
|
||||
return $this->pieceSkeleton;
|
||||
}
|
||||
|
||||
public function setRequiredFieldsForReference(?array $requiredFieldsForReference): static
|
||||
public function setPieceSkeleton(?array $pieceSkeleton): static
|
||||
{
|
||||
$this->requiredFieldsForReference = $requiredFieldsForReference;
|
||||
$this->pieceSkeleton = $pieceSkeleton;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProductSkeleton(): ?array
|
||||
{
|
||||
return $this->productSkeleton;
|
||||
}
|
||||
|
||||
public function setProductSkeleton(?array $productSkeleton): static
|
||||
{
|
||||
$this->productSkeleton = $productSkeleton;
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -251,30 +247,26 @@ class ModelType
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
return match ($this->category) {
|
||||
ModelCategory::COMPONENT => $this->getComponentStructureFromRelations(),
|
||||
ModelCategory::PIECE => $this->getPieceStructureFromRelations(),
|
||||
ModelCategory::PRODUCT => ['customFields' => $this->serializeCustomFields($this->productCustomFields)],
|
||||
ModelCategory::COMPONENT => $this->componentSkeleton,
|
||||
ModelCategory::PIECE => $this->pieceSkeleton,
|
||||
ModelCategory::PRODUCT => $this->productSkeleton,
|
||||
};
|
||||
}
|
||||
|
||||
#[Groups(['model_type:write'])]
|
||||
public function setStructure(?array $structure): static
|
||||
{
|
||||
$this->pendingStructure = $structure;
|
||||
if (!isset($this->category)) {
|
||||
$this->pendingStructure = $structure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->applyStructureForCategory($structure, $this->category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPendingStructure(): ?array
|
||||
{
|
||||
return $this->pendingStructure;
|
||||
}
|
||||
|
||||
public function clearPendingStructure(): void
|
||||
{
|
||||
$this->pendingStructure = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
@@ -299,140 +291,26 @@ class ModelType
|
||||
return $this->productCustomFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SkeletonPieceRequirement>
|
||||
*/
|
||||
public function getSkeletonPieceRequirements(): Collection
|
||||
private function applyStructureForCategory(?array $structure, ModelCategory $category): void
|
||||
{
|
||||
return $this->skeletonPieceRequirements;
|
||||
}
|
||||
if (ModelCategory::COMPONENT === $category) {
|
||||
$this->componentSkeleton = $structure;
|
||||
$this->pieceSkeleton = null;
|
||||
$this->productSkeleton = null;
|
||||
|
||||
public function addSkeletonPieceRequirement(SkeletonPieceRequirement $requirement): static
|
||||
{
|
||||
if (!$this->skeletonPieceRequirements->contains($requirement)) {
|
||||
$this->skeletonPieceRequirements->add($requirement);
|
||||
$requirement->setModelType($this);
|
||||
return;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
if (ModelCategory::PIECE === $category) {
|
||||
$this->pieceSkeleton = $structure;
|
||||
$this->componentSkeleton = null;
|
||||
$this->productSkeleton = null;
|
||||
|
||||
public function removeSkeletonPieceRequirement(SkeletonPieceRequirement $requirement): static
|
||||
{
|
||||
$this->skeletonPieceRequirements->removeElement($requirement);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SkeletonProductRequirement>
|
||||
*/
|
||||
public function getSkeletonProductRequirements(): Collection
|
||||
{
|
||||
return $this->skeletonProductRequirements;
|
||||
}
|
||||
|
||||
public function addSkeletonProductRequirement(SkeletonProductRequirement $requirement): static
|
||||
{
|
||||
if (!$this->skeletonProductRequirements->contains($requirement)) {
|
||||
$this->skeletonProductRequirements->add($requirement);
|
||||
$requirement->setModelType($this);
|
||||
return;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSkeletonProductRequirement(SkeletonProductRequirement $requirement): static
|
||||
{
|
||||
$this->skeletonProductRequirements->removeElement($requirement);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SkeletonSubcomponentRequirement>
|
||||
*/
|
||||
public function getSkeletonSubcomponentRequirements(): Collection
|
||||
{
|
||||
return $this->skeletonSubcomponentRequirements;
|
||||
}
|
||||
|
||||
public function addSkeletonSubcomponentRequirement(SkeletonSubcomponentRequirement $requirement): static
|
||||
{
|
||||
if (!$this->skeletonSubcomponentRequirements->contains($requirement)) {
|
||||
$this->skeletonSubcomponentRequirements->add($requirement);
|
||||
$requirement->setModelType($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSkeletonSubcomponentRequirement(SkeletonSubcomponentRequirement $requirement): static
|
||||
{
|
||||
$this->skeletonSubcomponentRequirements->removeElement($requirement);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function getComponentStructureFromRelations(): array
|
||||
{
|
||||
$structure = ['customFields' => $this->serializeCustomFields($this->customFields), 'pieces' => [], 'products' => [], 'subcomponents' => []];
|
||||
|
||||
foreach ($this->skeletonPieceRequirements as $req) {
|
||||
$structure['pieces'][] = [
|
||||
'typePieceId' => $req->getTypePiece()->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->skeletonProductRequirements as $req) {
|
||||
$structure['products'][] = [
|
||||
'typeProductId' => $req->getTypeProduct()->getId(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->skeletonSubcomponentRequirements as $req) {
|
||||
$structure['subcomponents'][] = [
|
||||
'alias' => $req->getAlias(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
'typeComposantId' => $req->getTypeComposant()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
private function getPieceStructureFromRelations(): array
|
||||
{
|
||||
return [
|
||||
'customFields' => $this->serializeCustomFields($this->pieceCustomFields),
|
||||
'products' => array_map(fn (SkeletonProductRequirement $req) => [
|
||||
'typeProductId' => $req->getTypeProduct()->getId(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
], $this->skeletonProductRequirements->toArray()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, CustomField> $fields
|
||||
*/
|
||||
private function serializeCustomFields(Collection $fields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($fields as $cf) {
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
$this->productSkeleton = $structure;
|
||||
$this->componentSkeleton = null;
|
||||
$this->pieceSkeleton = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Filter\MultiSearchFilter;
|
||||
use App\Repository\PieceRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -30,7 +29,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Table(name: 'pieces')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])]
|
||||
#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiResource(
|
||||
description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.',
|
||||
@@ -63,10 +61,6 @@ class Piece
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $referenceAuto = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $description = null;
|
||||
@@ -85,6 +79,10 @@ class Piece
|
||||
#[Groups(['piece:read'])]
|
||||
private ?Product $product = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productIds')]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?array $productIds = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Constructeur>
|
||||
*/
|
||||
@@ -111,34 +109,12 @@ class Piece
|
||||
#[Groups(['piece:read'])]
|
||||
private Collection $customFieldValues;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Product>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Product::class, inversedBy: 'linkedPieces')]
|
||||
#[ORM\JoinTable(name: 'piece_products')]
|
||||
#[ORM\JoinColumn(name: 'piece_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
private Collection $products;
|
||||
|
||||
/**
|
||||
* @var Collection<int, PieceProductSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachinePieceLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'piece', targetEntity: MachinePieceLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['piece:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
private bool $skipAudit = false;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['piece:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -154,8 +130,6 @@ class Piece
|
||||
$this->constructeurs = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->productSlots = new ArrayCollection();
|
||||
$this->machineLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
@@ -183,21 +157,6 @@ class Piece
|
||||
return $this;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
@@ -243,8 +202,13 @@ class Piece
|
||||
{
|
||||
$this->product = $product;
|
||||
|
||||
if (null !== $product) {
|
||||
$this->addProduct($product);
|
||||
if ($product && empty($this->productIds)) {
|
||||
$productId = $product->getId();
|
||||
$this->productIds = $productId ? [$productId] : null;
|
||||
}
|
||||
|
||||
if (!$product && empty($this->productIds)) {
|
||||
$this->productIds = null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -253,10 +217,46 @@ class Piece
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
#[Groups(['piece:read'])]
|
||||
public function getProductIds(): array
|
||||
{
|
||||
return $this->products->map(fn (Product $p) => $p->getId())->toArray();
|
||||
if (!is_array($this->productIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(
|
||||
array_filter(
|
||||
array_map(
|
||||
static fn ($value) => is_string($value) ? trim($value) : '',
|
||||
$this->productIds,
|
||||
),
|
||||
static fn (string $value) => '' !== $value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function setProductIds(?array $productIds): static
|
||||
{
|
||||
if (!is_array($productIds)) {
|
||||
$this->productIds = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$normalized = array_values(
|
||||
array_unique(
|
||||
array_filter(
|
||||
array_map(
|
||||
static fn ($value) => is_string($value) ? trim($value) : '',
|
||||
$productIds,
|
||||
),
|
||||
static fn (string $value) => '' !== $value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$this->productIds = [] === $normalized ? null : $normalized;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,22 +267,6 @@ class Piece
|
||||
return $this->constructeurs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Constructeur> $constructeurs
|
||||
*/
|
||||
public function setConstructeurs(iterable $constructeurs): static
|
||||
{
|
||||
$this->constructeurs = new ArrayCollection();
|
||||
|
||||
foreach ($constructeurs as $constructeur) {
|
||||
if ($constructeur instanceof Constructeur && !$this->constructeurs->contains($constructeur)) {
|
||||
$this->constructeurs->add($constructeur);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addConstructeur(Constructeur $constructeur): static
|
||||
{
|
||||
if (!$this->constructeurs->contains($constructeur)) {
|
||||
@@ -314,77 +298,4 @@ class Piece
|
||||
{
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Product>
|
||||
*/
|
||||
public function getProducts(): Collection
|
||||
{
|
||||
return $this->products;
|
||||
}
|
||||
|
||||
public function addProduct(Product $product): static
|
||||
{
|
||||
if (!$this->products->contains($product)) {
|
||||
$this->products->add($product);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProduct(Product $product): static
|
||||
{
|
||||
$this->products->removeElement($product);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PieceProductSlot>
|
||||
*/
|
||||
public function getProductSlots(): Collection
|
||||
{
|
||||
return $this->productSlots;
|
||||
}
|
||||
|
||||
public function addProductSlot(PieceProductSlot $slot): static
|
||||
{
|
||||
if (!$this->productSlots->contains($slot)) {
|
||||
$this->productSlots->add($slot);
|
||||
$slot->setPiece($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProductSlot(PieceProductSlot $slot): static
|
||||
{
|
||||
$this->productSlots->removeElement($slot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSkipAudit(): bool
|
||||
{
|
||||
return $this->skipAudit;
|
||||
}
|
||||
|
||||
public function setSkipAudit(bool $skipAudit): static
|
||||
{
|
||||
$this->skipAudit = $skipAudit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'piece_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class PieceProductSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Piece $piece;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getPiece(): Piece
|
||||
{
|
||||
return $this->piece;
|
||||
}
|
||||
|
||||
public function setPiece(Piece $piece): static
|
||||
{
|
||||
$this->piece = $piece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedProduct(): ?Product
|
||||
{
|
||||
return $this->selectedProduct;
|
||||
}
|
||||
|
||||
public function setSelectedProduct(?Product $selectedProduct): static
|
||||
{
|
||||
$this->selectedProduct = $selectedProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Filter\MultiSearchFilter;
|
||||
use App\Repository\ProductRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -28,7 +27,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Table(name: 'products')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])]
|
||||
#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])]
|
||||
#[ApiResource(
|
||||
description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.',
|
||||
@@ -108,24 +106,12 @@ class Product
|
||||
#[ORM\OneToMany(mappedBy: 'product', targetEntity: Composant::class)]
|
||||
private Collection $composants;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Piece>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Piece::class, mappedBy: 'products')]
|
||||
private Collection $linkedPieces;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachineProductLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'product', targetEntity: MachineProductLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['product:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
private bool $skipAudit = false;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['product:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -143,7 +129,6 @@ class Product
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->linkedPieces = new ArrayCollection();
|
||||
$this->machineLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
@@ -203,22 +188,6 @@ class Product
|
||||
return $this->constructeurs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Constructeur> $constructeurs
|
||||
*/
|
||||
public function setConstructeurs(iterable $constructeurs): static
|
||||
{
|
||||
$this->constructeurs = new ArrayCollection();
|
||||
|
||||
foreach ($constructeurs as $constructeur) {
|
||||
if ($constructeur instanceof Constructeur && !$this->constructeurs->contains($constructeur)) {
|
||||
$this->constructeurs->add($constructeur);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addConstructeur(Constructeur $constructeur): static
|
||||
{
|
||||
if (!$this->constructeurs->contains($constructeur)) {
|
||||
@@ -250,36 +219,4 @@ class Product
|
||||
{
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Piece>
|
||||
*/
|
||||
public function getLinkedPieces(): Collection
|
||||
{
|
||||
return $this->linkedPieces;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSkipAudit(): bool
|
||||
{
|
||||
return $this->skipAudit;
|
||||
}
|
||||
|
||||
public function setSkipAudit(bool $skipAudit): static
|
||||
{
|
||||
$this->skipAudit = $skipAudit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'skeleton_piece_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SkeletonPieceRequirement
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonPieceRequirements')]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $modelType;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $typePiece;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getModelType(): ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'skeleton_product_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SkeletonProductRequirement
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonProductRequirements')]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $modelType;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $typeProduct;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getModelType(): ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'skeleton_subcomponent_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SkeletonSubcomponentRequirement
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonSubcomponentRequirements')]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $modelType;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
private string $alias;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, name: 'familyCode')]
|
||||
private string $familyCode;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeComposant = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getModelType(): ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAlias(): string
|
||||
{
|
||||
return $this->alias;
|
||||
}
|
||||
|
||||
public function setAlias(string $alias): static
|
||||
{
|
||||
$this->alias = $alias;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?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';
|
||||
}
|
||||
@@ -52,16 +52,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$entityType = $this->entityType();
|
||||
|
||||
@@ -115,7 +106,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field || 'version' === $field) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -126,11 +117,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip decimal formatting differences (e.g. "33.00" vs "33")
|
||||
if (is_numeric($normalizedOld) && is_numeric($normalizedNew) && (float) $normalizedOld === (float) $normalizedNew) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
@@ -243,43 +229,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the entity has a version, increment it and return the new value.
|
||||
* Recomputes the changeset so Doctrine picks up the version bump.
|
||||
*/
|
||||
protected function incrementEntityVersion(object $entity, EntityManagerInterface $em, UnitOfWork $uow): ?int
|
||||
{
|
||||
if (!method_exists($entity, 'incrementVersion') || !method_exists($entity, 'getVersion')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the version was already changed (e.g. by a sync strategy), don't double-increment
|
||||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
if (isset($changeSet['version'])) {
|
||||
return $entity->getVersion();
|
||||
}
|
||||
|
||||
$entity->incrementVersion();
|
||||
$uow->recomputeSingleEntityChangeSet(
|
||||
$em->getClassMetadata($entity::class),
|
||||
$entity,
|
||||
);
|
||||
|
||||
return $entity->getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version without incrementing (for create actions).
|
||||
*/
|
||||
protected function getEntityVersion(object $entity): ?int
|
||||
{
|
||||
if (!method_exists($entity, 'getVersion')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $entity->getVersion();
|
||||
}
|
||||
|
||||
protected function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
@@ -311,8 +260,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotEntity($entity);
|
||||
$version = $this->getEntityVersion($entity);
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
@@ -327,9 +275,8 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
if ([] !== $diff) {
|
||||
$version = $this->incrementEntityVersion($entity, $em, $uow);
|
||||
$snapshot = $this->snapshotEntity($entity);
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId, $version));
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,8 +303,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotEntity($entity);
|
||||
$version = $this->getEntityVersion($entity);
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
@@ -406,10 +352,8 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $this->incrementEntityVersion($entity, $em, $uow);
|
||||
// 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));
|
||||
$snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity);
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,64 +34,15 @@ final class ComposantAuditSubscriber extends AbstractAuditSubscriber
|
||||
|
||||
protected function snapshotEntity(object $entity): array
|
||||
{
|
||||
$pieceSlots = [];
|
||||
foreach ($entity->getPieceSlots() as $slot) {
|
||||
$pieceSlots[] = [
|
||||
'id' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$subcomponentSlots = [];
|
||||
foreach ($entity->getSubcomponentSlots() as $slot) {
|
||||
$subcomponentSlots[] = [
|
||||
'id' => $slot->getId(),
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$productSlots = [];
|
||||
foreach ($entity->getProductSlots() as $slot) {
|
||||
$productSlots[] = [
|
||||
'id' => $slot->getId(),
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$customFieldValues = [];
|
||||
foreach ($entity->getCustomFieldValues() as $cfv) {
|
||||
$customFieldValues[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'fieldName' => $cfv->getCustomField()?->getName(),
|
||||
'fieldId' => $cfv->getCustomField()?->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'reference' => $this->safeGet($entity, 'getReference'),
|
||||
'description' => $this->safeGet($entity, 'getDescription'),
|
||||
'prix' => $this->safeGet($entity, 'getPrix'),
|
||||
'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')),
|
||||
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
'pieceSlots' => $pieceSlots,
|
||||
'subcomponentSlots' => $subcomponentSlots,
|
||||
'productSlots' => $productSlots,
|
||||
'customFieldValues' => $customFieldValues,
|
||||
'version' => $this->safeGet($entity, 'getVersion'),
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'reference' => $this->safeGet($entity, 'getReference'),
|
||||
'prix' => $this->safeGet($entity, 'getPrix'),
|
||||
'structure' => $this->safeGet($entity, 'getStructure'),
|
||||
'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')),
|
||||
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,38 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
{
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
// Let parent handle regular Machine entity changes (fields, collections, custom fields)
|
||||
parent::onFlush($args);
|
||||
|
||||
// Now handle link entity changes
|
||||
$em = $args->getObjectManager();
|
||||
if (!$em instanceof EntityManagerInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
|
||||
$this->processLinkChanges($em, $uow, $actorProfileId);
|
||||
}
|
||||
|
||||
protected function supports(object $entity): bool
|
||||
{
|
||||
return $entity instanceof Machine;
|
||||
@@ -60,154 +36,13 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
|
||||
protected function snapshotEntity(object $entity): array
|
||||
{
|
||||
$customFieldValues = [];
|
||||
foreach ($entity->getCustomFieldValues() as $cfv) {
|
||||
$customFieldValues[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'fieldName' => $cfv->getCustomField()?->getName(),
|
||||
'fieldId' => $cfv->getCustomField()?->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
];
|
||||
}
|
||||
|
||||
$componentLinks = [];
|
||||
foreach ($entity->getComponentLinks() as $link) {
|
||||
$componentLinks[] = [
|
||||
'id' => $link->getId(),
|
||||
'composantId' => $link->getComposant()->getId(),
|
||||
'composantName' => $link->getComposant()->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
$pieceLinks = [];
|
||||
foreach ($entity->getPieceLinks() as $link) {
|
||||
$pieceLinks[] = [
|
||||
'id' => $link->getId(),
|
||||
'pieceId' => $link->getPiece()->getId(),
|
||||
'pieceName' => $link->getPiece()->getName(),
|
||||
'quantity' => $link->getQuantity(),
|
||||
];
|
||||
}
|
||||
|
||||
$productLinks = [];
|
||||
foreach ($entity->getProductLinks() as $link) {
|
||||
$productLinks[] = [
|
||||
'id' => $link->getId(),
|
||||
'productId' => $link->getProduct()->getId(),
|
||||
'productName' => $link->getProduct()->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'reference' => $this->safeGet($entity, 'getReference'),
|
||||
'prix' => $this->safeGet($entity, 'getPrix'),
|
||||
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
'customFieldValues' => $customFieldValues,
|
||||
'componentLinks' => $componentLinks,
|
||||
'pieceLinks' => $pieceLinks,
|
||||
'productLinks' => $productLinks,
|
||||
'version' => $this->safeGet($entity, 'getVersion'),
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'reference' => $this->safeGet($entity, 'getReference'),
|
||||
'prix' => $this->safeGet($entity, 'getPrix'),
|
||||
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
|
||||
private function processLinkChanges(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId): void
|
||||
{
|
||||
$machineChanges = [];
|
||||
|
||||
// Detect inserted links
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
$info = $this->extractLinkInfo($entity, 'added');
|
||||
if (null === $info) {
|
||||
continue;
|
||||
}
|
||||
$machineId = (string) $info['machine']->getId();
|
||||
if ('' === $machineId) {
|
||||
continue;
|
||||
}
|
||||
$machineChanges[$machineId] ??= ['machine' => $info['machine'], 'diffs' => []];
|
||||
$machineChanges[$machineId]['diffs'][$info['diffKey']] = [
|
||||
'from' => null,
|
||||
'to' => $info['diffValue'],
|
||||
];
|
||||
}
|
||||
|
||||
// Detect deleted links
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
$info = $this->extractLinkInfo($entity, 'removed');
|
||||
if (null === $info) {
|
||||
continue;
|
||||
}
|
||||
$machineId = (string) $info['machine']->getId();
|
||||
if ('' === $machineId) {
|
||||
continue;
|
||||
}
|
||||
$machineChanges[$machineId] ??= ['machine' => $info['machine'], 'diffs' => []];
|
||||
$machineChanges[$machineId]['diffs'][$info['diffKey']] = [
|
||||
'from' => $info['diffValue'],
|
||||
'to' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Create audit logs for each affected machine
|
||||
foreach ($machineChanges as $machineId => $change) {
|
||||
$machine = $change['machine'];
|
||||
$diff = $change['diffs'];
|
||||
|
||||
if ([] === $diff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $this->incrementEntityVersion($machine, $em, $uow);
|
||||
$snapshot = $this->snapshotEntity($machine);
|
||||
|
||||
$this->persistAuditLog(
|
||||
$em,
|
||||
new AuditLog('machine', $machineId, 'update', $diff, $snapshot, $actorProfileId, $version),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{machine: Machine, diffKey: string, diffValue: array{id: string, name: string}}
|
||||
*/
|
||||
private function extractLinkInfo(object $entity, string $action): ?array
|
||||
{
|
||||
if ($entity instanceof MachineComponentLink) {
|
||||
return [
|
||||
'machine' => $entity->getMachine(),
|
||||
'diffKey' => $action.'Component',
|
||||
'diffValue' => [
|
||||
'id' => $entity->getComposant()->getId(),
|
||||
'name' => $entity->getComposant()->getName(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($entity instanceof MachinePieceLink) {
|
||||
return [
|
||||
'machine' => $entity->getMachine(),
|
||||
'diffKey' => $action.'Piece',
|
||||
'diffValue' => [
|
||||
'id' => $entity->getPiece()->getId(),
|
||||
'name' => $entity->getPiece()->getName(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($entity instanceof MachineProductLink) {
|
||||
return [
|
||||
'machine' => $entity->getMachine(),
|
||||
'diffKey' => $action.'Product',
|
||||
'diffValue' => [
|
||||
'id' => $entity->getProduct()->getId(),
|
||||
'name' => $entity->getProduct()->getName(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,39 +34,15 @@ final class PieceAuditSubscriber extends AbstractAuditSubscriber
|
||||
|
||||
protected function snapshotEntity(object $entity): array
|
||||
{
|
||||
$productSlots = [];
|
||||
foreach ($entity->getProductSlots() as $slot) {
|
||||
$productSlots[] = [
|
||||
'id' => $slot->getId(),
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$customFieldValues = [];
|
||||
foreach ($entity->getCustomFieldValues() as $cfv) {
|
||||
$customFieldValues[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'fieldName' => $cfv->getCustomField()?->getName(),
|
||||
'fieldId' => $cfv->getCustomField()?->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'reference' => $this->safeGet($entity, 'getReference'),
|
||||
'description' => $this->safeGet($entity, 'getDescription'),
|
||||
'prix' => $this->safeGet($entity, 'getPrix'),
|
||||
'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')),
|
||||
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
'productSlots' => $productSlots,
|
||||
'customFieldValues' => $customFieldValues,
|
||||
'version' => $this->safeGet($entity, 'getVersion'),
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'reference' => $this->safeGet($entity, 'getReference'),
|
||||
'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()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
/**
|
||||
* Keep the legacy single product relation in sync with the ManyToMany products collection.
|
||||
* Keep the legacy single product relation in sync with the new productIds array.
|
||||
*/
|
||||
final class PieceProductSyncSubscriber implements EventSubscriber
|
||||
{
|
||||
|
||||
@@ -34,25 +34,13 @@ final class ProductAuditSubscriber extends AbstractAuditSubscriber
|
||||
|
||||
protected function snapshotEntity(object $entity): array
|
||||
{
|
||||
$customFieldValues = [];
|
||||
foreach ($entity->getCustomFieldValues() as $cfv) {
|
||||
$customFieldValues[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'fieldName' => $cfv->getCustomField()?->getName(),
|
||||
'fieldId' => $cfv->getCustomField()?->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'reference' => $this->safeGet($entity, 'getReference'),
|
||||
'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'),
|
||||
'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')),
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
'customFieldValues' => $customFieldValues,
|
||||
'version' => $this->safeGet($entity, 'getVersion'),
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'reference' => $this->safeGet($entity, 'getReference'),
|
||||
'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'),
|
||||
'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')),
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Piece;
|
||||
use App\Service\ReferenceAutoGenerator;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class ReferenceAutoSubscriber
|
||||
{
|
||||
public function __construct(private readonly ReferenceAutoGenerator $generator) {}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
$uow = $em->getUnitOfWork();
|
||||
|
||||
/** @var array<string, Piece> */
|
||||
$piecesToRecalculate = [];
|
||||
|
||||
/** @var array<string, Composant> */
|
||||
$composantsToRecalculate = [];
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof Piece) {
|
||||
$piecesToRecalculate[$entity->getId()] = $entity;
|
||||
} elseif ($entity instanceof Composant) {
|
||||
$composantsToRecalculate[$entity->getId()] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if ($entity instanceof Piece) {
|
||||
$piecesToRecalculate[$entity->getId()] = $entity;
|
||||
} elseif ($entity instanceof Composant) {
|
||||
$composantsToRecalculate[$entity->getId()] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
// For CFV insertions: the new CFV is not yet in the DB, so the lazy-loaded
|
||||
// collection won't contain it. We must add it manually so the generator sees it.
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
if ($entity->getPiece()) {
|
||||
$piece = $entity->getPiece();
|
||||
if (!$piece->getCustomFieldValues()->contains($entity)) {
|
||||
$piece->getCustomFieldValues()->add($entity);
|
||||
}
|
||||
$piecesToRecalculate[$piece->getId()] = $piece;
|
||||
} elseif ($entity->getComposant()) {
|
||||
$composant = $entity->getComposant();
|
||||
if (!$composant->getCustomFieldValues()->contains($entity)) {
|
||||
$composant->getCustomFieldValues()->add($entity);
|
||||
}
|
||||
$composantsToRecalculate[$composant->getId()] = $composant;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
if ($entity->getPiece()) {
|
||||
$piece = $entity->getPiece();
|
||||
$piecesToRecalculate[$piece->getId()] = $piece;
|
||||
} elseif ($entity->getComposant()) {
|
||||
$composant = $entity->getComposant();
|
||||
$composantsToRecalculate[$composant->getId()] = $composant;
|
||||
}
|
||||
}
|
||||
|
||||
// For CFV deletions: remove from collection so the generator doesn't see stale values.
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
if ($entity->getPiece()) {
|
||||
$piece = $entity->getPiece();
|
||||
$piece->getCustomFieldValues()->removeElement($entity);
|
||||
$piecesToRecalculate[$piece->getId()] = $piece;
|
||||
} elseif ($entity->getComposant()) {
|
||||
$composant = $entity->getComposant();
|
||||
$composant->getCustomFieldValues()->removeElement($entity);
|
||||
$composantsToRecalculate[$composant->getId()] = $composant;
|
||||
}
|
||||
}
|
||||
|
||||
$pieceMeta = $em->getClassMetadata(Piece::class);
|
||||
$composantMeta = $em->getClassMetadata(Composant::class);
|
||||
|
||||
foreach ($piecesToRecalculate as $piece) {
|
||||
$newRef = $this->generator->generate($piece);
|
||||
if ($piece->getReferenceAuto() !== $newRef) {
|
||||
$piece->setReferenceAuto($newRef);
|
||||
$uow->recomputeSingleEntityChangeSet($pieceMeta, $piece);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($composantsToRecalculate as $composant) {
|
||||
$newRef = $this->generator->generate($composant);
|
||||
if ($composant->getReferenceAuto() !== $newRef) {
|
||||
$composant->setReferenceAuto($newRef);
|
||||
$uow->recomputeSingleEntityChangeSet($composantMeta, $composant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::EXCEPTION => ['onKernelException', 256],
|
||||
KernelEvents::EXCEPTION => 'onKernelException',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -28,17 +28,10 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$constraint = $this->detectConstraintName($exception);
|
||||
$error = match ($constraint) {
|
||||
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
|
||||
default => 'Un élément avec cette valeur existe déjà.',
|
||||
};
|
||||
|
||||
$event->setResponse(new JsonResponse(
|
||||
[
|
||||
'success' => false,
|
||||
'error' => $error,
|
||||
'constraint' => $constraint,
|
||||
'success' => false,
|
||||
'error' => 'nom duplique',
|
||||
],
|
||||
JsonResponse::HTTP_CONFLICT
|
||||
));
|
||||
@@ -54,15 +47,4 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function detectConstraintName(UniqueConstraintViolationException $exception): ?string
|
||||
{
|
||||
$message = $exception->getMessage();
|
||||
|
||||
if (preg_match('/constraint\s+"([^"]+)"/', $message, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filter;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
final class MultiSearchFilter extends AbstractFilter
|
||||
{
|
||||
public function getDescription(string $resourceClass): array
|
||||
{
|
||||
return [
|
||||
'search' => [
|
||||
'property' => null,
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => 'Search across: '.implode(', ', array_keys($this->properties ?? [])),
|
||||
'openapi' => [
|
||||
'allowEmptyValue' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
|
||||
{
|
||||
if ('search' !== $property || !$value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fields = $this->properties ?? [];
|
||||
if (empty($fields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$alias = $queryBuilder->getRootAliases()[0];
|
||||
$orConditions = [];
|
||||
|
||||
foreach (array_keys($fields) as $field) {
|
||||
$paramName = $queryNameGenerator->generateParameterName($field);
|
||||
$orConditions[] = sprintf('LOWER(%s.%s) LIKE LOWER(:%s)', $alias, $field, $paramName);
|
||||
$queryBuilder->setParameter($paramName, '%'.$value.'%');
|
||||
}
|
||||
|
||||
$queryBuilder->andWhere(implode(' OR ', $orConditions));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Resource;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpResource(
|
||||
uri: 'inventory://roles',
|
||||
name: 'Roles & Permissions',
|
||||
description: 'Role hierarchy and permissions for MCP tools.',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
class RolesResource
|
||||
{
|
||||
public function __invoke(): array
|
||||
{
|
||||
$roles = [
|
||||
'hierarchy' => [
|
||||
'ROLE_ADMIN' => 'Inherits ROLE_GESTIONNAIRE. Can manage profiles.',
|
||||
'ROLE_GESTIONNAIRE' => 'Inherits ROLE_VIEWER. Can create, update, delete all entities.',
|
||||
'ROLE_VIEWER' => 'Inherits ROLE_USER. Can read all entities, create comments, search.',
|
||||
'ROLE_USER' => 'Base role. Authenticated but minimal access.',
|
||||
],
|
||||
'tool_permissions' => [
|
||||
'ROLE_VIEWER' => 'list_*, get_*, search_inventory, get_dashboard_stats, get_entity_history, get_activity_log, list_comments, create_comment, get_unresolved_comments_count, list_custom_field_values, list_documents, list_slots',
|
||||
'ROLE_GESTIONNAIRE' => 'All VIEWER tools + create_*, update_*, delete_*, clone_machine, update_slots, add_machine_links, remove_machine_link, resolve_comment, upsert_custom_field_values, sync_model_type',
|
||||
],
|
||||
];
|
||||
|
||||
return [new TextContent(text: json_encode($roles, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))];
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Resource;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpResource(
|
||||
uri: 'inventory://schema/entities',
|
||||
name: 'Entity Schema',
|
||||
description: 'Complete schema of all inventory entities with their fields, types, and relationships.',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
class SchemaResource
|
||||
{
|
||||
public function __invoke(): array
|
||||
{
|
||||
$schema = [
|
||||
'Machine' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'prix (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['site (Site, required)', 'constructeurs (Constructeur[])', 'componentLinks (MachineComponentLink[])', 'pieceLinks (MachinePieceLink[])', 'productLinks (MachineProductLink[])', 'customFields (CustomField[])', 'customFieldValues (CustomFieldValue[])'],
|
||||
],
|
||||
'Composant' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['typeComposant (ModelType?)', 'constructeurs (Constructeur[])', 'pieceSlots (ComposantPieceSlot[])', 'productSlots (ComposantProductSlot[])', 'subcomponentSlots (ComposantSubcomponentSlot[])', 'customFieldValues (CustomFieldValue[])'],
|
||||
],
|
||||
'Piece' => [
|
||||
'fields' => ['id (string)', 'name (string)', 'reference (string?, unique)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['typePiece (ModelType?)', 'product (Product?)', 'constructeurs (Constructeur[])', 'productSlots (PieceProductSlot[])', 'customFieldValues (CustomFieldValue[])'],
|
||||
],
|
||||
'Product' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'supplierPrice (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['typeProduct (ModelType?)', 'constructeurs (Constructeur[])'],
|
||||
],
|
||||
'Site' => [
|
||||
'fields' => ['id (string)', 'name (string)', 'contactName (string)', 'contactPhone (string)', 'contactAddress (string)', 'contactPostalCode (string)', 'contactCity (string)', 'color (string)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['machines (Machine[])'],
|
||||
],
|
||||
'Constructeur' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'email (string?)', 'phone (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['machines (Machine[])', 'composants (Composant[])', 'pieces (Piece[])', 'products (Product[])'],
|
||||
],
|
||||
'ModelType' => [
|
||||
'fields' => ['id (string)', 'name (string)', 'category (machine|composant|piece|product)', 'code (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['skeletonPieceRequirements[]', 'skeletonProductRequirements[]', 'skeletonSubcomponentRequirements[]'],
|
||||
],
|
||||
];
|
||||
|
||||
return [new TextContent(text: json_encode($schema, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user