From cfe7baa4ae3bf352e09f61ec8c6f8d3b2adf0776 Mon Sep 17 00:00:00 2001 From: AUTIN Tristan Date: Mon, 12 Jan 2026 18:07:58 +0100 Subject: [PATCH] =?UTF-8?q?feat=20:=20Ajout=20de=20pinia,=20cr=C3=A9ation?= =?UTF-8?q?=20de=20la=20table=20weight=20et=20reception=20mise=20en=20plac?= =?UTF-8?q?e=20du=20syst=C3=A8me=20de=20step=20pour=20les=20receptions=20(?= =?UTF-8?q?WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/workspace.xml | 18 +- AGENTS.md | 35 +++ composer.json | 1 + composer.lock | 180 +++++++++++++++- config/packages/api_platform.yaml | 5 + config/reference.php | 6 +- config/services.yaml | 5 + docker/php/config/vhost.conf | 8 +- .../components/reception/reception-form.vue | 36 ++++ .../components/reception/reception-weight.vue | 41 ++++ frontend/composables/useApi.ts | 13 +- frontend/nuxt.config.ts | 4 +- frontend/package-lock.json | 87 ++++++-- frontend/package.json | 2 + frontend/pages/index.vue | 16 +- frontend/pages/reception/[[id]].vue | 42 ++++ frontend/services/dto/reception-data.ts | 9 + frontend/services/dto/weight-data.ts | 5 + frontend/services/reception.ts | 50 +++++ frontend/stores/reception.ts | 72 +++++++ migrations/Version20260112000100.php | 26 +++ migrations/Version20260112000200.php | 29 +++ migrations/Version20260112000300.php | 26 +++ migrations/Version20260112000400.php | 30 +++ src/Dto/PontBasculeReading.php | 31 +++ src/Entity/Reception.php | 199 ++++++++++++++++++ src/Entity/Weight.php | 103 +++++++++ src/Exception/PontBasculeException.php | 30 +++ src/Service/PontBasculePayloadDecoder.php | 68 ++++++ src/Service/PontBasculeService.php | 51 +++++ src/State/ReceptionWeighingProvider.php | 34 +++ 31 files changed, 1226 insertions(+), 36 deletions(-) create mode 100644 AGENTS.md create mode 100644 frontend/components/reception/reception-form.vue create mode 100644 frontend/components/reception/reception-weight.vue create mode 100644 frontend/pages/reception/[[id]].vue create mode 100644 frontend/services/dto/reception-data.ts create mode 100644 frontend/services/dto/weight-data.ts create mode 100644 frontend/services/reception.ts create mode 100644 frontend/stores/reception.ts create mode 100644 migrations/Version20260112000100.php create mode 100644 migrations/Version20260112000200.php create mode 100644 migrations/Version20260112000300.php create mode 100644 migrations/Version20260112000400.php create mode 100644 src/Dto/PontBasculeReading.php create mode 100644 src/Entity/Reception.php create mode 100644 src/Entity/Weight.php create mode 100644 src/Exception/PontBasculeException.php create mode 100644 src/Service/PontBasculePayloadDecoder.php create mode 100644 src/Service/PontBasculeService.php create mode 100644 src/State/ReceptionWeighingProvider.php diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 5e62f92..f7eb707 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,28 +5,27 @@ - - - + + - - - - + - + + + + + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8806129 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# 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: `dsd`, `weight`, `date_reception`, `license_plate`, `current_step` (default 0), `is_valid` (default false). +- `date_reception` is set by the UI, stored as `DateTimeImmutable`. +- Weight entity (`src/Entity/Weight.php`) is 1–1 with Reception, weights stored as `int` (kg), dates nullable. +- 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. +- Reception step UI uses store state (`currentStep`) in `frontend/pages/reception/[[id]].vue`. +- Active nav styles in header use `NuxtLink` with `custom` slot. + +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). diff --git a/composer.json b/composer.json index 0a01515..2881725 100644 --- a/composer.json +++ b/composer.json @@ -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.*", diff --git a/composer.lock b/composer.lock index bd03af8..fbf6e3d 100644 --- a/composer.lock +++ b/composer.lock @@ -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" } diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 02f295a..4b2b414 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -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'] diff --git a/config/reference.php b/config/reference.php index 7fe3045..5437970 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1,5 +1,7 @@ , @@ -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 diff --git a/config/services.yaml b/config/services.yaml index 79b8ce2..1c52e92 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/docker/php/config/vhost.conf b/docker/php/config/vhost.conf index e23c743..d300eef 100644 --- a/docker/php/config/vhost.conf +++ b/docker/php/config/vhost.conf @@ -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] - AliasMatch "^(/.*)?" "/var/www/html/frontend/dist$1" + AliasMatch "^/(?!api)(.*)$" "/var/www/html/frontend/dist/$1" AllowOverride All Order allow,deny diff --git a/frontend/components/reception/reception-form.vue b/frontend/components/reception/reception-form.vue new file mode 100644 index 0000000..d06c73f --- /dev/null +++ b/frontend/components/reception/reception-form.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/components/reception/reception-weight.vue b/frontend/components/reception/reception-weight.vue new file mode 100644 index 0000000..8c8cb8e --- /dev/null +++ b/frontend/components/reception/reception-weight.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts index 186e395..d27e341 100644 --- a/frontend/composables/useApi.ts +++ b/frontend/composables/useApi.ts @@ -21,7 +21,18 @@ export const useApi = (): ApiClient => { url: string, options: FetchOptions<'json'> = {} ) => { - return client(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(url, { ...options, method, headers }) } return { diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 4fc53d1..0095093 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -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 @@ -11,4 +11,4 @@ export default defineNuxtConfig({ typescript: { strict: true } -}) +}) \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d9d7b17..cb12af0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,9 @@ "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" }, @@ -56,7 +58,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 +2679,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 +3527,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 +3724,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 +4105,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4206,7 +4218,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 +4407,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 +7976,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 +8254,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 +8477,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 +8586,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9073,7 +9135,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 +9591,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 +10401,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 +11225,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 +11581,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 +11617,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 +11818,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/frontend/package.json b/frontend/package.json index 8a7e470..6ac10f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,9 @@ "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" }, diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index eeed3db..51c97a7 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -1,9 +1,21 @@ diff --git a/frontend/pages/reception/[[id]].vue b/frontend/pages/reception/[[id]].vue new file mode 100644 index 0000000..74fc1f6 --- /dev/null +++ b/frontend/pages/reception/[[id]].vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/services/dto/reception-data.ts b/frontend/services/dto/reception-data.ts new file mode 100644 index 0000000..06c614a --- /dev/null +++ b/frontend/services/dto/reception-data.ts @@ -0,0 +1,9 @@ +export interface ReceptionData { + id: number + dsd: number | null + licensePlate: string | null + weight: number | null + receptionDate: string + currentStep: number + isValid: boolean +} diff --git a/frontend/services/dto/weight-data.ts b/frontend/services/dto/weight-data.ts new file mode 100644 index 0000000..eb04e26 --- /dev/null +++ b/frontend/services/dto/weight-data.ts @@ -0,0 +1,5 @@ +export interface WeightData { + weight: number | null + dsd: number | null + receptionDate: string +} diff --git a/frontend/services/reception.ts b/frontend/services/reception.ts new file mode 100644 index 0000000..68698eb --- /dev/null +++ b/frontend/services/reception.ts @@ -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(`receptions`) + } catch (error) { + console.error(error.message, error) + return error + } +} + +export async function getReception(id: number) { + try { + return await api.get(`receptions/${id}`) + } catch (error) { + console.error(error.message, error) + return error + } +} + +export async function createReception(payload: Partial = {}) { + try { + return await api.post('receptions', payload) + } catch (error) { + console.error(error.message, error) + return error + } +} + +export async function updateReception(id: number, payload: Partial) { + try { + return await api.patch(`receptions/${id}`, payload) + } catch (error) { + console.error(error.message, error) + return error + } +} + +export async function getWeight(): Promise { + try { + return await api.get('receptions/weigh') + } catch (error) { + console.error(error.message, error) + return error + } +} diff --git a/frontend/stores/reception.ts b/frontend/stores/reception.ts new file mode 100644 index 0000000..3a72772 --- /dev/null +++ b/frontend/stores/reception.ts @@ -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) { + 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 + } + } + } +}) diff --git a/migrations/Version20260112000100.php b/migrations/Version20260112000100.php new file mode 100644 index 0000000..1aab642 --- /dev/null +++ b/migrations/Version20260112000100.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/migrations/Version20260112000200.php b/migrations/Version20260112000200.php new file mode 100644 index 0000000..bb2f654 --- /dev/null +++ b/migrations/Version20260112000200.php @@ -0,0 +1,29 @@ +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'); + } +} diff --git a/migrations/Version20260112000300.php b/migrations/Version20260112000300.php new file mode 100644 index 0000000..c219525 --- /dev/null +++ b/migrations/Version20260112000300.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/migrations/Version20260112000400.php b/migrations/Version20260112000400.php new file mode 100644 index 0000000..d735777 --- /dev/null +++ b/migrations/Version20260112000400.php @@ -0,0 +1,30 @@ +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'); + } +} diff --git a/src/Dto/PontBasculeReading.php b/src/Dto/PontBasculeReading.php new file mode 100644 index 0000000..96f4215 --- /dev/null +++ b/src/Dto/PontBasculeReading.php @@ -0,0 +1,31 @@ +dsd; + } + + public function getWeight(): ?float + { + return $this->weight; + } + + public function getDatetime(): ?DateTimeImmutable + { + return $this->datetime; + } +} diff --git a/src/Entity/Reception.php b/src/Entity/Reception.php new file mode 100644 index 0000000..e26147e --- /dev/null +++ b/src/Entity/Reception.php @@ -0,0 +1,199 @@ + ['reception:read']], + ), + new GetCollection( + normalizationContext: ['groups' => ['reception:read']], + ), + new Post( + normalizationContext: ['groups' => ['reception:read']], + denormalizationContext: ['groups' => ['reception:write']], + ), + new Patch( + 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:read']], + provider: ReceptionWeighingProvider::class, + ), + ], +)] +class Reception +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['reception:read'])] + private ?int $id = null; + + #[ORM\Column(nullable: true)] + #[Groups(['reception:read', 'reception:write'])] + private ?int $dsd = null; + + #[ORM\Column(type: 'float', nullable: true)] + #[Groups(['reception:read', 'reception:write'])] + private ?float $weight = 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'])] + private ?DateTimeImmutable $receptionDate = null; + + #[ORM\OneToOne(targetEntity: Weight::class, mappedBy: 'reception', cascade: ['persist', 'remove'])] + private ?Weight $weightEntry = null; + + public function __construct( + ?int $dsd = null, + ?float $weight = null, + ?DateTimeImmutable $receptionDate = null, + ) { + $this->dsd = $dsd; + $this->weight = $weight; + $this->receptionDate = $receptionDate; + } + + public function getId(): ?int + { + return $this->id; + } + + #[Groups(['reception:read'])] + public function getDsd(): ?int + { + return $this->dsd; + } + + public function setDsd(?int $dsd): self + { + $this->dsd = $dsd; + + return $this; + } + + #[Groups(['reception:read'])] + public function getWeight(): ?float + { + return $this->weight; + } + + public function setWeight(?float $weight): self + { + $this->weight = $weight; + + return $this; + } + + #[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; + } + + public function getWeightEntry(): ?Weight + { + return $this->weightEntry; + } + + public function setWeightEntry(?Weight $weightEntry): self + { + $this->weightEntry = $weightEntry; + + if (null !== $weightEntry && $weightEntry->getReception() !== $this) { + $weightEntry->setReception($this); + } + + return $this; + } + + #[ORM\PrePersist] + public function initializeReceptionDate(): void + { + if (null === $this->receptionDate) { + $this->receptionDate = new DateTimeImmutable(); + } + } +} diff --git a/src/Entity/Weight.php b/src/Entity/Weight.php new file mode 100644 index 0000000..c446e22 --- /dev/null +++ b/src/Entity/Weight.php @@ -0,0 +1,103 @@ +id; + } + + public function getReception(): ?Reception + { + return $this->reception; + } + + public function setReception(?Reception $reception): self + { + $this->reception = $reception; + + if (null !== $reception && $reception->getWeightEntry() !== $this) { + $reception->setWeightEntry($this); + } + + return $this; + } + + public function getGrossWeight(): ?int + { + return $this->grossWeight; + } + + public function setGrossWeight(?int $grossWeight): self + { + $this->grossWeight = $grossWeight; + + return $this; + } + + public function getTareWeight(): ?int + { + return $this->tareWeight; + } + + public function setTareWeight(?int $tareWeight): self + { + $this->tareWeight = $tareWeight; + + return $this; + } + + public function getGrossWeighedAt(): ?DateTimeImmutable + { + return $this->grossWeighedAt; + } + + public function setGrossWeighedAt(?DateTimeImmutable $grossWeighedAt): self + { + $this->grossWeighedAt = $grossWeighedAt; + + return $this; + } + + public function getTareWeighedAt(): ?DateTimeImmutable + { + return $this->tareWeighedAt; + } + + public function setTareWeighedAt(?DateTimeImmutable $tareWeighedAt): self + { + $this->tareWeighedAt = $tareWeighedAt; + + return $this; + } +} diff --git a/src/Exception/PontBasculeException.php b/src/Exception/PontBasculeException.php new file mode 100644 index 0000000..f0587ac --- /dev/null +++ b/src/Exception/PontBasculeException.php @@ -0,0 +1,30 @@ +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"}'; + } +} diff --git a/src/State/ReceptionWeighingProvider.php b/src/State/ReceptionWeighingProvider.php new file mode 100644 index 0000000..d6209b8 --- /dev/null +++ b/src/State/ReceptionWeighingProvider.php @@ -0,0 +1,34 @@ +pontBasculeService->fetch(); + } catch (PontBasculeException $exception) { + throw new HttpException(500, $exception->getMessage(), $exception); + } + + return new Reception( + dsd: $result->getDsd(), + weight: $result->getWeight(), + receptionDate: $result->getDatetime(), + ); + } +}