Compare commits

..

8 Commits

Author SHA1 Message Date
gitea-actions 22dddb73bd chore : bump version to v1.9.44
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-29 13:48:07 +00:00
Matthieu cb49c69662 fix(search) : préserver la recherche des listes au retour et ignorer les requêtes annulées
Auto Tag Develop / tag (push) Successful in 58s
- DetailHeader / MachineDetailHeader : le bouton Retour utilise router.back()
  (restaure l'URL précédente avec la query ?q=...) avec fallback sur le chemin
  nu si pas d'historique applicatif. Corrige la perte de recherche/tri/pagination
  au retour depuis une page détail (composants, produits, pièces, machines).
- ManagementView : détecte l'annulation via controller.signal.aborted au lieu de
  error.name (ofetch encapsule l'AbortError dans une FetchError), supprimant le
  toast d'erreur affiché lors d'une nouvelle recherche.
2026-05-29 15:47:06 +02:00
gitea-actions f18ae545d8 chore : bump version to v1.9.43
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 45s
2026-05-28 15:15:15 +00:00
Matthieu 3003ced157 fix(custom-fields) : protéger les flushs contre les CustomField orphelins
Auto Tag Develop / tag (push) Successful in 10s
Deux endroits accèdent à $cfv->getCustomField()->getName() à chaque flush
touchant un CustomFieldValue. Si la CustomField a été supprimée et que la
FK n'est pas en ON DELETE CASCADE, le proxy lève EntityNotFoundException
et fait crasher tout le flush (pas juste une lecture, comme dans le crash
côté MachineStructureController).

- ReferenceAutoGenerator::buildValueMap() : skip le CFV orphelin (la ref
  auto retombera proprement sur null via le check requiredFields existant).
- AbstractAuditSubscriber::trackCustomFieldValueChange() : skip l'entrée
  d'audit pour ce CFV au lieu de propager l'exception.
2026-05-28 17:15:04 +02:00
gitea-actions 2b318ce5d6 chore : bump version to v1.9.42
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 40s
2026-05-28 15:09:49 +00:00
Matthieu c10ab08803 fix(custom-fields) : forcer initializeObject pour vraiment charger le proxy
Auto Tag Develop / tag (push) Successful in 10s
Le helper ensureCustomFieldExists (commit af13dc0) appelait $cf->getId()
pour déclencher l'init du proxy, mais sur un proxy Doctrine getId() retourne
directement l'identifiant stocké dans le proxy (la clé utilisée pour le
construire) sans appeler __load(). L'EntityNotFoundException n'était donc
jamais levée dans le helper et le crash sortait quand même sur getName()
ligne 973.

Remplacement par EntityManager::initializeObject() qui appelle __load() et
propage bien l'exception. Même correction appliquée à ensurePieceExists()
dans les deux contrôleurs (le bug y était masqué par la migration FK
CASCADE/SET NULL livrée dans le commit 003e419).
2026-05-28 17:09:34 +02:00
gitea-actions 85d4726415 chore : bump version to v1.9.41
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 38s
2026-05-28 14:49:28 +00:00
Matthieu af13dc0237 fix(custom-fields) : empêche EntityNotFoundException sur CustomField orphelin
Auto Tag Develop / tag (push) Successful in 9s
Même pattern que la fix Piece (003e419) : helper ensureCustomFieldExists()
qui force l'init du proxy lazy et catch EntityNotFoundException dans
MachineStructureController::normalizeCustomFieldValues() et
CustomFieldValueController::normalizeCustomFieldValue(). Les CFV pointant
vers un CustomField supprimé sont silencieusement skippés au lieu de
crasher la vue machine entière.
2026-05-28 16:48:58 +02:00
8 changed files with 104 additions and 24 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.40' app.version: '1.9.44'
+15 -6
View File
@@ -15,10 +15,10 @@
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" /> <IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }} {{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button> </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" /> <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
{{ backLabel }} {{ backLabel }}
</NuxtLink> </button>
</div> </div>
</div> </div>
</template> </template>
@@ -29,6 +29,7 @@ import IconLucideEye from '~icons/lucide/eye'
import IconLucideArrowLeft from '~icons/lucide/arrow-left' import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const route = useRoute() const route = useRoute()
const router = useRouter()
const props = defineProps<{ const props = defineProps<{
title: string title: string
@@ -43,12 +44,20 @@ defineEmits<{
'toggle-edit': [] '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) { 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(() => { const backLabel = computed(() => {
if (route.query.from === 'machine') { if (route.query.from === 'machine') {
@@ -36,10 +36,10 @@
> >
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" /> <IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
</button> </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" /> <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
Parc machines Parc machines
</NuxtLink> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
import IconLucideArrowLeft from '~icons/lucide/arrow-left' import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const { canEdit } = usePermissions() 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<{ const props = defineProps<{
title: string title: string
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
limit.value = response.limit limit.value = response.limit
} }
catch (error: unknown) { 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)) showError(extractErrorMessage(error))
} }
finally { finally {
+28 -7
View File
@@ -298,8 +298,9 @@ class CustomFieldValueController extends AbstractController
/** /**
* Returns the Piece if its underlying row still exists in DB, otherwise null. * Returns the Piece if its underlying row still exists in DB, otherwise null.
* Forces a lazy proxy to initialize via getId() and swallows EntityNotFoundException * getId() on a Doctrine proxy does NOT trigger __load(), so we force the proxy
* so an orphan link to a deleted piece doesn't crash custom-field value writes. * to initialize explicitly to handle orphan links here instead of crashing on
* the first real getter.
*/ */
private function ensurePieceExists(?Piece $piece): ?Piece private function ensurePieceExists(?Piece $piece): ?Piece
{ {
@@ -307,7 +308,7 @@ class CustomFieldValueController extends AbstractController
return null; return null;
} }
try { try {
$piece->getId(); $this->entityManager->initializeObject($piece);
return $piece; return $piece;
} catch (EntityNotFoundException) { } catch (EntityNotFoundException) {
@@ -315,22 +316,42 @@ class CustomFieldValueController extends AbstractController
} }
} }
/**
* getId() on a Doctrine proxy returns the identifier without triggering __load(),
* so it never raises EntityNotFoundException even if the row is gone. Force the
* proxy to initialize explicitly so an orphan CFV is handled here instead of
* crashing on the first real getter.
*/
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
{
if (null === $cf) {
return null;
}
try {
$this->entityManager->initializeObject($cf);
return $cf;
} catch (EntityNotFoundException) {
return null;
}
}
private function normalizeCustomFieldValue(CustomFieldValue $value): array private function normalizeCustomFieldValue(CustomFieldValue $value): array
{ {
$customField = $value->getCustomField(); $customField = $this->ensureCustomFieldExists($value->getCustomField());
return [ return [
'id' => $value->getId(), 'id' => $value->getId(),
'value' => $value->getValue(), 'value' => $value->getValue(),
'customFieldId' => $customField->getId(), 'customFieldId' => $customField?->getId(),
'customField' => [ 'customField' => $customField ? [
'id' => $customField->getId(), 'id' => $customField->getId(),
'name' => $customField->getName(), 'name' => $customField->getName(),
'type' => $customField->getType(), 'type' => $customField->getType(),
'required' => $customField->isRequired(), 'required' => $customField->isRequired(),
'options' => $customField->getOptions(), 'options' => $customField->getOptions(),
'orderIndex' => $customField->getOrderIndex(), 'orderIndex' => $customField->getOrderIndex(),
], ] : null,
'machineId' => $value->getMachine()?->getId(), 'machineId' => $value->getMachine()?->getId(),
'composantId' => $value->getComposant()?->getId(), 'composantId' => $value->getComposant()?->getId(),
'pieceId' => $value->getPiece()?->getId(), 'pieceId' => $value->getPiece()?->getId(),
+28 -4
View File
@@ -815,8 +815,9 @@ class MachineStructureController extends AbstractController
/** /**
* Returns the Piece if its underlying row still exists in DB, otherwise null. * Returns the Piece if its underlying row still exists in DB, otherwise null.
* Forces a lazy proxy to initialize via getId() and swallows EntityNotFoundException * getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used
* so a stale FK (orphan link to a deleted piece) doesn't crash the whole machine view. * to build the proxy), so we force initialization via initializeObject() to
* surface a stale FK here instead of crashing on the first real getter.
*/ */
private function ensurePieceExists(?Piece $piece): ?Piece private function ensurePieceExists(?Piece $piece): ?Piece
{ {
@@ -824,7 +825,7 @@ class MachineStructureController extends AbstractController
return null; return null;
} }
try { try {
$piece->getId(); $this->entityManager->initializeObject($piece);
return $piece; return $piece;
} catch (EntityNotFoundException) { } catch (EntityNotFoundException) {
@@ -832,6 +833,26 @@ class MachineStructureController extends AbstractController
} }
} }
/**
* Returns the CustomField if its underlying row still exists, otherwise null.
* getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used
* to build the proxy. We force initialization explicitly so a stale FK to a
* deleted CustomField surfaces here instead of crashing on getName() later.
*/
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
{
if (null === $cf) {
return null;
}
try {
$this->entityManager->initializeObject($cf);
return $cf;
} catch (EntityNotFoundException) {
return null;
}
}
private function normalizePiece(Piece $piece): array private function normalizePiece(Piece $piece): array
{ {
$type = $piece->getTypePiece(); $type = $piece->getTypePiece();
@@ -942,7 +963,10 @@ class MachineStructureController extends AbstractController
if (!$cfv instanceof CustomFieldValue) { if (!$cfv instanceof CustomFieldValue) {
continue; continue;
} }
$cf = $cfv->getCustomField(); $cf = $this->ensureCustomFieldExists($cfv->getCustomField());
if (null === $cf) {
continue;
}
$items[] = [ $items[] = [
'id' => $cfv->getId(), 'id' => $cfv->getId(),
'value' => $cfv->getValue(), 'value' => $cfv->getValue(),
@@ -18,6 +18,7 @@ use DateTimeInterface;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork; use Doctrine\ORM\UnitOfWork;
@@ -432,7 +433,12 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return; return;
} }
$fieldName = 'customField:'.$cfv->getCustomField()->getName(); try {
$cfName = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
return;
}
$fieldName = 'customField:'.$cfName;
$diff = [$fieldName => ['from' => $from, 'to' => $to]]; $diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff); $pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
+7 -2
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Piece; use App\Entity\Piece;
use Doctrine\ORM\EntityNotFoundException;
class ReferenceAutoGenerator class ReferenceAutoGenerator
{ {
@@ -48,8 +49,12 @@ class ReferenceAutoGenerator
/** @var CustomFieldValue $cfv */ /** @var CustomFieldValue $cfv */
foreach ($entity->getCustomFieldValues() as $cfv) { foreach ($entity->getCustomFieldValues() as $cfv) {
$normalized = mb_strtoupper(trim($cfv->getValue())); try {
$map[$cfv->getCustomField()->getName()] = $normalized; $name = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
continue;
}
$map[$name] = mb_strtoupper(trim($cfv->getValue()));
} }
return $map; return $map;