Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions 024c20b964 chore: bump version to v0.1.157
Auto Tag Develop / tag (push) Successful in 11s
Build & Push Docker Image / build (push) Successful in 3m6s
2026-06-29 15:45:11 +00:00
Matthieu 6ee332757c feat(infra) : branche le SDK Sentry (back + front) vers GlitchTip + CA racine MALIO
Auto Tag Develop / tag (push) Successful in 14s
Error tracking centralise : remontee des erreurs back (Symfony) et front (Nuxt)
vers l'instance GlitchTip auto-hebergee. DSN vides par defaut => SDK inerte.

Backend :
- sentry/sentry-symfony ^5.10, bundle enregistre prod-only
- config/packages/sentry.yaml : handler Monolog niveau ERROR+, ignore 4xx/AccessDenied,
  pas d'APM, release = %app.version%
- .env : bloc SENTRY_DSN documente (vide => inerte)

Frontend :
- @sentry/nuxt ^10.61, module charge uniquement si NUXT_PUBLIC_SENTRY_DSN defini
- runtimeConfig.public.sentry + source maps (hidden) + options d'upload
- sentry.client.config.ts : init cote client gardee par if (dsn)

Deploiement :
- Dockerfile : ARG Sentry au build front (prefixe inline du RUN, token non persiste)
  + CA racine interne MALIO (update-ca-certificates) pour le handshake HTTPS GlitchTip back
- build-docker.yml : --build-arg depuis les secrets Gitea
- .env.prod.example : SENTRY_DSN (back, runtime)
2026-06-29 17:43:35 +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
89 changed files with 2205 additions and 290 deletions
+7
View File
@@ -19,3 +19,10 @@ JWT_COOKIE_TTL=86400
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
###> sentry/sentry-symfony ###
# Error tracking backend → GlitchTip (projet "starseed-api"). Prod only, vide => inerte.
# À définir dans l'env de prod (PAS ici, pas de secret commité). Format :
# SENTRY_DSN=https://<clé>@<host-ou-IP>:<port>/<id-projet>
# SENTRY_DSN=
###< sentry/sentry-symfony ###
+5
View File
@@ -20,6 +20,11 @@ jobs:
run: |
docker build \
-f infra/prod/Dockerfile \
--build-arg NUXT_PUBLIC_SENTRY_DSN="${{ secrets.STARSEED_SENTRY_DSN_FRONT }}" \
--build-arg SENTRY_URL="${{ secrets.SENTRY_URL }}" \
--build-arg SENTRY_ORG="${{ secrets.SENTRY_ORG }}" \
--build-arg SENTRY_PROJECT="${{ secrets.SENTRY_PROJECT }}" \
--build-arg SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \
-t gitea.malio.fr/malio-dev/starseed:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/starseed:latest \
.
+1
View File
@@ -19,6 +19,7 @@
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
"phpoffice/phpspreadsheet": "^5.7",
"phpstan/phpdoc-parser": "^2.3",
"sentry/sentry-symfony": "^5.10",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
"symfony/dotenv": "8.0.*",
Generated
+507 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "224bae08ec63f217eabf5b2b611deaa0",
"content-hash": "b8b93695be3d3ac324dc082fbd6db78c",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2675,6 +2675,185 @@
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.12.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.12.3"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
"type": "tidelift"
}
],
"time": "2026-06-23T15:21:08+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
@@ -4159,6 +4338,50 @@
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "ralph.khattar@gmail.com"
}
],
"description": "A polyfill for getallheaders.",
"support": {
"issues": "https://github.com/ralouphie/getallheaders/issues",
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
},
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.4.0",
@@ -4239,6 +4462,202 @@
},
"time": "2026-06-18T15:10:53+00:00"
},
{
"name": "sentry/sentry",
"version": "4.29.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "d732a4da195f231cedb2a2a78ae16dd73082afa3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/d732a4da195f231cedb2a2a78ae16dd73082afa3",
"reference": "d732a4da195f231cedb2a2a78ae16dd73082afa3",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
"carthage-software/mago": "1.30.0",
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/promises": "^2.0.3",
"monolog/monolog": "^1.6|^2.0|^3.0",
"nyholm/psr7": "^1.8",
"open-telemetry/api": "^1.0",
"open-telemetry/exporter-otlp": "^1.0",
"open-telemetry/sdk": "^1.0",
"open-telemetry/sem-conv": "^1.27",
"phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^8.5.52|^9.6.34",
"spiral/roadrunner-http": "^3.6",
"spiral/roadrunner-worker": "^3.6"
},
"suggest": {
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Sentry\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "PHP SDK for Sentry (http://sentry.io)",
"homepage": "http://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.29.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-06-29T14:47:44+00:00"
},
{
"name": "sentry/sentry-symfony",
"version": "5.10.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-symfony.git",
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.1.1",
"jean85/pretty-package-versions": "^1.5||^2.0",
"php": "^7.2||^8.0",
"sentry/sentry": "^4.23.0",
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/polyfill-php80": "^1.22",
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0",
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0"
},
"require-dev": {
"doctrine/dbal": "^2.13||^3.3||^4.0",
"doctrine/doctrine-bundle": "^2.6||^3.0",
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
"masterminds/html5": "^2.8",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "1.12.5",
"phpstan/phpstan-phpunit": "1.4.0",
"phpstan/phpstan-symfony": "1.4.10",
"phpunit/phpunit": "^8.5.40||^9.6.21",
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/monolog-bundle": "^3.4||^4.0",
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0",
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"vimeo/psalm": "^4.3||^5.16.0"
},
"suggest": {
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
},
"type": "symfony-bundle",
"autoload": {
"files": [
"src/aliases.php"
],
"psr-4": {
"Sentry\\SentryBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "Symfony integration for Sentry (http://getsentry.com)",
"homepage": "http://getsentry.com",
"keywords": [
"errors",
"logging",
"sentry",
"symfony"
],
"support": {
"issues": "https://github.com/getsentry/sentry-symfony/issues",
"source": "https://github.com/getsentry/sentry-symfony/tree/5.10.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-04-01T14:50:32+00:00"
},
{
"name": "symfony/asset",
"version": "v8.0.8",
@@ -7216,6 +7635,93 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19",
"reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/http-message": "^1.0|^2.0",
"symfony/http-foundation": "^7.4|^8.0"
},
"conflict": {
"php-http/discovery": "<1.15"
},
"require-dev": {
"nyholm/psr7": "^1.1",
"php-http/discovery": "^1.15",
"psr/log": "^1.1.4|^2|^3",
"symfony/browser-kit": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/runtime": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\PsrHttpMessage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "PSR HTTP message bridge",
"homepage": "https://symfony.com",
"keywords": [
"http",
"http-message",
"psr-17",
"psr-7"
],
"support": {
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/rate-limiter",
"version": "v8.0.8",
+2
View File
@@ -8,6 +8,7 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Sentry\SentryBundle\SentryBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
@@ -24,4 +25,5 @@ return [
LexikJWTAuthenticationBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SentryBundle::class => ['prod' => true],
];
+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:
+35
View File
@@ -0,0 +1,35 @@
# Error tracking → GlitchTip (compatible SDK Sentry).
# Actif uniquement en prod (bundle enregistré prod-only dans bundles.php).
# Si SENTRY_DSN est vide/non défini, le SDK est inerte (rien n'est envoyé).
when@prod:
parameters:
# Valeur par défaut : DSN vide => Sentry désactivé tant qu'il n'est pas fourni.
env(SENTRY_DSN): ''
sentry:
dsn: '%env(SENTRY_DSN)%'
# Capture des erreurs fatales PHP via le handler. On DÉSACTIVE le listener
# kernel pour éviter les doublons avec le handler Monolog (ci-dessous) : les
# exceptions du kernel sont déjà logguées par Symfony => remontées via Monolog.
register_error_listener: false
register_error_handler: true
options:
environment: '%env(APP_ENV)%'
release: '%app.version%'
# Pas d'APM/tracing (DuckDB hors périmètre du ticket #146).
traces_sample_rate: 0.0
# Ne pas remonter les 4xx HTTP comme des erreurs (bruit).
ignore_exceptions:
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
- Symfony\Component\Security\Core\Exception\AccessDeniedException
# Handler Monolog -> Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip
# (en plus des erreurs fatales). Les $logger->error(...) métier deviennent des Issues.
# Le filtre ignore_exceptions ci-dessus s'applique aussi à ces événements.
services:
Sentry\Monolog\Handler:
arguments:
$hub: '@Sentry\State\HubInterface'
$level: !php/const Monolog\Level::Error
$bubble: true
+3 -6
View File
@@ -184,6 +184,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 +197,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.157'
+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) ;
+6 -5
View File
@@ -53,7 +53,7 @@
},
"catalog": {
"categories": "Gestion des catégories",
"products": "Produits"
"products": "Catalogue produits"
}
},
"dashboard": {
@@ -72,7 +72,7 @@
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière modification"
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
@@ -218,7 +218,7 @@
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière modification"
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
@@ -389,7 +389,7 @@
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière modification"
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
@@ -745,7 +745,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."
},
@@ -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
+22 -1
View File
@@ -40,12 +40,33 @@ export default defineNuxtConfig({
'nuxt-toast',
'@nuxtjs/i18n',
'@nuxt/icon',
// Error tracking → GlitchTip. Module charge uniquement si un DSN est fourni
// (build prod) ; en dev sans DSN, aucun overhead Sentry. Les options d'upload
// des source maps sont passees en ligne (fournies au build via secrets CI).
...(process.env.NUXT_PUBLIC_SENTRY_DSN
? [['@sentry/nuxt/module', {
sourceMapsUploadOptions: {
url: process.env.SENTRY_URL,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
},
}] as [string, Record<string, unknown>]]
: []),
],
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE
apiBase: process.env.NUXT_PUBLIC_API_BASE,
sentry: {
// DSN du projet GlitchTip "starseed-front" (vide => SDK inerte).
dsn: process.env.NUXT_PUBLIC_SENTRY_DSN || '',
environment: process.env.NODE_ENV || 'development',
},
}
},
// Source maps "hidden" : generees et uploadees vers GlitchTip pour des stacktraces
// lisibles, sans exposer les .map au navigateur.
sourcemap: {client: 'hidden'},
devServer: {
port: 3004,
},
+793 -25
View File
@@ -7,11 +7,12 @@
"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",
"@pinia/nuxt": "^0.11.3",
"@sentry/nuxt": "^10.62.0",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
@@ -57,6 +58,49 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@apm-js-collab/code-transformer": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz",
"integrity": "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==",
"license": "Apache-2.0",
"dependencies": {
"@types/estree": "^1.0.8",
"astring": "^1.9.0",
"esquery": "^1.7.0",
"meriyah": "^6.1.4",
"semifies": "^1.0.0",
"source-map": "^0.6.0"
},
"bin": {
"code-transformer": "cli.js"
}
},
"node_modules/@apm-js-collab/code-transformer-bundler-plugins": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz",
"integrity": "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==",
"license": "MIT",
"dependencies": {
"@apm-js-collab/code-transformer": "^0.15.0",
"es-module-lexer": "^2.1.0",
"magic-string": "^0.30.21",
"module-details-from-path": "^1.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@apm-js-collab/tracing-hooks": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz",
"integrity": "sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA==",
"license": "Apache-2.0",
"dependencies": {
"@apm-js-collab/code-transformer": "^0.15.0",
"debug": "^4.4.1",
"module-details-from-path": "^1.0.4"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1866,9 +1910,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",
@@ -2625,6 +2669,101 @@
"dev": true,
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.214.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz",
"integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz",
"integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/instrumentation": {
"version": "0.214.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz",
"integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.214.0",
"import-in-the-middle": "^3.0.0",
"require-in-the-middle": "^8.0.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz",
"integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz",
"integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@oxc-minify/binding-android-arm-eabi": {
"version": "0.117.0",
"resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.117.0.tgz",
@@ -4534,6 +4673,556 @@
"win32"
]
},
"node_modules/@sentry/babel-plugin-component-annotate": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz",
"integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/@sentry/browser": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.62.0.tgz",
"integrity": "sha512-uJi0yPssB3Nt/cZ8/S8opW42gaM59/6IyNtPFYD7C0ciudi/nIo5QMVpCYBBI3jnKFOIQLlsMT4pDlOLuxxNuQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.62.0",
"@sentry/core": "10.62.0",
"@sentry/feedback": "10.62.0",
"@sentry/replay": "10.62.0",
"@sentry/replay-canvas": "10.62.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser-utils": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.62.0.tgz",
"integrity": "sha512-mS9HVVuWIdye9o0xUGFmzNOBqktF4n5kugrF8NCOYYDrr5ZV8Cx7BlquHQn5UpCeViVhZtcDlEm4iOK7++Px7A==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.62.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/bundler-plugin-core": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz",
"integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.18.5",
"@sentry/babel-plugin-component-annotate": "5.3.0",
"@sentry/cli": "^2.58.5",
"dotenv": "^16.3.1",
"find-up": "^5.0.0",
"glob": "^13.0.6",
"magic-string": "~0.30.8"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@sentry/cli": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
"integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==",
"hasInstallScript": true,
"license": "FSL-1.1-MIT",
"dependencies": {
"https-proxy-agent": "^5.0.0",
"node-fetch": "^2.6.7",
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0",
"which": "^2.0.2"
},
"bin": {
"sentry-cli": "bin/sentry-cli"
},
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@sentry/cli-darwin": "2.58.6",
"@sentry/cli-linux-arm": "2.58.6",
"@sentry/cli-linux-arm64": "2.58.6",
"@sentry/cli-linux-i686": "2.58.6",
"@sentry/cli-linux-x64": "2.58.6",
"@sentry/cli-win32-arm64": "2.58.6",
"@sentry/cli-win32-i686": "2.58.6",
"@sentry/cli-win32-x64": "2.58.6"
}
},
"node_modules/@sentry/cli-darwin": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz",
"integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==",
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-arm": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz",
"integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==",
"cpu": [
"arm"
],
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-arm64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz",
"integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==",
"cpu": [
"arm64"
],
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-i686": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz",
"integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==",
"cpu": [
"x86",
"ia32"
],
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-x64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz",
"integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==",
"cpu": [
"x64"
],
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-arm64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz",
"integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==",
"cpu": [
"arm64"
],
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-i686": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz",
"integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==",
"cpu": [
"x86",
"ia32"
],
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-x64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz",
"integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==",
"cpu": [
"x64"
],
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/@sentry/cli/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@sentry/cloudflare": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/cloudflare/-/cloudflare-10.62.0.tgz",
"integrity": "sha512-oHDpXXiO3XpBO2cHiTRQpSrtQOQrsU9JsO3TZ6ukdd24IUE6Tkc3l7hWdwzKqId3nTWP1Ef0Fr+offsrEGJ6UA==",
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@sentry/core": "10.62.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.x"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
"optional": true
}
}
},
"node_modules/@sentry/conventions": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.12.0.tgz",
"integrity": "sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@sentry/core": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz",
"integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/feedback": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.62.0.tgz",
"integrity": "sha512-d0BVjJVny6qpBgGJgWL0fbcoQHjtD3z3R8EK/KzTS3RO92JX5n3A536n5D/rh0gZFgcIwiUzBXegmyPOSQn9ng==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.62.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/node": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.62.0.tgz",
"integrity": "sha512-4hoU67bJY0o3irEDMZu2UIztAOsvEqFkLXA7EUKl1LXMA3Ba1Lb32OUVqlsTypiEInSDs/BtM+aAFKojZ3P3Fw==",
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/instrumentation": "^0.214.0",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@sentry/core": "10.62.0",
"@sentry/node-core": "10.62.0",
"@sentry/opentelemetry": "10.62.0",
"@sentry/server-utils": "10.62.0",
"import-in-the-middle": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/node-core": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.62.0.tgz",
"integrity": "sha512-V7rDgbxViiHU0OpcFEDp3l41IFvWTasKHfXw8SQ6yIgtZ8VpFqmz2TR5N7X85iIOmWIvK5HV0yp0eDdsly0+rA==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.12.0",
"@sentry/core": "10.62.0",
"@sentry/opentelemetry": "10.62.0",
"import-in-the-middle": "^3.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
"@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1",
"@opentelemetry/instrumentation": ">=0.57.1 <1",
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@opentelemetry/core": {
"optional": true
},
"@opentelemetry/exporter-trace-otlp-http": {
"optional": true
},
"@opentelemetry/instrumentation": {
"optional": true
},
"@opentelemetry/sdk-trace-base": {
"optional": true
}
}
},
"node_modules/@sentry/nuxt": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/nuxt/-/nuxt-10.62.0.tgz",
"integrity": "sha512-YM9N4mH/uOJP/zr3QmQgCpQFaLzoDRh0/SoMMuNq/EEtzFZLQT6+qd5tYERMAutU4ySHinIaKYC2Gq/hEs5LtA==",
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^3.13.2",
"@sentry/browser": "10.62.0",
"@sentry/cloudflare": "10.62.0",
"@sentry/core": "10.62.0",
"@sentry/node": "10.62.0",
"@sentry/node-core": "10.62.0",
"@sentry/rollup-plugin": "^5.3.0",
"@sentry/vite-plugin": "^5.3.0",
"@sentry/vue": "10.62.0",
"local-pkg": "^1.1.2"
},
"engines": {
"node": ">=18.19.1"
},
"peerDependencies": {
"nitro": "2.x || 3.x",
"nuxt": ">=3.7.0 || 4.x || 5.x"
},
"peerDependenciesMeta": {
"nitro": {
"optional": true
}
}
},
"node_modules/@sentry/nuxt/node_modules/@nuxt/kit": {
"version": "3.21.8",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.8.tgz",
"integrity": "sha512-kg63DUPY5AHPn+9XM7u8rYcdWHXjzwfUscgRDuiC5YUciQ+xdLRhdwXelYFxEAx2nxJHossliiQXbMm/Fleivw==",
"license": "MIT",
"dependencies": {
"c12": "^3.3.4",
"consola": "^3.4.2",
"defu": "^6.1.7",
"destr": "^2.0.5",
"errx": "^0.1.0",
"exsolve": "^1.0.8",
"ignore": "^7.0.5",
"jiti": "^2.7.0",
"klona": "^2.0.6",
"knitwork": "^1.3.0",
"mlly": "^1.8.2",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"pkg-types": "^2.3.1",
"rc9": "^3.0.1",
"scule": "^1.3.0",
"semver": "^7.8.0",
"tinyglobby": "^0.2.16",
"ufo": "^1.6.4",
"unctx": "^2.5.0",
"untyped": "^2.0.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/@sentry/opentelemetry": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.62.0.tgz",
"integrity": "sha512-nFwBgtjfwgY8P5lAuQFWfAsQW1MXxuQ6kR/HtBs+A6julqwGGS2QnQ65OCWMzz6IqDEL/pRgT1405/gU+OXU3A==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.12.0",
"@sentry/core": "10.62.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
}
},
"node_modules/@sentry/replay": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.62.0.tgz",
"integrity": "sha512-rWp4hBhZOmdQhisxcKzAwTGiRk/LvWnNaElWe7nbRhjsM/usp2095yfjq4iJ47v9MtO7xxY6eUz++fLBycqXKg==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.62.0",
"@sentry/core": "10.62.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/replay-canvas": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.62.0.tgz",
"integrity": "sha512-CzPAxmpe5US/ABGA1TzpjFKOFZN5uqlzrRh/uM9/daVuzLVKIAQ0XRNxo/PPEXvlDm/PoMdI5L0qIODuIKnyyw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.62.0",
"@sentry/replay": "10.62.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/rollup-plugin": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz",
"integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==",
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "5.3.0",
"magic-string": "~0.30.8"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"rollup": ">=3.2.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@sentry/server-utils": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.62.0.tgz",
"integrity": "sha512-S5szsj6kKBhxw97b2HA98fYp/PpWXvSizlisEzb2rnL4IH6RAJ8wP05/fnth8pSywTH+gtUu+i6Wn8e8rX5HvA==",
"license": "MIT",
"dependencies": {
"@apm-js-collab/code-transformer": "^0.15.0",
"@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0",
"@apm-js-collab/tracing-hooks": "^0.10.0",
"@sentry/conventions": "^0.12.0",
"@sentry/core": "10.62.0",
"magic-string": "~0.30.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/vite-plugin": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz",
"integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==",
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "5.3.0",
"@sentry/rollup-plugin": "5.3.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@sentry/vue": {
"version": "10.62.0",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.62.0.tgz",
"integrity": "sha512-aK3E302Zx/g1dqtUU30Q0jblvCW8MsVXuzwnxM4JSgO47o0jW74zaFh1K3Ym2uQWhLvP1rV2D49BYwCMUc4ovQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.62.0",
"@sentry/core": "10.62.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@tanstack/vue-router": "^1.64.0",
"pinia": "2.x || 3.x",
"vue": "2.x || 3.x"
},
"peerDependenciesMeta": {
"@tanstack/vue-router": {
"optional": true
},
"pinia": {
"optional": true
}
}
},
"node_modules/@simple-git/args-pathspec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.2.tgz",
@@ -6560,6 +7249,15 @@
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/astring": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz",
"integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==",
"license": "MIT",
"bin": {
"astring": "bin/astring"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -7152,6 +7850,12 @@
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"license": "MIT"
},
"node_modules/cjs-module-lexer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
"license": "MIT"
},
"node_modules/clean-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz",
@@ -8092,9 +8796,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
"license": "MIT"
},
"node_modules/es-object-atoms": {
@@ -9609,6 +10313,21 @@
"node": ">=4"
}
},
"node_modules/import-in-the-middle": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz",
"integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==",
"license": "Apache-2.0",
"dependencies": {
"acorn": "^8.15.0",
"acorn-import-attributes": "^1.9.5",
"cjs-module-lexer": "^2.2.0",
"module-details-from-path": "^1.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/impound": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/impound/-/impound-1.1.5.tgz",
@@ -9998,9 +10717,9 @@
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -10836,6 +11555,15 @@
"node": ">= 8"
}
},
"node_modules/meriyah": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz",
"integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==",
"license": "ISC",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -10999,6 +11727,12 @@
"integrity": "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==",
"license": "MIT"
},
"node_modules/module-details-from-path": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
"license": "MIT"
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -13214,13 +13948,13 @@
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"confbox": "^0.2.4",
"exsolve": "^1.0.8",
"pathe": "^2.0.3"
}
},
@@ -13949,6 +14683,15 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
@@ -14118,6 +14861,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -14515,6 +15264,19 @@
"node": ">=0.10.0"
}
},
"node_modules/require-in-the-middle": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"module-details-from-path": "^1.0.3"
},
"engines": {
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
}
},
"node_modules/reserved-identifiers": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz",
@@ -14838,10 +15600,16 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/semifies": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz",
"integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==",
"license": "Apache-2.0"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -15795,13 +16563,13 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -16021,9 +16789,9 @@
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"license": "MIT"
},
"node_modules/ultrahtml": {
+2 -1
View File
@@ -17,11 +17,12 @@
"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",
"@pinia/nuxt": "^0.11.3",
"@sentry/nuxt": "^10.62.0",
"nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
+18
View File
@@ -0,0 +1,18 @@
import * as Sentry from '@sentry/nuxt'
// Init Sentry cote client (SPA). Le DSN provient du build prod (NUXT_PUBLIC_SENTRY_DSN).
// Si le DSN est vide (dev), Sentry.init est un no-op : rien n'est envoye.
const config = useRuntimeConfig()
const dsn = config.public.sentry?.dsn
if (dsn) {
Sentry.init({
dsn,
environment: config.public.sentry?.environment,
// Pas d'APM/tracing (hors perimetre) : on ne remonte que les erreurs.
tracesSampleRate: 0,
// Pas de session replay (volume).
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 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() {
+5
View File
@@ -77,6 +77,9 @@ export const personas: Record<PersonaKey, 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 +89,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',
+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', () => {
+4
View File
@@ -11,3 +11,7 @@ JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
# Sentry / GlitchTip — error tracking backend (projet "starseed-api").
# Runtime, prod only. Vide/absent => SDK inerte (rien envoye).
# SENTRY_DSN=https://<cle>@<host-ou-IP>:<port>/<id-projet>
+23 -2
View File
@@ -30,21 +30,42 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# Error tracking → GlitchTip (build-time). Vides par defaut => module Sentry inerte
# et aucun upload de source maps. Fournis par la CI via --build-arg (secrets Gitea).
# Passes en prefixe inline du RUN (pas en ENV) pour ne pas persister le token dans
# une couche d'image.
ARG NUXT_PUBLIC_SENTRY_DSN=""
ARG SENTRY_URL=""
ARG SENTRY_ORG=""
ARG SENTRY_PROJECT=""
ARG SENTRY_AUTH_TOKEN=""
ENV CI=1 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE=/api \
NUXT_PUBLIC_APP_BASE=/
RUN npm run generate
RUN NUXT_PUBLIC_SENTRY_DSN="$NUXT_PUBLIC_SENTRY_DSN" \
SENTRY_URL="$SENTRY_URL" \
SENTRY_ORG="$SENTRY_ORG" \
SENTRY_PROJECT="$SENTRY_PROJECT" \
SENTRY_AUTH_TOKEN="$SENTRY_AUTH_TOKEN" \
npm run generate
# --- Stage 3: Production image ---
FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
nginx supervisor ca-certificates \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# CA racine interne MALIO (auto-signee) — permet au SDK Sentry/HttpClient de
# joindre les services HTTPS internes (ex. GlitchTip sur logs.malio-dev.fr).
COPY infra/prod/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
RUN update-ca-certificates
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFZzCCA0+gAwIBAgIUOiZigxwgIgtLipnLnu4eSgItc5MwDQYJKoZIhvcNAQEL
BQAwQzELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCU1BTElPLURFVjEgMB4GA1UEAwwX
TUFMSU8tREVWIExvY2FsIFJvb3QgQ0EwHhcNMjYwNjI1MTYxMjIwWhcNMzYwNjIy
MTYxMjIwWjBDMQswCQYDVQQGEwJGUjESMBAGA1UECgwJTUFMSU8tREVWMSAwHgYD
VQQDDBdNQUxJTy1ERVYgTG9jYWwgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQAD
ggIPADCCAgoCggIBALqHXVWEae9aKtveLfSpxYy9RS0Aslw2Ls9+LWI33lpMRs02
QssE9wquf3WGjz8NnHUWl5RM0QHC0DOCCddcbnRBciDRJeTaU43IGdNg+TSY+7aM
3t/jysZrpc/eu/udlIs7npCPaOGnRiuGN68Fkf9Q70FtmaASpusUe7J3jKDinznr
R2hARplO4OF01tFauu039A4yudLrZTUFTldicuZ6a5U3NhajgfNZA+pyJqvL3tLT
lXG3KupPD9BsbWe4zSM96CmyHM22QNlcL+M5XG5+EtDtM07tkDcyxFOsREjQHvSQ
NH+7h6G/QBHHKkYJhdyiuvpj6b5tEJBM2PVgy1T2JX5TuOBOLx6HvHLbNjUY/JI5
0sIjnHbeybQCOfnKNAwidtnqjAfVg+XJ9UZCiGJOeRJOdN5isvvqEKydsX4ouCTj
89kwBbfCJeCS6BiadvNFUwnM0PksV0ovnOiUEEAPHRiP74jZ3IvH95BEwiZzyLpy
tXiJMW7cJMaqlT3jNwq3P00irfrpJNy4S1Mg2cBQh5ucv+PcMBfQT8YiarzlTQJo
saksh/2C43WH+qIFAL2aeD+rKReVBZcGa1XOBI8FUJTu3rLd37+iS4N2BUKq4fWo
FttuX5NOfeU3BRDLlCJ2AXau7o0czVy896R9iZTfBJC95QWD07PdHgoctuexAgMB
AAGjUzBRMB0GA1UdDgQWBBRNU0WsMg/pqo5XF/WXx78GrAzD5TAfBgNVHSMEGDAW
gBRNU0WsMg/pqo5XF/WXx78GrAzD5TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4ICAQBFXsuT7Rm2oJBlWT/RsJtmWr95NoFLHovVDycgM8Vjm+E8hv/m
AcSjPjZDmXQLOrN31T/XUAs0nURHxSFgVzdIKpq2gOlGgHkZRMAW/iTON9Cqjn81
Arjp5fjAJyFkoCiT3eTOElpteF4NhL8xMFaOg1Y2CEfOYO9OZR7Z38HdB6IArVwr
W3Dxq3DPtarCeo1k8SHJmJzUduYCltV8urB43gIiI2Hqd7aAlpkTfDhruKxxr7sJ
3/TpemJDCN9m8XMv2QvxqpMwH6EXg/7oqit5k0MvD445f3xt9vZydmV/x6F7u/A/
gJitN+ixA4AKv7Lw210vaupiChqdY+78TXgLoPJ2/l2QPWG/R7Fb4yNZ2rEd6lyt
KLPxHDcdZetFnyqyaoB2SNtLx9hNUE5G3udU6DkNhDfQlDhqEG4f7GAInOu/cMWE
2uiIUEjcGSLM+XrrTFRc1tdXy6hnu+sw5ckvhwJ+kjah/pVGz21/y5a0p42AUznI
iN7HBV8YaSkeJLvBPnfakUAat1R98e0l72DucHe8RF44NmZCywpaUBsTpNy+bO2f
atqp4/ZEGJJlJ38rLv9bAuwr6d8x6T+m0oHknqtJHcWfO0kr4l3Lxsd8mRpGgmBe
zOjqjrat4vSc04Rqic4UV2IEoWCiSS/TSiBx8JAB6Ck0+YR9dUgXVQsFFg==
-----END CERTIFICATE-----
@@ -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',
],
],
];
@@ -195,6 +195,8 @@ final class SeedE2ECommand extends Command
// (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 +205,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;
+9
View File
@@ -124,6 +124,15 @@
"bin/phpunit"
]
},
"sentry/sentry-symfony": {
"version": "5.10",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "5.0",
"ref": "aac2bc5220e9ab5b9e3838a7a4da90e7f74e6148"
}
},
"symfony/console": {
"version": "8.0",
"recipe": {
@@ -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
@@ -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'));