diff --git a/.idea/workspace.xml b/.idea/workspace.xml index f7eb707..b9f3682 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,28 +4,23 @@ + + + + + file://$PROJECT_DIR$/src/State/ReceptionWeighingProvider.php - 28 + 27 + + file://$PROJECT_DIR$/src/State/ReceptionWeighingProvider.php + 22 + diff --git a/frontend/components/reception/reception-form.vue b/frontend/components/reception/reception-form.vue index d06c73f..9bb342e 100644 --- a/frontend/components/reception/reception-form.vue +++ b/frontend/components/reception/reception-form.vue @@ -1,36 +1,107 @@ diff --git a/frontend/components/reception/reception-unloading.vue b/frontend/components/reception/reception-unloading.vue new file mode 100644 index 0000000..bceac93 --- /dev/null +++ b/frontend/components/reception/reception-unloading.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/components/reception/reception-weight.vue b/frontend/components/reception/reception-weight.vue index 8c8cb8e..a640bf7 100644 --- a/frontend/components/reception/reception-weight.vue +++ b/frontend/components/reception/reception-weight.vue @@ -1,41 +1,110 @@ diff --git a/frontend/components/ui/loading-dots.vue b/frontend/components/ui/loading-dots.vue new file mode 100644 index 0000000..eca91a2 --- /dev/null +++ b/frontend/components/ui/loading-dots.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb12af0..1add253 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 6ac10f9..62e6346 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/pages/reception/[[id]].vue b/frontend/pages/reception/[[id]].vue index 74fc1f6..a87486a 100644 --- a/frontend/pages/reception/[[id]].vue +++ b/frontend/pages/reception/[[id]].vue @@ -1,42 +1,36 @@ diff --git a/frontend/services/dto/reception-data.ts b/frontend/services/dto/reception-data.ts index 06c614a..94b9c42 100644 --- a/frontend/services/dto/reception-data.ts +++ b/frontend/services/dto/reception-data.ts @@ -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 +} diff --git a/frontend/services/dto/weight-data.ts b/frontend/services/dto/weight-data.ts index eb04e26..d4af0a1 100644 --- a/frontend/services/dto/weight-data.ts +++ b/frontend/services/dto/weight-data.ts @@ -1,5 +1,5 @@ export interface WeightData { weight: number | null dsd: number | null - receptionDate: string + weighedAt: string | null } diff --git a/frontend/services/reception.ts b/frontend/services/reception.ts index 68698eb..3edb1f4 100644 --- a/frontend/services/reception.ts +++ b/frontend/services/reception.ts @@ -45,6 +45,6 @@ export async function getWeight(): Promise { return await api.get('receptions/weigh') } catch (error) { console.error(error.message, error) - return 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/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/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 index 96f4215..648c792 100644 --- a/src/Dto/PontBasculeReading.php +++ b/src/Dto/PontBasculeReading.php @@ -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; } } diff --git a/src/Entity/Reception.php b/src/Entity/Reception.php index e26147e..9743b20 100644 --- a/src/Entity/Reception.php +++ b/src/Entity/Reception.php @@ -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 + */ + 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; diff --git a/src/Entity/Weight.php b/src/Entity/Weight.php index c446e22..197e8f4 100644 --- a/src/Entity/Weight.php +++ b/src/Entity/Weight.php @@ -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; } diff --git a/src/State/ReceptionWeighingProvider.php b/src/State/ReceptionWeighingProvider.php index d6209b8..d6b6e0b 100644 --- a/src/State/ReceptionWeighingProvider.php +++ b/src/State/ReceptionWeighingProvider.php @@ -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; } }