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
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۰۰ (تکمیل نوبت‌دهی سرویسی در clinicpro)
**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۸ از ۱۰ تمام شد (مانده: backfill، مستندات)
**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۹ از ۱۰ تمام شد (مانده: مستندات + بازبینی پایانی)
**آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
@@ -52,8 +52,8 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| ۲.۱ | `service_total_minutes` و `service_buffer_minutes` (تهی‌پذیر) | ✅ | `SMALLINT DEFAULT NULL` هر دو · `migrations/Version20260730091332.php` · `setServiceDuration()` هر دو را با هم ست می‌کند (بافرِ بی‌مدت معنا ندارد) |
| ۲.۲ | هیچ ستون موجودی حذف/تغییر نوع/تغییر معنا نداد | ✅ | تأیید با `SHOW COLUMNS`: `slot_start` int NOT NULL · `slot_end` int NOT NULL · `active_slot_key` varchar(64) UNI · `is_reserve` tinyint default 0 — همه دست‌نخورده |
| ۲.۳ | دو کد خطای جدید در `ErrorCodes.php` با پیام فارسی | ✅ | `ERR_APPOINTMENT_003` (مدت نمی‌خواند) و `ERR_APPOINTMENT_004` (روش نوبت‌دهی ناسازگار) — **نه `_010`/`_011`** که متن تسک حدس زده بود؛ فایل فقط تا `_002` داشت |
| ۲.۴ | `app:appointment:backfill-service-duration` — dry-run پیش‌فرض، idempotent | | |
| ۲.۵ | backfill مقدار را از خود نوبت می‌گیرد، نه بازمحاسبه از سرویس‌ها | | |
| ۲.۴ | `app:appointment:backfill-service-duration` — dry-run پیش‌فرض، idempotent | | `src/Appointment/Command/BackfillServiceDurationCommand.php` با الگوی `BackfillAppointmentSessionsCommand` موجود. idempotency از خودِ کوئری می‌آید (`serviceTotalMinutes IS NULL`)، نه از یک پرچم |
| ۲.۵ | backfill مقدار را از خود نوبت می‌گیرد، نه بازمحاسبه از سرویس‌ها | | `intdiv(slot_end - slot_start, 60)`. تست `testForceFillsDurationFromTheAppointmentItself` با مدت دستیِ ۵۰ دقیقه‌ای می‌سنجد که بازمحاسبه نمی‌شود — قانون پنجم مستند |
| ۲.۶ | migration اجرا شد و `TenantSchemaCoverageTest` سبز | ✅ | dev با migration؛ **db_test با `ALTER` دستی** — تاریخچهٔ migration آن DB از dev جدا است (`Table users already exists`) و این روش نگهداشتِ موجودِ همان DB است |
## ۳. UI — پنل ادمین
@@ -93,6 +93,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
| ۴.۱۰ | `ServiceSlotExcludeSelfTest` — رفتار exclude | ✅ | ۶ تست / ۱۱ assertion. شامل: بازهٔ خودِ نوبت با exclude برمی‌گردد · مدت بلندتر روی همان ساعت · نوبتِ دیگری همچنان اشغال می‌ماند · `null` صریح و ضمنی خروجی یکسان · فیلتر repository فقط همان ردیف · exclude کردن نوبت رزرو بی‌اثر |
| ۴.۹ | `AppointmentServiceFieldsTest` — متدها و ستون‌های جدید | ✅ | ۹ تست / ۲۴ assertion. شامل: هم‌گامی ستون تکی · حفظ ترتیب · فهرست خالی → `null` · حالت اسلاتی هر دو ستون `null` · مدتِ `null` بافر را هم `null` می‌کند · تکراری‌ها dedup · نوبت قدیمیِ فقط-تکی · بقای مقادیر پس از flush/clear |
| ۴.۸ | `AppointmentEditPage` — دو حالت | ✅ | فایل جدید `AppointmentEditPage.serviceMode.test.tsx`: ۹ تست. شامل: پنهان‌بودن ورودی ساعت · دکمهٔ غیرفعال + راهنما · `exclude_appointment_uuid` در query · `service-reschedule` **بدون هیچ مدتی** · `PATCH` بعدی بی‌`version` · هشدار سرویس غیرفعال · و سه تست خط سرخ (اسلاتی و رزرو). فایل موجود `AppointmentEditPage.test.tsx` هم سبز ماند |
| ۴.۱۳ | `BackfillServiceDurationTest` | ✅ | ۷ تست / ۱۱ assertion: مقدار از خود نوبت (نه بازمحاسبه) · dry-run هیچ نمی‌نویسد · اجرای دوم بی‌اثر · حالت اسلاتی رد · گذشته رد · رزرو رد · لغوشده رد |
| ۴.۱۲ | `MyAppointmentsServiceFieldsTest` | ✅ | ۶ تست / ۱۷ assertion: فهرست کامل سرویس‌ها · مدت و بافر · `clinic_uuid` · فهرست رزرو هم مدت دارد · نوبت بی‌سرویس آرایهٔ خالی (نه null) · **صفحه‌بندی با سه سرویس روی یک نوبت نمی‌شکند** |
| ۴.۱۱ | کل vitest سبز | ✅ | `86 files / 604 tests passed` روی host (داخل ddev باینری esbuild پلتفرم اشتباه دارد — مسئلهٔ محیطی از قبل) |
@@ -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;
}
}
@@ -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());
}
}