Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1b170d87f | |||
| 0fc9daa974 | |||
| 104942a52b | |||
| c65757ee24 | |||
| 6e105fd070 | |||
| a0c4597de0 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.36'
|
||||
app.version: '1.9.39'
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
Ajouter une nouvelle machine
|
||||
</h3>
|
||||
<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="form-control">
|
||||
<label class="label">
|
||||
@@ -78,6 +91,7 @@ const props = defineProps<{
|
||||
sites: Array<{ id: string, name: string }>
|
||||
disabled: boolean
|
||||
preselectedSiteId?: string
|
||||
errorMessage?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
|
||||
export function useMachineCreatePage() {
|
||||
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
|
||||
|
||||
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local state
|
||||
@@ -27,6 +25,9 @@ export function useMachineCreatePage() {
|
||||
const submitting = ref(false)
|
||||
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({
|
||||
name: '',
|
||||
siteId: '',
|
||||
@@ -41,8 +42,10 @@ export function useMachineCreatePage() {
|
||||
const finalizeMachineCreation = async () => {
|
||||
if (submitting.value) return
|
||||
|
||||
createError.value = null
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -80,10 +83,10 @@ export function useMachineCreatePage() {
|
||||
await navigateTo('/machines')
|
||||
}
|
||||
} else if (result.error) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||
createError.value = humanizeError(result.error)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||
createError.value = humanizeError(error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -116,6 +119,7 @@ export function useMachineCreatePage() {
|
||||
machines,
|
||||
submitting,
|
||||
loading,
|
||||
createError,
|
||||
|
||||
// Actions
|
||||
finalizeMachineCreation,
|
||||
|
||||
@@ -58,7 +58,26 @@
|
||||
</option>
|
||||
</select>
|
||||
</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">
|
||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
|
||||
</label>
|
||||
@@ -66,13 +85,13 @@
|
||||
<input
|
||||
v-model="dateFrom"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
<span class="text-xs text-base-content/50">à</span>
|
||||
<input
|
||||
v-model="dateTo"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +116,7 @@
|
||||
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
||||
Ajouter un site
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
|
||||
<button class="btn btn-ghost btn-sm" @click="openAddMachineModal">
|
||||
Ajouter une machine
|
||||
</button>
|
||||
</div>
|
||||
@@ -263,7 +282,8 @@
|
||||
:sites="sites"
|
||||
:disabled="!canEdit"
|
||||
:preselected-site-id="preselectedSiteId"
|
||||
@close="showAddMachineModal = false"
|
||||
:error-message="addMachineError"
|
||||
@close="closeAddMachineModal"
|
||||
@create="handleCreateMachine"
|
||||
/>
|
||||
</main>
|
||||
@@ -293,8 +313,10 @@ const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
|
||||
// Data
|
||||
const showAddSiteModal = ref(false)
|
||||
const showAddMachineModal = ref(false)
|
||||
const addMachineError = ref(null)
|
||||
const searchTerm = ref('')
|
||||
const selectedSiteFilter = ref('')
|
||||
const sortOrder = ref('name-asc')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const collapsedSites = ref([])
|
||||
@@ -318,10 +340,33 @@ const machinesBySiteId = computed(() => {
|
||||
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(() => {
|
||||
return sites.value.map((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) => {
|
||||
addMachineError.value = null
|
||||
const result = await createMachine(data)
|
||||
|
||||
if (result.success) {
|
||||
showAddMachineModal.value = false
|
||||
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) => {
|
||||
preselectedSiteId.value = site.id
|
||||
showAddMachineModal.value = true
|
||||
openAddMachineModal()
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
</div>
|
||||
|
||||
<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-body space-y-6">
|
||||
<!-- Basic fields -->
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
||||
#[ORM\Table(name: 'machines')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_machine_name_site', columns: ['name', 'siteId'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[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(
|
||||
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: [
|
||||
@@ -45,7 +47,7 @@ class Machine
|
||||
#[Groups(['document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
#[Groups(['document:list'])]
|
||||
private string $name;
|
||||
|
||||
|
||||
@@ -30,8 +30,9 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
||||
|
||||
$constraint = $this->detectConstraintName($exception);
|
||||
$error = match ($constraint) {
|
||||
'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à.',
|
||||
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
|
||||
'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(
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Filter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Entity\ConstructeurTelephone;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
@@ -38,22 +39,27 @@ final class ConstructeurSearchFilter extends AbstractFilter
|
||||
}
|
||||
|
||||
$alias = $queryBuilder->getRootAliases()[0];
|
||||
$telAlias = $queryNameGenerator->generateJoinAlias('telephones');
|
||||
$telAlias = $queryNameGenerator->generateJoinAlias('phoneSearch');
|
||||
$paramName = $queryNameGenerator->generateParameterName('search');
|
||||
$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
|
||||
->leftJoin(sprintf('%s.telephones', $alias), $telAlias)
|
||||
->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,
|
||||
$telAlias,
|
||||
'',
|
||||
$paramName,
|
||||
$phoneSubQuery,
|
||||
))
|
||||
->setParameter($paramName, $likePattern)
|
||||
;
|
||||
|
||||
$queryBuilder->groupBy(sprintf('%s.id', $alias));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,88 @@ class MachineTest extends AbstractApiTestCase
|
||||
$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
|
||||
{
|
||||
$machine = $this->createMachine('Machine structure');
|
||||
|
||||
Reference in New Issue
Block a user