From 1c3b5669237d85b02847c08074832b1160f6c87d Mon Sep 17 00:00:00 2001 From: r-dev Date: Fri, 3 Apr 2026 10:15:47 +0200 Subject: [PATCH] feat(machines) : allow category-only links on machine structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable adding a component, piece, or product to a machine by selecting only the category (ModelType) without a specific entity. The link displays a red "À remplir" badge; clicking it reopens the modal pre-filled with the category so the user can associate an item later. Backend: entity FKs made nullable on the 3 link tables, modelType FK added, controller/audit/version/MCP normalization adapted for null entities. Frontend: modal accepts category-only confirm, page handles fill mode, hierarchy builder creates pending nodes, display components show clickable badge with event propagation through the full hierarchy. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/components/ComponentHierarchy.vue | 3 +- frontend/app/components/ComponentItem.vue | 19 +++- frontend/app/components/PieceItem.vue | 17 +++- .../machine/AddEntityToMachineModal.vue | 25 ++++- .../machine/MachineComponentsCard.vue | 2 + .../components/machine/MachinePiecesCard.vue | 2 + .../machine/MachineProductsCard.vue | 20 +++- .../app/composables/useMachineDetailData.ts | 6 ++ .../composables/useMachineDetailHierarchy.ts | 69 +++++++++++++- .../composables/useMachineDetailProducts.ts | 5 +- .../app/composables/useMachineHierarchy.ts | 53 +++++++++++ frontend/app/pages/machine/[id].vue | 43 +++++++-- .../Version20260403_CategoryOnlyLinks.php | 94 +++++++++++++++++++ src/Controller/MachineStructureController.php | 39 +++++--- src/Entity/MachineComponentLink.php | 24 ++++- src/Entity/MachinePieceLink.php | 24 ++++- src/Entity/MachineProductLink.php | 24 ++++- .../MachineAuditSubscriber.php | 31 +++--- src/Mcp/Tool/Machine/MachineStructureTool.php | 14 +-- src/Service/EntityVersionService.php | 15 +-- 20 files changed, 452 insertions(+), 77 deletions(-) create mode 100644 migrations/Version20260403_CategoryOnlyLinks.php diff --git a/frontend/app/components/ComponentHierarchy.vue b/frontend/app/components/ComponentHierarchy.vue index b3ab1bd..f1c5a87 100644 --- a/frontend/app/components/ComponentHierarchy.vue +++ b/frontend/app/components/ComponentHierarchy.vue @@ -12,6 +12,7 @@ @edit-piece="$emit('edit-piece', $event)" @custom-field-update="$emit('custom-field-update', $event)" @delete="$emit('delete')" + @fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)" /> @@ -43,5 +44,5 @@ defineProps({ } }) -defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete']) +defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity']) diff --git a/frontend/app/components/ComponentItem.vue b/frontend/app/components/ComponentItem.vue index a7e7bab..cac5db8 100644 --- a/frontend/app/components/ComponentItem.vue +++ b/frontend/app/components/ComponentItem.vue @@ -14,7 +14,7 @@ /> -
+
-

+

{{ component.name }}

+ {{ component.reference }} {{ component.prix }}€
@@ -54,7 +63,7 @@
-
+
@@ -241,6 +250,7 @@ @update="updatePiece" @edit="editPiece" @custom-field-update="updatePieceCustomField" + @fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)" />
@@ -276,6 +286,7 @@ @update="$emit('update', $event)" @edit-piece="$emit('edit-piece', $event)" @custom-field-update="$emit('custom-field-update', $event)" + @fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)" />
@@ -317,7 +328,7 @@ const props = defineProps({ toggleToken: { type: Number, default: 0 }, }) -const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete']) +const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity']) // --- Shared composables --- const { diff --git a/frontend/app/components/PieceItem.vue b/frontend/app/components/PieceItem.vue index 6007b39..0264494 100644 --- a/frontend/app/components/PieceItem.vue +++ b/frontend/app/components/PieceItem.vue @@ -14,7 +14,7 @@ /> -
+
-

+

{{ pieceData.name }} — manquant +

-
+
@@ -308,7 +317,7 @@ const props = defineProps({ toggleToken: { type: Number, default: 0 }, }) -const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete']) +const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete', 'fill-entity']) // --- Local reactive data for editing --- const pieceData = reactive({ diff --git a/frontend/app/components/machine/AddEntityToMachineModal.vue b/frontend/app/components/machine/AddEntityToMachineModal.vue index f6459ed..1da3c35 100644 --- a/frontend/app/components/machine/AddEntityToMachineModal.vue +++ b/frontend/app/components/machine/AddEntityToMachineModal.vue @@ -49,6 +49,12 @@ />
+
+

+ Aucun item sélectionné — la catégorie sera ajoutée avec le statut "À remplir". +

+
+

{{ selectedEntitySummary.name }}

@@ -64,10 +70,10 @@
@@ -90,11 +96,12 @@ type EntityKind = 'component' | 'piece' | 'product' const props = defineProps<{ open: boolean entityKind: EntityKind + prefillTypeId?: string }>() const emit = defineEmits<{ close: [] - confirm: [entityId: string] + confirm: [payload: { entityId?: string; modelTypeId: string; modelTypeName: string }] }>() const selectedTypeId = ref('') @@ -166,6 +173,10 @@ watch(() => props.open, async (isOpen) => { if (props.entityKind === 'component') await loadComponentTypes() else if (props.entityKind === 'piece') await loadPieceTypes() else await loadProductTypes() + + if (props.prefillTypeId) { + selectedTypeId.value = props.prefillTypeId + } }) // Load entities when type changes @@ -222,8 +233,12 @@ const handleClose = () => { } const handleConfirm = () => { - if (!selectedEntityId.value) return - emit('confirm', selectedEntityId.value) + if (!selectedTypeId.value) return + emit('confirm', { + entityId: selectedEntityId.value || undefined, + modelTypeId: selectedTypeId.value, + modelTypeName: selectedTypeName.value, + }) resetState() emit('close') } diff --git a/frontend/app/components/machine/MachineComponentsCard.vue b/frontend/app/components/machine/MachineComponentsCard.vue index 9f29faa..bdaddef 100644 --- a/frontend/app/components/machine/MachineComponentsCard.vue +++ b/frontend/app/components/machine/MachineComponentsCard.vue @@ -34,6 +34,7 @@ :toggle-token="collapseToggleToken" @edit-piece="$emit('edit-piece', $event)" @delete="$emit('remove-component', component.linkId || component.id)" + @fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)" />
@@ -68,5 +69,6 @@ defineEmits<{ 'custom-field-update': [fieldUpdate: any] 'add-component': [] 'remove-component': [linkId: string] + 'fill-entity': [linkId: string, modelTypeId: string] }>() diff --git a/frontend/app/components/machine/MachinePiecesCard.vue b/frontend/app/components/machine/MachinePiecesCard.vue index 20d2c72..4f469e7 100644 --- a/frontend/app/components/machine/MachinePiecesCard.vue +++ b/frontend/app/components/machine/MachinePiecesCard.vue @@ -35,6 +35,7 @@ @update="$emit('update-piece', $event)" @edit="$emit('edit-piece', $event)" @delete="$emit('remove-piece', piece.linkId || piece.id)" + @fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)" />
@@ -67,5 +68,6 @@ defineEmits<{ 'edit-piece': [piece: any] 'add-piece': [] 'remove-piece': [linkId: string] + 'fill-entity': [linkId: string, modelTypeId: string] }>() diff --git a/frontend/app/components/machine/MachineProductsCard.vue b/frontend/app/components/machine/MachineProductsCard.vue index 68c8575..6ae7822 100644 --- a/frontend/app/components/machine/MachineProductsCard.vue +++ b/frontend/app/components/machine/MachineProductsCard.vue @@ -23,14 +23,24 @@
-

+

{{ product.name }}

+ {{ product.groupLabel }} @@ -141,6 +151,9 @@ defineProps<{ supplierLabel?: string | null priceLabel?: string | null groupLabel?: string + pendingEntity?: boolean + modelTypeId?: string | null + modelType?: string | null documents?: Array<{ id?: string name?: string @@ -156,6 +169,7 @@ defineProps<{ defineEmits<{ 'add-product': [] 'remove-product': [linkId: string] + 'fill-entity': [linkId: string, modelTypeId: string] }>() const previewDocument = ref(null) diff --git a/frontend/app/composables/useMachineDetailData.ts b/frontend/app/composables/useMachineDetailData.ts index 5291bfd..0fe4eed 100644 --- a/frontend/app/composables/useMachineDetailData.ts +++ b/frontend/app/composables/useMachineDetailData.ts @@ -193,6 +193,10 @@ export function useMachineDetailData(machineId: string) { removePieceLink, addProductLink, removeProductLink, + addComponentLinkCategoryOnly, + addPieceLinkCategoryOnly, + addProductLinkCategoryOnly, + fillEntityLink, } = hierarchy // Keep the product links proxy in sync with the hierarchy's machineProductLinks @@ -511,6 +515,8 @@ export function useMachineDetailData(machineId: string) { loadMachineData, loadInitialData, addComponentLink, removeComponentLink, addPieceLink, removePieceLink, addProductLink, removeProductLink, reloadMachineStructure, + addComponentLinkCategoryOnly, addPieceLinkCategoryOnly, + addProductLinkCategoryOnly, fillEntityLink, // External constructeurs, loadProducts, updateMachineStructure, toast, diff --git a/frontend/app/composables/useMachineDetailHierarchy.ts b/frontend/app/composables/useMachineDetailHierarchy.ts index 17cd8bd..72c41c3 100644 --- a/frontend/app/composables/useMachineDetailHierarchy.ts +++ b/frontend/app/composables/useMachineDetailHierarchy.ts @@ -39,7 +39,7 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) { syncMachineCustomFields, } = deps - const { get, post: apiPost, delete: apiDel } = useApi() + const { get, post: apiPost, delete: apiDel, patch: apiPatch } = useApi() const toast = useToast() // --------------------------------------------------------------------------- @@ -263,6 +263,69 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) { return result } + const addComponentLinkCategoryOnly = async (modelTypeId: string) => { + const result: any = await apiPost('/machine_component_links', { + machine: `/api/machines/${machineId}`, + modelType: `/api/model_types/${modelTypeId}`, + }) + if (result.success) { + toast.showSuccess('Catégorie ajoutée — item à remplir') + await reloadMachineStructure() + } else { + toast.showError('Erreur lors de l\'ajout') + } + } + + const addPieceLinkCategoryOnly = async (modelTypeId: string) => { + const result: any = await apiPost('/machine_piece_links', { + machine: `/api/machines/${machineId}`, + modelType: `/api/model_types/${modelTypeId}`, + }) + if (result.success) { + toast.showSuccess('Catégorie ajoutée — item à remplir') + await reloadMachineStructure() + } else { + toast.showError('Erreur lors de l\'ajout') + } + } + + const addProductLinkCategoryOnly = async (modelTypeId: string) => { + const result: any = await apiPost('/machine_product_links', { + machine: `/api/machines/${machineId}`, + modelType: `/api/model_types/${modelTypeId}`, + }) + if (result.success) { + toast.showSuccess('Catégorie ajoutée — item à remplir') + await reloadMachineStructure() + } else { + toast.showError('Erreur lors de l\'ajout') + } + } + + const fillEntityLink = async (linkId: string, entityId: string, entityKind: string) => { + let endpoint = '' + let payload: Record = {} + + if (entityKind === 'component') { + endpoint = `/machine_component_links/${linkId}` + payload = { composant: `/api/composants/${entityId}` } + } else if (entityKind === 'piece') { + endpoint = `/machine_piece_links/${linkId}` + payload = { piece: `/api/pieces/${entityId}` } + } else { + endpoint = `/machine_product_links/${linkId}` + payload = { product: `/api/products/${entityId}` } + } + + const result: any = await apiPatch(endpoint, payload) + if (result.success) { + toast.showSuccess('Item associé avec succès') + await reloadMachineStructure() + } else { + toast.showError('Erreur lors de l\'association') + } + } + const removeProductLink = async (linkId: string) => { const result: any = await apiDel(`/machine_product_links/${linkId}`) if (result.success) { @@ -301,6 +364,10 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) { addPieceLink, removePieceLink, addProductLink, + addComponentLinkCategoryOnly, + addPieceLinkCategoryOnly, + addProductLinkCategoryOnly, + fillEntityLink, removeProductLink, } } diff --git a/frontend/app/composables/useMachineDetailProducts.ts b/frontend/app/composables/useMachineDetailProducts.ts index 41a9b40..92644c9 100644 --- a/frontend/app/composables/useMachineDetailProducts.ts +++ b/frontend/app/composables/useMachineDetailProducts.ts @@ -103,7 +103,7 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) { return { id: (resolved?.id as string) || productId || null, linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null, - name: (resolved?.name as string) || 'Produit inconnu', + name: (resolved?.name as string) || (link.modelType as AnyRecord)?.name as string || 'Produit inconnu', reference: (resolved?.reference as string) || null, supplierLabel: resolvedConstructeurs.length ? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null @@ -111,6 +111,9 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) { priceLabel: resolved ? getProductPriceLabel(resolved) : null, groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '', documents: productId ? (productDocumentsMap.value.get(productId) || []) : [], + pendingEntity: (link.pendingEntity as boolean) || false, + modelTypeId: (link.modelTypeId as string) || null, + modelType: (link.modelType as string) || null, } }) }) diff --git a/frontend/app/composables/useMachineHierarchy.ts b/frontend/app/composables/useMachineHierarchy.ts index 441d1be..df8064c 100644 --- a/frontend/app/composables/useMachineHierarchy.ts +++ b/frontend/app/composables/useMachineHierarchy.ts @@ -150,6 +150,30 @@ export const buildMachineHierarchyFromLinks = ( const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => { if (!link || typeof link !== 'object') return null + // Category-only link (no entity yet) + if (link.pendingEntity || (!link.piece && !link.pieceId)) { + const machinePieceLinkId = normalizePieceLinkId(link) + const mt = (link.modelType || null) as AnyRecord | null + return { + id: machinePieceLinkId || `pending-${link.id}`, + linkId: machinePieceLinkId, + name: mt?.name || 'Catégorie sans item', + reference: null, + prix: null, + pendingEntity: true, + modelTypeId: link.modelTypeId || mt?.id || null, + modelType: mt, + pieceId: null, + constructeurs: [], + documents: [], + customFields: [], + parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null, + parentComponentName, + machinePieceLinkId, + quantity: 1, + } + } + const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null @@ -205,6 +229,35 @@ export const buildMachineHierarchyFromLinks = ( const createComponentNode = (link: AnyRecord): AnyRecord | null => { if (!link || typeof link !== 'object') return null + // Category-only link (no entity yet) + if (link.pendingEntity || (!link.composant && !link.composantId)) { + const machineComponentLinkId = normalizeComponentLinkId(link) + const mt = (link.modelType || null) as AnyRecord | null + return { + id: machineComponentLinkId || `pending-${link.id}`, + linkId: machineComponentLinkId, + name: mt?.name || 'Catégorie sans item', + reference: null, + prix: null, + pendingEntity: true, + modelTypeId: link.modelTypeId || mt?.id || null, + modelType: mt, + composantId: null, + composant: null, + constructeurs: [], + documents: [], + customFields: [], + customFieldValues: [], + subComponents: [], + pieces: [], + overrides: null, + parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null, + machineComponentLinkId, + childLinks: [], + pieceLinks: [], + } + } + const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null diff --git a/frontend/app/pages/machine/[id].vue b/frontend/app/pages/machine/[id].vue index c5f0a15..0c01038 100644 --- a/frontend/app/pages/machine/[id].vue +++ b/frontend/app/pages/machine/[id].vue @@ -100,6 +100,7 @@ :is-edit-mode="d.isEditMode.value" @add-product="openAddModal('product')" @remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }" + @fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)" /> @@ -115,6 +116,7 @@ @custom-field-update="d.updatePieceCustomField" @add-component="openAddModal('component')" @remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }" + @fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)" /> @@ -129,6 +131,7 @@ @custom-field-update="d.updatePieceCustomField" @add-piece="openAddModal('piece')" @remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }" + @fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)" @toggle-collapse="d.toggleAllPieces" /> @@ -136,7 +139,8 @@ @@ -277,6 +281,8 @@ const historyFieldLabels = { const addModalOpen = ref(false) const addModalKind = ref('component') +const fillLinkId = ref('') +const fillTypeId = ref('') const openAddModal = (kind) => { addModalKind.value = kind @@ -288,17 +294,40 @@ const handleRemoveConstructeurLink = (constructeurId) => { d.handleMachineConstructeurChange(ids) } -const handleAddEntity = async (entityId) => { - if (addModalKind.value === 'component') { - await d.addComponentLink(entityId) - } else if (addModalKind.value === 'piece') { - await d.addPieceLink(entityId) +const handleAddEntity = async (payload) => { + const { entityId, modelTypeId } = payload + + if (fillLinkId.value) { + await d.fillEntityLink(fillLinkId.value, entityId, addModalKind.value) + fillLinkId.value = '' + fillTypeId.value = '' + } else if (entityId) { + if (addModalKind.value === 'component') { + await d.addComponentLink(entityId) + } else if (addModalKind.value === 'piece') { + await d.addPieceLink(entityId) + } else { + await d.addProductLink(entityId) + } } else { - await d.addProductLink(entityId) + if (addModalKind.value === 'component') { + await d.addComponentLinkCategoryOnly(modelTypeId) + } else if (addModalKind.value === 'piece') { + await d.addPieceLinkCategoryOnly(modelTypeId) + } else { + await d.addProductLinkCategoryOnly(modelTypeId) + } } refreshVersions() } +const handleFillEntity = (linkId, entityKind, modelTypeId) => { + fillLinkId.value = linkId + fillTypeId.value = modelTypeId + addModalKind.value = entityKind + addModalOpen.value = true +} + const machineViewTitle = computed(() => { return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine' }) diff --git a/migrations/Version20260403_CategoryOnlyLinks.php b/migrations/Version20260403_CategoryOnlyLinks.php new file mode 100644 index 0000000..f151f44 --- /dev/null +++ b/migrations/Version20260403_CategoryOnlyLinks.php @@ -0,0 +1,94 @@ +addSql('ALTER TABLE machine_component_links ALTER COLUMN composantid DROP NOT NULL'); + $this->addSql('ALTER TABLE machine_piece_links ALTER COLUMN pieceid DROP NOT NULL'); + $this->addSql('ALTER TABLE machine_product_links ALTER COLUMN productid DROP NOT NULL'); + + // 2. Add modeltypeid column to all 3 tables + $this->addSql('ALTER TABLE machine_component_links ADD COLUMN IF NOT EXISTS modeltypeid VARCHAR(36) DEFAULT NULL'); + $this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS modeltypeid VARCHAR(36) DEFAULT NULL'); + $this->addSql('ALTER TABLE machine_product_links ADD COLUMN IF NOT EXISTS modeltypeid VARCHAR(36) DEFAULT NULL'); + + // 3. Add FK constraints from modeltypeid to model_types(id) ON DELETE SET NULL + $this->addSql(<<<'SQL' + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_machine_component_links_modeltype' AND table_name = 'machine_component_links' + ) THEN + ALTER TABLE machine_component_links ADD CONSTRAINT fk_machine_component_links_modeltype + FOREIGN KEY (modeltypeid) REFERENCES model_types(id) ON DELETE SET NULL; + END IF; + END $$; + SQL); + + $this->addSql(<<<'SQL' + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_machine_piece_links_modeltype' AND table_name = 'machine_piece_links' + ) THEN + ALTER TABLE machine_piece_links ADD CONSTRAINT fk_machine_piece_links_modeltype + FOREIGN KEY (modeltypeid) REFERENCES model_types(id) ON DELETE SET NULL; + END IF; + END $$; + SQL); + + $this->addSql(<<<'SQL' + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_machine_product_links_modeltype' AND table_name = 'machine_product_links' + ) THEN + ALTER TABLE machine_product_links ADD CONSTRAINT fk_machine_product_links_modeltype + FOREIGN KEY (modeltypeid) REFERENCES model_types(id) ON DELETE SET NULL; + END IF; + END $$; + SQL); + + // 4. Add indexes on modeltypeid + $this->addSql('CREATE INDEX IF NOT EXISTS idx_machine_component_links_modeltypeid ON machine_component_links (modeltypeid)'); + $this->addSql('CREATE INDEX IF NOT EXISTS idx_machine_piece_links_modeltypeid ON machine_piece_links (modeltypeid)'); + $this->addSql('CREATE INDEX IF NOT EXISTS idx_machine_product_links_modeltypeid ON machine_product_links (modeltypeid)'); + } + + public function down(Schema $schema): void + { + // Drop indexes + $this->addSql('DROP INDEX IF EXISTS idx_machine_component_links_modeltypeid'); + $this->addSql('DROP INDEX IF EXISTS idx_machine_piece_links_modeltypeid'); + $this->addSql('DROP INDEX IF EXISTS idx_machine_product_links_modeltypeid'); + + // Drop FK constraints + $this->addSql('ALTER TABLE machine_component_links DROP CONSTRAINT IF EXISTS fk_machine_component_links_modeltype'); + $this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_machine_piece_links_modeltype'); + $this->addSql('ALTER TABLE machine_product_links DROP CONSTRAINT IF EXISTS fk_machine_product_links_modeltype'); + + // Drop modeltypeid columns + $this->addSql('ALTER TABLE machine_component_links DROP COLUMN IF EXISTS modeltypeid'); + $this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS modeltypeid'); + $this->addSql('ALTER TABLE machine_product_links DROP COLUMN IF EXISTS modeltypeid'); + + // Restore NOT NULL on entity FK columns + $this->addSql('ALTER TABLE machine_component_links ALTER COLUMN composantid SET NOT NULL'); + $this->addSql('ALTER TABLE machine_piece_links ALTER COLUMN pieceid SET NOT NULL'); + $this->addSql('ALTER TABLE machine_product_links ALTER COLUMN productid SET NOT NULL'); + } +} diff --git a/src/Controller/MachineStructureController.php b/src/Controller/MachineStructureController.php index edfdd9e..1401d4a 100644 --- a/src/Controller/MachineStructureController.php +++ b/src/Controller/MachineStructureController.php @@ -623,17 +623,21 @@ class MachineStructureController extends AbstractController { return array_map(function (MachineComponentLink $link): array { $composant = $link->getComposant(); + $modelType = $link->getModelType(); $parentLink = $link->getParentLink(); return [ 'id' => $link->getId(), 'linkId' => $link->getId(), 'machineId' => $link->getMachine()->getId(), - 'composantId' => $composant->getId(), - 'composant' => $this->normalizeComposant($composant), + 'composantId' => $composant?->getId(), + 'composant' => $composant ? $this->normalizeComposant($composant) : null, + 'modelTypeId' => $modelType?->getId(), + 'modelType' => $modelType ? $this->normalizeModelType($modelType) : null, + 'pendingEntity' => null === $composant, 'parentLinkId' => $parentLink?->getId(), 'parentComponentLinkId' => $parentLink?->getId(), - 'parentComponentId' => $parentLink?->getComposant()->getId(), + 'parentComponentId' => $parentLink?->getComposant()?->getId(), 'overrides' => $this->normalizeOverrides($link), 'childLinks' => [], 'pieceLinks' => [], @@ -645,19 +649,23 @@ class MachineStructureController extends AbstractController { return array_map(function (MachinePieceLink $link): array { $piece = $link->getPiece(); + $modelType = $link->getModelType(); $parentLink = $link->getParentLink(); return [ 'id' => $link->getId(), 'linkId' => $link->getId(), 'machineId' => $link->getMachine()->getId(), - 'pieceId' => $piece->getId(), - 'piece' => $this->normalizePiece($piece), + 'pieceId' => $piece?->getId(), + 'piece' => $piece ? $this->normalizePiece($piece) : null, + 'modelTypeId' => $modelType?->getId(), + 'modelType' => $modelType ? $this->normalizeModelType($modelType) : null, + 'pendingEntity' => null === $piece, 'parentLinkId' => $parentLink?->getId(), 'parentComponentLinkId' => $parentLink?->getId(), - 'parentComponentId' => $parentLink?->getComposant()->getId(), + 'parentComponentId' => $parentLink?->getComposant()?->getId(), 'overrides' => $this->normalizeOverrides($link), - 'quantity' => $this->resolvePieceQuantity($link), + 'quantity' => $piece ? $this->resolvePieceQuantity($link) : 1, ]; }, $links); } @@ -665,13 +673,16 @@ class MachineStructureController extends AbstractController private function resolvePieceQuantity(MachinePieceLink $link): int { $parentLink = $link->getParentLink(); + $piece = $link->getPiece(); - if (!$parentLink) { + if (!$parentLink || !$piece) { return $link->getQuantity(); } $composant = $parentLink->getComposant(); - $piece = $link->getPiece(); + if (!$composant) { + return $link->getQuantity(); + } foreach ($composant->getPieceSlots() as $slot) { if ($slot->getSelectedPiece()?->getId() === $piece->getId()) { @@ -685,14 +696,18 @@ class MachineStructureController extends AbstractController private function normalizeProductLinks(array $links): array { return array_map(function (MachineProductLink $link): array { - $product = $link->getProduct(); + $product = $link->getProduct(); + $modelType = $link->getModelType(); return [ 'id' => $link->getId(), 'linkId' => $link->getId(), 'machineId' => $link->getMachine()->getId(), - 'productId' => $product->getId(), - 'product' => $this->normalizeProduct($product), + 'productId' => $product?->getId(), + 'product' => $product ? $this->normalizeProduct($product) : null, + 'modelTypeId' => $modelType?->getId(), + 'modelType' => $modelType ? $this->normalizeModelType($modelType) : null, + 'pendingEntity' => null === $product, 'parentLinkId' => $link->getParentLink()?->getId(), 'parentComponentLinkId' => $link->getParentComponentLink()?->getId(), 'parentPieceLinkId' => $link->getParentPieceLink()?->getId(), diff --git a/src/Entity/MachineComponentLink.php b/src/Entity/MachineComponentLink.php index a815a41..e22d223 100644 --- a/src/Entity/MachineComponentLink.php +++ b/src/Entity/MachineComponentLink.php @@ -46,8 +46,12 @@ class MachineComponentLink private Machine $machine; #[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'machineLinks')] - #[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] - private Composant $composant; + #[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?Composant $composant = null; + + #[ORM\ManyToOne(targetEntity: ModelType::class)] + #[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?ModelType $modelType = null; #[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'childLinks')] #[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] @@ -107,18 +111,30 @@ class MachineComponentLink return $this; } - public function getComposant(): Composant + public function getComposant(): ?Composant { return $this->composant; } - public function setComposant(Composant $composant): static + public function setComposant(?Composant $composant): static { $this->composant = $composant; return $this; } + public function getModelType(): ?ModelType + { + return $this->modelType; + } + + public function setModelType(?ModelType $modelType): static + { + $this->modelType = $modelType; + + return $this; + } + public function getParentLink(): ?MachineComponentLink { return $this->parentLink; diff --git a/src/Entity/MachinePieceLink.php b/src/Entity/MachinePieceLink.php index efccb14..e580d03 100644 --- a/src/Entity/MachinePieceLink.php +++ b/src/Entity/MachinePieceLink.php @@ -47,8 +47,12 @@ class MachinePieceLink private Machine $machine; #[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'machineLinks')] - #[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] - private Piece $piece; + #[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?Piece $piece = null; + + #[ORM\ManyToOne(targetEntity: ModelType::class)] + #[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?ModelType $modelType = null; #[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'pieceLinks')] #[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] @@ -98,18 +102,30 @@ class MachinePieceLink return $this; } - public function getPiece(): Piece + public function getPiece(): ?Piece { return $this->piece; } - public function setPiece(Piece $piece): static + public function setPiece(?Piece $piece): static { $this->piece = $piece; return $this; } + public function getModelType(): ?ModelType + { + return $this->modelType; + } + + public function setModelType(?ModelType $modelType): static + { + $this->modelType = $modelType; + + return $this; + } + public function getParentLink(): ?MachineComponentLink { return $this->parentLink; diff --git a/src/Entity/MachineProductLink.php b/src/Entity/MachineProductLink.php index 677e5db..825b1db 100644 --- a/src/Entity/MachineProductLink.php +++ b/src/Entity/MachineProductLink.php @@ -46,8 +46,12 @@ class MachineProductLink private Machine $machine; #[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'machineLinks')] - #[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] - private Product $product; + #[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?Product $product = null; + + #[ORM\ManyToOne(targetEntity: ModelType::class)] + #[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?ModelType $modelType = null; #[ORM\ManyToOne(targetEntity: MachineProductLink::class, inversedBy: 'childLinks')] #[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] @@ -92,18 +96,30 @@ class MachineProductLink return $this; } - public function getProduct(): Product + public function getProduct(): ?Product { return $this->product; } - public function setProduct(Product $product): static + public function setProduct(?Product $product): static { $this->product = $product; return $this; } + public function getModelType(): ?ModelType + { + return $this->modelType; + } + + public function setModelType(?ModelType $modelType): static + { + $this->modelType = $modelType; + + return $this; + } + public function getParentLink(): ?MachineProductLink { return $this->parentLink; diff --git a/src/EventSubscriber/MachineAuditSubscriber.php b/src/EventSubscriber/MachineAuditSubscriber.php index d9c9476..c19272d 100644 --- a/src/EventSubscriber/MachineAuditSubscriber.php +++ b/src/EventSubscriber/MachineAuditSubscriber.php @@ -74,18 +74,20 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber foreach ($entity->getComponentLinks() as $link) { $componentLinks[] = [ 'id' => $link->getId(), - 'composantId' => $link->getComposant()->getId(), - 'composantName' => $link->getComposant()->getName(), + 'composantId' => $link->getComposant()?->getId(), + 'composantName' => $link->getComposant()?->getName(), + 'modelTypeId' => $link->getModelType()?->getId(), ]; } $pieceLinks = []; foreach ($entity->getPieceLinks() as $link) { $pieceLinks[] = [ - 'id' => $link->getId(), - 'pieceId' => $link->getPiece()->getId(), - 'pieceName' => $link->getPiece()->getName(), - 'quantity' => $link->getQuantity(), + 'id' => $link->getId(), + 'pieceId' => $link->getPiece()?->getId(), + 'pieceName' => $link->getPiece()?->getName(), + 'quantity' => $link->getQuantity(), + 'modelTypeId' => $link->getModelType()?->getId(), ]; } @@ -93,8 +95,9 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber foreach ($entity->getProductLinks() as $link) { $productLinks[] = [ 'id' => $link->getId(), - 'productId' => $link->getProduct()->getId(), - 'productName' => $link->getProduct()->getName(), + 'productId' => $link->getProduct()?->getId(), + 'productName' => $link->getProduct()?->getName(), + 'modelTypeId' => $link->getModelType()?->getId(), ]; } @@ -187,8 +190,8 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber 'machine' => $entity->getMachine(), 'diffKey' => $action.'Component', 'diffValue' => [ - 'id' => $entity->getComposant()->getId(), - 'name' => $entity->getComposant()->getName(), + 'id' => $entity->getComposant()?->getId() ?? $entity->getModelType()?->getId(), + 'name' => $entity->getComposant()?->getName() ?? $entity->getModelType()?->getName() ?? 'Catégorie seule', ], ]; } @@ -198,8 +201,8 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber 'machine' => $entity->getMachine(), 'diffKey' => $action.'Piece', 'diffValue' => [ - 'id' => $entity->getPiece()->getId(), - 'name' => $entity->getPiece()->getName(), + 'id' => $entity->getPiece()?->getId() ?? $entity->getModelType()?->getId(), + 'name' => $entity->getPiece()?->getName() ?? $entity->getModelType()?->getName() ?? 'Catégorie seule', ], ]; } @@ -209,8 +212,8 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber 'machine' => $entity->getMachine(), 'diffKey' => $action.'Product', 'diffValue' => [ - 'id' => $entity->getProduct()->getId(), - 'name' => $entity->getProduct()->getName(), + 'id' => $entity->getProduct()?->getId() ?? $entity->getModelType()?->getId(), + 'name' => $entity->getProduct()?->getName() ?? $entity->getModelType()?->getName() ?? 'Catégorie seule', ], ]; } diff --git a/src/Mcp/Tool/Machine/MachineStructureTool.php b/src/Mcp/Tool/Machine/MachineStructureTool.php index c5de485..b433eff 100644 --- a/src/Mcp/Tool/Machine/MachineStructureTool.php +++ b/src/Mcp/Tool/Machine/MachineStructureTool.php @@ -164,11 +164,11 @@ class MachineStructureTool 'id' => $link->getId(), 'linkId' => $link->getId(), 'machineId' => $link->getMachine()->getId(), - 'composantId' => $composant->getId(), - 'composant' => $this->normalizeComposant($composant), + 'composantId' => $composant?->getId(), + 'composant' => $composant ? $this->normalizeComposant($composant) : null, 'parentLinkId' => $parentLink?->getId(), 'parentComponentLinkId' => $parentLink?->getId(), - 'parentComponentId' => $parentLink?->getComposant()->getId(), + 'parentComponentId' => $parentLink?->getComposant()?->getId(), 'overrides' => $this->normalizeOverrides($link), 'childLinks' => [], 'pieceLinks' => [], @@ -189,13 +189,13 @@ class MachineStructureTool 'id' => $link->getId(), 'linkId' => $link->getId(), 'machineId' => $link->getMachine()->getId(), - 'pieceId' => $piece->getId(), - 'piece' => $this->normalizePiece($piece), + 'pieceId' => $piece?->getId(), + 'piece' => $piece ? $this->normalizePiece($piece) : null, 'parentLinkId' => $parentLink?->getId(), 'parentComponentLinkId' => $parentLink?->getId(), - 'parentComponentId' => $parentLink?->getComposant()->getId(), + 'parentComponentId' => $parentLink?->getComposant()?->getId(), 'overrides' => $this->normalizeOverrides($link), - 'quantity' => $this->resolvePieceQuantity($link), + 'quantity' => $piece ? $this->resolvePieceQuantity($link) : 1, ]; }, $links); } diff --git a/src/Service/EntityVersionService.php b/src/Service/EntityVersionService.php index 955c7fc..1fd2a6a 100644 --- a/src/Service/EntityVersionService.php +++ b/src/Service/EntityVersionService.php @@ -869,22 +869,25 @@ final class EntityVersionService $snapshot['componentLinks'] = []; foreach ($entity->getComponentLinks() as $link) { $snapshot['componentLinks'][] = [ - 'id' => $link->getId(), 'composantId' => $link->getComposant()->getId(), - 'composantName' => $link->getComposant()->getName(), + 'id' => $link->getId(), 'composantId' => $link->getComposant()?->getId(), + 'composantName' => $link->getComposant()?->getName(), + 'modelTypeId' => $link->getModelType()?->getId(), ]; } $snapshot['pieceLinks'] = []; foreach ($entity->getPieceLinks() as $link) { $snapshot['pieceLinks'][] = [ - 'id' => $link->getId(), 'pieceId' => $link->getPiece()->getId(), - 'pieceName' => $link->getPiece()->getName(), 'quantity' => $link->getQuantity(), + 'id' => $link->getId(), 'pieceId' => $link->getPiece()?->getId(), + 'pieceName' => $link->getPiece()?->getName(), 'quantity' => $link->getQuantity(), + 'modelTypeId' => $link->getModelType()?->getId(), ]; } $snapshot['productLinks'] = []; foreach ($entity->getProductLinks() as $link) { $snapshot['productLinks'][] = [ - 'id' => $link->getId(), 'productId' => $link->getProduct()->getId(), - 'productName' => $link->getProduct()->getName(), + 'id' => $link->getId(), 'productId' => $link->getProduct()?->getId(), + 'productName' => $link->getProduct()?->getName(), + 'modelTypeId' => $link->getModelType()?->getId(), ]; } }