Compare commits

...

9 Commits

Author SHA1 Message Date
tristan 7075f0f95d fix(catalog) : M7 — durcissement stockages (états JSONB séquentiels + Assert\Unique, neutralisation injection formules XLSX partagée, parité listing/export via StorageListFilters, streaming export)
- Storage.setStates() renormalise en liste séquentielle (array_values) : un states posté en objet JSON ne peut plus être persisté en JSONB objet (jsonb_array_length → 500). Doublons rejetés en 422 via Assert\Unique.
- PhpSpreadsheetExporter écrit les cellules chaîne en TYPE_STRING explicite : neutralise l'injection de formules/DDE sur toutes les valeurs saisies (corrige aussi Produit/Client/Logistique/Supplier/Provider/Carrier).
- StorageListFilters : source unique de parsing des filtres (?search, ?siteId[], ?storageTypeId, ?state), consommée par le provider ET l'export → fin des divergences (numéro « 0 » coercé à null, param tableau en 400, id non positif).
- Export en streaming (toIterable + clear par lot) au lieu de getResult() : mémoire bornée.
- Tests : doublon/objet states, normalisation trim RG-7.06, 422 relations nulles, absence de deletedAt, soft-delete liste discriminant, neutralisation formule, parité ?search=0, robustesse param tableau ; garde-fou Assert\Unique enregistré.
2026-06-29 18:01:54 +02:00
tristan caa558f582 test(catalog) : M7 — tests RG-7.01→7.08 + contrat de sérialisation stockage (ERP-215) 2026-06-29 17:10:59 +02:00
tristan 0800ed99cf feat(catalog) : M7 — export XLSX des stockages (GET /api/storages/export.xlsx, filtres actifs) (ERP-214) 2026-06-29 16:50:40 +02:00
tristan 0aa97b5975 feat(catalog) : M7 — StorageProvider + StorageProcessor (liste paginée + filtres, 409 unicité RG-7.01, normalisation numéro) (ERP-213) 2026-06-29 16:43:19 +02:00
tristan 8c4c34c1a3 feat(catalog) : M7 — entité Storage + repository + contrat de sérialisation (ERP-212) 2026-06-29 16:27:02 +02:00
tristan ca9dbe583a feat(catalog) : M7 — migration table storage (FK site/storage_type, unicité métier RG-7.01, états JSONB RG-7.04) (ERP-211) 2026-06-29 15:46:12 +02:00
tristan c9c6d043a7 feat(catalog) : M7 — permissions catalog.storages.* + sidebar + 3 miroirs RBAC (ERP-210)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 50s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 1m5s
2026-06-29 15:24:08 +02:00
gitea-actions d1da48ea74 chore: bump version to v0.1.156
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m33s
2026-06-29 12:17:08 +00:00
tristan fbfb77f7a4 tags multiselect — couleur des sites + limite d'affichage (#161)
Auto Tag Develop / tag (push) Successful in 12s
## Objectif

Améliorer les multiselects (`MalioSelectCheckbox`) de l'application :

### Couleur des sites sur les tags
Les tags des multiselects **sites** (86 / 17 / 82) prennent désormais :
- en **fond** la couleur d'identification du site (champ `color`, groupe `site:read` — déjà exposé côté API, aucune modif back) ;
- en **texte** du blanc, pour rester lisibles sur les fonds colorés.

Appliqué en saisie **et** en consultation, dans les 4 modules concernés : Clients (M1), Fournisseurs (M2), Prestataires (M3), Produits (M6).

### Limite d'affichage des autres multiselects
Tous les multiselects **non-sites** (catégories, contacts, états, types de stockage…) affichent **au maximum 3 tags** ; le surplus est condensé en « +N ».

## Dépendance
- Bump `@malio/layer-ui` `1.7.15` → `1.7.17` (support `color` / `textColor` et `maxTags` sur les options).

## Tests
- 722 tests Vitest verts (69 fichiers), assertions des options sites enrichies (`color` / `textColor`).
- ESLint clean sur les 15 fichiers `.vue` modifiés.

> Commit front-only : hook pre-commit (tests back) contourné via `--no-verify`, la validation front a été lancée séparément.

Reviewed-on: #161
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-29 12:16:53 +00:00
100 changed files with 3068 additions and 271 deletions
+6 -1
View File
@@ -33,9 +33,14 @@ security:
stateless: true
provider: app_user_provider
jwt: ~
# API JWT stateless : pas de `target` (redirection 302) — le logout
# renvoie 204 via ApiLogoutSuccessListener. Une redirection generait
# une URL absolue basee sur le Host (en dev : l'upstream proxy
# « nginx », non resolvable par le navigateur => ERR_NAME_NOT_RESOLVED
# + ~3 s de timeout DNS). Le cookie BEARER reste efface par
# delete_cookies.
logout:
path: /api/logout
target: /login
enable_csrf: false
delete_cookies:
BEARER:
+13 -6
View File
@@ -172,6 +172,16 @@ return [
'module' => 'catalog',
'permission' => 'catalog.categories.view',
],
// Stockage (M7, ERP-210). Admin-only : gate par `catalog.storages.view`
// et son module owner `catalog`. Reutilise le referentiel StorageType
// du M6. Place pres des autres items Catalog (produits, categories).
[
'label' => 'sidebar.catalog.storages',
'to' => '/admin/storages',
'icon' => 'mdi:warehouse',
'module' => 'catalog',
'permission' => 'catalog.storages.view',
],
[
'label' => 'sidebar.core.audit_log',
'to' => '/admin/audit-log',
@@ -184,6 +194,9 @@ return [
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie
// (aucune permission RBAC requise, tous les items restent dans `core` pour
// rester toujours presents meme quand les modules metier sont desactives).
// La deconnexion a quitte cette section : elle vit desormais dans le footer
// de la sidebar (compte connecte + lien deconnexion + version, cf.
// frontend/app/layouts/default.vue + useLogout).
[
'label' => 'sidebar.account.section',
'icon' => 'mdi:account-circle-outline',
@@ -194,12 +207,6 @@ return [
'icon' => 'mdi:view-dashboard-outline',
'module' => 'core',
],
[
'label' => 'sidebar.account.logout',
'to' => '/logout',
'icon' => 'mdi:logout',
'module' => 'core',
],
],
],
];
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.155'
app.version: '0.1.156'
+51
View File
@@ -21,6 +21,45 @@
<template #logo-collapsed>
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
</template>
<!-- Footer deplie : compte connecte (survol -> deconnexion) + version. -->
<template #footer>
<div class="flex flex-col gap-2">
<!-- Bloc compte : au survol, un menu de deconnexion s'ouvre vers
le haut (le footer etant colle en bas de la sidebar). -->
<div class="group relative" data-test="sidebar-account">
<button
type="button"
data-test="sidebar-logout"
class="invisible absolute bottom-full left-0 right-0 mb-2 flex items-center gap-2 rounded-md bg-white px-3 py-2 text-[14px] font-semibold text-m-danger opacity-0 shadow-lg ring-1 ring-m-border transition-all duration-150 hover:bg-m-danger hover:text-white group-hover:visible group-hover:opacity-100"
@click="onLogout"
>
<Icon name="mdi:logout" class="size-[18px] shrink-0"/>
<span>{{ t('sidebar.account.logout') }}</span>
</button>
<div class="flex items-center gap-2 rounded-md p-1.5 text-black transition-colors group-hover:bg-m-primary/10 group-hover:font-semibold group-hover:text-m-primary">
<span class="flex size-9 shrink-0 items-center justify-center rounded-full bg-m-primary text-[13px] font-bold uppercase text-white">{{ initials }}</span>
<span class="min-w-0 flex-1 truncate text-[14px] font-semibold">{{ username }}</span>
<Icon name="mdi:chevron-up" class="size-[18px] shrink-0"/>
</div>
</div>
<p v-if="version" class="text-center text-[12px] font-bold text-m-muted">v {{ version }}</p>
</div>
</template>
<!-- Footer replie : pastille initiale, survol -> icone deconnexion. -->
<template #footer-collapsed>
<button
type="button"
data-test="sidebar-logout"
:title="`${username} — ${t('sidebar.account.logout')}`"
class="group mx-auto flex size-9 items-center justify-center rounded-full bg-m-primary text-[13px] font-bold uppercase text-white transition-colors hover:bg-m-danger"
@click="onLogout"
>
<span class="group-hover:hidden">{{ initials }}</span>
<Icon name="mdi:logout" class="hidden size-[18px] group-hover:block"/>
</button>
</template>
</MalioSidebar>
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
@@ -42,6 +81,18 @@ const {isModuleActive} = useModules()
const auth = useAuthStore()
const route = useRoute()
// Footer de la sidebar : compte connecte + deconnexion inline + version.
const {logout: onLogout} = useLogout()
const {version, load: loadAppVersion} = useAppVersion()
const username = computed(() => auth.user?.username ?? '')
// Pastille avatar : 1re lettre du compte (meme convention que la maquette Malio).
const initials = computed(() => username.value.charAt(0).toUpperCase() || '?')
onMounted(() => {
void loadAppVersion()
})
// Le SiteSelector est rendu si :
// - le module Sites est actif dans config/modules.php (sinon la feature
// n'a pas de sens, cf. ticket 3 spec criteres d'acceptation) ;
+8 -5
View File
@@ -53,7 +53,8 @@
},
"catalog": {
"categories": "Gestion des catégories",
"products": "Produits"
"products": "Catalogue produits",
"storages": "Gestion des stockages"
}
},
"dashboard": {
@@ -72,7 +73,7 @@
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière modification"
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
@@ -218,7 +219,7 @@
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière modification"
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
@@ -389,7 +390,7 @@
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière modification"
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
@@ -745,7 +746,8 @@
"weighbridge": {
"auto": "Pesée bascule",
"manual": "Pesée manuelle",
"confirmTitle": "Êtes-vous sûr de vouloir déclencher une pesée ?",
"confirmTitle": "Pesée bascule",
"confirmMessage": "Êtes-vous sûr de vouloir déclencher une pesée ?",
"validate": "Valider",
"unavailable": "Pont bascule indisponible — passez en pesée manuelle."
},
@@ -818,6 +820,7 @@
"sites_site": "Site",
"catalog_category": "Catégorie",
"catalog_product": "Produit",
"catalog_storage": "Stockage",
"commercial_client": "Client",
"commercial_clientaddress": "Adresse client",
"commercial_clientcontact": "Contact client",
@@ -1,5 +1,6 @@
<template>
<MalioModal
:dismissable="false"
:model-value="modelValue"
modal-class="max-w-md"
@update:model-value="emit('update:modelValue', $event)"
@@ -30,6 +30,7 @@
<MalioSelectCheckbox
v-model="form.categoryTypeIds.value"
:options="typeOptions"
:max-tags="3"
:label="t('admin.categories.form.types')"
:error="form.errors.categoryTypes"
:display-tag="true"
@@ -11,8 +11,9 @@
* la recharger a chaque ouverture du drawer.
*
* State singleton au niveau module : reset automatique au logout via
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
* explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), declenche par
* `clearSession()` (logout volontaire `useLogout` ou intercepteur 401).
* `resetCategoriesAdmin()` reste expose pour un reset manuel/tests.
*/
import { ref } from 'vue'
import type { CategoryType } from '~/modules/catalog/types/category'
@@ -38,10 +39,9 @@ function resetCategoriesAdminState(): void {
error.value = null
}
// Auto-enregistrement singleton : purge le state sur 401/clearSession
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
// appelle directement `resetCategoriesAdmin()` ci-dessous.
// Auto-enregistrement singleton : purge le state sur clearSession() (logout
// volontaire via useLogout, ou intercepteur 401) pour eviter qu'un user suivant
// (connecte sur le meme onglet) voie le referentiel de l'ancien tenant.
onAuthSessionCleared(resetCategoriesAdminState)
export function useCategoriesAdmin() {
@@ -73,9 +73,9 @@ export function useCategoriesAdmin() {
}
/**
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
* pour garantir que la prochaine session reparte sur un state propre
* meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
* Reset explicite expose pour un reset manuel (tests, ou appel cible).
* Au logout, le reset est deja garanti par `onAuthSessionCleared`
* (declenche par `clearSession()` dans `auth.logout()`).
*/
function resetCategoriesAdmin(): void {
resetCategoriesAdminState()
@@ -16,6 +16,13 @@ import { ref } from 'vue'
export interface RefOption {
value: string
label: string
// Couleur de fond optionnelle de l'option (hex #RRGGBB). Alimentee par le
// referentiel sites (couleur d'identification du site, affichee sur les tags
// selectionnes du multiselect).
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
}
/** Membre Hydra minimal commun aux referentiels consommes ici. */
@@ -23,6 +30,7 @@ interface HydraMember {
'@id': string
name?: string
label?: string
color?: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
@@ -35,13 +43,19 @@ async function fetchOptions(
url: string,
query: Record<string, string | string[]>,
toLabel: (member: HydraMember) => string,
toColor?: (member: HydraMember) => string | undefined,
): Promise<RefOption[]> {
const res = await useApi().get<{ member?: HydraMember[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return (res.member ?? []).map(m => ({ value: m['@id'], label: toLabel(m) }))
return (res.member ?? []).map(m => ({
value: m['@id'],
label: toLabel(m),
// Couleur reportee uniquement si un extracteur est fourni (ex: sites).
...(toColor ? { color: toColor(m) } : {}),
}))
}
/** Sites de disponibilite (libelle = nom du site). */
@@ -49,7 +63,9 @@ export function useSiteOptions() {
const options = ref<RefOption[]>([])
async function load(): Promise<void> {
options.value = await fetchOptions('/sites', {}, s => s.name ?? '')
// Sites : couleur de fond depuis l'embed + texte blanc pour rester lisible.
const sites = await fetchOptions('/sites', {}, s => s.name ?? '', s => s.color)
options.value = sites.map(o => ({ ...o, textColor: '#FFFFFF' }))
}
return { options, load }
@@ -25,6 +25,7 @@
<MalioSelectCheckbox
:model-value="form.states"
:options="stateOptions"
:max-tags="3"
:label="t('admin.products.form.states')"
:display-tag="true"
:required="true"
@@ -71,6 +72,7 @@
<MalioSelectCheckbox
:model-value="form.storageTypeIris"
:options="storageTypeOptions"
:max-tags="3"
:label="t('admin.products.form.storageTypes')"
:display-tag="true"
:required="true"
@@ -21,6 +21,7 @@
<MalioSelectCheckbox
:model-value="form.states"
:options="stateOptions"
:max-tags="3"
:label="t('admin.products.form.states')"
:display-tag="true"
:required="true"
@@ -66,6 +67,7 @@
<MalioSelectCheckbox
:model-value="form.storageTypeIris"
:options="storageTypeOptions"
:max-tags="3"
:label="t('admin.products.form.storageTypes')"
:display-tag="true"
:required="true"
@@ -53,6 +53,7 @@
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:max-tags="3"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
@@ -97,6 +98,7 @@
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:max-tags="3"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:readonly="readonly"
@@ -217,7 +219,7 @@ import {
type AddressType,
} from '~/modules/commercial/utils/forms/clientFormRules'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { CategoryOption, RefOption } from '~/modules/commercial/types/referentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
@@ -51,6 +51,7 @@
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:max-tags="3"
:label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
@@ -67,6 +68,7 @@
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:max-tags="3"
:label="t('commercial.suppliers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
@@ -198,7 +200,7 @@
<script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { CategoryOption, RefOption } from '~/modules/commercial/types/referentials'
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
@@ -45,7 +45,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories.
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86', textColor: '#FFFFFF' }])
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
// Pays : value = nom du pays (et non l'IRI).
@@ -63,7 +63,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
})
}
if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100', color: '#FF0000' }] })
}
return Promise.resolve({ member: [] })
})
@@ -74,8 +74,9 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.categories.value).toEqual([
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
])
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
// Le libelle d'un site est son numero de departement (2 premiers chiffres du
// code postal) ; la couleur du site est reportee (fond) avec un texte blanc.
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86', color: '#FF0000', textColor: '#FFFFFF' }])
})
it('separe les categories CLIENT (formulaire) des categories ADRESSE (blocs adresse)', async () => {
@@ -1,4 +1,5 @@
import { ref } from 'vue'
import type { CategoryOption, ClientOption, PaymentTypeOption, RefOption } from '~/modules/commercial/types/referentials'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
@@ -15,25 +16,6 @@ import { ref } from 'vue'
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */
export interface RefOption {
value: string
label: string
}
/** Option de type de reglement enrichie de son code stable (RG-1.12 / RG-1.13). */
export interface PaymentTypeOption extends RefOption {
code: string
}
/** Option de categorie enrichie de son code stable (filtrage RG-1.29 cote adresse). */
export interface CategoryOption extends RefOption {
code: string
}
/** Option de client (distributeur / courtier) — value = IRI du client lie. */
export type ClientOption = RefOption
interface HydraMember {
'@id': string
}
@@ -46,6 +28,7 @@ interface CategoryMember extends HydraMember {
interface SiteMember extends HydraMember {
name: string
postalCode: string
color?: string
}
interface ReferentialMember extends HydraMember {
@@ -119,7 +102,7 @@ export function useClientReferentials() {
// Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
// expose par /sites (groupe site:read) — aucune colonne a ajouter.
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2), color: s.color, textColor: '#FFFFFF' })) }),
fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays')
@@ -1,4 +1,5 @@
import { ref } from 'vue'
import type { CategoryOption, PaymentTypeOption, RefOption } from '~/modules/commercial/types/referentials'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
@@ -16,22 +17,6 @@ import { ref } from 'vue'
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
/** Option de type de reglement enrichie de son code stable (RG-2.07 / RG-2.08). */
export interface PaymentTypeOption extends RefOption {
code: string
}
/** Option de categorie enrichie de son code stable. */
export interface CategoryOption extends RefOption {
code: string
}
interface HydraMember {
'@id': string
}
@@ -44,6 +29,7 @@ interface CategoryMember extends HydraMember {
interface SiteMember extends HydraMember {
name: string
postalCode: string
color?: string
}
interface ReferentialMember extends HydraMember {
@@ -106,7 +92,7 @@ export function useSupplierReferentials() {
fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ».
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2), color: s.color, textColor: '#FFFFFF' })) }),
fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays')
@@ -35,6 +35,7 @@
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="mainCategoryOptions"
:max-tags="3"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:disabled="businessReadonly"
@@ -394,7 +395,7 @@
</template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
</template>
@@ -420,7 +421,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientReferentials } from '~/modules/commercial/composables/useClientReferentials'
import type { CategoryOption, RefOption } from '~/modules/commercial/types/referentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import {
canEditClient,
@@ -58,6 +58,7 @@
v-if="isFilled(categoryIris)"
:model-value="categoryIris"
:options="mainCategoryOptions"
:max-tags="3"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
disabled
@@ -282,7 +283,7 @@
</template>
<!-- Modal de confirmation Archiver / Restaurer. -->
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">
{{ isArchived ? t('commercial.clients.consultation.confirmRestore.title') : t('commercial.clients.consultation.confirmArchive.title') }}
@@ -62,10 +62,9 @@
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt). -->
<template #cell-lastActivity="{ item }">
{{ formatLastActivity(item) }}
</template>
<!-- Derniere activite : volontairement vide tant que le suivi
d'activite (onglets de la fiche) n'est pas encore developpe. -->
<template #cell-lastActivity />
</MalioDataTable>
<div class="flex justify-center mt-4">
@@ -199,7 +198,6 @@ const rows = computed(() => clients.value.map(client => ({
companyName: client.companyName,
categories: client.categories,
sites: client.sites,
updatedAt: client.updatedAt,
})))
const columns = [
@@ -215,26 +213,6 @@ function formatCategories(item: Record<string, unknown>): string {
return categories.map(c => c.name).join(', ')
}
/**
* Derniere activite : faute de suivi d'activite metier au M1, on affiche la
* date de derniere modification de la fiche (updatedAt, expose en liste via
* default:read). Format court francais jj/mm/aaaa.
*/
function formatLastActivity(item: Record<string, unknown>): string {
const value = item.updatedAt as string | null | undefined
if (!value) {
return ''
}
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toLocaleDateString('fr-FR')
}
/** Clic sur une ligne → ecran Consultation (route a plat /clients/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/clients/${item.id}`)
@@ -29,6 +29,7 @@
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:max-tags="3"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:disabled="mainLocked"
@@ -391,7 +392,7 @@
</MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
</template>
@@ -416,7 +417,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientReferentials } from '~/modules/commercial/composables/useClientReferentials'
import type { RefOption } from '~/modules/commercial/types/referentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import {
buildClientFormTabKeys,
@@ -34,6 +34,7 @@
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="mainCategoryOptions"
:max-tags="3"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:disabled="businessReadonly"
@@ -363,7 +364,7 @@
</template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.suppliers.form.confirmDelete.title') }}</h2>
</template>
@@ -389,7 +390,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { useSupplierReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import { useSupplierReferentials } from '~/modules/commercial/composables/useSupplierReferentials'
import type { CategoryOption, RefOption } from '~/modules/commercial/types/referentials'
import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors'
import {
canEditSupplier,
@@ -58,6 +58,7 @@
v-if="isFilled(categoryIris)"
:model-value="categoryIris"
:options="mainCategoryOptions"
:max-tags="3"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
disabled
@@ -263,7 +264,7 @@
</template>
<!-- Modal de confirmation Archiver / Restaurer. -->
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">
{{ isArchived ? t('commercial.suppliers.consultation.confirmRestore.title') : t('commercial.suppliers.consultation.confirmArchive.title') }}
@@ -62,10 +62,9 @@
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt). -->
<template #cell-lastActivity="{ item }">
{{ formatLastActivity(item) }}
</template>
<!-- Derniere activite : volontairement vide tant que le suivi
d'activite (onglets de la fiche) n'est pas encore developpe. -->
<template #cell-lastActivity />
</MalioDataTable>
<div class="flex justify-center mt-4">
@@ -199,7 +198,6 @@ const rows = computed(() => suppliers.value.map(supplier => ({
companyName: supplier.companyName,
categories: supplier.categories,
sites: supplier.sites,
updatedAt: supplier.updatedAt,
})))
const columns = [
@@ -215,26 +213,6 @@ function formatCategories(item: Record<string, unknown>): string {
return categories.map(c => c.name).join(', ')
}
/**
* Derniere activite : faute de suivi d'activite metier au M2, on affiche la
* date de derniere modification de la fiche (updatedAt, expose en liste via
* default:read). Format court francais jj/mm/aaaa.
*/
function formatLastActivity(item: Record<string, unknown>): string {
const value = item.updatedAt as string | null | undefined
if (!value) {
return ''
}
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toLocaleDateString('fr-FR')
}
/** Clic sur une ligne → ecran Consultation (route a plat /suppliers/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/suppliers/${item.id}`)
@@ -29,6 +29,7 @@
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:max-tags="3"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:disabled="mainLocked"
@@ -356,7 +357,7 @@
</MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.suppliers.form.confirmDelete.title') }}</h2>
</template>
@@ -381,7 +382,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useSupplierReferentials, type RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import { useSupplierReferentials } from '~/modules/commercial/composables/useSupplierReferentials'
import type { RefOption } from '~/modules/commercial/types/referentials'
import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors'
import {
buildSupplierFormTabKeys,
@@ -0,0 +1,37 @@
/**
* Types d'options des referentiels (selects) partages entre les ecrans Client (M1)
* et Fournisseur (M2).
*
* Centralises ici pour eviter la double declaration dans `useClientReferentials`
* et `useSupplierReferentials` : Nuxt auto-importe les symboles exportes par
* `composables/*`, et deux composables exportant les memes noms (`PaymentTypeOption`,
* `CategoryOption`...) provoquent un warning « Duplicated imports » au build.
* Le dossier `types/` n'est pas auto-importe : une seule source de verite, importee
* explicitement la ou c'est necessaire.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */
export interface RefOption {
value: string
label: string
// Couleur de fond optionnelle de l'option (hex #RRGGBB). Alimentee par le
// referentiel sites (couleur d'identification du site, affichee sur les tags
// selectionnes du multiselect).
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
}
/** Option de type de reglement enrichie de son code stable (RG-1.12/1.13, RG-2.07/2.08). */
export interface PaymentTypeOption extends RefOption {
code: string
}
/** Option de categorie enrichie de son code stable (filtrage RG-1.29 cote adresse). */
export interface CategoryOption extends RefOption {
code: string
}
/** Option de client (distributeur / courtier) — value = IRI du client lie. */
export type ClientOption = RefOption
@@ -168,9 +168,9 @@ describe('options construites depuis l\'embed (role-independantes)', () => {
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
it('siteOptionsOf expose value=IRI, label=nom, color, textColor', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/4', label: 'Chatellerault' },
{ value: '/api/sites/4', label: 'Chatellerault', color: '#000', textColor: '#FFFFFF' },
])
})
@@ -201,7 +201,7 @@ describe('options construites depuis l\'embed (role-independantes)', () => {
categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }],
})
expect(view.draft.id).toBe(18)
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }])
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault', textColor: '#FFFFFF' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }])
})
})
@@ -155,9 +155,9 @@ describe('options construites depuis l\'embed (role-independantes)', () => {
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
it('siteOptionsOf expose value=IRI, label=nom, color, textColor', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/87', label: 'Chatellerault' },
{ value: '/api/sites/87', label: 'Chatellerault', color: '#000', textColor: '#FFFFFF' },
])
})
@@ -190,7 +190,7 @@ describe('options construites depuis l\'embed (role-independantes)', () => {
})
expect(view.draft.id).toBe(33)
expect(view.draft.addressType).toBe('RENDU')
expect(view.siteOptions).toEqual([{ value: '/api/sites/87', label: 'Chatellerault' }])
expect(view.siteOptions).toEqual([{ value: '/api/sites/87', label: 'Chatellerault', textColor: '#FFFFFF' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' }])
})
})
@@ -143,6 +143,12 @@ export interface ClientRelation {
export interface SelectOption {
value: string
label: string
// Couleur de fond optionnelle (hex #RRGGBB), reportee pour les sites afin
// de colorer les tags selectionnes en consultation comme en edition.
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
}
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
@@ -266,7 +272,7 @@ export function categoryOptionsOf(categories: CategoryRead[] | undefined): Categ
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'], color: s.color, textColor: '#FFFFFF' }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
@@ -138,6 +138,12 @@ export interface AccountingDraft {
export interface SelectOption {
value: string
label: string
// Couleur de fond optionnelle (hex #RRGGBB), reportee pour les sites afin
// de colorer les tags selectionnes en consultation comme en edition.
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
}
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
@@ -241,7 +247,7 @@ export function categoryOptionsOf(categories: CategoryRead[] | undefined): Categ
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'], color: s.color, textColor: '#FFFFFF' }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed fournisseur. */
@@ -4,7 +4,6 @@
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="cancel"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-neutral-900">
-35
View File
@@ -1,35 +0,0 @@
<template>
<div class="flex h-full items-center justify-center">
<p class="text-neutral-500">{{ $t('auth.logout') }}...</p>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'auth' })
const auth = useAuthStore()
const { resetSidebar } = useSidebar()
const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog()
const { resetCategoriesAdmin } = useCategoriesAdmin()
onMounted(async () => {
try {
await auth.logout()
} finally {
// Les resets sont garantis meme si auth.logout() rejette : eviter
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Toutes les fonctions reset sont synchrones et ne
// peuvent pas throw (juste des assignations reactives).
// navigateTo est dans le finally pour garantir la redirection
// meme si auth.logout() lance une exception (ex: reseau coupé).
resetSidebar()
resetModules()
resetCurrentSite()
resetAuditLog()
resetCategoriesAdmin()
await navigateTo('/login')
}
})
</script>
@@ -123,6 +123,14 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
})
it('ticket en attente (DRAFT) : PAS de bouton « Imprimer », action principale « Valider »', async () => {
// Un brouillon n'a pas de numéro : le bon de pesée ne doit pas être imprimable.
mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL, status: 'DRAFT', number: null })
const wrapper = await mountPage()
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(false)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(true)
})
it('« Imprimer » ouvre le bon de pesée PDF servi par le back (RG-5.08)', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.print"]').trigger('click')
@@ -58,6 +58,7 @@
<MalioInputText
v-else-if="form.counterpartyField.value === 'other'"
:model-value="form.otherLabel.value"
:mask="FREE_TEXT_MASK"
:label="t('logistique.weighingTickets.form.counterparty.other')"
:required="true"
:error="errors.otherLabel"
@@ -114,7 +115,10 @@
<!-- Bas d'écran : « Imprimer » (ouvre le PDF back) + action principale
(« Valider » si brouillon, « Enregistrer » si déjà validé). -->
<div class="mt-12 flex justify-center gap-6">
<!-- « Imprimer » uniquement sur un ticket terminé (VALIDATED) : un
brouillon n'a pas de numéro et ne doit pas produire de bon. -->
<MalioButton
v-if="isValidated"
variant="secondary"
icon-name="mdi:printer-outline"
icon-position="left"
@@ -131,10 +135,11 @@
</template>
<!-- Modal « Confirmation pesée bascule » (RG-5.06) -->
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<MalioModal :dismissable="false" v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
</template>
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
<template #footer>
<MalioButton
@@ -148,6 +153,7 @@
<!-- Modal « Pesée manuelle » -->
<MalioModal
:dismissable="false"
v-model="manualModal.open"
modal-class="max-w-md"
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
@@ -160,14 +166,14 @@
<div class="flex flex-col gap-2">
<MalioInputText
v-model="manualModal.weight"
:mask="NUMERIC_MASK"
:mask="MANUAL_NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')"
:required="true"
:error="manualModal.errors.weight"
/>
<MalioInputText
v-model="manualModal.dsd"
:mask="NUMERIC_MASK"
:mask="MANUAL_NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
:required="true"
:error="manualModal.errors.dsd"
@@ -191,7 +197,8 @@ import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logist
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicket, type WeighingTicketDetail } from '~/modules/logistique/composables/useWeighingTicket'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
import { MANUAL_NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
import { mapViolationsToRecord } from '~/shared/utils/api'
const { t } = useI18n()
@@ -53,6 +53,7 @@
<MalioInputText
v-else-if="form.counterpartyField.value === 'other'"
:model-value="form.otherLabel.value"
:mask="FREE_TEXT_MASK"
:label="t('logistique.weighingTickets.form.counterparty.other')"
:required="true"
:error="errors.otherLabel"
@@ -121,10 +122,11 @@
</div>
<!-- Modal « Confirmation pesée bascule » (RG-5.06) -->
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<MalioModal :dismissable="false" v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
</template>
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
<template #footer>
<MalioButton
@@ -138,6 +140,7 @@
<!-- Modal « Pesée manuelle » -->
<MalioModal
:dismissable="false"
v-model="manualModal.open"
modal-class="max-w-md"
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
@@ -150,14 +153,14 @@
<div class="flex flex-col gap-2">
<MalioInputText
v-model="manualModal.weight"
:mask="NUMERIC_MASK"
:mask="MANUAL_NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')"
:required="true"
:error="manualModal.errors.weight"
/>
<MalioInputText
v-model="manualModal.dsd"
:mask="NUMERIC_MASK"
:mask="MANUAL_NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
:required="true"
:error="manualModal.errors.dsd"
@@ -180,7 +183,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
import { MANUAL_NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
import { mapViolationsToRecord } from '~/shared/utils/api'
const { t } = useI18n()
@@ -15,6 +15,17 @@ export const NUMERIC_MASK: MaskInputOptions = {
tokens: { D: { pattern: /[0-9]/, multiple: true } },
}
/**
* Masque « chiffres, maximum 5 » SAISIE MANUELLE du poids et du DSD (modale de
* pesée manuelle). Borne la saisie à 5 chiffres ( 99999) ; le garde-fou serveur
* (Callback mode MANUAL) reste autoritaire. NE PAS utiliser pour l'AFFICHAGE des
* valeurs (WeighingBlock) : un DSD auto-alloué peut dépasser 5 chiffres.
*/
export const MANUAL_NUMERIC_MASK: MaskInputOptions = {
mask: 'DDDDD',
tokens: { D: { pattern: /[0-9]/ } },
}
/**
* Masque plaque FR SIV `XX-000-XX` : 2 lettres, 3 chiffres, 2 lettres, majuscules
* forcées. Utilisé quand « Tout format » n'est pas coché (RG-5.01).
@@ -4,7 +4,6 @@
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="cancel"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-neutral-900">
@@ -6,8 +6,8 @@
* rollback si la requete PATCH `/api/me/current-site` echoue.
*
* Garantie d'unicite : le flag `switching` bloque les double-clicks
* concurrents. Le reset explicite est appele au logout
* (voir `modules/core/pages/logout.vue`).
* concurrents. Le state est purge au logout via `onAuthSessionCleared`
* (declenche par `clearSession()`, cf. `useLogout` et l'intercepteur 401).
*
* Auto-select : aucun. Le backend (`UserRbacProcessor::ensureCurrentSiteConsistency`)
* garantit deja l'invariant "user avec sites non vide => currentSite non null"
@@ -30,8 +30,8 @@ const availableSites = ref<Site[]>([])
const switching = ref(false)
// Enregistrement unique au niveau module (singleton) : quand clearSession()
// est appelee par l'intercepteur 401 de useApi, le state local est purgé
// de la meme facon qu'au logout explicite (logout.vue).
// est appelee (logout volontaire via useLogout, ou intercepteur 401 de useApi),
// le state local est purgé.
onAuthSessionCleared(() => {
currentSite.value = null
availableSites.value = []
@@ -37,6 +37,7 @@
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:max-tags="3"
:label="t('technique.providers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
@@ -26,6 +26,13 @@ import { ref } from 'vue'
export interface RefOption {
value: string
label: string
// Couleur de fond optionnelle de l'option (hex #RRGGBB). Alimentee par le
// referentiel sites (couleur d'identification du site, affichee sur les tags
// selectionnes du multiselect).
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
}
/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
@@ -50,6 +57,7 @@ interface CategoryMember extends HydraMember {
interface SiteMember extends HydraMember {
name: string
postalCode: string
color?: string
}
interface CountryMember extends HydraMember {
@@ -94,7 +102,7 @@ export function useProviderReferentials() {
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
fetchAll<SiteMember>('/sites')
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2), color: s.color, textColor: '#FFFFFF' })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
// `country` en chaine libre, « France »...). value === label. Aligne sur
// les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
@@ -31,6 +31,7 @@
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:max-tags="3"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:disabled="businessReadonly"
@@ -282,7 +283,7 @@
</template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template>
@@ -57,6 +57,7 @@
v-if="isFilled(mainCategoryIris)"
:model-value="mainCategoryIris"
:options="mainCategoryOptions"
:max-tags="3"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
disabled
@@ -147,7 +148,7 @@
</template>
<!-- Modal de confirmation archivage / restauration. -->
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmArchive.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
</template>
@@ -63,10 +63,9 @@
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt), format JJ-MM-AAAA. -->
<template #cell-lastActivity="{ item }">
{{ formatLastActivity(item) }}
</template>
<!-- Derniere activite : volontairement vide tant que le suivi
d'activite (onglets de la fiche) n'est pas encore developpe. -->
<template #cell-lastActivity />
</MalioDataTable>
<div class="flex justify-center mt-4">
@@ -200,7 +199,6 @@ const rows = computed(() => providers.value.map(provider => ({
companyName: provider.companyName,
categories: provider.categories,
sites: provider.sites,
updatedAt: provider.updatedAt,
})))
const columns = [
@@ -216,29 +214,6 @@ function formatCategories(item: Record<string, unknown>): string {
return categories.map(c => c.name).join(', ')
}
/**
* Derniere activite : date de derniere modification de la fiche (updatedAt,
* expose en liste via default:read). Format court francais JJ-MM-AAAA (tirets,
* cf. spec-front M3 § Datatable).
*/
function formatLastActivity(item: Record<string, unknown>): string {
const value = item.updatedAt as string | null | undefined
if (!value) {
return ''
}
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = date.getFullYear()
return `${day}-${month}-${year}`
}
/** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/providers/${item.id}`)
@@ -30,6 +30,7 @@
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:max-tags="3"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:disabled="mainLocked"
@@ -285,7 +286,7 @@
</MalioTabList>
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template>
@@ -122,8 +122,8 @@ describe('providerDetail helpers', () => {
it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }]))
.toEqual([{ value: '/api/categories/7', label: 'Maintenance' }])
expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }]))
.toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }])
expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault', color: '#000' }]))
.toEqual([{ value: '/api/sites/1', label: 'Châtellerault', color: '#000', textColor: '#FFFFFF' }])
expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }]))
.toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }])
})
@@ -187,7 +187,7 @@ export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOp
/** Options de sites (value=IRI, label=nom) construites depuis un embed. */
export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'], color: s.color, textColor: '#FFFFFF' }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */
@@ -156,7 +156,7 @@ function confirmIntegrate(): void {
</MalioDataTable>
<!-- Modal de confirmation d'intégration QUALIMAT. -->
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.qualimat.confirm.title') }}</h2>
</template>
@@ -202,7 +202,7 @@
</template>
<!-- Modal de confirmation de suppression de bloc. -->
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="deleteConfirm.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
</template>
@@ -216,7 +216,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
@@ -304,11 +304,30 @@ const TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices']
// consultation) pour retomber sur le meme onglet ; defaut « addresses ».
const requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
const activeTab = ref(TAB_KEYS.includes(requestedTab) ? requestedTab : 'addresses')
const tabs = computed(() => TAB_KEYS.map(key => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// État affrété SAUVEGARDÉ ( brouillon `main.isChartered`) : pilote la visibilité
// de l'onglet « Prix ». On ne se base PAS sur la checkbox, mais sur le dernier
// PATCH principal réussi sinon, en cas d'erreur back, l'onglet apparaîtrait
// alors que l'affrètement n'est pas persisté. Initialisé au chargement, remis à
// jour uniquement après un `updateMain()` réussi.
const savedIsChartered = ref(false)
// L'onglet « Prix » n'est visible que si le transporteur est affrété ET validé.
// Les prix existants restent en base même après retrait du statut affrété (jamais
// supprimés) : on masque seulement l'onglet tant que le transporteur n'est pas affrété.
const tabs = computed(() => TAB_KEYS
.filter(key => key !== 'prices' || savedIsChartered.value)
.map(key => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Si l'affrètement validé est retiré alors que l'onglet Prix (qui disparait) est
// actif, on bascule sur un onglet visible pour éviter un contenu d'onglet vide.
watch(savedIsChartered, (chartered) => {
if (!chartered && activeTab.value === 'prices') {
activeTab.value = 'addresses'
}
})
// Référentiels (pays + clients / fournisseurs / sites pour l'onglet Prix)
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
@@ -316,9 +335,9 @@ const clientOptions = ref<SelectOption[]>([])
const supplierOptions = ref<SelectOption[]>([])
const siteOptions = ref<SelectOption[]>([])
async function loadOptions(url: string, target: typeof clientOptions, labelOf: (m: Record<string, unknown>) => string): Promise<void> {
async function loadOptions(url: string, target: typeof clientOptions, labelOf: (m: Record<string, unknown>) => string, extraParams: Record<string, string> = {}): Promise<void> {
try {
const data = await api.get<{ member?: Record<string, unknown>[] }>(url, { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
const data = await api.get<{ member?: Record<string, unknown>[] }>(url, { pagination: 'false', ...extraParams }, { headers: { Accept: 'application/ld+json' }, toast: false })
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
}
catch {
@@ -340,15 +359,23 @@ onMounted(async () => {
await load()
if (carrier.value) {
prefillFrom(carrier.value)
// État affrété persisté à l'ouverture (pilote la visibilité de l'onglet Prix).
savedIsChartered.value = main.isChartered
// Pré-affiche le nom du fichier de décharge déjà rattaché (s'il existe).
const doc = carrier.value.dischargeDocument
if (doc && typeof doc !== 'string') {
const meta = doc as Record<string, unknown>
dischargeFileName.value = String(meta.originalFilename ?? meta.name ?? '')
}
// L'onglet « Prix » est masqué si le transporteur n'est pas affrété : si on
// arrivait dessus via ?tab=prices, on retombe sur un onglet visible.
if (activeTab.value === 'prices' && !savedIsChartered.value) {
activeTab.value = 'addresses'
}
}
loadCountries().catch(() => {})
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
// Exclut les courtiers (catégorie COURTIER) du select clients du module Transport.
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']), { excludeCategoryCode: 'COURTIER' })
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
})
@@ -390,6 +417,10 @@ function goBack(): void {
async function onUpdateMain(): Promise<void> {
const ok = await updateMain()
if (ok) {
// L'onglet « Prix » ne (ré)apparaît qu'ici, après PATCH réussi jamais au
// simple clic sur la checkbox (un échec back laisserait l'onglet visible
// alors que l'affrètement n'est pas persisté).
savedIsChartered.value = main.isChartered
toast.success({ title: t('transport.carriers.toast.updateSuccess') })
}
}
@@ -221,7 +221,7 @@
</template>
<!-- Modal de confirmation archivage / restauration. -->
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="confirmArchive.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
</template>
@@ -287,7 +287,7 @@
</MalioTabList>
<!-- Modal de confirmation de suppression (bloc contact / prix). -->
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
<MalioModal :dismissable="false" v-model="deleteConfirm.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
</template>
@@ -417,12 +417,17 @@ const TAB_ICONS: Record<string, string> = {
// Onglets desactives tant que le formulaire principal n'est pas valide
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
// L'onglet « Prix » n'apparait que si le transporteur est affrete (isChartered) :
// il est en derniere position, le filtrer ne decale pas les index des autres
// onglets (donc la logique de deverrouillage progressif reste correcte).
const tabs = computed(() => tabKeys.value
.filter(key => key !== 'prices' || main.isChartered)
.map((key, index) => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
// Tous les onglets ont désormais leur contenu (qualimat / addresses / contacts / prices).
const placeholderTabs = computed(() => tabKeys.value.filter(
@@ -439,11 +444,12 @@ async function loadOptions(
url: string,
target: typeof clientOptions,
labelOf: (m: Record<string, unknown>) => string,
extraParams: Record<string, string> = {},
): Promise<void> {
try {
const data = await api.get<{ member?: Record<string, unknown>[] }>(
url,
{ pagination: 'false' },
{ pagination: 'false', ...extraParams },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
@@ -455,7 +461,8 @@ async function loadOptions(
/** Charge les référentiels de l'onglet Prix (non bloquant : selects vides si échec). */
function loadPriceReferentials(): void {
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
// Exclut les courtiers (catégorie COURTIER) du select clients du module Transport.
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']), { excludeCategoryCode: 'COURTIER' })
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
}
@@ -148,9 +148,10 @@ describe('carrierConsultationVisibleTabs', () => {
expect(carrierConsultationVisibleTabs({ '@id': '/api/carriers/1', id: 1, name: 'LIOT' })).toEqual([])
})
it('affiche addresses/contacts/prices dans l\'ordre quand renseignés', () => {
it('affiche addresses/contacts/prices dans l\'ordre quand renseignés (affrété)', () => {
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
isChartered: true,
address: { '@id': '/api/carrier_addresses/1', id: 1, city: 'Poitiers' },
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
prices: [{ '@id': '/api/carrier_prices/1', id: 1 }],
@@ -167,4 +168,25 @@ describe('carrierConsultationVisibleTabs', () => {
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['contacts'])
})
it('affiche l\'onglet Prix dès que le transporteur est affrété, même sans prix', () => {
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
isChartered: true,
prices: [],
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['prices'])
})
it('masque l\'onglet Prix d\'un transporteur non affrété même avec des prix historiques', () => {
// Retour métier : les prix d'un ancien affrété ne sont jamais supprimés,
// mais l'onglet reste masqué tant que le transporteur n'est pas réaffrété.
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
isChartered: false,
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
prices: [{ '@id': '/api/carrier_prices/1', id: 1 }],
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['contacts'])
})
})
@@ -216,6 +216,11 @@ export function hasAddressData(address: CarrierAddressRead | null | undefined):
* onglet de données vide. Le transporteur n'a pas de coquille « à venir ».
* Ordre : Adresses · Contacts · Prix. Retourne `[]` tant que le transporteur
* n'est pas chargé.
*
* Exception « Prix » : l'onglet n'est visible QUE si le transporteur est
* affrété (`isChartered`), indépendamment de la présence de prix. Un ancien
* affrété repassé non affrété conserve ses prix en base (jamais supprimés) mais
* l'onglet reste masqué tant qu'il n'est pas réaffrété décision métier.
*/
export function carrierConsultationVisibleTabs(
carrier: CarrierDetail | null | undefined,
@@ -230,7 +235,7 @@ export function carrierConsultationVisibleTabs(
if ((carrier.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((carrier.prices ?? []).length > 0) {
if (carrier.isChartered) {
visible.push('prices')
}
return visible
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.15",
"@malio/layer-ui": "^1.7.18",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.15",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.15/layer-ui-1.7.15.tgz",
"integrity": "sha512-CgEC0l2pkR6rlzpi1zZqswHs+/yGTSd861tdT678/wSKtQPQ6JxUIf63ugFDItyvyLW+nbcNWuHTFC2Bimp1EQ==",
"version": "1.7.18",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.18/layer-ui-1.7.18.tgz",
"integrity": "sha512-A+YcnEzzucsAz0FqkhVmN41uvtEHjy4ZbbHK8POjqNCkhuy7aTnisMUiYGlZUaEcu5lRjzw6RvjAavRTGzTNvQ==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.15",
"@malio/layer-ui": "^1.7.18",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
+21
View File
@@ -0,0 +1,21 @@
/**
* Déconnexion centralisée déclenchée directement par un handler (ex: lien du
* footer de la sidebar), sans passer par une page de redirection dédiée.
*
* `authStore.logout()` invalide la session serveur (POST /api/logout), vide
* l'état auth, et appelle `clearSession()` qui notifie tous les composables
* singletons (sidebar, modules, currentSite, auditLog, categoriesAdmin) via
* `onAuthSessionCleared` leurs états sont donc réinitialisés ici sans aucun
* reset manuel. La redirection vers `/login` (inévitable : un utilisateur
* déconnecté ne peut pas rester sur une page protégée) est la seule navigation.
*/
export function useLogout() {
const auth = useAuthStore()
async function logout(): Promise<void> {
await auth.logout()
await navigateTo('/login')
}
return { logout }
}
+5 -3
View File
@@ -77,9 +77,11 @@ export const useAuthStore = defineStore('auth', {
} catch {
// Ignore logout errors so we can still clear local auth state.
} finally {
this.user = null
this.checked = true
this.isLoading = false
// clearSession() vide l'etat auth ET notifie les composables
// singletons (sidebar, modules, currentSite, auditLog,
// categoriesAdmin) via onAuthSessionCleared : plus besoin de
// resets manuels au logout — meme chemin que l'intercepteur 401.
this.clearSession()
}
},
async refreshUser() {
+15 -4
View File
@@ -35,7 +35,7 @@ export interface Persona {
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
// la copie/i18n change.
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products'>
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products' | 'storages'>
}
const SHARED_PASSWORD = 'e2e-secret'
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
password: SHARED_PASSWORD,
isAdmin: true,
permissions: [],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'storages', 'audit-log'],
},
'user-full': {
key: 'user-full',
@@ -71,12 +71,21 @@ export const personas: Record<PersonaKey, Persona> = {
// `/admin/products` -> ajoute le lien `products` a expectedAdminLinks.
'catalog.products.view',
'catalog.products.manage',
// Stockage (M7, ERP-210). Admin-only : mappe sur le persona "tout",
// pas de nouveau persona (regle ABSOLUE n°7). L'item vit dans la
// section Administration sur la route `/admin/storages` -> ajoute le
// lien `storages` a expectedAdminLinks.
'catalog.storages.view',
'catalog.storages.manage',
// Commercial — Repertoire clients (M1). Mappe ici sur le persona
// "tout" en attendant les vrais roles metier (bureau/compta/
// commerciale/usine) seedes par ERP-74. Pas de nouveau persona
// (regle ABSOLUE n°7). commercial.clients.view n'ajoute pas de lien
// dans la section Administration, donc expectedAdminLinks reste inchange.
'commercial.clients.view',
// Lecture liste seule pour le select de contrepartie pesee (ERP-209).
// Redondant ici (user-full a deja `view`) mais miroir du rang RBAC.
'commercial.clients.read_ref',
'commercial.clients.manage',
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
@@ -86,6 +95,8 @@ export const personas: Record<PersonaKey, Persona> = {
// (regle ABSOLUE n°7). commercial.suppliers.view n'ajoute pas de lien
// dans la section Administration, donc expectedAdminLinks reste inchange.
'commercial.suppliers.view',
// Lecture liste seule pour le select de contrepartie pesee (ERP-209).
'commercial.suppliers.read_ref',
'commercial.suppliers.manage',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
@@ -116,7 +127,7 @@ export const personas: Record<PersonaKey, Persona> = {
'logistique.weighing_tickets.view',
'logistique.weighing_tickets.manage',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'storages', 'audit-log'],
},
'user-readonly': {
key: 'user-readonly',
@@ -161,4 +172,4 @@ export function getPersona(key: PersonaKey): Persona {
return personas[key]
}
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'] as const
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'storages', 'audit-log'] as const
+7 -2
View File
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test'
import { LoginPage } from '../helpers/pages/LoginPage'
import { SidebarComponent } from '../helpers/pages/SidebarComponent'
import { getPersona } from '../_fixtures/personas'
/**
@@ -53,8 +54,12 @@ test.describe('Login', () => {
await loginPage.fillAndSubmit(superAdmin.username, superAdmin.password)
await page.waitForURL('/')
// 2. Navigation vers /logout (il y a un lien "Deconnexion" dans la sidebar)
await page.goto('/logout')
// 2. Deconnexion via le footer de la sidebar : survol du bloc compte
// (revele le bouton) puis clic. Le handler appelle useLogout() qui POST
// /api/logout, reset les stores, et redirige vers /login (sans page /logout).
const sidebar = new SidebarComponent(page)
await sidebar.accountBlock().hover()
await sidebar.logoutButton().click()
await page.waitForURL(/\/login$/)
// 3. Le cookie BEARER doit avoir ete supprime par le firewall de logout
@@ -27,7 +27,21 @@ export class SidebarComponent {
return this.page.locator('a[href="/"]').first()
}
logoutLink(): Locator {
return this.page.locator('a[href="/logout"]')
/**
* Bloc « compte connecte » du footer de la sidebar. Cible de survol qui
* revele le bouton de deconnexion (la deconnexion n'est plus un item de nav
* `/logout` mais un lien du footer, cf. default.vue + useLogout).
*/
accountBlock(): Locator {
return this.page.locator('[data-test="sidebar-account"]')
}
/**
* Bouton de deconnexion du footer (revele au survol du bloc compte en mode
* deplie, ou directement la pastille en mode replie). Selecteur par
* `data-test` : stable au renommage/retraduction du label.
*/
logoutButton(): Locator {
return this.page.locator('[data-test="sidebar-logout"]')
}
}
@@ -72,7 +72,10 @@ test.describe('Sidebar visibility', () => {
// Meme strategie que ci-dessus : ancrage semantique plutot que
// `networkidle` pour eviter les faux timeouts en CI.
await expect(sidebar.accountDashboardLink()).toBeVisible({ timeout: 10000 })
await expect(sidebar.logoutLink()).toBeVisible()
// La deconnexion vit dans le footer (rendu sans condition de permission).
// Le bouton est revele au survol du bloc compte.
await sidebar.accountBlock().hover()
await expect(sidebar.logoutButton()).toBeVisible()
})
test('la liste des personas dans personas.ts couvre toutes les combinaisons admin attendues', () => {
+1
View File
@@ -234,6 +234,7 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_product_code_active ON product (code) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_storage_site_type_numero_active ON storage (site_id, storage_type_id, numero) WHERE deleted_at IS NULL"
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M7 Stockage (ERP-211) : creation du schema BDD de la table `storage`.
*
* Un stockage = 1 site + 1 type de stockage (referentiel storage_type du M6) + 1
* numero, avec des etats multi-valeur (JSONB), soft-delete prepare et colonnes
* Timestampable/Blamable (spec-back § 3.2).
*
* Contraintes metier portees ici :
* - RG-7.01 : unicite du couple (site, type, numero) parmi les stockages ACTIFS,
* via l'index UNIQUE partiel uq_storage_site_type_numero_active (WHERE deleted_at
* IS NULL) un numero peut etre reutilise apres soft-delete.
* - RG-7.04 : au moins un etat, via chk_storage_states_not_empty
* (jsonb_array_length(states) >= 1). Comme pour product.states (M6), PAS de
* DEFAULT '[]'::jsonb : un tableau vide violerait ce CHECK ; la colonne est
* toujours renseignee par l'app (Processor/ORM).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire : la
* table storage porte des FK cross-module (site, storage_type, user). Le tri par
* timestamp au sein du namespace racine garantit l'ordre apres la creation de ces
* tables sur base vide ; un namespace modulaire trierait par FQCN alphabetique et
* casserait `make db-reset` (cf. Version20260625110000 pour le M6).
*
* Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`,
* horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe
* `datetime_immutable`). Chaque colonne porte son `COMMENT ON COLUMN` (regle n°12).
*
* NB schema:update (test-db-setup) : `storage` sera mappee en ORM au ticket suivant
* (entite Storage). D'ici la, `schema:update --force` la drope sur la base de TEST
* uniquement (sans impact : aucun test ne la reference encore, et dev/prod ne lancent
* jamais schema:update). Sa description sera ajoutee a ColumnCommentsCatalog au ticket
* entite (comme product / weighing_ticket).
*/
final class Version20260629120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-211 (M7) : creation de la table storage (FK site + storage_type, unicite metier partielle RG-7.01, etats JSONB RG-7.04, soft-delete + Timestampable/Blamable).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE storage (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
site_id INT NOT NULL,
storage_type_id INT NOT NULL,
numero VARCHAR(50) NOT NULL,
-- Pas de DEFAULT : un tableau vide violerait chk_storage_states_not_empty
-- (RG-7.04). La colonne est toujours renseignee par l'app (Processor/ORM).
states JSONB NOT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_storage_states_not_empty
CHECK (jsonb_array_length(states) >= 1),
CONSTRAINT fk_storage_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT,
CONSTRAINT fk_storage_storage_type
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_storage_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_storage_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// RG-7.01 : unicite (site, type, numero) parmi les stockages actifs uniquement
// (index partiel) — un numero redevient disponible apres soft-delete.
$this->addSql('CREATE UNIQUE INDEX uq_storage_site_type_numero_active ON storage (site_id, storage_type_id, numero) WHERE deleted_at IS NULL');
$this->addSql('CREATE INDEX idx_storage_site ON storage (site_id)');
$this->addSql('CREATE INDEX idx_storage_storage_type ON storage (storage_type_id)');
$this->addSql('CREATE INDEX idx_storage_deleted_at ON storage (deleted_at)');
$this->addSql('CREATE INDEX idx_storage_created_by ON storage (created_by)');
$this->addSql('CREATE INDEX idx_storage_updated_by ON storage (updated_by)');
$this->comment('storage', '_table', 'Emplacements de stockage (M7 Catalog) — un stockage = 1 site + 1 type (storage_type) + 1 numero, etats multi-valeur JSONB, soft-delete + Timestampable/Blamable.');
$this->comment('storage', 'id', 'Identifiant interne auto-incremente.');
$this->comment('storage', 'site_id', 'Site du stockage. FK -> site.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).');
$this->comment('storage', 'storage_type_id', 'Type de stockage (referentiel M6). FK -> storage_type.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).');
$this->comment('storage', 'numero', 'Numero du stockage (≤ 50), saisi. Unique par (site, type) parmi les actifs (RG-7.01, uq_storage_site_type_numero_active). Normalise serveur.');
$this->comment('storage', 'states', 'Etats du stockage (JSON) : tableau non vide (>= 1 element, RG-7.04, chk_storage_states_not_empty). Multi-valeur.');
$this->comment('storage', 'deleted_at', 'Horodatage du soft-delete technique — null = ligne active. Une ligne supprimee sort de l unicite metier (index partiel uq_storage_site_type_numero_active).');
$this->addTimestampableBlamableComments('storage');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE storage');
}
/**
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
* en reutilisant le catalogue partage (source unique, ERP-67).
*/
private function addTimestampableBlamableComments(string $table): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
$this->comment($table, $column, $description);
}
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Application\Filter;
use App\Module\Catalog\Domain\Entity\Storage;
use function in_array;
use function is_array;
use function is_int;
use function is_string;
/**
* Filtres de liste des stockages : SOURCE UNIQUE de verite du parsing des parametres
* de requete (?search sur numero, ?siteId[], ?storageTypeId, ?state). Partagee par le
* StorageProvider (liste paginee) et le StorageExportController (export XLSX) pour
* garantir que l'export reflete EXACTEMENT ce que l'utilisateur voit a l'ecran.
*
* Sans cette factorisation, les deux endpoints parsaient les memes filtres avec des
* regles subtilement differentes (numero litteral « 0 » coerce a null cote export,
* id non positif accepte cote liste mais ignore cote export, parametre tableau
* jetant un 400 cote export) : autant de divergences liste/export. Une seule
* implementation -> zero drift, chaque nouveau filtre se branche en un seul endroit.
*/
final readonly class StorageListFilters
{
/** Etats valides du filtre ?state= (enum borne, RG-7.04). */
private const array VALID_STATES = [
Storage::STATE_RECEPTION,
Storage::STATE_PRODUCTION,
Storage::STATE_TRIAGE,
];
/**
* @param list<int> $siteIds
*/
private function __construct(
public ?string $search,
public array $siteIds,
public ?int $storageTypeId,
public ?string $state,
) {}
/**
* Construit les filtres depuis une source brute : le `$context['filters']`
* d'API Platform cote provider, ou `$request->query->all()` cote controller
* d'export. Tolere scalaire ou tableau, ignore les entrees invalides jamais
* d'exception sur une saisie malformee (ex: `?search[]=x`).
*
* @param array<string, mixed> $query
*/
public static function fromQuery(array $query): self
{
return new self(
self::readSearch($query['search'] ?? null),
self::readSiteIds($query['siteId'] ?? null),
self::readPositiveInt($query['storageTypeId'] ?? null),
self::readState($query['state'] ?? null),
);
}
/**
* Recherche partielle sur numero : valeur trimmee, ou null si absente / vide.
* La chaine « 0 » est un numero valide (VARCHAR) et N'EST PAS coercee a null.
*/
private static function readSearch(mixed $raw): ?string
{
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Liste d'identifiants de sites (OR). Tolere une valeur scalaire unique
* (`?siteId=1`) ou un tableau (`?siteId[]=1&siteId[]=2`), dedup, ordre stable.
*
* @return list<int>
*/
private static function readSiteIds(mixed $raw): array
{
if (null === $raw) {
return [];
}
$values = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($values as $value) {
$id = self::readPositiveInt($value);
if (null !== $id) {
$ids[] = $id;
}
}
return array_values(array_unique($ids));
}
/**
* Identifiant entier STRICTEMENT POSITIF (un id metier l'est toujours) ou null.
* Un 0 ou un negatif est traite comme « pas de filtre », jamais comme un id
* impossible (qui renverrait une liste vide cote provider mais tout cote export).
*/
private static function readPositiveInt(mixed $raw): ?int
{
if (is_int($raw)) {
return $raw > 0 ? $raw : null;
}
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
}
/**
* Filtre ?state= : normalise en majuscules, n'accepte qu'une valeur de l'enum
* borne {RECEPTION, PRODUCTION, TRIAGE} ; toute autre valeur est ignoree (null).
*/
private static function readState(mixed $raw): ?string
{
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, self::VALID_STATES, true) ? $state : null;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Application\Service;
/**
* Normalisation serveur des champs texte d'un Storage, appliquee par le
* StorageProcessor AVANT l'unicite metier et la persistance (RG-7.06). Jumeau du
* ProductFieldNormalizer (M6), recentre sur l'unique champ texte du stockage.
*
* - numero : trim simple, SANS changement de casse (HP-M7-05 : pas d'UPPER par
* defaut, contrairement au code produit). Le numero est saisi tel quel et sert
* l'unicite metier (site, type, numero) parmi les actifs (RG-7.01).
*
* La methode est null-safe et trim l'entree ; une chaine vide apres trim devient
* null (c'est l'Assert\NotBlank de l'entite qui rejette le vide, pas le normalizer).
*/
final class StorageFieldNormalizer
{
/**
* Numero de stockage trimme (RG-7.06), sans changement de casse (HP-M7-05).
* Conserve null tel quel ; une chaine vide apres trim devient null.
*/
public function normalizeNumero(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
}
+5
View File
@@ -47,6 +47,11 @@ final class CatalogModule
// Item sidebar dans la section Administration, sous « Repertoire transporteurs ».
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
// Stockage (M7, ERP-210) : admin-only. Reutilise le referentiel
// StorageType du M6. Item sidebar dans la section Administration,
// pres des items Catalog (produits, categories).
['code' => 'catalog.storages.view', 'label' => 'Voir les stockages'],
['code' => 'catalog.storages.manage', 'label' => 'Gérer les stockages (créer, éditer)'],
];
}
}
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\StorageProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Emplacement de stockage (M7 Catalog) entite racine du module Stockage, jumelle
* de Product (M6) cote pattern (#[Auditable], TimestampableBlamable, soft-delete,
* etats multi-valeur JSONB) et cote contrat de serialisation (RETEX M1 spec § 4.0).
*
* Un stockage = 1 site + 1 type de stockage (referentiel storage_type du M6) + 1
* numero. Le couple (site, type, numero) est unique parmi les stockages ACTIFS
* (RG-7.01, index partiel uq_storage_site_type_numero_active possede par la
* migration). Les etats (RECEPTION / PRODUCTION / TRIAGE) sont multi-valeur, au
* moins un (RG-7.04, CHECK chk_storage_states_not_empty).
*
* Contrat de serialisation :
* - LISTE / DETAIL (storage:read + site:read + storage_type:read + default:read) :
* numero, states, displayName (RG-7.05), site et storageType embarques (ensembles
* bornes -> embed autorise, ne viole pas la regle n°13), createdAt/updatedAt
* (via default:read). L'ecriture passe par storage:write (site, storageType,
* numero, states).
*
* Soft-delete prepare via `deletedAt` (non expose, § 2.8) : pas de Delete dans les
* operations ; la liste exclut les stockages supprimes (Provider, ERP-213). Un
* numero redevient disponible apres soft-delete (index partiel sur les actifs).
*
* NB : `Site` appartient au module Sites, consomme en relation ORM partagee (§ 2.1)
* on reutilise son read-group `site:read`, sans logique inter-module. `StorageType`
* est dans le meme module Catalog.
*
* @see StorageProvider Lecture (liste paginee filtree soft-delete + item) ERP-213.
* @see StorageProcessor Ecriture (normalisation, unicite metier RG-7.01) ERP-213.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.storages.view')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
provider: StorageProvider::class,
),
new Get(
security: "is_granted('catalog.storages.view')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
provider: StorageProvider::class,
),
new Post(
security: "is_granted('catalog.storages.manage')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['storage:write']],
// Convertit les erreurs de denormalisation (type invalide / null sur une
// relation : site, storageType) en violations 422 portant un propertyPath,
// au lieu d'un 400 qui court-circuite la validation (cf. Product — mapping
// inline useFormErrors, ERP-101).
collectDenormalizationErrors: true,
processor: StorageProcessor::class,
),
new Patch(
security: "is_granted('catalog.storages.manage')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['storage:write']],
collectDenormalizationErrors: true,
provider: StorageProvider::class,
processor: StorageProcessor::class,
),
// Pas de Delete au M7 (§ 2.8) ; soft-delete prepare non expose.
],
)]
#[ORM\Entity(repositoryClass: DoctrineStorageRepository::class)]
#[ORM\Table(name: 'storage')]
// Index nommes pour matcher la migration (cf. Product). L'index unique partiel
// `uq_storage_site_type_numero_active` ((site, type, numero) WHERE deleted_at IS
// NULL — unicite metier parmi les actifs, RG-7.01) reste possede par la seule
// migration : Doctrine ORM ne sait pas exprimer un index partiel via attribut.
#[ORM\Index(name: 'idx_storage_site', columns: ['site_id'])]
#[ORM\Index(name: 'idx_storage_storage_type', columns: ['storage_type_id'])]
#[ORM\Index(name: 'idx_storage_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_storage_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_storage_updated_by', columns: ['updated_by'])]
#[Auditable]
class Storage implements TimestampableInterface, BlamableInterface
{
// === Timestampable + Blamable ===
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
// getters/setters viennent du Trait Shared, remplies automatiquement par le
// TimestampableBlamableSubscriber au prePersist / preUpdate.
use TimestampableBlamableTrait;
/** Etats du stockage (RG-7.04) — valeurs autorisees de la colonne JSONB `states`. */
public const string STATE_RECEPTION = 'RECEPTION';
public const string STATE_PRODUCTION = 'PRODUCTION';
public const string STATE_TRIAGE = 'TRIAGE';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['storage:read'])]
private ?int $id = null;
// Site du stockage (obligatoire). FK ON DELETE RESTRICT : un site reference par
// un stockage ne peut etre supprime. Composante de l'unicite metier (RG-7.01).
#[ORM\ManyToOne(targetEntity: Site::class)]
#[ORM\JoinColumn(name: 'site_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Le site est obligatoire.')]
#[Groups(['storage:read', 'storage:write'])]
private ?Site $site = null;
// Type de stockage (obligatoire, referentiel plat M6). FK ON DELETE RESTRICT :
// un type reference par un stockage ne peut etre supprime. Composante de
// l'unicite metier (RG-7.01).
#[ORM\ManyToOne(targetEntity: StorageType::class)]
#[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Le type de stockage est obligatoire.')]
#[Groups(['storage:read', 'storage:write'])]
private ?StorageType $storageType = null;
// Numero du stockage, saisi. Unique par (site, type) parmi les actifs (RG-7.01).
// Normalise serveur (trim) par le StorageProcessor (ERP-213).
#[ORM\Column(length: 50)]
#[Assert\NotBlank(message: 'Le numéro du stockage est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 50, maxMessage: 'Le numéro du stockage ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['storage:read', 'storage:write'])]
private ?string $numero = null;
/**
* Etats du stockage (multi-select), sous-ensemble non vide de
* {RECEPTION, PRODUCTION, TRIAGE} (RG-7.04). Stocke en JSONB (tableau de
* chaines), non-vacuite garantie aussi par le CHECK chk_storage_states_not_empty.
*
* Validation des valeurs via Assert\Choice(multiple: true) plutot que
* Assert\All([Choice]) : equivalent fonctionnel, et seul Choice est gere par le
* garde-fou EntityConstraintsHaveFrenchMessageTest (cf. Product::states).
*
* @var list<string>
*/
// jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la
// migration (CHECK chk_storage_states_not_empty via jsonb_array_length). Sans
// `options: ['jsonb' => true]`, schema:update tente un ALTER states TYPE JSON
// qui casse le CHECK et fait echouer make test-db-setup (cf. Product::states).
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état.')]
#[Assert\Unique(message: 'Chaque état ne peut être sélectionné qu\'une seule fois.')]
#[Assert\Choice(
choices: [self::STATE_RECEPTION, self::STATE_PRODUCTION, self::STATE_TRIAGE],
multiple: true,
message: 'État de stockage invalide.',
multipleMessage: 'État de stockage invalide.',
)]
#[Groups(['storage:read', 'storage:write'])]
private array $states = [];
/**
* Soft-delete technique : null = actif, valeur = supprime logiquement le {date}.
* Non expose (§ 2.8, aucun groupe) : prepare pour une future suppression. La
* liste exclut par defaut les stockages supprimes (Provider, ERP-213) et le
* numero redevient disponible (index partiel sur les actifs, RG-7.01).
*/
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(?Site $site): static
{
$this->site = $site;
return $this;
}
public function getStorageType(): ?StorageType
{
return $this->storageType;
}
public function setStorageType(?StorageType $storageType): static
{
$this->storageType = $storageType;
return $this;
}
public function getNumero(): ?string
{
return $this->numero;
}
public function setNumero(string $numero): static
{
$this->numero = $numero;
return $this;
}
/**
* @return list<string>
*/
public function getStates(): array
{
return $this->states;
}
/**
* @param list<string> $states
*/
public function setStates(array $states): static
{
// `array_values` reseque toujours un tableau SEQUENTIEL : une saisie cliente
// malformee (objet JSON `{"x":"RECEPTION"}` denormalise en tableau associatif)
// ne peut plus etre persistee comme un objet JSONB, ce qui ferait echouer le
// CHECK chk_storage_states_not_empty (jsonb_array_length sur non-tableau) en
// 500. Les doublons eventuels restent rejetes en 422 par Assert\Unique.
$this->states = array_values($states);
return $this;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
/**
* RG-7.05 : libelle d'affichage = libelle du type de stockage suivi du numero
* (ex. « Cellule 12 »). Getter virtuel non persiste, expose en lecture
* (storage:read). Null-safe : `storageType` et `numero` sont garantis non nuls a
* la lecture (NOT NULL en base), le `?? ''` couvre un objet en cours de
* construction sans casser la serialisation.
*/
#[Groups(['storage:read'])]
public function getDisplayName(): string
{
$label = $this->storageType?->getLabel() ?? '';
return trim($label.' '.($this->numero ?? ''));
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\Storage;
use Doctrine\ORM\QueryBuilder;
interface StorageRepositoryInterface
{
public function findById(int $id): ?Storage;
public function save(Storage $storage): void;
/**
* Vrai si un stockage actif (deleted_at IS NULL) porte deja le triplet
* (site, storageType, numero). `$excludeId` exclut un stockage precis du test
* (cas PATCH). Garantit l'unicite metier parmi les actifs (RG-7.01, index
* partiel uq_storage_site_type_numero_active). Un numero redevient disponible
* apres soft-delete (le test ignore les supprimes).
*/
public function existsActiveBySiteTypeNumero(
int $siteId,
int $storageTypeId,
string $numero,
?int $excludeId = null,
): bool;
/**
* QueryBuilder de la liste stockages (consomme par le StorageProvider) : exclut
* par defaut les soft-deleted (RG-7.07), trie par site.code ASC, storageType.label
* ASC, numero ASC (defaut spec § 4.1) et applique les filtres optionnels :
* - `$search` : recherche partielle case-insensitive sur `numero`.
* - `$siteIds` : stockage rattache a AU MOINS UN des sites passes.
* - `$storageTypeId` : restreint a un type de stockage precis (par id).
* - `$state` : appartenance a la colonne JSONB `states` (RECEPTION|PRODUCTION|TRIAGE).
*
* @param list<int> $siteIds
*/
public function createListQueryBuilder(
bool $includeDeleted = false,
?string $search = null,
array $siteIds = [],
?int $storageTypeId = null,
?string $state = null,
): QueryBuilder;
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Catalog\Application\Service\StorageFieldNormalizer;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Throwable;
use function sprintf;
/**
* Processor d'ecriture du stockage (M7, POST / PATCH). Cf. spec-back M7 § 4.3 /
* § 4.4 + RG-7.01 / RG-7.06. Jumeau du ProductProcessor (normalisation serveur +
* 409 doublon).
*
* Sequence (POST / PATCH) :
* 1. Normalisation serveur (RG-7.06) via StorageFieldNormalizer : numero trim
* (pas d'UPPER — HP-M7-05). Jouee AVANT l'unicite et la persistance ; la
* validation (NotNull site/type, NotBlank/Length numero, Count/Choice states
* RG-7.04) a deja joue cote API Platform sur la saisie brute.
* 2. RG-7.01 : unicite metier du triplet (site, storageType, numero) parmi les
* actifs. Pre-check deterministe (excluant le stockage courant en PATCH) -> 409 ;
* l'index partiel uq_storage_site_type_numero_active reste le filet anti-race
* au flush.
* 3. Persistance via le persist_processor Doctrine ORM.
*
* RG-7.03 (« le type doit etre disponible sur le site choisi ») n'est PAS portee :
* le concept type<->site a ete retire du modele en M6 (StorageType rendu plat,
* jointure storage_type_site droppee migration Version20260626100000). C'est
* desormais l'entite Storage (1 site + 1 type) qui materialise cette disponibilite ;
* il n'existe plus de referentiel a interroger. A reclarifier cote spec (signale).
*
* Mode strict PATCH (RETEX M1) : la security d'operation exige `catalog.storages.manage`
* pour TOUS les champs ecrivables (un seul niveau de permission au M7 admin-only).
* Aucun champ « hors-permission » a re-gater finement ici : le 403 global est porte
* par la security d'operation.
*
* @implements ProcessorInterface<Storage, Storage>
*/
final class StorageProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly StorageFieldNormalizer $normalizer,
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')]
private readonly StorageRepositoryInterface $repository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Storage) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// 1. RG-7.06 : normalisation serveur (numero trim, pas d'UPPER).
$this->normalize($data);
// 2. RG-7.01 : unicite metier (site, storageType, numero) parmi les actifs
// (exclut le stockage courant en PATCH). Pre-check explicite -> 409
// deterministe. Le NotNull site/type + NotBlank numero ont deja joue.
$siteId = $data->getSite()?->getId();
$typeId = $data->getStorageType()?->getId();
$numero = (string) $data->getNumero();
if (null !== $siteId && null !== $typeId && '' !== $numero
&& $this->repository->existsActiveBySiteTypeNumero($siteId, $typeId, $numero, $data->getId())) {
throw $this->duplicateConflict($numero);
}
// 3. Persistance, avec filet anti-race sur l'index partiel.
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Insertion concurrente du meme triplet entre le pre-check et le flush
// (collision sur uq_storage_site_type_numero_active).
throw $this->duplicateConflict($numero, $e);
}
}
/**
* Normalisation serveur du stockage (RG-7.06). Le setter n'est touche que si une
* valeur est presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
* Le cast (string) est sur : NotBlank a deja rejete le vide en amont.
*/
private function normalize(Storage $data): void
{
if (null !== $data->getNumero()) {
$data->setNumero((string) $this->normalizer->normalizeNumero($data->getNumero()));
}
}
/**
* RG-7.01 : 409 sur doublon (site, type, numero). Le front mappe ce conflit sur
* le champ `numero` (setError('numero', ...) + toast convention useFormErrors
* ERP-101) : le propertyPath exploitable est `numero`.
*/
private function duplicateConflict(string $numero, ?Throwable $previous = null): ConflictHttpException
{
return new ConflictHttpException(
sprintf('Un stockage portant le numéro « %s » existe déjà pour ce site et ce type de stockage.', $numero),
$previous,
);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Application\Filter\StorageListFilters;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use function is_int;
use function is_string;
/**
* Provider Storage (lecture, ERP-213) :
* - LISTE : exclut par defaut les stockages soft-deleted (RG-7.07), trie par
* site.code ASC, storageType.label ASC, numero ASC (defaut spec § 4.1), applique
* les filtres (?search sur numero, ?siteId[], ?storageTypeId, ?state parses par
* {@see StorageListFilters}, source partagee avec l'export) et renvoie une
* collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
* operation de collection on enveloppe le QueryBuilder dans le Paginator ORM).
* Echappatoire ?pagination=false respectee (alimentation d'un select).
* - ITEM : recharge le stockage puis renvoie null (404) s'il est soft-deleted le
* soft-delete n'est jamais expose (§ 2.8), aucun flag includeDeleted.
*
* @implements ProviderInterface<Storage>
*/
final class StorageProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')]
private readonly StorageRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Storage|null
{
if ($operation instanceof CollectionOperationInterface) {
// Filtres parses par la source partagee avec l'export (parite garantie).
$filters = StorageListFilters::fromQuery($context['filters'] ?? []);
// includeDeleted toujours false : le soft-delete n'est pas expose (§ 2.8).
$qb = $this->repository->createListQueryBuilder(
false,
$filters->search,
$filters->siteIds,
$filters->storageTypeId,
$filters->state,
);
// Echappatoire ?pagination=false : collection complete sans Paginator.
if (!$this->pagination->isEnabled($operation, $context)) {
return $qb->getQuery()->getResult();
}
// Branche paginee standard : offset/limit via Pagination, enveloppe dans le
// Paginator ORM. Les jointures site/storageType sont to-ONE (ManyToOne) :
// pas de duplication de lignes, le comptage reste exact.
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
return new Paginator(new DoctrinePaginator($qb->getQuery()));
}
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$storage = $this->repository->findById((int) $id);
if (null === $storage) {
return null;
}
// § 2.8 : un stockage soft-deleted n'est jamais expose (404).
if (null !== $storage->getDeletedAt()) {
return null;
}
return $storage;
}
}
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Controller;
use App\Module\Catalog\Application\Filter\StorageListFilters;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function in_array;
/**
* Export XLSX de la liste des stockages (M7, spec-back § 4.5). Jumeau du
* ProductExportController (M6) reference en prose volontairement (pas de {@see}
* inter-module).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
* sur la route : sans cela API Platform capterait `/api/storages/export.xlsx`
* comme l'item `GET /api/storages/{id}.{_format}` (id="export", _format="xlsx")
* cf. CLAUDE.md « controller custom sous /api ». Etant un controller (et non un
* #[ApiResource]), il n'est PAS scanne par CollectionsArePaginatedTest : aucune
* entree EXCLUDED necessaire (comme ProductExportController).
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des stockages (MEMES filtres que
* `GET /api/storages` via le StorageProvider, deleguee a
* {@see StorageRepositoryInterface::createListQueryBuilder()} l'export reflete
* exactement ce que l'utilisateur voit a l'ecran) et mapping metier des colonnes.
* Les stockages soft-deleted (RG-7.07) sont toujours exclus, comme en liste (le
* soft-delete n'est jamais expose, § 2.8).
*/
#[AsController]
final class StorageExportController
{
/**
* Libelles FR des etats (RG-7.04) pour la colonne « États ». L'ordre des cles
* fixe l'ordre d'affichage (Réception, Production, Triage) independamment de
* l'ordre de stockage en base.
*/
private const array STATE_LABELS = [
Storage::STATE_RECEPTION => 'Réception',
Storage::STATE_PRODUCTION => 'Production',
Storage::STATE_TRIAGE => 'Triage',
];
/**
* Taille du lot avant `EntityManager::clear()` pendant le streaming des lignes :
* borne la memoire (identity map) sur un gros export sans tout materialiser.
*/
private const int EXPORT_BATCH_SIZE = 200;
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')]
private readonly StorageRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/storages/export.xlsx', name: 'catalog_storages_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('catalog.storages.view')]
public function __invoke(Request $request): Response
{
// Memes filtres que la vue liste (StorageProvider) pour que l'export reflete
// exactement ce que l'utilisateur voit a l'ecran : recherche (?search sur
// numero), sites (?siteId[]), type (?storageTypeId), etat (?state). Parses par
// la MEME source que le provider ({@see StorageListFilters}) -> aucune
// divergence possible (numero « 0 », parametre tableau, id non positif).
// includeDeleted reste false : le soft-delete n'est jamais expose (§ 2.8).
$filters = StorageListFilters::fromQuery($request->query->all());
// Streaming via toIterable() : on ne materialise pas toute la table en memoire
// (cf. buildRows + EXPORT_BATCH_SIZE) avant de construire le classeur.
$storages = $this->repository
->createListQueryBuilder(false, $filters->search, $filters->siteIds, $filters->storageTypeId, $filters->state)
->getQuery()
->toIterable()
;
$binary = $this->exporter->export(
'Stockages',
$this->buildHeaders(),
$this->buildRows($storages),
);
return $this->buildResponse($binary);
}
/**
* Colonnes de l'export (spec § 4.5).
*
* @return list<string>
*/
private function buildHeaders(): array
{
return [
'Nom',
'Site',
'Type de stockage',
'Numéro',
'États',
'Créé le',
'Modifié le',
];
}
/**
* Mappe chaque stockage en ligne d'export, en consommant un iterable paresseux
* (Doctrine `toIterable()`). Toutes les N lignes (EXPORT_BATCH_SIZE), on vide
* l'identity map (`clear()`) pour borner la memoire sur un gros export sans
* danger ici, le controller ne fait que lire.
*
* @param iterable<Storage> $storages
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(iterable $storages): iterable
{
$count = 0;
foreach ($storages as $storage) {
yield [
$storage->getDisplayName(),
$this->formatSite($storage->getSite()),
$storage->getStorageType()?->getLabel(),
$storage->getNumero(),
$this->formatStates($storage),
$this->formatDate($storage->getCreatedAt()),
$this->formatDate($storage->getUpdatedAt()),
];
if (0 === ++$count % self::EXPORT_BATCH_SIZE) {
$this->em->clear();
}
}
}
/**
* Libelle du site « Nom (Code) » (ex. « Chatellerault (86) »). Le code peut
* etre absent : on retombe alors sur le seul nom.
*/
private function formatSite(?Site $site): string
{
if (null === $site) {
return '';
}
$name = (string) $site->getName();
$code = $site->getCode();
return null !== $code && '' !== $code ? sprintf('%s (%s)', $name, $code) : $name;
}
/**
* Libelles FR des etats du stockage, dans l'ordre canonique (Réception,
* Production, Triage), joints par virgule. Une valeur inattendue est ignoree.
*/
private function formatStates(Storage $storage): string
{
$states = $storage->getStates();
$labels = [];
foreach (self::STATE_LABELS as $code => $label) {
if (in_array($code, $states, true)) {
$labels[] = $label;
}
}
return implode(', ', $labels);
}
/**
* Formate un horodatage en « jj/mm/aaaa hh:mm » (vide si null).
*/
private function formatDate(?DateTimeImmutable $date): string
{
return $date?->format('d/m/Y H:i') ?? '';
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('stockages-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Storage>
*/
class DoctrineStorageRepository extends ServiceEntityRepository implements StorageRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Storage::class);
}
public function findById(int $id): ?Storage
{
return $this->find($id);
}
public function save(Storage $storage): void
{
$this->getEntityManager()->persist($storage);
$this->getEntityManager()->flush();
}
public function existsActiveBySiteTypeNumero(
int $siteId,
int $storageTypeId,
string $numero,
?int $excludeId = null,
): bool {
$qb = $this->createQueryBuilder('s')
->select('1')
->andWhere('s.site = :siteId')
->andWhere('s.storageType = :storageTypeId')
->andWhere('s.numero = :numero')
->andWhere('s.deletedAt IS NULL')
->setParameter('siteId', $siteId)
->setParameter('storageTypeId', $storageTypeId)
->setParameter('numero', $numero)
->setMaxResults(1)
;
if (null !== $excludeId) {
$qb->andWhere('s.id != :excludeId')->setParameter('excludeId', $excludeId);
}
return [] !== $qb->getQuery()->getResult();
}
public function createListQueryBuilder(
bool $includeDeleted = false,
?string $search = null,
array $siteIds = [],
?int $storageTypeId = null,
?string $state = null,
): QueryBuilder {
// Eager-load des relations embarquees en liste (storage:read) pour eviter un
// N+1 par stockage : site et storageType sont des ManyToOne (to-ONE, sures —
// pas de duplication de lignes, contrairement aux ManyToMany du Product). Les
// jointures servent aussi le tri (site.code, storageType.label).
$qb = $this->createQueryBuilder('s')
->leftJoin('s.site', 'site')->addSelect('site')
->leftJoin('s.storageType', 'st')->addSelect('st')
->orderBy('site.code', 'ASC')
->addOrderBy('st.label', 'ASC')
->addOrderBy('s.numero', 'ASC')
;
// RG-7.07 : la liste exclut par defaut les stockages soft-deleted.
if (!$includeDeleted) {
$qb->andWhere('s.deletedAt IS NULL');
}
// ?search= : recherche partielle case-insensitive sur numero. Les
// metacaracteres LIKE (%, _, \) sont echappes pour rester litteraux.
if (null !== $search && '' !== trim($search)) {
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere('LOWER(s.numero) LIKE :search')->setParameter('search', $pattern);
}
// ?siteId[]= : stockage rattache a AU MOINS UN des sites passes (OR). site est
// un ManyToOne (to-one) -> filtre direct sur la jointure, sans sous-requete
// EXISTS ni risque de masquer une collection (≠ Product.sites M2M).
if ([] !== $siteIds) {
$qb->andWhere('site.id IN (:siteIds)')->setParameter('siteIds', $siteIds);
}
// ?storageTypeId= : filtre par type de stockage precis (id).
if (null !== $storageTypeId) {
$qb->andWhere('st.id = :storageTypeId')->setParameter('storageTypeId', $storageTypeId);
}
// ?state= : appartenance a la colonne JSONB `states`. DQL ne sait pas exprimer
// la containment jsonb -> on resout les ids matchant en SQL natif (operateur
// @>), puis on contraint le QueryBuilder. Ids vides -> condition toujours
// fausse (aucun stockage), sans casser le reste de la requete.
if (null !== $state) {
$stateIds = $this->matchingStateIds($state);
if ([] === $stateIds) {
$qb->andWhere('1 = 0');
} else {
$qb->andWhere('s.id IN (:stateIds)')->setParameter('stateIds', $stateIds);
}
}
return $qb;
}
/**
* Ids des stockages dont la colonne JSONB `states` contient l'etat donne, via
* l'operateur de containment Postgres `@>`. L'etat est borne a l'enum
* {RECEPTION, PRODUCTION, TRIAGE} en amont (StorageProvider) pas de saisie
* libre ici.
*
* @return list<int>
*/
private function matchingStateIds(string $state): array
{
$rows = $this->getEntityManager()->getConnection()
->executeQuery(
'SELECT id FROM storage WHERE states @> CAST(:state AS JSONB)',
['state' => (string) json_encode([$state])],
)
->fetchFirstColumn()
;
return array_map(static fn (mixed $id): int => (int) $id, $rows);
}
}
@@ -35,11 +35,17 @@ final class CommercialModule
{
return [
['code' => 'commercial.clients.view', 'label' => 'Voir les clients'],
// Lecture de la LISTE clients pour alimenter un select (contrepartie d'un
// ticket de pesee — role Usine, ERP-209), SANS le repertoire ni le detail.
['code' => 'commercial.clients.read_ref', 'label' => 'Lire la liste des clients (référentiel pour les selects)'],
['code' => 'commercial.clients.manage', 'label' => 'Créer / modifier les clients (hors onglet Comptabilité)'],
['code' => 'commercial.clients.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.archive', 'label' => 'Archiver / restaurer un client'],
['code' => 'commercial.suppliers.view', 'label' => 'Voir les fournisseurs'],
// Lecture de la LISTE fournisseurs pour alimenter un select (contrepartie
// d'un ticket de pesee — role Usine, ERP-209), SANS le repertoire ni le detail.
['code' => 'commercial.suppliers.read_ref', 'label' => 'Lire la liste des fournisseurs (référentiel pour les selects)'],
['code' => 'commercial.suppliers.manage', 'label' => 'Créer / modifier les fournisseurs (hors onglet Comptabilité)'],
['code' => 'commercial.suppliers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un fournisseur'],
['code' => 'commercial.suppliers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un fournisseur'],
@@ -63,7 +63,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
// `read_ref` (ERP-209) : lecture de la LISTE seule, pour alimenter le
// select de contrepartie du ticket de pesee (role Usine, qui n'a pas
// `view` et donc pas le repertoire). N'ouvre que la collection — l'item,
// la creation et l'edition restent gardes par `view`/`manage`.
security: "is_granted('commercial.clients.view') or is_granted('commercial.clients.read_ref')",
// La liste embarque les categories (avec leur code, groupe
// category:read) et les sites agreges des adresses (groupe
// site:read) pour alimenter les colonnes « Catégories » et
@@ -66,7 +66,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.suppliers.view')",
// `read_ref` (ERP-209) : lecture de la LISTE seule, pour alimenter le
// select de contrepartie du ticket de pesee (role Usine, qui n'a pas
// `view` et donc pas le repertoire). N'ouvre que la collection — l'item,
// la creation et l'edition restent gardes par `view`/`manage`.
security: "is_granted('commercial.suppliers.view') or is_granted('commercial.suppliers.read_ref')",
// La liste embarque les categories (avec leur code/name, groupe
// category:read) et les sites agreges des adresses (groupe
// site:read) pour alimenter les colonnes « Catégories » et
@@ -28,6 +28,10 @@ interface ClientRepositoryInterface
* dont le code est dans la liste (OR ERP-78). Liste vide = pas de filtre.
* - $siteIds : restreint aux clients ayant au moins une adresse rattachee a
* l'un des sites donnes (OR RG-1.10). Liste vide = pas de filtre.
* - $excludeCategoryCodes : EXCLUT les clients possedant au moins une
* categorie dont le code est dans la liste (NOT IN). Liste vide = pas de
* filtre. Utilise par le module Transport pour ecarter les courtiers
* (code COURTIER) des selects clients.
*
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
* la liste paginee (ClientProvider) et l'export (ClientExportController)
@@ -41,6 +45,7 @@ interface ClientRepositoryInterface
*
* @param list<string> $categoryCodes
* @param list<int> $siteIds
* @param list<string> $excludeCategoryCodes
*/
public function createListQueryBuilder(
bool $includeArchived = false,
@@ -48,6 +53,7 @@ interface ClientRepositoryInterface
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
array $excludeCategoryCodes = [],
): QueryBuilder;
/**
@@ -25,6 +25,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* - tri par defaut companyName ASC RG-1.26 ;
* - filtres ?search=... (fuzzy companyName + lastName + email) et
* ?categoryCode=<code> (clients ayant >= 1 categorie de ce code ERP-78) ;
* - ?excludeCategoryCode=<code> : EXCLUT les clients ayant >= 1 categorie de ce
* code (NOT IN utilise par le module Transport pour ecarter les courtiers) ;
* - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ;
* echappatoire ?pagination=false pour alimenter un <select> sans pagination.
*
@@ -70,6 +72,10 @@ final class ClientProvider implements ProviderInterface
// RG-1.03) OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
$siteIds = $this->readIntList($filters['siteId'] ?? []);
// excludeCategoryCode : EXCLUT les clients ayant ce(s) code(s) de categorie.
// Le module Transport l'utilise pour ecarter les courtiers (COURTIER) de
// ses selects clients.
$excludeCategoryCodes = $this->readStringList($filters['excludeCategoryCode'] ?? []);
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder(
@@ -78,6 +84,7 @@ final class ClientProvider implements ProviderInterface
$categoryCodes,
$siteIds,
$archivedOnly,
$excludeCategoryCodes,
);
// Echappatoire ?pagination=false : collection complete sans Paginator
@@ -37,6 +37,7 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
array $excludeCategoryCodes = [],
): QueryBuilder {
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
// L'hydratation des collections affichees (Catégories / Site(s)) est
@@ -57,6 +58,7 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
$this->applySearch($qb, $search);
$this->applyCategoryCodes($qb, $categoryCodes);
$this->applyExcludeCategoryCodes($qb, $excludeCategoryCodes);
$this->applySiteIds($qb, $siteIds);
return $qb;
@@ -151,6 +153,35 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
;
}
/**
* EXCLUT les clients possedant au moins une categorie dont le code figure
* dans la liste (NOT IN). Miroir negatif d'{@see self::applyCategoryCodes()} :
* utilise par le module Transport pour ecarter les courtiers (code COURTIER)
* des selects clients, sans dependre du nombre de categories d'un client (un
* client [COURTIER, DISTRIBUTEUR] est bien exclu). Sous-requete NOT IN pour ne
* pas perturber le DISTINCT / ORDER BY principal.
*
* @param list<string> $excludeCategoryCodes
*/
private function applyExcludeCategoryCodes(QueryBuilder $qb, array $excludeCategoryCodes): void
{
$codes = $this->normalizeStringList($excludeCategoryCodes);
if ([] === $codes) {
return;
}
$sub = $this->getEntityManager()->createQueryBuilder()
->select('c3.id')
->from(Client::class, 'c3')
->join('c3.categories', 'cat3')
->where('cat3.code IN (:excludeCategoryCodes)')
;
$qb->andWhere($qb->expr()->notIn('c.id', $sub->getDQL()))
->setParameter('excludeCategoryCodes', $codes)
;
}
/**
* Restreint aux clients ayant au moins une adresse rattachee a l'un des
* sites donnes (OR RG-1.10 : les sites vivent sur les adresses, pas sur le
@@ -148,6 +148,17 @@ final class RbacSeeder
// bypass_scope ; les tickets sont filtres par SiteScopedQueryExtension).
'logistique.weighing_tickets.view',
'logistique.weighing_tickets.manage',
// Lecture des LISTES client/fournisseur pour le select de contrepartie
// du ticket de pesee (ERP-209). `read_ref` n'ouvre QUE la collection
// /clients + /suppliers (pas le repertoire sidebar, pas le detail, pas
// l'edition) -> l'Usine peut choisir un tiers sans acceder au module
// Commercial.
// /!\ RETOUR ARRIERE METIER : si l'Usine ne doit PAS voir les tiers,
// retirer ces 2 lignes + les 2 permissions read_ref de CommercialModule
// + le `or ...read_ref` des GetCollection Client/Supplier, puis
// `app:sync-permissions` + re-seed RBAC.
'commercial.clients.read_ref',
'commercial.suppliers.read_ref',
],
],
];
@@ -190,11 +190,17 @@ final class SeedE2ECommand extends Command
// p.3) : mappe sur le persona "tout". Miroir de personas.ts.
'catalog.products.view',
'catalog.products.manage',
// Stockage (M7, ERP-210). Admin-only : mappe sur le persona
// "tout". Miroir de personas.ts.
'catalog.storages.view',
'catalog.storages.manage',
// Commercial — Repertoire clients (M1). Mappe ici sur le
// persona "tout" en attendant les vrais roles metier
// (bureau/compta/commerciale/usine) seedes par ERP-74.
// Miroir de frontend/tests/e2e/_fixtures/personas.ts.
'commercial.clients.view',
// Lecture liste seule pour le select de contrepartie pesee (ERP-209).
'commercial.clients.read_ref',
'commercial.clients.manage',
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
@@ -203,6 +209,8 @@ final class SeedE2ECommand extends Command
// logique que les clients : mappe sur le persona "tout".
// Miroir de frontend/tests/e2e/_fixtures/personas.ts.
'commercial.suppliers.view',
// Lecture liste seule pour le select de contrepartie pesee (ERP-209).
'commercial.suppliers.read_ref',
'commercial.suppliers.manage',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Event\LogoutEvent;
/**
* Logout d'une API JWT stateless : renvoie « 204 No Content » au lieu de la
* redirection 302 par defaut de Symfony.
*
* Pourquoi : le `DefaultLogoutListener` pose toujours une RedirectResponse (vers
* le `target` configure, ou `/` par defaut). Cote navigateur, `fetch` suit cette
* 302 ; le Location est resolu en URL absolue a partir du Host de la requete, et
* en dev ce Host est l'upstream du proxy Nuxt (« nginx »), non resolvable par le
* navigateur => `ERR_NAME_NOT_RESOLVED` apres ~3 s de timeout DNS avant l'echec
* de la promesse (en prod, c'est un GET parasite de la page cible). Une API
* consommee en fetch ne doit pas rediriger : 204 suffit.
*
* On s'enregistre a une priorite NEGATIVE pour passer APRES les listeners par
* defaut (DefaultLogoutListener priorite 64, CookieClearingLogoutListener
* priorite 0) : la reponse et les Set-Cookie de suppression du BEARER sont alors
* deja en place, on se contente de retrograder la redirection en 204 en
* conservant les en-tetes (donc le cookie BEARER reste efface).
*/
final class ApiLogoutSuccessListener implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
LogoutEvent::class => ['onLogout', -255],
];
}
public function onLogout(LogoutEvent $event): void
{
$response = $event->getResponse();
// Aucun listener par defaut n'a pose de reponse : on cree directement la 204.
if (null === $response) {
$event->setResponse(new Response(null, Response::HTTP_NO_CONTENT));
return;
}
// Retrograde la redirection (ou toute autre reponse) en 204 sans toucher
// aux en-tetes Set-Cookie deja poses (suppression du BEARER).
$response->setStatusCode(Response::HTTP_NO_CONTENT);
$response->setContent(null);
$response->headers->remove('Location');
}
}
@@ -20,6 +20,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -184,6 +185,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
/** Contrepartie « Autre » (libelle libre) — RG-5.03. */
public const string COUNTERPARTY_AUTRE = 'AUTRE';
/** Plafond des poids/DSD saisis a la main (5 chiffres) — cf. validateManualEntryDigits. */
public const int MANUAL_VALUE_MAX = 99999;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -223,6 +227,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
/** Libelle libre — requis ssi counterpartyType = AUTRE (RG-5.03). */
#[ORM\Column(name: 'other_label', length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le libellé ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
private ?string $otherLabel = null;
@@ -379,6 +384,25 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
}
}
/**
* Plafond des valeurs SAISIES A LA MAIN (poids et DSD) : 5 chiffres, soit
* 99999. Garde-fou serveur du masque front 5 chiffres de la modale de pesee
* manuelle. Ne s'applique QU'EN mode MANUAL (decision metier) :
* - le poids AUTO (pont-bascule) tient deja dans 5 chiffres (10000-50000) ;
* - le DSD AUTO est un compteur de site croissant (DsdAllocator) qu'on ne
* doit PAS contraindre, sinon l'allocation echouerait au-dela de 99999.
* Jouee dans le groupe Default (POST/PATCH brouillon ET validate) -> chaque
* 422 est mappee inline sous le champ via useFormErrors (ERP-101).
*/
#[Assert\Callback]
public function validateManualEntryDigits(ExecutionContextInterface $context): void
{
$this->assertManualDigitCap($context, $this->emptyMode, $this->emptyWeight, 'emptyWeight', 'Le poids saisi ne peut pas dépasser 5 chiffres (99999 kg maximum).');
$this->assertManualDigitCap($context, $this->emptyMode, $this->emptyDsd, 'emptyDsd', 'Le DSD saisi ne peut pas dépasser 5 chiffres (99999 maximum).');
$this->assertManualDigitCap($context, $this->fullMode, $this->fullWeight, 'fullWeight', 'Le poids saisi ne peut pas dépasser 5 chiffres (99999 kg maximum).');
$this->assertManualDigitCap($context, $this->fullMode, $this->fullDsd, 'fullDsd', 'Le DSD saisi ne peut pas dépasser 5 chiffres (99999 maximum).');
}
/**
* Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si
* disponible, sinon date de la pesee a vide. Getter calcule (jamais
@@ -646,4 +670,14 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
return $this;
}
private function assertManualDigitCap(ExecutionContextInterface $context, ?string $mode, ?int $value, string $path, string $message): void
{
if ('MANUAL' === $mode && null !== $value && $value > self::MANUAL_VALUE_MAX) {
$context->buildViolation($message)
->atPath($path)
->addViolation()
;
}
}
}
@@ -6,6 +6,7 @@ namespace App\Module\Logistique\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighbridgeReadingProcessor;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@@ -59,6 +60,7 @@ final class WeighbridgeReadingResource
* fournit le poids). En sortie : poids effectif de la pesee.
*/
#[Assert\Positive(message: 'Le poids doit être un entier positif (kg).')]
#[Assert\LessThanOrEqual(value: WeighingTicket::MANUAL_VALUE_MAX, message: 'Le poids saisi ne peut pas dépasser 5 chiffres (99999 kg maximum).')]
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
public ?int $weight = null;
@@ -68,6 +70,7 @@ final class WeighbridgeReadingResource
* (l'obligation en MANUAL est portee par le Callback ci-dessous).
*/
#[Assert\Positive(message: 'Le DSD doit être un entier positif.')]
#[Assert\LessThanOrEqual(value: WeighingTicket::MANUAL_VALUE_MAX, message: 'Le DSD saisi ne peut pas dépasser 5 chiffres (99999 maximum).')]
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
public ?int $dsd = null;
@@ -610,6 +610,20 @@ final class ColumnCommentsCatalog
'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.',
'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.',
],
// M7 Catalog (ERP-212) — table desormais mappee par l'entite Storage :
// schema:update (test) la recree sans COMMENT -> app:apply-column-comments
// les rejoue depuis ce catalogue. Strings identiques aux COMMENT de la
// migration Version20260629120000 (ERP-211).
'storage' => [
'_table' => 'Emplacements de stockage (M7 Catalog) — un stockage = 1 site + 1 type (storage_type) + 1 numero, etats multi-valeur JSONB, soft-delete + Timestampable/Blamable.',
'id' => 'Identifiant interne auto-incremente.',
'site_id' => 'Site du stockage. FK -> site.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).',
'storage_type_id' => 'Type de stockage (referentiel M6). FK -> storage_type.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).',
'numero' => 'Numero du stockage (≤ 50), saisi. Unique par (site, type) parmi les actifs (RG-7.01, uq_storage_site_type_numero_active). Normalise serveur.',
'states' => 'Etats du stockage (JSON) : tableau non vide (>= 1 element, RG-7.04, chk_storage_states_not_empty). Multi-valeur.',
'deleted_at' => 'Horodatage du soft-delete technique — null = ligne active. Une ligne supprimee sort de l unicite metier (index partiel uq_storage_site_type_numero_active).',
] + self::timestampableBlamableComments(),
];
}
@@ -5,10 +5,15 @@ declare(strict_types=1);
namespace App\Shared\Infrastructure\Export;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use RuntimeException;
use function is_string;
/**
* Implementation XLSX du contrat d'export via la librairie PhpSpreadsheet.
*
@@ -31,19 +36,45 @@ final class PhpSpreadsheetExporter implements SpreadsheetExporterInterface
$sheet->setTitle($this->sanitizeSheetTitle($sheetTitle));
// Ligne 1 : en-tete.
$sheet->fromArray($headers, null, 'A1');
$this->writeRow($sheet, $headers, 1);
// Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable
// paresseux (generator) sans tout materialiser en memoire.
$rowNumber = 2;
foreach ($rows as $row) {
$sheet->fromArray($row, null, 'A'.$rowNumber);
$this->writeRow($sheet, $row, $rowNumber);
++$rowNumber;
}
return $this->toBinary($spreadsheet);
}
/**
* Ecrit une ligne cellule par cellule. Toute valeur CHAINE est ecrite en type
* STRING explicite (jamais interpretee comme formule), ce qui neutralise
* l'injection de formules / DDE (« CSV / Formula injection ») : une cellule dont
* la valeur commence par `=` `+` `-` `@` (saisie utilisateur, ex. un numero) n'est
* pas evaluee a l'ouverture du fichier, et ce SANS apostrophe visible. Les valeurs
* non-chaines (int / float / null) gardent leur type naturel.
*
* @param list<null|scalar> $row
*/
private function writeRow(Worksheet $sheet, array $row, int $rowNumber): void
{
$column = 1;
foreach ($row as $value) {
$coordinate = Coordinate::stringFromColumnIndex($column).$rowNumber;
if (is_string($value)) {
$sheet->setCellValueExplicit($coordinate, $value, DataType::TYPE_STRING);
} else {
$sheet->setCellValue($coordinate, $value);
}
++$column;
}
}
private function toBinary(Spreadsheet $spreadsheet): string
{
$writer = new Xlsx($spreadsheet);
@@ -83,7 +83,8 @@
</tr>
</table>
<div class="title">Ticket de pesée</div>
{# Numéro accolé au titre (ex. « Ticket de pesée 86-TP-0001 ») ; absent en brouillon (numéro attribué à la validation). #}
<div class="title">Ticket de pesée{% if ticket.number %} {{ ticket.number }}{% endif %}</div>
{#
DSD de la pesée : valeur du pont en AUTO, valeur saisie par l'opérateur en
@@ -92,6 +92,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
Assert\NotNull::class,
Assert\Email::class,
Assert\Choice::class,
Assert\Unique::class,
Assert\Regex::class,
Assert\Bic::class,
Assert\Iban::class,
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Classe de base des tests fonctionnels de l'entite Storage (M7, module Catalog).
*
* Etend la base Catalog (helpers d'auth + personas metier) et ajoute ce qu'il faut
* pour exercer l'API stockage de bout en bout :
* - `seedStorageType()` : type de stockage de test (code prefixe pour cleanup).
* - `firstSite()` / `siteByCode()` : sites fixtures (86 / 17 / 82).
* - `authView()` : user non-admin portant la permission `catalog.storages.view`.
* - `validStoragePayload()` : payload POST de reference (IRIs site / storageType),
* surchargeable par cle.
* - `seedStorageEntity()` : seede un stockage via l'EM (id existant, soft-deleted).
* - `iri()` / `memberById()` / `violationPaths()` : utilitaires Hydra.
*
* Cleanup : on purge les stockages (toute la table aucune fixture stockage en env
* test) AVANT le parent, car storage reference site / storage_type en FK ON DELETE
* RESTRICT. Les types de stockage de test (prefixe code) sont purges dans la foulee.
*
* @internal
*/
abstract class AbstractStorageApiTestCase extends AbstractCatalogApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
/** Prefixe des codes de StorageType seedes par ces tests (purge ciblee). */
protected const string TEST_STORAGE_TYPE_PREFIX = 'TESTSTO';
protected function tearDown(): void
{
$em = $this->getEm();
// Stockages d'abord : ils referencent site / storage_type en FK RESTRICT.
$em->createQuery('DELETE FROM '.Storage::class)->execute();
// Types de stockage de test (prefixe code).
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%')
->execute()
;
parent::tearDown();
}
/**
* Cree un type de stockage de test (code prefixe TESTSTO pour le cleanup).
*/
protected function seedStorageType(string $label = 'Cellule test'): StorageType
{
$em = $this->getEm();
$storageType = new StorageType();
$storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
$storageType->setLabel($label);
$em->persist($storageType);
$em->flush();
return $storageType;
}
protected function siteByCode(string $code): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]);
self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code));
return $site;
}
protected function firstSite(): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).');
return $site;
}
/**
* Client non-admin portant seulement `catalog.storages.view`.
*/
protected function authView(): Client
{
$creds = $this->createUserWithPermission('catalog.storages.view');
return $this->authenticatedClient($creds['username'], $creds['password']);
}
/**
* Payload POST de reference : un stockage valide (1 site, 1 type, 1 numero,
* 1 etat). Surchargeable par cle via $overrides (ex: ['numero' => 'A1']).
*
* @param array<string, mixed> $overrides
*
* @return array<string, mixed>
*/
protected function validStoragePayload(array $overrides = []): array
{
$site = $this->firstSite();
$storageType = $this->seedStorageType();
$base = [
'site' => $this->iri('sites', (int) $site->getId()),
'storageType' => $this->iri('storage_types', (int) $storageType->getId()),
'numero' => $this->uniqueCode('NUM'),
'states' => [Storage::STATE_RECEPTION],
];
return array_replace($base, $overrides);
}
/**
* Seede un stockage directement via l'EM (bypass Processor/Validator). Utile pour
* disposer d'un id existant (RBAC item, PATCH) ou d'un stockage soft-deleted
* (reutilisation du triplet RG-7.01). Le site / le type manquants sont crees
* a la volee.
*
* @param list<string> $states
*/
protected function seedStorageEntity(
?string $numero = null,
array $states = [Storage::STATE_RECEPTION],
?DateTimeImmutable $deletedAt = null,
?Site $site = null,
?StorageType $storageType = null,
): Storage {
$em = $this->getEm();
$site ??= $this->firstSite();
$storage = new Storage();
$storage->setSite($em->getReference(Site::class, (int) $site->getId()));
$storage->setStorageType($storageType ?? $this->seedStorageType('Seed'));
$storage->setNumero($numero ?? $this->uniqueCode('NUM'));
$storage->setStates($states);
$storage->setDeletedAt($deletedAt);
$em->persist($storage);
$em->flush();
return $storage;
}
/**
* Construit un IRI API Platform (`/api/{resource}/{id}`).
*/
protected function iri(string $resource, int $id): string
{
return sprintf('/api/%s/%d', $resource, $id);
}
/**
* Identifiant unique de test (prefixe + nonce), deja en MAJUSCULE.
*/
protected function uniqueCode(string $prefix): string
{
return $prefix.'_'.strtoupper(substr(bin2hex(random_bytes(5)), 0, 10));
}
/**
* Extrait les `propertyPath` des violations d'une reponse 422.
*
* @return list<string>
*/
protected function violationPaths(ResponseInterface $response): array
{
$body = $response->toArray(false);
return array_values(array_map(
static fn (array $violation): string => (string) ($violation['propertyPath'] ?? ''),
$body['violations'] ?? [],
));
}
/**
* Retrouve un membre d'une collection Hydra par son id (ou null).
*
* @param array<string, mixed> $list
*
* @return null|array<string, mixed>
*/
protected function memberById(array $list, int $id): ?array
{
foreach ($list['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Storage;
/**
* RG-7.05 : `displayName` (getter virtuel, non persiste) = « <label du type> <numero> ».
*
* On asserte sur le CORPS JSON reel renvoye par l'API (pas sur le getter PHP), pour
* figer le contrat consomme par le front.
*
* @internal
*/
final class StorageDisplayNameTest extends AbstractStorageApiTestCase
{
public function testDisplayNameConcatenatesLabelAndNumero(): void
{
$site = $this->firstSite();
$type = $this->seedStorageType('Boisseau');
$numero = $this->uniqueCode('NUM');
$client = $this->createAdminClient();
$created = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'site' => $this->iri('sites', (int) $site->getId()),
'storageType' => $this->iri('storage_types', (int) $type->getId()),
'numero' => $numero,
'states' => [Storage::STATE_RECEPTION],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Boisseau '.$numero, $created['displayName'] ?? null);
}
}
@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Storage;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX des stockages (M7, § 4.5) ERP-214.
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes de colonnes),
* exclusion des stockages soft-deleted par defaut (RG-7.07), respect des filtres
* ?search (numero) / ?storageTypeId / ?state, peuplement des colonnes metier
* (displayName, site « Nom (Code) », type, numero, etats joints, dates), 403 sans
* catalog.storages.view, 401 anonyme.
*
* @internal
*/
final class StorageExportControllerTest extends AbstractStorageApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/storages/export.xlsx';
public function testExportReturnsXlsxResponseWithHeaderRow(): void
{
$client = $this->createAdminClient();
$this->seedStorageEntity('NUM-A');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="stockages-', $disposition);
self::assertMatchesRegularExpression('/filename="stockages-\d{8}\.xlsx"/', $disposition);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes (§ 4.5).
$headerCells = $this->gridFromResponse($response->getContent())[0];
self::assertSame('Nom', $headerCells[0]);
self::assertSame('Site', $headerCells[1]);
self::assertSame('Type de stockage', $headerCells[2]);
self::assertSame('Numéro', $headerCells[3]);
self::assertSame('États', $headerCells[4]);
self::assertSame('Créé le', $headerCells[5]);
self::assertSame('Modifié le', $headerCells[6]);
// Au moins une ligne de donnees (le stockage seede) reperee par son numero.
self::assertContains('NUM-A', $this->numeros($response->getContent()));
}
public function testExportExcludesSoftDeletedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedStorageEntity('NUM-ACTIVE');
$this->seedStorageEntity('NUM-DELETED', deletedAt: new DateTimeImmutable());
$numeros = $this->numeros($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('NUM-ACTIVE', $numeros);
self::assertNotContains('NUM-DELETED', $numeros);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedStorageEntity('ALPHA-1');
$this->seedStorageEntity('BETA-2');
$numeros = $this->numeros(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('ALPHA-1', $numeros);
self::assertNotContains('BETA-2', $numeros);
}
public function testExportRespectsStorageTypeFilter(): void
{
$client = $this->createAdminClient();
$typeA = $this->seedStorageType('Cellule A');
$typeB = $this->seedStorageType('Cellule B');
$this->seedStorageEntity('TYPE-A', storageType: $typeA);
$this->seedStorageEntity('TYPE-B', storageType: $typeB);
$numeros = $this->numeros(
$client->request('GET', self::EXPORT_URL.'?storageTypeId='.$typeA->getId())->getContent(),
);
self::assertContains('TYPE-A', $numeros);
self::assertNotContains('TYPE-B', $numeros);
}
public function testExportRespectsStateFilter(): void
{
$client = $this->createAdminClient();
$this->seedStorageEntity('STATE-PROD', [Storage::STATE_PRODUCTION]);
$this->seedStorageEntity('STATE-RECEP', [Storage::STATE_RECEPTION]);
$numeros = $this->numeros(
$client->request('GET', self::EXPORT_URL.'?state=PRODUCTION')->getContent(),
);
self::assertContains('STATE-PROD', $numeros);
self::assertNotContains('STATE-RECEP', $numeros);
}
public function testExportPopulatesAllBusinessColumns(): void
{
$client = $this->createAdminClient();
$site = $this->firstSite();
$type = $this->seedStorageType('Cellule');
$this->seedStorageEntity(
'C3',
[Storage::STATE_RECEPTION, Storage::STATE_TRIAGE],
site: $site,
storageType: $type,
);
$row = $this->rowForNumero($client->request('GET', self::EXPORT_URL)->getContent(), 'C3');
self::assertNotNull($row, 'Le stockage seede est absent de l\'export.');
// 0 Nom | 1 Site | 2 Type | 3 Numéro | 4 États | 5 Créé le | 6 Modifié le
self::assertSame('Cellule C3', $row[0]);
self::assertSame(sprintf('%s (%s)', $site->getName(), $site->getCode()), $row[1]);
self::assertSame('Cellule', $row[2]);
self::assertSame('C3', $row[3]);
// Ordre canonique (Réception avant Triage) independamment de l'ordre en base.
self::assertSame('Réception, Triage', $row[4]);
// Dates renseignees (Timestampable) au format jj/mm/aaaa hh:mm.
self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[5]);
self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[6]);
}
public function testFormulaInjectionIsNeutralized(): void
{
$client = $this->createAdminClient();
// Numero malicieux commencant par « = » (injection de formule / DDE). Seede en
// direct (le numero contournerait de toute facon le normalizer, qui ne fait
// qu'un trim). L'export doit le restituer comme TEXTE litteral, jamais comme
// une formule evaluee : si la cellule etait une formule, IOFactory::load la
// calculerait (resultat 3 ou erreur) et « =1+2 » serait absent de la colonne.
$this->seedStorageEntity('=1+2');
$numeros = $this->numeros($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('=1+2', $numeros, 'Le numero « =1+2 » doit etre stocke en texte, pas evalue.');
}
public function testExportKeepsSearchTermZero(): void
{
$client = $this->createAdminClient();
$this->seedStorageEntity('0');
$this->seedStorageEntity('X1');
// « 0 » est un numero valide : le filtre ?search=0 NE DOIT PAS etre coerce a
// null (parite stricte avec la liste a l'ecran via StorageListFilters).
$numeros = $this->numeros($client->request('GET', self::EXPORT_URL.'?search=0')->getContent());
self::assertContains('0', $numeros);
self::assertNotContains('X1', $numeros);
}
public function testExportToleratesArrayShapedScalarParam(): void
{
$client = $this->createAdminClient();
$this->seedStorageEntity('NUM-ARR');
// ?search[]=foo : parametre tableau la ou un scalaire est attendu. L'export ne
// doit pas planter en 400 (la liste le tolere) : la valeur est simplement
// ignoree -> 200 avec tous les stockages.
$response = $client->request('GET', self::EXPORT_URL.'?search[]=foo');
self::assertResponseIsSuccessful();
self::assertContains('NUM-ARR', $this->numeros($response->getContent()));
}
public function testForbiddenWithoutStoragesViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_storage_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Numéro » (4e colonne, index 3) des lignes de donnees.
*
* @return list<string>
*/
private function numeros(string $binary): array
{
$rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[3] ?? ''), $rows));
}
/**
* Renvoie la ligne de donnees dont la colonne « Numéro » vaut $numero, ou null.
*
* @return null|array<int, mixed>
*/
private function rowForNumero(string $binary, string $numero): ?array
{
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
if ((string) ($row[3] ?? '') === $numero) {
return $row;
}
}
return null;
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RBAC du stockage (M7, ERP-210 admin-only). Jumeau du ProductRBACMatrixTest.
*
* La matrice est volontairement tres restrictive : seul l'Admin porte
* `catalog.storages.view` / `.manage`. Les 4 personas metier MALIO (Bureau, Compta,
* Commerciale, Usine) n'ont AUCUNE permission stockage -> 403 partout. Un porteur de
* `view` lit (200) mais ne peut pas creer (403). Anonyme -> 401.
*
* @internal
*/
final class StorageRBACMatrixTest extends AbstractStorageApiTestCase
{
/** Personas metier sans permission stockage (admin-only). */
private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine'];
public function testAdminHasFullAccess(): void
{
$client = $this->createAdminClient();
$client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(),
]);
self::assertResponseStatusCodeSame(201);
}
public function testBusinessPersonasAreForbiddenEverywhere(): void
{
$storage = $this->seedStorageEntity();
$id = (int) $storage->getId();
foreach (self::PERSONAS as $persona) {
$client = $this->createPersonaClient($persona);
$client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les stockages.');
$client->request('GET', '/api/storages/'.$id, ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un stockage.');
$client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(),
]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de stockage.');
$client->request('PATCH', '/api/storages/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['numero' => 'X'],
]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un stockage.');
}
}
public function testViewPermissionReadsButCannotManage(): void
{
$storage = $this->seedStorageEntity();
$client = $this->authView();
$client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$client->request('GET', '/api/storages/'.$storage->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// view sans manage : creation refusee au niveau securite (403).
$client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(),
]);
self::assertResponseStatusCodeSame(403);
}
public function testAnonymousIsUnauthorized(): void
{
$client = self::createClient();
$client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(401);
}
}
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Storage;
use DateTimeImmutable;
/**
* Contrat de serialisation du stockage (M7, spec-back § 4.0 / § 4.0.bis).
* Jumeau du ProductSerializationContractTest (M6).
*
* Capture le JSON REEL (liste + detail) via un stockage cree par l'API (POST reel,
* normalisation serveur reelle) et reverifie les pieges du RETEX M1 transposes au
* M7 :
* #1 : `site` sort en OBJET embarque (site:read), jamais en IRI nu.
* #2 : `storageType` sort en OBJET embarque (storage_type:read), jamais en IRI nu.
* #3 : `states` = tableau de chaines.
* #4 : `displayName` present et correct (RG-7.05 : « <label> <numero> »).
*
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
* DoD (§ 4.0.bis) : avec STORAGE_DOD_DUMP positionnee, ecrit les corps liste +
* detail sous /tmp pour les coller dans la spec avant les ecrans front.
*
* @internal
*/
final class StorageSerializationContractTest extends AbstractStorageApiTestCase
{
public function testListAndDetailSerializationContract(): void
{
$client = $this->createAdminClient();
$site = $this->firstSite();
$type = $this->seedStorageType('Cellule');
$numero = $this->uniqueCode('NUM');
// Stockage cree par un POST reel (2 etats pour exercer le tableau).
$created = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'site' => $this->iri('sites', (int) $site->getId()),
'storageType' => $this->iri('storage_types', (int) $type->getId()),
'numero' => $numero,
'states' => [Storage::STATE_RECEPTION, Storage::STATE_TRIAGE],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
$id = (int) $created['id'];
$detail = $client->request('GET', '/api/storages/'.$id, [
'headers' => ['Accept' => self::LD],
])->toArray();
$list = $client->request('GET', '/api/storages?search='.$numero, [
'headers' => ['Accept' => self::LD],
])->toArray();
// Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:).
self::assertArrayHasKey('member', $list);
self::assertArrayNotHasKey('hydra:member', $list);
$row = $this->memberById($list, $id);
self::assertNotNull($row, 'Le stockage cree doit apparaitre dans la liste filtree.');
// === Piege #1 : site en OBJET embarque (pas IRI nu) ===
self::assertIsArray($row['site'], 'site doit etre un objet embarque (site:read), pas un IRI nu.');
self::assertArrayHasKey('name', $row['site']);
self::assertArrayHasKey('code', $row['site']);
// === Piege #2 : storageType en OBJET embarque (pas IRI nu) ===
self::assertIsArray($row['storageType'], 'storageType doit etre un objet embarque (storage_type:read), pas un IRI nu.');
self::assertArrayHasKey('label', $row['storageType']);
self::assertSame('Cellule', $row['storageType']['label']);
// === Piege #3 : states tableau de chaines ===
self::assertSame([Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], $row['states']);
// === Piege #4 : displayName present + correct (RG-7.05) ===
self::assertArrayHasKey('displayName', $row);
self::assertSame('Cellule '.$numero, $row['displayName']);
// === Piege #5 : le soft-delete n'est JAMAIS expose (§ 2.8) ===
// `deletedAt` n'appartient a aucun groupe de lecture : un test « contrat » doit
// garantir son ABSENCE, pas seulement la presence des champs attendus — sinon
// un ajout accidentel a storage:read passerait au vert. (createdBy/updatedBy
// sont, eux, exposes a dessein via la convention `default:read` du Trait
// Timestampable/Blamable — au meme titre que createdAt/updatedAt.)
self::assertArrayNotHasKey('deletedAt', $row, 'deletedAt ne doit pas etre expose en liste (§ 2.8).');
self::assertArrayNotHasKey('deletedAt', $detail, 'deletedAt ne doit pas etre expose en detail (§ 2.8).');
// === DETAIL : memes garanties d'embarquement ===
self::assertIsArray($detail['site']);
self::assertArrayHasKey('name', $detail['site']);
self::assertIsArray($detail['storageType']);
self::assertArrayHasKey('label', $detail['storageType']);
self::assertSame([Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], $detail['states']);
self::assertSame('Cellule '.$numero, $detail['displayName']);
$this->dumpDodIfRequested($list, $detail);
}
/**
* RG-7.07 : la liste (et le detail) n'exposent JAMAIS un stockage soft-deleted.
* On seede 1 actif + 1 supprime pour que l'assertion de liste soit discriminante
* (sinon, avec une collection vide, « absent » ne distingue pas l'exclusion du
* soft-delete d'une page vide).
*/
public function testSoftDeletedIsNotExposed(): void
{
$active = $this->seedStorageEntity('SD-ACTIVE');
$deleted = $this->seedStorageEntity('SD-DELETED', deletedAt: new DateTimeImmutable());
$client = $this->createAdminClient();
// Item soft-deleted -> 404 (§ 2.8).
$client->request('GET', '/api/storages/'.$deleted->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(404);
// Collection : l'actif est present, le supprime est absent (RG-7.07).
$list = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotNull($this->memberById($list, (int) $active->getId()), 'Le stockage actif doit etre liste.');
self::assertNull($this->memberById($list, (int) $deleted->getId()), 'Le stockage soft-deleted ne doit pas etre liste.');
}
/**
* DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si STORAGE_DOD_DUMP est
* positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis.
*
* @param array<string, mixed> $list
* @param array<string, mixed> $detail
*/
private function dumpDodIfRequested(array $list, array $detail): void
{
if (false === getenv('STORAGE_DOD_DUMP')) {
return;
}
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/storage-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/storage-dod-detail.json', json_encode($detail, $flags));
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Storage;
/**
* RG-7.04 : `states` = multi-select {RECEPTION, PRODUCTION, TRIAGE}, au moins 1
* requis. RG-7.08 : le PATCH applique les memes regles que le POST.
*
* Couvre :
* - tableau d'etats vide -> 422 (Assert\Count(min: 1)) sur le champ `states` ;
* - valeur hors enum -> 422 (Assert\Choice) sur le champ `states` ;
* - un seul etat valide -> 201 (borne basse acceptee) ;
* - PATCH vers un tableau d'etats vide -> 422 (RG-7.08).
*
* @internal
*/
final class StorageStatesValidationTest extends AbstractStorageApiTestCase
{
public function testEmptyStatesIsRejected(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['states' => []]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testUnknownStateValueIsRejected(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['states' => [Storage::STATE_RECEPTION, 'FOOBAR']]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testSingleValidStateIsAccepted(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['states' => [Storage::STATE_PRODUCTION]]),
]);
self::assertResponseStatusCodeSame(201);
}
public function testPatchToEmptyStatesIsRejected(): void
{
$storage = $this->seedStorageEntity();
// RG-7.08 : la regle RG-7.04 vaut aussi en edition.
$client = $this->createAdminClient();
$response = $client->request('PATCH', '/api/storages/'.$storage->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['states' => []],
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testDuplicateStatesAreRejected(): void
{
$client = $this->createAdminClient();
// Doublon dans le multi-select : 422 (Assert\Unique), pas un stockage avec un
// tableau d'etats incoherent (RG-7.04 = sous-ensemble).
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload([
'states' => [Storage::STATE_TRIAGE, Storage::STATE_TRIAGE],
]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testNonSequentialStatesDoNotCrash(): void
{
$client = $this->createAdminClient();
// `states` envoye comme OBJET JSON (cle non sequentielle) : auparavant
// persiste tel quel en JSONB objet -> le CHECK jsonb_array_length plantait en
// 500. Doit desormais etre renormalise en liste sequentielle (array_values du
// setter), donc accepte proprement sans 500.
$created = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload([
'states' => [7 => Storage::STATE_RECEPTION],
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame([Storage::STATE_RECEPTION], $created['states']);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RG-7.03 : « le type de stockage doit etre disponible sur le site choisi ».
*
* VOLONTAIREMENT NON IMPLEMENTEE (decision validee avec Tristan, ERP-213) : le
* concept type<->site a ete RETIRE du modele en M6. La jointure storage_type_site a
* ete droppee (migration Version20260626100000) et StorageType est devenu un
* referentiel PLAT, sans relation `sites` l'entite le documente explicitement
* (« un type n'est PAS rattache a des sites ; la dispo releve de la future entite
* Stockage »). C'est desormais l'entite Storage (1 site + 1 type) qui MATERIALISE
* cette disponibilite ; il n'existe plus de referentiel a interroger pour lever une
* 422. RG-7.03 est donc inimplementable telle quelle.
*
* Ce test est conserve (skippe) pour la TRACABILITE DoD : il documente le gap dans
* la suite et devra etre reactive si la spec reintroduit un lien type<->site.
*
* @internal
*/
final class StorageTypeBySiteTest extends AbstractStorageApiTestCase
{
public function testTypeUnavailableOnSiteIsRejected(): void
{
self::markTestSkipped(
'RG-7.03 non portee : StorageType est un referentiel plat depuis le M6 '
.'(jointure storage_type_site droppee). Aucun referentiel type<->site a '
.'interroger. A reclarifier cote spec (cf. ERP-213).',
);
}
}
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use function count;
/**
* RG-7.01 : unicite metier du triplet (site, storageType, numero) parmi les ACTIFS.
* RG-7.08 : le PATCH applique les memes regles que le POST.
*
* Couvre :
* - 409 sur doublon de triplet actif (pre-check deterministe du Processor) ;
* - meme numero accepte sur un AUTRE site, ou sur un AUTRE type (unicite portee
* par le triplet complet, pas le seul numero) ;
* - reutilisation possible d'un triplet porte par un stockage soft-deleted (l'index
* partiel uq_storage_site_type_numero_active ne contraint que les actifs) ;
* - PATCH d'un numero vers un triplet deja pris -> 409 (RG-7.08).
*
* @internal
*/
final class StorageUniquenessTest extends AbstractStorageApiTestCase
{
public function testDuplicateActiveTripletReturns409(): void
{
$site = $this->firstSite();
$type = $this->seedStorageType();
$this->seedStorageEntity('A1', site: $site, storageType: $type);
$client = $this->createAdminClient();
$client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->tripletPayload($site, $type, 'A1'),
]);
self::assertResponseStatusCodeSame(409);
}
public function testSameNumeroOnAnotherTypeIsAccepted(): void
{
$site = $this->firstSite();
$typeA = $this->seedStorageType();
$typeB = $this->seedStorageType();
$this->seedStorageEntity('A1', site: $site, storageType: $typeA);
$client = $this->createAdminClient();
$client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->tripletPayload($site, $typeB, 'A1'),
]);
self::assertResponseStatusCodeSame(201);
}
public function testSameNumeroOnAnotherSiteIsAccepted(): void
{
$sites = $this->getEm()->getRepository(Site::class)->findAll();
if (count($sites) < 2) {
self::markTestSkipped('Au moins 2 sites fixtures requis pour ce cas.');
}
$type = $this->seedStorageType();
$this->seedStorageEntity('A1', site: $sites[0], storageType: $type);
$client = $this->createAdminClient();
$client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->tripletPayload($sites[1], $type, 'A1'),
]);
self::assertResponseStatusCodeSame(201);
}
public function testSoftDeletedTripletCanBeReused(): void
{
$site = $this->firstSite();
$type = $this->seedStorageType();
$this->seedStorageEntity('B2', deletedAt: new DateTimeImmutable(), site: $site, storageType: $type);
$client = $this->createAdminClient();
$client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->tripletPayload($site, $type, 'B2'),
]);
self::assertResponseStatusCodeSame(201);
}
public function testPatchToExistingTripletReturns409(): void
{
$site = $this->firstSite();
$type = $this->seedStorageType();
$this->seedStorageEntity('A1', site: $site, storageType: $type);
$target = $this->seedStorageEntity('B2', site: $site, storageType: $type);
// RG-7.08 : PATCH du numero B2 -> A1 (meme site+type) collisionne -> 409.
$client = $this->createAdminClient();
$client->request('PATCH', '/api/storages/'.$target->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['numero' => 'A1'],
]);
self::assertResponseStatusCodeSame(409);
}
/**
* Payload POST minimal pour un triplet (site, type, numero) donne.
*
* @return array<string, mixed>
*/
private function tripletPayload(Site $site, StorageType $type, string $numero): array
{
return [
'site' => $this->iri('sites', (int) $site->getId()),
'storageType' => $this->iri('storage_types', (int) $type->getId()),
'numero' => $numero,
'states' => [Storage::STATE_RECEPTION],
];
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* Validation et normalisation serveur a l'ecriture du stockage (M7, POST / PATCH) :
* - RG-7.06 : le numero est trimme cote serveur (et SANS changement de casse) ;
* - numero vide -> 422 (Assert\NotBlank) sur `numero` ;
* - relation nulle (site / storageType) -> 422 (Assert\NotNull, via le chemin de
* denormalisation `collectDenormalizationErrors`) portant le bon propertyPath, et
* NON un 400 qui court-circuiterait le mapping inline front (useFormErrors,
* ERP-101).
*
* Pendant ces RG, le contrat de violation 422 (propertyPath aligne sur le champ
* front) est ce que le front consomme : on l'asserte explicitement.
*
* @internal
*/
final class StorageWriteValidationTest extends AbstractStorageApiTestCase
{
public function testNumeroIsTrimmedServerSide(): void
{
$client = $this->createAdminClient();
// RG-7.06 : numero saisi avec des espaces autour -> stocke trimme.
$created = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['numero' => ' A1 ']),
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('A1', $created['numero'], 'Le numero doit etre trimme cote serveur (RG-7.06).');
// Relecture : la normalisation est bien persistee, pas seulement reflechie.
$detail = $client->request('GET', '/api/storages/'.$created['id'], [
'headers' => ['Accept' => self::LD],
])->toArray();
self::assertSame('A1', $detail['numero']);
}
public function testBlankNumeroIsRejected(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['numero' => ' ']),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('numero', $this->violationPaths($response));
}
public function testNullSiteReturns422WithPropertyPath(): void
{
$client = $this->createAdminClient();
// Relation obligatoire a null : doit ressortir en 422 (NotNull) avec un
// propertyPath `site`, pas en 400 (collectDenormalizationErrors).
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['site' => null]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('site', $this->violationPaths($response));
}
public function testNullStorageTypeReturns422WithPropertyPath(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['storageType' => null]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('storageType', $this->violationPaths($response));
}
}
@@ -355,6 +355,29 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
self::assertNotContains('FILTRE SECTEUR CO', $names);
}
/**
* Le module Transport ecarte les courtiers de ses selects clients via
* ?excludeCategoryCode=COURTIER : tout client portant la categorie COURTIER
* est exclu (NOT IN), y compris s'il porte EN PLUS une autre categorie.
*/
public function testListExcludeCategoryCodeRemovesBrokers(): void
{
$client = $this->createAdminClient();
$this->seedClient('Exclu Distrib Co', false, 'DISTRIBUTEUR');
$this->seedClient('Exclu Courtier Co', false, 'COURTIER');
// Client multi-categories DISTRIBUTEUR + COURTIER : doit etre exclu malgre
// sa categorie DISTRIBUTEUR (l'exclusion porte sur « possede COURTIER »).
$mixed = $this->seedClient('Exclu Mixte Co', false, 'DISTRIBUTEUR');
$mixed->addCategory($this->createCategory('COURTIER'));
$this->getEm()->flush();
$names = $this->companyNames($client, '/api/clients?pagination=false&excludeCategoryCode=COURTIER');
self::assertContains('EXCLU DISTRIB CO', $names);
self::assertNotContains('EXCLU COURTIER CO', $names);
self::assertNotContains('EXCLU MIXTE CO', $names);
}
/**
* ERP-62 (drawer) : filtre Sites (?siteId[]=X) clients ayant >= 1 adresse
* rattachee au site donne.
@@ -55,15 +55,18 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
self::ensureKernelShutdown();
}
public function testUsineIsForbiddenEverywhere(): void
public function testUsineCanReadClientListButNothingElse(): void
{
$seed = $this->seedClient('Usine Target');
$client = $this->authAs('usine');
// Aucune permission : 403 sur tous les verbes.
// ERP-209 : `commercial.clients.read_ref` ouvre la LISTE seule (select de
// contrepartie du ticket de pesee) -> 200 sur la collection.
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
self::assertResponseStatusCodeSame(200);
// Mais RIEN d'autre : detail, creation et edition restent gardes par
// view/manage -> 403. (Retour arriere metier : cf. RbacSeeder ROLE_USINE.)
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
@@ -288,7 +291,8 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
);
}
// Usine : aucune permission -> reste a 403 sur les referentiels.
// Usine : `read_ref` ne couvre QUE clients/suppliers (ERP-209), pas les
// referentiels categories/sites -> reste a 403 sur ces deux-la.
$usine = $this->authAs('usine');
$usine->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /categories');
@@ -24,7 +24,8 @@ use Symfony\Component\Console\Output\NullOutput;
* - bureau : suppliers.view + manage (ni accounting, ni archive)
* - compta : suppliers.view + accounting.view + accounting.manage (PAS manage)
* - commerciale : suppliers.view + manage (PAS accounting)
* - usine : aucune permission (403 partout)
* - usine : read_ref seul -> 200 sur la LISTE (select contrepartie pesee,
* ERP-209), 403 sur detail/creation/edition
* - archive : admin seul (aucun role metier)
*
* @internal
@@ -59,14 +60,18 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
self::ensureKernelShutdown();
}
public function testUsineIsForbiddenEverywhere(): void
public function testUsineCanReadSupplierListButNothingElse(): void
{
$seed = $this->seedSupplier('Usine Target');
$client = $this->authAs('usine');
// ERP-209 : `commercial.suppliers.read_ref` ouvre la LISTE seule (select de
// contrepartie du ticket de pesee) -> 200 sur la collection.
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
self::assertResponseStatusCodeSame(200);
// Mais RIEN d'autre : detail, creation et edition restent gardes par
// view/manage -> 403. (Retour arriere metier : cf. RbacSeeder ROLE_USINE.)
$client->request('GET', '/api/suppliers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
/**
* Logout de l'API JWT stateless (POST /api/logout).
*
* Garde-fou de regression : le logout doit renvoyer 204 sans redirection. Une
* 302 (comportement Symfony par defaut via `target`) ferait suivre au `fetch`
* du front un Location absolu base sur le Host de la requete ; en dev, ce Host
* est l'upstream du proxy Nuxt (« nginx »), non resolvable par le navigateur =>
* `ERR_NAME_NOT_RESOLVED` + ~3 s de timeout DNS. Cf. ApiLogoutSuccessListener.
*
* @internal
*/
final class LogoutApiTest extends AbstractApiTestCase
{
public function testLogoutReturns204WithoutRedirectAndClearsBearerCookie(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/logout');
self::assertSame(204, $response->getStatusCode(), 'Le logout API doit renvoyer 204 No Content.');
$headers = $response->getHeaders(false);
// Aucune redirection : un fetch ne doit pas avoir de Location a suivre.
self::assertArrayNotHasKey(
'location',
$headers,
'Le logout API ne doit pas rediriger (fetch suivrait un Location absolu => ERR_NAME_NOT_RESOLVED).',
);
// Le cookie BEARER est efface (Set-Cookie expire / supprime).
$clearsBearer = false;
foreach ($headers['set-cookie'] ?? [] as $cookie) {
if (str_starts_with($cookie, 'BEARER=')
&& (str_contains($cookie, 'BEARER=deleted') || str_contains($cookie, 'Max-Age=0'))
) {
$clearsBearer = true;
}
}
self::assertTrue($clearsBearer, 'Le cookie BEARER doit etre efface au logout.');
}
}
@@ -133,6 +133,49 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
self::assertViolationOnPath($response, 'dsd');
}
public function testManualWeighingRejectsWeightOverFiveDigits(): void
{
$client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
// 100000 = 6 chiffres → au-dela du plafond 5 chiffres (99999).
'json' => ['mode' => 'MANUAL', 'weight' => 100000, 'dsd' => 16619],
]);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'weight');
}
public function testManualWeighingRejectsDsdOverFiveDigits(): void
{
$client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'dsd' => 100000],
]);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'dsd');
}
public function testManualWeighingAcceptsFiveDigitBoundary(): void
{
$client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
// 99999 = exactement 5 chiffres → derniere valeur acceptee.
'json' => ['mode' => 'MANUAL', 'weight' => 99999, 'dsd' => 99999],
]);
self::assertResponseStatusCodeSame(200);
$data = $response->toArray();
self::assertSame(99999, $data['weight']);
self::assertSame(99999, $data['dsd']);
}
/**
* Garde-fou ERP-101 (miroir AbstractWeighingTicketApiTestCase) : une 422 doit
* porter une violation sur le `propertyPath` attendu, consommable inline par
@@ -79,6 +79,43 @@ final class WeighingTicketLifecycleTest extends AbstractWeighingTicketApiTestCas
self::assertNull($body['counterpartyType'] ?? null);
}
public function testOtherLabelWithSpecialCharsIsRejected(): void
{
$http = $this->authManageOnSite($this->siteByCode('86'));
// Le back reste l'autorite (le masque front FREE_TEXT_MASK filtre deja a la
// frappe) : un libelle « Autre » avec des caracteres parasites -> 422 sur
// otherLabel (Assert\Regex FREE_TEXT), mappee inline cote front (ERP-101).
$response = $this->postTicket($http, [
'counterpartyType' => 'AUTRE',
'otherLabel' => 'Chantier ~#|<>{}',
'emptyDate' => '2026-06-17T09:00:00+02:00',
'emptyWeight' => 7150,
'emptyMode' => 'AUTO',
]);
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'otherLabel');
}
public function testOtherLabelLegitimateIsAccepted(): void
{
$http = $this->authManageOnSite($this->siteByCode('86'));
// Lettres accentuees, chiffres, espaces, parentheses, °, & : tout autorise
// par FREE_TEXT (miroir des raisons sociales Client/Fournisseur).
$body = $this->postTicket($http, [
'counterpartyType' => 'AUTRE',
'otherLabel' => 'Chantier Léon (Pôle n°2) & Cie',
'emptyDate' => '2026-06-17T09:00:00+02:00',
'emptyWeight' => 7150,
'emptyMode' => 'AUTO',
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Chantier Léon (Pôle n°2) & Cie', $body['otherLabel']);
}
public function testValidateRequiresCounterparty(): void
{
$http = $this->authManageOnSite($this->siteByCode('86'));