Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8272eb43e | |||
| add6129ee5 | |||
| 746578c2cf | |||
| d3f1e95711 | |||
| 517aefcd9b | |||
| 90ad851804 | |||
| c3ad3b68a2 | |||
| 494298f981 | |||
| b775718df6 | |||
| c02f999a32 | |||
| e05ba6a97c | |||
| 012d552ddc | |||
| 594ed7b631 | |||
| 7836f87cd2 | |||
| d5361ac3ec | |||
| 477295c400 | |||
| 22dddb73bd | |||
| cb49c69662 | |||
| f18ae545d8 | |||
| 3003ced157 | |||
| 2b318ce5d6 | |||
| c10ab08803 | |||
| 85d4726415 | |||
| af13dc0237 | |||
| 7e2cabfa65 | |||
| 003e419a93 | |||
| d1b170d87f | |||
| 0fc9daa974 | |||
| 104942a52b | |||
| c65757ee24 | |||
| 6e105fd070 | |||
| a0c4597de0 | |||
| d3f269452c | |||
| b3fa927e77 | |||
| f71f4c68da |
@@ -40,3 +40,10 @@ DEFAULT_URI=http://localhost
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
###< nelmio/cors-bundle ###
|
||||
|
||||
###> sentry/sentry-symfony ###
|
||||
# Error tracking backend → GlitchTip (projet "inventory-api"). Prod only, vide => inerte.
|
||||
# À définir dans l'env de prod (PAS ici, pas de secret commité). Format :
|
||||
# SENTRY_DSN=http://<clé>@<host-ou-IP>:<port>/<id-projet>
|
||||
# SENTRY_DSN=
|
||||
###< sentry/sentry-symfony ###
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ jobs:
|
||||
run: |
|
||||
docker build \
|
||||
-f infra/prod/Dockerfile \
|
||||
--build-arg NUXT_PUBLIC_SENTRY_DSN="${{ secrets.INVENTORY_SENTRY_DSN_FRONT }}" \
|
||||
--build-arg SENTRY_URL="${{ secrets.SENTRY_URL }}" \
|
||||
--build-arg SENTRY_ORG="${{ secrets.SENTRY_ORG }}" \
|
||||
--build-arg SENTRY_PROJECT="${{ secrets.SENTRY_PROJECT }}" \
|
||||
--build-arg SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \
|
||||
-t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/inventory:latest \
|
||||
.
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
"X-Profile-Id": "admin-default-profile",
|
||||
"X-Profile-Password": "A123"
|
||||
}
|
||||
},
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "http://project.malio-dev.fr/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer b355c6cbf27d2a86d7eba1c3132c99bb3133f94cfd9e9243ffcc3c5ae1dc82c8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# CLAUDE.md — Inventory Project
|
||||
|
||||
## Project Overview
|
||||
|
||||
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
|
||||
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
|
||||
**Monorepo** : backend Symfony + frontend Nuxt (`frontend/`) dans le **même dépôt git** (plus de submodule). Un seul commit/push couvre backend + frontend.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -15,269 +13,134 @@ Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
|
||||
| Frontend | Nuxt (SPA, SSR off) | 4 |
|
||||
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
|
||||
| CSS | TailwindCSS 4 + DaisyUI 5 | |
|
||||
| Auth | Session-based (cookies, pas JWT) | |
|
||||
| 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.
|
||||
## Documentation détaillée (lire à la demande, ne pas dupliquer ici)
|
||||
|
||||
Vu la complexité du projet, le détail vit dans `docs/` — y aller plutôt que de deviner :
|
||||
|
||||
- **`docs/FONCTIONNEMENT.md`** — le métier : à quoi sert l'app, entités, ModelType/skeleton, cycle de vie, rôles, fonctionnalités clés.
|
||||
- **`docs/GLOSSAIRE_METIER.md`** — glossaire complet, correspondance métier ↔ code (le « pourquoi »).
|
||||
- **`docs/BACKEND.md`** — catalogue backend complet : toutes les entités, **tous les controllers** (routes), audit, services, migrations, auth, rôles.
|
||||
- **`docs/FRONTEND.md`** — catalogue frontend : composables, composants, useApi, IRIs, content-types, auth, style.
|
||||
- **`docs/REVIEW_ARCHITECTURE.md`** — top 10 des sources de complexité et effets de bord (God controllers, canaux cachés, doubles flush…). **À consulter avant tout refacto.**
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Inventory/ # Backend Symfony (repo principal)
|
||||
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
|
||||
│ └── Trait/ # CuidEntityTrait (génération d'ID CUID)
|
||||
├── 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
|
||||
├── scripts/ # release.sh, normalize-dump.py
|
||||
├── fixtures/ # SQL fixtures
|
||||
├── tests/ # PHPUnit
|
||||
├── pre-commit, commit-msg # Git hooks
|
||||
├── makefile # Commandes Docker/dev
|
||||
├── VERSION # Source unique de version (semver)
|
||||
├── frontend/ # ← SUBMODULE GIT (repo séparé)
|
||||
│ ├── app/pages/ # Pages Nuxt (file-based routing)
|
||||
│ ├── app/components/ # Composants Vue (auto-imported)
|
||||
│ ├── app/composables/ # Composables Vue
|
||||
│ ├── app/shared/ # Types, utils, validation
|
||||
│ ├── app/middleware/ # Auth middleware global
|
||||
│ └── app/services/ # Service layer (wrappers useApi)
|
||||
├── src/Entity/ (+ Trait/) # Entités Doctrine (attributs PHP 8), CuidEntityTrait
|
||||
├── src/Controller/ # Controllers custom (session, comments, audit, structure…)
|
||||
├── src/EventSubscriber/ # Audit (onFlush) + sync/contraintes
|
||||
├── src/Service/ (+ Sync/) # Services métier (sync, conversion, storage, versions…)
|
||||
├── src/Enum/ src/DTO/ src/Filter/ src/Command/
|
||||
├── config/ migrations/ docker/ scripts/ fixtures/ tests/
|
||||
├── makefile VERSION # VERSION = source unique de version (semver)
|
||||
└── frontend/ # ← Frontend Nuxt (MÊME repo, pas un submodule)
|
||||
└── app/{pages,components,composables,shared,middleware,services}/
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
make start # Démarrer les containers
|
||||
make stop # Arrêter
|
||||
make shell # Shell interactif (nécessite un TTY)
|
||||
make install # Install complet (composer + npm + build)
|
||||
make start / make stop # Démarrer / arrêter les containers
|
||||
make shell # Shell interactif (nécessite un TTY)
|
||||
make install # Install complet (composer + npm + build)
|
||||
|
||||
# Backend
|
||||
make test # PHPUnit (tous les tests)
|
||||
make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique
|
||||
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer)
|
||||
make test # PHPUnit (tous)
|
||||
make test FILES=tests/Api/Entity/MachineTest.php # Un test
|
||||
make test-setup # Créer/MAJ le schéma de test
|
||||
make php-cs-fixer-allow-risky # Linter PHP
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
|
||||
|
||||
# Frontend (dans frontend/)
|
||||
npm run dev # Dev server (port 3001)
|
||||
npm run build # Build production
|
||||
npm run lint:fix # ESLint fix
|
||||
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
||||
npm run dev # Dev server (port 3001)
|
||||
npm run lint:fix # ESLint fix
|
||||
npx nuxi typecheck # TypeScript check (0 erreur 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
|
||||
make db-reset # Reset DB (drop + recreate schema)
|
||||
make fixtures-reset # Reset DB + recharger fixtures SQL
|
||||
make import-data # Importer les dumps SQL normalisés
|
||||
make cache-clear
|
||||
|
||||
# Import fournisseurs (customer.json → Constructeur + ConstructeurCategorie + ConstructeurTelephone)
|
||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run (par défaut)
|
||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
|
||||
# Non destructif : find-or-create par nom normalisé, ne change jamais un ID existant, n'ajoute que les téléphones/catégories manquants
|
||||
# Import fournisseurs (non destructif : find-or-create par nom normalisé)
|
||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run
|
||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
|
||||
|
||||
# Release
|
||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||
./scripts/release.sh patch # Bump version (patch/minor/major)
|
||||
```
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Branches
|
||||
- `master` — production
|
||||
- `develop` — branche principale de dev (cible des PR)
|
||||
- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail
|
||||
- **Branches** : `master` (prod), `develop` (cible des PR), `feat/* fix/* refactor/*`.
|
||||
- **Commit** (enforced par hook) : `<type>(<scope>) : <message>` — **espace obligatoire autour du `:`**. Types : `build chore ci docs feat fix perf refactor revert style test wip`.
|
||||
- Ex : `feat(auth) : add login page`, `fix(machines) : prevent null crash`
|
||||
- **Pre-commit hook** : php-cs-fixer + PHPUnit (bloque si échec).
|
||||
- **Workflow commit** : backend + frontend = **un seul commit/push** depuis la racine (pas de submodule). Le hook étant lent, committer avec `git commit --no-verify`. Push rejeté → `git pull --rebase` puis `git push`.
|
||||
- **Sync master ↔ develop** : `git checkout master && git merge develop && git push` puis revenir sur `develop`.
|
||||
|
||||
### Commit Message Format (enforced by hook)
|
||||
```
|
||||
<type>(<scope optionnel>) : <message>
|
||||
```
|
||||
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
|
||||
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip`
|
||||
## Pièges & patterns non-évidents
|
||||
|
||||
Exemples :
|
||||
- `feat(auth) : add login page`
|
||||
- `fix(machines) : prevent null crash on skeleton creation`
|
||||
> Le catalogue complet est dans `docs/BACKEND.md` / `docs/FRONTEND.md`. Ci-dessous **uniquement** ce qui n'est pas évident en lisant le code.
|
||||
|
||||
### Pre-commit Hook
|
||||
1. php-cs-fixer sur les fichiers PHP stagés
|
||||
2. PHPUnit — bloque le commit si tests échouent
|
||||
|
||||
### Submodule Workflow
|
||||
Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
1. Commit dans `frontend/` d'abord
|
||||
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
|
||||
3. Push les deux repos
|
||||
|
||||
## Architecture Backend
|
||||
|
||||
### Entités Principales
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `ConstructeurCategorie`, `ConstructeurTelephone`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||
|
||||
> **Constructeur (Fournisseur)** : possède `name`, `email`, une collection `telephones` (1-N → `ConstructeurTelephone`, cascade/orphanRemoval) et `categories` (M2M → `ConstructeurCategorie`, table `constructeur_categories`). Sérialisation API Platform via les groupes `constructeur:read` / `constructeur:write` (téléphones & catégories embarqués). ⚠️ L'adder M2M s'appelle `addCategory()`/`removeCategory()` (l'inflector singularise `categories` → `category`), pas `addCategorie`. `ConstructeurCategorie` et `ConstructeurTelephone` sont aussi des `ApiResource` à part entière (`/api/constructeur_categories`, `/api/constructeur_telephones`).
|
||||
|
||||
#### 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([...])]`)
|
||||
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` avec `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`
|
||||
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform
|
||||
- **Audit** : Subscribers Doctrine `onFlush` capturent diff + snapshot complet
|
||||
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
||||
|
||||
### 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.
|
||||
- `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.
|
||||
- `EntityVersionController` — `/api/{entity}/{id}/versions` (GET), `/api/{entity}/{id}/versions/{version}/restore` (POST) : historique de versions numérotées et restauration.
|
||||
- `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.
|
||||
- **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
|
||||
- `EntityVersionService` — gestion des versions numérotées (snapshot, restore) pour machines, pièces, composants, produits
|
||||
- `ReferenceAutoGenerator` — génération automatique de références pour pièces et composants à partir de formules ModelType
|
||||
- `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
|
||||
- `ReferenceAutoSubscriber` — recalcule les références auto des pièces/composants quand les CustomFieldValues changent (onFlush)
|
||||
|
||||
### Rôles (hiérarchie)
|
||||
```
|
||||
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
```
|
||||
### Backend
|
||||
- **IDs CUID** : strings `'cl' + bin2hex(random_bytes(12))`, **pas** d'auto-increment.
|
||||
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` + `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`.
|
||||
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform. Hiérarchie : `ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER`.
|
||||
- **Audit** : subscribers Doctrine `onFlush` (diff + snapshot complet).
|
||||
- **Migrations** : raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` (idempotence).
|
||||
- **Constructeur (Fournisseur)** : collection `telephones` (1-N, cascade/orphanRemoval) + `categories` (M2M, table `constructeur_categories`). ⚠️ L'adder M2M est `addCategory()`/`removeCategory()` (l'inflector singularise `categories` → `category`), **pas** `addCategorie`. Groupes API `constructeur:read` / `constructeur:write`.
|
||||
- **Normalisation slots/skeleton** : les anciennes colonnes JSON `structure`/`productIds` sont remplacées par des tables relationnelles — slots réels (`ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`, `PieceProductSlot`) vs définitions ModelType (`SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`).
|
||||
- **Custom Fields** : Composants/Pièces/Produits → définitions dans les `Skeleton*Requirement` du ModelType (clé `customFields` JSON) ; Machines → entités `CustomField` liées par `machineId` FK (pas de ModelType). Les deux partagent l'entité `CustomFieldValue` pour les valeurs.
|
||||
- **`MachineStructureController`** (`/api/machines/{id}/structure`, `/clone`) : source principale de données de la page détail machine (normalisation JSON manuelle). Cf. `REVIEW_ARCHITECTURE.md` (God controller).
|
||||
|
||||
### PostgreSQL — ATTENTION
|
||||
- Les noms de colonnes sont **TOUJOURS EN MINUSCULES** dans PG
|
||||
- Doctrine utilise camelCase (`typePieceId`) mais PG stocke `typepieceid`
|
||||
- Le SQL brut doit utiliser les noms lowercase
|
||||
- Tables de jointure many-to-many : colonnes `a` et `b` (ex: `_piececonstructeurs`)
|
||||
- Noms de colonnes **TOUJOURS EN MINUSCULES** en PG. Doctrine camelCase (`typePieceId`) → PG `typepieceid`. Le **SQL brut doit être lowercase**.
|
||||
- Tables de jointure M2M : colonnes `a` et `b` (ex : `_piececonstructeurs`).
|
||||
|
||||
## Architecture Frontend
|
||||
|
||||
### Patterns
|
||||
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
|
||||
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
|
||||
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
|
||||
- **⚠️ Préfixe `/api`** : `useApi()` **prepend déjà** `apiBaseUrl` (= `/api` par défaut, cf. `nuxt.config.ts`). Les appels doivent donc utiliser des chemins **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` et **PAS** `api.get('/api/custom-fields/names')` (sinon 404 sur `/api/api/...`).
|
||||
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
|
||||
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
|
||||
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
|
||||
- **Auto-imports** : Nuxt auto-importe composants (`components/`) et composables (`composables/`)
|
||||
|
||||
### DaisyUI Classes
|
||||
- Input : `input input-bordered input-sm md:input-md`
|
||||
- Textarea : `textarea textarea-bordered textarea-sm md:textarea-md`
|
||||
- Select : `select select-bordered select-sm md:select-md`
|
||||
- Button : `btn btn-sm md:btn-md btn-primary`
|
||||
### Frontend
|
||||
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`.
|
||||
- **Communication composants** : Props + Events uniquement (**pas** de provide/inject).
|
||||
- **API** : `useApi.ts` wrappe fetch avec `credentials: 'include'`. ⚠️ `useApi()` **préfixe déjà** `/api` → appeler **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` **et PAS** `'/api/custom-fields/names'` (sinon 404 sur `/api/api/...`).
|
||||
- **Content-Type** : `application/ld+json` (POST/PUT), `application/merge-patch+json` (PATCH).
|
||||
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`. Permissions : `usePermissions.ts` (miroir de la hiérarchie backend).
|
||||
- **Classes DaisyUI** : `input input-bordered input-sm md:input-md` (idem textarea/select/btn, `btn-primary`).
|
||||
|
||||
## Règles Importantes
|
||||
|
||||
### CLAUDE.md — Maintenance obligatoire
|
||||
- **Toujours consulter** ce fichier en début de conversation pour respecter les conventions
|
||||
- **Mettre à jour** ce fichier quand une nouvelle convention, pattern ou décision architecturale est établie
|
||||
- **Utiliser comme source de vérité** pour les commandes, patterns et règles du projet
|
||||
|
||||
### Toujours faire AVANT de modifier du code
|
||||
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
|
||||
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
|
||||
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
|
||||
### Avant de modifier du code
|
||||
1. **Lire le fichier** avant de l'éditer.
|
||||
2. **Reproduire le pattern existant** (noms, indentation, structure).
|
||||
3. **Vérifier backend ET frontend** — un changement peut impacter les deux (même repo).
|
||||
|
||||
### Après chaque modification
|
||||
1. Backend PHP : `make php-cs-fixer-allow-risky`
|
||||
2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés
|
||||
2. Frontend TS : `npm run lint:fix` puis `npx nuxi typecheck`
|
||||
|
||||
### Ne jamais faire
|
||||
- Ajouter des features non demandées, du code mort, ou des abstractions prématurées
|
||||
- Utiliser `provide/inject` — le codebase utilise Props + Events
|
||||
- Utiliser JWT/tokens — l'auth est session-based
|
||||
- Écrire du SQL avec des noms camelCase — PostgreSQL = lowercase
|
||||
- Committer sans que l'utilisateur le demande explicitement
|
||||
- Force push sans confirmation explicite
|
||||
- Modifier la config git
|
||||
- Features non demandées, code mort, abstractions prématurées
|
||||
- `provide/inject` (le code utilise Props + Events) · JWT/tokens (auth session-based)
|
||||
- SQL en camelCase (PG = lowercase)
|
||||
- Committer sans demande explicite · force push sans confirmation · modifier la config git
|
||||
|
||||
### Submodule — Synchronisation
|
||||
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
|
||||
- Main repo : `git checkout master && git merge develop && git push`
|
||||
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
|
||||
### Maintenir ce fichier
|
||||
Mettre à jour quand une nouvelle convention/pattern/décision archi est établie. Source de vérité pour commandes, pièges et règles ; le **détail** descriptif va dans `docs/`.
|
||||
|
||||
## Tests
|
||||
|
||||
### Stack de test
|
||||
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`)
|
||||
- **DAMA DoctrineTestBundle** — wrappe chaque test dans une transaction avec rollback automatique (pas de TRUNCATE)
|
||||
- Base de test : même PG, env `test`
|
||||
|
||||
### Commandes
|
||||
Voir section "Key Commands". Commande additionnelle :
|
||||
```bash
|
||||
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()`, `createConstructeurCategorie()`, `createConstructeurTelephone()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`), env `test`, même PG.
|
||||
- **DAMA DoctrineTestBundle** : chaque test wrappé en transaction + rollback auto → **ne PAS** faire de TRUNCATE/cleanup en `tearDown`.
|
||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories `create*()`).
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`.
|
||||
|
||||
## URLs Locales
|
||||
- API Symfony : `http://localhost:8081/api`
|
||||
- Nuxt dev : `http://localhost:3001`
|
||||
- Adminer (PG) : `http://localhost:5050`
|
||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||
- API Symfony : `http://localhost:8081/api` · Nuxt dev : `http://localhost:3001`
|
||||
- Adminer : `http://localhost:5050` · PG direct : `localhost:5433` (user/pass `root`, db `inventory`)
|
||||
|
||||
## Delegation Codex
|
||||
|
||||
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||
|
||||
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||
|
||||
C'est le meilleur ratio qualite/credits.
|
||||
## Délégation Codex
|
||||
Pour les tâches mécaniques (tests, boilerplate, renommages, refacto répétitif), déléguer à Codex via le plugin `codex` (junior rapide/pas cher). Garder Claude pour la réflexion, l'architecture et la vérification (senior). Meilleur ratio qualité/crédits.
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"nyholm/psr7": "^1.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"sentry/sentry-symfony": "^5.10",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
"symfony/dotenv": "8.0.*",
|
||||
|
||||
Generated
+419
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5c54b1589d9e815f4c9b7e5e1d2d69c7",
|
||||
"content-hash": "beb5fa2114e6597caebb6a098de494c1",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2361,6 +2361,185 @@
|
||||
},
|
||||
"time": "2025-10-26T09:35:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "2.12.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/psr7.git",
|
||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.1 || ^2.0",
|
||||
"ralouphie/getallheaders": "^3.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||
"symfony/polyfill-php80": "^1.25"
|
||||
},
|
||||
"provide": {
|
||||
"psr/http-factory-implementation": "1.0",
|
||||
"psr/http-message-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"http-interop/http-factory-tests": "1.1.0",
|
||||
"jshttp/mime-db": "1.54.0.1",
|
||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
|
||||
},
|
||||
"suggest": {
|
||||
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": true,
|
||||
"forward-command": false
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GuzzleHttp\\Psr7\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
},
|
||||
{
|
||||
"name": "George Mponos",
|
||||
"email": "gmponos@gmail.com",
|
||||
"homepage": "https://github.com/gmponos"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Nyholm",
|
||||
"email": "tobias.nyholm@gmail.com",
|
||||
"homepage": "https://github.com/Nyholm"
|
||||
},
|
||||
{
|
||||
"name": "Márk Sági-Kazár",
|
||||
"email": "mark.sagikazar@gmail.com",
|
||||
"homepage": "https://github.com/sagikazarmark"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Schultze",
|
||||
"email": "webmaster@tubo-world.de",
|
||||
"homepage": "https://github.com/Tobion"
|
||||
},
|
||||
{
|
||||
"name": "Márk Sági-Kazár",
|
||||
"email": "mark.sagikazar@gmail.com",
|
||||
"homepage": "https://sagikazarmark.hu"
|
||||
}
|
||||
],
|
||||
"description": "PSR-7 message implementation that also provides common utility methods",
|
||||
"keywords": [
|
||||
"http",
|
||||
"message",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response",
|
||||
"stream",
|
||||
"uri",
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/psr7/issues",
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.12.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Nyholm",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-23T15:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mcp/sdk",
|
||||
"version": "v0.4.0",
|
||||
@@ -3701,6 +3880,245 @@
|
||||
},
|
||||
"time": "2024-09-11T13:17:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "ralouphie/getallheaders",
|
||||
"version": "3.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ralouphie/getallheaders.git",
|
||||
"reference": "120b605dfeb996808c31b6477290a714d356e822"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
|
||||
"reference": "120b605dfeb996808c31b6477290a714d356e822",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.6"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-coveralls/php-coveralls": "^2.1",
|
||||
"phpunit/phpunit": "^5 || ^6.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/getallheaders.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ralph Khattar",
|
||||
"email": "ralph.khattar@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A polyfill for getallheaders.",
|
||||
"support": {
|
||||
"issues": "https://github.com/ralouphie/getallheaders/issues",
|
||||
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
|
||||
},
|
||||
"time": "2019-03-08T08:55:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry",
|
||||
"version": "4.28.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-php.git",
|
||||
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
||||
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
|
||||
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"raven/raven": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"carthage-software/mago": "1.30.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.4",
|
||||
"guzzlehttp/promises": "^2.0.3",
|
||||
"monolog/monolog": "^1.6|^2.0|^3.0",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"open-telemetry/api": "^1.0",
|
||||
"open-telemetry/exporter-otlp": "^1.0",
|
||||
"open-telemetry/sdk": "^1.0",
|
||||
"phpstan/phpstan": "^1.3",
|
||||
"phpunit/phpunit": "^8.5.52|^9.6.34",
|
||||
"spiral/roadrunner-http": "^3.6",
|
||||
"spiral/roadrunner-worker": "^3.6"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "PHP SDK for Sentry (http://sentry.io)",
|
||||
"homepage": "http://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-php/issues",
|
||||
"source": "https://github.com/getsentry/sentry-php/tree/4.28.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-11T12:22:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry-symfony",
|
||||
"version": "5.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-symfony.git",
|
||||
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
||||
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/psr7": "^2.1.1",
|
||||
"jean85/pretty-package-versions": "^1.5||^2.0",
|
||||
"php": "^7.2||^8.0",
|
||||
"sentry/sentry": "^4.23.0",
|
||||
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
|
||||
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/polyfill-php80": "^1.22",
|
||||
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0",
|
||||
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^2.13||^3.3||^4.0",
|
||||
"doctrine/doctrine-bundle": "^2.6||^3.0",
|
||||
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
|
||||
"masterminds/html5": "^2.8",
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "1.12.5",
|
||||
"phpstan/phpstan-phpunit": "1.4.0",
|
||||
"phpstan/phpstan-symfony": "1.4.10",
|
||||
"phpunit/phpunit": "^8.5.40||^9.6.21",
|
||||
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/monolog-bundle": "^3.4||^4.0",
|
||||
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0",
|
||||
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"vimeo/psalm": "^4.3||^5.16.0"
|
||||
},
|
||||
"suggest": {
|
||||
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
|
||||
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
|
||||
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/aliases.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\SentryBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "Symfony integration for Sentry (http://getsentry.com)",
|
||||
"homepage": "http://getsentry.com",
|
||||
"keywords": [
|
||||
"errors",
|
||||
"logging",
|
||||
"sentry",
|
||||
"symfony"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-symfony/issues",
|
||||
"source": "https://github.com/getsentry/sentry-symfony/tree/5.10.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-01T14:50:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/asset",
|
||||
"version": "v8.0.0",
|
||||
|
||||
@@ -7,6 +7,7 @@ use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
|
||||
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Sentry\SentryBundle\SentryBundle;
|
||||
use Symfony\AI\McpBundle\McpBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||
@@ -24,4 +25,5 @@ return [
|
||||
DAMADoctrineTestBundle::class => ['test' => true],
|
||||
McpBundle::class => ['all' => true],
|
||||
MonologBundle::class => ['all' => true],
|
||||
SentryBundle::class => ['prod' => 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.6
|
||||
version: 1.9.40
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Error tracking → GlitchTip (compatible SDK Sentry).
|
||||
# Actif uniquement en prod (bundle enregistré prod-only dans bundles.php).
|
||||
# Si SENTRY_DSN est vide/non défini, le SDK est inerte (rien n'est envoyé).
|
||||
when@prod:
|
||||
parameters:
|
||||
# Valeur par défaut : DSN vide => Sentry désactivé tant qu'il n'est pas fourni.
|
||||
env(SENTRY_DSN): ''
|
||||
|
||||
sentry:
|
||||
dsn: '%env(SENTRY_DSN)%'
|
||||
# Capture des erreurs fatales PHP via le handler. On DÉSACTIVE le listener
|
||||
# kernel pour éviter les doublons avec le handler Monolog (ci-dessous) : les
|
||||
# exceptions du kernel sont déjà logguées par Symfony => remontées via Monolog.
|
||||
register_error_listener: false
|
||||
register_error_handler: true
|
||||
options:
|
||||
environment: '%env(APP_ENV)%'
|
||||
release: '%app.version%'
|
||||
# Pas d'APM/tracing (DuckDB hors périmètre du ticket #146).
|
||||
traces_sample_rate: 0.0
|
||||
# Ne pas remonter les 4xx HTTP comme des erreurs (bruit).
|
||||
ignore_exceptions:
|
||||
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
|
||||
- Symfony\Component\Security\Core\Exception\AccessDeniedException
|
||||
|
||||
# Handler Monolog -> Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip
|
||||
# (en plus des erreurs fatales). Les $logger->error(...) métier deviennent des Issues.
|
||||
# Le filtre ignore_exceptions ci-dessus s'applique aussi à ces événements.
|
||||
services:
|
||||
Sentry\Monolog\Handler:
|
||||
arguments:
|
||||
$hub: '@Sentry\State\HubInterface'
|
||||
$level: !php/const Monolog\Level::Error
|
||||
$bubble: true
|
||||
@@ -4,6 +4,11 @@
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
# See also https://symfony.com/doc/current/service_container/import.html
|
||||
|
||||
# Expose le paramètre app.version (source unique, bumpé par le script de release) au
|
||||
# container — utilisé notamment comme "release" Sentry/GlitchTip dans sentry.yaml.
|
||||
imports:
|
||||
- { resource: version.yaml }
|
||||
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.34'
|
||||
app.version: '1.9.50'
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{{ backLabel }}
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -29,6 +29,7 @@ import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
@@ -43,12 +44,20 @@ defineEmits<{
|
||||
'toggle-edit': []
|
||||
}>()
|
||||
|
||||
const backDestination = computed(() => {
|
||||
// Retour : on revient à l'URL précédente pour préserver l'état de la liste
|
||||
// (recherche, tri, pagination persistés en query params). Fallback sur le
|
||||
// backLink si pas d'historique applicatif (accès direct, refresh, lien partagé).
|
||||
const goBack = () => {
|
||||
if (route.query.from === 'machine' && route.query.machineId) {
|
||||
return `/machine/${route.query.machineId}`
|
||||
router.push(`/machine/${route.query.machineId}`)
|
||||
return
|
||||
}
|
||||
return props.backLink
|
||||
})
|
||||
if (window.history.state?.back) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
router.push(props.backLink)
|
||||
}
|
||||
|
||||
const backLabel = computed(() => {
|
||||
if (route.query.from === 'machine') {
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
Ajouter une nouvelle machine
|
||||
</h3>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div v-if="errorMessage" class="alert alert-error mb-4" role="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
</svg>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -78,6 +91,7 @@ const props = defineProps<{
|
||||
sites: Array<{ id: string, name: string }>
|
||||
disabled: boolean
|
||||
preselectedSiteId?: string
|
||||
errorMessage?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<ul>
|
||||
<!-- First crumb (always visible) -->
|
||||
<li>
|
||||
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors">
|
||||
<NuxtLink :to="crumbs[0].to" class="text-base-content/60 hover:text-primary transition-colors">
|
||||
{{ crumbs[0].label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
@@ -18,7 +18,7 @@
|
||||
:key="i"
|
||||
class="hidden sm:list-item"
|
||||
>
|
||||
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors">
|
||||
<NuxtLink :to="crumb.to" class="text-base-content/60 hover:text-primary transition-colors">
|
||||
{{ crumb.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
@@ -32,15 +32,40 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useListQueryMemory } from '~/composables/useListQueryMemory'
|
||||
|
||||
interface Crumb {
|
||||
label: string
|
||||
path: string
|
||||
to: RouteLocationRaw
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const { remember, recall } = useListQueryMemory()
|
||||
|
||||
// Routes-listes dont la recherche / tri / pagination doit survivre à une
|
||||
// navigation par fil d'Ariane ou menu (qui ne passe pas par l'historique).
|
||||
const LIST_PATHS = ['/machines', '/catalogues/composants', '/catalogues/pieces', '/catalogues/produits']
|
||||
|
||||
// On enregistre la query courante dès qu'on est sur une route-liste (et à chaque
|
||||
// changement de recherche/tri/pagination, qui modifie fullPath).
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (LIST_PATHS.includes(route.path)) remember(route.path, route.query)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Cible d'un crumb pointant vers une liste : on réinjecte la dernière query
|
||||
// mémorisée pour restaurer l'état, sinon chemin nu (liste neuve).
|
||||
const listTo = (path: string): RouteLocationRaw => {
|
||||
const query = recall(path)
|
||||
return query && Object.keys(query).length > 0 ? { path, query } : path
|
||||
}
|
||||
|
||||
const crumbs = computed<Crumb[]>(() => {
|
||||
const result: Crumb[] = [{ label: 'Accueil', path: '/' }]
|
||||
const result: Crumb[] = [{ label: 'Accueil', to: '/' }]
|
||||
const path = route.path
|
||||
|
||||
// Home page — no breadcrumb
|
||||
@@ -48,88 +73,88 @@ const crumbs = computed<Crumb[]>(() => {
|
||||
|
||||
// Machine context from query param (when navigating from a machine detail page)
|
||||
if (route.query.from === 'machine' && route.query.machineId) {
|
||||
result.push({ label: 'Parc machines', path: '/machines' })
|
||||
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` })
|
||||
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||
result.push({ label: 'Machine', to: `/machine/${route.query.machineId}` })
|
||||
}
|
||||
|
||||
// Machines
|
||||
if (path === '/machines') {
|
||||
result.push({ label: 'Parc machines', path: '/machines' })
|
||||
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||
} else if (path.startsWith('/machine/') && !route.query.from) {
|
||||
result.push({ label: 'Parc machines', path: '/machines' })
|
||||
result.push({ label: 'Machine', path })
|
||||
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||
result.push({ label: 'Machine', to: path })
|
||||
}
|
||||
|
||||
// Catalogs
|
||||
else if (path.startsWith('/catalogues/composants')) {
|
||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||
} else if (path.startsWith('/catalogues/pieces')) {
|
||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||
} else if (path.startsWith('/catalogues/produits')) {
|
||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||
}
|
||||
|
||||
// Entity detail pages (when NOT from machine context)
|
||||
else if (path.startsWith('/component/') && !route.query.from) {
|
||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||
result.push({ label: 'Composant', path })
|
||||
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||
result.push({ label: 'Composant', to: path })
|
||||
} else if (path.startsWith('/piece/') && !route.query.from) {
|
||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||
result.push({ label: 'Pièce', path })
|
||||
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||
result.push({ label: 'Pièce', to: path })
|
||||
} else if (path.startsWith('/product/') && !route.query.from) {
|
||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||
result.push({ label: 'Produit', path })
|
||||
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||
result.push({ label: 'Produit', to: path })
|
||||
}
|
||||
|
||||
// Entity detail pages WITH machine context — add entity as last crumb
|
||||
else if (path.startsWith('/component/') && route.query.from === 'machine') {
|
||||
result.push({ label: 'Composant', path })
|
||||
result.push({ label: 'Composant', to: path })
|
||||
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
|
||||
result.push({ label: 'Pièce', path })
|
||||
result.push({ label: 'Pièce', to: path })
|
||||
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
|
||||
result.push({ label: 'Produit', path })
|
||||
result.push({ label: 'Produit', to: path })
|
||||
}
|
||||
|
||||
// Admin pages
|
||||
else if (path.startsWith('/sites')) {
|
||||
result.push({ label: 'Sites', path: '/sites' })
|
||||
result.push({ label: 'Sites', to: '/sites' })
|
||||
} else if (path.startsWith('/constructeurs')) {
|
||||
result.push({ label: 'Fournisseurs', path: '/constructeurs' })
|
||||
result.push({ label: 'Fournisseurs', to: '/constructeurs' })
|
||||
} else if (path.startsWith('/activity-log')) {
|
||||
result.push({ label: 'Journal d\'activité', path: '/activity-log' })
|
||||
result.push({ label: 'Journal d\'activité', to: '/activity-log' })
|
||||
} else if (path.startsWith('/admin')) {
|
||||
result.push({ label: 'Administration', path: '/admin' })
|
||||
result.push({ label: 'Administration', to: '/admin' })
|
||||
} else if (path.startsWith('/documents')) {
|
||||
result.push({ label: 'Documents', path: '/documents' })
|
||||
result.push({ label: 'Documents', to: '/documents' })
|
||||
} else if (path.startsWith('/comments')) {
|
||||
result.push({ label: 'Commentaires', path: '/comments' })
|
||||
result.push({ label: 'Commentaires', to: '/comments' })
|
||||
}
|
||||
|
||||
// Category pages
|
||||
else if (path.startsWith('/component-category')) {
|
||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||
result.push({ label: 'Catégorie', path })
|
||||
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||
result.push({ label: 'Catégorie', to: path })
|
||||
} else if (path.startsWith('/piece-category')) {
|
||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||
result.push({ label: 'Catégorie', path })
|
||||
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||
result.push({ label: 'Catégorie', to: path })
|
||||
} else if (path.startsWith('/product-category')) {
|
||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||
result.push({ label: 'Catégorie', path })
|
||||
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||
result.push({ label: 'Catégorie', to: path })
|
||||
}
|
||||
|
||||
// Create pages
|
||||
else if (path.startsWith('/pieces/create')) {
|
||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||
result.push({ label: 'Nouvelle pièce', path })
|
||||
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||
result.push({ label: 'Nouvelle pièce', to: path })
|
||||
} else if (path.startsWith('/component/create')) {
|
||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||
result.push({ label: 'Nouveau composant', path })
|
||||
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||
result.push({ label: 'Nouveau composant', to: path })
|
||||
} else if (path.startsWith('/product/create')) {
|
||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||
result.push({ label: 'Nouveau produit', path })
|
||||
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||
result.push({ label: 'Nouveau produit', to: path })
|
||||
} else if (path === '/machines/new') {
|
||||
result.push({ label: 'Parc machines', path: '/machines' })
|
||||
result.push({ label: 'Nouvelle machine', path })
|
||||
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||
result.push({ label: 'Nouvelle machine', to: path })
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
>
|
||||
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
Parc machines
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
|
||||
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const router = useRouter()
|
||||
|
||||
// Retour : revient à l'URL précédente pour préserver la recherche/filtres du
|
||||
// parc machines (persistés en query params). Fallback vers /machines si pas
|
||||
// d'historique applicatif (accès direct, refresh, lien partagé).
|
||||
const goBack = () => {
|
||||
if (window.history.state?.back) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
router.push('/machines')
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
|
||||
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
|
||||
limit.value = response.limit
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
|
||||
// Requête annulée volontairement (nouvelle recherche / démontage) : pas une
|
||||
// vraie erreur. On teste le signal car ofetch encapsule l'AbortError dans
|
||||
// une FetchError, donc error.name n'est pas fiable.
|
||||
if (controller.signal.aborted) return
|
||||
showError(extractErrorMessage(error))
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface ConstructeurTelephone {
|
||||
'@id'?: string
|
||||
@@ -33,6 +33,24 @@ interface ConstructeurResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ConstructeurPageOptions {
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
search?: string
|
||||
categoryId?: string
|
||||
orderField?: 'name' | 'email' | 'createdAt'
|
||||
orderDirection?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface ConstructeurPageResult {
|
||||
success: boolean
|
||||
items: Constructeur[]
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
currentPage: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const constructeurs = ref<Constructeur[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
@@ -83,8 +101,10 @@ export function useConstructeurs() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
const result = await get(`/constructeurs${query}`)
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '2000')
|
||||
if (search) params.set('search', search)
|
||||
const result = await get(`/constructeurs?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
constructeurs.value = uniqueConstructeurs(items)
|
||||
@@ -104,6 +124,37 @@ export function useConstructeurs() {
|
||||
return loadConstructeurs(search)
|
||||
}
|
||||
|
||||
const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
|
||||
const page = Math.max(1, opts.page ?? 1)
|
||||
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(page))
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
|
||||
if (opts.categoryId) params.set('categories.id', opts.categoryId)
|
||||
if (opts.orderField) {
|
||||
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
|
||||
}
|
||||
const result = await get(`/constructeurs?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
|
||||
}
|
||||
const items = extractCollection<Constructeur>(result.data)
|
||||
const totalItems = extractTotal(result.data, items.length)
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
|
||||
upsertConstructeurs(items)
|
||||
return { success: true, items, totalItems, totalPages, currentPage: page }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors du chargement de la page fournisseurs:', error)
|
||||
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -227,6 +278,7 @@ export function useConstructeurs() {
|
||||
loading,
|
||||
loadConstructeurs,
|
||||
searchConstructeurs,
|
||||
fetchConstructeursPage,
|
||||
createConstructeur,
|
||||
updateConstructeur,
|
||||
deleteConstructeur,
|
||||
|
||||
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
// CRUD operations
|
||||
const refreshDocuments = async () => {
|
||||
const e = entity()
|
||||
if (!e?.id || e._structurePiece) return
|
||||
// Pending / category-only nodes carry the link id (not a real entity id) and
|
||||
// have no backing piece/composant — never request documents for them.
|
||||
if (!e?.id || e._structurePiece || e.pendingEntity) return
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
||||
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
}
|
||||
|
||||
const ensureDocumentsLoaded = async () => {
|
||||
if (documentsLoaded.value || !entity()?.id) return
|
||||
const e = entity()
|
||||
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { reactive } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
// Singleton module-level : mémorise la dernière query (recherche / tri /
|
||||
// pagination / filtres) vue sur chaque route-liste. Permet aux navigations qui
|
||||
// ne passent PAS par l'historique du navigateur (fil d'Ariane, menu) de
|
||||
// restaurer l'état de la liste, là où router.back() le ferait pour le bouton
|
||||
// Retour. SPA only (SSR off) — pas de fuite d'état entre requêtes.
|
||||
const memory = reactive<Record<string, LocationQuery>>({})
|
||||
|
||||
export function useListQueryMemory() {
|
||||
const remember = (path: string, query: LocationQuery) => {
|
||||
memory[path] = { ...query }
|
||||
}
|
||||
const recall = (path: string): LocationQuery | undefined => memory[path]
|
||||
return { remember, recall }
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
|
||||
export function useMachineCreatePage() {
|
||||
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
|
||||
|
||||
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local state
|
||||
@@ -27,11 +25,17 @@ export function useMachineCreatePage() {
|
||||
const submitting = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
/** Persistent error shown inline in the form (e.g. duplicate name on the same site). */
|
||||
const createError = ref<string | null>(null)
|
||||
|
||||
const newMachine = reactive({
|
||||
name: '',
|
||||
siteId: '',
|
||||
reference: '',
|
||||
cloneFromMachineId: '',
|
||||
// 'full' = clone complet (composants/pièces concrets) ; 'structure' = catégories
|
||||
// uniquement (slots à compléter).
|
||||
cloneMode: 'full' as 'full' | 'structure',
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -41,8 +45,10 @@ export function useMachineCreatePage() {
|
||||
const finalizeMachineCreation = async () => {
|
||||
if (submitting.value) return
|
||||
|
||||
createError.value = null
|
||||
|
||||
if (!newMachine.name?.trim()) {
|
||||
toast.showError('Merci de renseigner un nom pour la machine')
|
||||
createError.value = 'Merci de renseigner un nom pour la machine.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -54,6 +60,7 @@ export function useMachineCreatePage() {
|
||||
result = await cloneMachine(newMachine.cloneFromMachineId, {
|
||||
name: newMachine.name,
|
||||
siteId: newMachine.siteId,
|
||||
mode: newMachine.cloneMode,
|
||||
...(newMachine.reference ? { reference: newMachine.reference } : {}),
|
||||
})
|
||||
} else {
|
||||
@@ -80,10 +87,10 @@ export function useMachineCreatePage() {
|
||||
await navigateTo('/machines')
|
||||
}
|
||||
} else if (result.error) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||
createError.value = humanizeError(result.error)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||
createError.value = humanizeError(error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -116,6 +123,7 @@ export function useMachineCreatePage() {
|
||||
machines,
|
||||
submitting,
|
||||
loading,
|
||||
createError,
|
||||
|
||||
// Actions
|
||||
finalizeMachineCreation,
|
||||
|
||||
@@ -169,7 +169,7 @@ export function useMachines() {
|
||||
}
|
||||
}
|
||||
|
||||
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string }): Promise<ApiResponse> => {
|
||||
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string; mode?: 'full' | 'structure' }): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post(`/machines/${sourceId}/clone`, data)
|
||||
|
||||
@@ -167,7 +167,7 @@ import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||
import { buildDeleteMessageWithUsage, type UsageInfo } from '~/shared/utils/deleteImpactUtils'
|
||||
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
|
||||
@@ -249,10 +249,25 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
|
||||
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
const api = useApi()
|
||||
|
||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||
const pieceName = piece?.name || 'cette pièce'
|
||||
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
|
||||
|
||||
let usage: UsageInfo = {}
|
||||
try {
|
||||
const result = await api.get(`/pieces/${piece.id}/used-in`)
|
||||
if (result.success && result.data) {
|
||||
usage = {
|
||||
machines: result.data.machines ?? [],
|
||||
composants: result.data.composants ?? [],
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Impossible de récupérer les usages de la pièce avant suppression :', error)
|
||||
}
|
||||
|
||||
const message = buildDeleteMessageWithUsage(pieceName, 'Cette pièce', usage)
|
||||
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
|
||||
if (!confirmed) return
|
||||
await deletePiece(piece.id)
|
||||
|
||||
@@ -19,13 +19,17 @@
|
||||
<div class="card-body space-y-4">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="filteredConstructeurs"
|
||||
:rows="pageItems"
|
||||
:loading="loading"
|
||||
:sort="currentSort"
|
||||
:show-counter="false"
|
||||
:pagination="paginationState"
|
||||
:show-counter="true"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun fournisseur trouvé."
|
||||
no-results-message="Aucun fournisseur trouvé."
|
||||
@sort="handleSort"
|
||||
@update:current-page="onPageChange"
|
||||
@update:per-page="onPerPageChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex flex-col sm:flex-row gap-3 w-full">
|
||||
@@ -204,7 +208,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
@@ -229,10 +233,17 @@ interface ConstructeurFormState {
|
||||
|
||||
const api = useApi()
|
||||
const { canEdit } = usePermissions()
|
||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||
const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
|
||||
const { categories: allCategories, loadCategories } = useConstructeurCategories()
|
||||
const { showError } = useToast()
|
||||
|
||||
const pageItems = ref<typeof constructeurs.value>([])
|
||||
const totalItems = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(30)
|
||||
const perPageOptions = [15, 30, 50, 100]
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
@@ -261,6 +272,44 @@ const handleSort = (sort) => {
|
||||
sortDir.value = sort.direction
|
||||
}
|
||||
|
||||
const paginationState = computed(() => ({
|
||||
currentPage: currentPage.value,
|
||||
totalPages: totalPages.value,
|
||||
totalItems: totalItems.value,
|
||||
pageItems: pageItems.value.length,
|
||||
perPage: perPage.value,
|
||||
perPageOptions,
|
||||
}))
|
||||
|
||||
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
|
||||
|
||||
const loadPage = async () => {
|
||||
const orderField = SORTABLE_FIELDS.has(sortKey.value)
|
||||
? (sortKey.value as 'name' | 'email' | 'createdAt')
|
||||
: 'name'
|
||||
const result = await fetchConstructeursPage({
|
||||
page: currentPage.value,
|
||||
itemsPerPage: perPage.value,
|
||||
search: searchTerm.value,
|
||||
categoryId: selectedCategoryId.value || undefined,
|
||||
orderField,
|
||||
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
|
||||
})
|
||||
if (!result.success) {
|
||||
if (result.error) showError(result.error)
|
||||
pageItems.value = []
|
||||
totalItems.value = 0
|
||||
totalPages.value = 0
|
||||
return
|
||||
}
|
||||
pageItems.value = result.items
|
||||
totalItems.value = result.totalItems
|
||||
totalPages.value = result.totalPages
|
||||
if (currentPage.value > result.totalPages && result.totalPages > 0) {
|
||||
currentPage.value = result.totalPages
|
||||
}
|
||||
}
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingConstructeur = ref<Record<string, any> | null>(null)
|
||||
@@ -268,29 +317,31 @@ const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], c
|
||||
|
||||
const rowPhones = constructeurPhones
|
||||
|
||||
const filteredConstructeurs = computed(() => {
|
||||
const key = sortKey.value
|
||||
const dir = sortDir.value === 'desc' ? -1 : 1
|
||||
let sorted = [...constructeurs.value].sort((a, b) => {
|
||||
if (key === 'createdAt') {
|
||||
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
||||
}
|
||||
return dir * (a[key] || '').localeCompare(b[key] || '')
|
||||
})
|
||||
if (selectedCategoryId.value) {
|
||||
sorted = sorted.filter(item => (item.categories || []).some(cat => cat.id === selectedCategoryId.value))
|
||||
}
|
||||
if (!searchTerm.value) { return sorted }
|
||||
const term = searchTerm.value.toLowerCase()
|
||||
return sorted.filter((item) => {
|
||||
const haystack = [item.name, item.email, ...rowPhones(item).map(t => t.numero)]
|
||||
return haystack.some(value => value && String(value).toLowerCase().includes(term))
|
||||
})
|
||||
const debouncedSearch = debounce(() => {
|
||||
currentPage.value = 1
|
||||
loadPage()
|
||||
}, 300)
|
||||
|
||||
watch(selectedCategoryId, () => {
|
||||
currentPage.value = 1
|
||||
loadPage()
|
||||
})
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
await searchConstructeurs(searchTerm.value)
|
||||
}, 300)
|
||||
watch([sortKey, sortDir], () => {
|
||||
currentPage.value = 1
|
||||
loadPage()
|
||||
})
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
loadPage()
|
||||
}
|
||||
|
||||
const onPerPageChange = (value: number) => {
|
||||
perPage.value = value
|
||||
currentPage.value = 1
|
||||
loadPage()
|
||||
}
|
||||
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
@@ -386,7 +437,7 @@ const saveConstructeur = async () => {
|
||||
saving.value = false
|
||||
if (result.success) {
|
||||
closeModal()
|
||||
await searchConstructeurs(searchTerm.value)
|
||||
await loadPage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +448,10 @@ const confirmDelete = async (constructeur) => {
|
||||
const result = await deleteConstructeur(constructeur.id)
|
||||
if (!result.success && result.error) {
|
||||
showError(result.error)
|
||||
return
|
||||
}
|
||||
if (result.success) {
|
||||
await loadPage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +463,7 @@ const loadStats = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConstructeurs()
|
||||
loadPage()
|
||||
loadCategories()
|
||||
loadStats()
|
||||
})
|
||||
|
||||
@@ -58,7 +58,26 @@
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="form-control md:w-52">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Trier par</span>
|
||||
</label>
|
||||
<select v-model="sortOrder" class="select select-bordered w-full">
|
||||
<option value="name-asc">
|
||||
Nom (A → Z)
|
||||
</option>
|
||||
<option value="name-desc">
|
||||
Nom (Z → A)
|
||||
</option>
|
||||
<option value="date-desc">
|
||||
Plus récentes
|
||||
</option>
|
||||
<option value="date-asc">
|
||||
Plus anciennes
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control md:w-80">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
|
||||
</label>
|
||||
@@ -66,13 +85,13 @@
|
||||
<input
|
||||
v-model="dateFrom"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
<span class="text-xs text-base-content/50">à</span>
|
||||
<input
|
||||
v-model="dateTo"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +116,7 @@
|
||||
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
||||
Ajouter un site
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
|
||||
<button class="btn btn-ghost btn-sm" @click="openAddMachineModal">
|
||||
Ajouter une machine
|
||||
</button>
|
||||
</div>
|
||||
@@ -263,7 +282,8 @@
|
||||
:sites="sites"
|
||||
:disabled="!canEdit"
|
||||
:preselected-site-id="preselectedSiteId"
|
||||
@close="showAddMachineModal = false"
|
||||
:error-message="addMachineError"
|
||||
@close="closeAddMachineModal"
|
||||
@create="handleCreateMachine"
|
||||
/>
|
||||
</main>
|
||||
@@ -293,8 +313,10 @@ const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
|
||||
// Data
|
||||
const showAddSiteModal = ref(false)
|
||||
const showAddMachineModal = ref(false)
|
||||
const addMachineError = ref(null)
|
||||
const searchTerm = ref('')
|
||||
const selectedSiteFilter = ref('')
|
||||
const sortOrder = ref('name-asc')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const collapsedSites = ref([])
|
||||
@@ -318,10 +340,33 @@ const machinesBySiteId = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
const sortMachines = (machineList) => {
|
||||
const list = [...machineList]
|
||||
switch (sortOrder.value) {
|
||||
case 'name-desc':
|
||||
return list.sort((a, b) =>
|
||||
(b.name || '').localeCompare(a.name || '', 'fr', { sensitivity: 'base', numeric: true })
|
||||
)
|
||||
case 'date-desc':
|
||||
return list.sort((a, b) =>
|
||||
new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
|
||||
)
|
||||
case 'date-asc':
|
||||
return list.sort((a, b) =>
|
||||
new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
|
||||
)
|
||||
case 'name-asc':
|
||||
default:
|
||||
return list.sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || '', 'fr', { sensitivity: 'base', numeric: true })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const sitesWithMachines = computed(() => {
|
||||
return sites.value.map((site) => ({
|
||||
...site,
|
||||
machines: machinesBySiteId.value.get(site.id) || []
|
||||
machines: sortMachines(machinesBySiteId.value.get(site.id) || [])
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -406,11 +451,14 @@ const handleCreateSite = async (data) => {
|
||||
}
|
||||
|
||||
const handleCreateMachine = async (data) => {
|
||||
addMachineError.value = null
|
||||
const result = await createMachine(data)
|
||||
|
||||
if (result.success) {
|
||||
showAddMachineModal.value = false
|
||||
await loadMachines()
|
||||
} else if (result.error) {
|
||||
addMachineError.value = humanizeError(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,9 +503,19 @@ const confirmDeleteMachine = async (machine) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openAddMachineModal = () => {
|
||||
addMachineError.value = null
|
||||
showAddMachineModal.value = true
|
||||
}
|
||||
|
||||
const closeAddMachineModal = () => {
|
||||
addMachineError.value = null
|
||||
showAddMachineModal.value = false
|
||||
}
|
||||
|
||||
const addMachineToSite = (site) => {
|
||||
preselectedSiteId.value = site.id
|
||||
showAddMachineModal.value = true
|
||||
openAddMachineModal()
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
</div>
|
||||
|
||||
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
|
||||
<div v-if="c.createError" class="alert alert-error" role="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
</svg>
|
||||
<span>{{ c.createError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-6">
|
||||
<!-- Basic fields -->
|
||||
@@ -90,6 +103,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clone mode (visible only when a source machine is selected) -->
|
||||
<div v-if="c.newMachine.cloneFromMachineId" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Mode de clonage</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:gap-6">
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="c.newMachine.cloneMode"
|
||||
type="radio"
|
||||
value="full"
|
||||
class="radio radio-primary radio-sm mt-0.5"
|
||||
:disabled="!canEdit"
|
||||
>
|
||||
<span class="text-sm">
|
||||
Tout cloner
|
||||
<span class="block text-xs text-gray-500">Structure + composants et pièces assignés</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="c.newMachine.cloneMode"
|
||||
type="radio"
|
||||
value="structure"
|
||||
class="radio radio-primary radio-sm mt-0.5"
|
||||
:disabled="!canEdit"
|
||||
>
|
||||
<span class="text-sm">
|
||||
Structure seule
|
||||
<span class="block text-xs text-gray-500">Catégories uniquement, slots à compléter</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
|
||||
<NuxtLink to="/machines" class="btn btn-outline btn-sm md:btn-md">
|
||||
|
||||
@@ -14,6 +14,77 @@ export const buildDeleteMessage = (entityName: string, impacts: string[]): strin
|
||||
if (impacts.length) {
|
||||
lines.push(`Cela supprimera également :\n• ${impacts.join('\n• ')}`)
|
||||
}
|
||||
lines.push('Cette action est irréversible.')
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
|
||||
interface UsedInMachine {
|
||||
id: string
|
||||
name: string | null
|
||||
site?: { id: string; name: string | null } | null
|
||||
}
|
||||
|
||||
interface UsedInEntity {
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
|
||||
export interface UsageInfo {
|
||||
machines?: UsedInMachine[]
|
||||
composants?: UsedInEntity[]
|
||||
pieces?: UsedInEntity[]
|
||||
}
|
||||
|
||||
const formatMachineLine = (m: UsedInMachine): string => {
|
||||
const name = m.name?.trim() || '(sans nom)'
|
||||
const siteName = m.site?.name?.trim()
|
||||
return siteName ? `${name} (${siteName})` : name
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a delete-confirmation message that lists the machines (and other
|
||||
* entities) currently using the item. The user sees exactly what will be
|
||||
* detached before they confirm the deletion.
|
||||
*/
|
||||
export const buildDeleteMessageWithUsage = (
|
||||
entityName: string,
|
||||
entityLabel: string,
|
||||
usage: UsageInfo,
|
||||
): string => {
|
||||
const machines = usage.machines ?? []
|
||||
const composants = usage.composants ?? []
|
||||
const pieces = usage.pieces ?? []
|
||||
|
||||
const lines = [`Voulez-vous vraiment supprimer « ${entityName} » ?`]
|
||||
|
||||
if (machines.length > 0) {
|
||||
const header = machines.length === 1
|
||||
? `${entityLabel} est actuellement utilisée par 1 machine :`
|
||||
: `${entityLabel} est actuellement utilisée par ${machines.length} machines :`
|
||||
const bullets = machines.map((m) => `• ${formatMachineLine(m)}`).join('\n')
|
||||
lines.push(`${header}\n${bullets}\n\nLa supprimer la retirera de ${machines.length === 1 ? 'cette machine' : 'ces machines'}.`)
|
||||
}
|
||||
|
||||
if (composants.length > 0) {
|
||||
const header = composants.length === 1
|
||||
? 'Elle est également référencée par 1 composant :'
|
||||
: `Elle est également référencée par ${composants.length} composants :`
|
||||
const bullets = composants
|
||||
.map((c) => `• ${c.name?.trim() || '(sans nom)'}`)
|
||||
.join('\n')
|
||||
lines.push(`${header}\n${bullets}`)
|
||||
}
|
||||
|
||||
if (pieces.length > 0) {
|
||||
const header = pieces.length === 1
|
||||
? 'Elle est également utilisée par 1 pièce :'
|
||||
: `Elle est également utilisée par ${pieces.length} pièces :`
|
||||
const bullets = pieces
|
||||
.map((p) => `• ${p.name?.trim() || '(sans nom)'}`)
|
||||
.join('\n')
|
||||
lines.push(`${header}\n${bullets}`)
|
||||
}
|
||||
|
||||
lines.push('Cette action est irréversible.')
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
+23
-2
@@ -41,7 +41,20 @@ export default defineNuxtConfig({
|
||||
lucide: () => import('@iconify-json/lucide/icons.json').then(i => i.default)
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
// Error tracking → GlitchTip. Module chargé uniquement si un DSN est fourni
|
||||
// (build prod) ; en dev sans DSN, aucun overhead Sentry. Les options d'upload des
|
||||
// source maps sont passées en ligne (fournies au build via secrets CI).
|
||||
...(process.env.NUXT_PUBLIC_SENTRY_DSN
|
||||
? [['@sentry/nuxt/module', {
|
||||
sourceMapsUploadOptions: {
|
||||
url: process.env.SENTRY_URL,
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN
|
||||
}
|
||||
}] as [string, Record<string, unknown>]]
|
||||
: [])
|
||||
],
|
||||
runtimeConfig: {
|
||||
apiBaseUrl: process.env.NUXT_API_BASE_URL
|
||||
@@ -57,9 +70,17 @@ export default defineNuxtConfig({
|
||||
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'false',
|
||||
enableAnalytics: process.env.NUXT_PUBLIC_ENABLE_ANALYTICS || 'false',
|
||||
csrfToken: process.env.NUXT_PUBLIC_CSRF_TOKEN || '',
|
||||
logLevel: process.env.NUXT_PUBLIC_LOG_LEVEL || 'warn'
|
||||
logLevel: process.env.NUXT_PUBLIC_LOG_LEVEL || 'warn',
|
||||
sentry: {
|
||||
// DSN du projet GlitchTip "inventory-front" (vide => SDK inerte).
|
||||
dsn: process.env.NUXT_PUBLIC_SENTRY_DSN || '',
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
}
|
||||
}
|
||||
},
|
||||
// Source maps "hidden" : générées et uploadées vers GlitchTip pour des stacktraces
|
||||
// lisibles, sans exposer les .map au navigateur.
|
||||
sourcemap: { client: 'hidden' },
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
server: {
|
||||
|
||||
Generated
+839
-15
@@ -8,6 +8,7 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@sentry/nuxt": "^10.61.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"daisyui": "^5.0.48",
|
||||
"nuxt": "^4.0.1",
|
||||
@@ -70,6 +71,64 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@apm-js-collab/code-transformer": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz",
|
||||
"integrity": "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.8",
|
||||
"astring": "^1.9.0",
|
||||
"esquery": "^1.7.0",
|
||||
"meriyah": "^6.1.4",
|
||||
"semifies": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"code-transformer": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@apm-js-collab/code-transformer-bundler-plugins": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz",
|
||||
"integrity": "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apm-js-collab/code-transformer": "^0.15.0",
|
||||
"es-module-lexer": "^2.1.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"module-details-from-path": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apm-js-collab/code-transformer-bundler-plugins/node_modules/es-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@apm-js-collab/code-transformer/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apm-js-collab/tracing-hooks": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz",
|
||||
"integrity": "sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@apm-js-collab/code-transformer": "^0.15.0",
|
||||
"debug": "^4.4.1",
|
||||
"module-details-from-path": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -2133,6 +2192,103 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.214.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz",
|
||||
"integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/core": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz",
|
||||
"integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/instrumentation": {
|
||||
"version": "0.214.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz",
|
||||
"integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.214.0",
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
"require-in-the-middle": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz",
|
||||
"integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.8.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz",
|
||||
"integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.8.0",
|
||||
"@opentelemetry/resources": "2.8.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.41.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
|
||||
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-minify/binding-android-arm64": {
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.87.0.tgz",
|
||||
@@ -3747,6 +3903,602 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry/babel-plugin-component-annotate": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz",
|
||||
"integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.62.0.tgz",
|
||||
"integrity": "sha512-uJi0yPssB3Nt/cZ8/S8opW42gaM59/6IyNtPFYD7C0ciudi/nIo5QMVpCYBBI3jnKFOIQLlsMT4pDlOLuxxNuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.62.0",
|
||||
"@sentry/core": "10.62.0",
|
||||
"@sentry/feedback": "10.62.0",
|
||||
"@sentry/replay": "10.62.0",
|
||||
"@sentry/replay-canvas": "10.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser-utils": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.62.0.tgz",
|
||||
"integrity": "sha512-mS9HVVuWIdye9o0xUGFmzNOBqktF4n5kugrF8NCOYYDrr5ZV8Cx7BlquHQn5UpCeViVhZtcDlEm4iOK7++Px7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugin-core": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz",
|
||||
"integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.18.5",
|
||||
"@sentry/babel-plugin-component-annotate": "5.3.0",
|
||||
"@sentry/cli": "^2.58.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"find-up": "^5.0.0",
|
||||
"glob": "^13.0.6",
|
||||
"magic-string": "~0.30.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugin-core/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugin-core/node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugin-core/node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugin-core/node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/bundler-plugin-core/node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
|
||||
"integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"progress": "^2.0.3",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"which": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"sentry-cli": "bin/sentry-cli"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@sentry/cli-darwin": "2.58.6",
|
||||
"@sentry/cli-linux-arm": "2.58.6",
|
||||
"@sentry/cli-linux-arm64": "2.58.6",
|
||||
"@sentry/cli-linux-i686": "2.58.6",
|
||||
"@sentry/cli-linux-x64": "2.58.6",
|
||||
"@sentry/cli-win32-arm64": "2.58.6",
|
||||
"@sentry/cli-win32-i686": "2.58.6",
|
||||
"@sentry/cli-win32-x64": "2.58.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-darwin": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz",
|
||||
"integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==",
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz",
|
||||
"integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm64": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz",
|
||||
"integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-i686": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz",
|
||||
"integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-x64": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz",
|
||||
"integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-arm64": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz",
|
||||
"integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-i686": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz",
|
||||
"integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-x64": {
|
||||
"version": "2.58.6",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz",
|
||||
"integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cloudflare": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cloudflare/-/cloudflare-10.62.0.tgz",
|
||||
"integrity": "sha512-oHDpXXiO3XpBO2cHiTRQpSrtQOQrsU9JsO3TZ6ukdd24IUE6Tkc3l7hWdwzKqId3nTWP1Ef0Fr+offsrEGJ6UA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@sentry/core": "10.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cloudflare/workers-types": "^4.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cloudflare/workers-types": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/conventions": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.12.0.tgz",
|
||||
"integrity": "sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz",
|
||||
"integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/feedback": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.62.0.tgz",
|
||||
"integrity": "sha512-d0BVjJVny6qpBgGJgWL0fbcoQHjtD3z3R8EK/KzTS3RO92JX5n3A536n5D/rh0gZFgcIwiUzBXegmyPOSQn9ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/node": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.62.0.tgz",
|
||||
"integrity": "sha512-4hoU67bJY0o3irEDMZu2UIztAOsvEqFkLXA7EUKl1LXMA3Ba1Lb32OUVqlsTypiEInSDs/BtM+aAFKojZ3P3Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/instrumentation": "^0.214.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.6.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@sentry/core": "10.62.0",
|
||||
"@sentry/node-core": "10.62.0",
|
||||
"@sentry/opentelemetry": "10.62.0",
|
||||
"@sentry/server-utils": "10.62.0",
|
||||
"import-in-the-middle": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/node-core": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.62.0.tgz",
|
||||
"integrity": "sha512-V7rDgbxViiHU0OpcFEDp3l41IFvWTasKHfXw8SQ6yIgtZ8VpFqmz2TR5N7X85iIOmWIvK5HV0yp0eDdsly0+rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.12.0",
|
||||
"@sentry/core": "10.62.0",
|
||||
"@sentry/opentelemetry": "10.62.0",
|
||||
"import-in-the-middle": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1",
|
||||
"@opentelemetry/instrumentation": ">=0.57.1 <1",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/exporter-trace-otlp-http": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/instrumentation": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/sdk-trace-base": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/nuxt": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/nuxt/-/nuxt-10.62.0.tgz",
|
||||
"integrity": "sha512-YM9N4mH/uOJP/zr3QmQgCpQFaLzoDRh0/SoMMuNq/EEtzFZLQT6+qd5tYERMAutU4ySHinIaKYC2Gq/hEs5LtA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^3.13.2",
|
||||
"@sentry/browser": "10.62.0",
|
||||
"@sentry/cloudflare": "10.62.0",
|
||||
"@sentry/core": "10.62.0",
|
||||
"@sentry/node": "10.62.0",
|
||||
"@sentry/node-core": "10.62.0",
|
||||
"@sentry/rollup-plugin": "^5.3.0",
|
||||
"@sentry/vite-plugin": "^5.3.0",
|
||||
"@sentry/vue": "10.62.0",
|
||||
"local-pkg": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.19.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nitro": "2.x || 3.x",
|
||||
"nuxt": ">=3.7.0 || 4.x || 5.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"nitro": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/opentelemetry": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.62.0.tgz",
|
||||
"integrity": "sha512-nFwBgtjfwgY8P5lAuQFWfAsQW1MXxuQ6kR/HtBs+A6julqwGGS2QnQ65OCWMzz6IqDEL/pRgT1405/gU+OXU3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.12.0",
|
||||
"@sentry/core": "10.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.62.0.tgz",
|
||||
"integrity": "sha512-rWp4hBhZOmdQhisxcKzAwTGiRk/LvWnNaElWe7nbRhjsM/usp2095yfjq4iJ47v9MtO7xxY6eUz++fLBycqXKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.62.0",
|
||||
"@sentry/core": "10.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay-canvas": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.62.0.tgz",
|
||||
"integrity": "sha512-CzPAxmpe5US/ABGA1TzpjFKOFZN5uqlzrRh/uM9/daVuzLVKIAQ0XRNxo/PPEXvlDm/PoMdI5L0qIODuIKnyyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.62.0",
|
||||
"@sentry/replay": "10.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/rollup-plugin": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz",
|
||||
"integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/bundler-plugin-core": "5.3.0",
|
||||
"magic-string": "~0.30.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": ">=3.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/server-utils": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.62.0.tgz",
|
||||
"integrity": "sha512-S5szsj6kKBhxw97b2HA98fYp/PpWXvSizlisEzb2rnL4IH6RAJ8wP05/fnth8pSywTH+gtUu+i6Wn8e8rX5HvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apm-js-collab/code-transformer": "^0.15.0",
|
||||
"@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0",
|
||||
"@apm-js-collab/tracing-hooks": "^0.10.0",
|
||||
"@sentry/conventions": "^0.12.0",
|
||||
"@sentry/core": "10.62.0",
|
||||
"magic-string": "~0.30.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/vite-plugin": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz",
|
||||
"integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/bundler-plugin-core": "5.3.0",
|
||||
"@sentry/rollup-plugin": "5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/vue": {
|
||||
"version": "10.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.62.0.tgz",
|
||||
"integrity": "sha512-aK3E302Zx/g1dqtUU30Q0jblvCW8MsVXuzwnxM4JSgO47o0jW74zaFh1K3Ym2uQWhLvP1rV2D49BYwCMUc4ovQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser": "10.62.0",
|
||||
"@sentry/core": "10.62.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/vue-router": "^1.64.0",
|
||||
"pinia": "2.x || 3.x",
|
||||
"vue": "2.x || 3.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tanstack/vue-router": {
|
||||
"optional": true
|
||||
},
|
||||
"pinia": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.1.0.tgz",
|
||||
@@ -5451,6 +6203,15 @@
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/astring": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz",
|
||||
"integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"astring": "bin/astring"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@@ -5958,6 +6719,12 @@
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/cjs-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clean-regexp": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz",
|
||||
@@ -7481,10 +8248,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
|
||||
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
|
||||
"devOptional": true,
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"estraverse": "^5.1.0"
|
||||
@@ -7510,7 +8276,6 @@
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
@@ -7732,7 +8497,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
@@ -8385,6 +9149,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/import-in-the-middle": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz",
|
||||
"integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-attributes": "^1.9.5",
|
||||
"cjs-module-lexer": "^2.2.0",
|
||||
"module-details-from-path": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/impound": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/impound/-/impound-1.0.0.tgz",
|
||||
@@ -9514,7 +10293,6 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
@@ -9670,6 +10448,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/meriyah": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz",
|
||||
"integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
@@ -9768,10 +10555,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"license": "ISC",
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
@@ -9829,6 +10616,12 @@
|
||||
"integrity": "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/module-details-from-path": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
||||
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -10185,6 +10978,7 @@
|
||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.1.2.tgz",
|
||||
"integrity": "sha512-g5mwszCZT4ZeGJm83nxoZvtvZoAEaY65VDdn7p7UgznePbRaEJJ1KS1OIld4FPVkoDZ8TEVuDNqI9gUn12Exvg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nuxt/cli": "^3.28.0",
|
||||
"@nuxt/devalue": "^2.0.2",
|
||||
@@ -10618,7 +11412,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
@@ -10634,7 +11427,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
@@ -10729,7 +11521,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -11694,6 +12485,15 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -11720,6 +12520,12 @@
|
||||
"integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -12011,6 +12817,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-in-the-middle": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
|
||||
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"module-details-from-path": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
@@ -12296,6 +13115,12 @@
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semifies": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz",
|
||||
"integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
@@ -14604,7 +15429,6 @@
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@sentry/nuxt": "^10.61.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"daisyui": "^5.0.48",
|
||||
"nuxt": "^4.0.1",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as Sentry from '@sentry/nuxt'
|
||||
|
||||
// Init Sentry côté client (SPA). Le DSN provient du build prod (NUXT_PUBLIC_SENTRY_DSN).
|
||||
// Si le DSN est vide (dev), Sentry.init est un no-op : rien n'est envoyé.
|
||||
const config = useRuntimeConfig()
|
||||
const dsn = config.public.sentry?.dsn
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: config.public.sentry?.environment,
|
||||
// Pas d'APM/tracing (hors périmètre ticket #146) : on ne remonte que les erreurs.
|
||||
tracesSampleRate: 0,
|
||||
// Pas de session replay (volume).
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysOnErrorSampleRate: 0,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockLoadDocumentsByPiece = vi.fn()
|
||||
const mockLoadDocumentsByComponent = vi.fn()
|
||||
|
||||
vi.mock('~/composables/useDocuments', () => ({
|
||||
useDocuments: () => ({
|
||||
loadDocumentsByPiece: mockLoadDocumentsByPiece,
|
||||
loadDocumentsByComponent: mockLoadDocumentsByComponent,
|
||||
uploadDocuments: vi.fn(),
|
||||
deleteDocument: vi.fn(),
|
||||
updateDocument: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('~/utils/documentPreview', () => ({
|
||||
canPreviewDocument: () => true,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// refreshDocuments — pending / orphan entities
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('refreshDocuments', () => {
|
||||
it('does NOT load documents for a pending piece node (orphan link id is not a piece id)', async () => {
|
||||
// A category-only / pending piece node: its `id` is the machinePieceLink id,
|
||||
// there is no real piece behind it (pieceId is null).
|
||||
const pendingNode = {
|
||||
id: 'cl48179803369dd93b4a90b784', // machinePieceLink id, NOT a piece id
|
||||
pieceId: null,
|
||||
pendingEntity: true,
|
||||
documents: [],
|
||||
}
|
||||
|
||||
const { refreshDocuments } = useEntityDocuments({
|
||||
entity: () => pendingNode,
|
||||
entityType: 'piece',
|
||||
})
|
||||
|
||||
await refreshDocuments()
|
||||
|
||||
expect(mockLoadDocumentsByPiece).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads documents for a real piece node using its piece id', async () => {
|
||||
mockLoadDocumentsByPiece.mockResolvedValue({ success: true, data: [] })
|
||||
|
||||
const realNode = {
|
||||
id: 'clrealpieceid000000000000',
|
||||
pieceId: 'clrealpieceid000000000000',
|
||||
pendingEntity: false,
|
||||
documents: [],
|
||||
}
|
||||
|
||||
const { refreshDocuments } = useEntityDocuments({
|
||||
entity: () => realNode,
|
||||
entityType: 'piece',
|
||||
})
|
||||
|
||||
await refreshDocuments()
|
||||
|
||||
expect(mockLoadDocumentsByPiece).toHaveBeenCalledWith('clrealpieceid000000000000', { updateStore: false })
|
||||
})
|
||||
})
|
||||
@@ -8,3 +8,7 @@ DATABASE_URL="postgresql://inventory_user:password@host.docker.internal:5432/inv
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGIN='^https?://inventory\.malio-dev\.fr$'
|
||||
|
||||
# Sentry / GlitchTip — error tracking backend (projet "inventory-api").
|
||||
# Runtime, prod only. Vide/absent => SDK inerte (rien envoyé).
|
||||
# SENTRY_DSN=http://<clé>@<host-ou-IP>:<port>/<id-projet>
|
||||
|
||||
+17
-1
@@ -31,11 +31,27 @@ RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
COPY config/version.yaml /app/config/version.yaml
|
||||
|
||||
# Error tracking → GlitchTip (build-time). Vides par défaut => module Sentry inerte
|
||||
# et aucun upload de source maps. Fournis par la CI via --build-arg (secrets Gitea).
|
||||
# Passés en préfixe inline du RUN (pas en ENV) pour ne pas persister le token dans
|
||||
# une couche d'image.
|
||||
ARG NUXT_PUBLIC_SENTRY_DSN=""
|
||||
ARG SENTRY_URL=""
|
||||
ARG SENTRY_ORG=""
|
||||
ARG SENTRY_PROJECT=""
|
||||
ARG SENTRY_AUTH_TOKEN=""
|
||||
|
||||
ENV CI=1 \
|
||||
NUXT_TELEMETRY_DISABLED=1 \
|
||||
NUXT_PUBLIC_API_BASE_URL=/api \
|
||||
NUXT_PUBLIC_APP_BASE=/
|
||||
RUN npm run generate
|
||||
RUN NUXT_PUBLIC_SENTRY_DSN="$NUXT_PUBLIC_SENTRY_DSN" \
|
||||
SENTRY_URL="$SENTRY_URL" \
|
||||
SENTRY_ORG="$SENTRY_ORG" \
|
||||
SENTRY_PROJECT="$SENTRY_PROJECT" \
|
||||
SENTRY_AUTH_TOKEN="$SENTRY_AUTH_TOKEN" \
|
||||
npm run generate
|
||||
|
||||
# --- Stage 3: Production image ---
|
||||
FROM php:8.4-fpm AS production
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260527140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Machine name uniqueness is now scoped per site: drop global unique index on machines(name), add composite unique index on (name, siteid)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Drop the global unique index/constraint on machines(name).
|
||||
// Doctrine-generated name (CRC32 of table+column): uniq_f1ce8ded5e237e06.
|
||||
// It may exist either as a constraint or as a bare index depending on origin,
|
||||
// so we drop defensively in both forms.
|
||||
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS uniq_f1ce8ded5e237e06');
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_f1ce8ded5e237e06');
|
||||
// Defensive fallbacks for other possible legacy names of the global unique index on name.
|
||||
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS machines_name_key');
|
||||
$this->addSql('DROP INDEX IF EXISTS machines_name_key');
|
||||
|
||||
// New uniqueness scope: a machine name is unique within a given site only.
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_machine_name_site ON machines (name, siteid)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_machine_name_site');
|
||||
|
||||
// Best-effort restore of the global unique index on machines(name).
|
||||
// WARNING: this will fail if duplicate names now exist across sites (which the
|
||||
// per-site scope allowed). Resolve duplicates manually before rolling back.
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f1ce8ded5e237e06 ON machines (name)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Align all FKs pointing to `pieces.id` with what entities declare
|
||||
* (ON DELETE CASCADE / SET NULL). Cleans up pre-existing orphan rows
|
||||
* inserted before the constraints existed, so the new FKs can be added.
|
||||
*
|
||||
* Mirror of Version20260506140000_FixComposantCascadeFKs for the Piece side.
|
||||
*/
|
||||
final class Version20260528090000_FixPieceCascadeFKs extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Align CASCADE/SET NULL FKs on pieces references (machine_piece_links, composant_piece_slots, piece_product_slots, documents, custom_field_values, piece_constructeur_links); cleanup pre-existing orphans';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// =========================================================================
|
||||
// 1. Audit log : snapshot des rows orphelines avant suppression.
|
||||
// =========================================================================
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'machine_piece_link',
|
||||
l.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', l.id,
|
||||
'machineId', l.machineid,
|
||||
'pieceId', l.pieceid,
|
||||
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM machine_piece_links l
|
||||
WHERE l.pieceid IS NOT NULL
|
||||
AND l.pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'piece_product_slot',
|
||||
s.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', s.id,
|
||||
'pieceId', s.pieceid,
|
||||
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM piece_product_slots s
|
||||
WHERE s.pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'document',
|
||||
d.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', d.id,
|
||||
'name', d.name,
|
||||
'filename', d.filename,
|
||||
'pieceId', d.pieceid,
|
||||
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM documents d
|
||||
WHERE d.pieceid IS NOT NULL
|
||||
AND d.pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'custom_field_value',
|
||||
v.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', v.id,
|
||||
'pieceId', v.pieceid,
|
||||
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM custom_field_values v
|
||||
WHERE v.pieceid IS NOT NULL
|
||||
AND v.pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'piece_constructeur_link',
|
||||
l.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', l.id,
|
||||
'pieceId', l.pieceid,
|
||||
'constructeurId', l.constructeurid,
|
||||
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM piece_constructeur_links l
|
||||
WHERE l.pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
// =========================================================================
|
||||
// 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette).
|
||||
// =========================================================================
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM machine_piece_links
|
||||
WHERE pieceid IS NOT NULL
|
||||
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE composant_piece_slots SET selectedpieceid = NULL
|
||||
WHERE selectedpieceid IS NOT NULL
|
||||
AND selectedpieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM piece_product_slots
|
||||
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM documents
|
||||
WHERE pieceid IS NOT NULL
|
||||
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM custom_field_values
|
||||
WHERE pieceid IS NOT NULL
|
||||
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM piece_constructeur_links
|
||||
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM piece_products
|
||||
WHERE piece_id NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
// =========================================================================
|
||||
// 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur
|
||||
// nom historique), puis ADD CONSTRAINT avec le bon ON DELETE.
|
||||
// =========================================================================
|
||||
$this->dropFksReferencingPieces('machine_piece_links', 'pieceid');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece
|
||||
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||
SQL);
|
||||
|
||||
$this->dropFksReferencingPieces('composant_piece_slots', 'selectedpieceid');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE composant_piece_slots ADD CONSTRAINT fk_cps_selected_piece
|
||||
FOREIGN KEY (selectedpieceid) REFERENCES pieces(id) ON DELETE SET NULL
|
||||
SQL);
|
||||
|
||||
$this->dropFksReferencingPieces('piece_product_slots', 'pieceid');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE piece_product_slots ADD CONSTRAINT fk_pps_piece
|
||||
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||
SQL);
|
||||
|
||||
$this->dropFksReferencingPieces('documents', 'pieceid');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE documents ADD CONSTRAINT fk_documents_piece
|
||||
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||
SQL);
|
||||
|
||||
$this->dropFksReferencingPieces('custom_field_values', 'pieceid');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece
|
||||
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||
SQL);
|
||||
|
||||
$this->dropFksReferencingPieces('piece_constructeur_links', 'pieceid');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE piece_constructeur_links ADD CONSTRAINT fk_pcl_piece
|
||||
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
|
||||
$this->addSql('ALTER TABLE composant_piece_slots DROP CONSTRAINT IF EXISTS fk_cps_selected_piece');
|
||||
$this->addSql('ALTER TABLE piece_product_slots DROP CONSTRAINT IF EXISTS fk_pps_piece');
|
||||
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_piece');
|
||||
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece');
|
||||
$this->addSql('ALTER TABLE piece_constructeur_links DROP CONSTRAINT IF EXISTS fk_pcl_piece');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every FK on $table.$column that references the `pieces` table,
|
||||
* regardless of its historic name. Idempotent.
|
||||
*/
|
||||
private function dropFksReferencingPieces(string $table, string $column): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
DO \$\$
|
||||
DECLARE
|
||||
fk_name TEXT;
|
||||
BEGIN
|
||||
FOR fk_name IN
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON kcu.constraint_name = tc.constraint_name
|
||||
AND kcu.table_schema = tc.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.table_name = '{$table}'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
AND kcu.column_name = '{$column}'
|
||||
AND ccu.table_name = 'pieces'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
|
||||
END LOOP;
|
||||
END \$\$;
|
||||
SQL;
|
||||
$this->addSql($sql);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Repair migration for Version20260528090000_FixPieceCascadeFKs.
|
||||
*
|
||||
* On some environments (prod included) that migration was recorded as executed
|
||||
* but two of its six FKs to `pieces.id` never took effect:
|
||||
* - machine_piece_links.pieceid (fk_mpl_piece)
|
||||
* - custom_field_values.pieceid (fk_cfv_piece)
|
||||
* Without them, deleting a Piece leaves orphan rows behind (a stale pieceid
|
||||
* pointing to a non-existent piece), which surfaces as a "Catégorie sans item"
|
||||
* ghost on the machine detail page and a 404 on /documents/piece/{id}.
|
||||
*
|
||||
* This migration re-applies ONLY those two missing pieces of the original one:
|
||||
* snapshot orphans to audit_logs, delete them, then (re)add the FK with the
|
||||
* correct ON DELETE CASCADE. Fully idempotent — safe where the FKs already exist.
|
||||
*/
|
||||
final class Version20260529150000_AddMissingPieceCascadeFKs extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Repair missing CASCADE FKs to pieces on machine_piece_links and custom_field_values (orphan cleanup + fk_mpl_piece / fk_cfv_piece)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// =========================================================================
|
||||
// 1. Audit log : snapshot des rows orphelines avant suppression.
|
||||
// =========================================================================
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'machine_piece_link',
|
||||
l.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', l.id,
|
||||
'machineId', l.machineid,
|
||||
'pieceId', l.pieceid,
|
||||
'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM machine_piece_links l
|
||||
WHERE l.pieceid IS NOT NULL
|
||||
AND l.pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'custom_field_value',
|
||||
v.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', v.id,
|
||||
'pieceId', v.pieceid,
|
||||
'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM custom_field_values v
|
||||
WHERE v.pieceid IS NOT NULL
|
||||
AND v.pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
// =========================================================================
|
||||
// 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette).
|
||||
// =========================================================================
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM machine_piece_links
|
||||
WHERE pieceid IS NOT NULL
|
||||
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM custom_field_values
|
||||
WHERE pieceid IS NOT NULL
|
||||
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
SQL);
|
||||
|
||||
// =========================================================================
|
||||
// 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur
|
||||
// nom historique), puis ADD CONSTRAINT avec le bon ON DELETE.
|
||||
// =========================================================================
|
||||
$this->dropFksReferencingPieces('machine_piece_links', 'pieceid');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece
|
||||
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||
SQL);
|
||||
|
||||
$this->dropFksReferencingPieces('custom_field_values', 'pieceid');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece
|
||||
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
|
||||
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every FK on $table.$column that references the `pieces` table,
|
||||
* regardless of its historic name. Idempotent.
|
||||
*/
|
||||
private function dropFksReferencingPieces(string $table, string $column): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
DO \$\$
|
||||
DECLARE
|
||||
fk_name TEXT;
|
||||
BEGIN
|
||||
FOR fk_name IN
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON kcu.constraint_name = tc.constraint_name
|
||||
AND kcu.table_schema = tc.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.table_name = '{$table}'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
AND kcu.column_name = '{$column}'
|
||||
AND ccu.table_name = 'pieces'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
|
||||
END LOOP;
|
||||
END \$\$;
|
||||
SQL;
|
||||
$this->addSql($sql);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
-- =============================================================================
|
||||
-- cleanup_orphan_piece_refs.sql
|
||||
-- =============================================================================
|
||||
-- Contexte : la suppression directe de rows dans `pieces` (bypass Doctrine /
|
||||
-- FK DB sans ON DELETE CASCADE) laisse des références orphelines dans plusieurs
|
||||
-- tables, ce qui fait planter l'API au chargement d'une Machine :
|
||||
-- Doctrine\ORM\EntityNotFoundException: Entity of type 'App\Entity\Piece' ...
|
||||
--
|
||||
-- Ce script fait deux choses :
|
||||
-- 1. ÉTAPE 1 (toujours exécutée) : compte les références orphelines par table
|
||||
-- pour visualiser l'ampleur du problème.
|
||||
-- 2. ÉTAPE 2 (commentée par défaut) : insère un audit_log par row, puis
|
||||
-- DELETE / UPDATE SET NULL selon la sémantique attendue côté entité.
|
||||
-- Décommenter le bloc `BEGIN; ... COMMIT;` pour appliquer.
|
||||
--
|
||||
-- Usage :
|
||||
-- # Dry-run (compte seulement)
|
||||
-- psql -h <host> -U <user> -d inventory -f scripts/cleanup_orphan_piece_refs.sql
|
||||
--
|
||||
-- # Application : décommenter le bloc transactionnel en bas du fichier,
|
||||
-- # puis relancer la même commande. La transaction garantit l'atomicité.
|
||||
-- =============================================================================
|
||||
|
||||
|
||||
-- ============================== ÉTAPE 1 : DRY-RUN ============================
|
||||
\echo ''
|
||||
\echo '=== Orphelins par table (Pieces) ==='
|
||||
|
||||
SELECT 'machine_piece_links' AS table_name, count(*) AS orphans
|
||||
FROM machine_piece_links
|
||||
WHERE pieceid IS NOT NULL
|
||||
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
UNION ALL
|
||||
SELECT 'composant_piece_slots', count(*)
|
||||
FROM composant_piece_slots
|
||||
WHERE selectedpieceid IS NOT NULL
|
||||
AND selectedpieceid NOT IN (SELECT id FROM pieces)
|
||||
UNION ALL
|
||||
SELECT 'piece_product_slots', count(*)
|
||||
FROM piece_product_slots
|
||||
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||
UNION ALL
|
||||
SELECT 'documents', count(*)
|
||||
FROM documents
|
||||
WHERE pieceid IS NOT NULL
|
||||
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
UNION ALL
|
||||
SELECT 'custom_field_values', count(*)
|
||||
FROM custom_field_values
|
||||
WHERE pieceid IS NOT NULL
|
||||
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
UNION ALL
|
||||
SELECT 'piece_constructeur_links', count(*)
|
||||
FROM piece_constructeur_links
|
||||
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||
UNION ALL
|
||||
SELECT 'piece_products', count(*)
|
||||
FROM piece_products
|
||||
WHERE piece_id NOT IN (SELECT id FROM pieces)
|
||||
ORDER BY table_name;
|
||||
|
||||
\echo ''
|
||||
\echo '=> Pour appliquer le cleanup, décommenter le bloc BEGIN/COMMIT ci-dessous.'
|
||||
\echo ''
|
||||
|
||||
|
||||
-- ============================== ÉTAPE 2 : APPLY =============================
|
||||
-- Décommenter ce bloc pour exécuter le cleanup. La transaction garantit
|
||||
-- l'atomicité : tout passe, ou rien (en cas d'erreur, ROLLBACK auto).
|
||||
--
|
||||
-- BEGIN;
|
||||
--
|
||||
-- -- 1. Audit log : snapshot des rows qui vont être supprimées (traçabilité prod).
|
||||
--
|
||||
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
-- SELECT
|
||||
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
-- 'machine_piece_link',
|
||||
-- l.id,
|
||||
-- 'delete',
|
||||
-- json_build_object(
|
||||
-- 'id', l.id,
|
||||
-- 'machineId', l.machineid,
|
||||
-- 'pieceId', l.pieceid,
|
||||
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||
-- ),
|
||||
-- NULL,
|
||||
-- NOW()
|
||||
-- FROM machine_piece_links l
|
||||
-- WHERE l.pieceid IS NOT NULL
|
||||
-- AND l.pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
-- SELECT
|
||||
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
-- 'piece_product_slot',
|
||||
-- s.id,
|
||||
-- 'delete',
|
||||
-- json_build_object(
|
||||
-- 'id', s.id,
|
||||
-- 'pieceId', s.pieceid,
|
||||
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||
-- ),
|
||||
-- NULL,
|
||||
-- NOW()
|
||||
-- FROM piece_product_slots s
|
||||
-- WHERE s.pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
-- SELECT
|
||||
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
-- 'document',
|
||||
-- d.id,
|
||||
-- 'delete',
|
||||
-- json_build_object(
|
||||
-- 'id', d.id,
|
||||
-- 'name', d.name,
|
||||
-- 'filename', d.filename,
|
||||
-- 'pieceId', d.pieceid,
|
||||
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||
-- ),
|
||||
-- NULL,
|
||||
-- NOW()
|
||||
-- FROM documents d
|
||||
-- WHERE d.pieceid IS NOT NULL
|
||||
-- AND d.pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
-- SELECT
|
||||
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
-- 'custom_field_value',
|
||||
-- v.id,
|
||||
-- 'delete',
|
||||
-- json_build_object(
|
||||
-- 'id', v.id,
|
||||
-- 'pieceId', v.pieceid,
|
||||
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||
-- ),
|
||||
-- NULL,
|
||||
-- NOW()
|
||||
-- FROM custom_field_values v
|
||||
-- WHERE v.pieceid IS NOT NULL
|
||||
-- AND v.pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
-- SELECT
|
||||
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
-- 'piece_constructeur_link',
|
||||
-- l.id,
|
||||
-- 'delete',
|
||||
-- json_build_object(
|
||||
-- 'id', l.id,
|
||||
-- 'pieceId', l.pieceid,
|
||||
-- 'constructeurId', l.constructeurid,
|
||||
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||
-- ),
|
||||
-- NULL,
|
||||
-- NOW()
|
||||
-- FROM piece_constructeur_links l
|
||||
-- WHERE l.pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- -- 2. Nettoyage des orphelins.
|
||||
--
|
||||
-- DELETE FROM machine_piece_links
|
||||
-- WHERE pieceid IS NOT NULL
|
||||
-- AND pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- UPDATE composant_piece_slots SET selectedpieceid = NULL
|
||||
-- WHERE selectedpieceid IS NOT NULL
|
||||
-- AND selectedpieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- DELETE FROM piece_product_slots
|
||||
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- DELETE FROM documents
|
||||
-- WHERE pieceid IS NOT NULL
|
||||
-- AND pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- DELETE FROM custom_field_values
|
||||
-- WHERE pieceid IS NOT NULL
|
||||
-- AND pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- DELETE FROM piece_constructeur_links
|
||||
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- DELETE FROM piece_products
|
||||
-- WHERE piece_id NOT IN (SELECT id FROM pieces);
|
||||
--
|
||||
-- -- 3. Vérification post-cleanup : tout doit être à 0.
|
||||
-- SELECT 'machine_piece_links' AS table_name, count(*) AS remaining_orphans
|
||||
-- FROM machine_piece_links
|
||||
-- WHERE pieceid IS NOT NULL
|
||||
-- AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
-- UNION ALL
|
||||
-- SELECT 'composant_piece_slots', count(*)
|
||||
-- FROM composant_piece_slots
|
||||
-- WHERE selectedpieceid IS NOT NULL
|
||||
-- AND selectedpieceid NOT IN (SELECT id FROM pieces)
|
||||
-- UNION ALL
|
||||
-- SELECT 'piece_product_slots', count(*)
|
||||
-- FROM piece_product_slots
|
||||
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||
-- UNION ALL
|
||||
-- SELECT 'documents', count(*)
|
||||
-- FROM documents
|
||||
-- WHERE pieceid IS NOT NULL
|
||||
-- AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
-- UNION ALL
|
||||
-- SELECT 'custom_field_values', count(*)
|
||||
-- FROM custom_field_values
|
||||
-- WHERE pieceid IS NOT NULL
|
||||
-- AND pieceid NOT IN (SELECT id FROM pieces)
|
||||
-- UNION ALL
|
||||
-- SELECT 'piece_constructeur_links', count(*)
|
||||
-- FROM piece_constructeur_links
|
||||
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||
-- UNION ALL
|
||||
-- SELECT 'piece_products', count(*)
|
||||
-- FROM piece_products
|
||||
-- WHERE piece_id NOT IN (SELECT id FROM pieces)
|
||||
-- ORDER BY table_name;
|
||||
--
|
||||
-- COMMIT;
|
||||
@@ -6,6 +6,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Piece;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\CustomFieldRepository;
|
||||
use App\Repository\CustomFieldValueRepository;
|
||||
@@ -15,6 +16,7 @@ use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -288,28 +290,68 @@ class CustomFieldValueController extends AbstractController
|
||||
|
||||
case 'machinePieceLink':
|
||||
$value->setMachinePieceLink($entity);
|
||||
$value->setPiece($entity->getPiece());
|
||||
$value->setPiece($this->ensurePieceExists($entity->getPiece()));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Piece if its underlying row still exists in DB, otherwise null.
|
||||
* getId() on a Doctrine proxy does NOT trigger __load(), so we force the proxy
|
||||
* to initialize explicitly to handle orphan links here instead of crashing on
|
||||
* the first real getter.
|
||||
*/
|
||||
private function ensurePieceExists(?Piece $piece): ?Piece
|
||||
{
|
||||
if (null === $piece) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$this->entityManager->initializeObject($piece);
|
||||
|
||||
return $piece;
|
||||
} catch (EntityNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getId() on a Doctrine proxy returns the identifier without triggering __load(),
|
||||
* so it never raises EntityNotFoundException even if the row is gone. Force the
|
||||
* proxy to initialize explicitly so an orphan CFV is handled here instead of
|
||||
* crashing on the first real getter.
|
||||
*/
|
||||
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
|
||||
{
|
||||
if (null === $cf) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$this->entityManager->initializeObject($cf);
|
||||
|
||||
return $cf;
|
||||
} catch (EntityNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldValue(CustomFieldValue $value): array
|
||||
{
|
||||
$customField = $value->getCustomField();
|
||||
$customField = $this->ensureCustomFieldExists($value->getCustomField());
|
||||
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'value' => $value->getValue(),
|
||||
'customFieldId' => $customField->getId(),
|
||||
'customField' => [
|
||||
'customFieldId' => $customField?->getId(),
|
||||
'customField' => $customField ? [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
],
|
||||
] : null,
|
||||
'machineId' => $value->getMachine()?->getId(),
|
||||
'composantId' => $value->getComposant()?->getId(),
|
||||
'pieceId' => $value->getPiece()?->getId(),
|
||||
|
||||
@@ -26,6 +26,7 @@ use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -131,6 +132,14 @@ class MachineStructureController extends AbstractController
|
||||
return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404);
|
||||
}
|
||||
|
||||
// Clone mode: 'full' copies concrete components/pieces/products; 'structure'
|
||||
// only keeps the slots' categories (modelType) with empty concrete entities.
|
||||
$mode = $payload['mode'] ?? 'full';
|
||||
if (!in_array($mode, ['full', 'structure'], true)) {
|
||||
return $this->json(['success' => false, 'error' => 'mode invalide (valeurs autorisées : full, structure).'], 400);
|
||||
}
|
||||
$structureOnly = 'structure' === $mode;
|
||||
|
||||
// Create new machine
|
||||
$newMachine = new Machine();
|
||||
$newMachine->setName($payload['name']);
|
||||
@@ -155,13 +164,13 @@ class MachineStructureController extends AbstractController
|
||||
$this->cloneCustomFields($source, $newMachine);
|
||||
|
||||
// Copy component links (preserving hierarchy)
|
||||
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
|
||||
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine, $structureOnly);
|
||||
|
||||
// Copy piece links
|
||||
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
|
||||
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap, $structureOnly);
|
||||
|
||||
// Copy product links
|
||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap, $structureOnly);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -214,7 +223,7 @@ class MachineStructureController extends AbstractController
|
||||
/**
|
||||
* @return array<string, MachineComponentLink> Map of old link ID → new link
|
||||
*/
|
||||
private function cloneComponentLinks(Machine $source, Machine $target): array
|
||||
private function cloneComponentLinks(Machine $source, Machine $target, bool $structureOnly = false): array
|
||||
{
|
||||
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||
$linkMap = [];
|
||||
@@ -223,6 +232,16 @@ class MachineStructureController extends AbstractController
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineComponentLink();
|
||||
$newLink->setMachine($target);
|
||||
|
||||
if ($structureOnly) {
|
||||
// Keep only the slot category; leave the concrete component empty.
|
||||
$newLink->setModelType($link->getModelType() ?? $link->getComposant()?->getTypeComposant());
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$newLink->setComposant($link->getComposant());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
@@ -258,7 +277,7 @@ class MachineStructureController extends AbstractController
|
||||
*
|
||||
* @return array<string, MachinePieceLink> Map of old link ID → new link
|
||||
*/
|
||||
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
|
||||
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap, bool $structureOnly = false): array
|
||||
{
|
||||
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||
$linkMap = [];
|
||||
@@ -266,17 +285,27 @@ class MachineStructureController extends AbstractController
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachinePieceLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setPiece($link->getPiece());
|
||||
$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()])) {
|
||||
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
|
||||
}
|
||||
|
||||
if ($structureOnly) {
|
||||
// Keep only the slot category; leave the concrete piece empty.
|
||||
$newLink->setModelType($link->getModelType() ?? $link->getPiece()?->getTypePiece());
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$newLink->setPiece($link->getPiece());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
|
||||
foreach ($link->getContextFieldValues() as $cfv) {
|
||||
@@ -304,6 +333,7 @@ class MachineStructureController extends AbstractController
|
||||
Machine $target,
|
||||
array $componentLinkMap,
|
||||
array $pieceLinkMap,
|
||||
bool $structureOnly = false,
|
||||
): void {
|
||||
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||
$linkMap = [];
|
||||
@@ -312,7 +342,13 @@ class MachineStructureController extends AbstractController
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineProductLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setProduct($link->getProduct());
|
||||
|
||||
if ($structureOnly) {
|
||||
// Keep only the slot category; leave the concrete product empty.
|
||||
$newLink->setModelType($link->getModelType() ?? $link->getProduct()?->getTypeProduct());
|
||||
} else {
|
||||
$newLink->setProduct($link->getProduct());
|
||||
}
|
||||
|
||||
$parentComponent = $link->getParentComponentLink();
|
||||
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
|
||||
@@ -676,7 +712,7 @@ class MachineStructureController extends AbstractController
|
||||
private function normalizePieceLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$piece = $link->getPiece();
|
||||
$piece = $this->ensurePieceExists($link->getPiece());
|
||||
$modelType = $link->getModelType();
|
||||
$parentLink = $link->getParentLink();
|
||||
$type = $piece?->getTypePiece();
|
||||
@@ -704,7 +740,7 @@ class MachineStructureController extends AbstractController
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
$piece = $link->getPiece();
|
||||
$piece = $this->ensurePieceExists($link->getPiece());
|
||||
|
||||
if (!$parentLink || !$piece) {
|
||||
return $link->getQuantity();
|
||||
@@ -716,7 +752,8 @@ class MachineStructureController extends AbstractController
|
||||
}
|
||||
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
|
||||
$selected = $this->ensurePieceExists($slot->getSelectedPiece());
|
||||
if ($selected?->getId() === $piece->getId()) {
|
||||
return $slot->getQuantity();
|
||||
}
|
||||
}
|
||||
@@ -771,15 +808,16 @@ class MachineStructureController extends AbstractController
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
$pieceData = [
|
||||
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
|
||||
$pieceData = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
'selectedPieceId' => $selectedPiece?->getId(),
|
||||
];
|
||||
if ($slot->getSelectedPiece()) {
|
||||
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
|
||||
if ($selectedPiece) {
|
||||
$pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece);
|
||||
}
|
||||
$pieces[] = $pieceData;
|
||||
}
|
||||
@@ -810,6 +848,48 @@ class MachineStructureController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Piece if its underlying row still exists in DB, otherwise null.
|
||||
* getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used
|
||||
* to build the proxy), so we force initialization via initializeObject() to
|
||||
* surface a stale FK here instead of crashing on the first real getter.
|
||||
*/
|
||||
private function ensurePieceExists(?Piece $piece): ?Piece
|
||||
{
|
||||
if (null === $piece) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->entityManager->initializeObject($piece);
|
||||
|
||||
return $piece;
|
||||
} catch (EntityNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CustomField if its underlying row still exists, otherwise null.
|
||||
* getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used
|
||||
* to build the proxy. We force initialization explicitly so a stale FK to a
|
||||
* deleted CustomField surfaces here instead of crashing on getName() later.
|
||||
*/
|
||||
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
|
||||
{
|
||||
if (null === $cf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->entityManager->initializeObject($cf);
|
||||
|
||||
return $cf;
|
||||
} catch (EntityNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePiece(Piece $piece): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
@@ -920,7 +1000,10 @@ class MachineStructureController extends AbstractController
|
||||
if (!$cfv instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$cf = $cfv->getCustomField();
|
||||
$cf = $this->ensureCustomFieldExists($cfv->getCustomField());
|
||||
if (null === $cf) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -12,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Filter\ConstructeurSearchFilter;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -26,6 +30,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
|
||||
#[ORM\Table(name: 'constructeurs')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(ConstructeurSearchFilter::class)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['categories.id' => 'exact'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'email', 'createdAt'])]
|
||||
#[ApiResource(
|
||||
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
|
||||
operations: [
|
||||
@@ -37,7 +44,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200,
|
||||
paginationMaximumItemsPerPage: 2000,
|
||||
normalizationContext: ['groups' => ['constructeur:read']],
|
||||
denormalizationContext: ['groups' => ['constructeur:write']]
|
||||
)]
|
||||
|
||||
@@ -24,8 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
||||
#[ORM\Table(name: 'machines')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_machine_name_site', columns: ['name', 'siteId'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
|
||||
#[UniqueEntity(fields: ['name', 'site'], message: 'Une machine avec ce nom existe déjà sur ce site.')]
|
||||
#[ApiResource(
|
||||
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
|
||||
operations: [
|
||||
@@ -45,7 +47,7 @@ class Machine
|
||||
#[Groups(['document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
#[Groups(['document:list'])]
|
||||
private string $name;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use DateTimeInterface;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
@@ -432,7 +433,12 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
return;
|
||||
}
|
||||
|
||||
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
|
||||
try {
|
||||
$cfName = $cfv->getCustomField()->getName();
|
||||
} catch (EntityNotFoundException) {
|
||||
return;
|
||||
}
|
||||
$fieldName = 'customField:'.$cfName;
|
||||
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
||||
|
||||
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
||||
|
||||
@@ -30,8 +30,9 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
||||
|
||||
$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à.',
|
||||
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
|
||||
'uniq_machine_name_site' => 'Une machine avec ce nom existe déjà sur ce site.',
|
||||
default => 'Un élément avec cette valeur existe déjà.',
|
||||
};
|
||||
|
||||
$event->setResponse(new JsonResponse(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?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 App\Entity\ConstructeurTelephone;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Search filter pour Constructeur : LIKE insensible à la casse sur name, email
|
||||
* + LEFT JOIN sur la collection telephones pour matcher aussi sur telephone.numero.
|
||||
* Param query : ?search=...
|
||||
*/
|
||||
final class ConstructeurSearchFilter extends AbstractFilter
|
||||
{
|
||||
public function getDescription(string $resourceClass): array
|
||||
{
|
||||
return [
|
||||
'search' => [
|
||||
'property' => null,
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => 'Recherche dans le nom, l\'email et les numéros de téléphone du fournisseur.',
|
||||
'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 || !is_string($value) || '' === trim($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$alias = $queryBuilder->getRootAliases()[0];
|
||||
$telAlias = $queryNameGenerator->generateJoinAlias('phoneSearch');
|
||||
$paramName = $queryNameGenerator->generateParameterName('search');
|
||||
$likePattern = '%'.mb_strtolower(trim($value)).'%';
|
||||
|
||||
$em = $queryBuilder->getEntityManager();
|
||||
$phoneSubQuery = $em->createQueryBuilder()
|
||||
->select('1')
|
||||
->from(ConstructeurTelephone::class, $telAlias)
|
||||
->where(sprintf('%1$s.constructeur = %2$s', $telAlias, $alias))
|
||||
->andWhere(sprintf('LOWER(%s.numero) LIKE :%s', $telAlias, $paramName))
|
||||
->getDQL()
|
||||
;
|
||||
|
||||
$queryBuilder
|
||||
->andWhere(sprintf(
|
||||
'LOWER(%1$s.name) LIKE :%2$s OR LOWER(%1$s.email) LIKE :%2$s OR EXISTS (%3$s)',
|
||||
$alias,
|
||||
$paramName,
|
||||
$phoneSubQuery,
|
||||
))
|
||||
->setParameter($paramName, $likePattern)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Service;
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Piece;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
|
||||
class ReferenceAutoGenerator
|
||||
{
|
||||
@@ -48,8 +49,12 @@ class ReferenceAutoGenerator
|
||||
|
||||
/** @var CustomFieldValue $cfv */
|
||||
foreach ($entity->getCustomFieldValues() as $cfv) {
|
||||
$normalized = mb_strtoupper(trim($cfv->getValue()));
|
||||
$map[$cfv->getCustomField()->getName()] = $normalized;
|
||||
try {
|
||||
$name = $cfv->getCustomField()->getName();
|
||||
} catch (EntityNotFoundException) {
|
||||
continue;
|
||||
}
|
||||
$map[$name] = mb_strtoupper(trim($cfv->getValue()));
|
||||
}
|
||||
|
||||
return $map;
|
||||
|
||||
@@ -109,6 +109,15 @@
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"sentry/sentry-symfony": {
|
||||
"version": "5.10",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "5.0",
|
||||
"ref": "aac2bc5220e9ab5b9e3838a7a4da90e7f74e6148"
|
||||
}
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
|
||||
@@ -308,4 +308,88 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
|
||||
$this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
|
||||
public function testCloneMachineStructureModeKeepsCategoriesWithoutConcreteEntities(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site Structure');
|
||||
$compType = $this->createModelType('Motor Struct', 'MOTST', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Bearing Struct', 'BRGST', ModelCategory::PIECE);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'RPM Struct',
|
||||
type: 'number',
|
||||
typeComposant: $compType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$source = $this->createMachine('Source Struct Machine', $site);
|
||||
$composant = $this->createComposant('Motor ST', 'MOTST-001', $compType);
|
||||
$componentLink = $this->createMachineComponentLink($source, $composant);
|
||||
$piece = $this->createPiece('Bearing ST', 'BRGST-001', $pieceType);
|
||||
$this->createMachinePieceLink($source, $piece, $componentLink);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '4200',
|
||||
composant: $composant,
|
||||
machineComponentLink: $componentLink,
|
||||
);
|
||||
|
||||
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'json' => [
|
||||
'name' => 'Cloned Struct Machine',
|
||||
'siteId' => $site->getId(),
|
||||
'mode' => 'structure',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
|
||||
// Component slot: category preserved, concrete component dropped, no context values.
|
||||
$clonedComponent = $data['componentLinks'][0] ?? null;
|
||||
$this->assertNotNull($clonedComponent, 'Structure clone should expose the component slot');
|
||||
$this->assertTrue($clonedComponent['pendingEntity']);
|
||||
$this->assertNull($clonedComponent['composantId']);
|
||||
$this->assertSame($compType->getId(), $clonedComponent['modelTypeId']);
|
||||
$this->assertCount(0, $clonedComponent['contextCustomFieldValues']);
|
||||
|
||||
// Piece slot: category preserved, concrete piece dropped.
|
||||
$clonedPiece = $data['pieceLinks'][0] ?? null;
|
||||
$this->assertNotNull($clonedPiece, 'Structure clone should expose the piece slot');
|
||||
$this->assertTrue($clonedPiece['pendingEntity']);
|
||||
$this->assertNull($clonedPiece['pieceId']);
|
||||
$this->assertSame($pieceType->getId(), $clonedPiece['modelTypeId']);
|
||||
|
||||
// Source machine stays intact (still has its concrete component).
|
||||
$sourceData = $client->request('GET', '/api/machines/'.$source->getId().'/structure')->toArray();
|
||||
$this->assertFalse($sourceData['componentLinks'][0]['pendingEntity']);
|
||||
$this->assertSame($composant->getId(), $sourceData['componentLinks'][0]['composantId']);
|
||||
}
|
||||
|
||||
public function testCloneMachineFullModeStillCopiesConcreteEntities(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site Full');
|
||||
$compType = $this->createModelType('Motor Full', 'MOTFL', ModelCategory::COMPONENT);
|
||||
$source = $this->createMachine('Source Full Machine', $site);
|
||||
$composant = $this->createComposant('Motor FL', 'MOTFL-001', $compType);
|
||||
$this->createMachineComponentLink($source, $composant);
|
||||
|
||||
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'json' => [
|
||||
'name' => 'Cloned Full Machine',
|
||||
'siteId' => $site->getId(),
|
||||
'mode' => 'full',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$clonedComponent = $response->toArray()['componentLinks'][0] ?? null;
|
||||
$this->assertNotNull($clonedComponent);
|
||||
$this->assertFalse($clonedComponent['pendingEntity']);
|
||||
$this->assertSame($composant->getId(), $clonedComponent['composantId']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,88 @@ class MachineTest extends AbstractApiTestCase
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testSameNameOnDifferentSitesIsAllowed(): void
|
||||
{
|
||||
$siteA = $this->createSite('Usine A');
|
||||
$siteB = $this->createSite('Usine B');
|
||||
$this->createMachine('Pompe', $siteA);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/machines', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => 'Pompe',
|
||||
'site' => self::iri('sites', $siteB->getId()),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains(['name' => 'Pompe']);
|
||||
}
|
||||
|
||||
public function testSameNameOnSameSiteIsRejected(): void
|
||||
{
|
||||
$site = $this->createSite('Usine');
|
||||
$this->createMachine('Pompe', $site);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/machines', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => 'Pompe',
|
||||
'site' => self::iri('sites', $site->getId()),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$this->assertJsonContains([
|
||||
'violations' => [
|
||||
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testRenameToExistingNameOnSameSiteIsRejected(): void
|
||||
{
|
||||
$site = $this->createSite('Usine');
|
||||
$this->createMachine('Pompe', $site);
|
||||
$other = $this->createMachine('Moteur', $site);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', self::iri('machines', $other->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => 'Pompe'],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$this->assertJsonContains([
|
||||
'violations' => [
|
||||
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testMoveToSiteWhereNameExistsIsRejected(): void
|
||||
{
|
||||
$siteA = $this->createSite('Usine A');
|
||||
$siteB = $this->createSite('Usine B');
|
||||
$this->createMachine('Pompe', $siteB);
|
||||
$machine = $this->createMachine('Pompe', $siteA);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', self::iri('machines', $machine->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['site' => self::iri('sites', $siteB->getId())],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$this->assertJsonContains([
|
||||
'violations' => [
|
||||
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testGetStructureEndpoint(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine structure');
|
||||
|
||||
Reference in New Issue
Block a user