feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception

This commit is contained in:
2026-01-29 16:33:37 +01:00
parent cff80b5ab2
commit 07be7e8d14
26 changed files with 1141 additions and 107 deletions

25
.idea/workspace.xml generated
View File

@@ -4,15 +4,15 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/AGENTS.md" beforeDir="false" afterPath="$PROJECT_DIR$/AGENTS.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" 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/components/ui/pdf-printer.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/components/reception/reception-unloading.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/components/reception/reception-product-received.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/composables/usePdfPrinter.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/composables/usePdfPrinter.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/i18n/locales/fr.json" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/i18n/locales/fr.json" 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$/src/Entity/Address.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/Address.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$/templates/reception_voucher.html.twig" beforeDir="false" afterPath="$PROJECT_DIR$/templates/reception_voucher.html.twig" afterDir="false" />
</list>
@@ -278,7 +278,8 @@
<workItem from="1768894030675" duration="83922000" />
<workItem from="1769413136483" duration="58000" />
<workItem from="1769413279223" duration="40490000" />
<workItem from="1769612160652" duration="12834000" />
<workItem from="1769612160652" duration="23952000" />
<workItem from="1769696465294" duration="4048000" />
</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" />
@@ -576,7 +577,15 @@
<option name="project" value="LOCAL" />
<updated>1769529522614</updated>
</task>
<option name="localTasksCounter" value="38" />
<task id="LOCAL-00038" summary="feat : ajout du numéro identification des receptions et ajustement du bon de reception">
<option name="closed" value="true" />
<created>1769676223697</created>
<option name="number" value="00038" />
<option name="presentableId" value="LOCAL-00038" />
<option name="project" value="LOCAL" />
<updated>1769676223697</updated>
</task>
<option name="localTasksCounter" value="39" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -626,7 +635,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="feat : ajout de l'authentification avec lexik" />
<MESSAGE value="fix : correction de l'accès au swagger en mode dev qui n'était plus accessible" />
<MESSAGE value="feat : ajout de la conf pour le déploiement en recette" />
<MESSAGE value="fix : fix de la conf pour le déploiement en recette" />
@@ -651,7 +659,8 @@
<MESSAGE value="fix : modification de la conf du bundle ednotif" />
<MESSAGE value="feat : update du CHANGELOG.md" />
<MESSAGE value="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)" />
<option name="LAST_COMMIT_MESSAGE" value="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)" />
<MESSAGE value="feat : ajout du numéro identification des receptions et ajustement du bon de reception" />
<option name="LAST_COMMIT_MESSAGE" value="feat : ajout du numéro identification des receptions et ajustement du bon de reception" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

View File

@@ -10,6 +10,7 @@ Backend conventions
- API Platform operations are defined on Doctrine entities.
- Reception entity is in `src/Entity/Reception.php`, with custom weigh endpoint `/receptions/weigh`.
- Reception fields: `date_reception`, `license_plate`, `current_step` (default 0), `is_valid` (default false).
- Reception also has `identification_number` (auto `N-BR-####`), `merchandise_type`, `merchandise_detail`, `buildings` (M2M), and `pellet_buildings` (via `reception_pellet_building`).
- `date_reception` is set by the UI, stored as `DateTimeImmutable`, serialized as `Y-m-d`.
- Weight entity (`src/Entity/Weight.php`) is 1N with Reception, each row stores `type` (`gross` or `tare`), `dsd`, `weight`, `weighed_at` (all nullable except `type`).
- Weigh endpoint `/receptions/weigh` returns `PontBasculeReading` with `dsd`, `weight`, `weighedAt` (formatted `Y-m-d`).
@@ -29,6 +30,7 @@ Frontend conventions
- Zod is used for form validation (e.g. `frontend/components/reception/reception-form.vue`); shared helpers live in `frontend/utils/zod-errors.ts`.
- Weighing logic is shared via `frontend/composables/useWeighing.ts`.
- Reception step UI uses store state (`currentStep`) in `frontend/pages/reception/[[id]].vue`.
- Step 2 uses `frontend/components/reception/reception-product-received.vue` for merchandise selection; type codes in `frontend/utils/constants.ts`.
- Active nav styles in header use `NuxtLink` with `custom` slot.
- Reusable UI components live under `frontend/components/ui/` and are auto-imported with `Ui` prefix (e.g. `UiLoadingDots`).
- Service layer lives in `frontend/services/` with typed DTOs in `frontend/services/dto/`.
@@ -47,6 +49,8 @@ Notes
- Keep endpoints in plural (API Platform convention).
- New reference data added:
- Reception types (`reception_type`, fields: `label`, `code`), selectable on reception form.
- Merchandise types (`merchandise_type`, fields: `label`, `code`) and pellet types (`pellet_type`, fields: `label`, `code`).
- Buildings (`building`, fields: `label`, `code`) and reception allocations (`reception_building` M2M, `reception_pellet_building` unique on reception/pellet/building).
- Suppliers (`supplier`) with addresses (`address`, fields: `label`, `street`, `postal_code`, `city`, `country_code` ISO2), via `supplier_address` join table.
- Trucks (`truck`, field: `name`), linked to receptions.
- Carriers (`carrier`, fields: `name`, nullable `code`), Drivers (`driver`, fields: `name`, `carrier_id`), Vehicles (`vehicle`, fields: `plate`, `carrier_id`, `truck_id`) used for LIOT logic.

View File

@@ -0,0 +1,232 @@
<template>
<div class="flex flex-col items-center gap-16">
<!-- @TODO voir pour séparer dans un composant au moment de l'implémentation des Bovins -->
<div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"
class="flex flex-col gap-8 items-center w-full">
<h1 class="text-4xl uppercase font-bold">Sélectionner des marchandises réceptionnnées</h1>
<div class="flex flex-col w-[550px]">
<label for="merchandise-type" class="font-bold uppercase text-xl mb-2">Type de marchandises</label>
<select
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
class="border-b border-black justify-self-start text-xl pb-[6px] bg-transparent cursor-pointer"
:class="selectedMerchandiseTypeId ? 'text-black' : 'text-neutral-400'"
>
<option value="" disabled class="text-neutral-400">Sélectionner</option>
<option
v-for="type in merchandiseTypes"
:key="type.id"
:value="String(type.id)"
class="text-black"
>
{{ type.label }}
</option>
</select>
</div>
<div
v-if="selectedMerchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[550px]"
>
<label for="merchandise-detail" class="font-bold uppercase text-xl mb-2">Préciser</label>
<input
id="merchandise-detail"
v-model="merchandiseDetail"
placeholder="Précisions complémentaires"
type="text"
maxlength="255"
class="border-b border-black text-xl pb-[6px] bg-transparent"
>
</div>
<div
v-if="selectedMerchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-evenly"
>
<div
v-for="building in buildings"
:key="building.id"
>
<label
class="flex items-center gap-2 text-xl"
>
<input
v-model="selectedBuildingIds"
type="checkbox"
:value="String(building.id)"
>
<span>{{ building.label }}</span>
</label>
</div>
</div>
<div
v-if="selectedMerchandiseTypeId && isGranule"
class="flex flex-col gap-10 w-full max-w-[900px]"
>
<div class="grid grid-cols-1 gap-10 md:grid-cols-4">
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
<p class="font-bold uppercase">{{ type.label }}</p>
<label
v-for="building in buildings"
:key="building.id"
class="flex items-center gap-2 text-lg"
>
<input
v-model="selectedPelletBuildingIds[String(type.id)]"
type="checkbox"
:value="String(building.id)"
>
<span>{{ building.label }}</span>
</label>
</div>
</div>
</div>
</div>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Peser</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getBuildingList } from '~/services/building'
import { getMerchandiseTypeList } from '~/services/merchandise-type'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
import type { BuildingData } from '~/services/dto/building-data'
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
import { getPelletTypeList } from '~/services/pellet-type'
import {
createReceptionPelletBuilding,
deleteReceptionPelletBuilding,
getReceptionPelletBuildingList
} from '~/services/reception-pellet-building'
import { useReceptionStore } from '~/stores/reception'
import { MERCHANDISE_TYPE_CODES, RECEPTION_TYPE_CODES } from '~/utils/constants'
const receptionStore = useReceptionStore()
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
const buildings = ref<BuildingData[]>([])
const pelletTypes = ref<PelletTypeData[]>([])
const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('')
const selectedMerchandiseType = computed(() =>
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value)
)
const isGranule = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE)
const isAutres = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES)
onMounted(async () => {
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
getMerchandiseTypeList(),
getBuildingList(),
getPelletTypeList()
])
merchandiseTypes.value = merchandiseTypeList
buildings.value = buildingList
pelletTypes.value = pelletTypeList
const currentId = receptionStore.current?.merchandiseType?.id
if (currentId) {
selectedMerchandiseTypeId.value = String(currentId)
}
merchandiseDetail.value = receptionStore.current?.merchandiseDetail ?? ''
selectedBuildingIds.value =
receptionStore.current?.buildings?.map((building) => String(building.id)) ?? []
const existingPelletSelections = receptionStore.current?.pelletBuildings ?? []
const selectionMap: Record<string, string[]> = {}
for (const selection of existingPelletSelections) {
const pelletTypeId = String(selection.pelletType.id)
const buildingId = String(selection.building.id)
if (!selectionMap[pelletTypeId]) {
selectionMap[pelletTypeId] = []
}
selectionMap[pelletTypeId].push(buildingId)
}
for (const pelletType of pelletTypes.value) {
const key = String(pelletType.id)
if (!selectionMap[key]) {
selectionMap[key] = []
}
}
selectedPelletBuildingIds.value = selectionMap
})
async function goNext() {
if (!receptionStore.current) {
return
}
const nextStep = receptionStore.current.currentStep + 1
const receptionIri = `/api/receptions/${receptionStore.current.id}`
await receptionStore.updateReception(receptionStore.current.id, {
merchandiseType: selectedMerchandiseTypeId.value
? `/api/merchandise_types/${selectedMerchandiseTypeId.value}`
: null,
merchandiseDetail: isAutres.value ? merchandiseDetail.value.trim() : null,
buildings: isGranule.value
? []
: selectedBuildingIds.value.map((id) => `/api/buildings/${id}`),
currentStep: nextStep
})
if (isGranule.value) {
await syncPelletSelections(receptionIri)
} else {
await clearPelletSelections(receptionIri)
}
}
async function clearPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
for (const selection of existing) {
await deleteReceptionPelletBuilding(selection.id)
}
}
async function syncPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
const existingMap = new Map<string, number>()
for (const selection of existing) {
const key = `${selection.pelletType.id}:${selection.building.id}`
existingMap.set(key, selection.id)
}
const desiredEntries: Array<{ pelletTypeId: string; buildingId: string }> = []
for (const [pelletTypeId, buildingIds] of Object.entries(selectedPelletBuildingIds.value)) {
for (const buildingId of buildingIds) {
desiredEntries.push({ pelletTypeId, buildingId })
}
}
const desiredKeys = new Set(desiredEntries.map(
(entry) => `${entry.pelletTypeId}:${entry.buildingId}`
))
for (const [key, id] of existingMap.entries()) {
if (!desiredKeys.has(key)) {
await deleteReceptionPelletBuilding(id)
}
}
for (const entry of desiredEntries) {
const key = `${entry.pelletTypeId}:${entry.buildingId}`
if (!existingMap.has(key)) {
await createReceptionPelletBuilding({
reception: receptionIri,
pelletType: `/api/pellet_types/${entry.pelletTypeId}`,
building: `/api/buildings/${entry.buildingId}`
})
}
}
}
</script>

View File

@@ -1,30 +0,0 @@
<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-4xl 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"
>Peser</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

@@ -6,21 +6,26 @@ export const usePdfPrinter = () => {
const currentReception = receptionStore.current
const printPdf = async (url: string): Promise<void> => {
if (!import.meta.client) {
return
}
const blob = await api.getBlob(url);
const pdfBlob = blob.type === 'application/pdf'
? blob
: new Blob([blob], { type: 'application/pdf' });
const blobUrl = URL.createObjectURL(pdfBlob);
const filename = `${currentReception.identificationNumber}_${currentReception.supplier.name}_${currentReception.licensePlate}.pdf`;
const blob = await api.getBlob(url)
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a');
a.href = url;
// nom du fichier à changer par les infos du store pinia
a.download = `${currentReception.identificationNumber}_${currentReception.supplier.name}_${currentReception.licensePlate}`;
a.href = blobUrl;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
a.remove();
window.open(blobUrl, '_blank', 'noopener,noreferrer')
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000)
window.open(blobUrl, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
return {

View File

@@ -17,6 +17,20 @@
"receptionType": {
"list": "Impossible de récupérer la liste des types de réception."
},
"merchandiseType": {
"list": "Impossible de récupérer la liste des types de marchandises."
},
"building": {
"list": "Impossible de récupérer la liste des bâtiments."
},
"pelletType": {
"list": "Impossible de récupérer la liste des types de granulés."
},
"receptionPelletBuilding": {
"list": "Impossible de récupérer la liste des dépôts de granulés.",
"create": "Impossible d'enregistrer le dépôt de granulés.",
"delete": "Impossible de supprimer le dépôt de granulés."
},
"supplier": {
"list": "Impossible de récupérer la liste des fournisseurs."
},

View File

@@ -16,7 +16,7 @@
</div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
<ReceptionUnloading v-if="storeReception?.currentStep === 2"/>
<ReceptionProductReceived v-if="storeReception?.currentStep === 2"/>
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
</div>
</template>

View File

@@ -0,0 +1,23 @@
import { useApi } from '~/composables/useApi'
import type { BuildingData } from '~/services/dto/building-data'
export type BuildingListResponse =
| BuildingData[]
| { 'hydra:member'?: BuildingData[] }
export async function getBuildingList(): Promise<BuildingData[]> {
const api = useApi()
const response = await api.get<BuildingListResponse>('buildings', {}, {
toastErrorKey: 'errors.building.list'
})
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}

View File

@@ -0,0 +1,5 @@
export interface BuildingData {
id: number
label: string
code: string
}

View File

@@ -0,0 +1,5 @@
export interface MerchandiseTypeData {
id: number
label: string
code: string
}

View File

@@ -0,0 +1,5 @@
export interface PelletTypeData {
id: number
label: string
code: string
}

View File

@@ -1,4 +1,7 @@
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
import type { BuildingData } from '~/services/dto/building-data'
import type { ReceptionPelletBuildingData } from '~/services/dto/reception-pellet-building-data'
import type { UserData } from '~/services/dto/user-data'
import type { SupplierData } from '~/services/dto/supplier-data'
import type { AddressData } from '~/services/dto/address-data'
@@ -15,6 +18,10 @@ export interface ReceptionData {
currentStep: number
isValid: boolean
receptionType?: ReceptionTypeData | null
merchandiseType?: MerchandiseTypeData | null
merchandiseDetail?: string | null
buildings?: BuildingData[] | null
pelletBuildings?: ReceptionPelletBuildingData[] | null
user?: UserData | null
supplier?: SupplierData | null
address?: AddressData | null
@@ -37,6 +44,9 @@ export type ReceptionPayload = {
currentStep?: number
isValid?: boolean
receptionType?: string | null
merchandiseType?: string | null
merchandiseDetail?: string | null
buildings?: string[] | null
user?: string | null
supplier?: string | null
address?: string | null

View File

@@ -0,0 +1,9 @@
import type { BuildingData } from '~/services/dto/building-data'
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
export interface ReceptionPelletBuildingData {
id: number
reception?: string
building: BuildingData
pelletType: PelletTypeData
}

View File

@@ -0,0 +1,23 @@
import { useApi } from '~/composables/useApi'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
export type MerchandiseTypeListResponse =
| MerchandiseTypeData[]
| { 'hydra:member'?: MerchandiseTypeData[] }
export async function getMerchandiseTypeList(): Promise<MerchandiseTypeData[]> {
const api = useApi()
const response = await api.get<MerchandiseTypeListResponse>('merchandise_types', {}, {
toastErrorKey: 'errors.merchandiseType.list'
})
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}

View File

@@ -0,0 +1,23 @@
import { useApi } from '~/composables/useApi'
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
export type PelletTypeListResponse =
| PelletTypeData[]
| { 'hydra:member'?: PelletTypeData[] }
export async function getPelletTypeList(): Promise<PelletTypeData[]> {
const api = useApi()
const response = await api.get<PelletTypeListResponse>('pellet_types', {}, {
toastErrorKey: 'errors.pelletType.list'
})
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}

View File

@@ -0,0 +1,51 @@
import { useApi } from '~/composables/useApi'
import type { ReceptionPelletBuildingData } from '~/services/dto/reception-pellet-building-data'
export type ReceptionPelletBuildingListResponse =
| ReceptionPelletBuildingData[]
| { 'hydra:member'?: ReceptionPelletBuildingData[] }
export type ReceptionPelletBuildingPayload = {
reception: string
pelletType: string
building: string
}
export async function getReceptionPelletBuildingList(
receptionIri: string
): Promise<ReceptionPelletBuildingData[]> {
const api = useApi()
const response = await api.get<ReceptionPelletBuildingListResponse>(
'reception_pellet_buildings',
{ reception: receptionIri },
{
toastErrorKey: 'errors.receptionPelletBuilding.list'
}
)
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}
export async function createReceptionPelletBuilding(
payload: ReceptionPelletBuildingPayload
): Promise<ReceptionPelletBuildingData> {
const api = useApi()
return api.post<ReceptionPelletBuildingData>('reception_pellet_buildings', payload, {
toastErrorKey: 'errors.receptionPelletBuilding.create'
})
}
export async function deleteReceptionPelletBuilding(id: number): Promise<void> {
const api = useApi()
await api.delete<void>(`reception_pellet_buildings/${id}`, {}, {
toastErrorKey: 'errors.receptionPelletBuilding.delete'
})
}

View File

@@ -0,0 +1,8 @@
export const RECEPTION_TYPE_CODES = {
MERCHANDISES: 'MARCHANDISES'
} as const
export const MERCHANDISE_TYPE_CODES = {
GRANULE: 'GRANULE',
AUTRES: 'AUTRES'
} as const

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260128000200 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add merchandise types and link receptions';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE merchandise_type (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(120) NOT NULL, code VARCHAR(50) NOT NULL, PRIMARY KEY(id))');
$this->addSql('ALTER TABLE reception ADD merchandise_type_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_83DC02E3BCAAA7C0 ON reception (merchandise_type_id)');
$this->addSql('ALTER TABLE reception ADD CONSTRAINT FK_83DC02E3BCAAA7C0 FOREIGN KEY (merchandise_type_id) REFERENCES merchandise_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE reception DROP CONSTRAINT FK_83DC02E3BCAAA7C0');
$this->addSql('DROP INDEX IDX_83DC02E3BCAAA7C0');
$this->addSql('ALTER TABLE reception DROP merchandise_type_id');
$this->addSql('DROP TABLE merchandise_type');
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260128000300 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add buildings, pellet types, and reception allocations';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE building (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(120) NOT NULL, code VARCHAR(50) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE TABLE pellet_type (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(120) NOT NULL, code VARCHAR(50) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE TABLE reception_building (reception_id INT NOT NULL, building_id INT NOT NULL, PRIMARY KEY(reception_id, building_id))');
$this->addSql('CREATE INDEX IDX_46E7F9F23E4A2E34 ON reception_building (reception_id)');
$this->addSql('CREATE INDEX IDX_46E7F9F24D2A7E12 ON reception_building (building_id)');
$this->addSql('ALTER TABLE reception_building ADD CONSTRAINT FK_46E7F9F23E4A2E34 FOREIGN KEY (reception_id) REFERENCES reception (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE reception_building ADD CONSTRAINT FK_46E7F9F24D2A7E12 FOREIGN KEY (building_id) REFERENCES building (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE TABLE reception_pellet_building (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, reception_id INT NOT NULL, pellet_type_id INT NOT NULL, building_id INT NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX uniq_reception_pellet_building ON reception_pellet_building (reception_id, pellet_type_id, building_id)');
$this->addSql('CREATE INDEX IDX_5DF3AA933E4A2E34 ON reception_pellet_building (reception_id)');
$this->addSql('CREATE INDEX IDX_5DF3AA93955258D ON reception_pellet_building (pellet_type_id)');
$this->addSql('CREATE INDEX IDX_5DF3AA934D2A7E12 ON reception_pellet_building (building_id)');
$this->addSql('ALTER TABLE reception_pellet_building ADD CONSTRAINT FK_5DF3AA933E4A2E34 FOREIGN KEY (reception_id) REFERENCES reception (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE reception_pellet_building ADD CONSTRAINT FK_5DF3AA93955258D FOREIGN KEY (pellet_type_id) REFERENCES pellet_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE reception_pellet_building ADD CONSTRAINT FK_5DF3AA934D2A7E12 FOREIGN KEY (building_id) REFERENCES building (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE reception_pellet_building DROP CONSTRAINT FK_5DF3AA933E4A2E34');
$this->addSql('ALTER TABLE reception_pellet_building DROP CONSTRAINT FK_5DF3AA93955258D');
$this->addSql('ALTER TABLE reception_pellet_building DROP CONSTRAINT FK_5DF3AA934D2A7E12');
$this->addSql('DROP TABLE reception_pellet_building');
$this->addSql('ALTER TABLE reception_building DROP CONSTRAINT FK_46E7F9F23E4A2E34');
$this->addSql('ALTER TABLE reception_building DROP CONSTRAINT FK_46E7F9F24D2A7E12');
$this->addSql('DROP TABLE reception_building');
$this->addSql('DROP TABLE pellet_type');
$this->addSql('DROP TABLE building');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260128000400 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add merchandise detail to reception';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE reception ADD merchandise_detail VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE reception DROP merchandise_detail');
}
}

92
src/Entity/Building.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\Table(name: 'building')]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['building:read']],
),
new GetCollection(
normalizationContext: ['groups' => ['building:read']],
),
],
security: "is_granted('ROLE_USER')",
)]
class Building
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['building:read', 'reception:read'])]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['building:read', 'reception:read'])]
private string $label = '';
#[ORM\Column(length: 50)]
#[Groups(['building:read', 'reception:read'])]
private string $code = '';
/**
* @var Collection<int, Reception>
*/
#[ORM\ManyToMany(targetEntity: Reception::class, mappedBy: 'buildings')]
private Collection $receptions;
public function __construct()
{
$this->receptions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(string $label): self
{
$this->label = $label;
return $this;
}
public function getCode(): string
{
return $this->code;
}
public function setCode(string $code): self
{
$this->code = $code;
return $this;
}
/**
* @return Collection<int, Reception>
*/
public function getReceptions(): Collection
{
return $this->receptions;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\Table(name: 'merchandise_type')]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['merchandise-type:read']],
),
new GetCollection(
normalizationContext: ['groups' => ['merchandise-type:read']],
),
],
security: "is_granted('ROLE_USER')",
)]
class MerchandiseType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['reception:read', 'merchandise-type:read'])]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['reception:read', 'merchandise-type:read'])]
private string $label = '';
#[ORM\Column(length: 50)]
#[Groups(['reception:read', 'merchandise-type:read'])]
private string $code = '';
/**
* @var Collection<int, Reception>
*/
#[ORM\OneToMany(mappedBy: 'merchandiseType', targetEntity: Reception::class)]
private Collection $receptions;
public function __construct()
{
$this->receptions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(string $label): self
{
$this->label = $label;
return $this;
}
public function getCode(): string
{
return $this->code;
}
public function setCode(string $code): self
{
$this->code = $code;
return $this;
}
/**
* @return Collection<int, Reception>
*/
public function getReceptions(): Collection
{
return $this->receptions;
}
}

71
src/Entity/PelletType.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\Table(name: 'pellet_type')]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['pellet-type:read']],
),
new GetCollection(
normalizationContext: ['groups' => ['pellet-type:read']],
),
],
security: "is_granted('ROLE_USER')",
)]
class PelletType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['pellet-type:read', 'reception:read'])]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Groups(['pellet-type:read', 'reception:read'])]
private string $label = '';
#[ORM\Column(length: 50)]
#[Groups(['pellet-type:read', 'reception:read'])]
private string $code = '';
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(string $label): self
{
$this->label = $label;
return $this;
}
public function getCode(): string
{
return $this->code;
}
public function setCode(string $code): self
{
$this->code = $code;
return $this;
}
}

View File

@@ -96,6 +96,10 @@ class Reception
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $receptionDate = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['reception:read', 'reception:write'])]
private ?string $merchandiseDetail = null;
#[ORM\OneToMany(targetEntity: Weight::class, mappedBy: 'reception', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['reception:read'])]
private Collection $weights;
@@ -106,6 +110,28 @@ class Reception
#[ApiProperty(readableLink: true)]
private ?ReceptionType $receptionType = null;
#[ORM\ManyToOne(inversedBy: 'receptions')]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['reception:read', 'reception:write'])]
#[ApiProperty(readableLink: true)]
private ?MerchandiseType $merchandiseType = null;
/**
* @var Collection<int, Building>
*/
#[ORM\ManyToMany(targetEntity: Building::class, inversedBy: 'receptions')]
#[ORM\JoinTable(name: 'reception_building')]
#[Groups(['reception:read', 'reception:write'])]
#[ApiProperty(readableLink: true)]
private Collection $buildings;
/**
* @var Collection<int, ReceptionPelletBuilding>
*/
#[ORM\OneToMany(targetEntity: ReceptionPelletBuilding::class, mappedBy: 'reception', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['reception:read'])]
private Collection $pelletBuildings;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['reception:read', 'reception:write'])]
@@ -147,6 +173,8 @@ class Reception
) {
$this->receptionDate = $receptionDate;
$this->weights = new ArrayCollection();
$this->buildings = new ArrayCollection();
$this->pelletBuildings = new ArrayCollection();
}
public function getId(): ?int
@@ -218,6 +246,18 @@ class Reception
return $this;
}
public function getMerchandiseDetail(): ?string
{
return $this->merchandiseDetail;
}
public function setMerchandiseDetail(?string $merchandiseDetail): self
{
$this->merchandiseDetail = $merchandiseDetail;
return $this;
}
/**
* @return Collection<int, Weight>
*/
@@ -238,6 +278,71 @@ class Reception
return $this;
}
public function getMerchandiseType(): ?MerchandiseType
{
return $this->merchandiseType;
}
public function setMerchandiseType(?MerchandiseType $merchandiseType): self
{
$this->merchandiseType = $merchandiseType;
return $this;
}
/**
* @return Collection<int, Building>
*/
public function getBuildings(): Collection
{
return $this->buildings;
}
public function addBuilding(Building $building): self
{
if (!$this->buildings->contains($building)) {
$this->buildings->add($building);
}
return $this;
}
public function removeBuilding(Building $building): self
{
$this->buildings->removeElement($building);
return $this;
}
/**
* @return Collection<int, ReceptionPelletBuilding>
*/
public function getPelletBuildings(): Collection
{
return $this->pelletBuildings;
}
public function addPelletBuilding(ReceptionPelletBuilding $pelletBuilding): self
{
if (!$this->pelletBuildings->contains($pelletBuilding)) {
$this->pelletBuildings->add($pelletBuilding);
$pelletBuilding->setReception($this);
}
return $this;
}
public function removePelletBuilding(ReceptionPelletBuilding $pelletBuilding): self
{
if ($this->pelletBuildings->removeElement($pelletBuilding)) {
if ($pelletBuilding->getReception() === $this) {
$pelletBuilding->setReception(null);
}
}
return $this;
}
public function getUser(): ?User
{
return $this->user;

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\Table(name: 'reception_pellet_building')]
#[ORM\UniqueConstraint(name: 'uniq_reception_pellet_building', columns: ['reception_id', 'pellet_type_id', 'building_id'])]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['reception-pellet-building:read']],
),
new GetCollection(
normalizationContext: ['groups' => ['reception-pellet-building:read']],
),
new Post(
normalizationContext: ['groups' => ['reception-pellet-building:read']],
denormalizationContext: ['groups' => ['reception-pellet-building:write']],
),
new Delete(),
],
security: "is_granted('ROLE_USER')",
)]
#[ApiFilter(SearchFilter::class, properties: ['reception' => 'exact'])]
class ReceptionPelletBuilding
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['reception-pellet-building:read', 'reception:read'])]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'pelletBuildings')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['reception-pellet-building:read', 'reception-pellet-building:write'])]
private ?Reception $reception = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['reception-pellet-building:read', 'reception-pellet-building:write', 'reception:read'])]
private ?PelletType $pelletType = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['reception-pellet-building:read', 'reception-pellet-building:write', 'reception:read'])]
private ?Building $building = null;
public function getId(): ?int
{
return $this->id;
}
public function getReception(): ?Reception
{
return $this->reception;
}
public function setReception(?Reception $reception): self
{
$this->reception = $reception;
return $this;
}
public function getPelletType(): ?PelletType
{
return $this->pelletType;
}
public function setPelletType(?PelletType $pelletType): self
{
$this->pelletType = $pelletType;
return $this;
}
public function getBuilding(): ?Building
{
return $this->building;
}
public function setBuilding(?Building $building): self
{
$this->building = $building;
return $this;
}
}

View File

@@ -16,12 +16,10 @@
p{ margin:0; }
em{ font-style: normal; }
.red{ color:red; }
.company-block{
font-size:14px;
text-align:left;
line-height:1.25;
line-height:1.3;
}
.box{
@@ -36,7 +34,7 @@
text-align:center;
font-size: 18pt;
font-weight: 700;
margin: 64px 0 15px 0;
margin: 64px 0 20px 0;
}
.info-table {
@@ -63,30 +61,60 @@
}
th{ text-align:center; font-weight:700; }
/* tables de layout (sans bordures) */
.layout, .layout td{ border:none !important; padding:0; }
/* GRAND TABLEAU : verrouillage dompdf */
.bigtable-wrap{
border: 1px solid #000;
height: 425px;
margin-bottom: 10px;
}
.bigtable{
width: 100%;
height: 100%;
border: none;
border-collapse: collapse;
table-layout: fixed;
}
.bigtable th,
.bigtable td{
font-size: 16px;
border: 1px solid #333;
}
.bigtable thead th{ border-top: 0; }
.bigtable tbody tr:last-child td{ border-bottom: 0; }
.bigtable tr th:first-child,
.bigtable tr td:first-child{ border-left: 0; }
.bigtable tr th:last-child,
.bigtable tr td:last-child{ border-right: 0; }
.bigtable thead th{ border-bottom: 0; }
.bigtable tbody tr:first-child td{ border-top: 1px solid #333; }
.bigtable-notes{
font-size: 14px;
line-height: 1.25;
}
/* ligne “filler” pour forcer la hauteur comme l'exemple */
.fill td{
border-top:none;
height: 75mm; /* ajuste si besoin */
.border-bottom {
border-bottom: 1px solid #000;
}
.footer-block{ page-break-inside: avoid; }
.signature-title{ font-size:12px; margin-bottom:2mm; }
.signature-box{ height: 22mm; margin-bottom: 4mm; }
.signature-box {
height: 22mm;
margin-bottom: 4mm;
border: 0.5px solid #000;
padding: 6px 10px;
}
.meta{ font-size: 16px; line-height: 1.35; }
</style>
</head>
@@ -104,7 +132,7 @@
14 Allée dArgenson<br>
Z.I Nord Secteur Est<br>
86100 CHATELLERAULT<br>
TEL : 05 49 20 09 10<br>
Tel. : 05 49 20 09 10<br>
Email : lpc.contacts@lpc-liot.fr<br>
RCS Châtellerault B 444 262 455
</td>
@@ -113,7 +141,7 @@
</td>
<td style="width:30%; text-align:right; vertical-align:top; font-size: 14px;">
<div style="display:inline-block; width:75mm;">
<div style="display:inline-block; width:75mm; line-height:1.3;">
<strong>{{ reception.supplier.name }}</strong><br>
<span>{{ reception.address.street }}</span><br>
<span>{{ reception.address.postalCode }} {{ reception.address.city }}</span>
@@ -140,20 +168,20 @@
</tr>
</table>
<!-- GRAND TABLEAU -->
<table class="bigtable" style="margin-bottom:10px; width:100%; border-collapse:collapse; table-layout:fixed;">
<div class="bigtable-wrap">
<table class="bigtable">
<thead>
<tr>
<th style="width:75%; text-align:center;">Désignation</th>
<th style="width:25%; text-align:right; white-space:nowrap;">Qté livrée (kg)</th>
<th style="width:25%; text-align:center; white-space:nowrap;">Qté livrée (kg)</th>
</tr>
</thead>
<tbody>
<tr>
<td style="width:75%;">
<strong class="red">MAÏS sec</strong><br><br>
<strong>{{ reception.receptionType.label }}</strong><br><br>
<div class="bigtable-notes">
{% set grossWeight = null %}
@@ -171,7 +199,7 @@
</div>
</td>
<td style="width:25%; text-align:right; white-space:nowrap;">
<td style="width:25%; text-align:center; white-space:nowrap;">
{% if grossWeight and tareWeight %}
{{ grossWeight.weight - tareWeight.weight }}
{% else %}
@@ -180,17 +208,60 @@
</td>
</tr>
<!-- filler : garde le grand bloc haut comme sur l'exemple -->
<tr class="fill">
<td style="width:75%;"></td>
<td style="width:25%;"></td>
<tr class="border-bottom">
<td>
<strong>
{% if reception.merchandiseType %}
{{ reception.merchandiseType.label }}
{% else %}
-
{% endif %}
</strong>
<br><br>
<div class="bigtable-notes">
{% if reception.merchandiseType and reception.merchandiseType.code == 'AUTRES' and reception.merchandiseDetail %}
<p><strong>Précision</strong> : {{ reception.merchandiseDetail }}</p>
{% endif %}
{% if reception.merchandiseType and reception.merchandiseType.code == 'GRANULE' %}
{% set pelletGroups = {} %}
{% for selection in reception.pelletBuildings %}
{% set pelletLabel = selection.pelletType.label %}
{% if pelletGroups[pelletLabel] is not defined %}
{% set pelletGroups = pelletGroups|merge({ (pelletLabel): [] }) %}
{% endif %}
{% set pelletGroups = pelletGroups|merge({
(pelletLabel): pelletGroups[pelletLabel]|merge([selection.building.label])
}) %}
{% endfor %}
{% for pelletLabel, buildingLabels in pelletGroups %}
<p><strong>{{ pelletLabel }}</strong> : {{ buildingLabels|join(', ') }}</p>
{% else %}
<p>Aucun dépôt de granulés renseigné.</p>
{% endfor %}
{% else %}
{% set buildingLabels = [] %}
{% for building in reception.buildings %}
{% set buildingLabels = buildingLabels|merge([building.label]) %}
{% endfor %}
{% if buildingLabels %}
<p><strong>Ferme</strong> : {{ buildingLabels|join(', ') }}</p>
{% else %}
<p>Aucun bâtiment renseigné.</p>
{% endif %}
{% endif %}
</div>
</td>
<td></td>
</tr>
</tbody>
</table>
</table>
</div>
<!-- BAS : meta à gauche / signatures à droite (sans float) -->
<table class="layout">
<!-- BAS : meta à gauche / signatures à droite -->
<table class="layout footer-block">
<tr>
<td style="width:60%; padding-right:8mm; vertical-align:top;">
<div class="meta">