| Numéro du ticket | Titre du ticket | |------------------|-----------------| | #203 | Réceptions — Parcours de pesée multi-étapes | ## Description de la PR [#203] Réceptions — Parcours de pesée multi-étapes ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: #3 Reviewed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-authored-by: AUTIN Tristan <tristan@yuno.malio.fr> Co-committed-by: AUTIN Tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #3.
This commit is contained in:
123
.idea/workspace.xml
generated
123
.idea/workspace.xml
generated
@@ -4,29 +4,10 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="Feat">
|
||||
<change afterPath="$PROJECT_DIR$/frontend/composables/useApi.ts" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/frontend/layouts/default.vue" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/frontend/pages/reception.vue" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/frontend/services/dto/weight-data.ts" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/frontend/services/reception.ts" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/frontend/tailwind.config.ts" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/ApiResource/Reception.php" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/Dto/PontBasculeReading.php" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/Exception/PontBasculeException.php" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/Service/PontBasculePayloadDecoder.php" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/Service/PontBasculeService.php" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/src/State/ReceptionWeighingProvider.php" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/ferme.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/ferme.iml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/php.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/php.xml" afterDir="false" />
|
||||
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : update du fichier README.md et CHANGELOG.md">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/composer.json" beforeDir="false" afterPath="$PROJECT_DIR$/composer.json" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/composer.lock" beforeDir="false" afterPath="$PROJECT_DIR$/composer.lock" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/config/services.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/config/services.yaml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/docker/php/config/vhost.conf" beforeDir="false" afterPath="$PROJECT_DIR$/docker/php/config/vhost.conf" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/frontend/app.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/app.vue" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/frontend/nuxt.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/nuxt.config.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/frontend/composables/useApi.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/composables/useApi.ts" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -38,7 +19,7 @@
|
||||
<execution />
|
||||
</component>
|
||||
<component name="EmbeddingIndexingInfo">
|
||||
<option name="cachedIndexableFilesCount" value="115" />
|
||||
<option name="cachedIndexableFilesCount" value="137" />
|
||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||
</component>
|
||||
<component name="FileTemplateManagerImpl">
|
||||
@@ -217,30 +198,31 @@
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"git-widget-placeholder": "feat/ajout-configuration-front",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "settings.php.debug.servers",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"git-widget-placeholder": "feat/203-reception-parcours-pesee-multi-etapas",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "reference.webide.settings.project.settings.php.debug",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
},
|
||||
"keyToStringList": {
|
||||
"vue.recent.templates": [
|
||||
"Vue Composition API Component"
|
||||
"keyToStringList": {
|
||||
"vue.recent.templates": [
|
||||
"Vue Composition API Component"
|
||||
]
|
||||
}
|
||||
}]]></component>
|
||||
}</component>
|
||||
<component name="RecentsManager">
|
||||
<key name="MoveFile.RECENT_KEYS">
|
||||
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages\reception" />
|
||||
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages" />
|
||||
</key>
|
||||
</component>
|
||||
@@ -260,7 +242,49 @@
|
||||
<updated>1767956826164</updated>
|
||||
<workItem from="1767956827666" duration="7866000" />
|
||||
<workItem from="1768201706520" duration="13383000" />
|
||||
<workItem from="1768287908317" duration="23185000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)">
|
||||
<option name="closed" value="true" />
|
||||
<created>1768237763998</created>
|
||||
<option name="number" value="00001" />
|
||||
<option name="presentableId" value="LOCAL-00001" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1768237763998</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00002" summary="feat : Ajout de zod, création d'un composant de chargement loading-dots.vue et finalisation du flow d'une reception">
|
||||
<option name="closed" value="true" />
|
||||
<created>1768316052474</created>
|
||||
<option name="number" value="00002" />
|
||||
<option name="presentableId" value="LOCAL-00002" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1768316052474</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00003" summary="feat : Ajout d'un composable pour la pesée qui sera réutilisable pour l'expédition, ajout de contrainte sur les entity de reception et weight pour plus de robustesse et correction de la class active des liens dans la nav">
|
||||
<option name="closed" value="true" />
|
||||
<created>1768316835575</created>
|
||||
<option name="number" value="00003" />
|
||||
<option name="presentableId" value="LOCAL-00003" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1768316835575</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00004" summary="feat : update du fichier AGENTS.md">
|
||||
<option name="closed" value="true" />
|
||||
<created>1768316965511</created>
|
||||
<option name="number" value="00004" />
|
||||
<option name="presentableId" value="LOCAL-00004" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1768316965511</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00005" summary="feat : update du fichier README.md et CHANGELOG.md">
|
||||
<option name="closed" value="true" />
|
||||
<created>1768317786187</created>
|
||||
<option name="number" value="00005" />
|
||||
<option name="presentableId" value="LOCAL-00005" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1768317786187</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="6" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -277,14 +301,29 @@
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<MESSAGE value="Feat : (WIP) Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions" />
|
||||
<MESSAGE value="Feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)" />
|
||||
<MESSAGE value="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)" />
|
||||
<MESSAGE value="feat : Ajout de zod, création d'un composant de chargement loading-dots.vue et finalisation du flow d'une reception" />
|
||||
<MESSAGE value="feat : Ajout d'un composable pour la pesée qui sera réutilisable pour l'expédition, ajout de contrainte sur les entity de reception et weight pour plus de robustesse et correction de la class active des liens dans la nav" />
|
||||
<MESSAGE value="feat : update du fichier AGENTS.md" />
|
||||
<MESSAGE value="feat : update du fichier README.md et CHANGELOG.md" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="feat : update du fichier README.md et CHANGELOG.md" />
|
||||
</component>
|
||||
<component name="XDebuggerManager">
|
||||
<breakpoint-manager>
|
||||
<breakpoints>
|
||||
<line-breakpoint enabled="true" type="php">
|
||||
<url>file://$PROJECT_DIR$/src/State/ReceptionWeighingProvider.php</url>
|
||||
<line>28</line>
|
||||
<line>27</line>
|
||||
<option name="timeStamp" value="6" />
|
||||
</line-breakpoint>
|
||||
<line-breakpoint enabled="true" type="php">
|
||||
<url>file://$PROJECT_DIR$/src/State/ReceptionWeighingProvider.php</url>
|
||||
<line>22</line>
|
||||
<option name="timeStamp" value="7" />
|
||||
</line-breakpoint>
|
||||
</breakpoints>
|
||||
</breakpoint-manager>
|
||||
</component>
|
||||
|
||||
39
AGENTS.md
Normal file
39
AGENTS.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# AGENTS.md
|
||||
|
||||
Project overview
|
||||
- Symfony 8 + API Platform 4 backend, Nuxt 3 frontend in `frontend/`.
|
||||
- Apache vhost serves API under `/api` and frontend from `frontend/dist`.
|
||||
- API base URL on frontend uses `NUXT_PUBLIC_API_BASE` (see `frontend/.env`).
|
||||
|
||||
Backend conventions
|
||||
- Use English for code identifiers/messages; keep “pont-bascule” as domain term.
|
||||
- API Platform operations are defined on Doctrine entities.
|
||||
- Reception entity is in `src/Entity/Reception.php`, with custom weigh endpoint `/receptions/weigh`.
|
||||
- Reception fields: `date_reception`, `license_plate`, `current_step` (default 0), `is_valid` (default false).
|
||||
- `date_reception` is set by the UI, stored as `DateTimeImmutable`, serialized as `Y-m-d`.
|
||||
- Weight entity (`src/Entity/Weight.php`) is 1–N with Reception, each row stores `type` (`gross` or `tare`), `dsd`, `weight`, `weighed_at` (all nullable except `type`).
|
||||
- Weigh endpoint `/receptions/weigh` returns `PontBasculeReading` with `dsd`, `weight`, `weighedAt` (formatted `Y-m-d`).
|
||||
- Custom exception: `App\Exception\PontBasculeException` with French messages, mapped to 500 in provider.
|
||||
- Parsing of pont-bascule payload is in `src/Service/PontBasculePayloadDecoder.php`.
|
||||
- `config/reference.php` is auto-generated; keep it.
|
||||
|
||||
Frontend conventions
|
||||
- Nuxt SSR disabled; Tailwind used.
|
||||
- Layout in `frontend/layouts/default.vue`: max width `1050px`, header full width.
|
||||
- Tailwind custom color palette is `primary` (e.g. `bg-primary-500`).
|
||||
- API composable in `frontend/composables/useApi.ts` with `get/post/put/patch/delete` and default JSON/PATCH content types.
|
||||
- Pinia store: `frontend/stores/reception.ts` is the source of truth for the current reception.
|
||||
- Zod is used for form validation (e.g. `frontend/components/reception/reception-form.vue`); shared helpers live in `frontend/utils/zod-errors.ts`.
|
||||
- Weighing logic is shared via `frontend/composables/useWeighing.ts`.
|
||||
- Reception step UI uses store state (`currentStep`) in `frontend/pages/reception/[[id]].vue`.
|
||||
- Active nav styles in header use `NuxtLink` with `custom` slot.
|
||||
- Reusable UI components live under `frontend/components/ui/` and are auto-imported with `Ui` prefix (e.g. `UiLoadingDots`).
|
||||
|
||||
Environment & routing
|
||||
- Frontend dev server: `npm run dev` in `frontend/`.
|
||||
- API base for local dev: `http://localhost:8080/api` (set in `frontend/.env` via `NUXT_PUBLIC_API_BASE`).
|
||||
- CORS handled by Nelmio; `.env` includes `CORS_ALLOW_ORIGIN` regex for localhost.
|
||||
|
||||
Notes
|
||||
- Do not add a GET that creates resources; use POST + PATCH.
|
||||
- Keep endpoints in plural (API Platform convention).
|
||||
@@ -7,8 +7,14 @@ Liste des évolutions du projet Ferme
|
||||
Ajouter dans le fichier .env
|
||||
- DEFAULT_URI
|
||||
- DATABASE_URL
|
||||
- PONT_BASCULE_BYPASS (doit être à true en dev)
|
||||
- PONT_BASCULE_URL
|
||||
|
||||
Ajouter dans le fichier .env du frontend
|
||||
- NUXT_PUBLIC_API_BASE
|
||||
|
||||
### Added
|
||||
* [#203] Réceptions — Parcours de pesée multi-étapes (début)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -17,6 +17,18 @@ make install
|
||||
```
|
||||
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
|
||||
|
||||
### Configuration global
|
||||
Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue.
|
||||
|
||||
Vérifier que dans le .env.local, vous avez :
|
||||
* APP_SECRET (doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
|
||||
* DATABASE_URL
|
||||
* PONT_BASCULE_BYPASS (doit être à true en dev)
|
||||
* PONT_BASCULE_URL
|
||||
|
||||
Vérifier que dans le .env du dossier frontend, vous avez :
|
||||
* NUXT_PUBLIC_API_BASE
|
||||
|
||||
### Configuration xdebug
|
||||
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
|
||||
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
|
||||
180
composer.lock
generated
180
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "bab4560dec1d36eec0b0aa2284bd8559",
|
||||
"content-hash": "3e883e3a506afa201779d16a950f4845",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -4429,6 +4429,180 @@
|
||||
],
|
||||
"time": "2025-12-23T14:52:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "ea062691009cc2b7bb87734fef20e02671cbd50b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/ea062691009cc2b7bb87734fef20e02671cbd50b",
|
||||
"reference": "ea062691009cc2b7bb87734fef20e02671cbd50b",
|
||||
"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.3"
|
||||
},
|
||||
"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": "2025-12-23T14:52:06+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",
|
||||
"version": "v8.0.3",
|
||||
@@ -10208,7 +10382,7 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
@@ -10216,6 +10390,6 @@
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
@@ -5,3 +5,8 @@ api_platform:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
formats:
|
||||
json: ['application/json']
|
||||
jsonld: ['application/ld+json']
|
||||
patch_formats:
|
||||
json: ['application/merge-patch+json']
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
||||
|
||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
@@ -465,7 +467,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
||||
* http_client?: bool|array{ // HTTP Client configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
|
||||
* default_options?: array{
|
||||
* headers?: array<string, mixed>,
|
||||
@@ -1378,7 +1380,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* mercure?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* hub_url?: scalar|null|Param, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null
|
||||
* include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false
|
||||
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
|
||||
* },
|
||||
* messenger?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
|
||||
@@ -19,5 +19,10 @@ services:
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
App\Service\PontBasculeService:
|
||||
arguments:
|
||||
$endpoint: '%env(PONT_BASCULE_URL)%'
|
||||
$bypass: '%env(bool:PONT_BASCULE_BYPASS)%'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
@@ -6,9 +6,15 @@
|
||||
Options FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
|
||||
RewriteEngine On
|
||||
RewriteRule ^index\.php$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ /api/index.php [L]
|
||||
</Directory>
|
||||
|
||||
AliasMatch "^(/.*)?" "/var/www/html/frontend/dist$1"
|
||||
AliasMatch "^/(?!api)(.*)$" "/var/www/html/frontend/dist/$1"
|
||||
<Directory /var/www/html/frontend/dist>
|
||||
AllowOverride All
|
||||
Order allow,deny
|
||||
|
||||
107
frontend/components/reception/reception-form.vue
Normal file
107
frontend/components/reception/reception-form.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<form @submit.prevent="validate">
|
||||
<div class="grid grid-cols-1 items-start gap-8 mb-16">
|
||||
<h1 class="font-bold text-5xl uppercase">Réception</h1>
|
||||
<div class="flex flex-col">
|
||||
<label for="license-plate" class="font-bold uppercase text-xl mb-4">Immatriculation</label>
|
||||
<input
|
||||
id="license-plate"
|
||||
v-model="form.licensePlate"
|
||||
type="text"
|
||||
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
|
||||
/>
|
||||
<p v-if="fieldErrors.licensePlate" class="text-red-600 text-sm">{{ fieldErrors.licensePlate }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="reception-date" class="font-bold uppercase text-xl mb-4">Date de reception</label>
|
||||
<input
|
||||
id="reception-date"
|
||||
v-model="form.receptionDate"
|
||||
type="date"
|
||||
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
|
||||
/>
|
||||
<p v-if="fieldErrors.receptionDate" class="text-red-600 text-sm">{{ fieldErrors.receptionDate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
|
||||
>Valider
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="text-red-600 mt-4">{{ errorMessage }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { z } from 'zod'
|
||||
import { mapZodErrors } from '~/utils/zod-errors'
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
|
||||
type ReceptionFormData = {
|
||||
licensePlate: string
|
||||
receptionDate: string
|
||||
}
|
||||
|
||||
const receptionStore = useReceptionStore()
|
||||
const { errorMessage: storeErrorMessage, current: storeReception } = storeToRefs(receptionStore)
|
||||
const form = reactive<ReceptionFormData>({
|
||||
licensePlate: '',
|
||||
receptionDate: ''
|
||||
})
|
||||
const fieldErrors = reactive<Partial<Record<keyof ReceptionFormData, string>>>({
|
||||
licensePlate: undefined,
|
||||
receptionDate: undefined
|
||||
})
|
||||
const errorMessage = computed(() => storeErrorMessage.value)
|
||||
const formSchema = z.object({
|
||||
licensePlate: z
|
||||
.string()
|
||||
.min(1, 'Immatriculation requise.')
|
||||
.max(20, 'Immatriculation trop longue (20 caracteres max).'),
|
||||
receptionDate: z
|
||||
.string()
|
||||
.min(1, 'Date de reception requise.')
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date de reception invalide.')
|
||||
})
|
||||
|
||||
watch(
|
||||
storeReception,
|
||||
(reception) => {
|
||||
form.licensePlate = reception?.licensePlate ?? ''
|
||||
form.receptionDate = reception?.receptionDate ?? ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function validate() {
|
||||
if (!receptionStore.current) {
|
||||
return
|
||||
}
|
||||
|
||||
fieldErrors.licensePlate = undefined
|
||||
fieldErrors.receptionDate = undefined
|
||||
const normalizedLicensePlate = form.licensePlate.trim()
|
||||
const normalizedReceptionDate = form.receptionDate.trim()
|
||||
const result = formSchema.safeParse({
|
||||
licensePlate: normalizedLicensePlate,
|
||||
receptionDate: normalizedReceptionDate
|
||||
})
|
||||
if (!result.success) {
|
||||
const errors = mapZodErrors<ReceptionFormData>(result.error)
|
||||
fieldErrors.licensePlate = errors.licensePlate ?? 'Formulaire invalide.'
|
||||
fieldErrors.receptionDate = errors.receptionDate ?? 'Formulaire invalide.'
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = receptionStore.current.currentStep + 1
|
||||
await receptionStore.updateReception(receptionStore.current.id, {
|
||||
currentStep: nextStep,
|
||||
licensePlate: normalizedLicensePlate || null,
|
||||
receptionDate: normalizedReceptionDate || null
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
30
frontend/components/reception/reception-unloading.vue
Normal file
30
frontend/components/reception/reception-unloading.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center mt-[164px] gap-32">
|
||||
<div class="flex gap-8 items-center justify-center">
|
||||
<!--@TODO Prendre en compte que l'on peut aussi décharger de la marchandise-->
|
||||
<h1 class="text-3xl uppercase font-bold">Décharger les bêtes</h1>
|
||||
<UiLoadingDots />
|
||||
</div>
|
||||
<button
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
@click="goNext"
|
||||
>Suivant</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
|
||||
const receptionStore = useReceptionStore()
|
||||
|
||||
async function goNext() {
|
||||
if (!receptionStore.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = receptionStore.current.currentStep + 1
|
||||
await receptionStore.updateReception(receptionStore.current.id, {
|
||||
currentStep: nextStep
|
||||
})
|
||||
}
|
||||
</script>
|
||||
67
frontend/components/reception/reception-weight.vue
Normal file
67
frontend/components/reception/reception-weight.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex flex-col items-center w-[660px]">
|
||||
<h1 class="font-bold text-5xl uppercase">{{ title }}</h1>
|
||||
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
|
||||
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
|
||||
<div
|
||||
v-if="errorMessage || showLoadingBox"
|
||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
|
||||
<p v-if="errorMessage" class="text-red-500">{{ errorMessage }}</p>
|
||||
<UiLoadingDots v-else />
|
||||
</div>
|
||||
<div v-else-if="displayWeight !== null" class="w-full">
|
||||
<div
|
||||
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl">
|
||||
{{ displayWeight }} kg
|
||||
</div>
|
||||
<div class="grid grid-cols-2 border border-black text-center">
|
||||
<p class="border-r border-black py-3 text-4xl font-bold">DSD</p>
|
||||
<p class="py-3 text-4xl">{{ displayDsd }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mt-[54px]">
|
||||
<button
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
|
||||
@click="fetchWeight"
|
||||
>{{ displayWeight !== null ? 'refaire une pesee' : 'peser' }}</button>
|
||||
<button
|
||||
v-if="displayWeight !== null"
|
||||
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
|
||||
@click="saveWeight"
|
||||
>Valider la pesée</button>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWeighing } from '~/composables/useWeighing'
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'gross' | 'tare'
|
||||
}>()
|
||||
|
||||
const receptionStore = useReceptionStore()
|
||||
const { current: storeReception, errorMessage: storeErrorMessage } = storeToRefs(receptionStore)
|
||||
const {
|
||||
displayWeight,
|
||||
displayDsd,
|
||||
title,
|
||||
errorMessage,
|
||||
showLoadingBox,
|
||||
fetchWeight,
|
||||
saveWeight
|
||||
} = useWeighing({
|
||||
mode: props.mode,
|
||||
reception: storeReception,
|
||||
updateReception: receptionStore.updateReception,
|
||||
loadReception: receptionStore.loadReception,
|
||||
storeError: storeErrorMessage
|
||||
})
|
||||
// @TODO Voir comment mettre en place la genération du bon, la validation de la reception et le dernier step
|
||||
|
||||
</script>
|
||||
50
frontend/components/ui/loading-dots.vue
Normal file
50
frontend/components/ui/loading-dots.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 text-sm uppercase">
|
||||
<span class="loader-dots">
|
||||
<span class="loader-dot"></span>
|
||||
<span class="loader-dot"></span>
|
||||
<span class="loader-dot"></span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loader-dots {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loader-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
animation: loader-bounce 1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.loader-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.loader-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes loader-bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,14 +14,25 @@ export type ApiClient = {
|
||||
export const useApi = (): ApiClient => {
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase ?? '/api'
|
||||
const client = $fetch.create({ baseURL })
|
||||
const client = $fetch.create({ baseURL, retry: 0 })
|
||||
|
||||
const request = <T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
options: FetchOptions<'json'> = {}
|
||||
) => {
|
||||
return client<T>(url, { ...options, method })
|
||||
const needsJsonBody = method === 'POST' || method === 'PUT'
|
||||
const needsMergePatch = method === 'PATCH'
|
||||
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||
|
||||
if (needsMergePatch && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/merge-patch+json')
|
||||
} else if (needsJsonBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return client<T>(url, { ...options, method, headers })
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
109
frontend/composables/useWeighing.ts
Normal file
109
frontend/composables/useWeighing.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { ReceptionData, WeightEntryData } from '~/services/dto/reception-data'
|
||||
import type { WeightData } from '~/services/dto/weight-data'
|
||||
import { getWeight } from '~/services/reception'
|
||||
import { createWeight, updateWeight } from '~/services/weight'
|
||||
|
||||
export type WeighingMode = 'gross' | 'tare'
|
||||
|
||||
type UseWeighingOptions = {
|
||||
mode: WeighingMode
|
||||
reception: Ref<ReceptionData | null>
|
||||
updateReception: (id: number, payload: Partial<ReceptionData>) => Promise<ReceptionData | null>
|
||||
loadReception?: (id: number) => Promise<ReceptionData | null>
|
||||
storeError?: Ref<string | null>
|
||||
}
|
||||
|
||||
export const useWeighing = ({
|
||||
mode,
|
||||
reception,
|
||||
updateReception,
|
||||
loadReception,
|
||||
storeError
|
||||
}: UseWeighingOptions) => {
|
||||
const weightData = ref<WeightData | null>(null)
|
||||
const localErrorMessage = ref<string | null>(null)
|
||||
|
||||
const currentWeightEntry = computed<WeightEntryData | null>(() => {
|
||||
const weights = reception.value?.weights ?? []
|
||||
return weights.find((entry) => entry.type === mode) ?? null
|
||||
})
|
||||
|
||||
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
|
||||
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
|
||||
const title = computed(() => (mode === 'gross' ? 'Pesée à plein' : 'Pesée à vide'))
|
||||
const errorMessage = computed(() => localErrorMessage.value ?? storeError?.value ?? null)
|
||||
const showLoadingBox = computed(() => displayWeight.value === null && !errorMessage.value)
|
||||
|
||||
const fetchWeight = async () => {
|
||||
localErrorMessage.value = null
|
||||
try {
|
||||
weightData.value = await getWeight()
|
||||
} catch (error) {
|
||||
localErrorMessage.value = error?.data?.error ?? error?.message ?? 'Erreur inconnue.'
|
||||
}
|
||||
}
|
||||
|
||||
const saveWeight = async () => {
|
||||
localErrorMessage.value = null
|
||||
if (!reception.value) {
|
||||
localErrorMessage.value = 'Réception introuvable.'
|
||||
return
|
||||
}
|
||||
|
||||
const existingEntry = currentWeightEntry.value
|
||||
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
|
||||
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
|
||||
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
|
||||
|
||||
if (baseWeight === null) {
|
||||
localErrorMessage.value = 'Veuillez d’abord peser.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (existingEntry?.id) {
|
||||
await updateWeight(existingEntry.id, {
|
||||
type: mode,
|
||||
dsd: baseDsd,
|
||||
weight: baseWeight,
|
||||
weighedAt: baseWeighedAt
|
||||
})
|
||||
} else {
|
||||
await createWeight({
|
||||
reception: `/receptions/${reception.value.id}`,
|
||||
type: mode,
|
||||
dsd: baseDsd,
|
||||
weight: baseWeight,
|
||||
weighedAt: baseWeighedAt
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
localErrorMessage.value = error?.data?.error ?? error?.message ?? 'Erreur inconnue.'
|
||||
return
|
||||
}
|
||||
|
||||
const nextStep = reception.value.currentStep + 1
|
||||
await updateReception(reception.value.id, {
|
||||
currentStep: nextStep,
|
||||
isValid: mode === 'tare' ? true : reception.value.isValid
|
||||
})
|
||||
|
||||
if (loadReception) {
|
||||
await loadReception(reception.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
weightData,
|
||||
currentWeightEntry,
|
||||
displayWeight,
|
||||
displayDsd,
|
||||
title,
|
||||
errorMessage,
|
||||
showLoadingBox,
|
||||
fetchWeight,
|
||||
saveWeight
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="isActive ? 'opacity-100' : 'opacity-50'"
|
||||
:class="isReceptionActive ? 'opacity-100' : 'opacity-50'"
|
||||
>
|
||||
Reception
|
||||
</a>
|
||||
@@ -36,3 +36,8 @@
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const isReceptionActive = computed(() => route.path.startsWith('/reception'))
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
ssr: false,
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE
|
||||
|
||||
98
frontend/package-lock.json
generated
98
frontend/package-lock.json
generated
@@ -7,9 +7,12 @@
|
||||
"name": "frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^4.2.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0"
|
||||
@@ -56,7 +59,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -2678,6 +2680,20 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinia/nuxt": {
|
||||
"version": "0.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.3.tgz",
|
||||
"integrity": "sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w==",
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pinia": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -3512,7 +3528,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
|
||||
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
@@ -3710,7 +3725,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4092,7 +4106,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4206,7 +4219,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -4396,7 +4408,6 @@
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
@@ -7966,7 +7977,6 @@
|
||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.2.2.tgz",
|
||||
"integrity": "sha512-n6oYFikgLEb70J4+K19jAzfx4exZcRSRX7yZn09P5qlf2Z59VNOBqNmaZO5ObzvyGUZ308SZfL629/Q2v2FVjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dxup/nuxt": "^0.2.2",
|
||||
"@nuxt/cli": "^3.31.1",
|
||||
@@ -8245,7 +8255,6 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.102.0.tgz",
|
||||
"integrity": "sha512-xMiyHgr2FZsphQ12ZCsXRvSYzmKXCm1ejmyG4GDZIiKOmhyt5iKtWq0klOfFsEQ6jcgbwrUdwcCVYzr1F+h5og==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.102.0"
|
||||
},
|
||||
@@ -8469,6 +8478,61 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.5.0",
|
||||
"vue": "^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-kit": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.9",
|
||||
"birpc": "^2.3.0",
|
||||
"hookable": "^5.5.3",
|
||||
"mitt": "^3.0.1",
|
||||
"perfect-debounce": "^1.0.0",
|
||||
"speakingurl": "^14.0.1",
|
||||
"superjson": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-shared": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
@@ -8523,7 +8587,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -9073,7 +9136,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -9530,7 +9592,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
||||
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -10341,7 +10402,6 @@
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -11166,7 +11226,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -11523,7 +11582,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-sfc": "3.5.26",
|
||||
@@ -11560,7 +11618,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
@@ -11762,7 +11819,6 @@
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
@@ -11887,6 +11943,14 @@
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^4.2.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0"
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<h1 class="text-3xl font-bold">Nuxt OK ✅</h1>
|
||||
<div class="">
|
||||
<h1 class="text-3xl font-bold">Liste des receptions</h1>
|
||||
<ul>
|
||||
<li v-for="reception in receptionList" :key="reception.id">
|
||||
<NuxtLink :to="`/reception/${reception.id}`">Réception numéro {{ reception.id}}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {ReceptionData} from "~/services/dto/reception-data";
|
||||
import {getReceptionList} from "~/services/reception";
|
||||
|
||||
const receptionList = ref<ReceptionData[]>()
|
||||
|
||||
onMounted(async () => {
|
||||
receptionList.value = await getReceptionList()
|
||||
})
|
||||
</script>
|
||||
|
||||
36
frontend/pages/reception/[[id]].vue
Normal file
36
frontend/pages/reception/[[id]].vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div v-if="errorMessage" class="text-red-600">{{ errorMessage }}</div>
|
||||
<div v-else>
|
||||
<div class="flex justify-between h-[52px] mb-[90px]">
|
||||
<p class="self-center">Indicateur d’étapes</p>
|
||||
<NuxtLink to="/" class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center">Mettre en attente</NuxtLink>
|
||||
</div>
|
||||
<ReceptionForm v-if="storeReception?.currentStep === 0"/>
|
||||
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
|
||||
<ReceptionUnloading v-if="storeReception?.currentStep === 2"/>
|
||||
<ReceptionWeight v-if="storeReception?.currentStep === 3" mode="tare"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useReceptionStore } from '~/stores/reception'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const receptionStore = useReceptionStore()
|
||||
const { current: storeReception, errorMessage } = storeToRefs(receptionStore)
|
||||
|
||||
onMounted(async () => {
|
||||
const raw = route.params.id
|
||||
const idStr = Array.isArray(raw) ? raw[0] : raw
|
||||
const id = idStr ? Number(idStr) : null
|
||||
|
||||
if (id === null) {
|
||||
await receptionStore.createReception()
|
||||
} else {
|
||||
await receptionStore.loadReception(id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
16
frontend/services/dto/reception-data.ts
Normal file
16
frontend/services/dto/reception-data.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface ReceptionData {
|
||||
id: number
|
||||
licensePlate: string | null
|
||||
weights?: WeightEntryData[] | null
|
||||
receptionDate: string
|
||||
currentStep: number
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
export interface WeightEntryData {
|
||||
id?: number
|
||||
type: 'gross' | 'tare'
|
||||
dsd: number | null
|
||||
weight: number | null
|
||||
weighedAt: string | null
|
||||
}
|
||||
5
frontend/services/dto/weight-data.ts
Normal file
5
frontend/services/dto/weight-data.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface WeightData {
|
||||
weight: number | null
|
||||
dsd: number | null
|
||||
weighedAt: string | null
|
||||
}
|
||||
50
frontend/services/reception.ts
Normal file
50
frontend/services/reception.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import type { WeightData } from '~/services/dto/weight-data'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
export async function getReceptionList() {
|
||||
try {
|
||||
return await api.get<ReceptionData>(`receptions`)
|
||||
} catch (error) {
|
||||
console.error(error.message, error)
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReception(id: number) {
|
||||
try {
|
||||
return await api.get<ReceptionData>(`receptions/${id}`)
|
||||
} catch (error) {
|
||||
console.error(error.message, error)
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
export async function createReception(payload: Partial<ReceptionData> = {}) {
|
||||
try {
|
||||
return await api.post<ReceptionData>('receptions', payload)
|
||||
} catch (error) {
|
||||
console.error(error.message, error)
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateReception(id: number, payload: Partial<ReceptionData>) {
|
||||
try {
|
||||
return await api.patch<ReceptionData>(`receptions/${id}`, payload)
|
||||
} catch (error) {
|
||||
console.error(error.message, error)
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWeight(): Promise<WeightData> {
|
||||
try {
|
||||
return await api.get<WeightData>('receptions/weigh')
|
||||
} catch (error) {
|
||||
console.error(error.message, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
30
frontend/services/weight.ts
Normal file
30
frontend/services/weight.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import type { WeightEntryData } from '~/services/dto/reception-data'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
export type WeightPayload = {
|
||||
reception: string
|
||||
type: 'gross' | 'tare'
|
||||
dsd: number | null
|
||||
weight: number | null
|
||||
weighedAt: string | null
|
||||
}
|
||||
|
||||
export async function createWeight(payload: WeightPayload) {
|
||||
try {
|
||||
return await api.post<WeightEntryData>('weights', payload)
|
||||
} catch (error) {
|
||||
console.error(error.message, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWeight(id: number, payload: Partial<WeightPayload>) {
|
||||
try {
|
||||
return await api.patch<WeightEntryData>(`weights/${id}`, payload)
|
||||
} catch (error) {
|
||||
console.error(error.message, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
72
frontend/stores/reception.ts
Normal file
72
frontend/stores/reception.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ReceptionData } from '~/services/dto/reception-data'
|
||||
import { createReception, getReception, updateReception } from '~/services/reception'
|
||||
|
||||
const isReceptionData = (value: unknown): value is ReceptionData => {
|
||||
return Boolean(value && typeof value === 'object' && 'id' in value)
|
||||
}
|
||||
|
||||
export const useReceptionStore = defineStore('reception', {
|
||||
state: () => ({
|
||||
current: null as ReceptionData | null,
|
||||
isLoading: false,
|
||||
errorMessage: null as string | null
|
||||
}),
|
||||
actions: {
|
||||
setCurrent(reception: ReceptionData | null) {
|
||||
this.current = reception
|
||||
},
|
||||
clearError() {
|
||||
this.errorMessage = null
|
||||
},
|
||||
async loadReception(id: number) {
|
||||
this.isLoading = true
|
||||
this.errorMessage = null
|
||||
try {
|
||||
const result = await getReception(id)
|
||||
if (!isReceptionData(result)) {
|
||||
this.errorMessage = 'Réception introuvable.'
|
||||
this.current = null
|
||||
return null
|
||||
}
|
||||
|
||||
this.current = result
|
||||
return result
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
async createReception() {
|
||||
this.isLoading = true
|
||||
this.errorMessage = null
|
||||
try {
|
||||
const result = await createReception()
|
||||
if (!isReceptionData(result)) {
|
||||
this.errorMessage = 'Impossible de créer la réception.'
|
||||
return null
|
||||
}
|
||||
|
||||
this.current = result
|
||||
return result
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
async updateReception(id: number, payload: Partial<ReceptionData>) {
|
||||
this.isLoading = true
|
||||
this.errorMessage = null
|
||||
try {
|
||||
const result = await updateReception(id, payload)
|
||||
if (!isReceptionData(result)) {
|
||||
this.errorMessage = 'Impossible de mettre à jour la réception.'
|
||||
return null
|
||||
}
|
||||
|
||||
this.current = result
|
||||
return result
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
17
frontend/utils/zod-errors.ts
Normal file
17
frontend/utils/zod-errors.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ZodError } from 'zod'
|
||||
|
||||
export type FieldErrors<T extends Record<string, unknown>> = Partial<Record<keyof T, string>>
|
||||
|
||||
export const mapZodErrors = <T extends Record<string, unknown>>(error: ZodError<T>): FieldErrors<T> => {
|
||||
const flattened = error.flatten().fieldErrors
|
||||
const result: FieldErrors<T> = {}
|
||||
|
||||
for (const key in flattened) {
|
||||
const message = flattened[key]?.[0]
|
||||
if (message) {
|
||||
result[key as keyof T] = message
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
26
migrations/Version20260112000100.php
Normal file
26
migrations/Version20260112000100.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260112000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create reception table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE reception (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, dsd INT DEFAULT NULL, weight DOUBLE PRECISION DEFAULT NULL, weighed_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE reception');
|
||||
}
|
||||
}
|
||||
29
migrations/Version20260112000200.php
Normal file
29
migrations/Version20260112000200.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260112000200 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create weight table and link to reception';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE weight (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, reception_id INT NOT NULL, gross_weight INT DEFAULT NULL, tare_weight INT DEFAULT NULL, gross_weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, tare_weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_7B4E3B2304A72F3F ON weight (reception_id)');
|
||||
$this->addSql('ALTER TABLE weight ADD CONSTRAINT FK_7B4E3B2304A72F3F FOREIGN KEY (reception_id) REFERENCES reception (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE weight DROP CONSTRAINT FK_7B4E3B2304A72F3F');
|
||||
$this->addSql('DROP TABLE weight');
|
||||
}
|
||||
}
|
||||
26
migrations/Version20260112000300.php
Normal file
26
migrations/Version20260112000300.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260112000300 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Rename weighed_at to date_reception in reception table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception RENAME COLUMN weighed_at TO date_reception');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception RENAME COLUMN date_reception TO weighed_at');
|
||||
}
|
||||
}
|
||||
30
migrations/Version20260112000400.php
Normal file
30
migrations/Version20260112000400.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260112000400 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add license plate, current step, and validity fields to reception';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception ADD license_plate VARCHAR(20) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE reception ADD current_step INT DEFAULT 0 NOT NULL');
|
||||
$this->addSql('ALTER TABLE reception ADD is_valid BOOLEAN DEFAULT FALSE NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception DROP license_plate');
|
||||
$this->addSql('ALTER TABLE reception DROP current_step');
|
||||
$this->addSql('ALTER TABLE reception DROP is_valid');
|
||||
}
|
||||
}
|
||||
28
migrations/Version20260112000500.php
Normal file
28
migrations/Version20260112000500.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260112000500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Remove dsd and weight columns from reception';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception DROP dsd');
|
||||
$this->addSql('ALTER TABLE reception DROP weight');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE reception ADD dsd INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE reception ADD weight DOUBLE PRECISION DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
42
migrations/Version20260112000600.php
Normal file
42
migrations/Version20260112000600.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260112000600 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Update weight table to store single weighings with type';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX UNIQ_7B4E3B2304A72F3F');
|
||||
$this->addSql('ALTER TABLE weight DROP gross_weight');
|
||||
$this->addSql('ALTER TABLE weight DROP tare_weight');
|
||||
$this->addSql('ALTER TABLE weight DROP gross_weighed_at');
|
||||
$this->addSql('ALTER TABLE weight DROP tare_weighed_at');
|
||||
$this->addSql('ALTER TABLE weight ADD dsd INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE weight ADD weight INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE weight ADD weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE weight ADD type VARCHAR(10) DEFAULT \'gross\' NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE weight DROP dsd');
|
||||
$this->addSql('ALTER TABLE weight DROP weight');
|
||||
$this->addSql('ALTER TABLE weight DROP weighed_at');
|
||||
$this->addSql('ALTER TABLE weight DROP type');
|
||||
$this->addSql('ALTER TABLE weight ADD gross_weight INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE weight ADD tare_weight INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE weight ADD gross_weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE weight ADD tare_weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_7B4E3B2304A72F3F ON weight (reception_id)');
|
||||
}
|
||||
}
|
||||
38
src/Dto/PontBasculeReading.php
Normal file
38
src/Dto/PontBasculeReading.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
|
||||
final readonly class PontBasculeReading
|
||||
{
|
||||
public function __construct(
|
||||
#[Groups(['reception:weigh:read'])]
|
||||
private ?int $dsd,
|
||||
#[Groups(['reception:weigh:read'])]
|
||||
private ?float $weight,
|
||||
#[Groups(['reception:weigh:read'])]
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||
private ?DateTimeImmutable $weighedAt = null,
|
||||
) {}
|
||||
|
||||
public function getDsd(): ?int
|
||||
{
|
||||
return $this->dsd;
|
||||
}
|
||||
|
||||
public function getWeight(): ?float
|
||||
{
|
||||
return $this->weight;
|
||||
}
|
||||
|
||||
public function getWeighedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->weighedAt;
|
||||
}
|
||||
}
|
||||
185
src/Entity/Reception.php
Normal file
185
src/Entity/Reception.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\State\ReceptionWeighingProvider;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'reception')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
requirements: ['id' => '\d+'],
|
||||
normalizationContext: ['groups' => ['reception:read']],
|
||||
),
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['reception:read']],
|
||||
),
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['reception:read']],
|
||||
denormalizationContext: ['groups' => ['reception:write']],
|
||||
),
|
||||
new Patch(
|
||||
requirements: ['id' => '\d+'],
|
||||
normalizationContext: ['groups' => ['reception:read']],
|
||||
denormalizationContext: ['groups' => ['reception:write']],
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/receptions/weigh',
|
||||
openapi: new OpenApiOperation(
|
||||
summary: 'Fetch the current weight reading',
|
||||
description: 'Queries the pont-bascule and returns the weight data.',
|
||||
),
|
||||
normalizationContext: ['groups' => ['reception:weigh:read']],
|
||||
output: PontBasculeReading::class,
|
||||
provider: ReceptionWeighingProvider::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
class Reception
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['reception:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['reception:read', 'reception:write'])]
|
||||
private ?string $licensePlate = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['reception:read', 'reception:write'])]
|
||||
private int $currentStep = 0;
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['reception:read', 'reception:write'])]
|
||||
private bool $isValid = false;
|
||||
|
||||
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
|
||||
#[Groups(['reception:read', 'reception:write'])]
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||
private ?DateTimeImmutable $receptionDate = null;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: Weight::class, mappedBy: 'reception', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[Groups(['reception:read'])]
|
||||
private Collection $weights;
|
||||
|
||||
public function __construct(
|
||||
?DateTimeImmutable $receptionDate = null,
|
||||
) {
|
||||
$this->receptionDate = $receptionDate;
|
||||
$this->weights = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function getLicensePlate(): ?string
|
||||
{
|
||||
return $this->licensePlate;
|
||||
}
|
||||
|
||||
public function setLicensePlate(?string $licensePlate): self
|
||||
{
|
||||
$this->licensePlate = $licensePlate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function getCurrentStep(): int
|
||||
{
|
||||
return $this->currentStep;
|
||||
}
|
||||
|
||||
public function setCurrentStep(int $currentStep): self
|
||||
{
|
||||
$this->currentStep = $currentStep;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->isValid;
|
||||
}
|
||||
|
||||
public function setIsValid(bool $isValid): self
|
||||
{
|
||||
$this->isValid = $isValid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['reception:read'])]
|
||||
public function getReceptionDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->receptionDate;
|
||||
}
|
||||
|
||||
public function setReceptionDate(?DateTimeImmutable $receptionDate): self
|
||||
{
|
||||
$this->receptionDate = $receptionDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Weight>
|
||||
*/
|
||||
public function getWeights(): Collection
|
||||
{
|
||||
return $this->weights;
|
||||
}
|
||||
|
||||
public function addWeight(Weight $weight): self
|
||||
{
|
||||
if (!$this->weights->contains($weight)) {
|
||||
$this->weights->add($weight);
|
||||
$weight->setReception($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeWeight(Weight $weight): self
|
||||
{
|
||||
if ($this->weights->removeElement($weight)) {
|
||||
if ($weight->getReception() === $this) {
|
||||
$weight->setReception(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function initializeReceptionDate(): void
|
||||
{
|
||||
if (null === $this->receptionDate) {
|
||||
$this->receptionDate = new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/Entity/Weight.php
Normal file
139
src/Entity/Weight.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'weight')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(normalizationContext: ['groups' => ['weight:read']]),
|
||||
new GetCollection(normalizationContext: ['groups' => ['weight:read']]),
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['weight:read']],
|
||||
denormalizationContext: ['groups' => ['weight:write']],
|
||||
),
|
||||
new Patch(
|
||||
normalizationContext: ['groups' => ['weight:read']],
|
||||
denormalizationContext: ['groups' => ['weight:write']],
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[UniqueEntity(fields: ['reception', 'type'], message: 'A weighing already exists for this type.')]
|
||||
class Weight
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['reception:read', 'weight:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'weights')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[Groups(['weight:read', 'weight:write'])]
|
||||
private ?Reception $reception = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Groups(['reception:read', 'weight:read', 'weight:write'])]
|
||||
#[Assert\PositiveOrZero]
|
||||
private ?int $dsd = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Groups(['reception:read', 'weight:read', 'weight:write'])]
|
||||
#[Assert\PositiveOrZero]
|
||||
private ?int $weight = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['reception:read', 'weight:read', 'weight:write'])]
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||
private ?DateTimeImmutable $weighedAt = null;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
#[Groups(['reception:read', 'weight:read', 'weight:write'])]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Choice(choices: ['gross', 'tare'])]
|
||||
private string $type = 'gross';
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getReception(): ?Reception
|
||||
{
|
||||
return $this->reception;
|
||||
}
|
||||
|
||||
public function setReception(?Reception $reception): self
|
||||
{
|
||||
$this->reception = $reception;
|
||||
|
||||
if (null !== $reception && !$reception->getWeights()->contains($this)) {
|
||||
$reception->addWeight($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDsd(): ?int
|
||||
{
|
||||
return $this->dsd;
|
||||
}
|
||||
|
||||
public function setDsd(?int $dsd): self
|
||||
{
|
||||
$this->dsd = $dsd;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWeight(): ?int
|
||||
{
|
||||
return $this->weight;
|
||||
}
|
||||
|
||||
public function setWeight(?int $weight): self
|
||||
{
|
||||
$this->weight = $weight;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWeighedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->weighedAt;
|
||||
}
|
||||
|
||||
public function setWeighedAt(?DateTimeImmutable $weighedAt): self
|
||||
{
|
||||
$this->weighedAt = $weighedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(string $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
30
src/Exception/PontBasculeException.php
Normal file
30
src/Exception/PontBasculeException.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class PontBasculeException extends RuntimeException
|
||||
{
|
||||
public static function transportFailure(string $details): self
|
||||
{
|
||||
return new self('Erreur lors de la communication avec le pont bascule: '.$details, 500);
|
||||
}
|
||||
|
||||
public static function invalidPayload(): self
|
||||
{
|
||||
return new self('Réponse invalide du pont bascule.', 500);
|
||||
}
|
||||
|
||||
public static function missingPayloadField(string $field): self
|
||||
{
|
||||
return new self('Réponse incomplète du pont bascule: champ "'.$field.'" manquant.', 500);
|
||||
}
|
||||
|
||||
public static function unreadableValues(): self
|
||||
{
|
||||
return new self('Impossible de lire les valeurs de pesée du pont bascule.', 500);
|
||||
}
|
||||
}
|
||||
68
src/Service/PontBasculePayloadDecoder.php
Normal file
68
src/Service/PontBasculePayloadDecoder.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\Exception\PontBasculeException;
|
||||
|
||||
final class PontBasculePayloadDecoder
|
||||
{
|
||||
public function decode(string $body): PontBasculeReading
|
||||
{
|
||||
// Payload is JSON with a "response_ascii" string containing STX (0x02) segments.
|
||||
$payload = json_decode($body, true);
|
||||
if (!is_array($payload)) {
|
||||
throw PontBasculeException::invalidPayload();
|
||||
}
|
||||
|
||||
$ascii = $payload['response_ascii'] ?? null;
|
||||
if (!is_string($ascii)) {
|
||||
throw PontBasculeException::missingPayloadField('response_ascii');
|
||||
}
|
||||
|
||||
$dsd = null;
|
||||
$net = null;
|
||||
|
||||
// Each segment starts with a 2-digit code followed by the numeric value.
|
||||
$segments = preg_split('/\\x02/', $ascii) ?: [];
|
||||
foreach ($segments as $segment) {
|
||||
$segment = trim($segment);
|
||||
if ('' === $segment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!preg_match('/^(\d{2})(\d+)(?:\.kg)?/', $segment, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = $matches[1];
|
||||
$value = $matches[2];
|
||||
|
||||
// Code 99 holds the DSD value.
|
||||
if ('99' === $code) {
|
||||
$dsd = (int) ltrim($value, '0');
|
||||
if (0 === $dsd && '' !== $value) {
|
||||
$dsd = 0;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code 03 is the net weight; other codes are ignored for now.
|
||||
if ('03' === $code) {
|
||||
$net = (float) ltrim($value, '0');
|
||||
if (0.0 === $net && '' !== $value) {
|
||||
$net = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $dsd && null === $net) {
|
||||
throw PontBasculeException::unreadableValues();
|
||||
}
|
||||
|
||||
return new PontBasculeReading($dsd, $net);
|
||||
}
|
||||
}
|
||||
51
src/Service/PontBasculeService.php
Normal file
51
src/Service/PontBasculeService.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\Exception\PontBasculeException;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
final class PontBasculeService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly PontBasculePayloadDecoder $payloadDecoder,
|
||||
private readonly string $endpoint,
|
||||
private readonly bool $bypass,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @TODO Voir pour que le pont-bascule retourne la date
|
||||
*/
|
||||
public function fetch(): PontBasculeReading
|
||||
{
|
||||
if ($this->bypass) {
|
||||
$body = $this->getBypassPayload();
|
||||
} else {
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', $this->endpoint);
|
||||
$body = $response->getContent(false);
|
||||
} catch (TransportExceptionInterface $exception) {
|
||||
throw PontBasculeException::transportFailure($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$reading = $this->payloadDecoder->decode($body);
|
||||
|
||||
return new PontBasculeReading(
|
||||
$reading->getDsd(),
|
||||
$reading->getWeight(),
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
|
||||
private function getBypassPayload(): string
|
||||
{
|
||||
return '{"ok":true,"busy":false,"mode":"serial","port":"/dev/ttyUSB0","baudrate":9600,"request_hex":"01 10 39 39 4D 0D 0A","response_hex":"01 02 30 34 30 32 30 30 02 30 31 30 30 31 34 32 30 2E 6B 67 20 02 30 32 30 30 30 30 30 30 2E 6B 67 20 02 30 33 30 30 31 34 32 30 2E 6B 67 20 02 39 39 30 30 31 32 31 0D 0A","response_ascii":"\u0001\u0002040200\u000201001420.kg \u000202000000.kg \u000203001420.kg \u00029900121"}';
|
||||
}
|
||||
}
|
||||
30
src/State/ReceptionWeighingProvider.php
Normal file
30
src/State/ReceptionWeighingProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\Exception\PontBasculeException;
|
||||
use App\Service\PontBasculeService;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
final readonly class ReceptionWeighingProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private PontBasculeService $pontBasculeService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?PontBasculeReading
|
||||
{
|
||||
try {
|
||||
$result = $this->pontBasculeService->fetch();
|
||||
} catch (PontBasculeException $exception) {
|
||||
throw new HttpException(500, $exception->getMessage(), $exception);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
61
tests/Service/PontBasculePayloadDecoderTest.php
Normal file
61
tests/Service/PontBasculePayloadDecoderTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Exception\PontBasculeException;
|
||||
use App\Service\PontBasculePayloadDecoder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class PontBasculePayloadDecoderTest extends TestCase
|
||||
{
|
||||
public function testDecodeValidPayload(): void
|
||||
{
|
||||
$decoder = new PontBasculePayloadDecoder();
|
||||
|
||||
$payload = json_encode([
|
||||
'response_ascii' => "\u{0001}\u{0002}040200\u{0002}01001420.kg \u{0002}02000000.kg \u{0002}03001420.kg \u{0002}9900121",
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$result = $decoder->decode($payload);
|
||||
|
||||
self::assertSame(121, $result->getDsd());
|
||||
self::assertSame(1420.0, $result->getWeight());
|
||||
}
|
||||
|
||||
public function testDecodeInvalidPayloadThrows(): void
|
||||
{
|
||||
$decoder = new PontBasculePayloadDecoder();
|
||||
|
||||
$this->expectException(PontBasculeException::class);
|
||||
$this->expectExceptionMessage('Réponse invalide du pont bascule.');
|
||||
|
||||
$decoder->decode('not-json');
|
||||
}
|
||||
|
||||
public function testDecodeMissingFieldThrows(): void
|
||||
{
|
||||
$decoder = new PontBasculePayloadDecoder();
|
||||
$payload = json_encode(['ok' => true], JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->expectException(PontBasculeException::class);
|
||||
$this->expectExceptionMessage('Réponse incomplète du pont bascule: champ "response_ascii" manquant.');
|
||||
|
||||
$decoder->decode($payload);
|
||||
}
|
||||
|
||||
public function testDecodeUnreadableValuesThrows(): void
|
||||
{
|
||||
$decoder = new PontBasculePayloadDecoder();
|
||||
$payload = json_encode(['response_ascii' => 'no-data'], JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->expectException(PontBasculeException::class);
|
||||
$this->expectExceptionMessage('Impossible de lire les valeurs de pesée du pont bascule.');
|
||||
|
||||
$decoder->decode($payload);
|
||||
}
|
||||
}
|
||||
85
tests/Service/PontBasculeServiceTest.php
Normal file
85
tests/Service/PontBasculeServiceTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Exception\PontBasculeException;
|
||||
use App\Service\PontBasculePayloadDecoder;
|
||||
use App\Service\PontBasculeService;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class PontBasculeServiceTest extends TestCase
|
||||
{
|
||||
public function testFetchBypassUsesDecoder(): void
|
||||
{
|
||||
$decoder = new PontBasculePayloadDecoder();
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->expects(self::never())->method('request');
|
||||
|
||||
$service = new PontBasculeService($httpClient, $decoder, 'http://example.test', true);
|
||||
|
||||
$result = $service->fetch();
|
||||
|
||||
self::assertSame(121, $result->getDsd());
|
||||
self::assertSame(1420.0, $result->getWeight());
|
||||
self::assertInstanceOf(DateTimeImmutable::class, $result->getWeighedAt());
|
||||
}
|
||||
|
||||
public function testFetchUsesHttpClientWhenNotBypass(): void
|
||||
{
|
||||
$payload = json_encode([
|
||||
'response_ascii' => "\u{0001}\u{0002}040200\u{0002}03000123.kg \u{0002}9900042",
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
$response->expects(self::once())->method('getContent')->with(false)->willReturn($payload);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient
|
||||
->expects(self::once())
|
||||
->method('request')
|
||||
->with('POST', 'http://example.test')
|
||||
->willReturn($response)
|
||||
;
|
||||
|
||||
$decoder = new PontBasculePayloadDecoder();
|
||||
|
||||
$service = new PontBasculeService($httpClient, $decoder, 'http://example.test', false);
|
||||
|
||||
$result = $service->fetch();
|
||||
|
||||
self::assertSame(42, $result->getDsd());
|
||||
self::assertSame(123.0, $result->getWeight());
|
||||
self::assertInstanceOf(DateTimeImmutable::class, $result->getWeighedAt());
|
||||
}
|
||||
|
||||
public function testFetchThrowsOnTransportFailure(): void
|
||||
{
|
||||
$exception = $this->createStub(TransportExceptionInterface::class);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient
|
||||
->expects(self::once())
|
||||
->method('request')
|
||||
->willThrowException($exception)
|
||||
;
|
||||
|
||||
$decoder = new PontBasculePayloadDecoder();
|
||||
|
||||
$service = new PontBasculeService($httpClient, $decoder, 'http://example.test', false);
|
||||
|
||||
$this->expectException(PontBasculeException::class);
|
||||
$this->expectExceptionMessage('Erreur lors de la communication avec le pont bascule:');
|
||||
|
||||
$service->fetch();
|
||||
}
|
||||
}
|
||||
62
tests/State/ReceptionWeighingProviderTest.php
Normal file
62
tests/State/ReceptionWeighingProviderTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Dto\PontBasculeReading;
|
||||
use App\Service\PontBasculePayloadDecoder;
|
||||
use App\Service\PontBasculeService;
|
||||
use App\State\ReceptionWeighingProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ReceptionWeighingProviderTest extends TestCase
|
||||
{
|
||||
public function testProvideReturnsReading(): void
|
||||
{
|
||||
$decoder = new PontBasculePayloadDecoder();
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient->expects(self::never())->method('request');
|
||||
|
||||
$service = new PontBasculeService($httpClient, $decoder, 'http://example.test', true);
|
||||
|
||||
$provider = new ReceptionWeighingProvider($service);
|
||||
|
||||
$result = $provider->provide(new Get());
|
||||
|
||||
self::assertInstanceOf(PontBasculeReading::class, $result);
|
||||
self::assertSame(121, $result->getDsd());
|
||||
self::assertSame(1420.0, $result->getWeight());
|
||||
}
|
||||
|
||||
public function testProvideThrowsHttpException(): void
|
||||
{
|
||||
$exception = $this->createStub(TransportExceptionInterface::class);
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$httpClient
|
||||
->expects(self::once())
|
||||
->method('request')
|
||||
->willThrowException($exception)
|
||||
;
|
||||
|
||||
$decoder = new PontBasculePayloadDecoder();
|
||||
|
||||
$service = new PontBasculeService($httpClient, $decoder, 'http://example.test', false);
|
||||
|
||||
$provider = new ReceptionWeighingProvider($service);
|
||||
|
||||
$this->expectException(HttpException::class);
|
||||
$this->expectExceptionMessage('Erreur lors de la communication avec le pont bascule:');
|
||||
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user