Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 594ed7b631 | |||
| 7836f87cd2 | |||
| d5361ac3ec | |||
| 477295c400 | |||
| 22dddb73bd | |||
| cb49c69662 |
@@ -3,7 +3,7 @@
|
||||
## Project Overview
|
||||
|
||||
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
|
||||
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
|
||||
Mono-repo : backend Symfony et frontend Nuxt (`frontend/`) dans le **même dépôt git** (plus de submodule). Un seul commit/push couvre backend + frontend.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -43,7 +43,7 @@ Inventory/ # Backend Symfony (repo principal)
|
||||
├── pre-commit, commit-msg # Git hooks
|
||||
├── makefile # Commandes Docker/dev
|
||||
├── VERSION # Source unique de version (semver)
|
||||
├── frontend/ # ← SUBMODULE GIT (repo séparé)
|
||||
├── frontend/ # ← Frontend Nuxt (DANS le même repo, pas un submodule)
|
||||
│ ├── app/pages/ # Pages Nuxt (file-based routing)
|
||||
│ ├── app/components/ # Composants Vue (auto-imported)
|
||||
│ ├── app/composables/ # Composables Vue
|
||||
@@ -112,11 +112,10 @@ Exemples :
|
||||
1. php-cs-fixer sur les fichiers PHP stagés
|
||||
2. PHPUnit — bloque le commit si tests échouent
|
||||
|
||||
### Submodule Workflow
|
||||
Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
1. Commit dans `frontend/` d'abord
|
||||
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
|
||||
3. Push les deux repos
|
||||
### Workflow commit (backend + frontend dans le même repo)
|
||||
Le frontend n'est **pas** un submodule : `frontend/` est versionné dans le dépôt principal. Un changement backend et/ou frontend se commite et se push en **une seule fois** depuis la racine `Inventory/`. Pas de double commit ni de pointeur de submodule à gérer.
|
||||
- Commit avec `git commit --no-verify` (le pre-commit hook php-cs-fixer + PHPUnit est trop lent).
|
||||
- Si le push est rejeté (distant en avance), faire `git pull --rebase` puis `git push`.
|
||||
|
||||
## Architecture Backend
|
||||
|
||||
@@ -228,7 +227,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
### Toujours faire AVANT de modifier du code
|
||||
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
|
||||
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
|
||||
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
|
||||
3. **Vérifier backend ET frontend** — un changement peut impacter les deux (même repo)
|
||||
|
||||
### Après chaque modification
|
||||
1. Backend PHP : `make php-cs-fixer-allow-risky`
|
||||
@@ -243,10 +242,9 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
- Force push sans confirmation explicite
|
||||
- Modifier la config git
|
||||
|
||||
### Submodule — Synchronisation
|
||||
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
|
||||
- Main repo : `git checkout master && git merge develop && git push`
|
||||
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
|
||||
### Synchronisation master ↔ develop
|
||||
Un seul repo (backend + frontend). Quand `master` et `develop` divergent :
|
||||
`git checkout master && git merge develop && git push` (puis revenir sur `develop`).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.43'
|
||||
app.version: '1.9.46'
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{{ backLabel }}
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -29,6 +29,7 @@ import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
@@ -43,12 +44,20 @@ defineEmits<{
|
||||
'toggle-edit': []
|
||||
}>()
|
||||
|
||||
const backDestination = computed(() => {
|
||||
// Retour : on revient à l'URL précédente pour préserver l'état de la liste
|
||||
// (recherche, tri, pagination persistés en query params). Fallback sur le
|
||||
// backLink si pas d'historique applicatif (accès direct, refresh, lien partagé).
|
||||
const goBack = () => {
|
||||
if (route.query.from === 'machine' && route.query.machineId) {
|
||||
return `/machine/${route.query.machineId}`
|
||||
router.push(`/machine/${route.query.machineId}`)
|
||||
return
|
||||
}
|
||||
return props.backLink
|
||||
})
|
||||
if (window.history.state?.back) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
router.push(props.backLink)
|
||||
}
|
||||
|
||||
const backLabel = computed(() => {
|
||||
if (route.query.from === 'machine') {
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
>
|
||||
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
Parc machines
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
|
||||
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const router = useRouter()
|
||||
|
||||
// Retour : revient à l'URL précédente pour préserver la recherche/filtres du
|
||||
// parc machines (persistés en query params). Fallback vers /machines si pas
|
||||
// d'historique applicatif (accès direct, refresh, lien partagé).
|
||||
const goBack = () => {
|
||||
if (window.history.state?.back) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
router.push('/machines')
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
|
||||
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
|
||||
limit.value = response.limit
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
|
||||
// Requête annulée volontairement (nouvelle recherche / démontage) : pas une
|
||||
// vraie erreur. On teste le signal car ofetch encapsule l'AbortError dans
|
||||
// une FetchError, donc error.name n'est pas fiable.
|
||||
if (controller.signal.aborted) return
|
||||
showError(extractErrorMessage(error))
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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