diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md index 6c3f1522..9ca2af2e 100644 --- a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md +++ b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md @@ -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 پلتفرم اشتباه دارد — مسئلهٔ محیطی از قبل) | diff --git a/src/Appointment/Command/BackfillServiceDurationCommand.php b/src/Appointment/Command/BackfillServiceDurationCommand.php new file mode 100644 index 00000000..d4ef3b13 --- /dev/null +++ b/src/Appointment/Command/BackfillServiceDurationCommand.php @@ -0,0 +1,121 @@ +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; + } +} diff --git a/tests/Appointment/BackfillServiceDurationTest.php b/tests/Appointment/BackfillServiceDurationTest.php new file mode 100644 index 00000000..0f5f206d --- /dev/null +++ b/tests/Appointment/BackfillServiceDurationTest.php @@ -0,0 +1,163 @@ +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()); + } +}