feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)

This commit is contained in:
2026-01-12 18:07:58 +01:00
parent 03638d988b
commit cfe7baa4ae
31 changed files with 1226 additions and 36 deletions

18
.idea/workspace.xml generated
View File

@@ -5,28 +5,27 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="Feat"> <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/components/reception/reception-form.vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/layouts/default.vue" afterDir="false" /> <change afterPath="$PROJECT_DIR$/frontend/components/reception/reception-weight.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/dto/weight-data.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/reception.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/Dto/PontBasculeReading.php" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/Exception/PontBasculeException.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/PontBasculePayloadDecoder.php" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/Service/PontBasculeService.php" afterDir="false" /> <change afterPath="$PROJECT_DIR$/src/Service/PontBasculeService.php" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/State/ReceptionWeighingProvider.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" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <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.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$/composer.lock" beforeDir="false" afterPath="$PROJECT_DIR$/composer.lock" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/packages/api_platform.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/config/packages/api_platform.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" 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$/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$/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/composables/useApi.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/composables/useApi.ts" 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/nuxt.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/nuxt.config.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/index.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/index.vue" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -224,7 +223,7 @@
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true", "RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true", "RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "feat/ajout-configuration-front", "git-widget-placeholder": "feat/203-reception-parcours-pesee-multi-etapas",
"node.js.detected.package.eslint": "true", "node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true", "node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)", "node.js.selected.package.eslint": "(autodetect)",
@@ -241,6 +240,7 @@
}]]></component> }]]></component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS"> <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" /> <recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages" />
</key> </key>
</component> </component>

35
AGENTS.md Normal file
View File

@@ -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 11 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).

View File

@@ -21,6 +21,7 @@
"symfony/expression-language": "8.0.*", "symfony/expression-language": "8.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*", "symfony/property-info": "8.0.*",
"symfony/runtime": "8.0.*", "symfony/runtime": "8.0.*",

180
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "bab4560dec1d36eec0b0aa2284bd8559", "content-hash": "3e883e3a506afa201779d16a950f4845",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -4429,6 +4429,180 @@
], ],
"time": "2025-12-23T14:52:06+00:00" "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", "name": "symfony/http-foundation",
"version": "v8.0.3", "version": "v8.0.3",
@@ -10208,7 +10382,7 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
@@ -10216,6 +10390,6 @@
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*" "ext-iconv": "*"
}, },
"platform-dev": [], "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"
} }

View File

@@ -5,3 +5,8 @@ api_platform:
stateless: true stateless: true
cache_headers: cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin'] vary: ['Content-Type', 'Authorization', 'Origin']
formats:
json: ['application/json']
jsonld: ['application/ld+json']
patch_formats:
json: ['application/merge-patch+json']

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content. // This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
namespace Symfony\Component\DependencyInjection\Loader\Configurator; 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 * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration * 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. * max_host_connections?: int|Param, // The maximum number of connections to a single host.
* default_options?: array{ * default_options?: array{
* headers?: array<string, mixed>, * headers?: array<string, mixed>,
@@ -1378,7 +1380,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* mercure?: bool|array{ * mercure?: bool|array{
* enabled?: bool|Param, // Default: false * 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 * 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{ * messenger?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false

View File

@@ -19,5 +19,10 @@ services:
App\: App\:
resource: '../src/' 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 # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones

View File

@@ -6,9 +6,15 @@
Options FollowSymLinks Options FollowSymLinks
AllowOverride All AllowOverride All
Require all granted Require all granted
RewriteEngine On
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /api/index.php [L]
</Directory> </Directory>
AliasMatch "^(/.*)?" "/var/www/html/frontend/dist$1" AliasMatch "^/(?!api)(.*)$" "/var/www/html/frontend/dist/$1"
<Directory /var/www/html/frontend/dist> <Directory /var/www/html/frontend/dist>
AllowOverride All AllowOverride All
Order allow,deny Order allow,deny

View File

@@ -0,0 +1,36 @@
<template>
<h1>Formulaire</h1>
<button
@click="validate"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
>Valider
</button>
</template>
<script setup lang="ts">
import { useReceptionStore } from '~/stores/reception'
const receptionStore = useReceptionStore()
const isLoading = ref<boolean>(false)
const errorMessage = ref<string | null>(null)
async function validate() {
if (!receptionStore.current) {
errorMessage.value = 'Réception introuvable.'
return
}
isLoading.value = true
try {
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep
})
} catch (error) {
errorMessage.value = error.error ?? 'Erreur inconnue.'
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div v-if="weightData">
<p>{{ weightData.weight }} kg</p>
<p>DSD : {{ weightData.dsd }}</p>
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="getReceptionWeight"
>Peser</button>
</template>
<script setup lang="ts">
import { getWeight } from '~/services/reception'
import type { WeightData } from '~/services/dto/weight-data'
import { useReceptionStore } from '~/stores/reception'
const isLoading = ref(false)
const weightData = ref<WeightData | null>(null)
const errorMessage = ref<string | null>(null)
const receptionStore = useReceptionStore()
async function getReceptionWeight() {
isLoading.value = true
try {
weightData.value = await getWeight()
if (receptionStore.current) {
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
dsd: weightData.value?.dsd ?? null,
weight: weightData.value?.weight ?? null,
currentStep: nextStep
})
}
} catch (error) {
errorMessage.value = error.error
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -21,7 +21,18 @@ export const useApi = (): ApiClient => {
url: string, url: string,
options: FetchOptions<'json'> = {} 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 { return {

View File

@@ -2,7 +2,7 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: { enabled: true }, devtools: { enabled: true },
ssr: false, ssr: false,
modules: ['@nuxtjs/tailwindcss'], modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
runtimeConfig: { runtimeConfig: {
public: { public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE apiBase: process.env.NUXT_PUBLIC_API_BASE
@@ -11,4 +11,4 @@ export default defineNuxtConfig({
typescript: { typescript: {
strict: true strict: true
} }
}) })

View File

@@ -7,7 +7,9 @@
"name": "frontend", "name": "frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.2.2", "nuxt": "^4.2.2",
"pinia": "^3.0.4",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
@@ -56,7 +58,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -2678,6 +2679,20 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "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", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.28.5", "@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.26", "@vue/compiler-core": "3.5.26",
@@ -3710,7 +3724,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4092,7 +4105,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4206,7 +4218,6 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -4396,7 +4407,6 @@
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"consola": "^3.2.3" "consola": "^3.2.3"
} }
@@ -7966,7 +7976,6 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.2.2.tgz", "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.2.2.tgz",
"integrity": "sha512-n6oYFikgLEb70J4+K19jAzfx4exZcRSRX7yZn09P5qlf2Z59VNOBqNmaZO5ObzvyGUZ308SZfL629/Q2v2FVjw==", "integrity": "sha512-n6oYFikgLEb70J4+K19jAzfx4exZcRSRX7yZn09P5qlf2Z59VNOBqNmaZO5ObzvyGUZ308SZfL629/Q2v2FVjw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dxup/nuxt": "^0.2.2", "@dxup/nuxt": "^0.2.2",
"@nuxt/cli": "^3.31.1", "@nuxt/cli": "^3.31.1",
@@ -8245,7 +8254,6 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.102.0.tgz", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.102.0.tgz",
"integrity": "sha512-xMiyHgr2FZsphQ12ZCsXRvSYzmKXCm1ejmyG4GDZIiKOmhyt5iKtWq0klOfFsEQ6jcgbwrUdwcCVYzr1F+h5og==", "integrity": "sha512-xMiyHgr2FZsphQ12ZCsXRvSYzmKXCm1ejmyG4GDZIiKOmhyt5iKtWq0klOfFsEQ6jcgbwrUdwcCVYzr1F+h5og==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@oxc-project/types": "^0.102.0" "@oxc-project/types": "^0.102.0"
}, },
@@ -8469,6 +8477,61 @@
"node": ">=0.10.0" "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": { "node_modules/pirates": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@@ -8523,7 +8586,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -9073,7 +9135,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
"util-deprecate": "^1.0.2" "util-deprecate": "^1.0.2"
@@ -9530,7 +9591,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -10341,7 +10401,6 @@
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -11166,7 +11225,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -11523,7 +11581,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.26", "@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "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", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/devtools-api": "^6.6.4" "@vue/devtools-api": "^6.6.4"
}, },
@@ -11762,7 +11818,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },

View File

@@ -11,7 +11,9 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist" "build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
}, },
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.2.2", "nuxt": "^4.2.2",
"pinia": "^3.0.4",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },

View File

@@ -1,9 +1,21 @@
<template> <template>
<div class="min-h-screen flex items-center justify-center"> <div class="">
<h1 class="text-3xl font-bold">Nuxt OK </h1> <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> </div>
</template> </template>
<script setup lang="ts"> <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> </script>

View File

@@ -0,0 +1,42 @@
<template>
<div v-if="errorMessage" class="text-red-600">{{ errorMessage }}</div>
<div v-if="isLoading" class="text-neutral-600">Chargement...</div>
<div v-else>
<ReceptionForm v-if="storeReception?.currentStep === 0"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1"/>
<div v-if="storeReception?.currentStep === 2">Décharger</div>
<ReceptionWeight v-if="storeReception?.currentStep === 3"/>
</div>
</template>
<script setup lang="ts">
import { createReception, getReception } from '~/services/reception'
import type { ReceptionData } from '~/services/dto/reception-data'
import { useReceptionStore } from '~/stores/reception'
import { storeToRefs } from 'pinia'
const route = useRoute()
const router = useRouter()
const isLoading = ref<boolean>(false)
const errorMessage = ref<string | null>(null)
const receptionStore = useReceptionStore()
const { current: storeReception } = storeToRefs(receptionStore)
onMounted(async () => {
isLoading.value = true
const raw = route.params.id
const idStr = Array.isArray(raw) ? raw[0] : raw
const id = idStr ? Number(idStr) : null
try {
const result = id === null ? await createReception() : await getReception(id)
if (result) {
receptionStore.setCurrent(result as ReceptionData)
}
} catch (error) {
errorMessage.value = error.error ?? 'Erreur inconnue.'
}
isLoading.value = false
})
</script>

View File

@@ -0,0 +1,9 @@
export interface ReceptionData {
id: number
dsd: number | null
licensePlate: string | null
weight: number | null
receptionDate: string
currentStep: number
isValid: boolean
}

View File

@@ -0,0 +1,5 @@
export interface WeightData {
weight: number | null
dsd: number | null
receptionDate: string
}

View 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)
return error
}
}

View 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
}
}
}
})

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Dto;
use DateTimeImmutable;
final readonly class PontBasculeReading
{
public function __construct(
private ?int $dsd,
private ?float $weight,
private ?DateTimeImmutable $datetime = null,
) {}
public function getDsd(): ?int
{
return $this->dsd;
}
public function getWeight(): ?float
{
return $this->weight;
}
public function getDatetime(): ?DateTimeImmutable
{
return $this->datetime;
}
}

199
src/Entity/Reception.php Normal file
View File

@@ -0,0 +1,199 @@
<?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\State\ReceptionWeighingProvider;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')]
#[ApiResource(
operations: [
new Get(
normalizationContext: ['groups' => ['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();
}
}
}

103
src/Entity/Weight.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'weight')]
class Weight
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToOne(inversedBy: 'weightEntry')]
#[ORM\JoinColumn(nullable: false)]
private ?Reception $reception = null;
#[ORM\Column(nullable: true)]
private ?int $grossWeight = null;
#[ORM\Column(nullable: true)]
private ?int $tareWeight = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $grossWeighedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $tareWeighedAt = null;
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->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;
}
}

View 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);
}
}

View 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);
}
}

View 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"}';
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Reception;
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 = []): ?Reception
{
try {
$result = $this->pontBasculeService->fetch();
} catch (PontBasculeException $exception) {
throw new HttpException(500, $exception->getMessage(), $exception);
}
return new Reception(
dsd: $result->getDsd(),
weight: $result->getWeight(),
receptionDate: $result->getDatetime(),
);
}
}