From 7836f87cd2d542db259679fedf1f98acc98c6944 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 29 May 2026 16:10:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(machines)=20:=20pi=C3=A8ce=20supprim=C3=A9e?= =?UTF-8?q?=20ne=20bloque=20plus=20la=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un lien machine_piece_links orphelin (pieceid pointant vers une pièce supprimée) faisait charger les documents via l'id du lien (GET /documents/piece/{linkId}) → 404 + toast bloquant, et la catégorie restait affichée à vide. - front : useEntityDocuments ne charge plus les documents pour un node pending (refreshDocuments + ensureDocumentsLoaded) + test - back : migration Version20260529150000 réparant les 2 FK CASCADE vers pieces (fk_mpl_piece, fk_cfv_piece) jamais appliquées par Version20260528090000, avec nettoyage des orphelins (1 mpl + 3 cfv) --- .../app/composables/useEntityDocuments.ts | 7 +- .../composables/useEntityDocuments.test.ts | 73 +++++++++ ...260529150000_AddMissingPieceCascadeFKs.php | 145 ++++++++++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/composables/useEntityDocuments.test.ts create mode 100644 migrations/Version20260529150000_AddMissingPieceCascadeFKs.php diff --git a/frontend/app/composables/useEntityDocuments.ts b/frontend/app/composables/useEntityDocuments.ts index f00d946..dbe46f8 100644 --- a/frontend/app/composables/useEntityDocuments.ts +++ b/frontend/app/composables/useEntityDocuments.ts @@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) { // CRUD operations const refreshDocuments = async () => { const e = entity() - if (!e?.id || e._structurePiece) return + // Pending / category-only nodes carry the link id (not a real entity id) and + // have no backing piece/composant — never request documents for them. + if (!e?.id || e._structurePiece || e.pendingEntity) return loadingDocuments.value = true try { const result: any = await loadDocumentsFn(e.id, { updateStore: false }) @@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) { } const ensureDocumentsLoaded = async () => { - if (documentsLoaded.value || !entity()?.id) return + const e = entity() + if (documentsLoaded.value || !e?.id || e.pendingEntity) return await refreshDocuments() } diff --git a/frontend/tests/composables/useEntityDocuments.test.ts b/frontend/tests/composables/useEntityDocuments.test.ts new file mode 100644 index 0000000..e8606d2 --- /dev/null +++ b/frontend/tests/composables/useEntityDocuments.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { useEntityDocuments } from '~/composables/useEntityDocuments' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockLoadDocumentsByPiece = vi.fn() +const mockLoadDocumentsByComponent = vi.fn() + +vi.mock('~/composables/useDocuments', () => ({ + useDocuments: () => ({ + loadDocumentsByPiece: mockLoadDocumentsByPiece, + loadDocumentsByComponent: mockLoadDocumentsByComponent, + uploadDocuments: vi.fn(), + deleteDocument: vi.fn(), + updateDocument: vi.fn(), + }), +})) + +vi.mock('~/utils/documentPreview', () => ({ + canPreviewDocument: () => true, +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +// --------------------------------------------------------------------------- +// refreshDocuments — pending / orphan entities +// --------------------------------------------------------------------------- +describe('refreshDocuments', () => { + it('does NOT load documents for a pending piece node (orphan link id is not a piece id)', async () => { + // A category-only / pending piece node: its `id` is the machinePieceLink id, + // there is no real piece behind it (pieceId is null). + const pendingNode = { + id: 'cl48179803369dd93b4a90b784', // machinePieceLink id, NOT a piece id + pieceId: null, + pendingEntity: true, + documents: [], + } + + const { refreshDocuments } = useEntityDocuments({ + entity: () => pendingNode, + entityType: 'piece', + }) + + await refreshDocuments() + + expect(mockLoadDocumentsByPiece).not.toHaveBeenCalled() + }) + + it('loads documents for a real piece node using its piece id', async () => { + mockLoadDocumentsByPiece.mockResolvedValue({ success: true, data: [] }) + + const realNode = { + id: 'clrealpieceid000000000000', + pieceId: 'clrealpieceid000000000000', + pendingEntity: false, + documents: [], + } + + const { refreshDocuments } = useEntityDocuments({ + entity: () => realNode, + entityType: 'piece', + }) + + await refreshDocuments() + + expect(mockLoadDocumentsByPiece).toHaveBeenCalledWith('clrealpieceid000000000000', { updateStore: false }) + }) +}) diff --git a/migrations/Version20260529150000_AddMissingPieceCascadeFKs.php b/migrations/Version20260529150000_AddMissingPieceCascadeFKs.php new file mode 100644 index 0000000..daa7d39 --- /dev/null +++ b/migrations/Version20260529150000_AddMissingPieceCascadeFKs.php @@ -0,0 +1,145 @@ +addSql(<<<'SQL' + INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) + SELECT + 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), + 'machine_piece_link', + l.id, + 'delete', + json_build_object( + 'id', l.id, + 'machineId', l.machineid, + 'pieceId', l.pieceid, + 'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed' + ), + NULL, + NOW() + FROM machine_piece_links l + WHERE l.pieceid IS NOT NULL + AND l.pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) + SELECT + 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), + 'custom_field_value', + v.id, + 'delete', + json_build_object( + 'id', v.id, + 'pieceId', v.pieceid, + 'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed' + ), + NULL, + NOW() + FROM custom_field_values v + WHERE v.pieceid IS NOT NULL + AND v.pieceid NOT IN (SELECT id FROM pieces) + SQL); + + // ========================================================================= + // 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette). + // ========================================================================= + $this->addSql(<<<'SQL' + DELETE FROM machine_piece_links + WHERE pieceid IS NOT NULL + AND pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + DELETE FROM custom_field_values + WHERE pieceid IS NOT NULL + AND pieceid NOT IN (SELECT id FROM pieces) + SQL); + + // ========================================================================= + // 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur + // nom historique), puis ADD CONSTRAINT avec le bon ON DELETE. + // ========================================================================= + $this->dropFksReferencingPieces('machine_piece_links', 'pieceid'); + $this->addSql(<<<'SQL' + ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece + FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE + SQL); + + $this->dropFksReferencingPieces('custom_field_values', 'pieceid'); + $this->addSql(<<<'SQL' + ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece + FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece'); + $this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece'); + } + + /** + * Drop every FK on $table.$column that references the `pieces` table, + * regardless of its historic name. Idempotent. + */ + private function dropFksReferencingPieces(string $table, string $column): void + { + $sql = <<addSql($sql); + } +}