<?php
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiSubresource;
use App\DBAL\Types\DeliveryStatusType;
use App\DBAL\Types\RouteStatusType;
use App\Handler\ResponseCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\OrderFilter;
use App\Traits\TestRoute;
use Ramsey\Uuid\Uuid;
/**
* @ApiResource(
* normalizationContext={"groups"={"layout:read"}, "enable_max_depth"=true},
* attributes={"order"={"startDate", "routeDays.dayIndex"}}
* )
* @ApiFilter(SearchFilter::class, properties={"status"})
* @ApiFilter(OrderFilter::class, properties={"startDate", "title", "status"})
*/
#[ORM\HasLifecycleCallbacks]
#[ORM\Entity(repositoryClass: 'App\Repository\RouteRepository')]
class Route
{
#[Groups(groups: ['layout:read', 'route:list'])]
#[ORM\Id]
#[ORM\Column(type: 'guid', unique: true)]
private $id;
#[Groups(groups: ['layout:read', 'route:list'])]
#[ORM\ManyToOne(targetEntity: 'App\Entity\Vehicle', inversedBy: 'routes')]
private $vehicle;
#[Groups(groups: ['layout:read'])]
#[ORM\Column(type: 'integer')]
private $duration = 0;
#[Groups(groups: ['layout:read', 'route:list'])]
#[ORM\Column(type: 'date', nullable: true)]
private $startDate;
#[Groups(groups: ['layout:read'])]
#[ORM\Column(type: 'json')]
private $polygon = [];
#[ORM\OneToMany(targetEntity: 'App\Entity\RouteElement', mappedBy: 'route', cascade: ['persist'])]
private $routeElements;
#[Groups(groups: ['layout:read'])]
#[ORM\ManyToOne(targetEntity: 'App\Entity\Location')]
#[ORM\JoinColumn(nullable: false)]
private $sourceLocation;
#[Groups(groups: ['layout:read'])]
#[ORM\ManyToOne(targetEntity: 'App\Entity\Location')]
#[ORM\JoinColumn(nullable: false)]
private $destinationLocation;
#[Groups(groups: ['layout:read'])]
#[ORM\Column(type: 'string', nullable: true)]
private $destinationLocationId;
#[Groups(groups: ['layout:read'])]
#[ORM\Column(type: 'string', nullable: true)]
private $sourceLocationId;
#[Groups(groups: ['layout:read', 'route:list'])]
#[MaxDepth(2)]
#[ORM\OneToMany(targetEntity: 'App\Entity\RouteDay', mappedBy: 'route', cascade: ['persist'])]
#[ORM\OrderBy(value: ['dayIndex' => 'ASC'])]
private $routeDays;
#[Groups(groups: ['layout:read', 'route:list'])]
#[ORM\Column(type: 'string', length: 64)]
private $title;
#[Groups(groups: ['layout:read', 'route:list'])]
#[ORM\Column(type: 'RouteStatusType')]
private $status = 'Open';
#[ORM\OneToMany(targetEntity: 'App\Entity\Protocol', mappedBy: 'route', orphanRemoval: true)]
private $protocols;
private $isDeleted = false;
private $batchProcessing = false;
#[ORM\PostPersist]
public function createDays(LifecycleEventArgs $args)
{
/*
$em = $args->getEntityManager();
if($this->routeDays->count() == 0) {
$startDate = $this->startDate->format('U');
$endDate = $this->endDate->format('U');
$currentDate = $startDate;
$dayIndex = 1;
do {
$newDay = new RouteDay();
$newDay->setRoute($this);
if($dayIndex == 1) {
$newDay->setStartLocation($this->getSourceLocation());
}
$newDay->setDayIndex($dayIndex++);
$newDay->setDate(new \DateTime(date('Y-m-d', $currentDate)));
$em->persist($newDay);
$currentDate += 86400;
// $newDay
} while($currentDate <= $endDate);
$em->flush();
}
*/
}
public function refresh()
{
// Do not refresh during batch processing
if ($this->isBatchProcessing()) return;
foreach ($this->getRouteDays() as $day) {
$elements = $day->getRouteElements();
foreach ($elements as $element) {
$element->refreshTravelTime();
}
$day->refreshStartTimes();
$day->refreshFinalRoute();
$day->refreshTotals();
}
$this->updateWeights();
}
#[ORM\PreUpdate]
public function preUpdate(PreUpdateEventArgs $args)
{
$changeSet = $args->getEntityChangeSet();
$em = $args->getEntityManager();
if (
isset($changeSet['startDate'])
) {
$routeDays = $this->getRouteDays();
$currentDate = clone $this->getStartDate();
$dailyInterval = new \DateInterval('P1D');
if ($currentDate !== null) {
foreach ($routeDays as $routeDay) {
$routeDay->setDate(new \DateTime($currentDate->format('Y-m-d')));
$currentDate = $currentDate->add($dailyInterval);
$em->persist($routeDay);
}
}
}
}
#[ORM\PrePersist]
public function processPolygon()
{
if (is_string($this->polygon)) {
$this->polygon = json_decode($this->polygon, true);
}
if (empty($this->title)) {
$this->title = 'Route ' . date('Y-m-d');
}
}
public function __construct()
{
$this->routeElements = new ArrayCollection();
$this->routeDays = new ArrayCollection();
$this->protocols = new ArrayCollection();
$this->id = Uuid::uuid4();
}
public function getId(): ?string
{
return $this->id;
}
public function getVehicle(): ?Vehicle
{
return $this->vehicle;
}
public function setVehicle(?Vehicle $vehicle): self
{
$this->vehicle = $vehicle;
return $this;
}
public function getDuration(): ?int
{
return $this->duration;
}
public function setDuration(int $duration): self
{
$this->duration = $duration;
return $this;
}
public function getStartDate(): ?\DateTimeInterface
{
return $this->startDate;
}
public function setStartDate(?\DateTimeInterface $startDate): self
{
$this->startDate = $startDate;
return $this;
}
public function getEndDate(): ?\DateTimeInterface
{
return $this->getRouteDays()->last()->getDate();
}
public function getPolygon(): ?array
{
return $this->polygon;
}
public function setPolygon(?array $polygon): self
{
$this->polygon = $polygon;
return $this;
}
/**
* @return Collection|RouteElement[]
*/
public function getRouteElements(): Collection
{
return $this->routeElements;
}
public function addRouteElement(RouteElement $routeElement): self
{
if (!$this->routeElements->contains($routeElement)) {
$this->routeElements[] = $routeElement;
$routeElement->setRoute($this);
}
return $this;
}
public function removeRouteElement(RouteElement $routeElement): self
{
if ($this->routeElements->contains($routeElement)) {
$this->routeElements->removeElement($routeElement);
// set the owning side to null (unless already changed)
if ($routeElement->getRoute() === $this) {
$routeElement->setRoute(null);
}
}
return $this;
}
#[ORM\PreRemove]
public function preRemove()
{
$this->isDeleted = true;
}
public function setIsDeleted($value)
{
$this->isDeleted = ($value == true);
}
public function isDeleted()
{
return $this->isDeleted;
}
public function getSourceLocation(): ?Location
{
return $this->sourceLocation;
}
public function setSourceLocation(?Location $sourceLocation): self
{
$this->sourceLocation = $sourceLocation;
return $this;
}
public function getDestinationLocation(): ?Location
{
return $this->destinationLocation;
}
public function setDestinationLocation(?Location $destinationLocation): self
{
$this->destinationLocation = $destinationLocation;
return $this;
}
public function updateWeights()
{
$days = $this->getRouteDays();
$weight = 0;
foreach ($days as $day) {
if (
$day->getStartLocation() !== null &&
$day->getStartLocation()->isWarehouse()
) {
$loadingWeight = $this->calcLoadingWeight(
$day->getStartLocation(),
$day->getDate()
);
$weight += $loadingWeight;
}
$day->setStartWeight(
$weight
);
$routeElements = $day->getRouteElements();
// Resorting is required to get correct position text
$iterator = $routeElements->getIterator();
$iterator->uasort(function ($a, $b) {
return ($a->getSequence() < $b->getSequence()) ? -1 : 1;
});
/** @var RouteElement[] $routeElements */
$routeElements = new ArrayCollection(iterator_to_array($iterator));
foreach ($routeElements as $routeElement) {
$routeElement->setTravelWeight($weight);
$startWeight = $weight;
if ($routeElement->isDelivery()) {
$weight -= $routeElement->getDeliveryWeights();
} elseif ($routeElement->isLocation()) {
$loadingWeight = $this->calcLoadingWeight(
$routeElement->getLocation(),
$routeElement->getFullArrivalDateTime()
);
$routeElement->setLoadingWeight($loadingWeight);
$weight += $loadingWeight;
}
$routeElement->setWeightChange($weight - $startWeight);
$routeElement->setFinalWeight($weight);
}
$day->setEndWeight($weight);
}
$this->updateRoutePositions();
}
public function updateRoutePositions()
{
$routePosition = 0;
$days = $this->getRouteDays();
foreach ($days as $day) {
$routeElements = $day->getRouteElements();
// Resorting is required to get correct position text
$iterator = $routeElements->getIterator();
$iterator->uasort(function ($a, $b) {
return ($a->getSequence() < $b->getSequence()) ? -1 : 1;
});
/** @var RouteElement[] $routeElements */
$routeElements = new ArrayCollection(iterator_to_array($iterator));
foreach ($routeElements as $routeElement) {
if ($this->getStatus() != RouteStatusType::OPEN && !$routeElement->hasRoutePosition()) {
// Because fixedRoutePosition is a textfield, it is 1-based.
$routeElement->setFixedRoutePosition($this->getRouteElements()->count() + 1);
}
$fixedPosition = $routeElement->getFixedRoutePosition();
$routeElement->setRoutePosition($routePosition);
if (empty($fixedPosition)) {
$routePosition++;
}
// } else {
// $routeElement->setRoutePosition($fixedPosition);
// }
}
}
}
public function calcLoadingWeight(Location $location, \DateTimeInterface $since)
{
$weight = 0;
$days = $this->getRouteDays();
// echo '- START -'.PHP_EOL;
foreach ($days as $day) {
if (
$day->getStartLocation() !== null &&
$day->getStartLocation()->getId() === $location->getId() &&
$day->getDate()->format('Y-m-d') > $since->format('Y-m-d')
) {
// Current Day have same location, then the requested location
// will be loaded later
return $weight;
}
$routeElements = $day->getRouteElements();
foreach ($routeElements as $routeElement) {
if (
$routeElement->getFullArrivalDateTime() < $since
) {
continue;
}
if (
$routeElement->isLocation() &&
$routeElement->getFullArrivalDateTime() > $since &&
$routeElement->getLocation()->getId() === $location->getId()
) {
return $weight;
}
if ($routeElement->isLocation()) {
continue;
}
if (
$routeElement->isDelivery() &&
$routeElement->getDelivery()->getSourceAddress() &&
$routeElement->getDelivery()->getSourceAddress()->getId() === $location->getAddress()->getId()
) {
$weight += $routeElement->getDeliveryWeights();
}
}
}
return $weight;
}
/**
* @return mixed
*/
#[Groups(groups: ['layout:read', 'route:list'])]
public function getRouteEfficency() : array
{
$travelTime = 0;
$deliveryMinutes = 0;
foreach ($this->getRouteDays() as $day) {
foreach ($day->getRouteElements() as $routeElements) {
$travelTime += $routeElements->getTravelTime();
$deliveryMinutes += $routeElements->getDeliveryDuration();
}
$travelTime += $day->getFinalTravelTime();
}
return [
'delivery_time' => round($deliveryMinutes, 2),
'travel_time' => round($travelTime, 2),
];
}
/**
* @return Collection|RouteDay[]
*/
public function getRouteDays(): Collection
{
return $this->routeDays;
}
/**
* @throws \Exception
*/
public function getRouteDayByIndex(int $dayIndex): RouteDay
{
foreach ($this->getRouteDays() as $day) {
if ($day->getDayIndex() == $dayIndex) {
return $day;
}
}
throw new \Exception('Requested dayIndex ' . $dayIndex . ' not found');
}
public function addRouteDay(RouteDay $routeDay): self
{
if (!$this->routeDays->contains($routeDay)) {
$this->routeDays[] = $routeDay;
$routeDay->setRoute($this);
}
return $this;
}
public function removeRouteDay(RouteDay $routeDay): self
{
if ($this->routeDays->contains($routeDay)) {
$this->routeDays->removeElement($routeDay);
// set the owning side to null (unless already changed)
if ($routeDay->getRoute() === $this) {
$routeDay->setRoute(null);
}
}
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
public function getStatus()
{
return $this->status;
}
public function setStatus($status): self
{
$this->status = $status;
$routeElements = $this->getRouteElements();
if ($status == RouteStatusType::OPEN) {
$deliveryCollectionStatus = DeliveryStatusType::OPEN;
} elseif ($status == RouteStatusType::ACTIVE) {
$deliveryCollectionStatus = DeliveryStatusType::PLANNED;
} elseif ($status == RouteStatusType::PLANNED) {
$deliveryCollectionStatus = DeliveryStatusType::PLANNED;
} elseif ($status == RouteStatusType::ARCHIVED) {
$deliveryCollectionStatus = DeliveryStatusType::DONE;
}
foreach ($routeElements as $routeElement) {
if ($routeElement->isDelivery()) {
$collection = $routeElement->getCollection();
if (empty($collection)) {
throw new \Exception('RouteElement is Delivery, but without Collection: ' . $routeElement->getId());
}
$routeElement->getCollection()->setStatus($deliveryCollectionStatus);
}
if ($status == RouteStatusType::PLANNED) {
$fixedPosition = $routeElement->getFixedRoutePosition();
if(empty($fixedPosition)) {
$routeElement->setFixedRoutePosition($routeElement->getRoutePosition() + 1);
}
}
}
return $this;
}
/**
* @return Collection|Protocol[]
*/
public function getProtocols(): Collection
{
return $this->protocols;
}
public function addProtocol(Protocol $protocol): self
{
if (!$this->protocols->contains($protocol)) {
$this->protocols[] = $protocol;
$protocol->setRoute($this);
}
return $this;
}
public function removeProtocol(Protocol $protocol): self
{
if ($this->protocols->contains($protocol)) {
$this->protocols->removeElement($protocol);
// set the owning side to null (unless already changed)
if ($protocol->getRoute() === $this) {
$protocol->setRoute(null);
}
}
return $this;
}
public function getRouteImage($width = 800, $height = 1024): string
{
$mapOptions = [
'width' => $width,
'height' => $height,
'legs' => [],
'marker' => [],
];
/**
* @var RouteDay[] $routeDays
*/
$routeDays = $this->getRouteDays();
$placed = [];
foreach ($routeDays as $routeDay) {
$startLocation = $routeDay->getStartLocation();
if (!empty($startLocation)) {
$mapOptions['marker'][] = [
'coords' => [(float)$startLocation->getAddress()->getLongitude(), (float)$startLocation->getAddress()->getLatitude()],
// 'text' => substr(trim(str_replace('DB24', '', $startLocation->getAddress()->getCompany())), 0, 1),
'icon' => 'store',
];
}
/**
* @var RouteElement[] $routeElements
*/
$routeElements = $routeDay->getRouteElements();
foreach ($routeElements as $routeElement) {
if($routeElement->isPause()) continue;
// Check if this is a virtual element (end location polyline only)
$extra = $routeElement->getExtra();
$isVirtual = isset($extra['isVirtual']) && $extra['isVirtual'] === true;
$routing = $routeElement->getRouting();
// Only add marker if element has a destination address (skip virtual elements)
if (!$isVirtual && $routeElement->getDestinationAddress() !== null) {
if (!isset($placed[$routeElement->getDestinationAddress()->getId()])) {
$mapOptions['marker'][] = [
'coords' => [(float)$routeElement->getDestinationAddress()->getLongitude(), (float)$routeElement->getDestinationAddress()->getLatitude()],
'text' => ''.$routeElement->getFinalRoutePosition(),
'icon' => 'grey',
];
$placed[$routeElement->getDestinationAddress()->getId()] = true;
}
}
// Add polyline for all elements (including virtual ones)
$polyline = [];
if(!empty($routing['polyline'])) {
foreach ($routing['polyline'] as $single) {
$polyline[] = [$single['x'], $single['y']];
}
$mapOptions['legs'][] = [
'color' => '#51789b99',
'width' => 8,
'polyline' => $polyline,
];
}
}
if($routeDay->getFinalRouting() !== null) {
$routeDay->refreshFinalRoute();
$finalRouting = $routeDay->getFinalRouting();
if (!empty($finalRouting['polyline'])) {
$polyline = [];
foreach ($finalRouting['polyline'] as $single) {
$polyline[] = [$single['x'], $single['y']];
}
$mapOptions['legs'][] = [
'color' => '#51789b99',
'width' => 8,
'polyline' => $polyline,
];
}
}
}
$logFile = __DIR__ . '/../../var/log/map_debug.log';
$ch = curl_init();
//curl_setopt($ch, CURLOPT_URL, 'http://localhost:3001/');
curl_setopt($ch, CURLOPT_URL, 'https://static-maps.redoo.cloud/');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json'));
$payload = json_encode($mapOptions);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
file_put_contents($logFile, "\n" . date('Y-m-d H:i:s') . ' [MAP] Sending map request for route ' . $this->getId() . "\n", FILE_APPEND);
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] - Markers: ' . count($mapOptions['marker']) . ', Legs: ' . count($mapOptions['legs']) . "\n", FILE_APPEND);
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] - Payload: ' . $payload . "\n", FILE_APPEND);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] ERROR: ' . $error . "\n", FILE_APPEND);
return '';
}
if ($httpCode !== 200) {
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] ERROR: HTTP ' . $httpCode . ': ' . substr($response, 0, 500) . "\n", FILE_APPEND);
return '';
}
if (empty($response)) {
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] ERROR: Empty response' . "\n", FILE_APPEND);
return '';
}
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] SUCCESS: Response length ' . strlen($response) . ' bytes' . "\n", FILE_APPEND);
// Save raw PNG for debugging
$debugPngPath = __DIR__ . '/../../var/log/map_' . $this->getId() . '.png';
file_put_contents($debugPngPath, $response);
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] Saved PNG to: ' . $debugPngPath . "\n", FILE_APPEND);
// Verify it's a valid PNG
$imageInfo = getimagesizefromstring($response);
if ($imageInfo === false) {
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] ERROR: Response is not a valid image!' . "\n", FILE_APPEND);
return '';
}
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] Image verified: ' . $imageInfo[0] . 'x' . $imageInfo[1] . ' ' . $imageInfo['mime'] . "\n", FILE_APPEND);
$dataUri = 'data:image/png;base64,' . base64_encode($response);
file_put_contents($logFile, date('Y-m-d H:i:s') . ' [MAP] Data URI length: ' . strlen($dataUri) . ' chars' . "\n", FILE_APPEND);
return $dataUri;
}
public function getTotalTravelDistance($formatted = false)
{
$totalTravelDistance = 0;
/**
* @var RouteDay[] $routeDays
*/
$routeDays = $this->getRouteDays();
foreach ($routeDays as $routeDay) {
/**
* @var RouteElement[] $routeElements
*/
$routeElements = $routeDay->getRouteElements();
foreach ($routeElements as $routeElement) {
$totalTravelDistance += $routeElement->getTravelDistance();
}
$totalTravelDistance += $routeDay->getFinalTravelDistance();
}
if ($formatted == false) {
return $totalTravelDistance;
}
return number_format($totalTravelDistance, 2, ',', '.');
}
public function getTotalTravelTime($formatted = false)
{
$totalValue = 0;
/**
* @var RouteDay[] $routeDays
*/
$routeDays = $this->getRouteDays();
foreach ($routeDays as $routeDay) {
/**
* @var RouteElement[] $routeElements
*/
$routeElements = $routeDay->getRouteElements();
foreach ($routeElements as $routeElement) {
$totalValue += $routeElement->getTravelTime();
}
}
if ($formatted === false) {
return $totalValue;
}
$hours = floor($totalValue / 60);
$minutes = $totalValue - ($hours * 60);
return str_pad($hours, 2, '0', STR_PAD_LEFT) . ':' . str_pad($minutes, 2, '0', STR_PAD_LEFT) . ' h';
}
public function getTotalDistanceCosts($formatted = false)
{
$totalValue = 0;
/**
* @var RouteDay[] $routeDays
*/
$routeDays = $this->getRouteDays();
foreach ($routeDays as $routeDay) {
/**
* @var RouteElement[] $routeElements
*/
$routeElements = $routeDay->getRouteElements();
foreach ($routeElements as $routeElement) {
$routing = $routeElement->getRouting();
if (!empty($routing['tollcost'])) {
$totalValue += $routing['distancecost'];
}
}
}
if ($formatted == false) {
return $totalValue;
}
return number_format($totalValue, 2, ',', '.');
}
public function getTotalTollCosts($formatted = false)
{
$totalValue = 0;
/**
* @var RouteDay[] $routeDays
*/
$routeDays = $this->getRouteDays();
foreach ($routeDays as $routeDay) {
/**
* @var RouteElement[] $routeElements
*/
$routeElements = $routeDay->getRouteElements();
foreach ($routeElements as $routeElement) {
$routing = $routeElement->getRouting();
if(!empty($routing['tollcost'])) {
$totalValue += $routing['tollcost'];
}
}
}
if ($formatted == false) {
return $totalValue;
}
return number_format($totalValue, 2, ',', '.');
}
public function getTotalCosts($formatted = false)
{
$totalValue = $this->getTotalTollCosts() + $this->getTotalDistanceCosts();
if ($formatted == false) {
return $totalValue;
}
return number_format($totalValue, 2, ',', '.');
}
/**
* When Batch Processing is enabled, no route refresh is done on route
*
* @return void
*/
public function enableBatchProcessing()
{
$this->batchProcessing = true;
}
public function disableBatchProcessing()
{
$this->batchProcessing = false;
$this->refresh();
}
public function isBatchProcessing()
{
return $this->batchProcessing;
}
}