[#203] Réceptions — Parcours de pesée multi-étapes #3
18
.idea/workspace.xml
generated
18
.idea/workspace.xml
generated
@@ -5,28 +5,27 @@
|
||||
</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/components/reception/reception-form.vue" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/frontend/components/reception/reception-weight.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" />
|
||||
<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/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/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/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/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>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -224,7 +223,7 @@
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "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.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
@@ -241,6 +240,7 @@
|
||||
}]]></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>
|
||||
|
||||
35
AGENTS.md
Normal file
35
AGENTS.md
Normal 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 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).
|
||||
@@ -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
|
||||
|
||||
36
frontend/components/reception/reception-form.vue
Normal file
36
frontend/components/reception/reception-form.vue
Normal 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>
|
||||
41
frontend/components/reception/reception-weight.vue
Normal file
41
frontend/components/reception/reception-weight.vue
Normal 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>
|
||||
@@ -21,7 +21,18 @@ export const useApi = (): ApiClient => {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
87
frontend/package-lock.json
generated
87
frontend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
42
frontend/pages/reception/[[id]].vue
Normal file
42
frontend/pages/reception/[[id]].vue
Normal 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>
|
||||
9
frontend/services/dto/reception-data.ts
Normal file
9
frontend/services/dto/reception-data.ts
Normal 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
|
||||
}
|
||||
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
|
||||
receptionDate: string
|
||||
}
|
||||
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)
|
||||
return 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
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');
|
||||
}
|
||||
}
|
||||
31
src/Dto/PontBasculeReading.php
Normal file
31
src/Dto/PontBasculeReading.php
Normal 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
199
src/Entity/Reception.php
Normal 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
103
src/Entity/Weight.php
Normal 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;
|
||||
}
|
||||
}
|
||||
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"}';
|
||||
}
|
||||
}
|
||||
34
src/State/ReceptionWeighingProvider.php
Normal file
34
src/State/ReceptionWeighingProvider.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user