-
Nuxt OK ✅
+
+
Liste des receptions
+
+ -
+ Réception numéro {{ reception.id}}
+
+
diff --git a/frontend/pages/reception/[[id]].vue b/frontend/pages/reception/[[id]].vue
new file mode 100644
index 0000000..b66ed38
--- /dev/null
+++ b/frontend/pages/reception/[[id]].vue
@@ -0,0 +1,36 @@
+
+ {{ errorMessage }}
+
+
+
Indicateur d’étapes
+
Mettre en attente
+
+
+
+
+
+
+
+
+
diff --git a/frontend/services/dto/reception-data.ts b/frontend/services/dto/reception-data.ts
new file mode 100644
index 0000000..94b9c42
--- /dev/null
+++ b/frontend/services/dto/reception-data.ts
@@ -0,0 +1,16 @@
+export interface ReceptionData {
+ id: number
+ licensePlate: string | null
+ weights?: WeightEntryData[] | null
+ receptionDate: string
+ currentStep: number
+ isValid: boolean
+}
+
+export interface WeightEntryData {
+ id?: number
+ type: 'gross' | 'tare'
+ dsd: number | null
+ weight: number | null
+ weighedAt: string | null
+}
diff --git a/frontend/services/dto/weight-data.ts b/frontend/services/dto/weight-data.ts
new file mode 100644
index 0000000..d4af0a1
--- /dev/null
+++ b/frontend/services/dto/weight-data.ts
@@ -0,0 +1,5 @@
+export interface WeightData {
+ weight: number | null
+ dsd: number | null
+ weighedAt: string | null
+}
diff --git a/frontend/services/reception.ts b/frontend/services/reception.ts
new file mode 100644
index 0000000..3edb1f4
--- /dev/null
+++ b/frontend/services/reception.ts
@@ -0,0 +1,50 @@
+import { useApi } from '~/composables/useApi'
+import type { ReceptionData } from '~/services/dto/reception-data'
+import type { WeightData } from '~/services/dto/weight-data'
+
+const api = useApi()
+
+export async function getReceptionList() {
+ try {
+ return await api.get
(`receptions`)
+ } catch (error) {
+ console.error(error.message, error)
+ return error
+ }
+}
+
+export async function getReception(id: number) {
+ try {
+ return await api.get(`receptions/${id}`)
+ } catch (error) {
+ console.error(error.message, error)
+ return error
+ }
+}
+
+export async function createReception(payload: Partial = {}) {
+ try {
+ return await api.post('receptions', payload)
+ } catch (error) {
+ console.error(error.message, error)
+ return error
+ }
+}
+
+export async function updateReception(id: number, payload: Partial) {
+ try {
+ return await api.patch(`receptions/${id}`, payload)
+ } catch (error) {
+ console.error(error.message, error)
+ return error
+ }
+}
+
+export async function getWeight(): Promise {
+ try {
+ return await api.get('receptions/weigh')
+ } catch (error) {
+ console.error(error.message, error)
+ throw error
+ }
+}
diff --git a/frontend/services/weight.ts b/frontend/services/weight.ts
new file mode 100644
index 0000000..05fa97c
--- /dev/null
+++ b/frontend/services/weight.ts
@@ -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('weights', payload)
+ } catch (error) {
+ console.error(error.message, error)
+ throw error
+ }
+}
+
+export async function updateWeight(id: number, payload: Partial) {
+ try {
+ return await api.patch(`weights/${id}`, payload)
+ } catch (error) {
+ console.error(error.message, error)
+ throw error
+ }
+}
diff --git a/frontend/stores/reception.ts b/frontend/stores/reception.ts
new file mode 100644
index 0000000..3a72772
--- /dev/null
+++ b/frontend/stores/reception.ts
@@ -0,0 +1,72 @@
+import { defineStore } from 'pinia'
+import type { ReceptionData } from '~/services/dto/reception-data'
+import { createReception, getReception, updateReception } from '~/services/reception'
+
+const isReceptionData = (value: unknown): value is ReceptionData => {
+ return Boolean(value && typeof value === 'object' && 'id' in value)
+}
+
+export const useReceptionStore = defineStore('reception', {
+ state: () => ({
+ current: null as ReceptionData | null,
+ isLoading: false,
+ errorMessage: null as string | null
+ }),
+ actions: {
+ setCurrent(reception: ReceptionData | null) {
+ this.current = reception
+ },
+ clearError() {
+ this.errorMessage = null
+ },
+ async loadReception(id: number) {
+ this.isLoading = true
+ this.errorMessage = null
+ try {
+ const result = await getReception(id)
+ if (!isReceptionData(result)) {
+ this.errorMessage = 'Réception introuvable.'
+ this.current = null
+ return null
+ }
+
+ this.current = result
+ return result
+ } finally {
+ this.isLoading = false
+ }
+ },
+ async createReception() {
+ this.isLoading = true
+ this.errorMessage = null
+ try {
+ const result = await createReception()
+ if (!isReceptionData(result)) {
+ this.errorMessage = 'Impossible de créer la réception.'
+ return null
+ }
+
+ this.current = result
+ return result
+ } finally {
+ this.isLoading = false
+ }
+ },
+ async updateReception(id: number, payload: Partial) {
+ this.isLoading = true
+ this.errorMessage = null
+ try {
+ const result = await updateReception(id, payload)
+ if (!isReceptionData(result)) {
+ this.errorMessage = 'Impossible de mettre à jour la réception.'
+ return null
+ }
+
+ this.current = result
+ return result
+ } finally {
+ this.isLoading = false
+ }
+ }
+ }
+})
diff --git a/frontend/utils/zod-errors.ts b/frontend/utils/zod-errors.ts
new file mode 100644
index 0000000..c21b18c
--- /dev/null
+++ b/frontend/utils/zod-errors.ts
@@ -0,0 +1,17 @@
+import type { ZodError } from 'zod'
+
+export type FieldErrors> = Partial>
+
+export const mapZodErrors = >(error: ZodError): FieldErrors => {
+ const flattened = error.flatten().fieldErrors
+ const result: FieldErrors = {}
+
+ for (const key in flattened) {
+ const message = flattened[key]?.[0]
+ if (message) {
+ result[key as keyof T] = message
+ }
+ }
+
+ return result
+}
diff --git a/migrations/Version20260112000100.php b/migrations/Version20260112000100.php
new file mode 100644
index 0000000..1aab642
--- /dev/null
+++ b/migrations/Version20260112000100.php
@@ -0,0 +1,26 @@
+addSql('CREATE TABLE reception (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, dsd INT DEFAULT NULL, weight DOUBLE PRECISION DEFAULT NULL, weighed_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE reception');
+ }
+}
diff --git a/migrations/Version20260112000200.php b/migrations/Version20260112000200.php
new file mode 100644
index 0000000..bb2f654
--- /dev/null
+++ b/migrations/Version20260112000200.php
@@ -0,0 +1,29 @@
+addSql('CREATE TABLE weight (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, reception_id INT NOT NULL, gross_weight INT DEFAULT NULL, tare_weight INT DEFAULT NULL, gross_weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, tare_weighed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
+ $this->addSql('CREATE UNIQUE INDEX UNIQ_7B4E3B2304A72F3F ON weight (reception_id)');
+ $this->addSql('ALTER TABLE weight ADD CONSTRAINT FK_7B4E3B2304A72F3F FOREIGN KEY (reception_id) REFERENCES reception (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE weight DROP CONSTRAINT FK_7B4E3B2304A72F3F');
+ $this->addSql('DROP TABLE weight');
+ }
+}
diff --git a/migrations/Version20260112000300.php b/migrations/Version20260112000300.php
new file mode 100644
index 0000000..c219525
--- /dev/null
+++ b/migrations/Version20260112000300.php
@@ -0,0 +1,26 @@
+addSql('ALTER TABLE reception RENAME COLUMN weighed_at TO date_reception');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE reception RENAME COLUMN date_reception TO weighed_at');
+ }
+}
diff --git a/migrations/Version20260112000400.php b/migrations/Version20260112000400.php
new file mode 100644
index 0000000..d735777
--- /dev/null
+++ b/migrations/Version20260112000400.php
@@ -0,0 +1,30 @@
+addSql('ALTER TABLE reception ADD license_plate VARCHAR(20) DEFAULT NULL');
+ $this->addSql('ALTER TABLE reception ADD current_step INT DEFAULT 0 NOT NULL');
+ $this->addSql('ALTER TABLE reception ADD is_valid BOOLEAN DEFAULT FALSE NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE reception DROP license_plate');
+ $this->addSql('ALTER TABLE reception DROP current_step');
+ $this->addSql('ALTER TABLE reception DROP is_valid');
+ }
+}
diff --git a/migrations/Version20260112000500.php b/migrations/Version20260112000500.php
new file mode 100644
index 0000000..0364e8b
--- /dev/null
+++ b/migrations/Version20260112000500.php
@@ -0,0 +1,28 @@
+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');
+ }
+}
diff --git a/migrations/Version20260112000600.php b/migrations/Version20260112000600.php
new file mode 100644
index 0000000..3ecd139
--- /dev/null
+++ b/migrations/Version20260112000600.php
@@ -0,0 +1,42 @@
+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)');
+ }
+}
diff --git a/src/Dto/PontBasculeReading.php b/src/Dto/PontBasculeReading.php
new file mode 100644
index 0000000..648c792
--- /dev/null
+++ b/src/Dto/PontBasculeReading.php
@@ -0,0 +1,38 @@
+ 'Y-m-d'])]
+ private ?DateTimeImmutable $weighedAt = null,
+ ) {}
+
+ public function getDsd(): ?int
+ {
+ return $this->dsd;
+ }
+
+ public function getWeight(): ?float
+ {
+ return $this->weight;
+ }
+
+ public function getWeighedAt(): ?DateTimeImmutable
+ {
+ return $this->weighedAt;
+ }
+}
diff --git a/src/Entity/Reception.php b/src/Entity/Reception.php
new file mode 100644
index 0000000..9743b20
--- /dev/null
+++ b/src/Entity/Reception.php
@@ -0,0 +1,185 @@
+ '\d+'],
+ normalizationContext: ['groups' => ['reception:read']],
+ ),
+ new GetCollection(
+ normalizationContext: ['groups' => ['reception:read']],
+ ),
+ new Post(
+ normalizationContext: ['groups' => ['reception:read']],
+ denormalizationContext: ['groups' => ['reception:write']],
+ ),
+ new Patch(
+ requirements: ['id' => '\d+'],
+ normalizationContext: ['groups' => ['reception:read']],
+ denormalizationContext: ['groups' => ['reception:write']],
+ ),
+ new Get(
+ uriTemplate: '/receptions/weigh',
+ openapi: new OpenApiOperation(
+ summary: 'Fetch the current weight reading',
+ description: 'Queries the pont-bascule and returns the weight data.',
+ ),
+ normalizationContext: ['groups' => ['reception:weigh:read']],
+ output: PontBasculeReading::class,
+ provider: ReceptionWeighingProvider::class,
+ ),
+ ],
+)]
+class Reception
+{
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['reception:read'])]
+ private ?int $id = null;
+
+ #[ORM\Column(length: 20, nullable: true)]
+ #[Groups(['reception:read', 'reception:write'])]
+ private ?string $licensePlate = null;
+
+ #[ORM\Column(options: ['default' => 0])]
+ #[Groups(['reception:read', 'reception:write'])]
+ private int $currentStep = 0;
+
+ #[ORM\Column(options: ['default' => false])]
+ #[Groups(['reception:read', 'reception:write'])]
+ private bool $isValid = false;
+
+ #[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
+ #[Groups(['reception:read', 'reception:write'])]
+ #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
+ private ?DateTimeImmutable $receptionDate = null;
+
+ #[ORM\OneToMany(targetEntity: Weight::class, mappedBy: 'reception', cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[Groups(['reception:read'])]
+ private Collection $weights;
+
+ public function __construct(
+ ?DateTimeImmutable $receptionDate = null,
+ ) {
+ $this->receptionDate = $receptionDate;
+ $this->weights = new ArrayCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ #[Groups(['reception:read'])]
+ public function getLicensePlate(): ?string
+ {
+ return $this->licensePlate;
+ }
+
+ public function setLicensePlate(?string $licensePlate): self
+ {
+ $this->licensePlate = $licensePlate;
+
+ return $this;
+ }
+
+ #[Groups(['reception:read'])]
+ public function getCurrentStep(): int
+ {
+ return $this->currentStep;
+ }
+
+ public function setCurrentStep(int $currentStep): self
+ {
+ $this->currentStep = $currentStep;
+
+ return $this;
+ }
+
+ #[Groups(['reception:read'])]
+ public function isValid(): bool
+ {
+ return $this->isValid;
+ }
+
+ public function setIsValid(bool $isValid): self
+ {
+ $this->isValid = $isValid;
+
+ return $this;
+ }
+
+ #[Groups(['reception:read'])]
+ public function getReceptionDate(): ?DateTimeImmutable
+ {
+ return $this->receptionDate;
+ }
+
+ public function setReceptionDate(?DateTimeImmutable $receptionDate): self
+ {
+ $this->receptionDate = $receptionDate;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getWeights(): Collection
+ {
+ return $this->weights;
+ }
+
+ public function addWeight(Weight $weight): self
+ {
+ if (!$this->weights->contains($weight)) {
+ $this->weights->add($weight);
+ $weight->setReception($this);
+ }
+
+ return $this;
+ }
+
+ public function removeWeight(Weight $weight): self
+ {
+ if ($this->weights->removeElement($weight)) {
+ if ($weight->getReception() === $this) {
+ $weight->setReception(null);
+ }
+ }
+
+ return $this;
+ }
+
+ #[ORM\PrePersist]
+ public function initializeReceptionDate(): void
+ {
+ if (null === $this->receptionDate) {
+ $this->receptionDate = new DateTimeImmutable();
+ }
+ }
+}
diff --git a/src/Entity/Weight.php b/src/Entity/Weight.php
new file mode 100644
index 0000000..0b619b5
--- /dev/null
+++ b/src/Entity/Weight.php
@@ -0,0 +1,139 @@
+ ['weight:read']]),
+ new GetCollection(normalizationContext: ['groups' => ['weight:read']]),
+ new Post(
+ normalizationContext: ['groups' => ['weight:read']],
+ denormalizationContext: ['groups' => ['weight:write']],
+ ),
+ new Patch(
+ normalizationContext: ['groups' => ['weight:read']],
+ denormalizationContext: ['groups' => ['weight:write']],
+ ),
+ ],
+)]
+#[UniqueEntity(fields: ['reception', 'type'], message: 'A weighing already exists for this type.')]
+class Weight
+{
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ #[Groups(['reception:read', 'weight:read'])]
+ private ?int $id = null;
+
+ #[ORM\ManyToOne(inversedBy: 'weights')]
+ #[ORM\JoinColumn(nullable: false)]
+ #[Groups(['weight:read', 'weight:write'])]
+ private ?Reception $reception = null;
+
+ #[ORM\Column(nullable: true)]
+ #[Groups(['reception:read', 'weight:read', 'weight:write'])]
+ #[Assert\PositiveOrZero]
+ private ?int $dsd = null;
+
+ #[ORM\Column(nullable: true)]
+ #[Groups(['reception:read', 'weight:read', 'weight:write'])]
+ #[Assert\PositiveOrZero]
+ private ?int $weight = null;
+
+ #[ORM\Column(type: 'datetime_immutable', nullable: true)]
+ #[Groups(['reception:read', 'weight:read', 'weight:write'])]
+ #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
+ private ?DateTimeImmutable $weighedAt = null;
+
+ #[ORM\Column(length: 10)]
+ #[Groups(['reception:read', 'weight:read', 'weight:write'])]
+ #[Assert\NotBlank]
+ #[Assert\Choice(choices: ['gross', 'tare'])]
+ private string $type = 'gross';
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getReception(): ?Reception
+ {
+ return $this->reception;
+ }
+
+ public function setReception(?Reception $reception): self
+ {
+ $this->reception = $reception;
+
+ if (null !== $reception && !$reception->getWeights()->contains($this)) {
+ $reception->addWeight($this);
+ }
+
+ return $this;
+ }
+
+ public function getDsd(): ?int
+ {
+ return $this->dsd;
+ }
+
+ public function setDsd(?int $dsd): self
+ {
+ $this->dsd = $dsd;
+
+ return $this;
+ }
+
+ public function getWeight(): ?int
+ {
+ return $this->weight;
+ }
+
+ public function setWeight(?int $weight): self
+ {
+ $this->weight = $weight;
+
+ return $this;
+ }
+
+ public function getWeighedAt(): ?DateTimeImmutable
+ {
+ return $this->weighedAt;
+ }
+
+ public function setWeighedAt(?DateTimeImmutable $weighedAt): self
+ {
+ $this->weighedAt = $weighedAt;
+
+ return $this;
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function setType(string $type): self
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+}
diff --git a/src/Exception/PontBasculeException.php b/src/Exception/PontBasculeException.php
new file mode 100644
index 0000000..f0587ac
--- /dev/null
+++ b/src/Exception/PontBasculeException.php
@@ -0,0 +1,30 @@
+bypass) {
+ $body = $this->getBypassPayload();
+ } else {
+ try {
+ $response = $this->httpClient->request('POST', $this->endpoint);
+ $body = $response->getContent(false);
+ } catch (TransportExceptionInterface $exception) {
+ throw PontBasculeException::transportFailure($exception->getMessage());
+ }
+ }
+
+ $reading = $this->payloadDecoder->decode($body);
+
+ return new PontBasculeReading(
+ $reading->getDsd(),
+ $reading->getWeight(),
+ new DateTimeImmutable(),
+ );
+ }
+
+ private function getBypassPayload(): string
+ {
+ return '{"ok":true,"busy":false,"mode":"serial","port":"/dev/ttyUSB0","baudrate":9600,"request_hex":"01 10 39 39 4D 0D 0A","response_hex":"01 02 30 34 30 32 30 30 02 30 31 30 30 31 34 32 30 2E 6B 67 20 02 30 32 30 30 30 30 30 30 2E 6B 67 20 02 30 33 30 30 31 34 32 30 2E 6B 67 20 02 39 39 30 30 31 32 31 0D 0A","response_ascii":"\u0001\u0002040200\u000201001420.kg \u000202000000.kg \u000203001420.kg \u00029900121"}';
+ }
+}
diff --git a/src/State/ReceptionWeighingProvider.php b/src/State/ReceptionWeighingProvider.php
new file mode 100644
index 0000000..d6b6e0b
--- /dev/null
+++ b/src/State/ReceptionWeighingProvider.php
@@ -0,0 +1,30 @@
+pontBasculeService->fetch();
+ } catch (PontBasculeException $exception) {
+ throw new HttpException(500, $exception->getMessage(), $exception);
+ }
+
+ return $result;
+ }
+}
diff --git a/tests/Service/PontBasculePayloadDecoderTest.php b/tests/Service/PontBasculePayloadDecoderTest.php
new file mode 100644
index 0000000..d56198c
--- /dev/null
+++ b/tests/Service/PontBasculePayloadDecoderTest.php
@@ -0,0 +1,61 @@
+ "\u{0001}\u{0002}040200\u{0002}01001420.kg \u{0002}02000000.kg \u{0002}03001420.kg \u{0002}9900121",
+ ], JSON_THROW_ON_ERROR);
+
+ $result = $decoder->decode($payload);
+
+ self::assertSame(121, $result->getDsd());
+ self::assertSame(1420.0, $result->getWeight());
+ }
+
+ public function testDecodeInvalidPayloadThrows(): void
+ {
+ $decoder = new PontBasculePayloadDecoder();
+
+ $this->expectException(PontBasculeException::class);
+ $this->expectExceptionMessage('Réponse invalide du pont bascule.');
+
+ $decoder->decode('not-json');
+ }
+
+ public function testDecodeMissingFieldThrows(): void
+ {
+ $decoder = new PontBasculePayloadDecoder();
+ $payload = json_encode(['ok' => true], JSON_THROW_ON_ERROR);
+
+ $this->expectException(PontBasculeException::class);
+ $this->expectExceptionMessage('Réponse incomplète du pont bascule: champ "response_ascii" manquant.');
+
+ $decoder->decode($payload);
+ }
+
+ public function testDecodeUnreadableValuesThrows(): void
+ {
+ $decoder = new PontBasculePayloadDecoder();
+ $payload = json_encode(['response_ascii' => 'no-data'], JSON_THROW_ON_ERROR);
+
+ $this->expectException(PontBasculeException::class);
+ $this->expectExceptionMessage('Impossible de lire les valeurs de pesée du pont bascule.');
+
+ $decoder->decode($payload);
+ }
+}
diff --git a/tests/Service/PontBasculeServiceTest.php b/tests/Service/PontBasculeServiceTest.php
new file mode 100644
index 0000000..751c4ce
--- /dev/null
+++ b/tests/Service/PontBasculeServiceTest.php
@@ -0,0 +1,85 @@
+createMock(HttpClientInterface::class);
+ $httpClient->expects(self::never())->method('request');
+
+ $service = new PontBasculeService($httpClient, $decoder, 'http://example.test', true);
+
+ $result = $service->fetch();
+
+ self::assertSame(121, $result->getDsd());
+ self::assertSame(1420.0, $result->getWeight());
+ self::assertInstanceOf(DateTimeImmutable::class, $result->getWeighedAt());
+ }
+
+ public function testFetchUsesHttpClientWhenNotBypass(): void
+ {
+ $payload = json_encode([
+ 'response_ascii' => "\u{0001}\u{0002}040200\u{0002}03000123.kg \u{0002}9900042",
+ ], JSON_THROW_ON_ERROR);
+
+ $response = $this->createMock(ResponseInterface::class);
+ $response->expects(self::once())->method('getContent')->with(false)->willReturn($payload);
+
+ $httpClient = $this->createMock(HttpClientInterface::class);
+ $httpClient
+ ->expects(self::once())
+ ->method('request')
+ ->with('POST', 'http://example.test')
+ ->willReturn($response)
+ ;
+
+ $decoder = new PontBasculePayloadDecoder();
+
+ $service = new PontBasculeService($httpClient, $decoder, 'http://example.test', false);
+
+ $result = $service->fetch();
+
+ self::assertSame(42, $result->getDsd());
+ self::assertSame(123.0, $result->getWeight());
+ self::assertInstanceOf(DateTimeImmutable::class, $result->getWeighedAt());
+ }
+
+ public function testFetchThrowsOnTransportFailure(): void
+ {
+ $exception = $this->createStub(TransportExceptionInterface::class);
+
+ $httpClient = $this->createMock(HttpClientInterface::class);
+ $httpClient
+ ->expects(self::once())
+ ->method('request')
+ ->willThrowException($exception)
+ ;
+
+ $decoder = new PontBasculePayloadDecoder();
+
+ $service = new PontBasculeService($httpClient, $decoder, 'http://example.test', false);
+
+ $this->expectException(PontBasculeException::class);
+ $this->expectExceptionMessage('Erreur lors de la communication avec le pont bascule:');
+
+ $service->fetch();
+ }
+}
diff --git a/tests/State/ReceptionWeighingProviderTest.php b/tests/State/ReceptionWeighingProviderTest.php
new file mode 100644
index 0000000..d68f2a8
--- /dev/null
+++ b/tests/State/ReceptionWeighingProviderTest.php
@@ -0,0 +1,62 @@
+createMock(HttpClientInterface::class);
+ $httpClient->expects(self::never())->method('request');
+
+ $service = new PontBasculeService($httpClient, $decoder, 'http://example.test', true);
+
+ $provider = new ReceptionWeighingProvider($service);
+
+ $result = $provider->provide(new Get());
+
+ self::assertInstanceOf(PontBasculeReading::class, $result);
+ self::assertSame(121, $result->getDsd());
+ self::assertSame(1420.0, $result->getWeight());
+ }
+
+ public function testProvideThrowsHttpException(): void
+ {
+ $exception = $this->createStub(TransportExceptionInterface::class);
+
+ $httpClient = $this->createMock(HttpClientInterface::class);
+ $httpClient
+ ->expects(self::once())
+ ->method('request')
+ ->willThrowException($exception)
+ ;
+
+ $decoder = new PontBasculePayloadDecoder();
+
+ $service = new PontBasculeService($httpClient, $decoder, 'http://example.test', false);
+
+ $provider = new ReceptionWeighingProvider($service);
+
+ $this->expectException(HttpException::class);
+ $this->expectExceptionMessage('Erreur lors de la communication avec le pont bascule:');
+
+ $provider->provide(new Get());
+ }
+}