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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user