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,163 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
/**
* `app:appointment:backfill-service-duration` — نوبت‌های آیندهٔ محیط‌های سرویسی که
* پیش از افزودن ستون‌های مدت ثبت شده‌اند.
*
* مقدار از **خودِ نوبت** می‌آید نه از بازمحاسبهٔ سرویس‌ها: نوبت موجود ممکن است با مدت
* دستی ثبت شده باشد و بازمحاسبه یعنی تغییرِ گذشته.
*/
class BackfillServiceDurationTest extends ApiTestCase
{
private function tester(): CommandTester
{
$application = new Application(static::$kernel);
return new CommandTester($application->find('app:appointment:backfill-service-duration'));
}
private function doctorInMode(string $mode, int $buffer = 0): Doctor
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر backfill');
$this->em->persist($doctor);
$this->em->flush();
$schedule = $this->newWeeklySchedule($doctor, [
'0' => ['sessions' => [[
'active' => true, 'start_time' => '09:00', 'end_time' => '18:00',
'duration_per_patient' => 20, 'location_id' => 1,
]]],
]);
$schedule->setMeta(['booking_mode' => $mode, 'buffer_minutes' => $buffer]);
$this->em->persist($schedule);
$this->em->flush();
return $doctor;
}
private function appointment(
Doctor $doctor,
string $when,
int $minutes,
string $status = Appointment::STATUS_CONFIRMED,
bool $reserve = false,
): Appointment {
$patient = $this->createUser(['ROLE_USER']);
$start = (int) strtotime($when);
$appt = $this->newAppointment($doctor, $patient, $start, $start + $minutes * 60);
if ($status !== Appointment::STATUS_PENDING) {
$appt->transitionTo($status);
}
if ($reserve) {
$appt->rescheduleTo($start, $start, true);
}
$this->em->persist($appt);
$this->em->flush();
return $appt;
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testForceFillsDurationFromTheAppointmentItself(): void
{
$doctor = $this->doctorInMode(WeeklySchedule::MODE_SERVICE, buffer: 10);
// مدت دستیِ ۵۰ دقیقه — نباید با مدت سرویس‌ها بازمحاسبه شود.
$appt = $this->appointment($doctor, '+6 days 11:00', 50);
$this->tester()->execute(['--force' => true]);
$this->em->refresh($appt);
self::assertSame(50, $appt->getServiceTotalMinutes(), 'مدت از slot_end - slot_start');
self::assertSame(10, $appt->getServiceBufferMinutes(), 'بافر از meta برنامه');
}
public function testDryRunWritesNothing(): void
{
$doctor = $this->doctorInMode(WeeklySchedule::MODE_SERVICE, buffer: 5);
$appt = $this->appointment($doctor, '+6 days 12:00', 30);
$tester = $this->tester();
$tester->execute([]);
$this->em->refresh($appt);
self::assertNull($appt->getServiceTotalMinutes(), 'dry-run پیش‌فرض است');
self::assertStringContainsString('--force', $tester->getDisplay());
}
public function testRunningTwiceChangesNothingTheSecondTime(): void
{
$doctor = $this->doctorInMode(WeeklySchedule::MODE_SERVICE, buffer: 5);
$appt = $this->appointment($doctor, '+6 days 13:00', 40);
$first = $this->tester();
$first->execute(['--force' => true]);
$this->em->refresh($appt);
self::assertStringContainsString($appt->getUuid(), $first->getDisplay(), 'بار اول باید کاندید باشد');
$second = $this->tester();
$second->execute(['--force' => true]);
$this->em->refresh($appt);
self::assertSame(40, $appt->getServiceTotalMinutes(), 'مقدار عوض نشد');
// کوئری با `serviceTotalMinutes IS NULL` فیلتر می‌کند، پس ردیفِ پرشده دیگر
// کاندید نیست و در خروجی اجرای دوم نمی‌آید.
self::assertStringNotContainsString($appt->getUuid(), $second->getDisplay(), 'idempotent');
}
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
public function testSlotModeAppointmentIsSkipped(): void
{
$doctor = $this->doctorInMode(WeeklySchedule::MODE_SLOT);
$appt = $this->appointment($doctor, '+6 days 14:00', 20);
$this->tester()->execute(['--force' => true]);
$this->em->refresh($appt);
self::assertNull($appt->getServiceTotalMinutes(), 'حالت اسلاتی نباید ستون سرویسی بگیرد');
}
public function testPastAppointmentIsSkipped(): void
{
$doctor = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$appt = $this->appointment($doctor, '-3 days 10:00', 30);
$this->tester()->execute(['--force' => true]);
$this->em->refresh($appt);
self::assertNull($appt->getServiceTotalMinutes(), 'گذشته بازنویسی نمی‌شود');
}
public function testReserveEntryIsSkipped(): void
{
$doctor = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$appt = $this->appointment($doctor, '+6 days 00:00', 0, reserve: true);
$this->tester()->execute(['--force' => true]);
$this->em->refresh($appt);
self::assertNull($appt->getServiceTotalMinutes(), 'رزرو مدتی برای استنتاج ندارد');
}
public function testCancelledAppointmentIsSkipped(): void
{
$doctor = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$appt = $this->appointment($doctor, '+6 days 15:00', 30, Appointment::STATUS_CANCELLED_BY_DOCTOR);
$this->tester()->execute(['--force' => true]);
$this->em->refresh($appt);
self::assertNull($appt->getServiceTotalMinutes());
}
}