Compare commits

..

8 Commits

Author SHA1 Message Date
gitea-actions
c88333b052 chore : bump version to v1.9.29
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m6s
2026-05-03 18:05:16 +00:00
8f5cd98b82 fix(machine-clone) : preserve context field values when cloning a machine
All checks were successful
Auto Tag Develop / tag (push) Successful in 35s
Context CustomFieldValues attached to component/piece links were
silently dropped from the clone response (and from any subsequent
read in the same request) because the controller persisted the new
CFVs without adding them to the inverse-side collection of the new
link. Doctrine does not auto-sync inverse OneToMany associations,
so getContextFieldValues() returned an empty collection on the
freshly persisted link.

Also synchronise the inverse collection in the test factory so
identity-mapped entities reflect newly-created CFVs when reused
by request handlers within the same test.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:59:03 +02:00
48f7e4c6ac test(session) : align expectations with hardened auth from WIP 476060c
Generic 'Identifiants invalides.' is now returned for both wrong
password and missing-password-set cases (security obscurity, prevents
account enumeration). Tests still asserted the granular 'Mot de passe
incorrect.' message and a 403 status that the controller no longer
emits.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:56:53 +02:00
c46769a67d fix(model-types) : nullify weak references on ModelType delete
Belt-and-suspenders against orphan refs when a ModelType is deleted:
applicatively nullifies typeComposantId / typePieceId / typeProductId
on every "ON DELETE SET NULL" relationship before the row is removed,
in case the database FK cascade fails to fire.

Observed in prod 2026-04-28: deletion of ModelType "Paliers" left an
orphan in skeleton_subcomponent_requirements, surfacing as a 500 when
API Platform tried to lazy-load the missing proxy.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:29:36 +02:00
gitea-actions
28394ce1b4 chore : bump version to v1.9.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 3m16s
2026-04-10 14:57:59 +00:00
Matthieu
8cfcb41a39 feat(conversion) : commande CLI pour convertir la catégorie Moteur de PIECE vers COMPONENT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Migre les 18 pièces en composants, transfère documents, custom fields,
slots et skeleton requirements dans une transaction. Supporte --dry-run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:57:46 +02:00
gitea-actions
980a7c310e chore : bump version to v1.9.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 2m32s
2026-04-09 12:34:46 +00:00
Matthieu
00f18d1c7d feat(infra) : add monolog logging and persist logs in prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Install symfony/monolog-bundle with rotating_file handlers.
Add named volume inventory_logs for prod log persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:33:42 +02:00
14 changed files with 752 additions and 5 deletions

View File

@@ -7,6 +7,13 @@
"X-Profile-Id": "admin-default-profile",
"X-Profile-Password": "A123"
}
},
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": {
"Authorization": "Bearer b355c6cbf27d2a86d7eba1c3132c99bb3133f94cfd9e9243ffcc3c5ae1dc82c8"
}
}
}
}

View File

@@ -24,6 +24,7 @@
"symfony/framework-bundle": "8.0.*",
"symfony/mcp-bundle": "^0.6.0",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0.2",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",

261
composer.lock generated
View File

@@ -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": "f94dc3c05e9ba6be99c510aad3d17182",
"content-hash": "5c54b1589d9e815f4c9b7e5e1d2d69c7",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2437,6 +2437,109 @@
},
"time": "2026-02-23T21:42:54+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
},
"provide": {
"psr/log-implementation": "3.0.0"
},
"require-dev": {
"aws/aws-sdk-php": "^3.0",
"doctrine/couchdb": "~1.0@dev",
"elasticsearch/elasticsearch": "^7 || ^8",
"ext-json": "*",
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
"mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
"predis/predis": "^1.1 || ^2",
"rollbar/rollbar": "^4.0",
"ruflin/elastica": "^7 || ^8",
"symfony/mailer": "^5.4 || ^6",
"symfony/mime": "^5.4 || ^6"
},
"suggest": {
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
"ext-mbstring": "Allow to work properly with unicode symbols",
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
"ext-openssl": "Required to send log messages using SSL",
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
"rollbar/rollbar": "Allow sending log messages to Rollbar",
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Monolog\\": "src/Monolog"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
"homepage": "https://github.com/Seldaek/monolog",
"keywords": [
"log",
"logging",
"psr-3"
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
},
"funding": [
{
"url": "https://github.com/Seldaek",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
"type": "tidelift"
}
],
"time": "2026-01-02T08:56:05+00:00"
},
{
"name": "nelmio/cors-bundle",
"version": "2.6.0",
@@ -5427,6 +5530,162 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/monolog-bridge",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bridge.git",
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c6efdcbd5cc17cf7618fb4447053b792df6ae724",
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724",
"shasum": ""
},
"require": {
"monolog/monolog": "^3",
"php": ">=8.4",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/service-contracts": "^2.5|^3"
},
"require-dev": {
"symfony/console": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0",
"symfony/mailer": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/mime": "^7.4|^8.0",
"symfony/security-core": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Monolog\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides integration for Monolog with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/monolog-bridge/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/monolog-bundle",
"version": "v4.0.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bundle.git",
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/c012c6aba13129eb02aa7dd61e66e720911d8598",
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.0",
"monolog/monolog": "^3.5",
"php": ">=8.2",
"symfony/config": "^7.3 || ^8.0",
"symfony/dependency-injection": "^7.3 || ^8.0",
"symfony/http-kernel": "^7.3 || ^8.0",
"symfony/monolog-bridge": "^7.3 || ^8.0",
"symfony/polyfill-php84": "^1.30"
},
"require-dev": {
"phpunit/phpunit": "^11.5.41 || ^12.3",
"symfony/console": "^7.3 || ^8.0",
"symfony/yaml": "^7.3 || ^8.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MonologBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony MonologBundle",
"homepage": "https://symfony.com",
"keywords": [
"log",
"logging"
],
"support": {
"issues": "https://github.com/symfony/monolog-bundle/issues",
"source": "https://github.com/symfony/monolog-bundle/tree/v4.0.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-02T18:27:21+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.0.0",

View File

@@ -9,6 +9,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
@@ -22,4 +23,5 @@ return [
ApiPlatformBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true],
McpBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,56 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 7
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50
nested:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 30
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: rotating_file
channels: [deprecation]
path: "%kernel.logs_dir%/deprecations.log"
max_files: 7

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.26'
app.version: '1.9.29'

View File

@@ -98,6 +98,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2092,6 +2093,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -4112,6 +4114,7 @@
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -4181,6 +4184,7 @@
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.44.1",
"@typescript-eslint/types": "8.44.1",
@@ -4977,6 +4981,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.4",
"@vue/compiler-core": "3.5.22",
@@ -5207,6 +5212,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5637,6 +5643,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -7069,6 +7076,7 @@
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10490,6 +10498,7 @@
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -10536,6 +10545,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.87.0.tgz",
"integrity": "sha512-uc47XrtHwkBoES4HFgwgfH9sqwAtJXgAIBq4fFBMZ4hWmgVZoExyn+L4g4VuaecVKXkz1bvlaHcfwHAJPQb5Gw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.87.0"
},
@@ -10937,6 +10947,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11376,6 +11387,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -12118,6 +12130,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -13180,6 +13193,7 @@
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13537,6 +13551,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -13783,6 +13798,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14186,6 +14202,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",
@@ -14230,6 +14247,7 @@
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0",
@@ -14253,6 +14271,7 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},

View File

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

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace App\Command;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
#[AsCommand(
name: 'app:convert-moteur-piece-to-component',
description: 'Convertit la catégorie "Moteur" (PIECE) en COMPONENT et migre toutes les pièces liées en composants.',
)]
class ConvertMoteurPieceToComponentCommand extends Command
{
private const MODEL_TYPE_ID = 'cmgytewe0002447ffup09bscr';
public function __construct(
private readonly Connection $connection,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche les actions sans les exécuter');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
$io->title('Conversion catégorie "Moteur" : PIECE → COMPONENT');
// ── 1. Vérifications ──────────────────────────────────────────────
$modelType = $this->connection->fetchAssociative(
'SELECT id, name, code, category FROM model_types WHERE id = :id',
['id' => self::MODEL_TYPE_ID],
);
if (!$modelType) {
$io->error('ModelType "Moteur" introuvable (id: '.self::MODEL_TYPE_ID.')');
return Command::FAILURE;
}
if ('PIECE' !== $modelType['category']) {
$io->error(sprintf('Le ModelType "Moteur" est déjà de catégorie %s — rien à faire.', $modelType['category']));
return Command::FAILURE;
}
$pieces = $this->connection->fetchAllAssociative(
'SELECT id, name, reference FROM pieces WHERE typepieceid = :id ORDER BY name',
['id' => self::MODEL_TYPE_ID],
);
$pieceCount = count($pieces);
$io->info(sprintf('Pièces à convertir : %d', $pieceCount));
if ($pieceCount > 0) {
$io->table(
['ID', 'Nom', 'Référence'],
array_map(fn (array $p) => [$p['id'], $p['name'], $p['reference'] ?? '—'], $pieces),
);
}
// Check blockers
$blockers = [];
$machineLinked = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM machine_piece_links mpl
JOIN pieces p ON mpl.pieceid = p.id
WHERE p.typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
if ($machineLinked > 0) {
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines — conversion impossible.', $machineLinked);
}
$nameCollisions = $this->connection->fetchFirstColumn(
'SELECT p.name FROM pieces p
WHERE p.typepieceid = :id
AND p.name IN (SELECT c.name FROM composants c)',
['id' => self::MODEL_TYPE_ID],
);
if ([] !== $nameCollisions) {
$blockers[] = sprintf('Collision de noms avec des composants existants : %s', implode(', ', $nameCollisions));
}
$categoryCollision = (int) $this->connection->fetchOne(
"SELECT COUNT(*) FROM model_types WHERE category = 'COMPONENT' AND name = :name AND id != :id",
['name' => $modelType['name'], 'id' => self::MODEL_TYPE_ID],
);
if ($categoryCollision > 0) {
$blockers[] = sprintf('Un ModelType composant « %s » existe déjà.', $modelType['name']);
}
if ([] !== $blockers) {
$io->error($blockers);
return Command::FAILURE;
}
// Summary of related data
$relatedCounts = $this->countRelatedData();
$io->section('Données liées à migrer');
$io->table(
['Table', 'Nombre'],
array_map(fn (string $k, int $v) => [$k, $v], array_keys($relatedCounts), array_values($relatedCounts)),
);
if ($dryRun) {
$io->warning('Mode dry-run : aucune modification effectuée.');
return Command::SUCCESS;
}
// ── 2. Exécution ──────────────────────────────────────────────────
$this->connection->beginTransaction();
try {
$now = new DateTimeImmutable()->format('Y-m-d H:i:s');
// 2a. Copier les pièces dans composants
$converted = $this->connection->executeStatement(
'INSERT INTO composants (id, name, reference, referenceauto, description, prix, typecomposantid, productid, version, createdat, updatedat)
SELECT id, name, reference, referenceauto, description, prix, typepieceid, productid, version, createdat, :now
FROM pieces
WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$io->text(sprintf('✓ %d pièce(s) copiée(s) dans composants', $converted));
// 2b. Transférer les documents
$docs = $this->connection->executeStatement(
'UPDATE documents SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d document(s) transféré(s)', $docs));
// 2c. Transférer les custom_field_values
$cfv = $this->connection->executeStatement(
'UPDATE custom_field_values SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d valeur(s) de champs perso transférée(s)', $cfv));
// 2d. Transférer les custom_fields (définitions)
$cf = $this->connection->executeStatement(
'UPDATE custom_fields SET typecomposantid = typepieceid, typepieceid = NULL
WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d définition(s) de champs perso transférée(s)', $cf));
// 2e. Transférer les constructeur links
$ctorLinks = $this->connection->executeStatement(
"INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
pcl.pieceid, pcl.constructeurid, pcl.supplierreference, pcl.createdat, :now
FROM piece_constructeur_links pcl
WHERE pcl.pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
if ($ctorLinks > 0) {
$this->connection->executeStatement(
'DELETE FROM piece_constructeur_links
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => self::MODEL_TYPE_ID],
);
}
$io->text(sprintf('✓ %d lien(s) constructeur transféré(s)', $ctorLinks));
// 2f. Convertir composant_piece_slots → composant_subcomponent_slots
$slots = $this->connection->executeStatement(
"INSERT INTO composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat)
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
cps.composantid,
COALESCE(sp.name, 'Moteur'),
'moteur',
cps.typepieceid,
cps.selectedpieceid,
cps.position,
cps.createdat,
:now
FROM composant_piece_slots cps
LEFT JOIN pieces sp ON sp.id = cps.selectedpieceid
WHERE cps.typepieceid = :id",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$this->connection->executeStatement(
'DELETE FROM composant_piece_slots WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d slot(s) pièce convertis en slots sous-composant', $slots));
// 2g. Convertir skeleton_piece_requirements → skeleton_subcomponent_requirements
$skelReqs = $this->connection->executeStatement(
"INSERT INTO skeleton_subcomponent_requirements (id, modeltypeid, alias, familycode, typecomposantid, position, createdat, updatedat)
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
spr.modeltypeid,
'Moteur',
'moteur',
spr.typepieceid,
spr.position,
spr.createdat,
:now
FROM skeleton_piece_requirements spr
WHERE spr.typepieceid = :id",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$this->connection->executeStatement(
'DELETE FROM skeleton_piece_requirements WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d skeleton requirement(s) convertis', $skelReqs));
// 2h. Mettre à jour audit_logs entity_type
$auditUpdated = $this->connection->executeStatement(
"UPDATE audit_logs SET entitytype = 'composant'
WHERE entitytype = 'piece'
AND entityid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d audit log(s) mis à jour', $auditUpdated));
// 2i. Mettre à jour comments entity_type
$commentsUpdated = $this->connection->executeStatement(
"UPDATE comments SET entity_type = 'composant'
WHERE entity_type = 'piece'
AND entity_id IN (SELECT id FROM pieces WHERE typepieceid = :id)",
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d commentaire(s) mis à jour', $commentsUpdated));
// 2j. Supprimer les pièces originales
$deleted = $this->connection->executeStatement(
'DELETE FROM pieces WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d pièce(s) supprimée(s)', $deleted));
// 2k. Changer la catégorie du ModelType
$this->connection->executeStatement(
"UPDATE model_types SET category = 'COMPONENT', updatedat = :now WHERE id = :id",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$io->text('✓ ModelType "Moteur" passé en COMPONENT');
$this->connection->commit();
$io->success(sprintf('Conversion terminée : %d pièces → composants.', $converted));
return Command::SUCCESS;
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Erreur — rollback effectué : '.$e->getMessage());
return Command::FAILURE;
}
}
/**
* @return array<string, int>
*/
private function countRelatedData(): array
{
$id = self::MODEL_TYPE_ID;
return [
'pieces' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM pieces WHERE typepieceid = :id',
['id' => $id],
),
'documents' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM documents WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $id],
),
'custom_field_values' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM custom_field_values WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $id],
),
'custom_fields' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM custom_fields WHERE typepieceid = :id',
['id' => $id],
),
'piece_constructeur_links' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM piece_constructeur_links WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $id],
),
'composant_piece_slots (→ subcomponent_slots)' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_piece_slots WHERE typepieceid = :id',
['id' => $id],
),
'skeleton_piece_requirements (→ subcomponent_reqs)' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM skeleton_piece_requirements WHERE typepieceid = :id',
['id' => $id],
),
];
}
}

View File

@@ -337,6 +337,7 @@ class MachineStructureController extends AbstractController
$newValue->setMachineComponentLink($newLink);
$newValue->setComposant($newLink->getComposant());
$this->entityManager->persist($newValue);
$newLink->getContextFieldValues()->add($newValue);
}
}
@@ -352,6 +353,7 @@ class MachineStructureController extends AbstractController
$newValue->setMachinePieceLink($newLink);
$newValue->setPiece($newLink->getPiece());
$this->entityManager->persist($newValue);
$newLink->getContextFieldValues()->add($newValue);
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\ModelType;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Doctrine\ORM\Events;
use function sprintf;
/**
* Belt-and-suspenders cleanup of weak references to a ModelType before deletion:
* runs the equivalent of every "ON DELETE SET NULL" cascade applicatively, in case
* the database FK fails to fire (observed on prod in 2026-04 — the deletion of
* ModelType "Paliers" left an orphan in skeleton_subcomponent_requirements).
*/
#[AsDoctrineListener(event: Events::preRemove)]
final class ModelTypeReferenceCleanupSubscriber
{
/** @var list<array{0: string, 1: string}> */
private const NULLABLE_REFERENCES = [
['skeleton_subcomponent_requirements', 'typecomposantid'],
['skeleton_piece_requirements', 'typepieceid'],
['skeleton_product_requirements', 'typeproductid'],
['composant_piece_slots', 'typepieceid'],
['composant_product_slots', 'typeproductid'],
['composant_subcomponent_slots', 'typecomposantid'],
['piece_product_slots', 'typeproductid'],
['machine_component_links', 'modeltypeid'],
['machine_piece_links', 'modeltypeid'],
['machine_product_links', 'modeltypeid'],
];
public function preRemove(PreRemoveEventArgs $args): void
{
$entity = $args->getObject();
if (!$entity instanceof ModelType) {
return;
}
$id = $entity->getId();
if (!$id) {
return;
}
$conn = $args->getObjectManager()->getConnection();
foreach (self::NULLABLE_REFERENCES as [$table, $column]) {
$conn->executeStatement(
sprintf('UPDATE %s SET %s = NULL WHERE %s = ?', $table, $column, $column),
[$id],
);
}
}
}

View File

@@ -157,6 +157,18 @@
"symfony/mcp-bundle": {
"version": "v0.6.0"
},
"symfony/monolog-bundle": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/property-info": {
"version": "8.0",
"recipe": {

View File

@@ -7,10 +7,10 @@ namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Entity\Composant;
use App\Entity\ComposantConstructeurLink;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\ComposantConstructeurLink;
use App\Entity\Constructeur;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
@@ -467,6 +467,14 @@ abstract class AbstractApiTestCase extends ApiTestCase
$em->persist($cfv);
$em->flush();
// Keep inverse-side collections in sync so identity-mapped entities reflect the new CFV.
if (null !== $machineComponentLink && !$machineComponentLink->getContextFieldValues()->contains($cfv)) {
$machineComponentLink->getContextFieldValues()->add($cfv);
}
if (null !== $machinePieceLink && !$machinePieceLink->getContextFieldValues()->contains($cfv)) {
$machinePieceLink->getContextFieldValues()->add($cfv);
}
return $cfv;
}

View File

@@ -47,7 +47,7 @@ class SessionProfileTest extends AbstractApiTestCase
]);
$this->assertResponseStatusCodeSame(401);
$this->assertJsonContains(['message' => 'Mot de passe incorrect.']);
$this->assertJsonContains(['message' => 'Identifiants invalides.']);
}
public function testLoginMissingPassword(): void
@@ -103,7 +103,7 @@ class SessionProfileTest extends AbstractApiTestCase
],
]);
$this->assertResponseStatusCodeSame(403);
$this->assertResponseStatusCodeSame(401);
}
public function testGetActiveProfileAuthenticated(): void