Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions
16ebede557 chore: bump version to v0.0.105
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build Release Artefact / build (push) Successful in 1m34s
2026-05-21 09:34:48 +00:00
3f48568ae9 [#FER-30] Revoir l'affichage type bovin (!57)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #57
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-21 09:34:40 +00:00
gitea-actions
39f67b3c90 chore: bump version to v0.0.104
All checks were successful
Build Release Artefact / build (push) Successful in 1m37s
Auto Tag Develop / tag (push) Successful in 7s
2026-05-19 13:53:49 +00:00
eccb8e1fc6 [#FER-22] Pouvoir exporter les réceptions/expéditions fines en Excel (!56)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #56
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-19 13:49:33 +00:00
gitea-actions
de39207102 chore: bump version to v0.0.103
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build Release Artefact / build (push) Successful in 1m34s
2026-05-18 07:40:20 +00:00
5da0003c4d [#FER-25] Ajout un cron pour la synchro de l'inventaire bovin (!55)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #55
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-18 07:40:10 +00:00
23 changed files with 1196 additions and 145 deletions

View File

@@ -67,6 +67,9 @@ Ajouter dans le fichier .env du frontend
* [#FER-18] Mise à jour du tableau d'arrivage
* [#FER-26] Passeport du bovin
* [#FER-27] Fix export inventaire bovin
* [#FER-25] Ajout un cron pour la synchro de l'inventaire bovin
* [#FER-22] Pouvoir exporter les réceptions/expéditions fines en Excel
* [#FER-30] Revoir l'affichage type bovin
### Changed

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.102'
app.version: '0.0.105'

View File

@@ -5,12 +5,10 @@
@submit.prevent="goNext"
>
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
<div
class="flex flex-row gap-8 items-center w-full">
<div class="grid grid-cols-4 gap-x-8 gap-y-6">
<div
v-for="type in bovineType"
:key="type.id"
class="mt-8 flex flex-row mb-2 w-full">
:key="type.id">
<UiNumberInput
:id="type.id"
:label="type.label"
@@ -23,12 +21,11 @@
wrapper-class="gap-3"
/>
</div>
<div
class="mt-8 flex flex-row mb-2 gap-6">
<div>
<UiNumberInput
label="Autres"
v-model="otherQuantity"
class="max-w-[80px]"
class="max-w-[150px]"
wrapper-class="gap-3"
/>
</div>
@@ -79,7 +76,7 @@ const totalBovines = computed(() => {
const loadBovineType = async () => {
isLoadingBovineType.value = true
try {
bovineType.value = await getBovineTypeList()
bovineType.value = await getBovineTypeList({ display: true })
} finally {
isLoadingBovineType.value = false
}

View File

@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { getBovineTypeList } from '~/services/bovine-type'
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
@@ -45,7 +45,18 @@ const emit = defineEmits<{
(event: 'update:otherQuantity', value: number | null): void
}>()
const bovineTypes = ref<BovineTypeData[]>([])
// Types activés par l'admin (display=true), chargés depuis l'API.
const displayedTypes = ref<BovineTypeData[]>([])
// On affiche les types activés ET ceux déjà saisis sur la réception (même masqués),
// pour ne pas faire disparaître/perdre une quantité existante.
const bovineTypes = computed<BovineTypeData[]>(() => {
const seen = new Set(displayedTypes.value.map((type) => type.id))
const fromExisting = props.modelValue
.map((entry) => entry.bovineType)
.filter((type): type is BovineTypeData => Boolean(type) && !seen.has(type.id))
return [...displayedTypes.value, ...fromExisting]
})
const localQuantities = reactive<Record<string, number | null>>({})
const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0)
// Verrou pour éviter les boucles props -> local -> emit -> props.
@@ -154,8 +165,13 @@ watch(
{ deep: true }
)
// Re-synchronise dès que la liste fusionnée change (chargement async des types).
watch(bovineTypes, () => {
syncLocalFromProps()
})
onMounted(async () => {
bovineTypes.value = await getBovineTypeList()
displayedTypes.value = await getBovineTypeList({ display: true })
syncLocalFromProps()
})
</script>

View File

@@ -14,10 +14,13 @@
</h1>
</div>
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
<div class="grid grid-cols-2 items-start pt-7 mb-8 gap-x-[200px]">
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" required />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
</div>
<div class="mb-11">
<UiCheckbox v-model="form.display" label="Afficher dans les réceptions" />
</div>
<div class="flex justify-center items-center">
<UiButton
type="submit"
@@ -53,7 +56,8 @@ function resolveId(param: unknown) {
const form = reactive<BovinFormData>({
label: '',
code: ''
code: '',
display: false
})
@@ -64,6 +68,7 @@ const hydrateFromBovin = (bovin: BovineTypeData | null) => {
isHydrating.value = true
form.label = bovin.label ?? ''
form.code = bovin.code ?? ''
form.display = bovin.display ?? false
isHydrating.value = false
}
@@ -92,8 +97,8 @@ async function validate() {
const basePayload = {
label: normalizedBovinLabel,
code: normalizedBovinCode
code: normalizedBovinCode,
display: form.display
}
isLoading.value = true

View File

@@ -29,6 +29,14 @@
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
<template #cell-display="{ item }">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-sm font-medium"
:class="item.display ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'"
>
{{ item.display ? 'Oui' : 'Non' }}
</span>
</template>
</UiDataTable>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
@@ -58,7 +66,8 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
const columns = [
{ key: 'label', label: 'Nom' },
{ key: 'code', label: 'Code' }
{ key: 'code', label: 'Code' },
{ key: 'display', label: 'Affiché en réception' }
]
const goToBovin = (bovin: BovineTypeData) => {

View File

@@ -2,6 +2,15 @@
<div class="flex items-center justify-start gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des réceptions finies</h1>
<div
v-if="auth.isBureau"
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
title="Exporter en Excel"
@click="exportReceptions"
>
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
</div>
</div>
<div class="px-[86px]">
@@ -79,9 +88,35 @@ import type { ReceptionData } from '~/services/dto/reception-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { getReceptionTypeList } from '~/services/reception-type'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { useAuthStore } from '~/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const api = useApi()
const receptionTypes = ref<ReceptionTypeData[]>([])
const exporting = ref(false)
const exportReceptions = async () => {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.getBlob('receptions/export')
const filename = `receptions_${new Date().toISOString().slice(0, 10)}.xlsx`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch {
// toast déjà géré par useApi onResponseError
} finally {
exporting.value = false
}
}
const receptionTypeOptions = computed(() =>
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))

View File

@@ -2,6 +2,15 @@
<div class="flex items-center justify-start gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">liste des expéditions finies</h1>
<div
v-if="auth.isBureau"
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer hover:opacity-80"
:class="exporting ? 'cursor-not-allowed opacity-60' : ''"
title="Exporter en Excel"
@click="exportShipments"
>
<Icon name="mdi:file-excel-outline" size="32" class="text-white" />
</div>
</div>
<div class="px-[86px]">
@@ -77,9 +86,35 @@ import type { ShipmentData } from '~/services/dto/shipment-data'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { getShipmentTypeList } from '~/services/shipment-type'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { useAuthStore } from '~/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const api = useApi()
const shipmentTypes = ref<ShipmentTypeData[]>([])
const exporting = ref(false)
const exportShipments = async () => {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.getBlob('shipments/export')
const filename = `expeditions_${new Date().toISOString().slice(0, 10)}.xlsx`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch {
// toast déjà géré par useApi onResponseError
} finally {
exporting.value = false
}
}
const shipmentTypeOptions = computed(() =>
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))

View File

@@ -5,9 +5,13 @@ export type BovineTypeListResponse =
| BovineTypeData[]
| { 'hydra:member'?: BovineTypeData[] }
export async function getBovineTypeList(): Promise<BovineTypeData[]> {
export async function getBovineTypeList(filters: { display?: boolean } = {}): Promise<BovineTypeData[]> {
const api = useApi()
const response = await api.get<BovineTypeListResponse>('bovine_types', {}, {
const query: Record<string, string> = {}
if (filters.display !== undefined) {
query.display = filters.display ? 'true' : 'false'
}
const response = await api.get<BovineTypeListResponse>('bovine_types', query, {
toastErrorKey: 'errors.bovin.list'
})
@@ -51,10 +55,12 @@ export async function updateBovin(id: number, payload: BovinPayload = {}): Promi
const mapToBovineTypeData = (item: BovineTypeData): BovineTypeData => ({
id: item.id,
label: item.label,
code: item.code
code: item.code,
display: item.display ?? false
})
const toBovineTypePayload = (payload: BovinPayload): Partial<BovineTypeData> => ({
label: payload.label ?? undefined,
code: payload.code ?? undefined
code: payload.code ?? undefined,
display: payload.display ?? undefined
})

View File

@@ -2,14 +2,17 @@ export interface BovineTypeData{
id: number
label: string
code: string
display: boolean
}
export interface BovinFormData {
label: string
code: string
display: boolean
}
export type BovinPayload = {
label?: string | null
code?: string | null
display?: boolean | null
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260521092455 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajoute la colonne display sur bovine_type (défaut false) pour piloter l\'affichage dans une réception';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine_type ADD display BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bovine_type DROP display');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Reception\ReceptionExportProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/receptions/export',
openapi: new OpenApiOperation(
summary: 'Export Excel des réceptions terminées.',
description: 'Retourne un fichier XLSX listant toutes les réceptions validées (isValid = true), triées par date décroissante.',
tags: ['Receptions'],
),
security: "is_granted('ROLE_BUREAU')",
output: false,
provider: ReceptionExportProvider::class,
),
]
)]
final class ReceptionExport
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Shipment\ShipmentExportProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/shipments/export',
openapi: new OpenApiOperation(
summary: 'Export Excel des expéditions terminées.',
description: 'Retourne un fichier XLSX listant toutes les expéditions validées (isValid = true), triées par date décroissante.',
tags: ['Shipments'],
),
security: "is_granted('ROLE_BUREAU')",
output: false,
provider: ShipmentExportProvider::class,
),
]
)]
final class ShipmentExport
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\BovineInventorySyncer;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
#[AsCommand(
name: 'app:sync-bovine-inventory',
description: "Synchronise l'inventaire bovin avec EDNOTIF (équivalent du bouton Rafraîchir de l'interface)."
)]
final class SyncBovineInventoryCommand extends Command
{
public function __construct(
private readonly BovineInventorySyncer $syncer,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->syncer->sync();
} catch (Throwable $e) {
$io->error(sprintf('Échec de la synchronisation : %s', $e->getMessage()));
return Command::FAILURE;
}
$io->success(sprintf(
'Inventaire synchronisé · Créés : %d · Mis à jour : %d · Sortis : %d · Total EDNOTIF : %d',
$result->created,
$result->updated,
$result->exited,
$result->total,
));
return Command::SUCCESS;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
@@ -19,6 +20,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
'label' => 'ipartial',
'code' => 'ipartial',
])]
#[ApiFilter(BooleanFilter::class, properties: ['display'])]
#[ApiResource(
operations: [
new Get(
@@ -58,6 +60,15 @@ class BovineType
#[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read', 'bovine:read', 'building_case:read'])]
private ?string $code = null;
/**
* Détermine si le type bovin est proposé à la sélection lors d'une réception.
* Les types créés automatiquement par la synchro inventaire arrivent à false ;
* seul un admin peut les activer.
*/
#[ORM\Column(options: ['default' => false])]
#[Groups(['bovine-type:read', 'bovine-type:write'])]
private bool $display = false;
public function getId(): ?int
{
return $this->id;
@@ -86,4 +97,16 @@ class BovineType
return $this;
}
public function isDisplay(): bool
{
return $this->display;
}
public function setDisplay(bool $display): static
{
$this->display = $display;
return $this;
}
}

View File

@@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading;
use App\Repository\ReceptionRepository;
use App\State\Reception\ReceptionReceiptProvider;
use App\State\Reception\ReceptionWeighingProvider;
use DateTimeImmutable;
@@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\Entity(repositoryClass: ReceptionRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]

View File

@@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading;
use App\Repository\ShipmentRepository;
use App\State\Shipment\ShipmentReceiptProvider;
use App\State\Shipment\ShipmentWeighingProvider;
use DateTimeImmutable;
@@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\Entity(repositoryClass: ShipmentRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'shipment')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Reception;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Reception>
*/
final class ReceptionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Reception::class);
}
/**
* Liste des réceptions validées pour l'export Excel (de la plus récente à la plus ancienne).
*
* @return list<Reception>
*/
public function findValidatedForExport(): array
{
return $this->createQueryBuilder('r')
->leftJoin('r.supplier', 'supplier')->addSelect('supplier')
->leftJoin('supplier.addresses', 'supplierAddresses')->addSelect('supplierAddresses')
->leftJoin('r.address', 'address')->addSelect('address')
->leftJoin('r.carrier', 'carrier')->addSelect('carrier')
->leftJoin('r.driver', 'driver')->addSelect('driver')
->leftJoin('r.truck', 'truck')->addSelect('truck')
->leftJoin('r.user', 'user')->addSelect('user')
->leftJoin('r.receptionType', 'receptionType')->addSelect('receptionType')
->leftJoin('r.merchandiseType', 'merchandiseType')->addSelect('merchandiseType')
->leftJoin('r.weights', 'weights')->addSelect('weights')
->leftJoin('r.bovines_types', 'bovinesTypes')->addSelect('bovinesTypes')
->leftJoin('bovinesTypes.bovineType', 'bovineType')->addSelect('bovineType')
->leftJoin('r.pelletBuildings', 'pelletBuildings')->addSelect('pelletBuildings')
->leftJoin('pelletBuildings.pelletType', 'pelletType')->addSelect('pelletType')
->leftJoin('pelletBuildings.building', 'pelletBuilding')->addSelect('pelletBuilding')
->where('r.isValid = :valid')
->setParameter('valid', true)
->orderBy('r.receptionDate', 'DESC')
->addOrderBy('r.id', 'DESC')
->getQuery()
->getResult()
;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Shipment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Shipment>
*/
final class ShipmentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Shipment::class);
}
/**
* Liste des expéditions validées pour l'export Excel (de la plus récente à la plus ancienne).
*
* @return list<Shipment>
*/
public function findValidatedForExport(): array
{
return $this->createQueryBuilder('s')
->leftJoin('s.customer', 'customer')->addSelect('customer')
->leftJoin('customer.addresses', 'customerAddresses')->addSelect('customerAddresses')
->leftJoin('s.address', 'address')->addSelect('address')
->leftJoin('s.carrier', 'carrier')->addSelect('carrier')
->leftJoin('s.driver', 'driver')->addSelect('driver')
->leftJoin('s.truck', 'truck')->addSelect('truck')
->leftJoin('s.user', 'user')->addSelect('user')
->leftJoin('s.shipmentType', 'shipmentType')->addSelect('shipmentType')
->leftJoin('s.weights', 'weights')->addSelect('weights')
->where('s.isValid = :valid')
->setParameter('valid', true)
->orderBy('s.shipmentDate', 'DESC')
->addOrderBy('s.id', 'DESC')
->getQuery()
->getResult()
;
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use App\Entity\BovineType;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
final class BovineInventorySyncer
{
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct(
private readonly BovinApiInterface $bovinApi,
private readonly EntityManagerInterface $em,
) {}
public function sync(): BovineSyncInventoryResult
{
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
$result = new BovineSyncInventoryResult();
$result->total = count($inventory->animals);
$this->bovineTypeCache = [];
foreach ($this->em->getRepository(BovineType::class)->findAll() as $bovineType) {
if (null !== $bovineType->getCode()) {
$this->bovineTypeCache[$bovineType->getCode()] = $bovineType;
}
}
$existingByNationalNumber = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
}
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
continue;
}
$seen[$nationalNumber] = true;
if (isset($existingByNationalNumber[$nationalNumber])) {
$bovine = $existingByNationalNumber[$nationalNumber];
++$result->updated;
} else {
$bovine = new Bovine();
$bovine->setNationalNumber($nationalNumber);
$this->em->persist($bovine);
++$result->created;
}
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
if (isset($seen[$nationalNumber])) {
continue;
}
if (null !== $bovine->getExitedAt()) {
continue;
}
$bovine->setExitedAt($now);
++$result->exited;
}
$this->em->flush();
return $result;
}
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
{
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
}
$latestEntry = null;
$latestExit = null;
foreach ($animal->presencePeriods as $period) {
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
$latestEntry = $period->entry->date;
}
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
$latestExit = $period->exit->date;
}
}
$bovine->setArrivalDate($latestEntry);
$bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths();
}
/**
* Trouve un BovineType existant par code, sinon en crée un placeholder
* que l'admin pourra renommer dans /admin/bovin/bovin-list.
*/
private function resolveBovineType(?string $code): ?BovineType
{
if (null === $code || '' === $code) {
return null;
}
if (isset($this->bovineTypeCache[$code])) {
return $this->bovineTypeCache[$code];
}
$bovineType = new BovineType();
$bovineType->setCode($code);
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
$this->em->persist($bovineType);
$this->bovineTypeCache[$code] = $bovineType;
return $bovineType;
}
}

View File

@@ -7,26 +7,15 @@ namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use App\Entity\BovineType;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
use App\Service\BovineInventorySyncer;
/**
* @implements ProcessorInterface<mixed, BovineSyncInventoryResult>
*/
final class BovineSyncInventoryProcessor implements ProcessorInterface
final readonly class BovineSyncInventoryProcessor implements ProcessorInterface
{
/**
* @var array<string, BovineType>
*/
private array $bovineTypeCache = [];
public function __construct(
private BovinApiInterface $bovinApi,
private EntityManagerInterface $em,
private BovineInventorySyncer $syncer,
) {}
public function process(
@@ -35,113 +24,6 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
array $uriVariables = [],
array $context = [],
): BovineSyncInventoryResult {
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
$result = new BovineSyncInventoryResult();
$result->total = count($inventory->animals);
$this->bovineTypeCache = [];
foreach ($this->em->getRepository(BovineType::class)->findAll() as $bovineType) {
if (null !== $bovineType->getCode()) {
$this->bovineTypeCache[$bovineType->getCode()] = $bovineType;
}
}
$existingByNationalNumber = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
}
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
continue;
}
$seen[$nationalNumber] = true;
if (isset($existingByNationalNumber[$nationalNumber])) {
$bovine = $existingByNationalNumber[$nationalNumber];
++$result->updated;
} else {
$bovine = new Bovine();
$bovine->setNationalNumber($nationalNumber);
$this->em->persist($bovine);
++$result->created;
}
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
if (isset($seen[$nationalNumber])) {
continue;
}
if (null !== $bovine->getExitedAt()) {
continue;
}
$bovine->setExitedAt($now);
++$result->exited;
}
$this->em->flush();
return $result;
}
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
{
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBovineType($this->resolveBovineType($identification->breedType));
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setMotherNationalNumber($identification->motherCarrier?->bovin?->nationalNumber);
$bovine->setMotherBovineType($this->resolveBovineType($identification->motherCarrier?->breedType));
$bovine->setFatherNationalNumber($identification->fatherIpg?->bovin?->nationalNumber);
$bovine->setFatherBovineType($this->resolveBovineType($identification->fatherIpg?->breedType));
}
$latestEntry = null;
$latestExit = null;
foreach ($animal->presencePeriods as $period) {
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
$latestEntry = $period->entry->date;
}
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
$latestExit = $period->exit->date;
}
}
$bovine->setArrivalDate($latestEntry);
$bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths();
}
/**
* Trouve un BovineType existant par code, sinon en crée un placeholder
* que l'admin pourra renommer dans /admin/bovin/bovin-list.
*/
private function resolveBovineType(?string $code): ?BovineType
{
if (null === $code || '' === $code) {
return null;
}
if (isset($this->bovineTypeCache[$code])) {
return $this->bovineTypeCache[$code];
}
$bovineType = new BovineType();
$bovineType->setCode($code);
$bovineType->setLabel(sprintf('À renommer (%s)', $code));
$this->em->persist($bovineType);
$this->bovineTypeCache[$code] = $bovineType;
return $bovineType;
return $this->syncer->sync();
}
}

View File

@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace App\State\Reception;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Address;
use App\Entity\Reception;
use App\Entity\Weight;
use App\Repository\ReceptionRepository;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* @implements ProviderInterface<Response>
*/
final readonly class ReceptionExportProvider implements ProviderInterface
{
private const FARM_NAME = 'FERME SCEA LES NAUDS';
private const HEADER_FILL = 'FFCCECFF';
/**
* Largeurs de colonnes (A à V).
*/
private const COLUMN_WIDTHS = [
'A' => 12.0,
'B' => 11.0,
'C' => 7.0,
'D' => 14.0,
'E' => 12.0,
'F' => 22.0,
'G' => 30.0,
'H' => 30.0,
'I' => 18.0,
'J' => 8.0,
'K' => 18.0,
'L' => 14.0,
'M' => 12.0,
'N' => 16.0,
'O' => 22.0,
'P' => 11.0,
'Q' => 11.0,
'R' => 11.0,
'S' => 22.0,
'T' => 26.0,
'U' => 9.0,
'V' => 26.0,
];
public function __construct(
private ReceptionRepository $receptionRepository,
private LoggerInterface $logger,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$receptions = $this->receptionRepository->findValidatedForExport();
$spreadsheet = $this->buildSpreadsheet($receptions);
$body = $this->renderXlsx($spreadsheet);
$filename = sprintf('receptions_%s.xlsx', new DateTimeImmutable()->format('Y-m-d'));
$response = new Response($body);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
$response->headers->set('Content-Length', (string) strlen($body));
return $response;
}
/**
* @param list<Reception> $receptions
*/
private function buildSpreadsheet(array $receptions): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11);
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Receptions');
$pageSetup = $sheet->getPageSetup();
$pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4);
$pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
$pageSetup->setFitToWidth(1);
$pageSetup->setFitToHeight(0);
$pageSetup->setRowsToRepeatAtTopByStartAndEnd(1, 2);
$sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3);
// Ligne 1 : titre + date
$sheet->setCellValue('A1', sprintf('%s — RÉCEPTIONS TERMINÉES', self::FARM_NAME));
$sheet->mergeCells('A1:U1');
$sheet->getStyle('A1:U1')->applyFromArray([
'font' => [
'name' => 'Arial Black',
'size' => 16,
'bold' => true,
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
$sheet->setCellValue('V1', ExcelDate::PHPToExcel(new DateTimeImmutable()));
$sheet->getStyle('V1')->getNumberFormat()->setFormatCode('dd/mm/yyyy');
$sheet->getStyle('V1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT)->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle('V1')->getFont()->setSize(12)->setBold(true);
$sheet->getRowDimension(1)->setRowHeight(26.0);
$sheet->getStyle('A1:V1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK);
// Ligne 2 : en-têtes
$headers = [
'A' => 'N° identification',
'B' => 'Date',
'C' => 'Heure',
'D' => 'Type réception',
'E' => 'Utilisateur',
'F' => 'Fournisseur',
'G' => 'Adresse fournisseur',
'H' => 'Adresse réception',
'I' => 'Transporteur',
'J' => 'Code trans.',
'K' => 'Chauffeur',
'L' => 'Camion',
'M' => 'Plaque',
'N' => 'Type marchandise',
'O' => 'Détail marchandise',
'P' => 'Brut (kg)',
'Q' => 'Tare (kg)',
'R' => 'Net (kg)',
'S' => 'Détail bovins',
'T' => 'Bovins par type',
'U' => 'Total bovins',
'V' => 'Granulés / bâtiments',
];
foreach ($headers as $col => $value) {
$sheet->setCellValue($col.'2', $value);
}
$sheet->getRowDimension(2)->setRowHeight(32.0);
$sheet->getStyle('A2:V2')->applyFromArray([
'font' => ['bold' => true],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
'wrapText' => true,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['argb' => self::HEADER_FILL],
],
]);
foreach (self::COLUMN_WIDTHS as $col => $width) {
$sheet->getColumnDimension($col)->setWidth($width);
}
// Données
$row = 3;
foreach ($receptions as $reception) {
try {
$this->writeReceptionRow($sheet, $row, $reception);
} catch (Throwable $e) {
$this->logger->warning('Export réceptions : ligne ignorée suite à une erreur.', [
'receptionId' => $reception->getId(),
'identificationNumber' => $reception->getIdentificationNumber(),
'row' => $row,
'exception' => $e,
]);
}
++$row;
}
$lastRow = $row - 1;
if ($lastRow >= 2) {
$sheet->getStyle('A2:V'.$lastRow)->getBorders()->applyFromArray([
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
'outline' => ['borderStyle' => Border::BORDER_MEDIUM],
]);
}
$sheet->freezePane('A3');
return $spreadsheet;
}
private function writeReceptionRow(Worksheet $sheet, int $row, Reception $reception): void
{
$sheet->setCellValue('A'.$row, $reception->getIdentificationNumber() ?? '');
$date = $reception->getReceptionDate();
if (null !== $date) {
$excelDate = $this->safePhpToExcel($date);
if (null !== $excelDate) {
$sheet->setCellValue('B'.$row, $excelDate);
$sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('dd/mm/yyyy');
}
$sheet->setCellValue('C'.$row, $date->format('H:i'));
}
$sheet->setCellValue('D'.$row, $reception->getReceptionType()?->getLabel() ?? '');
$sheet->setCellValue('E'.$row, $reception->getUser()?->getUsername() ?? '');
$supplier = $reception->getSupplier();
$sheet->setCellValue('F'.$row, $supplier?->getName() ?? '');
$sheet->setCellValue('G'.$row, $this->formatAddresses($supplier?->getAddresses()));
$sheet->setCellValue('H'.$row, $reception->getAddress()?->getFullAddress() ?? '');
$carrier = $reception->getCarrier();
$sheet->setCellValue('I'.$row, $carrier?->getName() ?? '');
$sheet->setCellValue('J'.$row, $carrier?->getCode() ?? '');
$sheet->setCellValue('K'.$row, $reception->getDriver()?->getName() ?? '');
$sheet->setCellValue('L'.$row, $reception->getTruck()?->getName() ?? '');
$sheet->setCellValue('M'.$row, $reception->getLicensePlate() ?? '');
$sheet->setCellValue('N'.$row, $reception->getMerchandiseType()?->getLabel() ?? '');
$sheet->setCellValue('O'.$row, $reception->getMerchandiseDetail() ?? '');
$gross = $this->extractWeight($reception->getWeights(), 'gross');
$tare = $this->extractWeight($reception->getWeights(), 'tare');
if (null !== $gross) {
$sheet->setCellValue('P'.$row, $gross);
}
if (null !== $tare) {
$sheet->setCellValue('Q'.$row, $tare);
}
if (null !== $gross && null !== $tare) {
$sheet->setCellValue('R'.$row, $gross - $tare);
}
$sheet->getStyle('P'.$row.':R'.$row)->getNumberFormat()->setFormatCode('#,##0');
$sheet->setCellValue('S'.$row, $reception->getBovineDetail() ?? '');
[$bovinesText, $bovinesTotal] = $this->formatBovineTypes($reception);
$sheet->setCellValue('T'.$row, $bovinesText);
if (null !== $bovinesTotal) {
$sheet->setCellValue('U'.$row, $bovinesTotal);
}
$sheet->setCellValue('V'.$row, $this->formatPelletBuildings($reception));
// Alignements
$sheet->getStyle('A'.$row.':C'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('J'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('P'.$row.':R'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle('U'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('G'.$row.':H'.$row)->getAlignment()->setWrapText(true);
$sheet->getStyle('O'.$row)->getAlignment()->setWrapText(true);
$sheet->getStyle('S'.$row.':V'.$row)->getAlignment()->setWrapText(true);
}
/**
* @return array{0: string, 1: ?int} [texte concaténé, total]
*/
private function formatBovineTypes(Reception $reception): array
{
$parts = [];
$total = 0;
$found = false;
foreach ($reception->getBovinesTypes() as $rb) {
$label = $rb->getBovineType()?->getLabel();
$qty = $rb->getQuantity();
if (null === $label && null === $qty) {
continue;
}
$parts[] = sprintf('%s : %d', $label ?? '—', $qty ?? 0);
$total += $qty ?? 0;
$found = true;
}
return [implode(', ', $parts), $found ? $total : null];
}
private function formatPelletBuildings(Reception $reception): string
{
$parts = [];
foreach ($reception->getPelletBuildings() as $pb) {
$pellet = $pb->getPelletType()?->getLabel();
$building = $pb->getBuilding()?->getLabel() ?? $pb->getBuilding()?->getCode();
if (null === $pellet && null === $building) {
continue;
}
$parts[] = sprintf('%s (%s)', $pellet ?? '—', $building ?? '—');
}
return implode(', ', $parts);
}
/**
* @param null|iterable<Address> $addresses
*/
private function formatAddresses(?iterable $addresses): string
{
if (null === $addresses) {
return '';
}
$parts = [];
foreach ($addresses as $address) {
$full = $address->getFullAddress();
if ('' !== $full) {
$parts[] = $full;
}
}
return implode(' ; ', $parts);
}
/**
* @param iterable<Weight> $weights
*/
private function extractWeight(iterable $weights, string $type): ?int
{
foreach ($weights as $weight) {
if ($weight->getType() === $type) {
return $weight->getWeight();
}
}
return null;
}
private function safePhpToExcel(?DateTimeImmutable $date): ?float
{
if (null === $date) {
return null;
}
try {
$value = ExcelDate::PHPToExcel($date);
} catch (Throwable) {
return null;
}
return is_float($value) ? $value : null;
}
private function renderXlsx(Spreadsheet $spreadsheet): string
{
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
ob_start();
$writer->save('php://output');
$body = ob_get_clean();
return false !== $body ? $body : '';
}
}

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\State\Shipment;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Address;
use App\Entity\Shipment;
use App\Entity\Weight;
use App\Repository\ShipmentRepository;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* @implements ProviderInterface<Response>
*/
final readonly class ShipmentExportProvider implements ProviderInterface
{
private const FARM_NAME = 'FERME SCEA LES NAUDS';
private const HEADER_FILL = 'FFCCECFF';
/**
* Largeurs de colonnes (A à Q).
*/
private const COLUMN_WIDTHS = [
'A' => 12.0,
'B' => 11.0,
'C' => 7.0,
'D' => 16.0,
'E' => 12.0,
'F' => 22.0,
'G' => 30.0,
'H' => 30.0,
'I' => 18.0,
'J' => 8.0,
'K' => 18.0,
'L' => 14.0,
'M' => 12.0,
'N' => 11.0,
'O' => 11.0,
'P' => 11.0,
'Q' => 13.0,
];
public function __construct(
private ShipmentRepository $shipmentRepository,
private LoggerInterface $logger,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$shipments = $this->shipmentRepository->findValidatedForExport();
$spreadsheet = $this->buildSpreadsheet($shipments);
$body = $this->renderXlsx($spreadsheet);
$filename = sprintf('expeditions_%s.xlsx', new DateTimeImmutable()->format('Y-m-d'));
$response = new Response($body);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
$response->headers->set('Content-Length', (string) strlen($body));
return $response;
}
/**
* @param list<Shipment> $shipments
*/
private function buildSpreadsheet(array $shipments): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getDefaultStyle()->getFont()->setName('Aptos Narrow')->setSize(11);
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Expeditions');
$pageSetup = $sheet->getPageSetup();
$pageSetup->setPaperSize(PageSetup::PAPERSIZE_A4);
$pageSetup->setOrientation(PageSetup::ORIENTATION_LANDSCAPE);
$pageSetup->setFitToWidth(1);
$pageSetup->setFitToHeight(0);
$pageSetup->setRowsToRepeatAtTopByStartAndEnd(1, 2);
$sheet->getPageMargins()->setTop(0.4)->setBottom(0.4)->setLeft(0.3)->setRight(0.3);
// Ligne 1 : titre + date
$sheet->setCellValue('A1', sprintf('%s — EXPÉDITIONS TERMINÉES', self::FARM_NAME));
$sheet->mergeCells('A1:P1');
$sheet->getStyle('A1:P1')->applyFromArray([
'font' => [
'name' => 'Arial Black',
'size' => 16,
'bold' => true,
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
$sheet->setCellValue('Q1', ExcelDate::PHPToExcel(new DateTimeImmutable()));
$sheet->getStyle('Q1')->getNumberFormat()->setFormatCode('dd/mm/yyyy');
$sheet->getStyle('Q1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT)->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle('Q1')->getFont()->setSize(12)->setBold(true);
$sheet->getRowDimension(1)->setRowHeight(26.0);
$sheet->getStyle('A1:Q1')->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THICK);
// Ligne 2 : en-têtes
$headers = [
'A' => 'N° identification',
'B' => 'Date',
'C' => 'Heure',
'D' => "Type d'expédition",
'E' => 'Utilisateur',
'F' => 'Client',
'G' => 'Adresse client',
'H' => 'Adresse expédition',
'I' => 'Transporteur',
'J' => 'Code trans.',
'K' => 'Chauffeur',
'L' => 'Camion',
'M' => 'Plaque',
'N' => 'Brut (kg)',
'O' => 'Tare (kg)',
'P' => 'Net (kg)',
'Q' => 'Nb bovins',
];
foreach ($headers as $col => $value) {
$sheet->setCellValue($col.'2', $value);
}
$sheet->getRowDimension(2)->setRowHeight(32.0);
$sheet->getStyle('A2:Q2')->applyFromArray([
'font' => ['bold' => true],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
'wrapText' => true,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['argb' => self::HEADER_FILL],
],
]);
foreach (self::COLUMN_WIDTHS as $col => $width) {
$sheet->getColumnDimension($col)->setWidth($width);
}
// Données
$row = 3;
foreach ($shipments as $shipment) {
try {
$this->writeShipmentRow($sheet, $row, $shipment);
} catch (Throwable $e) {
$this->logger->warning('Export expéditions : ligne ignorée suite à une erreur.', [
'shipmentId' => $shipment->getId(),
'identificationNumber' => $shipment->getIdentificationNumber(),
'row' => $row,
'exception' => $e,
]);
}
++$row;
}
$lastRow = $row - 1;
if ($lastRow >= 2) {
$sheet->getStyle('A2:Q'.$lastRow)->getBorders()->applyFromArray([
'allBorders' => ['borderStyle' => Border::BORDER_THIN],
'outline' => ['borderStyle' => Border::BORDER_MEDIUM],
]);
}
$sheet->freezePane('A3');
return $spreadsheet;
}
private function writeShipmentRow(Worksheet $sheet, int $row, Shipment $shipment): void
{
$sheet->setCellValue('A'.$row, $shipment->getIdentificationNumber() ?? '');
$date = $shipment->getShipmentDate();
if (null !== $date) {
$excelDate = $this->safePhpToExcel($date);
if (null !== $excelDate) {
$sheet->setCellValue('B'.$row, $excelDate);
$sheet->getStyle('B'.$row)->getNumberFormat()->setFormatCode('dd/mm/yyyy');
}
$sheet->setCellValue('C'.$row, $date->format('H:i'));
}
$sheet->setCellValue('D'.$row, $shipment->getShipmentType()?->getLabel() ?? '');
$sheet->setCellValue('E'.$row, $shipment->getUser()?->getUsername() ?? '');
$customer = $shipment->getCustomer();
$sheet->setCellValue('F'.$row, $customer?->getName() ?? '');
$sheet->setCellValue('G'.$row, $this->formatAddresses($customer?->getAddresses()));
$sheet->setCellValue('H'.$row, $shipment->getAddress()?->getFullAddress() ?? '');
$carrier = $shipment->getCarrier();
$sheet->setCellValue('I'.$row, $carrier?->getName() ?? '');
$sheet->setCellValue('J'.$row, $carrier?->getCode() ?? '');
$sheet->setCellValue('K'.$row, $shipment->getDriver()?->getName() ?? '');
$sheet->setCellValue('L'.$row, $shipment->getTruck()?->getName() ?? '');
$sheet->setCellValue('M'.$row, $shipment->getLicensePlate() ?? '');
$gross = $this->extractWeight($shipment->getWeights(), 'gross');
$tare = $this->extractWeight($shipment->getWeights(), 'tare');
if (null !== $gross) {
$sheet->setCellValue('N'.$row, $gross);
}
if (null !== $tare) {
$sheet->setCellValue('O'.$row, $tare);
}
if (null !== $gross && null !== $tare) {
$sheet->setCellValue('P'.$row, $gross - $tare);
}
$sheet->getStyle('N'.$row.':P'.$row)->getNumberFormat()->setFormatCode('#,##0');
$sheet->setCellValue('Q'.$row, $shipment->getNbBovinSend());
// Alignements
$sheet->getStyle('A'.$row.':C'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('J'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('N'.$row.':P'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle('Q'.$row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('G'.$row.':H'.$row)->getAlignment()->setWrapText(true);
}
/**
* @param null|iterable<Address> $addresses
*/
private function formatAddresses(?iterable $addresses): string
{
if (null === $addresses) {
return '';
}
$parts = [];
foreach ($addresses as $address) {
$full = $address->getFullAddress();
if ('' !== $full) {
$parts[] = $full;
}
}
return implode(' ; ', $parts);
}
/**
* @param iterable<Weight> $weights
*/
private function extractWeight(iterable $weights, string $type): ?int
{
foreach ($weights as $weight) {
if ($weight->getType() === $type) {
return $weight->getWeight();
}
}
return null;
}
private function safePhpToExcel(?DateTimeImmutable $date): ?float
{
if (null === $date) {
return null;
}
try {
$value = ExcelDate::PHPToExcel($date);
} catch (Throwable) {
return null;
}
return is_float($value) ? $value : null;
}
private function renderXlsx(Spreadsheet $spreadsheet): string
{
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
ob_start();
$writer->save('php://output');
$body = ob_get_clean();
return false !== $body ? $body : '';
}
}