Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
649f5a8570 | ||
| e6ba2259cb | |||
|
|
27d51ffdb1 | ||
|
|
53d4d5768b | ||
|
|
3ff89d43ed | ||
|
|
5c55441e6c | ||
|
|
e432153083 | ||
|
|
b16b619fc9 | ||
|
|
c88333b052 | ||
| 8f5cd98b82 | |||
| 48f7e4c6ac | |||
| c46769a67d | |||
|
|
28394ce1b4 | ||
|
|
8cfcb41a39 | ||
|
|
980a7c310e | ||
|
|
00f18d1c7d |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,3 +264,12 @@ make test-setup # Créer/mettre à jour le schéma test
|
||||
- Nuxt dev : `http://localhost:3001`
|
||||
- Adminer (PG) : `http://localhost:5050`
|
||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||
|
||||
## Delegation Codex
|
||||
|
||||
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||
|
||||
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||
|
||||
C'est le meilleur ratio qualite/credits.
|
||||
|
||||
@@ -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
261
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
56
config/packages/monolog.yaml
Normal file
56
config/packages/monolog.yaml
Normal 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
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.26'
|
||||
app.version: '1.9.30'
|
||||
|
||||
@@ -739,12 +739,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
pieceData.name = props.piece.name || ''
|
||||
pieceData.reference = props.piece.reference || ''
|
||||
pieceData.prix = props.piece.prix || ''
|
||||
pieceData.quantity = props.piece.quantity ?? 1
|
||||
loadProducts().catch(() => {})
|
||||
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
||||
if (!props.piece.documents?.length) refreshDocuments()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -119,7 +119,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
if (!machineName.value.trim()) return false
|
||||
return true
|
||||
})
|
||||
const debug = ref(false)
|
||||
|
||||
const componentsCollapsed = ref(true)
|
||||
const collapseToggleToken = ref(0)
|
||||
@@ -227,22 +226,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
const componentTypeOptions = computed(() => componentTypes.value || [])
|
||||
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
||||
|
||||
const componentTypeLabelMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
componentTypeOptions.value.forEach((type) => {
|
||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceTypeLabelMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
pieceTypeOptions.value.forEach((type) => {
|
||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// Machine field methods
|
||||
const initMachineFields = () => {
|
||||
if (machine.value) {
|
||||
@@ -306,7 +289,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
// UI methods
|
||||
const toggleEditMode = () => {
|
||||
isEditMode.value = !isEditMode.value
|
||||
debug.value = !debug.value
|
||||
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
||||
refreshMachineDocuments()
|
||||
}
|
||||
@@ -432,12 +414,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
await productsPromise
|
||||
const linksApplied = applyMachineLinks(machineResult.data)
|
||||
|
||||
if (machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
|
||||
if (!linksApplied) {
|
||||
components.value = transformComponentCustomFields(machinePayload.components || [])
|
||||
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
||||
@@ -447,6 +423,8 @@ export function useMachineDetailData(machineId: string) {
|
||||
}
|
||||
|
||||
if (machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
|
||||
@@ -496,11 +474,11 @@ export function useMachineDetailData(machineId: string) {
|
||||
// UI state
|
||||
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
||||
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
||||
isEditMode, debug,
|
||||
isEditMode,
|
||||
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
||||
|
||||
// Computed
|
||||
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap,
|
||||
componentTypeOptions, pieceTypeOptions,
|
||||
productInventory, productById, flattenedComponents, machinePieces,
|
||||
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function useToast() {
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
duration: type === 'error' ? 0 : duration,
|
||||
duration,
|
||||
}
|
||||
|
||||
if (toasts.value.length >= MAX_TOASTS) {
|
||||
@@ -42,8 +42,7 @@ export function useToast() {
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
// Only auto-dismiss non-error toasts
|
||||
if (type !== 'error' && duration > 0) {
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
@@ -56,8 +55,8 @@ export function useToast() {
|
||||
return showToast(message, 'success', duration)
|
||||
}
|
||||
|
||||
const showError = (message: string): number => {
|
||||
return showToast(message, 'error', 0)
|
||||
const showError = (message: string, duration = 8000): number => {
|
||||
return showToast(message, 'error', duration)
|
||||
}
|
||||
|
||||
const showWarning = (message: string, duration = 6000): number => {
|
||||
|
||||
@@ -715,10 +715,12 @@
|
||||
</p>
|
||||
<p class="text-base-content/70 leading-relaxed mb-4">
|
||||
<strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand
|
||||
l'element est monte sur une machine. Par exemple, la "position sur la machine"
|
||||
d'une pompe : dans le catalogue, la pompe n'est montee nulle part, donc ce champ
|
||||
ne sert a rien. Mais quand on regarde cette pompe depuis la fiche d'une machine,
|
||||
on veut savoir ou elle est installee.
|
||||
l'element est monte sur une machine. Prenons l'exemple d'un palier : sur une
|
||||
machine, vous en avez souvent deux, un en haut (le palier de tete) et un en
|
||||
bas (le palier de pied). Dans le catalogue, le palier n'est monte nulle part,
|
||||
donc savoir s'il est "en haut" ou "en bas" ne veut rien dire. Mais des qu'on
|
||||
regarde ce palier depuis la fiche d'une machine, on veut savoir lequel des
|
||||
deux c'est.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -731,16 +733,16 @@
|
||||
<p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">Debit max</span>
|
||||
<span>120 L/min</span>
|
||||
<span class="text-base-content/70">Diametre interieur</span>
|
||||
<span>50 mm</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">ATEX</span>
|
||||
<span>Oui</span>
|
||||
<span class="text-base-content/70">Type</span>
|
||||
<span>Roulement a billes</span>
|
||||
</div>
|
||||
<div class="border-t border-dashed border-base-300 pt-2 mt-2">
|
||||
<div class="flex justify-between opacity-30">
|
||||
<span class="line-through">Position sur la machine</span>
|
||||
<span class="line-through">Emplacement</span>
|
||||
<span class="text-xs italic">pas affiche ici</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -753,17 +755,17 @@
|
||||
<p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">Debit max</span>
|
||||
<span>120 L/min</span>
|
||||
<span class="text-base-content/70">Diametre interieur</span>
|
||||
<span>50 mm</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">ATEX</span>
|
||||
<span>Oui</span>
|
||||
<span class="text-base-content/70">Type</span>
|
||||
<span>Roulement a billes</span>
|
||||
</div>
|
||||
<div class="border-t border-primary/20 pt-2 mt-2">
|
||||
<div class="flex justify-between bg-primary/10 rounded px-2 py-1.5">
|
||||
<span class="text-base-content font-medium">Position sur la machine</span>
|
||||
<span class="font-bold">Secteur B - Ligne 3</span>
|
||||
<span class="text-base-content font-medium">Emplacement</span>
|
||||
<span class="font-bold">Haut (palier de tete)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
106
migrations/Version20260506140000_FixComposantCascadeFKs.php
Normal file
106
migrations/Version20260506140000_FixComposantCascadeFKs.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260506140000_FixComposantCascadeFKs extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add missing CASCADE FKs documents.composantid and machine_component_links.composantid; cleanup pre-existing orphan rows';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Trace des suppressions à venir dans audit_logs (actor = NULL = "system").
|
||||
// On copie un snapshot minimal avant DELETE pour cohérence avec les autres "delete".
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'document',
|
||||
d.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', d.id,
|
||||
'name', d.name,
|
||||
'filename', d.filename,
|
||||
'composantId', d.composantid,
|
||||
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM documents d
|
||||
WHERE d.composantid IS NOT NULL
|
||||
AND d.composantid NOT IN (SELECT id FROM composants)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'machine_component_link',
|
||||
l.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', l.id,
|
||||
'machineId', l.machineid,
|
||||
'composantId', l.composantid,
|
||||
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM machine_component_links l
|
||||
WHERE l.composantid IS NOT NULL
|
||||
AND l.composantid NOT IN (SELECT id FROM composants)
|
||||
SQL);
|
||||
|
||||
// 2. Nettoyage des orphelins.
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM documents
|
||||
WHERE composantid IS NOT NULL
|
||||
AND composantid NOT IN (SELECT id FROM composants)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM machine_component_links
|
||||
WHERE composantid IS NOT NULL
|
||||
AND composantid NOT IN (SELECT id FROM composants)
|
||||
SQL);
|
||||
|
||||
// 3. Ajout idempotent des 2 FK manquantes (alignement avec les entités Doctrine).
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_documents_composant' AND table_name = 'documents'
|
||||
) THEN
|
||||
ALTER TABLE documents ADD CONSTRAINT fk_documents_composant
|
||||
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_mcl_composant' AND table_name = 'machine_component_links'
|
||||
) THEN
|
||||
ALTER TABLE machine_component_links ADD CONSTRAINT fk_mcl_composant
|
||||
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_composant');
|
||||
$this->addSql('ALTER TABLE machine_component_links DROP CONSTRAINT IF EXISTS fk_mcl_composant');
|
||||
}
|
||||
}
|
||||
320
src/Command/ConvertMoteurPieceToComponentCommand.php
Normal file
320
src/Command/ConvertMoteurPieceToComponentCommand.php
Normal 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],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -162,9 +162,6 @@ class MachineStructureController extends AbstractController
|
||||
// Copy product links
|
||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||
|
||||
// Copy context field values
|
||||
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||
@@ -230,6 +227,17 @@ class MachineStructureController extends AbstractController
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$this->entityManager->persist($newLink);
|
||||
|
||||
foreach ($link->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);
|
||||
$newLink->getContextFieldValues()->add($newValue);
|
||||
}
|
||||
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
@@ -269,6 +277,17 @@ class MachineStructureController extends AbstractController
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
|
||||
foreach ($link->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);
|
||||
$newLink->getContextFieldValues()->add($newValue);
|
||||
}
|
||||
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
@@ -317,45 +336,6 @@ class MachineStructureController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePayloadList(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
|
||||
@@ -11,8 +11,8 @@ use App\Entity\Machine;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use App\Entity\Site;
|
||||
use App\Service\ActorProfileResolver;
|
||||
use BackedEnum;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -22,10 +22,6 @@ use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Error;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
@@ -35,8 +31,7 @@ use function method_exists;
|
||||
abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
protected readonly ActorProfileResolver $actorProfileResolver,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
@@ -61,7 +56,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$actorProfileId = $this->actorProfileResolver->resolve();
|
||||
$entityType = $this->entityType();
|
||||
|
||||
if ($this->hasCollectionTracking()) {
|
||||
@@ -278,28 +273,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
return $entity->getVersion();
|
||||
}
|
||||
|
||||
protected function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
||||
{
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
@@ -385,13 +358,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
||||
}
|
||||
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
||||
}
|
||||
|
||||
// Note: scheduled collection updates/deletions are intentionally not
|
||||
// tracked here — constructeurs are now persisted as ConstructeurLink
|
||||
// entities (OneToMany), so Doctrine no longer fires collection events
|
||||
// for them. Custom field values are handled below.
|
||||
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
||||
|
||||
foreach ($pendingUpdates as $entityId => $diff) {
|
||||
@@ -411,17 +381,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op: constructeurs are now tracked as ConstructeurLink entities (OneToMany),
|
||||
* so Doctrine no longer fires collection update events for them.
|
||||
*/
|
||||
private function collectCollectionUpdate(
|
||||
object $collection,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingEntities,
|
||||
): void {}
|
||||
|
||||
private function collectCustomFieldValueChanges(
|
||||
UnitOfWork $uow,
|
||||
array &$pendingUpdates,
|
||||
|
||||
@@ -31,7 +31,7 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$actorProfileId = $this->actorProfileResolver->resolve();
|
||||
|
||||
$this->processLinkChanges($em, $uow, $actorProfileId);
|
||||
}
|
||||
|
||||
57
src/EventSubscriber/ModelTypeReferenceCleanupSubscriber.php
Normal file
57
src/EventSubscriber/ModelTypeReferenceCleanupSubscriber.php
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/Service/ActorProfileResolver.php
Normal file
41
src/Service/ActorProfileResolver.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
final class ActorProfileResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function resolve(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Throwable;
|
||||
|
||||
final class EntityVersionService
|
||||
@@ -56,7 +55,7 @@ final class EntityVersionService
|
||||
public function __construct(
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly ActorProfileResolver $actorProfileResolver,
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly PieceRepository $pieces,
|
||||
@@ -187,7 +186,7 @@ final class EntityVersionService
|
||||
'restore',
|
||||
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
|
||||
$this->buildCurrentSnapshot($entityType, $entity),
|
||||
$this->resolveActorProfileId(),
|
||||
$this->actorProfileResolver->resolve(),
|
||||
$newVersion,
|
||||
);
|
||||
$this->em->persist($restoreAuditLog);
|
||||
@@ -917,25 +916,11 @@ final class EntityVersionService
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
$snapshot['productSlots'] = [];
|
||||
foreach ($entity->getProductSlots() as $slot) {
|
||||
$snapshot['productSlots'][] = [
|
||||
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
|
||||
}
|
||||
|
||||
if ('piece' === $entityType) {
|
||||
$snapshot['productSlots'] = [];
|
||||
foreach ($entity->getProductSlots() as $slot) {
|
||||
$snapshot['productSlots'][] = [
|
||||
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
|
||||
}
|
||||
|
||||
// Custom field values
|
||||
@@ -953,21 +938,23 @@ final class EntityVersionService
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current actor profile ID from the session.
|
||||
* Mirrors AbstractAuditSubscriber::resolveActorProfileId().
|
||||
* @param iterable<ComposantProductSlot|PieceProductSlot> $slots
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function resolveActorProfileId(): ?string
|
||||
private function serializeProductSlots(iterable $slots): array
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
$serialized = [];
|
||||
foreach ($slots as $slot) {
|
||||
$serialized[] = [
|
||||
'id' => $slot->getId(),
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
return $serialized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
final class ModelTypeCategoryConversionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
private readonly ActorProfileResolver $actorProfileResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -327,17 +321,7 @@ final class ModelTypeCategoryConversionService
|
||||
);
|
||||
|
||||
// 7. Update ModelType
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
'cat' => ModelCategory::COMPONENT->value,
|
||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
'id' => $modelTypeId,
|
||||
],
|
||||
);
|
||||
$this->updateModelTypeCategory($modelTypeId, ModelCategory::COMPONENT);
|
||||
|
||||
return $count;
|
||||
}
|
||||
@@ -406,19 +390,24 @@ final class ModelTypeCategoryConversionService
|
||||
);
|
||||
|
||||
// 7. Update ModelType
|
||||
$this->updateModelTypeCategory($modelTypeId, ModelCategory::PIECE);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function updateModelTypeCategory(string $modelTypeId, ModelCategory $category): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
'cat' => ModelCategory::PIECE->value,
|
||||
'cat' => $category->value,
|
||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
'id' => $modelTypeId,
|
||||
],
|
||||
);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,30 +446,9 @@ final class ModelTypeCategoryConversionService
|
||||
'action' => 'convert',
|
||||
'diff' => json_encode($diff),
|
||||
'snapshot' => json_encode($snapshot),
|
||||
'actor' => $this->resolveActorProfileId(),
|
||||
'actor' => $this->actorProfileResolver->resolve(),
|
||||
'now' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
12
symfony.lock
12
symfony.lock
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace App\Tests\Api\Entity;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testStructureReturnsContextFieldsOnComponentLink(): void
|
||||
@@ -56,7 +59,7 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
|
||||
$normalFields = array_filter(
|
||||
$componentLink['composant']['customFields'],
|
||||
fn (array $f) => $f['name'] === 'Serial',
|
||||
fn (array $f) => 'Serial' === $f['name'],
|
||||
);
|
||||
$this->assertCount(1, $normalFields);
|
||||
}
|
||||
@@ -65,8 +68,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site B');
|
||||
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
||||
$site = $this->createSite('Site B');
|
||||
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Wear Level',
|
||||
type: 'select',
|
||||
@@ -101,8 +104,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site C');
|
||||
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
||||
$site = $this->createSite('Site C');
|
||||
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Flow Rate',
|
||||
type: 'number',
|
||||
@@ -131,8 +134,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site D');
|
||||
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
||||
$site = $this->createSite('Site D');
|
||||
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Pressure',
|
||||
type: 'number',
|
||||
@@ -171,8 +174,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site E');
|
||||
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
||||
$site = $this->createSite('Site E');
|
||||
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Calibration Date',
|
||||
type: 'date',
|
||||
@@ -190,8 +193,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site F');
|
||||
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
||||
$site = $this->createSite('Site F');
|
||||
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'RPM Setting',
|
||||
type: 'number',
|
||||
@@ -225,4 +228,84 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
|
||||
public function testCloneMachineCopiesPieceContextFieldValues(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site G');
|
||||
$modelType = $this->createModelType('Bearing Clone', 'BRGC', ModelCategory::PIECE);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Wear Level',
|
||||
type: 'text',
|
||||
typePiece: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$source = $this->createMachine('Source Piece Machine', $site);
|
||||
$piece = $this->createPiece('Bearing C', 'BRGC-001', $modelType);
|
||||
$link = $this->createMachinePieceLink($source, $piece);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: 'Fair',
|
||||
piece: $piece,
|
||||
machinePieceLink: $link,
|
||||
);
|
||||
|
||||
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'json' => [
|
||||
'name' => 'Cloned Piece Machine',
|
||||
'siteId' => $site->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
|
||||
$clonedLink = $data['pieceLinks'][0] ?? null;
|
||||
$this->assertNotNull($clonedLink, 'Clone should expose at least one pieceLink');
|
||||
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||
$this->assertSame('Fair', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
|
||||
public function testCloneMachineLeavesSourceContextFieldValuesIntact(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site H');
|
||||
$modelType = $this->createModelType('Motor Source', 'MOTS', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'RPM',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$source = $this->createMachine('Original Machine', $site);
|
||||
$composant = $this->createComposant('Motor S', 'MOTS-001', $modelType);
|
||||
$link = $this->createMachineComponentLink($source, $composant);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '1500',
|
||||
composant: $composant,
|
||||
machineComponentLink: $link,
|
||||
);
|
||||
|
||||
$client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'json' => [
|
||||
'name' => 'Clone Machine',
|
||||
'siteId' => $site->getId(),
|
||||
],
|
||||
]);
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
|
||||
// Source must still expose its original context field value
|
||||
$sourceData = $client->request('GET', '/api/machines/'.$source->getId().'/structure')->toArray();
|
||||
$sourceLink = $sourceData['componentLinks'][0] ?? null;
|
||||
$this->assertNotNull($sourceLink, 'Source machine should still expose its component link');
|
||||
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
|
||||
$this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user