feat(booking): add backfill command for service duration columns
Fills service_total_minutes/service_buffer_minutes on future service-mode appointments booked before the columns existed. The value comes from the appointment itself (slot_end - slot_start), not from recomputing the services: an existing appointment may have been booked with a manual duration and recomputing would rewrite the past. Slot-mode, past, reserve and cancelled appointments are skipped. Dry-run by default. Idempotency comes from the query filtering on serviceTotalMinutes IS NULL rather than from a flag, so a second run has nothing to do. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Command;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Service\ServiceBookingCalculator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* نوبتهای **آیندهٔ** محیطهای سرویسی که پیش از افزودن ستونهای
|
||||
* `service_total_minutes` / `service_buffer_minutes` ثبت شدهاند.
|
||||
*
|
||||
* مقدار از **خودِ نوبت** گرفته میشود (`slot_end - slot_start`)، نه از بازمحاسبهٔ مدت
|
||||
* سرویسها: نوبت موجود ممکن است با مدت دستی ثبت شده باشد و بازمحاسبه یعنی تغییرِ
|
||||
* گذشته — همان چیزی که قانون پنجم مستند ممنوع کرده.
|
||||
*
|
||||
* نوبتهای اسلاتی و نوبتهای گذشته رد میشوند. نوبت رزرو (`slot_start == slot_end`) هم
|
||||
* مدتی برای استنتاج ندارد و رد میشود.
|
||||
*
|
||||
* dry-run پیشفرض است؛ با `--force` مینویسد. اجرای دوباره چیزی را دوباره ست نمیکند.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:appointment:backfill-service-duration',
|
||||
description: 'Fill service_total_minutes/service_buffer_minutes for future service-mode appointments',
|
||||
)]
|
||||
class BackfillServiceDurationCommand extends Command
|
||||
{
|
||||
/** فقط نوبتهایی که هنوز اتفاق نیفتادهاند؛ گذشته را بازنویسی نمیکنیم. */
|
||||
private const LIVE_STATUSES = [
|
||||
Appointment::STATUS_PENDING,
|
||||
Appointment::STATUS_CONFIRMED,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ServiceBookingCalculator $calculator,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('force', null, InputOption::VALUE_NONE, 'Write the values instead of only reporting them');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$force = (bool) $input->getOption('force');
|
||||
|
||||
/** @var Appointment[] $candidates */
|
||||
$candidates = $this->em->createQueryBuilder()
|
||||
->select('a')
|
||||
->from(Appointment::class, 'a')
|
||||
->where('a.serviceTotalMinutes IS NULL')
|
||||
->andWhere('a.status IN (:live)')
|
||||
->andWhere('a.slotStart > :now')
|
||||
->andWhere('a.isReserve = false')
|
||||
->setParameter('live', self::LIVE_STATUSES)
|
||||
->setParameter('now', time())
|
||||
->orderBy('a.slotStart', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$rows = [];
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($candidates as $appointment) {
|
||||
if (!$this->calculator->isServiceMode($appointment->getDoctor(), $appointment->getClinic())) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$minutes = intdiv($appointment->getSlotEnd() - $appointment->getSlotStart(), 60);
|
||||
if ($minutes <= 0) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$buffer = $this->calculator->bufferMinutes($appointment->getDoctor(), $appointment->getClinic());
|
||||
$rows[] = [
|
||||
$appointment->getUuid(),
|
||||
date('Y-m-d H:i', $appointment->getSlotStart()),
|
||||
$minutes,
|
||||
$buffer,
|
||||
];
|
||||
|
||||
if ($force) {
|
||||
$appointment->setServiceDuration($minutes, $buffer);
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$io->success(sprintf('چیزی برای backfill نیست (%d نوبت غیرمرتبط رد شد).', $skipped));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->table(['uuid', 'شروع', 'مدت (دقیقه)', 'بافر (دقیقه)'], $rows);
|
||||
|
||||
if (!$force) {
|
||||
$io->warning(sprintf(
|
||||
'%d نوبت قابل backfill است. برای نوشتن، دوباره با --force اجرا کنید.',
|
||||
count($rows),
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$io->success(sprintf('%d نوبت بهروزرسانی شد (%d رد شد).', count($rows), $skipped));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user