Compare commits

..

14 Commits

Author SHA1 Message Date
Matthieu
1529d21f12 docs(custom-fields) : add spec and implementation plans for machine context custom fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:25:07 +02:00
Matthieu
d6441bef06 feat(ui) : highlight empty slots with category name in red
- Empty component slots (pieces, products, subcomponents) now display
  the category/type name with red styling instead of generic labels
- Machine view: empty structure pieces show type name + "manquant" in red
- Backend: include typePiece in structure slot data for name resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:21:25 +02:00
Matthieu
12c2b1e1b3 chore(infra) : remove release artefact pipeline
All checks were successful
Auto Tag Develop / tag (push) Successful in 11s
Keep only Docker-based deployment workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:35:51 +02:00
gitea-actions
b92c09cf55 chore : bump version to v1.9.10
Some checks failed
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 3m22s
Build Release Artefact / build (push) Failing after 1m42s
2026-04-02 10:05:05 +00:00
18cb9d5d80 refactor(infra) : reorganize docker config into infra/dev and infra/prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Align project structure with Lesstime: move docker/ to infra/dev/ and
deploy/ to infra/prod/. Update all references in docker-compose,
makefile, CI workflow, Dockerfile, .gitignore and .dockerignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:11:35 +02:00
gitea-actions
4ba134dd69 chore : bump version to v1.9.9
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 32s
Build Release Artefact / build (push) Failing after 1m27s
2026-04-01 14:18:42 +00:00
Matthieu
5e7a744151 feat : add maintenance mode toggle in admin panel
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
- Backend: MaintenanceModeListener blocks non-admin API requests when
  var/maintenance flag file exists. MaintenanceController provides
  toggle (PUT /api/admin/maintenance) and public check endpoint
  (GET /api/maintenance/check).
- Frontend: Toggle button in admin page, maintenance.vue page for
  blocked users, middleware redirects non-admins to /maintenance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:18:32 +02:00
gitea-actions
044b64152c chore : bump version to v1.9.8
Some checks failed
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
Build Release Artefact / build (push) Failing after 1m38s
2026-04-01 12:52:57 +00:00
Matthieu
4de3ffa0e0 chore : trigger auto-tag
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
2026-04-01 14:52:45 +02:00
Matthieu
5bdf578de9 refactor : migrate VERSION file to config/version.yaml
Some checks failed
Auto Tag Develop / tag (push) Failing after 7s
Same versioning system as SIRH/Lesstime. Updates nuxt.config.ts,
Dockerfile, deploy.sh, auto-tag CI, and release script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:52:01 +02:00
gitea-actions
bc1b757a96 chore : bump version to v1.9.7
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 18s
Build Release Artefact / build (push) Successful in 2m53s
2026-04-01 12:47:15 +00:00
Matthieu
24b664e85b fix : update frontend/ to latest develop branch content
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
The initial merge only had master content. This replaces frontend/
with the full develop branch including reference-auto, constructeur
links, versioning, and all recent features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:47:04 +02:00
gitea-actions
8565e68062 chore : bump version to v1.9.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 23s
Build Release Artefact / build (push) Successful in 2m5s
2026-04-01 12:36:42 +00:00
Matthieu
a8a95b16a9 fix : mount var/storage/documents volume instead of var/uploads
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:36:30 +02:00
47 changed files with 2874 additions and 133 deletions

View File

@@ -2,10 +2,10 @@
.gitea
.env.local
.env.test
docker/
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example
infra/dev/
infra/prod/docker-compose.yml
infra/prod/deploy.sh
infra/prod/.env.example
frontend/node_modules
frontend/.nuxt
frontend/.output

View File

@@ -16,7 +16,7 @@ jobs:
token: ${{ secrets.REGISTRY_TOKEN }}
persist-credentials: true
- name: Create next tag from VERSION
- name: Create next tag from config/version.yaml
shell: bash
run: |
set -euo pipefail
@@ -28,18 +28,18 @@ jobs:
fi
changed_version=false
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^VERSION$'; then
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^config/version\.yaml$'; then
changed_version=true
fi
read_version() {
cat VERSION | tr -d '[:space:]'
awk -F': *' '/app\.version:/{print $2}' config/version.yaml | tr -d '[:space:]' | tr -d "'\""
}
if $changed_version; then
version="$(read_version)"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version in VERSION: $version" >&2
echo "Invalid version in version.yaml: $version" >&2
exit 1
fi
else
@@ -52,10 +52,10 @@ jobs:
version="${major}.${minor}.$((patch + 1))"
fi
echo "$version" > VERSION
printf "parameters:\\n app.version: '%s'\\n" "$version" > config/version.yaml
git config user.name "gitea-actions"
git config user.email "gitea-actions@local"
git add VERSION
git add config/version.yaml
git commit -m "chore : bump version to v$version" || true
git push origin develop || true
fi

View File

@@ -19,7 +19,7 @@ jobs:
- name: Build Docker image
run: |
docker build \
-f deploy/docker/Dockerfile.prod \
-f infra/prod/Dockerfile \
-t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/inventory:latest \
.

View File

@@ -1,67 +0,0 @@
name: Build Release Artefact
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install backend deps (prod)
env:
APP_ENV: prod
APP_DEBUG: "0"
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
- name: Build frontend (static)
run: |
cd frontend
npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE_URL=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
test -f .output/public/index.html
- name: Build artefact
shell: bash
run: |
set -euo pipefail
mkdir -p release
tar -czf "release/inventory-${GITHUB_REF_NAME}.tar.gz" \
.env \
bin \
config \
migrations \
public \
src \
templates \
vendor \
composer.json \
composer.lock \
symfony.lock \
VERSION \
frontend/.output
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/inventory-${{ github.ref_name }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

2
.gitignore vendored
View File

@@ -20,7 +20,7 @@
###< phpunit/phpunit ###
###> docker ###
docker/.env.docker.local
infra/dev/.env.docker.local
###< docker ###
###> migration archives ###
/_archives/

View File

@@ -57,7 +57,7 @@ make start
make install
```
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `infra/dev/.env.docker.local`.
### Que fait `make install` ?
@@ -254,7 +254,7 @@ Configuration PhpStorm / VSCode :
- **Port** : `8081`
- **Path mapping** : racine du projet → `/var/www/html`
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale.
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `infra/dev/.env.docker.local` avec votre IP locale.
## Git

View File

@@ -1 +0,0 @@
1.9.5

View File

@@ -1,7 +1,7 @@
api_platform:
title: Inventory API
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
version: 1.9.1
version: 1.9.6
defaults:
stateless: false
cache_headers:

View File

@@ -55,6 +55,7 @@ security:
- { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
- { path: ^/api/maintenance/check$, roles: PUBLIC_ACCESS }
- { path: ^/_mcp, roles: ROLE_USER }
- { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/contexts, roles: PUBLIC_ACCESS }

2
config/version.yaml Normal file
View File

@@ -0,0 +1,2 @@
parameters:
app.version: '1.9.10'

View File

@@ -2,7 +2,7 @@ services:
web:
container_name: php-${DOCKER_APP_NAME}-apache
build:
context: ./docker/php
context: ./infra/dev
dockerfile: Dockerfile
args:
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
@@ -20,9 +20,9 @@ services:
- ~/.cache:/var/www/.cache # Pour la cache de composer
- ~/.config:/var/www/.config # Pour la config de yarn
- ~/.composer:/var/www/.composer # Pour la config de composer
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/vhost.conf:/etc/apache2/sites-available/000-default.conf
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
- ./infra/dev/vhost.conf:/etc/apache2/sites-available/000-default.conf
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG
- ./LOG/logs_apache:/var/log/apache2/
extra_hosts:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,841 @@
# Machine Context Custom Fields — Backend Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Parallel plan:** This is the backend half. The frontend plan is at `2026-04-02-machine-context-fields-frontend.md`. Both can run in parallel on separate worktrees — they share no files.
**Goal:** Add `machineContextOnly` flag on `CustomField`, link FKs on `CustomFieldValue`, structure controller normalization, upsert support, clone support, and tests.
**Architecture:** `CustomField.machineContextOnly` flags definitions. `CustomFieldValue` gets nullable FKs to `MachineComponentLink`/`MachinePieceLink`. The structure response returns `contextCustomFields` and `contextCustomFieldValues` per link. Upsert and clone are extended.
**Tech Stack:** Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, PHPUnit 12
**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md`
---
## File Map
### Create
- `migrations/VersionXXXX_MachineContextCustomFields.php` — migration
- `tests/Api/Entity/MachineContextCustomFieldTest.php` — test class
### Modify
- `src/Entity/CustomField.php` — add `machineContextOnly` property
- `src/Entity/CustomFieldValue.php` — add `machineComponentLink` and `machinePieceLink` FKs
- `src/Entity/MachineComponentLink.php` — add `contextFieldValues` collection
- `src/Entity/MachinePieceLink.php` — add `contextFieldValues` collection
- `src/Controller/MachineStructureController.php` — normalize context fields + clone
- `src/Controller/CustomFieldValueController.php` — link-based upsert
- `tests/AbstractApiTestCase.php` — extend factories
---
## Task 1: Migration + Entity `CustomField` — `machineContextOnly`
**Files:**
- Modify: `src/Entity/CustomField.php` (add property after line 56)
- [ ] **Step 1: Add `machineContextOnly` property to `CustomField` entity**
In `src/Entity/CustomField.php`, add after the `$required` property (line 56):
```php
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $machineContextOnly = false;
```
Add getter/setter before the closing `}`:
```php
public function isMachineContextOnly(): bool
{
return $this->machineContextOnly;
}
public function setMachineContextOnly(bool $machineContextOnly): static
{
$this->machineContextOnly = $machineContextOnly;
return $this;
}
```
- [ ] **Step 2: Generate and adjust migration**
```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
```
Edit the generated migration to use idempotent SQL:
```sql
ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL;
```
- [ ] **Step 3: Run migration**
```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 4: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 5: Commit**
```bash
git add src/Entity/CustomField.php migrations/
git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity"
```
---
## Task 2: Entity `CustomFieldValue` — link FKs + inverse collections
**Files:**
- Modify: `src/Entity/CustomFieldValue.php` (add after line 67 — `$product` property)
- Modify: `src/Entity/MachineComponentLink.php` (add after line 72 — `$productLinks`)
- Modify: `src/Entity/MachinePieceLink.php` (add after line 61 — `$productLinks`)
- [ ] **Step 1: Add FKs to `CustomFieldValue`**
In `src/Entity/CustomFieldValue.php`, add after the `$product` property (line 67):
```php
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $machineComponentLink = null;
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachinePieceLink $machinePieceLink = null;
```
Add getters/setters before the closing `}`:
```php
public function getMachineComponentLink(): ?MachineComponentLink
{
return $this->machineComponentLink;
}
public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static
{
$this->machineComponentLink = $machineComponentLink;
return $this;
}
public function getMachinePieceLink(): ?MachinePieceLink
{
return $this->machinePieceLink;
}
public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static
{
$this->machinePieceLink = $machinePieceLink;
return $this;
}
```
- [ ] **Step 2: Add `contextFieldValues` collection to `MachineComponentLink`**
In `src/Entity/MachineComponentLink.php`, add after the `$productLinks` collection (line 72):
```php
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
```
In the constructor (line 95), add:
```php
$this->contextFieldValues = new ArrayCollection();
```
Add getter before the closing `}`:
```php
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
```
- [ ] **Step 3: Add `contextFieldValues` collection to `MachinePieceLink`**
In `src/Entity/MachinePieceLink.php`, add after the `$productLinks` collection (line 61):
```php
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
```
In the constructor (line 86), add:
```php
$this->contextFieldValues = new ArrayCollection();
```
Add getter:
```php
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
```
- [ ] **Step 4: Generate and adjust migration**
```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
```
Edit migration to use idempotent SQL:
```sql
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL;
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link
FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link
FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid);
CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid);
```
- [ ] **Step 5: Run migration + linter**
```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
make php-cs-fixer-allow-risky
```
- [ ] **Step 6: Commit**
```bash
git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/
git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context"
```
---
## Task 3: Test factories — extend helpers
**Files:**
- Modify: `tests/AbstractApiTestCase.php:399-461`
- [ ] **Step 1: Update `createCustomField()` factory**
In `tests/AbstractApiTestCase.php`, add `bool $machineContextOnly = false` parameter at the end of `createCustomField` (line 399), and add `$cf->setMachineContextOnly($machineContextOnly);` after `$cf->setOrderIndex($orderIndex);` (line 411).
- [ ] **Step 2: Update `createCustomFieldValue()` factory**
Add two new nullable parameters at the end of `createCustomFieldValue` (line 432):
```php
?MachineComponentLink $machineComponentLink = null,
?MachinePieceLink $machinePieceLink = null,
```
Add the corresponding setter calls **after the closing `}` of the `if (null !== $product)` block** (after line 454, NOT inside it):
```php
if (null !== $machineComponentLink) {
$cfv->setMachineComponentLink($machineComponentLink);
}
if (null !== $machinePieceLink) {
$cfv->setMachinePieceLink($machinePieceLink);
}
```
- [ ] **Step 3: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 4: Commit**
```bash
git add tests/AbstractApiTestCase.php
git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params"
```
---
## Task 4: `MachineStructureController` — normalize context fields
**Files:**
- Modify: `src/Controller/MachineStructureController.php`
- [ ] **Step 1: Add `machineContextOnly` to all normalization methods**
In `normalizeCustomFields` (line 601), add to the output array at line 615:
```php
'machineContextOnly' => $customField->isMachineContextOnly(),
```
In `normalizeCustomFieldDefinitions` (line 838), add to the output array at line 852:
```php
'machineContextOnly' => $cf->isMachineContextOnly(),
```
In `normalizeCustomFieldValues` (line 861), add to the nested `customField` array at line 879:
```php
'machineContextOnly' => $cf->isMachineContextOnly(),
```
- [ ] **Step 2: Add `normalizeContextCustomFieldDefinitions` helper**
Add a new private method after `normalizeCustomFieldValues`:
```php
private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
foreach ($customFields as $cf) {
if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'machineContextOnly' => true,
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
```
- [ ] **Step 3: Update `normalizeComponentLinks` to include context fields**
In `normalizeComponentLinks` (line 622), add `$type` variable and context field keys to the returned array:
```php
private function normalizeComponentLinks(array $links): array
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$parentLink = $link->getParentLink();
$type = $composant->getTypeComposant();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
```
- [ ] **Step 4: Update `normalizePieceLinks` to include context fields**
In `normalizePieceLinks` (line 644):
```php
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$parentLink = $link->getParentLink();
$type = $piece->getTypePiece();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $this->resolvePieceQuantity($link),
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
```
- [ ] **Step 5: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 6: Commit**
```bash
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : expose context custom fields in machine structure response"
```
---
## Task 5: `CustomFieldValueController` — support link-based upsert
**Files:**
- Modify: `src/Controller/CustomFieldValueController.php`
- [ ] **Step 1: Inject link repositories in constructor**
In the constructor (line 24), add:
```php
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
```
Add use statements at the top:
```php
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
```
- [ ] **Step 2: Extend `resolveTarget` to support link entities**
In `resolveTarget` (line 211), the method applies `strtolower()` on entityType at line 213. The candidate loop and match block must handle this.
Update the candidate list in the foreach (line 217):
```php
foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) {
```
**IMPORTANT:** The candidate loop assigns `$entityType` in camelCase, but `strtolower()` (line 213) only applies when `entityType` comes from the payload directly. Add a normalization after the loop closes (after the existing `break;` at line 226), before the empty-check at line 229:
```php
$entityType = strtolower($entityType);
```
This ensures both code paths (direct payload `entityType` and candidate loop) deliver lowercase to the match block.
Update the match block (line 233) — all keys lowercase, but `resolveEntity` returns camelCase type for `applyTarget`:
```php
return match ($entityType) {
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository),
'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
};
```
- [ ] **Step 3: Extend `applyTarget` for link entities**
Add two new cases in `applyTarget` (line 252):
```php
case 'machineComponentLink':
$value->setMachineComponentLink($entity);
$value->setComposant($entity->getComposant());
break;
case 'machinePieceLink':
$value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece());
break;
```
- [ ] **Step 4: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 5: Commit**
```bash
git add src/Controller/CustomFieldValueController.php
git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController"
```
---
## Task 6: Clone support — copy context field values
**Files:**
- Modify: `src/Controller/MachineStructureController.php` (after `cloneProductLinks`, before `flush` in `cloneMachine`)
- [ ] **Step 1: Add `cloneContextFieldValues` helper method**
Add after `cloneProductLinks`:
```php
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneContextFieldValues(
array $componentLinkMap,
array $pieceLinkMap,
): void {
foreach ($componentLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachineComponentLink($newLink);
$newValue->setComposant($newLink->getComposant());
$this->entityManager->persist($newValue);
}
}
foreach ($pieceLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachinePieceLink($newLink);
$newValue->setPiece($newLink->getPiece());
$this->entityManager->persist($newValue);
}
}
}
```
- [ ] **Step 2: Call from `cloneMachine` method**
In `cloneMachine` (line 113), after `$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);` (line 163) and before `$this->entityManager->flush();` (line 165), add:
```php
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
```
- [ ] **Step 3: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 4: Commit**
```bash
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : clone context field values on machine clone"
```
---
## Task 7: Backend tests
**Files:**
- Create: `tests/Api/Entity/MachineContextCustomFieldTest.php`
- [ ] **Step 1: Write test class**
```php
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
class MachineContextCustomFieldTest extends AbstractApiTestCase
{
public function testStructureReturnsContextFieldsOnComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site A');
$modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Voltage',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$normalField = $this->createCustomField(
name: 'Serial',
type: 'text',
typeComposant: $modelType,
);
$machine = $this->createMachine('Machine A', $site);
$composant = $this->createComposant('Motor 1', 'MOT-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '220',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$componentLink = $data['componentLinks'][0];
$this->assertArrayHasKey('contextCustomFields', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFields']);
$this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']);
$this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']);
$this->assertArrayHasKey('contextCustomFieldValues', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFieldValues']);
$this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']);
$normalFields = array_filter(
$componentLink['composant']['customFields'],
fn (array $f) => $f['name'] === 'Serial',
);
$this->assertCount(1, $normalFields);
}
public function testStructureReturnsContextFieldsOnPieceLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site B');
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
$contextField = $this->createCustomField(
name: 'Wear Level',
type: 'select',
typePiece: $modelType,
machineContextOnly: true,
);
$contextField->setOptions(['Good', 'Fair', 'Replace']);
$this->getEntityManager()->flush();
$machine = $this->createMachine('Machine B', $site);
$piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType);
$link = $this->createMachinePieceLink($machine, $piece);
$this->createCustomFieldValue(
customField: $contextField,
value: 'Fair',
piece: $piece,
machinePieceLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$data = $response->toArray();
$pieceLink = $data['pieceLinks'][0];
$this->assertCount(1, $pieceLink['contextCustomFields']);
$this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']);
$this->assertCount(1, $pieceLink['contextCustomFieldValues']);
$this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']);
}
public function testUpsertContextFieldValueViaComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site C');
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Flow Rate',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machine = $this->createMachine('Machine C', $site);
$composant = $this->createComposant('Pump 1', 'PMP-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$response = $client->request('POST', '/api/custom-fields/values/upsert', [
'json' => [
'customFieldId' => $contextField->getId(),
'machineComponentLinkId' => $link->getId(),
'value' => '380',
],
]);
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertSame('380', $data['value']);
}
public function testSameComposantDifferentValuesPerMachine(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site D');
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Pressure',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machineA = $this->createMachine('Machine A', $site);
$machineB = $this->createMachine('Machine B', $site);
$composant = $this->createComposant('Valve 1', 'VLV-001', $modelType);
$linkA = $this->createMachineComponentLink($machineA, $composant);
$linkB = $this->createMachineComponentLink($machineB, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '100',
composant: $composant,
machineComponentLink: $linkA,
);
$this->createCustomFieldValue(
customField: $contextField,
value: '200',
composant: $composant,
machineComponentLink: $linkB,
);
$dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray();
$this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
$dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray();
$this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
}
public function testMachineContextOnlyFieldSerialization(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site E');
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Calibration Date',
type: 'date',
typeComposant: $modelType,
machineContextOnly: true,
);
$response = $client->request('GET', '/api/custom_fields/'.$contextField->getId());
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertTrue($data['machineContextOnly']);
}
public function testCloneMachineCopiesContextFieldValues(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site F');
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'RPM Setting',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$source = $this->createMachine('Source Machine', $site);
$composant = $this->createComposant('Motor C', 'MOTC-001', $modelType);
$link = $this->createMachineComponentLink($source, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '3000',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
'json' => [
'name' => 'Cloned Machine',
'siteId' => $site->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
$data = $response->toArray();
$clonedLink = $data['componentLinks'][0] ?? null;
$this->assertNotNull($clonedLink);
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
}
}
```
- [ ] **Step 2: Run tests**
```bash
make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php
```
Expected: All 6 tests pass.
- [ ] **Step 3: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 4: Run full test suite**
```bash
make test
```
Expected: All existing tests still pass.
- [ ] **Step 5: Commit**
```bash
git add tests/Api/Entity/MachineContextCustomFieldTest.php
git commit -m "test(custom-fields) : add tests for machine context custom fields"
```

View File

@@ -0,0 +1,404 @@
# Machine Context Custom Fields — Frontend Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Parallel plan:** This is the frontend half. The backend plan is at `2026-04-02-machine-context-fields-backend.md`. Both can run in parallel on separate worktrees — they share no files. Frontend tests requiring the API will need the backend done first.
**Goal:** Add `machineContextOnly` toggle in structure editors, filter context fields from standalone pages, and display/edit them in the machine detail view.
**Architecture:** Structure editors get a checkbox per field. The machine-detail transform propagates `contextCustomFields`/`contextCustomFieldValues` from the API link response onto the component/piece objects. Standalone entity views filter these out. Machine view displays them in a separate "Champs contextuels" section using the existing `CustomFieldDisplay` component, saving via upsert with the link ID.
**Tech Stack:** Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5
**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md`
---
## File Map
### Modify
- `frontend/app/shared/types/inventory.ts` — add `machineContextOnly` to custom field types
- `frontend/app/components/PieceModelStructureEditor.vue` — add checkbox toggle
- `frontend/app/composables/usePieceStructureEditorLogic.ts` — add default in `createEmptyField()`
- `frontend/app/components/StructureNodeEditor.vue` — add checkbox toggle
- `frontend/app/composables/useStructureNodeCrud.ts` — add default in `addCustomField()`
- `frontend/app/composables/useEntityCustomFields.ts` — filter out `machineContextOnly` fields
- `frontend/app/composables/useMachineDetailCustomFields.ts` — propagate context fields, filter from normal merge
- `frontend/app/components/ComponentItem.vue` — display context custom fields section
- `frontend/app/components/PieceItem.vue` — display context custom fields section
---
## Task 1: Types — add `machineContextOnly`
**Files:**
- Modify: `frontend/app/shared/types/inventory.ts`
- [ ] **Step 1: Add `machineContextOnly` to `ComponentModelCustomField`**
In the `ComponentModelCustomField` interface (around line 14), add:
```typescript
machineContextOnly?: boolean
```
- [ ] **Step 2: Add `machineContextOnly` to `PieceModelCustomField`**
In the `PieceModelCustomField` interface (around line 65), add:
```typescript
machineContextOnly?: boolean
```
- [ ] **Step 3: Commit**
```bash
cd frontend && git add app/shared/types/inventory.ts
git commit -m "feat(custom-fields) : add machineContextOnly to custom field types"
```
---
## Task 2: Structure editors — add toggle
**Files:**
- Modify: `frontend/app/components/PieceModelStructureEditor.vue:122-125`
- Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts:283-290`
- Modify: `frontend/app/components/StructureNodeEditor.vue:121-125`
- Modify: `frontend/app/composables/useStructureNodeCrud.ts:49-62`
- [ ] **Step 1: Add toggle in `PieceModelStructureEditor.vue`**
After the "Obligatoire" checkbox block (line 125, after `</div>` closing the required checkbox), add:
```vue
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
```
- [ ] **Step 2: Update `usePieceStructureEditorLogic.ts` — 3 functions**
**a) `createEmptyField`** (line 283) — add `machineContextOnly: false` to the returned object:
```typescript
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid('field'),
name: '',
type: 'text',
required: false,
optionsText: '',
machineContextOnly: false,
orderIndex,
})
```
**b) `toEditorField`** (line 78-91) — add `machineContextOnly` to the returned object, after the `orderIndex` line (line 90):
```typescript
machineContextOnly: Boolean(input?.machineContextOnly),
```
**c) `buildPayload`** (line 160-165) — add `machineContextOnly` to the `payload` object after `orderIndex` (line 164):
```typescript
machineContextOnly: Boolean(field.machineContextOnly),
```
- [ ] **Step 3: Add toggle in `StructureNodeEditor.vue`**
After the "Obligatoire" checkbox closing `</div>` (line 123) and **before** the `<textarea>` for select options (line 124), add:
```vue
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
```
- [ ] **Step 4: Update `addCustomField` in `useStructureNodeCrud.ts`**
In `addCustomField` (line 49), add `machineContextOnly: false` to the pushed object (line 53-60):
```typescript
fields.push({
name: '',
type: 'text',
required: false,
optionsText: '',
options: [],
machineContextOnly: false,
orderIndex: nextIndex,
})
```
- [ ] **Step 5: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
Expected: 0 errors.
- [ ] **Step 6: Commit**
```bash
cd frontend && git add .
git commit -m "feat(custom-fields) : add machineContextOnly toggle in structure editors"
```
---
## Task 3: Filter context fields on standalone pages + machine-detail transform
**Files:**
- Modify: `frontend/app/composables/useEntityCustomFields.ts:42-49`
- Modify: `frontend/app/composables/useMachineDetailCustomFields.ts:141-154,241-256`
- [ ] **Step 1: Filter `machineContextOnly` from `displayedCustomFields` in `useEntityCustomFields.ts`**
Update the `displayedCustomFields` computed (line 42):
```typescript
const displayedCustomFields = computed(() =>
dedupeMergedFields(
mergeFieldDefinitionsWithValues(
definitionSources.value,
entity().customFieldValues,
),
).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly),
)
```
- [ ] **Step 2: Filter `machineContextOnly` from normal customFields in machine-detail transform and propagate context data**
In `frontend/app/composables/useMachineDetailCustomFields.ts`:
**For pieces** — In `transformCustomFields` (line 70), the returned object is built at line 141. Replace the `customFields,` line (line 143) with:
```typescript
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: piece.contextCustomFields ?? [],
contextCustomFieldValues: piece.contextCustomFieldValues ?? [],
```
**For components** — In `transformComponentCustomFields` (line 158), the returned object is built at line 241. Replace the `customFields,` line (line 243) with:
```typescript
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: component.contextCustomFields ?? [],
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
```
- [ ] **Step 3: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
Expected: 0 errors.
- [ ] **Step 4: Commit**
```bash
cd frontend && git add .
git commit -m "feat(custom-fields) : filter machineContextOnly from standalone and machine-detail views"
```
---
## Task 4: Display context fields in machine view — `ComponentItem.vue`
**Files:**
- Modify: `frontend/app/components/ComponentItem.vue`
Context fields are on the `component` object (set by the transform in Task 3), not as separate props.
- [ ] **Step 1: Add template section**
After the existing `CustomFieldDisplay` block (line 195), add:
```vue
<!-- Context custom fields (machine-specific) -->
<div v-if="mergedContextFields.length" class="mt-4">
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
Champs contextuels
</h4>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
@field-blur="updateContextCustomField"
/>
</div>
```
- [ ] **Step 2: Add imports and script logic**
**IMPORTANT:** `ComponentItem.vue` uses `<script setup>` WITHOUT `lang="ts"`. Do NOT use TypeScript annotations (`: any`, `: string`, etc.) in any code added to this file.
Add these imports (they are NOT already present in the component):
```javascript
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
```
Add after the existing `useEntityCustomFields` block (around line 348):
```javascript
const { upsertCustomFieldValue } = useCustomFields()
const { showSuccess, showError } = useToast()
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
})
const updateContextCustomField = async (field) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId || field.customField?.id
if (!customFieldId) return
const result = await upsertCustomFieldValue(
customFieldId,
'machineComponentLink',
linkId,
field.value ?? '',
)
if (result.success) {
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
} else {
showError(`Erreur lors de la mise à jour du champ contextuel`)
}
}
```
- [ ] **Step 3: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 4: Commit**
```bash
cd frontend && git add app/components/ComponentItem.vue
git commit -m "feat(custom-fields) : display context custom fields in ComponentItem"
```
---
## Task 5: Display context fields in machine view — `PieceItem.vue`
**Files:**
- Modify: `frontend/app/components/PieceItem.vue`
- [ ] **Step 1: Add template section**
After the existing `CustomFieldDisplay` block (line 236), add:
```vue
<!-- Context custom fields (machine-specific) -->
<div v-if="mergedContextFields.length" class="mt-4">
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
Champs contextuels
</h4>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
@field-blur="updateContextCustomField"
/>
</div>
```
- [ ] **Step 2: Add imports and script logic**
**IMPORTANT:** `PieceItem.vue` uses `<script setup>` WITHOUT `lang="ts"`. Do NOT use TypeScript annotations in any code added to this file.
Add these imports (they are NOT already present in the component):
```javascript
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
```
Add after the existing `useEntityCustomFields` block (around line 366):
```javascript
const { upsertCustomFieldValue } = useCustomFields()
const { showSuccess, showError } = useToast()
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
})
const updateContextCustomField = async (field) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId || field.customField?.id
if (!customFieldId) return
const result = await upsertCustomFieldValue(
customFieldId,
'machinePieceLink',
linkId,
field.value ?? '',
)
if (result.success) {
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
} else {
showError(`Erreur lors de la mise à jour du champ contextuel`)
}
}
```
- [ ] **Step 3: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 4: Commit**
```bash
cd frontend && git add app/components/PieceItem.vue
git commit -m "feat(custom-fields) : display context custom fields in PieceItem"
```
---
## Task 6: Frontend build verification
- [ ] **Step 1: Run full build**
```bash
cd frontend && npm run build
```
Expected: Build succeeds with no errors.
- [ ] **Step 2: Final commit — update submodule pointer (from main repo)**
```bash
cd /home/matthieu/dev_malio/Inventory
git add frontend
git commit -m "chore : update frontend submodule for context custom fields"
```

View File

@@ -0,0 +1,154 @@
# Machine Context Custom Fields
**Date** : 2026-04-02
**Statut** : Validé
## Objectif
Permettre de définir des champs personnalisés sur un ModelType (catégorie de pièce/composant) qui ne s'affichent et ne sont remplissables que lorsque l'item est lié à une machine. Les valeurs sont propres au lien machine (une même pièce dans deux machines peut avoir des valeurs différentes).
## Périmètre
- **Entités concernées** : Composants et Pièces (pas Produits)
- **Définition** : Sur le ModelType, avec un flag `machineContextOnly`
- **Valeurs** : Stockées par lien (`MachineComponentLink` / `MachinePieceLink`)
- **Affichage** : Uniquement dans la vue machine, pas sur les fiches autonomes
## Architecture
### Approche retenue
Extension des entités existantes `CustomField` et `CustomFieldValue` avec :
- Un flag de filtrage sur la définition
- Des FK vers les entités de lien pour les valeurs
### Alternatives écartées
- **Entités séparées** (`MachineContextField` / `MachineContextFieldValue`) — trop de duplication de logique
- **JSON sur les liens** — contraire au projet de normalisation JSON→tables en cours
## Backend
### 1. Entité `CustomField`
Nouveau champ :
```php
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['customField:read', 'customField:write'])]
private bool $machineContextOnly = false;
```
Getter/setter associés.
### 2. Entité `CustomFieldValue`
Nouvelles FK nullable :
```php
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(nullable: true)]
private ?MachineComponentLink $machineComponentLink = null;
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(nullable: true)]
private ?MachinePieceLink $machinePieceLink = null;
```
Contrainte métier : quand `machineComponentLink` est set, `composant` reste aussi set (pour faciliter les queries par composant). Idem pour `machinePieceLink` + `piece`.
### 3. Entités `MachineComponentLink` / `MachinePieceLink`
Nouvelle collection :
```php
#[ORM\OneToMany(targetEntity: CustomFieldValue::class, mappedBy: 'machineComponentLink', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
```
Idem sur `MachinePieceLink` avec `mappedBy: 'machinePieceLink'`.
### 4. Migration
```sql
ALTER TABLE custom_field ADD machine_context_only BOOLEAN DEFAULT false NOT NULL;
ALTER TABLE custom_field_value ADD machine_component_link_id VARCHAR(36) DEFAULT NULL;
ALTER TABLE custom_field_value ADD machine_piece_link_id VARCHAR(36) DEFAULT NULL;
ALTER TABLE custom_field_value
ADD CONSTRAINT fk_cfv_machine_component_link
FOREIGN KEY (machine_component_link_id) REFERENCES machine_component_link(id) ON DELETE CASCADE;
ALTER TABLE custom_field_value
ADD CONSTRAINT fk_cfv_machine_piece_link
FOREIGN KEY (machine_piece_link_id) REFERENCES machine_piece_link(id) ON DELETE CASCADE;
CREATE INDEX idx_cfv_machine_component_link ON custom_field_value(machine_component_link_id);
CREATE INDEX idx_cfv_machine_piece_link ON custom_field_value(machine_piece_link_id);
```
### 5. `MachineStructureController`
Dans `normalizeComposant()` et `normalizePiece()` :
- Récupérer les `CustomField` du ModelType où `machineContextOnly = true`
- Récupérer les `CustomFieldValue` liées au lien via `machineComponentLink` / `machinePieceLink`
- Ajouter dans la réponse :
```json
{
"contextCustomFields": [{ "id": "...", "name": "...", "type": "...", ... }],
"contextCustomFieldValues": [{ "id": "...", "value": "...", "customField": {...} }]
}
```
Séparé des `customFields` / `customFieldValues` globaux existants.
### 6. `CustomFieldValueController`
L'upsert existant est étendu pour accepter `machineComponentLink` ou `machinePieceLink` dans le body. Le controller vérifie que si le `CustomField` a `machineContextOnly = true`, un lien machine est obligatoire.
### 7. Clonage machine
`MachineStructureController::cloneCustomFields()` doit aussi cloner les `contextFieldValues` des liens, en les rattachant aux nouveaux liens créés lors du clone.
## Frontend
### 1. Page ModelType — Définition des champs
Dans l'UI d'édition des custom fields d'un ModelType, ajouter un **toggle/checkbox** "Contexte machine uniquement" sur chaque définition de champ. Cela set `machineContextOnly: true` lors de la sauvegarde.
Concerne les custom fields des catégories COMPONENT et PIECE (pas PRODUCT, hors périmètre).
### 2. Vue machine — `ComponentItem.vue` / `PieceItem.vue`
Nouvelle section "Champs contextuels" affichée sous les custom fields existants :
- Reçoit `contextCustomFields` et `contextCustomFieldValues` en props
- Réutilise le composant `CustomFieldDisplay.vue` existant
- Mode édition : sur blur/change, appel upsert via `CustomFieldValueController` avec le `machineComponentLinkId` ou `machinePieceLinkId`
### 3. Fiches autonomes pièce/composant
Filtrer les champs `machineContextOnly = true` pour ne pas les afficher :
- Dans `useEntityCustomFields` : exclure ces champs du `displayedCustomFields`
- Dans `useMachineDetailCustomFields` : séparer les champs normaux des champs contextuels
### 4. Transformation des données (`useMachineDetailCustomFields`)
`transformComponentCustomFields()` et `transformCustomFields()` :
- Extraire `contextCustomFields` / `contextCustomFieldValues` depuis la réponse structure
- Les passer en propriétés séparées sur l'objet transformé
## Tests
### Backend
- Test unitaire : `CustomField` avec `machineContextOnly = true` est correctement sérialisé
- Test API : upsert d'un `CustomFieldValue` avec `machineComponentLink` fonctionne
- Test API : upsert d'un `CustomFieldValue` contextuel sans lien machine retourne une erreur
- Test API : `/api/machines/{id}/structure` retourne les `contextCustomFields` et `contextCustomFieldValues`
- Test API : clone machine copie les valeurs contextuelles
### Frontend
- Typecheck : 0 erreurs après modifications
- Vérification manuelle : les champs contextuels apparaissent dans la vue machine
- Vérification manuelle : les champs contextuels n'apparaissent pas sur les fiches autonomes

View File

@@ -14,7 +14,7 @@
/>
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot ? 'bg-error/10 border border-error' : 'bg-base-200'">
<div class="flex items-start gap-3 flex-1 min-w-0">
<button
type="button"
@@ -28,8 +28,9 @@
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
</button>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold">
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot }">
{{ pieceData.name }}
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1"> manquant</span>
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"

View File

@@ -288,14 +288,16 @@ export function useComponentEdit(componentId: string) {
if (!structure?.pieces) return []
return (structure.pieces as any[]).map((slot: any, i: number) => {
const edits = slotEdits.pieces[slot.slotId]
const selectedPieceId = edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null)
return {
slotId: slot.slotId,
typePieceId: slot.typePieceId,
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null),
selectedPieceId,
selectedPieceName: slot.selectedPieceName ?? null,
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
position: slot.position ?? i,
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
isEmpty: !selectedPieceId,
}
})
})
@@ -305,14 +307,16 @@ export function useComponentEdit(componentId: string) {
if (!structure?.products) return []
return (structure.products as any[]).map((slot: any, i: number) => {
const edits = slotEdits.products[slot.slotId]
const selectedProductId = edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null)
return {
slotId: slot.slotId,
typeProductId: slot.typeProductId,
selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null),
selectedProductId,
selectedProductName: slot.selectedProductName ?? null,
familyCode: slot.familyCode,
position: slot.position ?? i,
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
isEmpty: !selectedProductId,
}
})
})
@@ -322,15 +326,17 @@ export function useComponentEdit(componentId: string) {
if (!structure?.subcomponents) return []
return (structure.subcomponents as any[]).map((slot: any, i: number) => {
const edits = slotEdits.subcomponents[slot.slotId]
const selectedComponentId = edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null)
return {
slotId: slot.slotId,
typeComposantId: slot.typeComposantId,
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null),
selectedComponentId,
selectedComponentName: slot.selectedComponentName ?? null,
alias: slot.alias,
familyCode: slot.familyCode,
position: slot.position ?? i,
label: slot.alias || `Sous-composant #${i + 1}`,
isEmpty: !selectedComponentId,
}
})
})

View File

@@ -227,11 +227,13 @@ export const buildMachineHierarchyFromLinks = (
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
const isEmpty = !resolved
const typePieceName = (resolved?.typePiece as AnyRecord)?.name || (definition.typePiece as AnyRecord)?.name || (def.typePiece as AnyRecord)?.name || null
return {
...(resolved || {}),
id: resolved?.id || `structure-piece-${composantId}-${index}`,
pieceId: resolved?.id || null,
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
name: resolved?.name || definition.role || definition.name || def.role || def.name || (typePieceName ? `${typePieceName}` : `Pièce ${index + 1}`),
reference: resolved?.reference || definition.reference || def.reference || null,
prix: resolved?.prix ?? null,
constructeurs: resolved?.constructeurs || [],
@@ -243,6 +245,7 @@ export const buildMachineHierarchyFromLinks = (
parentComponentLinkId: machineComponentLinkId,
parentComponentName: componentName,
_structurePiece: true,
_emptySlot: isEmpty,
}
}) as AnyRecord[]

View File

@@ -0,0 +1,30 @@
import { ref } from 'vue'
import { useApi } from './useApi'
const maintenanceEnabled = ref(false)
export function useMaintenance() {
const { apiCall } = useApi()
const loading = ref(false)
const fetchStatus = async () => {
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance')
if (res.success && res.data) {
maintenanceEnabled.value = res.data.enabled
}
}
const toggle = async () => {
loading.value = true
try {
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance', { method: 'PUT' })
if (res.success && res.data) {
maintenanceEnabled.value = res.data.enabled
}
} finally {
loading.value = false
}
}
return { maintenanceEnabled, loading, fetchStatus, toggle }
}

View File

@@ -1,4 +1,4 @@
import { useProfileSession, usePermissions } from "#imports";
import { useProfileSession, usePermissions, useApi } from "#imports";
export default defineNuxtRouteMiddleware(async (to) => {
const { ensureSession, activeProfile } = useProfileSession();
@@ -12,9 +12,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
normalizedPath.startsWith("/profiles") ||
fullPath.startsWith("/profiles") ||
routeName.startsWith("profiles");
const isMaintenanceRoute = normalizedPath === "/maintenance";
// Redirect to login if no active profile
if (!activeProfile.value && !isProfilesRoute) {
if (!activeProfile.value && !isProfilesRoute && !isMaintenanceRoute) {
return navigateTo("/profiles");
}
@@ -29,5 +30,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
}
// Maintenance mode check for non-admin users
if (!isAdmin.value && !isMaintenanceRoute && !isProfilesRoute) {
const { apiCall } = useApi();
const res = await apiCall<{ enabled: boolean }>('/maintenance/check');
if (res.success && res.data?.enabled) {
return navigateTo("/maintenance");
}
}
}
});

View File

@@ -1,5 +1,28 @@
<template>
<div class="container mx-auto p-6 max-w-6xl">
<!-- Maintenance Mode -->
<div class="alert mb-6" :class="maintenanceEnabled ? 'alert-warning' : 'alert-info'">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span class="font-medium">Mode maintenance</span>
<span v-if="maintenanceEnabled" class="badge badge-warning badge-sm">Actif</span>
<span v-else class="badge badge-ghost badge-sm">Inactif</span>
</div>
<button
class="btn btn-sm"
:class="maintenanceEnabled ? 'btn-ghost' : 'btn-warning'"
:disabled="maintenanceLoading"
@click="handleToggleMaintenance"
>
<span v-if="maintenanceLoading" class="loading loading-spinner loading-xs" />
{{ maintenanceEnabled ? 'Désactiver' : 'Activer' }}
</button>
</div>
<p class="text-sm opacity-70 mt-1">
{{ maintenanceEnabled ? 'Seuls les administrateurs peuvent accéder à l\'application.' : 'L\'application est accessible à tous les utilisateurs.' }}
</p>
</div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">
Administration des profils
@@ -153,9 +176,14 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useAdminProfiles } from '#imports'
import { useAdminProfiles, useMaintenance } from '#imports'
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
const { maintenanceEnabled, loading: maintenanceLoading, fetchStatus: fetchMaintenanceStatus, toggle: toggleMaintenance } = useMaintenance()
const handleToggleMaintenance = async () => {
await toggleMaintenance()
}
const loaded = ref(false)
const isLoading = computed(() => loading.value || !loaded.value)
@@ -264,7 +292,7 @@ const handleDeactivate = async (profileId) => {
}
onMounted(async () => {
await fetchAll()
await Promise.all([fetchAll(), fetchMaintenanceStatus()])
loaded.value = true
})
</script>

View File

@@ -195,7 +195,7 @@
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<div class="flex items-start gap-2">
<div class="flex-1">
@@ -231,7 +231,7 @@
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<ProductSelect
:model-value="slot.selectedProductId"
@@ -252,7 +252,7 @@
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<ComposantSelect
:model-value="slot.selectedComponentId"

View File

@@ -230,7 +230,7 @@
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<div class="flex items-start gap-2">
@@ -255,9 +255,12 @@
</div>
</div>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
{{ slot.selectedPieceName || '— Non sélectionné' }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
<div v-else class="input input-bordered input-sm md:input-md flex items-center gap-2" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>
{{ slot.selectedPieceName }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
</template>
</div>
</div>
</div>
@@ -272,7 +275,7 @@
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ProductSelect
@@ -282,8 +285,9 @@
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ slot.selectedProductName || '— Non sélectionné' }}
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedProductName }}</template>
</div>
</div>
</div>
@@ -298,7 +302,7 @@
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ComposantSelect
@@ -308,8 +312,9 @@
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ slot.selectedComponentName || '— Non sélectionné' }}
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedComponentName }}</template>
</div>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="text-center max-w-md">
<h1 class="text-4xl font-bold mb-4">
Maintenance
</h1>
<p class="text-lg text-base-content/70 mb-6">
L'application est actuellement en maintenance. Veuillez réessayer ultérieurement.
</p>
<button class="btn btn-primary" @click="retry">
Réessayer
</button>
</div>
</div>
</template>
<script setup>
const retry = () => {
navigateTo('/')
}
</script>

View File

@@ -224,7 +224,7 @@
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
{{ entry.label }}
</span>
</label>
@@ -244,10 +244,11 @@
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ entry.label }}</span>
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
</label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ productSelectionLabels[index] || '— Non sélectionné' }}
<div class="input input-bordered input-sm md:input-md flex items-center" :class="productSelectionLabels[index] ? 'bg-base-200' : 'border-error bg-error/10 text-error font-semibold'">
<template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
<template v-else>{{ productSelectionLabels[index] }}</template>
</div>
</div>
</div>

View File

@@ -147,7 +147,7 @@ defineEmits<{
**Step 2: Run lint**
Run: `cd frontend && npm run lint:fix`
Run: `cd Inventory_frontend && npm run lint:fix`
**Step 3: Replace document list in each of the 5 files**
@@ -166,7 +166,7 @@ Remove the now-unused imports (`documentIcon`, `formatSize`, `shouldInlinePdf`,
**Step 4: Run lint + typecheck**
Run: `cd frontend && npm run lint:fix && npx nuxi typecheck`
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
**Step 5: Commit**
@@ -258,7 +258,7 @@ Import sanitize/hydrate functions from the new files. File should end up ~270 li
**Step 4: Verify all imports across the codebase still work**
Run: `cd frontend && npx nuxi typecheck`
Run: `cd Inventory_frontend && npx nuxi typecheck`
**Step 5: Commit**

View File

@@ -3,12 +3,14 @@ import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
// Lire la version depuis le fichier VERSION à la racine du projet parent
// Lire la version depuis config/version.yaml à la racine du projet parent
const getAppVersion = (): string => {
try {
const __dirname = dirname(fileURLToPath(import.meta.url))
const versionPath = resolve(__dirname, '..', 'VERSION')
return readFileSync(versionPath, 'utf-8').trim()
const versionPath = resolve(__dirname, '..', 'config', 'version.yaml')
const content = readFileSync(versionPath, 'utf-8')
const match = content.match(/app\.version:\s*'([^']+)'/)
return match ? match[1] : '0.0.0'
} catch {
return '0.0.0'
}

View File

@@ -19,7 +19,6 @@ COPY migrations migrations/
COPY public public/
COPY src src/
COPY templates templates/
COPY VERSION VERSION
RUN composer dump-autoload --optimize --no-dev
@@ -31,6 +30,7 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
COPY config/version.yaml /app/config/version.yaml
ENV CI=1 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE_URL=/api \
@@ -61,8 +61,8 @@ RUN ln -sf /dev/stdout /var/log/nginx/access.log \
RUN rm -f /etc/nginx/sites-enabled/default
# Configs
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/inventory.conf
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/inventory.conf
# Backend from stage 1
COPY --from=backend-build /app /var/www/html

View File

@@ -24,5 +24,5 @@ echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(sudo docker compose exec -T app cat VERSION)
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"

View File

@@ -6,7 +6,7 @@ services:
ports:
- "8082:80"
volumes:
- ./uploads:/var/www/html/var/uploads
- ./storage:/var/www/html/var/storage/documents
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

View File

@@ -1,6 +1,6 @@
# Permet d'utiliser un .env.docker.local pour override
ENV_DEFAULT = docker/.env.docker
ENV_LOCAL = docker/.env.docker.local
ENV_DEFAULT = infra/dev/.env.docker
ENV_LOCAL = infra/dev/.env.docker.local
ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
# Permet d'avoir les variables du fichier .env.docker.local
@@ -25,13 +25,13 @@ DATA_SQL_NORM ?= data_norm.sql
#========================================================================================
env-init:
@mkdir -p docker
@mkdir -p infra/dev
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
# Lance le container
start: env-init
@echo "**** START CONTAINERS ****"
@cp --update=none docker/.env.docker docker/.env.docker.local
@cp --update=none infra/dev/.env.docker infra/dev/.env.docker.local
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
@echo ""
@echo "URLs disponibles:"

View File

@@ -11,11 +11,11 @@ NC='\033[0m' # No Color
# Répertoire racine du projet
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
VERSION_FILE="$PROJECT_ROOT/VERSION"
VERSION_FILE="$PROJECT_ROOT/config/version.yaml"
API_PLATFORM_FILE="$PROJECT_ROOT/config/packages/api_platform.yaml"
# Lire la version actuelle
current_version=$(cat "$VERSION_FILE" | tr -d '\n')
current_version=$(awk -F': *' '/app\.version:/{print $2}' "$VERSION_FILE" | tr -d "' \n\r")
# Fonction pour afficher l'aide
show_help() {
@@ -113,8 +113,8 @@ cd "$PROJECT_ROOT"
# ===========================================
# ÉTAPE 1 : Mettre à jour VERSION
# ===========================================
echo -e "${BLUE}[1/4]${NC} Mise à jour du fichier VERSION..."
echo "$new_version" > "$VERSION_FILE"
echo -e "${BLUE}[1/4]${NC} Mise à jour de config/version.yaml..."
printf "parameters:\\n app.version: '%s'\\n" "$new_version" > "$VERSION_FILE"
# ===========================================
# ÉTAPE 2 : Mettre à jour api_platform.yaml

View File

@@ -728,6 +728,7 @@ class MachineStructureController extends AbstractController
$pieceData = [
'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
];

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
final class MaintenanceController extends AbstractController
{
public function __construct(
private readonly KernelInterface $kernel,
) {}
#[Route('/api/maintenance/check', name: 'maintenance_check', methods: ['GET'])]
public function check(): JsonResponse
{
return new JsonResponse([
'enabled' => file_exists($this->flagPath()),
]);
}
#[Route('/api/admin/maintenance', name: 'admin_maintenance_status', methods: ['GET'])]
public function status(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
return new JsonResponse([
'enabled' => file_exists($this->flagPath()),
]);
}
#[Route('/api/admin/maintenance', name: 'admin_maintenance_toggle', methods: ['PUT'])]
public function toggle(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$path = $this->flagPath();
if (file_exists($path)) {
unlink($path);
$enabled = false;
} else {
file_put_contents($path, (string) time());
$enabled = true;
}
return new JsonResponse(['enabled' => $enabled]);
}
private function flagPath(): string
{
return $this->kernel->getProjectDir() . '/var/maintenance';
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
#[AsEventListener(event: 'kernel.request', priority: 10)]
final class MaintenanceModeListener
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly TokenStorageInterface $tokenStorage,
) {}
public function __invoke(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$flagFile = $this->kernel->getProjectDir() . '/var/maintenance';
if (!file_exists($flagFile)) {
return;
}
$request = $event->getRequest();
$path = $request->getPathInfo();
// Always allow maintenance status endpoint and session endpoints
if (str_starts_with($path, '/api/admin/maintenance')
|| str_starts_with($path, '/api/maintenance/check')
|| str_starts_with($path, '/api/session')
|| str_starts_with($path, '/api/health')
|| str_starts_with($path, '/api/docs')
) {
return;
}
// Allow admin users through
$token = $this->tokenStorage->getToken();
if ($token && $token->getUser()) {
$roles = $token->getRoleNames();
if (in_array('ROLE_ADMIN', $roles, true)) {
return;
}
}
$event->setResponse(new JsonResponse(
['message' => 'Application en maintenance. Veuillez réessayer ultérieurement.'],
JsonResponse::HTTP_SERVICE_UNAVAILABLE,
));
}
}