Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f8ed075b6 | |||
| 1e783bd753 | |||
| 9f4f45f761 | |||
| e99747ac72 | |||
| 36edd11854 | |||
| 45cb5c834c |
+2
-2
@@ -24,6 +24,7 @@
|
|||||||
"symfony/expression-language": "8.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/intl": "8.0.*",
|
"symfony/intl": "8.0.*",
|
||||||
"symfony/mime": "8.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
@@ -95,7 +96,6 @@
|
|||||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||||
"friendsofphp/php-cs-fixer": "^3.94",
|
"friendsofphp/php-cs-fixer": "^3.94",
|
||||||
"phpunit/phpunit": "^13.0",
|
"phpunit/phpunit": "^13.0",
|
||||||
"symfony/browser-kit": "8.0.*",
|
"symfony/browser-kit": "8.0.*"
|
||||||
"symfony/http-client": "8.0.*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+175
-175
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
|
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -5412,6 +5412,180 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/http-client",
|
||||||
|
"version": "v8.0.13",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/http-client.git",
|
||||||
|
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
|
||||||
|
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"psr/log": "^1|^2|^3",
|
||||||
|
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"amphp/amp": "<3",
|
||||||
|
"php-http/discovery": "<1.15"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"php-http/async-client-implementation": "*",
|
||||||
|
"php-http/client-implementation": "*",
|
||||||
|
"psr/http-client-implementation": "1.0",
|
||||||
|
"symfony/http-client-implementation": "3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"amphp/http-client": "^5.3.2",
|
||||||
|
"amphp/http-tunnel": "^2.0",
|
||||||
|
"guzzlehttp/promises": "^1.4|^2.0",
|
||||||
|
"nyholm/psr7": "^1.0",
|
||||||
|
"php-http/httplug": "^1.0|^2.0",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"symfony/cache": "^7.4|^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.4|^8.0",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/messenger": "^7.4|^8.0",
|
||||||
|
"symfony/process": "^7.4|^8.0",
|
||||||
|
"symfony/rate-limiter": "^7.4|^8.0",
|
||||||
|
"symfony/stopwatch": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\HttpClient\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"http"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
|
||||||
|
},
|
||||||
|
"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-05-24T09:58:02+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/http-client-contracts",
|
||||||
|
"version": "v3.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||||
|
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||||
|
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/contracts",
|
||||||
|
"name": "symfony/contracts"
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.6-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Contracts\\HttpClient\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Test/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Generic abstractions related to HTTP clients",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"abstractions",
|
||||||
|
"contracts",
|
||||||
|
"decoupling",
|
||||||
|
"interfaces",
|
||||||
|
"interoperability",
|
||||||
|
"standards"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-04-29T11:18:49+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-foundation",
|
"name": "symfony/http-foundation",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -11785,180 +11959,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/http-client",
|
|
||||||
"version": "v8.0.8",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/http-client.git",
|
|
||||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
|
|
||||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.4",
|
|
||||||
"psr/log": "^1|^2|^3",
|
|
||||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
|
||||||
"symfony/service-contracts": "^2.5|^3"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"amphp/amp": "<3",
|
|
||||||
"php-http/discovery": "<1.15"
|
|
||||||
},
|
|
||||||
"provide": {
|
|
||||||
"php-http/async-client-implementation": "*",
|
|
||||||
"php-http/client-implementation": "*",
|
|
||||||
"psr/http-client-implementation": "1.0",
|
|
||||||
"symfony/http-client-implementation": "3.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"amphp/http-client": "^5.3.2",
|
|
||||||
"amphp/http-tunnel": "^2.0",
|
|
||||||
"guzzlehttp/promises": "^1.4|^2.0",
|
|
||||||
"nyholm/psr7": "^1.0",
|
|
||||||
"php-http/httplug": "^1.0|^2.0",
|
|
||||||
"psr/http-client": "^1.0",
|
|
||||||
"symfony/cache": "^7.4|^8.0",
|
|
||||||
"symfony/dependency-injection": "^7.4|^8.0",
|
|
||||||
"symfony/http-kernel": "^7.4|^8.0",
|
|
||||||
"symfony/messenger": "^7.4|^8.0",
|
|
||||||
"symfony/process": "^7.4|^8.0",
|
|
||||||
"symfony/rate-limiter": "^7.4|^8.0",
|
|
||||||
"symfony/stopwatch": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Component\\HttpClient\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Nicolas Grekas",
|
|
||||||
"email": "p@tchwork.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"keywords": [
|
|
||||||
"http"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/http-client/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/http-client-contracts",
|
|
||||||
"version": "v3.6.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
|
||||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
|
||||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.1"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"thanks": {
|
|
||||||
"url": "https://github.com/symfony/contracts",
|
|
||||||
"name": "symfony/contracts"
|
|
||||||
},
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-main": "3.6-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Contracts\\HttpClient\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Test/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Nicolas Grekas",
|
|
||||||
"email": "p@tchwork.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Generic abstractions related to HTTP clients",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"keywords": [
|
|
||||||
"abstractions",
|
|
||||||
"contracts",
|
|
||||||
"decoupling",
|
|
||||||
"interfaces",
|
|
||||||
"interoperability",
|
|
||||||
"standards"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2025-04-29T11:18:49+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/process",
|
"name": "symfony/process",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ api_platform:
|
|||||||
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
||||||
# en dehors de Domain/Entity : AuditLogResource, etc.
|
# en dehors de Domain/Entity : AuditLogResource, etc.
|
||||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
||||||
|
# Entites techniques partagees portant un #[ApiResource]
|
||||||
|
# (UploadedDocument — infra upload generique ERP-154).
|
||||||
|
- '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||||
formats:
|
formats:
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ doctrine:
|
|||||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||||
mappings:
|
mappings:
|
||||||
|
# Mapping des entites techniques partagees (src/Shared/Domain/Entity).
|
||||||
|
# Premier occupant : UploadedDocument (infra upload generique ERP-154).
|
||||||
|
# Necessaire car les entites Shared ne sont pas couvertes par
|
||||||
|
# l'auto_mapping (qui ne cible que les bundles).
|
||||||
|
Shared:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||||
|
prefix: 'App\Shared\Domain\Entity'
|
||||||
|
alias: Shared
|
||||||
Core:
|
Core:
|
||||||
type: attribute
|
type: attribute
|
||||||
is_bundle: false
|
is_bundle: false
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.122'
|
app.version: '0.1.125'
|
||||||
|
|||||||
@@ -157,12 +157,16 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<ClientContactBlock
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -199,7 +203,7 @@
|
|||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -304,7 +308,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -440,6 +444,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -490,10 +495,6 @@ const contacts = ref<ContactFormDraft[]>([])
|
|||||||
const addresses = ref<AddressFormDraft[]>([])
|
const addresses = ref<AddressFormDraft[]>([])
|
||||||
const ribs = ref<RibFormDraft[]>([])
|
const ribs = ref<RibFormDraft[]>([])
|
||||||
|
|
||||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
|
||||||
const removedContactIds = ref<number[]>([])
|
|
||||||
const removedAddressIds = ref<number[]>([])
|
|
||||||
const removedRibIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const mainSubmitting = ref(false)
|
const mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -754,32 +755,31 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||||
|
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||||
|
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||||
function askRemoveContact(index: number): void {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/client_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
|
||||||
* collection contacts (endpoints client_contact dedies).
|
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedContactIds.value) {
|
|
||||||
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedContactIds.value = []
|
|
||||||
|
|
||||||
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||||
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
||||||
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
||||||
@@ -836,14 +836,15 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/client_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -855,17 +856,12 @@ function onAddressDegraded(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
/** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedAddressIds.value) {
|
|
||||||
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedAddressIds.value = []
|
|
||||||
|
|
||||||
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
@@ -937,29 +933,32 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||||
|
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
|
||||||
function askRemoveRib(index: number): void {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/client_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||||
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
|
||||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
* persiste) sur le PATCH scalaires.
|
||||||
*
|
*
|
||||||
|
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||||
|
* plus de DELETE differe ici.
|
||||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
* sinon 403 sur tout le payload).
|
||||||
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
|
||||||
* RG-1.28 : sinon 403 sur tout le payload).
|
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -1013,14 +1012,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
|
||||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
|
||||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
|
||||||
for (const id of removedRibIds.value) {
|
|
||||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedRibIds.value = []
|
|
||||||
|
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -156,12 +156,16 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<ClientContactBlock
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contact')"
|
:readonly="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -198,7 +202,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('address')"
|
:readonly="isValidated('address')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -303,7 +307,7 @@
|
|||||||
>
|
>
|
||||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -417,6 +421,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -126,12 +126,16 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<SupplierContactBlock
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -168,7 +172,7 @@
|
|||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -273,7 +277,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -407,6 +411,7 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -456,10 +461,6 @@ const contacts = ref<SupplierContactFormDraft[]>([])
|
|||||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
const ribs = ref<SupplierRibFormDraft[]>([])
|
||||||
|
|
||||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
|
||||||
const removedContactIds = ref<number[]>([])
|
|
||||||
const removedAddressIds = ref<number[]>([])
|
|
||||||
const removedRibIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const mainSubmitting = ref(false)
|
const mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -653,32 +654,31 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||||
|
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||||
|
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||||
function askRemoveContact(index: number): void {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/supplier_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
|
||||||
* collection contacts (endpoints supplier_contact dedies).
|
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedContactIds.value) {
|
|
||||||
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedContactIds.value = []
|
|
||||||
|
|
||||||
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
|
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||||
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
|
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
|
||||||
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||||
@@ -726,14 +726,15 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/supplier_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -745,17 +746,12 @@ function onAddressDegraded(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
|
/** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedAddressIds.value) {
|
|
||||||
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedAddressIds.value = []
|
|
||||||
|
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
@@ -826,15 +822,18 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||||
|
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
|
||||||
function askRemoveRib(index: number): void {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/supplier_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -843,11 +842,12 @@ function askRemoveRib(index: number): void {
|
|||||||
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
||||||
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||||
*
|
*
|
||||||
|
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||||
|
* plus de DELETE differe ici.
|
||||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
* sinon 403 sur tout le payload).
|
||||||
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -897,14 +897,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
|
||||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
|
||||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
|
||||||
for (const id of removedRibIds.value) {
|
|
||||||
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedRibIds.value = []
|
|
||||||
|
|
||||||
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -121,12 +121,16 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<SupplierContactBlock
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contacts')"
|
:readonly="isValidated('contacts')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -163,7 +167,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('addresses')"
|
:readonly="isValidated('addresses')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -267,7 +271,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -380,6 +384,7 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, reactive, ref, type Ref } from 'vue'
|
import { computed, reactive, ref, type Ref } from 'vue'
|
||||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import {
|
import {
|
||||||
emptyProviderAccounting,
|
emptyProviderAccounting,
|
||||||
emptyProviderAddress,
|
emptyProviderAddress,
|
||||||
@@ -73,6 +74,16 @@ export function useProviderForm() {
|
|||||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||||
const mainErrors = useFormErrors()
|
const mainErrors = useFormErrors()
|
||||||
|
|
||||||
|
// ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de
|
||||||
|
// sous-ressource (message back affiche en toast dedie — pas de mapping inline,
|
||||||
|
// le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409.
|
||||||
|
function notifyRemovalError(error: unknown): void {
|
||||||
|
toast.error({
|
||||||
|
title: t('technique.providers.toast.error'),
|
||||||
|
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Etat du prestataire cree ────────────────────────────────────────────
|
// ── Etat du prestataire cree ────────────────────────────────────────────
|
||||||
const providerId = ref<number | null>(null)
|
const providerId = ref<number | null>(null)
|
||||||
const mainLocked = ref(false)
|
const mainLocked = ref(false)
|
||||||
@@ -317,9 +328,18 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeContact(index: number): void {
|
// ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
|
||||||
contacts.value.splice(index, 1)
|
// confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
|
||||||
contactErrors.value.splice(index, 1)
|
async function removeContact(index: number): Promise<void> {
|
||||||
|
await removeCollectionRow({
|
||||||
|
rows: contacts.value,
|
||||||
|
errors: contactErrors.value,
|
||||||
|
index,
|
||||||
|
endpoint: '/provider_contacts',
|
||||||
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
|
makeEmpty: emptyProviderContact,
|
||||||
|
onError: notifyRemovalError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -387,9 +407,17 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAddress(index: number): void {
|
// ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
|
||||||
addresses.value.splice(index, 1)
|
async function removeAddress(index: number): Promise<void> {
|
||||||
addressErrors.value.splice(index, 1)
|
await removeCollectionRow({
|
||||||
|
rows: addresses.value,
|
||||||
|
errors: addressErrors.value,
|
||||||
|
index,
|
||||||
|
endpoint: '/provider_addresses',
|
||||||
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
|
makeEmpty: emptyProviderAddress,
|
||||||
|
onError: notifyRemovalError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -479,13 +507,18 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRib(index: number): void {
|
// ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
|
||||||
ribs.value.splice(index, 1)
|
// du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
|
||||||
ribErrors.value.splice(index, 1)
|
async function removeRib(index: number): Promise<void> {
|
||||||
// Garde au moins un bloc RIB visible (sous LCR).
|
await removeCollectionRow({
|
||||||
if (ribs.value.length === 0) {
|
rows: ribs.value,
|
||||||
ribs.value.push(emptyProviderRib())
|
errors: ribErrors.value,
|
||||||
}
|
index,
|
||||||
|
endpoint: '/provider_ribs',
|
||||||
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
|
makeEmpty: emptyProviderRib,
|
||||||
|
onError: notifyRemovalError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,11 +62,15 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<ProviderContactBlock
|
<ProviderContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -102,7 +106,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -206,7 +210,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -308,6 +312,7 @@ import {
|
|||||||
emptyProviderRib,
|
emptyProviderRib,
|
||||||
} from '~/modules/technique/types/providerForm'
|
} from '~/modules/technique/types/providerForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -63,11 +63,15 @@
|
|||||||
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||||
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||||
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||||
|
bloc enregistre. -->
|
||||||
<ProviderContactBlock
|
<ProviderContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contact')"
|
:readonly="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -102,7 +106,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('address')"
|
:readonly="isValidated('address')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -206,7 +210,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -292,6 +296,7 @@ import {
|
|||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de `removeCollectionRow` — suppression d'une ligne de collection
|
||||||
|
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
|
||||||
|
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
|
||||||
|
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
|
||||||
|
* blocs).
|
||||||
|
*/
|
||||||
|
interface Row extends DeletableRow {
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmpty(): Row {
|
||||||
|
return { id: null, label: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('removeCollectionRow', () => {
|
||||||
|
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||||
|
const errors: Record<string, string>[] = [{}, {}]
|
||||||
|
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
const removed = await removeCollectionRow({
|
||||||
|
rows, errors, index: 0,
|
||||||
|
endpoint: '/client_contacts',
|
||||||
|
deleteRow, makeEmpty, onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(deleteRow).toHaveBeenCalledOnce()
|
||||||
|
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
|
||||||
|
expect(removed).toBe(true)
|
||||||
|
expect(rows).toEqual([{ id: 11, label: 'B' }])
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(onError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
|
||||||
|
const errors: Record<string, string>[] = [{}, {}]
|
||||||
|
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
const removed = await removeCollectionRow({
|
||||||
|
rows, errors, index: 1,
|
||||||
|
endpoint: '/client_contacts',
|
||||||
|
deleteRow, makeEmpty, onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(deleteRow).not.toHaveBeenCalled()
|
||||||
|
expect(removed).toBe(true)
|
||||||
|
expect(rows).toEqual([{ id: 10, label: 'A' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||||
|
const errors: Record<string, string>[] = [{}, {}]
|
||||||
|
const error = { response: { status: 409 } }
|
||||||
|
const deleteRow = vi.fn().mockRejectedValue(error)
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
const removed = await removeCollectionRow({
|
||||||
|
rows, errors, index: 0,
|
||||||
|
endpoint: '/client_ribs',
|
||||||
|
deleteRow, makeEmpty, onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(removed).toBe(false)
|
||||||
|
expect(onError).toHaveBeenCalledWith(error)
|
||||||
|
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
|
||||||
|
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
|
||||||
|
expect(errors).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }]
|
||||||
|
const errors: Record<string, string>[] = [{}]
|
||||||
|
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await removeCollectionRow({
|
||||||
|
rows, errors, index: 0,
|
||||||
|
endpoint: '/client_contacts',
|
||||||
|
deleteRow, makeEmpty, onError: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rows).toEqual([{ id: null, label: '' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de `isRowRemovable` — la poubelle d'un bloc n'apparait que s'il reste un
|
||||||
|
* AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que
|
||||||
|
* rien n'est sauvegarde, et de supprimer son dernier bloc enregistre (ERP-172).
|
||||||
|
*/
|
||||||
|
describe('isRowRemovable', () => {
|
||||||
|
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
|
||||||
|
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
|
||||||
|
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||||
|
expect(isRowRemovable(rows, 1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
|
||||||
|
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
|
||||||
|
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||||
|
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
|
||||||
|
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||||
|
expect(isRowRemovable(rows, 0)).toBe(true)
|
||||||
|
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux pour un unique bloc', () => {
|
||||||
|
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/** Ligne de collection supprimable (contact / adresse / RIB). */
|
||||||
|
export interface DeletableRow {
|
||||||
|
id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
|
||||||
|
*
|
||||||
|
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
|
||||||
|
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
|
||||||
|
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
|
||||||
|
* simple brouillon saisi mais pas valide) ;
|
||||||
|
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
|
||||||
|
* - on ne peut jamais supprimer son dernier bloc enregistre.
|
||||||
|
*/
|
||||||
|
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
|
||||||
|
return rows.some((row, i) => i !== index && row.id != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options de {@link removeCollectionRow}. */
|
||||||
|
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
|
||||||
|
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
|
||||||
|
rows: T[]
|
||||||
|
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
|
||||||
|
errors: Record<string, string>[]
|
||||||
|
/** Index de la ligne a retirer. */
|
||||||
|
index: number
|
||||||
|
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
|
||||||
|
endpoint: string
|
||||||
|
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
|
||||||
|
deleteRow: (url: string) => Promise<unknown>
|
||||||
|
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
|
||||||
|
makeEmpty: () => T
|
||||||
|
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
|
||||||
|
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
|
||||||
|
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
|
||||||
|
*
|
||||||
|
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
|
||||||
|
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
|
||||||
|
* tableau. On ne retire le bloc QUE si le serveur a confirme — sinon le bloc
|
||||||
|
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
|
||||||
|
* LCR -> 409 back, RG-x.08).
|
||||||
|
*
|
||||||
|
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
|
||||||
|
* reactifs), le `splice` declenche donc la reactivite.
|
||||||
|
*
|
||||||
|
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
|
||||||
|
* `false` si la suppression serveur a echoue (bloc conserve).
|
||||||
|
*/
|
||||||
|
export async function removeCollectionRow<T extends DeletableRow>(
|
||||||
|
options: RemoveCollectionRowOptions<T>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
|
||||||
|
const removed = rows[index]
|
||||||
|
|
||||||
|
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
|
||||||
|
if (removed?.id != null) {
|
||||||
|
try {
|
||||||
|
await deleteRow(`${endpoint}/${removed.id}`)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
onError(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.splice(index, 1)
|
||||||
|
errors.splice(index, 1)
|
||||||
|
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||||
|
if (rows.length === 0) {
|
||||||
|
rows.push(makeEmpty())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-154 — Infra d'upload de fichiers generique et reutilisable (src/Shared).
|
||||||
|
*
|
||||||
|
* Cree la table `uploaded_document` : reference technique d'un fichier televerse
|
||||||
|
* (PDF / image), gere par le service Shared\Infrastructure\Upload\FileUploader.
|
||||||
|
* La « Decharge » du M4 transporteurs en sera le premier consommateur, mais ce
|
||||||
|
* ticket ne touche AUCUN module : la table vit cote Shared.
|
||||||
|
*
|
||||||
|
* Caracteristiques :
|
||||||
|
* - Document IMMUABLE : pas d'onglet edition, pas de updated_at / updated_by.
|
||||||
|
* Seules les colonnes created_at (UTC, remplie par le FileUploader via
|
||||||
|
* l'horloge injectee) et created_by (auteur HTTP, null hors HTTP) tracent
|
||||||
|
* l'origine. C'est pourquoi l'entite Shared n'implemente PAS
|
||||||
|
* Timestampable/Blamable (qui imposeraient les 4 colonnes).
|
||||||
|
* - checksum sha256 (64 caracteres hex) : controle d'integrite + future
|
||||||
|
* deduplication eventuelle (hors scope ici).
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
|
||||||
|
* modulaire : la table porte une FK cross-module vers "user" (created_by). Le
|
||||||
|
* tri par version au sein du namespace racine garantit qu'elle joue APRES la
|
||||||
|
* creation de "user" sur base vide.
|
||||||
|
*
|
||||||
|
* Style DDL aligne sur le M1/M2/M3 : `INT GENERATED BY DEFAULT AS IDENTITY` et
|
||||||
|
* `TIMESTAMP(0) WITHOUT TIME ZONE` (mapping ORM `datetime_immutable`), pour que
|
||||||
|
* `schema:update --force` reste un no-op une fois l'entite mappee.
|
||||||
|
*
|
||||||
|
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne porte sa
|
||||||
|
* description ici. La table est aussi ajoutee a `ColumnCommentsCatalog` car
|
||||||
|
* l'entite UploadedDocument existe des ce ticket — `app:apply-column-comments`
|
||||||
|
* du `test-db-setup` rejoue donc ces COMMENT apres le `schema:update --force`.
|
||||||
|
*/
|
||||||
|
final class Version20260615130000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-154 : table uploaded_document (infra upload generique Shared) — fichier televerse immuable, checksum sha256.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE uploaded_document (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
original_filename VARCHAR(255) NOT NULL,
|
||||||
|
stored_path VARCHAR(512) NOT NULL,
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
size_bytes INT NOT NULL,
|
||||||
|
checksum VARCHAR(64) NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_uploaded_document_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Postgres n'indexe pas automatiquement les colonnes de FK.
|
||||||
|
$this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)');
|
||||||
|
// Recherche d'integrite / future deduplication par empreinte sha256.
|
||||||
|
$this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)');
|
||||||
|
|
||||||
|
$this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS uploaded_document');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Shared\Infrastructure\ApiPlatform\State\UploadedDocumentProcessor;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference technique d'un fichier televerse (infra generique Shared, ERP-154).
|
||||||
|
*
|
||||||
|
* Entite IMMUABLE : un document n'est jamais modifie apres creation (pas d'onglet
|
||||||
|
* edition cote front). Elle ne porte donc QUE `created_at` / `created_by` — pas
|
||||||
|
* la paire `updated_*` — et n'implemente volontairement pas Timestampable /
|
||||||
|
* Blamable (qui imposeraient les 4 colonnes). `created_at` est rempli par le
|
||||||
|
* FileUploader via l'horloge injectee ; `created_by` est positionne par le
|
||||||
|
* processor depuis l'utilisateur authentifie (null hors HTTP).
|
||||||
|
*
|
||||||
|
* Pas de `#[Auditable]` : c'est un enregistrement d'infrastructure (et non un
|
||||||
|
* agregat metier edite), sa tracabilite est portee par created_at / created_by.
|
||||||
|
*
|
||||||
|
* Operations API :
|
||||||
|
* - Post (/uploaded_documents, multipart) : `deserialize: false` — le binaire
|
||||||
|
* n'est pas deserialise dans l'entite, le UploadedDocumentProcessor lit le
|
||||||
|
* fichier de la requete, delegue au FileUploader (validation MIME server-side,
|
||||||
|
* bornage taille, checksum, ecriture disque) puis persiste. MIME hors
|
||||||
|
* whitelist -> 422.
|
||||||
|
* - Get (/uploaded_documents/{id}) : necessaire pour qu'API Platform genere
|
||||||
|
* l'IRI renvoyee par le Post. Protege par IS_AUTHENTICATED_FULLY uniquement
|
||||||
|
* (pas de RBAC ni de cloisonnement tenant ici) : cette ressource est une
|
||||||
|
* infra GENERIQUE qui ne porte aucune notion de proprietaire metier. Le
|
||||||
|
* cloisonnement d'acces (qui peut voir quel document) est volontairement
|
||||||
|
* delegue au module CONSOMMATEUR (ex: la Decharge M4), qui exposera le
|
||||||
|
* document via sa propre ressource cloisonnee plutot que via cet endpoint
|
||||||
|
* technique. Ne renvoie que des metadonnees (jamais le binaire).
|
||||||
|
*
|
||||||
|
* Pas de GetCollection exposee (non requise) — la regle de pagination ne
|
||||||
|
* s'applique donc pas ici.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'uploaded_document')]
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
// Entree multipart : le binaire arrive en multipart/form-data sous
|
||||||
|
// le champ « file ». Sans cet inputFormats, API Platform rejette la
|
||||||
|
// requete en 415.
|
||||||
|
inputFormats: ['multipart' => ['multipart/form-data']],
|
||||||
|
// Le fichier n'est pas deserialisable dans l'entite : le processor
|
||||||
|
// lit le binaire de la requete. La validation est portee par le
|
||||||
|
// FileUploader (MIME server-side, taille), pas par les contraintes.
|
||||||
|
deserialize: false,
|
||||||
|
validate: false,
|
||||||
|
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||||
|
processor: UploadedDocumentProcessor::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['uploaded_document:read']],
|
||||||
|
)]
|
||||||
|
class UploadedDocument
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['uploaded_document:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'original_filename', length: 255)]
|
||||||
|
#[Groups(['uploaded_document:read'])]
|
||||||
|
private string $originalFilename;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'stored_path', length: 512)]
|
||||||
|
#[Groups(['uploaded_document:read'])]
|
||||||
|
private string $storedPath;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'mime_type', length: 100)]
|
||||||
|
#[Groups(['uploaded_document:read'])]
|
||||||
|
private string $mimeType;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'size_bytes', type: 'integer')]
|
||||||
|
#[Groups(['uploaded_document:read'])]
|
||||||
|
private int $sizeBytes;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'checksum', length: 64)]
|
||||||
|
#[Groups(['uploaded_document:read'])]
|
||||||
|
private string $checksum;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
|
||||||
|
#[Groups(['uploaded_document:read'])]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?UserInterface $createdBy = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $originalFilename,
|
||||||
|
string $storedPath,
|
||||||
|
string $mimeType,
|
||||||
|
int $sizeBytes,
|
||||||
|
string $checksum,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
) {
|
||||||
|
$this->originalFilename = $originalFilename;
|
||||||
|
$this->storedPath = $storedPath;
|
||||||
|
$this->mimeType = $mimeType;
|
||||||
|
$this->sizeBytes = $sizeBytes;
|
||||||
|
$this->checksum = $checksum;
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOriginalFilename(): string
|
||||||
|
{
|
||||||
|
return $this->originalFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStoredPath(): string
|
||||||
|
{
|
||||||
|
return $this->storedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMimeType(): string
|
||||||
|
{
|
||||||
|
return $this->mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSizeBytes(): int
|
||||||
|
{
|
||||||
|
return $this->sizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChecksum(): string
|
||||||
|
{
|
||||||
|
return $this->checksum;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedBy(): ?UserInterface
|
||||||
|
{
|
||||||
|
return $this->createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedBy(?UserInterface $user): void
|
||||||
|
{
|
||||||
|
$this->createdBy = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Levee quand le fichier televerse depasse la taille maximale autorisee
|
||||||
|
* (FileUploader::MAX_SIZE_BYTES). Traduite en HTTP 422 par le processor.
|
||||||
|
*/
|
||||||
|
final class FileTooLargeException extends FileUploadException
|
||||||
|
{
|
||||||
|
public function __construct(int $size, int $maxSize)
|
||||||
|
{
|
||||||
|
parent::__construct(sprintf(
|
||||||
|
'Le fichier (%d octets) dépasse la taille maximale autorisée (%d octets).',
|
||||||
|
$size,
|
||||||
|
$maxSize,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Exception;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception de base des erreurs de televersement (FileUploader).
|
||||||
|
*
|
||||||
|
* Decouplee de HTTP : le service Shared\Infrastructure\Upload\FileUploader leve
|
||||||
|
* une de ces exceptions metier, et c'est la couche API (UploadedDocumentProcessor)
|
||||||
|
* qui la traduit en reponse HTTP 422.
|
||||||
|
*/
|
||||||
|
class FileUploadException extends RuntimeException {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Levee quand le type MIME detecte server-side n'appartient pas a la whitelist
|
||||||
|
* du FileUploader (PDF + images). Traduite en HTTP 422 par le processor.
|
||||||
|
*/
|
||||||
|
final class UnsupportedMimeTypeException extends FileUploadException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $allowed Types MIME autorises
|
||||||
|
*/
|
||||||
|
public function __construct(string $mimeType, array $allowed)
|
||||||
|
{
|
||||||
|
parent::__construct(sprintf(
|
||||||
|
'Le type de fichier « %s » n\'est pas autorisé. Types acceptés : %s.',
|
||||||
|
$mimeType,
|
||||||
|
implode(', ', $allowed),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\ApiPlatform\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Shared\Domain\Exception\FileUploadException;
|
||||||
|
use App\Shared\Infrastructure\Upload\FileUploader;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor d'ecriture de l'upload generique (POST /api/uploaded_documents).
|
||||||
|
*
|
||||||
|
* L'operation Post est en `deserialize: false` : le binaire n'est pas mappe sur
|
||||||
|
* l'entite. Ce processor lit le fichier multipart de la requete (champ « file »),
|
||||||
|
* delegue au FileUploader (validation MIME server-side, bornage taille, checksum,
|
||||||
|
* ecriture disque), positionne l'auteur (created_by) puis persiste via le
|
||||||
|
* processor Doctrine standard. Le retour est l'entite, qu'API Platform serialise
|
||||||
|
* en JSON-LD (avec son @id / IRI).
|
||||||
|
*
|
||||||
|
* Mapping des erreurs :
|
||||||
|
* - fichier absent -> 422 ;
|
||||||
|
* - MIME hors whitelist / fichier trop volumineux (FileUploadException) -> 422.
|
||||||
|
*
|
||||||
|
* Si la persistance Doctrine echoue APRES l'ecriture disque, le fichier physique
|
||||||
|
* deja deplace est supprime (compensation) pour ne pas laisser de binaire orphelin.
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<mixed, mixed>
|
||||||
|
*/
|
||||||
|
final class UploadedDocumentProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
|
private readonly FileUploader $fileUploader,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
$file = $request?->files->get('file');
|
||||||
|
|
||||||
|
if (!$file instanceof UploadedFile) {
|
||||||
|
throw new UnprocessableEntityHttpException('Aucun fichier fourni (champ « file » attendu).');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$document = $this->fileUploader->upload($file);
|
||||||
|
} catch (FileUploadException $e) {
|
||||||
|
// MIME hors whitelist ou fichier trop volumineux -> 422 avec le
|
||||||
|
// message metier explicite porte par l'exception.
|
||||||
|
throw new UnprocessableEntityHttpException($e->getMessage(), $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if ($user instanceof UserInterface) {
|
||||||
|
$document->setCreatedBy($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->persistProcessor->process($document, $operation, $uriVariables, $context);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// La persistance a echoue APRES l'ecriture disque (erreur DB, FK...) :
|
||||||
|
// on supprime le fichier orphelin pour ne pas le laisser sans ligne
|
||||||
|
// uploaded_document correspondante, puis on relaie l'erreur.
|
||||||
|
$this->fileUploader->remove($document);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,18 @@ final class ColumnCommentsCatalog
|
|||||||
public static function comments(): array
|
public static function comments(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'uploaded_document' => [
|
||||||
|
'_table' => 'Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.',
|
||||||
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
|
'original_filename' => 'Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.',
|
||||||
|
'stored_path' => 'Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.',
|
||||||
|
'mime_type' => 'Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).',
|
||||||
|
'size_bytes' => 'Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.',
|
||||||
|
'checksum' => 'Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).',
|
||||||
|
'created_at' => 'Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).',
|
||||||
|
'created_by' => 'ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.',
|
||||||
|
],
|
||||||
|
|
||||||
'audit_log' => [
|
'audit_log' => [
|
||||||
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
||||||
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Upload;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Entity\UploadedDocument;
|
||||||
|
use App\Shared\Domain\Exception\FileTooLargeException;
|
||||||
|
use App\Shared\Domain\Exception\FileUploadException;
|
||||||
|
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service generique de televersement de fichiers (infra Shared, ERP-154).
|
||||||
|
*
|
||||||
|
* Responsabilites :
|
||||||
|
* - Validation du type MIME SERVER-SIDE via `getMimeType()` (detection finfo
|
||||||
|
* sur le contenu reel) — JAMAIS `getClientMimeType()`, spoofable par le
|
||||||
|
* client (regle backend.md « Upload de fichiers »).
|
||||||
|
* - Whitelist MIME explicite (PDF + images courantes).
|
||||||
|
* - Bornage de la taille (MAX_SIZE_BYTES).
|
||||||
|
* - Calcul du checksum sha256 (controle d integrite) AVANT le deplacement.
|
||||||
|
* - Ecriture disque sous `var/uploads/{yyyy}/{mm}/` avec un nom genere
|
||||||
|
* aleatoirement (jamais le nom client, qui reste une simple metadonnee).
|
||||||
|
*
|
||||||
|
* Le service est volontairement decouple de HTTP au-dela du type UploadedFile :
|
||||||
|
* il leve des exceptions metier (FileUploadException), traduites en 422 par le
|
||||||
|
* UploadedDocumentProcessor.
|
||||||
|
*/
|
||||||
|
final class FileUploader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Types MIME autorises (detectes server-side) : PDF + images courantes.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const ALLOWED_MIME_TYPES = [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taille maximale autorisee : 10 Mo.
|
||||||
|
*/
|
||||||
|
public const MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
// Racine de stockage des fichiers televerses (hors web root, sous var/).
|
||||||
|
#[Autowire('%kernel.project_dir%/var/uploads')]
|
||||||
|
private readonly string $uploadBaseDir,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide, calcule l empreinte, deplace le fichier sur disque et retourne
|
||||||
|
* un UploadedDocument NON persiste (le caller le persiste).
|
||||||
|
*
|
||||||
|
* @throws UnsupportedMimeTypeException si le MIME server-side est hors whitelist
|
||||||
|
* @throws FileTooLargeException si le fichier depasse MAX_SIZE_BYTES
|
||||||
|
*/
|
||||||
|
public function upload(UploadedFile $file): UploadedDocument
|
||||||
|
{
|
||||||
|
// Detection MIME server-side (finfo sur le contenu) — jamais le MIME
|
||||||
|
// declare par le client.
|
||||||
|
$mimeType = $file->getMimeType() ?? 'application/octet-stream';
|
||||||
|
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
||||||
|
throw new UnsupportedMimeTypeException($mimeType, self::ALLOWED_MIME_TYPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSize() peut renvoyer false si le fichier est illisible.
|
||||||
|
$size = $file->getSize();
|
||||||
|
if (false === $size || $size > self::MAX_SIZE_BYTES) {
|
||||||
|
throw new FileTooLargeException(false === $size ? 0 : $size, self::MAX_SIZE_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checksum AVANT le move : le chemin du fichier change apres deplacement.
|
||||||
|
// hash_file renvoie false si le fichier temporaire est illisible (I/O) :
|
||||||
|
// on echoue proprement plutot que de propager un TypeError opaque au
|
||||||
|
// constructeur (parametre $checksum type string).
|
||||||
|
$checksum = hash_file('sha256', $file->getPathname());
|
||||||
|
if (false === $checksum) {
|
||||||
|
throw new FileUploadException('Impossible de lire le fichier televerse pour en calculer l\'empreinte.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = $this->clock->now();
|
||||||
|
$relativeDir = $now->format('Y').'/'.$now->format('m');
|
||||||
|
$targetDir = $this->uploadBaseDir.'/'.$relativeDir;
|
||||||
|
|
||||||
|
// Nom de stockage genere aleatoirement : evite les collisions et toute
|
||||||
|
// injection via le nom client. Extension deduite du MIME.
|
||||||
|
$extension = $file->guessExtension() ?: 'bin';
|
||||||
|
$storedName = bin2hex(random_bytes(16)).'.'.$extension;
|
||||||
|
|
||||||
|
// Le nom d origine est conserve uniquement comme metadonnee d affichage,
|
||||||
|
// borne a la longueur de colonne (255).
|
||||||
|
$originalFilename = mb_substr($file->getClientOriginalName(), 0, 255);
|
||||||
|
|
||||||
|
$file->move($targetDir, $storedName);
|
||||||
|
|
||||||
|
return new UploadedDocument(
|
||||||
|
originalFilename: $originalFilename,
|
||||||
|
storedPath: $relativeDir.'/'.$storedName,
|
||||||
|
mimeType: $mimeType,
|
||||||
|
sizeBytes: $size,
|
||||||
|
checksum: $checksum,
|
||||||
|
createdAt: $now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime le fichier physique d'un document (compensation lorsque la
|
||||||
|
* persistance echoue APRES l'ecriture disque). Best-effort : silencieux si
|
||||||
|
* le fichier a deja disparu. Evite d'accumuler des binaires orphelins non
|
||||||
|
* references en base.
|
||||||
|
*/
|
||||||
|
public function remove(UploadedDocument $document): void
|
||||||
|
{
|
||||||
|
$path = $this->uploadBaseDir.'/'.$document->getStoredPath();
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Shared\Api;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Entity\UploadedDocument;
|
||||||
|
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests fonctionnels de l'endpoint d'upload generique (ERP-154).
|
||||||
|
*
|
||||||
|
* Couvre :
|
||||||
|
* - POST multipart d'un PDF valide -> 201, IRI renvoyee, ligne persistee,
|
||||||
|
* checksum sha256 calcule cote serveur ;
|
||||||
|
* - POST d'un MIME hors whitelist (text/plain) -> 422 ;
|
||||||
|
* - POST sans fichier -> 422 ;
|
||||||
|
* - POST anonyme -> 401 (acces /api protege globalement).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class UploadedDocumentApiTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
private const string ENDPOINT = '/api/uploaded_documents';
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $tempFiles = [];
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->tempFiles as $path) {
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUploadValidPdfReturnsIriAndPersistsRowWithChecksum(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$content = $this->minimalPdf();
|
||||||
|
$file = $this->makeUploadedFile($content, 'facture.pdf');
|
||||||
|
|
||||||
|
$response = $client->request('POST', self::ENDPOINT, [
|
||||||
|
'headers' => ['Accept' => 'application/ld+json'],
|
||||||
|
'extra' => ['files' => ['file' => $file]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
self::assertArrayHasKey('@id', $data);
|
||||||
|
self::assertStringStartsWith(self::ENDPOINT.'/', $data['@id']);
|
||||||
|
self::assertSame('facture.pdf', $data['originalFilename']);
|
||||||
|
self::assertSame('application/pdf', $data['mimeType']);
|
||||||
|
self::assertSame(\strlen($content), $data['sizeBytes']);
|
||||||
|
self::assertSame(hash('sha256', $content), $data['checksum']);
|
||||||
|
self::assertSame(64, \strlen($data['checksum']));
|
||||||
|
|
||||||
|
// La ligne est bien persistee et relisible via le repository.
|
||||||
|
$id = $data['id'];
|
||||||
|
$document = $this->getEm()->getRepository(UploadedDocument::class)->find($id);
|
||||||
|
self::assertInstanceOf(UploadedDocument::class, $document);
|
||||||
|
self::assertSame(hash('sha256', $content), $document->getChecksum());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUploadDisallowedMimeTypeReturns422(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$file = $this->makeUploadedFile('just some plain text content', 'note.txt');
|
||||||
|
|
||||||
|
$client->request('POST', self::ENDPOINT, [
|
||||||
|
'headers' => ['Accept' => 'application/ld+json'],
|
||||||
|
'extra' => ['files' => ['file' => $file]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUploadWithoutFileReturns422(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
|
||||||
|
$client->request('POST', self::ENDPOINT, [
|
||||||
|
'headers' => ['Accept' => 'application/ld+json'],
|
||||||
|
'extra' => ['files' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUploadAnonymousIsRejected(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
|
||||||
|
|
||||||
|
$client->request('POST', self::ENDPOINT, [
|
||||||
|
'headers' => ['Accept' => 'application/ld+json'],
|
||||||
|
'extra' => ['files' => ['file' => $file]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
|
||||||
|
*/
|
||||||
|
private function makeUploadedFile(string $content, string $clientName): UploadedFile
|
||||||
|
{
|
||||||
|
$path = sys_get_temp_dir().'/erp154-api-'.bin2hex(random_bytes(4));
|
||||||
|
file_put_contents($path, $content);
|
||||||
|
$this->tempFiles[] = $path;
|
||||||
|
|
||||||
|
return new UploadedFile($path, $clientName, null, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenu PDF minimal valide (entete `%PDF-1.4` -> finfo `application/pdf`).
|
||||||
|
*/
|
||||||
|
private function minimalPdf(): string
|
||||||
|
{
|
||||||
|
return "%PDF-1.4\n"
|
||||||
|
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||||
|
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||||
|
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
|
||||||
|
."trailer<</Root 1 0 R/Size 4>>\n"
|
||||||
|
."%%EOF\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Shared\Infrastructure\Upload;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Exception\FileTooLargeException;
|
||||||
|
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
|
||||||
|
use App\Shared\Infrastructure\Upload\FileUploader;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Clock\MockClock;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du service generique de televersement (ERP-154).
|
||||||
|
*
|
||||||
|
* Couvre : rejet d'un MIME hors whitelist, rejet d'un fichier trop volumineux,
|
||||||
|
* et le chemin nominal (checksum sha256 calcule, taille/MIME captures, fichier
|
||||||
|
* ecrit sous var/uploads/{yyyy}/{mm}/ avec horodatage de l'horloge injectee).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class FileUploaderTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $uploadBaseDir;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $tempFiles = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->uploadBaseDir = sys_get_temp_dir().'/erp154-uploads-'.bin2hex(random_bytes(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Nettoyage des fichiers sources et de l'arborescence de destination.
|
||||||
|
foreach ($this->tempFiles as $path) {
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->removeDirectory($this->uploadBaseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsMimeTypeOutsideWhitelist(): void
|
||||||
|
{
|
||||||
|
$uploader = $this->createUploader();
|
||||||
|
$file = $this->makeUploadedFile('hello world plain text', 'note.txt');
|
||||||
|
|
||||||
|
$this->expectException(UnsupportedMimeTypeException::class);
|
||||||
|
|
||||||
|
$uploader->upload($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsFileLargerThanMaxSize(): void
|
||||||
|
{
|
||||||
|
$uploader = $this->createUploader();
|
||||||
|
|
||||||
|
// Contenu PDF valide mais artificiellement gonfle au-dela de la borne.
|
||||||
|
$content = "%PDF-1.4\n".str_repeat('A', FileUploader::MAX_SIZE_BYTES + 1);
|
||||||
|
$file = $this->makeUploadedFile($content, 'huge.pdf');
|
||||||
|
|
||||||
|
$this->expectException(FileTooLargeException::class);
|
||||||
|
|
||||||
|
$uploader->upload($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStoresPdfAndComputesSha256Checksum(): void
|
||||||
|
{
|
||||||
|
$content = $this->minimalPdf();
|
||||||
|
$clock = new MockClock(new DateTimeImmutable('2026-06-15 10:00:00'));
|
||||||
|
$uploader = $this->createUploader($clock);
|
||||||
|
$file = $this->makeUploadedFile($content, 'facture.pdf');
|
||||||
|
|
||||||
|
$document = $uploader->upload($file);
|
||||||
|
|
||||||
|
self::assertSame('facture.pdf', $document->getOriginalFilename());
|
||||||
|
self::assertSame('application/pdf', $document->getMimeType());
|
||||||
|
self::assertSame(\strlen($content), $document->getSizeBytes());
|
||||||
|
self::assertSame(hash('sha256', $content), $document->getChecksum());
|
||||||
|
self::assertSame(64, \strlen($document->getChecksum()));
|
||||||
|
|
||||||
|
// Chemin relatif date selon l'horloge injectee (2026/06).
|
||||||
|
self::assertStringStartsWith('2026/06/', $document->getStoredPath());
|
||||||
|
self::assertFileExists($this->uploadBaseDir.'/'.$document->getStoredPath());
|
||||||
|
|
||||||
|
// Le fichier ecrit a bien le contenu d'origine (checksum coherent).
|
||||||
|
self::assertSame(
|
||||||
|
$document->getChecksum(),
|
||||||
|
hash_file('sha256', $this->uploadBaseDir.'/'.$document->getStoredPath()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRemoveDeletesStoredFile(): void
|
||||||
|
{
|
||||||
|
$uploader = $this->createUploader();
|
||||||
|
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
|
||||||
|
$document = $uploader->upload($file);
|
||||||
|
$storedPath = $this->uploadBaseDir.'/'.$document->getStoredPath();
|
||||||
|
self::assertFileExists($storedPath);
|
||||||
|
|
||||||
|
// Compensation : remove() efface le fichier physique...
|
||||||
|
$uploader->remove($document);
|
||||||
|
self::assertFileDoesNotExist($storedPath);
|
||||||
|
|
||||||
|
// ...et reste silencieux si on le rappelle alors que le fichier a disparu.
|
||||||
|
$uploader->remove($document);
|
||||||
|
self::assertFileDoesNotExist($storedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUploader(?MockClock $clock = null): FileUploader
|
||||||
|
{
|
||||||
|
return new FileUploader($this->uploadBaseDir, $clock ?? new MockClock());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
|
||||||
|
*/
|
||||||
|
private function makeUploadedFile(string $content, string $clientName): UploadedFile
|
||||||
|
{
|
||||||
|
$path = sys_get_temp_dir().'/erp154-src-'.bin2hex(random_bytes(4));
|
||||||
|
file_put_contents($path, $content);
|
||||||
|
$this->tempFiles[] = $path;
|
||||||
|
|
||||||
|
// Le 5e argument `test: true` court-circuite move_uploaded_file().
|
||||||
|
return new UploadedFile($path, $clientName, null, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenu PDF minimal valide — l'entete `%PDF-1.4` suffit a faire detecter
|
||||||
|
* `application/pdf` par finfo (getMimeType server-side).
|
||||||
|
*/
|
||||||
|
private function minimalPdf(): string
|
||||||
|
{
|
||||||
|
return "%PDF-1.4\n"
|
||||||
|
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||||
|
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||||
|
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
|
||||||
|
."trailer<</Root 1 0 R/Size 4>>\n"
|
||||||
|
."%%EOF\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = scandir($dir) ?: [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ('.' === $item || '..' === $item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir.'/'.$item;
|
||||||
|
is_dir($path) ? $this->removeDirectory($path) : @unlink($path);
|
||||||
|
}
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user