fix(machines) : pièce supprimée ne bloque plus la machine
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
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)
This commit is contained in:
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
|||||||
// CRUD operations
|
// CRUD operations
|
||||||
const refreshDocuments = async () => {
|
const refreshDocuments = async () => {
|
||||||
const e = entity()
|
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
|
loadingDocuments.value = true
|
||||||
try {
|
try {
|
||||||
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
||||||
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ensureDocumentsLoaded = async () => {
|
const ensureDocumentsLoaded = async () => {
|
||||||
if (documentsLoaded.value || !entity()?.id) return
|
const e = entity()
|
||||||
|
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
|
||||||
await refreshDocuments()
|
await refreshDocuments()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repair migration for Version20260528090000_FixPieceCascadeFKs.
|
||||||
|
*
|
||||||
|
* On some environments (prod included) that migration was recorded as executed
|
||||||
|
* but two of its six FKs to `pieces.id` never took effect:
|
||||||
|
* - machine_piece_links.pieceid (fk_mpl_piece)
|
||||||
|
* - custom_field_values.pieceid (fk_cfv_piece)
|
||||||
|
* Without them, deleting a Piece leaves orphan rows behind (a stale pieceid
|
||||||
|
* pointing to a non-existent piece), which surfaces as a "Catégorie sans item"
|
||||||
|
* ghost on the machine detail page and a 404 on /documents/piece/{id}.
|
||||||
|
*
|
||||||
|
* This migration re-applies ONLY those two missing pieces of the original one:
|
||||||
|
* snapshot orphans to audit_logs, delete them, then (re)add the FK with the
|
||||||
|
* correct ON DELETE CASCADE. Fully idempotent — safe where the FKs already exist.
|
||||||
|
*/
|
||||||
|
final class Version20260529150000_AddMissingPieceCascadeFKs extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Repair missing CASCADE FKs to pieces on machine_piece_links and custom_field_values (orphan cleanup + fk_mpl_piece / fk_cfv_piece)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// =========================================================================
|
||||||
|
// 1. Audit log : snapshot des rows orphelines avant suppression.
|
||||||
|
// =========================================================================
|
||||||
|
$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_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 = <<<SQL
|
||||||
|
DO \$\$
|
||||||
|
DECLARE
|
||||||
|
fk_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR fk_name IN
|
||||||
|
SELECT tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON kcu.constraint_name = tc.constraint_name
|
||||||
|
AND kcu.table_schema = tc.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.table_name = '{$table}'
|
||||||
|
AND tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND kcu.column_name = '{$column}'
|
||||||
|
AND ccu.table_name = 'pieces'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
SQL;
|
||||||
|
$this->addSql($sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user