Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions d1b170d87f chore : bump version to v1.9.39
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 1m20s
2026-05-27 13:45:07 +00:00
Matthieu 0fc9daa974 feat(machines) : unicité du nom de machine par site
Auto Tag Develop / tag (push) Successful in 13s
Le nom d'une machine n'est plus unique globalement mais par site :
deux machines peuvent porter le même nom sur des sites différents,
mais le doublon reste interdit sur un même site.

- Machine : contrainte composite (name, siteId) + UniqueEntity (name, site)
- UniqueConstraintSubscriber : message explicite pour uniq_machine_name_site
- Migration : drop index global sur name + create unique index (name, siteid)
- Front : message d'erreur inline explicite à la création (page + modale)
- Tests : 4 scénarios (sites différents / même site / renommage / déplacement)
2026-05-27 15:37:38 +02:00
gitea-actions 104942a52b chore : bump version to v1.9.38
Build & Push Docker Image / build (push) Successful in 2m53s
Auto Tag Develop / tag (push) Successful in 9s
2026-05-21 14:28:44 +00:00
Matthieu c65757ee24 feat(vue-ensemble) : tri alphabétique des machines par défaut + select de tri (nom/date) + harmonisation tailles des champs de filtre
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:23:40 +02:00
Matthieu 6e105fd070 chore : bump version to v1.9.37
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 10:50:20 +02:00
Matthieu a0c4597de0 fix(fournisseurs) : ConstructeurSearchFilter utilise EXISTS subquery au lieu de LEFT JOIN
Le LEFT JOIN sur telephones causait une erreur PostgreSQL 'column must appear in GROUP BY' parce que Doctrine sélectionnait aussi les colonnes des téléphones joints. EXISTS subquery corrélée évite la duplication de lignes sans introduire de GROUP BY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:49:43 +02:00
10 changed files with 245 additions and 23 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.36' app.version: '1.9.39'
@@ -5,6 +5,19 @@
Ajouter une nouvelle machine Ajouter une nouvelle machine
</h3> </h3>
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div v-if="errorMessage" class="alert alert-error mb-4" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
<span>{{ errorMessage }}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -78,6 +91,7 @@ const props = defineProps<{
sites: Array<{ id: string, name: string }> sites: Array<{ id: string, name: string }>
disabled: boolean disabled: boolean
preselectedSiteId?: string preselectedSiteId?: string
errorMessage?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -8,7 +8,6 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
export function useMachineCreatePage() { export function useMachineCreatePage() {
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
const { machines, loadMachines, createMachine, cloneMachine } = useMachines() const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
const { sites, loadSites } = useSites() const { sites, loadSites } = useSites()
const toast = useToast()
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Local state // Local state
@@ -27,6 +25,9 @@ export function useMachineCreatePage() {
const submitting = ref(false) const submitting = ref(false)
const loading = ref(true) const loading = ref(true)
/** Persistent error shown inline in the form (e.g. duplicate name on the same site). */
const createError = ref<string | null>(null)
const newMachine = reactive({ const newMachine = reactive({
name: '', name: '',
siteId: '', siteId: '',
@@ -41,8 +42,10 @@ export function useMachineCreatePage() {
const finalizeMachineCreation = async () => { const finalizeMachineCreation = async () => {
if (submitting.value) return if (submitting.value) return
createError.value = null
if (!newMachine.name?.trim()) { if (!newMachine.name?.trim()) {
toast.showError('Merci de renseigner un nom pour la machine') createError.value = 'Merci de renseigner un nom pour la machine.'
return return
} }
@@ -80,10 +83,10 @@ export function useMachineCreatePage() {
await navigateTo('/machines') await navigateTo('/machines')
} }
} else if (result.error) { } else if (result.error) {
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`) createError.value = humanizeError(result.error)
} }
} catch (error: any) { } catch (error: any) {
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`) createError.value = humanizeError(error.message)
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -116,6 +119,7 @@ export function useMachineCreatePage() {
machines, machines,
submitting, submitting,
loading, loading,
createError,
// Actions // Actions
finalizeMachineCreation, finalizeMachineCreation,
+65 -7
View File
@@ -58,7 +58,26 @@
</option> </option>
</select> </select>
</div> </div>
<div class="form-control"> <div class="form-control md:w-52">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Trier par</span>
</label>
<select v-model="sortOrder" class="select select-bordered w-full">
<option value="name-asc">
Nom (A → Z)
</option>
<option value="name-desc">
Nom (Z → A)
</option>
<option value="date-desc">
Plus récentes
</option>
<option value="date-asc">
Plus anciennes
</option>
</select>
</div>
<div class="form-control md:w-80">
<label class="label"> <label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span> <span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
</label> </label>
@@ -66,13 +85,13 @@
<input <input
v-model="dateFrom" v-model="dateFrom"
type="date" type="date"
class="input input-bordered input-sm" class="input input-bordered w-full"
> >
<span class="text-xs text-base-content/50">à</span> <span class="text-xs text-base-content/50">à</span>
<input <input
v-model="dateTo" v-model="dateTo"
type="date" type="date"
class="input input-bordered input-sm" class="input input-bordered w-full"
> >
</div> </div>
</div> </div>
@@ -97,7 +116,7 @@
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true"> <button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site Ajouter un site
</button> </button>
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true"> <button class="btn btn-ghost btn-sm" @click="openAddMachineModal">
Ajouter une machine Ajouter une machine
</button> </button>
</div> </div>
@@ -263,7 +282,8 @@
:sites="sites" :sites="sites"
:disabled="!canEdit" :disabled="!canEdit"
:preselected-site-id="preselectedSiteId" :preselected-site-id="preselectedSiteId"
@close="showAddMachineModal = false" :error-message="addMachineError"
@close="closeAddMachineModal"
@create="handleCreateMachine" @create="handleCreateMachine"
/> />
</main> </main>
@@ -293,8 +313,10 @@ const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
// Data // Data
const showAddSiteModal = ref(false) const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false) const showAddMachineModal = ref(false)
const addMachineError = ref(null)
const searchTerm = ref('') const searchTerm = ref('')
const selectedSiteFilter = ref('') const selectedSiteFilter = ref('')
const sortOrder = ref('name-asc')
const dateFrom = ref('') const dateFrom = ref('')
const dateTo = ref('') const dateTo = ref('')
const collapsedSites = ref([]) const collapsedSites = ref([])
@@ -318,10 +340,33 @@ const machinesBySiteId = computed(() => {
return map return map
}) })
const sortMachines = (machineList) => {
const list = [...machineList]
switch (sortOrder.value) {
case 'name-desc':
return list.sort((a, b) =>
(b.name || '').localeCompare(a.name || '', 'fr', { sensitivity: 'base', numeric: true })
)
case 'date-desc':
return list.sort((a, b) =>
new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
)
case 'date-asc':
return list.sort((a, b) =>
new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
)
case 'name-asc':
default:
return list.sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr', { sensitivity: 'base', numeric: true })
)
}
}
const sitesWithMachines = computed(() => { const sitesWithMachines = computed(() => {
return sites.value.map((site) => ({ return sites.value.map((site) => ({
...site, ...site,
machines: machinesBySiteId.value.get(site.id) || [] machines: sortMachines(machinesBySiteId.value.get(site.id) || [])
})) }))
}) })
@@ -406,11 +451,14 @@ const handleCreateSite = async (data) => {
} }
const handleCreateMachine = async (data) => { const handleCreateMachine = async (data) => {
addMachineError.value = null
const result = await createMachine(data) const result = await createMachine(data)
if (result.success) { if (result.success) {
showAddMachineModal.value = false showAddMachineModal.value = false
await loadMachines() await loadMachines()
} else if (result.error) {
addMachineError.value = humanizeError(result.error)
} }
} }
@@ -455,9 +503,19 @@ const confirmDeleteMachine = async (machine) => {
} }
} }
const openAddMachineModal = () => {
addMachineError.value = null
showAddMachineModal.value = true
}
const closeAddMachineModal = () => {
addMachineError.value = null
showAddMachineModal.value = false
}
const addMachineToSite = (site) => { const addMachineToSite = (site) => {
preselectedSiteId.value = site.id preselectedSiteId.value = site.id
showAddMachineModal.value = true openAddMachineModal()
} }
// Lifecycle // Lifecycle
+13
View File
@@ -20,6 +20,19 @@
</div> </div>
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation"> <form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div v-if="c.createError" class="alert alert-error" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
<span>{{ c.createError }}</span>
</div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6"> <div class="card-body space-y-6">
<!-- Basic fields --> <!-- Basic fields -->
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260527140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Machine name uniqueness is now scoped per site: drop global unique index on machines(name), add composite unique index on (name, siteid)';
}
public function up(Schema $schema): void
{
// Drop the global unique index/constraint on machines(name).
// Doctrine-generated name (CRC32 of table+column): uniq_f1ce8ded5e237e06.
// It may exist either as a constraint or as a bare index depending on origin,
// so we drop defensively in both forms.
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS uniq_f1ce8ded5e237e06');
$this->addSql('DROP INDEX IF EXISTS uniq_f1ce8ded5e237e06');
// Defensive fallbacks for other possible legacy names of the global unique index on name.
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS machines_name_key');
$this->addSql('DROP INDEX IF EXISTS machines_name_key');
// New uniqueness scope: a machine name is unique within a given site only.
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_machine_name_site ON machines (name, siteid)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS uniq_machine_name_site');
// Best-effort restore of the global unique index on machines(name).
// WARNING: this will fail if duplicate names now exist across sites (which the
// per-site scope allowed). Resolve duplicates manually before rolling back.
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f1ce8ded5e237e06 ON machines (name)');
}
}
+3 -1
View File
@@ -24,8 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MachineRepository::class)] #[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')] #[ORM\Table(name: 'machines')]
#[ORM\UniqueConstraint(name: 'uniq_machine_name_site', columns: ['name', 'siteId'])]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)] #[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
#[UniqueEntity(fields: ['name', 'site'], message: 'Une machine avec ce nom existe déjà sur ce site.')]
#[ApiResource( #[ApiResource(
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.', description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
operations: [ operations: [
@@ -45,7 +47,7 @@ class Machine
#[Groups(['document:list'])] #[Groups(['document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:list'])] #[Groups(['document:list'])]
private string $name; private string $name;
@@ -30,8 +30,9 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
$constraint = $this->detectConstraintName($exception); $constraint = $this->detectConstraintName($exception);
$error = match ($constraint) { $error = match ($constraint) {
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.', 'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
default => 'Un élément avec cette valeur existe déjà.', 'uniq_machine_name_site' => 'Une machine avec ce nom existe déjà sur ce site.',
default => 'Un élément avec cette valeur existe déjà.',
}; };
$event->setResponse(new JsonResponse( $event->setResponse(new JsonResponse(
+13 -7
View File
@@ -7,6 +7,7 @@ namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
/** /**
@@ -38,22 +39,27 @@ final class ConstructeurSearchFilter extends AbstractFilter
} }
$alias = $queryBuilder->getRootAliases()[0]; $alias = $queryBuilder->getRootAliases()[0];
$telAlias = $queryNameGenerator->generateJoinAlias('telephones'); $telAlias = $queryNameGenerator->generateJoinAlias('phoneSearch');
$paramName = $queryNameGenerator->generateParameterName('search'); $paramName = $queryNameGenerator->generateParameterName('search');
$likePattern = '%'.mb_strtolower(trim($value)).'%'; $likePattern = '%'.mb_strtolower(trim($value)).'%';
$em = $queryBuilder->getEntityManager();
$phoneSubQuery = $em->createQueryBuilder()
->select('1')
->from(ConstructeurTelephone::class, $telAlias)
->where(sprintf('%1$s.constructeur = %2$s', $telAlias, $alias))
->andWhere(sprintf('LOWER(%s.numero) LIKE :%s', $telAlias, $paramName))
->getDQL()
;
$queryBuilder $queryBuilder
->leftJoin(sprintf('%s.telephones', $alias), $telAlias)
->andWhere(sprintf( ->andWhere(sprintf(
'LOWER(%1$s.name) LIKE :%4$s OR LOWER(%1$s.email) LIKE :%4$s OR LOWER(%2$s.numero) LIKE :%4$s', 'LOWER(%1$s.name) LIKE :%2$s OR LOWER(%1$s.email) LIKE :%2$s OR EXISTS (%3$s)',
$alias, $alias,
$telAlias,
'',
$paramName, $paramName,
$phoneSubQuery,
)) ))
->setParameter($paramName, $likePattern) ->setParameter($paramName, $likePattern)
; ;
$queryBuilder->groupBy(sprintf('%s.id', $alias));
} }
} }
+82
View File
@@ -134,6 +134,88 @@ class MachineTest extends AbstractApiTestCase
$this->assertResponseStatusCodeSame(422); $this->assertResponseStatusCodeSame(422);
} }
public function testSameNameOnDifferentSitesIsAllowed(): void
{
$siteA = $this->createSite('Usine A');
$siteB = $this->createSite('Usine B');
$this->createMachine('Pompe', $siteA);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pompe',
'site' => self::iri('sites', $siteB->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Pompe']);
}
public function testSameNameOnSameSiteIsRejected(): void
{
$site = $this->createSite('Usine');
$this->createMachine('Pompe', $site);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pompe',
'site' => self::iri('sites', $site->getId()),
],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testRenameToExistingNameOnSameSiteIsRejected(): void
{
$site = $this->createSite('Usine');
$this->createMachine('Pompe', $site);
$other = $this->createMachine('Moteur', $site);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machines', $other->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Pompe'],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testMoveToSiteWhereNameExistsIsRejected(): void
{
$siteA = $this->createSite('Usine A');
$siteB = $this->createSite('Usine B');
$this->createMachine('Pompe', $siteB);
$machine = $this->createMachine('Pompe', $siteA);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machines', $machine->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => self::iri('sites', $siteB->getId())],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testGetStructureEndpoint(): void public function testGetStructureEndpoint(): void
{ {
$machine = $this->createMachine('Machine structure'); $machine = $this->createMachine('Machine structure');