<?php
declare(strict_types=1);
namespace App\Admin\Controller;
use App\Entity\AdmUser;
use App\Entity\Client;
use App\Entity\Equipment;
use App\Entity\Motorcycle;
use App\Entity\Reserve;
use App\Entity\Type\EquipmentType;
use App\Entity\Type\RentStatusType;
use App\Entity\Type\WeekDay;
use App\EntityService\ReserveEntityService;
use App\Event\ReserveCreateEvent;
use App\Repository\EquipmentRepository;
use App\Repository\MotorcycleRepository;
use App\Repository\ReserveRepository;
use App\Service\TransactionService;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Sonata\AdminBundle\Controller\CRUDController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ReserveAdminController extends CRUDController
{
private const DEFAULT_CALENDAR_DAYS_LIMIT = 30;
private EntityManagerInterface $entityManager;
private ReserveRepository $reserveRepository;
private MotorcycleRepository $motorcycleRepository;
private EquipmentRepository $equipmentRepository;
private ReserveEntityService $reserveEntityService;
private EventDispatcherInterface $eventDispatcher;
public function __construct(
EntityManagerInterface $entityManager,
ReserveRepository $reserveRepository,
MotorcycleRepository $motorcycleRepository,
EquipmentRepository $equipmentRepository,
ReserveEntityService $reserveEntityService,
EventDispatcherInterface $eventDispatcher
)
{
$this->entityManager = $entityManager;
$this->reserveRepository = $reserveRepository;
$this->motorcycleRepository = $motorcycleRepository;
$this->equipmentRepository = $equipmentRepository;
$this->reserveEntityService = $reserveEntityService;
$this->eventDispatcher = $eventDispatcher;
}
public function getReserveCalendarAction(Request $request): JsonResponse
{
$days = $request->get('days');
if (!$days) {
$days = self::DEFAULT_CALENDAR_DAYS_LIMIT;
}
$searchStartDate = new \DateTime();
$searchEndDate = new \DateTime();
$searchEndDate->setTime(23, 59, 59);
$searchStartDate->modify('- 1 day');
$searchEndDate->modify('- 1 day');
$graph = [];
for ($i = 0; $i <= $days; $i++) {
$searchStartDate->setTime(0,0);
$searchStartDate->modify('+ 1 day');
$searchEndDate->modify('+ 1 day');
$result = $this->reserveRepository->reserveList($searchStartDate, $searchEndDate);
$reserves = [];
$result->map(static function (Reserve $reserve) use (&$reserves, $searchStartDate, $searchEndDate) {
$motId = $reserve->getMotorcycle()->getId();
$motName = $reserve->getMotorcycle()->getFullName();
$startTime = $reserve->getStartDate();
$endDate = $reserve->getEndDate();
$endDay = (clone $searchEndDate)->setTime(23,59);
if ((int)$startTime->format('d') !== (int) $searchStartDate->format('d')) {
$startTime = clone $searchStartDate;
}
if ($endDate > $endDay) {
$endDate = $endDay;
}
// Normalize to full hours for the calendar grid.
// A rental starting at 15:15 will occupy the 15:00 slot onward on this day.
$slotStart = (clone $startTime)->setTime((int)$startTime->format('H'), 0, 0);
$list = [];
$current = clone $slotStart;
while ($current <= $endDate) {
$list[] = $current->format('H:i');
$current->modify('+ 1 hour');
}
if ((int)$reserve->getEndDate()->format('d') !== (int) $searchEndDate->format('d') ||
(int)$reserve->getEndDate()->format('m') > (int) $searchEndDate->format('m')
) {
$list[] = '24:00';
}
// Removed array_pop so the last slot (end hour) is included for coloring and red border on end day.
// For exact hour end on end day, the last in list will be the end hour, full color + red on its right edge.
if (!isset($reserves[$motId])) {
$reserves[$motId] = [
'name' => $motName,
'color' => $reserve->getMotorcycle()->getColor(),
'segments' => []
];
}
$starts_today = ($reserve->getStartDate()->format('Y-m-d') === $searchStartDate->format('Y-m-d'));
$ends_today = ($reserve->getEndDate()->format('Y-m-d') === $searchStartDate->format('Y-m-d'));
$reserves[$motId]['segments'][] = [
'times' => $list,
'starts_today' => $starts_today,
'ends_today' => $ends_today,
'real_start' => $reserve->getStartDate()->format('H:i'),
'real_end' => $reserve->getEndDate()->format('H:i'),
];
});
$schedules = $this->prepareReserves($reserves);
$graph[] = [
'date' => sprintf(
'(%s) %s',
WeekDay::WEEKDAY_RU[(int)$searchStartDate->format('w')],
$searchStartDate->format('d.m.Y')),
'schedules' => $schedules
];
}
return new JsonResponse($graph);
}
private function prepareReserves($reserves): array
{
$schedules = [];
foreach ($reserves as $motId => $value) {
$schedules[] = [
'id' => $motId,
'name' => $value['name'],
'color' => $value['color'],
'segments' => $value['segments']
];
}
return $schedules;
}
public function createAcceptanceCertificateAction(Request $request): Response
{
/** @var Reserve $reserve */
$reserve = $this->reserveRepository->find($request->get('id'));
$this->reserveEntityService->toActiveStatus($reserve);
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new ReserveCreateEvent($reserve), ReserveCreateEvent::NAME);
$data = $this->getAcceptanceCertificateViewData($reserve);
return $this->renderWithExtraParams('admin/Document/acceptance_certificate.html.twig', $data);
}
/**
* Simple view of the acceptance certificate (акт) without any side effects.
* Used from the "Просмотреть акт" button on active reserves list.
*/
public function viewAcceptanceCertificateAction(Request $request): Response
{
$reserve = $this->reserveRepository->find($request->get('id'));
if (!$reserve) {
throw $this->createNotFoundException('Reserve not found');
}
$data = $this->getAcceptanceCertificateViewData($reserve);
return $this->renderWithExtraParams('admin/Document/acceptance_certificate.html.twig', $data);
}
/**
* Prepares the data array for the acceptance_certificate.html.twig template.
* Shared between create (which does activation) and pure view.
*/
private function getAcceptanceCertificateViewData(Reserve $reserve): array
{
/** @var AdmUser $admin */
$admin = $this->getUser();
/** @var Client $client */
$client = $reserve->getClient();
/** @var Motorcycle $motorcycle */
$motorcycle = $reserve->getMotorcycle();
$equipment = [];
/** @var Equipment $eq */
foreach ($reserve->getEquipment() as $eq) {
$number = ' (№ ' . $eq->getSerialNumber() . ')';
$equipment[] = EquipmentType::RU_VALUES[$eq->getType()] . ' ' . $eq->getBrand() . ' ' . $eq->getModel() . ' ' . $eq->getSize() . $number ;
}
return [
'admin' => [
'jobTitle' => $admin ? $admin->getBiography() : '',
'name' => $admin ? ($admin->getLastname() . ' ' . $admin->getFirstname()) : ''
],
'currentDate' => (new DateTime())->format('d.m.Y'),
'client' => [
'number' => $client ? $client->getUserNumber() : '',
'fio' => $client ? $client->getFio() : '',
'signDate' => ($client && $client->getDateSigningContract()) ? $client->getDateSigningContract()->format('d.m.Y') : ''
],
'motorcycle' => [
'name' => $motorcycle ? $motorcycle->getFullName() : '',
'vin' => $motorcycle ? $motorcycle->getVinNumber() : '',
'year' => $motorcycle && $motorcycle->getIssueYear() ? $motorcycle->getIssueYear()->format('Y') : '',
'registrationNumber' => $motorcycle ? $motorcycle->getRegistrationNumber() : '',
'paymentAmount' => $motorcycle ? $motorcycle->getPaymentAmount() : null,
'mileage' => $motorcycle ? $motorcycle->getMileage() : null,
],
'reserve' => [
'amount' => $reserve->getAmount(),
'amountWords' => $this->num2str($reserve->getAmount()),
'startDate' => $reserve->getStartDate()->format('d.m.Y H:i'),
'endDate' => $reserve->getEndDate()->format('d.m.Y H:i'),
'returnDate' => $reserve->getEndDate()->format('d.m.Y'),
'returnTimeIntervalStart' => $reserve->getEndDate()->format('H:i'),
'returnTimeIntervalEnd' => (clone $reserve->getEndDate())->modify('+ 15 minutes')->format('H:i'),
],
'equipment' => $equipment
];
}
public function openReserveAction(Request $request): Response
{
$reserve = $this->reserveRepository->find($request->get('id'));
///На потом, достать весь экип у которого нет активной аренды (или типы аренды котрые раньше)
/// т текущий экип если уже брали
return $this->renderWithExtraParams('admin/Reserve/open_reserve_page.html.twig', [
'object' => $reserve,
'equipment' => $this->getEquipmentPrepare(),
'jsonEquipment' => json_encode($this->getEquipmentPrepare())
]);
}
public function closeReserveAction(Request $request): Response
{
$reserve = $this->reserveRepository->find($request->get('id'));
return $this->renderWithExtraParams('admin/Reserve/close_reserve_page.html.twig', [
'object' => $reserve,
]);
}
public function finaleCloseReserveAction(Request $request): Response
{
/** @var Reserve $reserve */
$reserve = $this->reserveRepository->find($request->get('id'));
if (!$reserve) {
throw $this->createNotFoundException('Reserve not found');
}
$payload = $this->extractRequestPayload($request);
$endMileage = $payload['endMileage'] ?? $request->get('endMileage') ?? $request->get('mileage');
if ($endMileage !== null && $endMileage !== '') {
$mileage = (int) $endMileage;
$reserve->setEndRentMileage($mileage);
if ($reserve->getMotorcycle()) {
$reserve->getMotorcycle()->setMileage($mileage);
}
}
$reserve->setStatus(RentStatusType::ENDED);
$this->entityManager->flush();
return $this->redirectToRoute('admin_app_reserve_active_list');
}
public function addToReserveAction(Request $request): Response
{
try {
$payload = $this->extractRequestPayload($request);
$reserveId = $payload['reserve'] ?? $request->get('reserve');
$equipmentList = $payload['equipment'] ?? $request->get('equipment');
$startReserveMileage = $payload['startMileage'] ?? $request->get('startMileage');
$endReserveMileage = $payload['endMileage'] ?? $request->get('endMileage');
/** @var Reserve|null $reserve */
$reserve = $this->reserveRepository->find((int) $reserveId);
if (!$reserve) {
return new JsonResponse('Reserve not found', Response::HTTP_NOT_FOUND);
}
if ($startReserveMileage !== null && $startReserveMileage !== '') {
$mileage = (int) $startReserveMileage;
$reserve->setStartRentMileage($mileage);
if ($reserve->getMotorcycle()) {
$reserve->getMotorcycle()->setMileage($mileage);
}
}
if ($endReserveMileage !== null && $endReserveMileage !== '') {
$mileage = (int) $endReserveMileage;
$reserve->setEndRentMileage($mileage);
if ($reserve->getMotorcycle()) {
$reserve->getMotorcycle()->setMileage($mileage);
}
}
if (is_array($equipmentList)) {
foreach ($equipmentList as $item) {
/** @var Equipment|null $equipment */
$equipment = $this->equipmentRepository->find((int) $item);
if ($equipment) {
$reserve->addEquipment($equipment);
}
}
}
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
} catch (\Throwable $e) {
return new JsonResponse($e->getMessage(), Response::HTTP_I_AM_A_TEAPOT);
}
}
/**
* Supports both JSON body (fetch) and classic form/query params.
*/
private function extractRequestPayload(Request $request): array
{
$content = $request->getContent();
if ($content) {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
return $decoded;
}
}
return $request->request->all();
}
/**
* AJAX action to calculate reserve amount.
* Now supports dynamic calculation: breaks down the total rental time
* into combination of available packages (сутки + 6 часов etc.) and sums their prices.
* Example: 24h (160) + 6h (90) = 250.
*/
public function calculateAmountAction(Request $request): JsonResponse
{
$motorcycleId = $request->request->get('motorcycle');
$startStr = $request->request->get('start');
$endStr = $request->request->get('end');
$durationKey = $request->request->get('duration');
if (!$motorcycleId || !$startStr) {
return new JsonResponse(['success' => false, 'error' => 'Не указан мотоцикл или дата начала'], 400);
}
/** @var \App\Entity\Motorcycle|null $motorcycle */
$motorcycle = $this->motorcycleRepository->find((int)$motorcycleId);
if (!$motorcycle) {
return new JsonResponse(['success' => false, 'error' => 'Мотоцикл не найден'], 404);
}
$rentalPrice = $motorcycle->getRentalPrice();
if (!$rentalPrice) {
return new JsonResponse([
'success' => false,
'error' => 'У этого мотоцикла не назначен профиль цен аренды. Укажите профиль в карточке мотоцикла.'
], 400);
}
try {
$start = new \DateTime($startStr);
} catch (\Exception $e) {
return new JsonResponse(['success' => false, 'error' => 'Некорректная дата начала'], 400);
}
// Prefer dynamic calculation when both start and end are available (this is the new main path)
if ($endStr) {
try {
$end = new \DateTime($endStr);
if ($end <= $start) {
return new JsonResponse(['success' => false, 'error' => 'Дата окончания должна быть позже даты начала'], 400);
}
$diffSeconds = $end->getTimestamp() - $start->getTimestamp();
$totalHours = $diffSeconds / 3600;
$calc = $this->calculateDynamicPrice($rentalPrice, $start, $totalHours);
return new JsonResponse([
'success' => true,
'amount' => $calc['amount'],
'breakdown' => $calc['breakdown'],
'total_hours' => round($totalHours, 2),
'profile' => (string)$rentalPrice,
]);
} catch (\Exception $e) {
return new JsonResponse(['success' => false, 'error' => 'Ошибка при расчёте по датам'], 400);
}
}
// Fallback: single package by durationKey (old behavior, useful if only quick button used without end date yet)
if ($durationKey) {
if ($durationKey === '5h') {
$weekday = (int)$start->format('N');
$isWeekday = $weekday <= 5;
$startHour = (int)$start->format('H');
if (!$isWeekday || $startHour < 18) {
return new JsonResponse([
'success' => false,
'error' => 'Пакет «5 часов (вечер)» доступен только по будням (пн–пт) и только если аренда начинается в 18:00 или позже.'
], 400);
}
}
$amount = $rentalPrice->getPriceForDuration($durationKey);
if ($amount === null || $amount <= 0) {
return new JsonResponse([
'success' => false,
'error' => 'Для выбранной длительности не задана цена в профиле.'
], 400);
}
return new JsonResponse([
'success' => true,
'amount' => $amount,
'breakdown' => $durationKey . ' (по профилю ' . $rentalPrice . ')',
'profile' => (string)$rentalPrice,
]);
}
return new JsonResponse(['success' => false, 'error' => 'Укажите даты окончания или выберите длительность для расчёта.'], 400);
}
/**
* Dynamic price calculation.
* Breaks total rental time into combination of available packages and sums the prices.
* Supports combinations like "сутки + 6 часов".
*/
private function calculateDynamicPrice(\App\Entity\RentalPrice $rentalPrice, \DateTimeInterface $start, float $totalHours): array
{
// Packages in descending order of duration
$packages = [
['key' => '30d', 'hours' => 720, 'price' => $rentalPrice->getPrice30d(), 'label' => '30 дней'],
['key' => '7d', 'hours' => 168, 'price' => $rentalPrice->getPrice7d(), 'label' => 'неделя'],
['key' => '24h', 'hours' => 24, 'price' => $rentalPrice->getPrice24h(), 'label' => 'сутки'],
['key' => '6h', 'hours' => 6, 'price' => $rentalPrice->getPrice6h(), 'label' => '6 часов'],
['key' => '5h', 'hours' => 5, 'price' => $rentalPrice->getPrice5h(), 'label' => '5 часов (вечер)'],
['key' => '1h', 'hours' => 1, 'price' => $rentalPrice->getPrice1h(), 'label' => '1 час'],
];
$remaining = $totalHours;
$parts = [];
$total = 0.0;
$canUse5h = null; // cached check for the special package
foreach ($packages as $pkg) {
if ($pkg['price'] === null || $pkg['price'] <= 0 || $pkg['hours'] <= 0) {
continue;
}
$count = (int) floor($remaining / $pkg['hours']);
if ($count > 0) {
if ($pkg['key'] === '5h') {
if ($canUse5h === null) {
$weekday = (int)$start->format('N'); // 1=Mon ... 5=Fri
$startHour = (int)$start->format('H');
$canUse5h = ($weekday <= 5 && $startHour >= 18);
}
if (!$canUse5h) {
continue; // skip 5h package, fall back to smaller units
}
}
$parts[] = $count . ' × ' . $pkg['label'] . ' (' . $pkg['price'] . ')';
$total += $count * $pkg['price'];
$remaining -= $count * $pkg['hours'];
}
}
// Handle any small remaining time using 1h price (round up)
if ($remaining > 0.01) {
$oneHPrice = $rentalPrice->getPrice1h();
if ($oneHPrice > 0) {
$extra = (int) ceil($remaining);
$parts[] = $extra . ' × 1 час (' . $oneHPrice . ')';
$total += $extra * $oneHPrice;
}
}
$breakdown = implode(' + ', $parts);
if ($breakdown) {
$breakdown .= ' = ' . $total;
} else {
$breakdown = 'Не удалось подобрать пакеты для указанного времени';
}
return [
'amount' => $total,
'breakdown' => $breakdown,
];
}
private function getEquipmentPrepare(): array
{
$equipment = $this->equipmentRepository->findAll();
$res = [];
foreach ($equipment as $item) {
$image = $item->getImages()->first();
$res[EquipmentType::VALUES[$item->getType()]][] = [
'id' => $item->getId(),
'brand' => $item->getBrand(),
'model' => $item->getModel(),
'size' => $item->getSize(),
'image' => $image ? $image->getPath() : null
];
}
return $res;
}
private function num2str($num): string
{
$nul='ноль';
$ten=array(
array('','один','два','три','четыре','пять','шесть','семь', 'восемь','девять'),
array('','одна','две','три','четыре','пять','шесть','семь', 'восемь','девять'),
);
$a20=array('десять','одиннадцать','двенадцать','тринадцать','четырнадцать' ,'пятнадцать','шестнадцать','семнадцать','восемнадцать','девятнадцать');
$tens=array(2=>'двадцать','тридцать','сорок','пятьдесят','шестьдесят','семьдесят' ,'восемьдесят','девяносто');
$hundred=array('','сто','двести','триста','четыреста','пятьсот','шестьсот', 'семьсот','восемьсот','девятьсот');
$unit=array( // Units
array('копейка' ,'копейки' ,'копеек', 1),
array('рубль' ,'рубля' ,'рублей' ,0),
array('тысяча' ,'тысячи' ,'тысяч' ,1),
array('миллион' ,'миллиона','миллионов' ,0),
array('миллиард','милиарда','миллиардов',0),
);
//
list($rub,$kop) = explode('.',sprintf("%015.2f", floatval($num)));
$out = array();
if (intval($rub)>0) {
foreach(str_split($rub,3) as $uk=>$v) { // by 3 symbols
if (!intval($v)) continue;
$uk = sizeof($unit)-$uk-1; // unit key
$gender = $unit[$uk][3];
list($i1,$i2,$i3) = array_map('intval',str_split($v,1));
// mega-logic
$out[] = $hundred[$i1]; # 1xx-9xx
if ($i2>1) $out[]= $tens[$i2].' '.$ten[$gender][$i3]; # 20-99
else $out[]= $i2>0 ? $a20[$i3] : $ten[$gender][$i3]; # 10-19 | 1-9
// units without rub & kop
if ($uk>1) $out[]= $this->morph($v,$unit[$uk][0],$unit[$uk][1],$unit[$uk][2]);
} //foreach
}
else $out[] = $nul;
// $out[] = $this->morph(intval($rub), $unit[1][0],$unit[1][1],$unit[1][2]); // rub
// $out[] = $kop.' '.$this->morph($kop,$unit[0][0],$unit[0][1],$unit[0][2]); // kop
return trim(preg_replace('/ {2,}/', ' ', join(' ',$out)));
}
private function morph($n, $f1, $f2, $f5) {
$n = abs(intval($n)) % 100;
if ($n>10 && $n<20) return $f5;
$n = $n % 10;
if ($n>1 && $n<5) return $f2;
if ($n==1) return $f1;
return $f5;
}
}