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:
hamed
2026-07-30 15:08:35 +03:30
co-authored by Claude Opus 5
parent f1ea7bb161
commit 7482eb2ba3
3 changed files with 288 additions and 3 deletions
@@ -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;
}
}