[#203] Réceptions — Parcours de pesée multi-étapes #3

Merged
tristan merged 10 commits from feat/203-reception-parcours-pesee-multi-etapas into develop 2026-01-14 07:17:34 +00:00
19 changed files with 547 additions and 165 deletions
Showing only changes of commit 6dab1d789a - Show all commits

58
.idea/workspace.xml generated
View File

@@ -4,28 +4,23 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="Feat">
<change afterPath="$PROJECT_DIR$/frontend/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$/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" />
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)">
<change afterPath="$PROJECT_DIR$/frontend/components/reception/reception-unloading.vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/components/ui/loading-dots.vue" 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/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/components/reception/reception-form.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/components/reception/reception-form.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/components/reception/reception-weight.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/components/reception/reception-weight.vue" 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" />
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/[[id]].vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/[[id]].vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/services/dto/reception-data.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/services/dto/reception-data.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/services/dto/weight-data.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/services/dto/weight-data.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/services/reception.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/services/reception.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Dto/PontBasculeReading.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Dto/PontBasculeReading.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/Reception.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/Reception.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/Weight.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/Weight.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/State/ReceptionWeighingProvider.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/State/ReceptionWeighingProvider.php" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -37,7 +32,7 @@
<execution />
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="115" />
<option name="cachedIndexableFilesCount" value="137" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="FileTemplateManagerImpl">
@@ -229,7 +224,7 @@
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "settings.php.debug.servers",
"settings.editor.selected.configurable": "reference.webide.settings.project.settings.php.debug",
"vue.rearranger.settings.migration": "true"
},
"keyToStringList": {
@@ -260,7 +255,17 @@
<updated>1767956826164</updated>
<workItem from="1767956827666" duration="7866000" />
<workItem from="1768201706520" duration="13383000" />
<workItem from="1768287908317" duration="20308000" />
</task>
<task id="LOCAL-00001" summary="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)">
<option name="closed" value="true" />
<created>1768237763998</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1768237763998</updated>
</task>
<option name="localTasksCounter" value="2" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -277,14 +282,25 @@
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Feat : (WIP) Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions" />
<MESSAGE value="Feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)" />
<MESSAGE value="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)" />
<option name="LAST_COMMIT_MESSAGE" value="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<line-breakpoint enabled="true" type="php">
<url>file://$PROJECT_DIR$/src/State/ReceptionWeighingProvider.php</url>
<line>28</line>
<line>27</line>
<option name="timeStamp" value="6" />
</line-breakpoint>
<line-breakpoint enabled="true" type="php">
<url>file://$PROJECT_DIR$/src/State/ReceptionWeighingProvider.php</url>
<line>22</line>
<option name="timeStamp" value="7" />
</line-breakpoint>
</breakpoints>
</breakpoint-manager>
</component>

View File

@@ -1,36 +1,107 @@
<template>
<h1>Formulaire</h1>
<form @submit.prevent="validate">
<div class="grid grid-cols-1 items-start gap-8 mb-16">
<h1 class="font-bold text-5xl uppercase">Réception</h1>
<div class="flex flex-col">
<label for="license-plate" class="font-bold uppercase text-xl mb-4">Immatriculation</label>
<input
id="license-plate"
v-model="form.licensePlate"
type="text"
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
/>
<p v-if="fieldErrors.licensePlate" class="text-red-600 text-sm">{{ fieldErrors.licensePlate }}</p>
</div>
<div class="flex flex-col">
<label for="reception-date" class="font-bold uppercase text-xl mb-4">Date de reception</label>
<input
id="reception-date"
v-model="form.receptionDate"
type="date"
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase"
/>
<p v-if="fieldErrors.receptionDate" class="text-red-600 text-sm">{{ fieldErrors.receptionDate }}</p>
</div>
</div>
<div class="flex justify-center">
<button
@click="validate"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Valider
</button>
</div>
<p v-if="errorMessage" class="text-red-600 mt-4">{{ errorMessage }}</p>
</form>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { z } from 'zod'
import { mapZodErrors } from '~/utils/zod-errors'
import { useReceptionStore } from '~/stores/reception'
type ReceptionFormData = {
licensePlate: string
receptionDate: string
}
const receptionStore = useReceptionStore()
const isLoading = ref<boolean>(false)
const errorMessage = ref<string | null>(null)
const { errorMessage: storeErrorMessage, current: storeReception } = storeToRefs(receptionStore)
const form = reactive<ReceptionFormData>({
licensePlate: '',
receptionDate: ''
})
const fieldErrors = reactive<Partial<Record<keyof ReceptionFormData, string>>>({
licensePlate: undefined,
receptionDate: undefined
})
const errorMessage = computed(() => storeErrorMessage.value)
const formSchema = z.object({
licensePlate: z
.string()
.min(1, 'Immatriculation requise.')
.max(20, 'Immatriculation trop longue (20 caracteres max).'),
receptionDate: z
.string()
.min(1, 'Date de reception requise.')
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date de reception invalide.')
})
watch(
storeReception,
(reception) => {
form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate ?? ''
},
{ immediate: true }
)
async function validate() {
if (!receptionStore.current) {
errorMessage.value = 'Réception introuvable.'
return
}
isLoading.value = true
try {
fieldErrors.licensePlate = undefined
fieldErrors.receptionDate = undefined
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.trim()
const result = formSchema.safeParse({
licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate
})
if (!result.success) {
const errors = mapZodErrors<ReceptionFormData>(result.error)
fieldErrors.licensePlate = errors.licensePlate ?? 'Formulaire invalide.'
fieldErrors.receptionDate = errors.receptionDate ?? 'Formulaire invalide.'
return
}
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep
currentStep: nextStep,
licensePlate: normalizedLicensePlate || null,
receptionDate: normalizedReceptionDate || null
})
} catch (error) {
errorMessage.value = error.error ?? 'Erreur inconnue.'
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center mt-[164px] gap-32">
<div class="flex gap-8 items-center justify-center">
<!--@TODO Prendre en compte que l'on peut aussi décharger de la marchandise-->
<h1 class="text-3xl uppercase font-bold">Décharger les bêtes</h1>
<UiLoadingDots />
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Suivant</button>
</div>
</template>
<script setup lang="ts">
import { useReceptionStore } from '~/stores/reception'
const receptionStore = useReceptionStore()
async function goNext() {
if (!receptionStore.current) {
return
}
const nextStep = receptionStore.current.currentStep + 1
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: nextStep
})
}
</script>

View File

@@ -1,41 +1,110 @@
<template>
<div v-if="weightData">
<p>{{ weightData.weight }} kg</p>
<p>DSD : {{ weightData.dsd }}</p>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase">{{ title }}</h1>
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
<div
v-if="errorMessage || showLoadingBox"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
<p v-if="errorMessage" class="text-red-500">{{ errorMessage }}</p>
<UiLoadingDots v-else />
</div>
<div v-else-if="displayWeight !== null" class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl">
{{ displayWeight }} kg
</div>
<div class="grid grid-cols-2 border border-black text-center">
<p class="border-r border-black py-3 text-4xl font-bold">DSD</p>
<p class="py-3 text-4xl">{{ displayDsd }}</p>
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="getReceptionWeight"
>Peser</button>
>{{ displayWeight !== null ? 'refaire une pesee' : 'peser' }}</button>
<button
v-if="displayWeight !== null"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="validateWeight"
>Valider la pesée</button>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { getWeight } from '~/services/reception'
import type { WeightData } from '~/services/dto/weight-data'
import { createWeight, updateWeight } from '~/services/weight'
import { useReceptionStore } from '~/stores/reception'
const isLoading = ref(false)
const props = defineProps<{
mode: 'gross' | 'tare'
}>()
const weightData = ref<WeightData | null>(null)
const errorMessage = ref<string | null>(null)
const localErrorMessage = ref<string | null>(null)
const receptionStore = useReceptionStore()
const { current: storeReception, errorMessage: storeErrorMessage } = storeToRefs(receptionStore)
const errorMessage = computed(() => localErrorMessage.value ?? storeErrorMessage.value)
const currentWeightEntry = computed(
() => storeReception.value?.weights?.find((entry) => entry.type === props.mode) ?? null
)
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
const title = computed(() => (props.mode === 'gross' ? 'Pesée à plein' : 'Pesée à vide'))
const showLoadingBox = computed(() => displayWeight.value === null && !errorMessage.value)
async function getReceptionWeight() {
isLoading.value = true
localErrorMessage.value = null
try {
weightData.value = await getWeight()
} catch (error) {
localErrorMessage.value = error?.data?.error ?? error?.message ?? 'Erreur inconnue.'
}
}
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
async function validateWeight() {
localErrorMessage.value = null
const existingEntry = currentWeightEntry.value
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
try {
if (existingEntry?.id) {
await updateWeight(existingEntry.id, {
type: props.mode,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
} else {
await createWeight({
reception: `/receptions/${storeReception.value.id}`,
type: props.mode,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
}
} catch (error) {
errorMessage.value = error.error
} finally {
isLoading.value = false
localErrorMessage.value = error?.data?.error ?? error?.message ?? 'Erreur inconnue.'
return
}
// @TODO Voir comment mettre en place la genération du bon, la validation de la reception et le dernier step
const nextStep = storeReception.value.currentStep + 1
await receptionStore.updateReception(storeReception.value.id, {
currentStep: nextStep,
isValid: props.mode === 'tare' ? true : storeReception.value.isValid
})
await receptionStore.loadReception(storeReception.value.id)
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex items-center gap-2 text-sm uppercase">
<span class="loader-dots">
<span class="loader-dot"></span>
<span class="loader-dot"></span>
<span class="loader-dot"></span>
</span>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.loader-dots {
display: inline-flex;
gap: 4px;
align-items: center;
}
.loader-dot {
width: 20px;
height: 20px;
border-radius: 9999px;
background: currentColor;
animation: loader-bounce 1s infinite ease-in-out;
}
.loader-dot:nth-child(2) {
animation-delay: 0.15s;
}
.loader-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes loader-bounce {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@@ -11,7 +11,8 @@
"nuxt": "^4.2.2",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"zod": "^4.3.5"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"
@@ -11942,6 +11943,14 @@
"engines": {
"node": ">= 14"
}
},
"node_modules/zod": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -15,7 +15,8 @@
"nuxt": "^4.2.2",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"zod": "^4.3.5"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"

View File

@@ -1,42 +1,36 @@
<template>
<div v-if="errorMessage" class="text-red-600">{{ errorMessage }}</div>
<div v-if="isLoading" class="text-neutral-600">Chargement...</div>
<div v-else>
<div class="flex justify-between h-[52px] mb-[90px]">
<p class="self-center">Indicateur détapes</p>
<NuxtLink to="/" class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center">Mettre en pause</NuxtLink>
</div>
<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"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
<ReceptionUnloading v-if="storeReception?.currentStep === 2"/>
<ReceptionWeight v-if="storeReception?.currentStep === 3" mode="tare"/>
</div>
</template>
<script setup lang="ts">
import { 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)
const { current: storeReception, isLoading, errorMessage } = 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)
if (id === null) {
await receptionStore.createReception()
} else {
await receptionStore.loadReception(id)
}
} catch (error) {
errorMessage.value = error.error ?? 'Erreur inconnue.'
}
isLoading.value = false
})
</script>

View File

@@ -1,9 +1,16 @@
export interface ReceptionData {
id: number
dsd: number | null
licensePlate: string | null
weight: number | null
weights?: WeightEntryData[] | null
receptionDate: string
currentStep: number
isValid: boolean
}
export interface WeightEntryData {
id?: number
type: 'gross' | 'tare'
dsd: number | null
weight: number | null
weighedAt: string | null
}

View File

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

View File

@@ -45,6 +45,6 @@ export async function getWeight(): Promise<WeightData> {
return await api.get<WeightData>('receptions/weigh')
} catch (error) {
console.error(error.message, error)
return error
throw error
}
}

View File

@@ -0,0 +1,30 @@
import { useApi } from '~/composables/useApi'
import type { WeightEntryData } from '~/services/dto/reception-data'
const api = useApi()
export type WeightPayload = {
reception: string
type: 'gross' | 'tare'
dsd: number | null
weight: number | null
weighedAt: string | null
}
export async function createWeight(payload: WeightPayload) {
try {
return await api.post<WeightEntryData>('weights', payload)
} catch (error) {
console.error(error.message, error)
throw error
}
}
export async function updateWeight(id: number, payload: Partial<WeightPayload>) {
try {
return await api.patch<WeightEntryData>(`weights/${id}`, payload)
} catch (error) {
console.error(error.message, error)
throw error
}
}

View File

@@ -0,0 +1,17 @@
import type { ZodError } from 'zod'
export type FieldErrors<T extends Record<string, unknown>> = Partial<Record<keyof T, string>>
export const mapZodErrors = <T extends Record<string, unknown>>(error: ZodError<T>): FieldErrors<T> => {
const flattened = error.flatten().fieldErrors
const result: FieldErrors<T> = {}
for (const key in flattened) {
const message = flattened[key]?.[0]
if (message) {
result[key as keyof T] = message
}
}
return result
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260112000500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove dsd and weight columns from reception';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE reception DROP dsd');
$this->addSql('ALTER TABLE reception DROP weight');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE reception ADD dsd INT DEFAULT NULL');
$this->addSql('ALTER TABLE reception ADD weight DOUBLE PRECISION DEFAULT NULL');
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260112000600 extends AbstractMigration
{
public function getDescription(): string
{
return 'Update weight table to store single weighings with type';
}
public function up(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_7B4E3B2304A72F3F');
$this->addSql('ALTER TABLE weight DROP gross_weight');
$this->addSql('ALTER TABLE weight DROP tare_weight');
$this->addSql('ALTER TABLE weight DROP gross_weighed_at');
$this->addSql('ALTER TABLE weight DROP tare_weighed_at');
$this->addSql('ALTER TABLE weight ADD dsd INT DEFAULT NULL');
$this->addSql('ALTER TABLE weight ADD weight INT DEFAULT NULL');
$this->addSql('ALTER TABLE weight ADD weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE weight ADD type VARCHAR(10) DEFAULT \'gross\' NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE weight DROP dsd');
$this->addSql('ALTER TABLE weight DROP weight');
$this->addSql('ALTER TABLE weight DROP weighed_at');
$this->addSql('ALTER TABLE weight DROP type');
$this->addSql('ALTER TABLE weight ADD gross_weight INT DEFAULT NULL');
$this->addSql('ALTER TABLE weight ADD tare_weight INT DEFAULT NULL');
$this->addSql('ALTER TABLE weight ADD gross_weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE weight ADD tare_weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_7B4E3B2304A72F3F ON weight (reception_id)');
}
}

View File

@@ -5,13 +5,20 @@ declare(strict_types=1);
namespace App\Dto;
use DateTimeImmutable;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
final readonly class PontBasculeReading
{
public function __construct(
#[Groups(['reception:weigh:read'])]
private ?int $dsd,
#[Groups(['reception:weigh:read'])]
private ?float $weight,
private ?DateTimeImmutable $datetime = null,
#[Groups(['reception:weigh:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $weighedAt = null,
) {}
public function getDsd(): ?int
@@ -24,8 +31,8 @@ final readonly class PontBasculeReading
return $this->weight;
}
public function getDatetime(): ?DateTimeImmutable
public function getWeighedAt(): ?DateTimeImmutable
{
return $this->datetime;
return $this->weighedAt;
}
}

View File

@@ -10,10 +10,15 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading;
use App\State\ReceptionWeighingProvider;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
@@ -21,6 +26,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['reception:read']],
),
new GetCollection(
@@ -31,6 +37,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['reception:write']],
),
new Patch(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['reception:read']],
denormalizationContext: ['groups' => ['reception:write']],
),
@@ -40,7 +47,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
summary: 'Fetch the current weight reading',
description: 'Queries the pont-bascule and returns the weight data.',
),
normalizationContext: ['groups' => ['reception:read']],
normalizationContext: ['groups' => ['reception:weigh:read']],
output: PontBasculeReading::class,
provider: ReceptionWeighingProvider::class,
),
],
@@ -53,14 +61,6 @@ class Reception
#[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;
@@ -74,20 +74,19 @@ class Reception
private bool $isValid = false;
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
#[Groups(['reception:read'])]
#[Groups(['reception:read', 'reception:write'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $receptionDate = null;
#[ORM\OneToOne(targetEntity: Weight::class, mappedBy: 'reception', cascade: ['persist', 'remove'])]
private ?Weight $weightEntry = null;
#[ORM\OneToMany(targetEntity: Weight::class, mappedBy: 'reception', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['reception:read'])]
private Collection $weights;
public function __construct(
?int $dsd = null,
?float $weight = null,
?DateTimeImmutable $receptionDate = null,
) {
$this->dsd = $dsd;
$this->weight = $weight;
$this->receptionDate = $receptionDate;
$this->weights = new ArrayCollection();
}
public function getId(): ?int
@@ -95,32 +94,6 @@ class Reception
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
{
@@ -173,17 +146,30 @@ class Reception
return $this;
}
public function getWeightEntry(): ?Weight
/**
* @return Collection<int, Weight>
*/
public function getWeights(): Collection
{
return $this->weightEntry;
return $this->weights;
}
public function setWeightEntry(?Weight $weightEntry): self
public function addWeight(Weight $weight): self
{
$this->weightEntry = $weightEntry;
if (!$this->weights->contains($weight)) {
$this->weights->add($weight);
$weight->setReception($this);
}
if (null !== $weightEntry && $weightEntry->getReception() !== $this) {
$weightEntry->setReception($this);
return $this;
}
public function removeWeight(Weight $weight): self
{
if ($this->weights->removeElement($weight)) {
if ($weight->getReception() === $this) {
$weight->setReception(null);
}
}
return $this;

View File

@@ -4,33 +4,62 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\Table(name: 'weight')]
#[ApiResource(
operations: [
new Get(normalizationContext: ['groups' => ['weight:read']]),
new GetCollection(normalizationContext: ['groups' => ['weight:read']]),
new Post(
normalizationContext: ['groups' => ['weight:read']],
denormalizationContext: ['groups' => ['weight:write']],
),
new Patch(
normalizationContext: ['groups' => ['weight:read']],
denormalizationContext: ['groups' => ['weight:write']],
),
],
)]
class Weight
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['reception:read', 'weight:read'])]
private ?int $id = null;
#[ORM\OneToOne(inversedBy: 'weightEntry')]
#[ORM\ManyToOne(inversedBy: 'weights')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['weight:read', 'weight:write'])]
private ?Reception $reception = null;
#[ORM\Column(nullable: true)]
private ?int $grossWeight = null;
#[Groups(['reception:read', 'weight:read', 'weight:write'])]
private ?int $dsd = null;
#[ORM\Column(nullable: true)]
private ?int $tareWeight = null;
#[Groups(['reception:read', 'weight:read', 'weight:write'])]
private ?int $weight = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $grossWeighedAt = null;
#[Groups(['reception:read', 'weight:read', 'weight:write'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $weighedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $tareWeighedAt = null;
#[ORM\Column(length: 10)]
#[Groups(['reception:read', 'weight:read', 'weight:write'])]
private string $type = 'gross';
public function getId(): ?int
{
@@ -46,57 +75,57 @@ class Weight
{
$this->reception = $reception;
if (null !== $reception && $reception->getWeightEntry() !== $this) {
$reception->setWeightEntry($this);
if (null !== $reception && !$reception->getWeights()->contains($this)) {
$reception->addWeight($this);
}
return $this;
}
public function getGrossWeight(): ?int
public function getDsd(): ?int
{
return $this->grossWeight;
return $this->dsd;
}
public function setGrossWeight(?int $grossWeight): self
public function setDsd(?int $dsd): self
{
$this->grossWeight = $grossWeight;
$this->dsd = $dsd;
return $this;
}
public function getTareWeight(): ?int
public function getWeight(): ?int
{
return $this->tareWeight;
return $this->weight;
}
public function setTareWeight(?int $tareWeight): self
public function setWeight(?int $weight): self
{
$this->tareWeight = $tareWeight;
$this->weight = $weight;
return $this;
}
public function getGrossWeighedAt(): ?DateTimeImmutable
public function getWeighedAt(): ?DateTimeImmutable
{
return $this->grossWeighedAt;
return $this->weighedAt;
}
public function setGrossWeighedAt(?DateTimeImmutable $grossWeighedAt): self
public function setWeighedAt(?DateTimeImmutable $weighedAt): self
{
$this->grossWeighedAt = $grossWeighedAt;
$this->weighedAt = $weighedAt;
return $this;
}
public function getTareWeighedAt(): ?DateTimeImmutable
public function getType(): string
{
return $this->tareWeighedAt;
return $this->type;
}
public function setTareWeighedAt(?DateTimeImmutable $tareWeighedAt): self
public function setType(string $type): self
{
$this->tareWeighedAt = $tareWeighedAt;
$this->type = $type;
return $this;
}

View File

@@ -6,7 +6,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Reception;
use App\Dto\PontBasculeReading;
use App\Exception\PontBasculeException;
use App\Service\PontBasculeService;
use Symfony\Component\HttpKernel\Exception\HttpException;
@@ -17,7 +17,7 @@ final readonly class ReceptionWeighingProvider implements ProviderInterface
private PontBasculeService $pontBasculeService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?Reception
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?PontBasculeReading
{
try {
$result = $this->pontBasculeService->fetch();
@@ -25,10 +25,6 @@ final readonly class ReceptionWeighingProvider implements ProviderInterface
throw new HttpException(500, $exception->getMessage(), $exception);
}
return new Reception(
dsd: $result->getDsd(),
weight: $result->getWeight(),
receptionDate: $result->getDatetime(),
);
return $result;
}
}