fix(booking): aggregate public booking state across all schedules

The public doctor payload built `active`/`free_turn`/`hours_of_work` from the
personal schedule alone, so a doctor bookable only at a clinic was reported as
"نوبت‌دهی غیرفعال". Aggregate over every schedule instead: any schedule with
online booking on and an active day makes the doctor bookable, and the disabled
label only appears when all of them are off.

Three admin-panel fixes for the same class of bug:

- AppointmentsPage took the selected doctor from `dbUuid`, which is the clinic's
  uuid inside a clinic context — the slots request 404'd. Use `doctorUuid`.
- TurnsTimeline rendered any error or unknown empty_reason as "این روز شیفت کاری
  ندارد". Errors now surface as errors and unknown reasons get a neutral message;
  the day-off wording is reserved for an explicit day_off from the backend.
- Admins have no clinic context, so slots fell back to the personal schedule.
  They now pick a location from `appointment-booking-locations` and that choice
  drives the slot, service and create-appointment requests.

Adds `app:schedule:normalize-format` for legacy rows stored as a bare JSON list
covering only Saturday, which read as day-off for the rest of the week.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 16:25:24 +03:30
co-authored by Claude Fable 5
parent 63bf2cdd12
commit 7baa4df3d4
15 changed files with 631 additions and 69 deletions
@@ -0,0 +1,107 @@
<?php
namespace App\Appointment\Command;
use App\Appointment\Repository\WeeklyScheduleRepository;
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;
/**
* Converts legacy weekly-schedule settings to the canonical shape.
*
* Canonical: keys 0..6 (0 = Saturday), each {"sessions": [...]}, plus a "meta"
* key. Legacy rows are either a bare JSON list ([{"sessions": ...}]) or miss
* some day keys entirely; readers treat the absent days as day-off, which does
* not match what the owner configured and hides the row from per-day tooling.
*/
#[AsCommand(name: 'app:schedule:normalize-format', description: 'Report (and optionally rewrite) weekly schedules stored in a legacy setting format')]
class NormalizeScheduleFormatCommand extends Command
{
private const DAY_COUNT = 7;
public function __construct(
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('fix', null, InputOption::VALUE_NONE, 'Rewrite offending rows to the canonical 7-day format');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$fix = (bool) $input->getOption('fix');
$rows = [];
$touched = 0;
foreach ($this->scheduleRepo->findAll() as $schedule) {
$setting = $schedule->getDaySchedule();
// json_decode کلیدهای "0".."6" را به int تبدیل می‌کند؛ ردیف canonical کامل
// هم list سرراست است. تنها نشانهٔ قابل‌اتکای فرمت legacy، غیبت روزهاست.
$missing = [];
for ($i = 0; $i < self::DAY_COUNT; $i++) {
if (!isset($setting[$i]['sessions'])) {
$missing[] = $i;
}
}
if ($missing === []) {
continue;
}
$clinic = $schedule->getClinic();
$rows[] = [
$schedule->getDoctor()->getUuid(),
$clinic === null ? 'personal' : ($clinic->getName() ?? 'clinic'),
implode(',', $missing),
];
if (!$fix) {
continue;
}
$normalized = [];
for ($i = 0; $i < self::DAY_COUNT; $i++) {
$day = $setting[$i] ?? null;
$normalized[$i] = is_array($day) && isset($day['sessions'])
? $day
: ['sessions' => []];
}
// setSetting جای meta موجود را حفظ می‌کند؛ setMeta آن را (در نبودش با
// DEFAULT_META) صریح در ردیف می‌نویسد تا فرمت canonical کامل شود.
$schedule->setSetting($normalized);
$schedule->setMeta($schedule->getMeta());
$touched++;
}
if ($rows === []) {
$io->success('All weekly schedules already use the canonical 7-day format.');
return Command::SUCCESS;
}
$io->table(['doctor', 'context', 'missing days'], $rows);
if (!$fix) {
$io->warning(sprintf('%d legacy row(s). Re-run with --fix to rewrite them.', count($rows)));
return Command::SUCCESS;
}
$this->em->flush();
$io->success(sprintf('Rewrote %d schedule(s) to the canonical format.', $touched));
return Command::SUCCESS;
}
}
@@ -47,15 +47,6 @@ class WeeklyScheduleRepository extends ServiceEntityRepository
->getResult();
}
/**
* @deprecated برنامهٔ context شخصی را برمی‌گرداند. برای کد جدید از
* findByDoctorAndClinic() استفاده کن تا context صریح باشد.
*/
public function findByDoctor(Doctor $doctor): ?WeeklySchedule
{
return $this->findByDoctorAndClinic($doctor, null);
}
/** @param Doctor[] $doctors @return WeeklySchedule[] */
public function findByDoctors(array $doctors): array
{
+2 -2
View File
@@ -324,11 +324,11 @@ class ClinicController extends BaseController
$scheduleMap = [];
foreach ($this->scheduleRepo->findByDoctors($clinicDoctors) as $schedule) {
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule;
$scheduleMap[$schedule->getDoctor()->getId()][] = $schedule;
}
$doctors = array_map(
fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? null),
fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? []),
$clinicDoctors
);
+8 -10
View File
@@ -123,8 +123,7 @@ class DoctorController extends BaseController
$this->userRepo->save($user);
}
$schedule = $this->scheduleRepo->findByDoctor($doctor);
return $this->success(['data' => $doctor->toDetailArray($schedule)], 201);
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))], 201);
}
#[OA\Get(
@@ -176,8 +175,8 @@ class DoctorController extends BaseController
? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()]
: null;
$schedule = $this->scheduleRepo->findByDoctor($doctor);
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), [
$schedules = $this->scheduleRepo->findAllByDoctor($doctor);
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), [
'clinics' => $clinicData,
'representation' => $representation,
])]);
@@ -201,8 +200,8 @@ class DoctorController extends BaseController
return $this->error(ErrorCodes::ERR_AUTH_006, 'این پزشک عضو کلینیک شما نیست', 403);
}
$schedule = $this->scheduleRepo->findByDoctor($doctor);
return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), ['clinics' => [[
$schedules = $this->scheduleRepo->findAllByDoctor($doctor);
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), ['clinics' => [[
'id' => (string) $clinic->getId(),
'uuid' => $clinic->getUuid(),
'name' => $clinic->getName(),
@@ -258,11 +257,11 @@ class DoctorController extends BaseController
$scheduleMap = [];
foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) {
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule;
$scheduleMap[$schedule->getDoctor()->getId()][] = $schedule;
}
return $this->paginated(
array_map(fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? null), $result['items']),
array_map(fn(Doctor $d) => $d->toListArray($scheduleMap[$d->getId()] ?? []), $result['items']),
$result['total'],
$result['page'],
$result['limit']
@@ -348,8 +347,7 @@ class DoctorController extends BaseController
$this->hydrateDoctor($doctor, $data);
$this->doctorRepo->save($doctor);
$schedule = $this->scheduleRepo->findByDoctor($doctor);
return $this->success(['data' => $doctor->toDetailArray($schedule)]);
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
}
#[OA\Delete(
+53 -24
View File
@@ -414,29 +414,52 @@ class Doctor
private const APPOINTMENT_DISABLED_LABEL = 'نوبت‌دهی آنلاین غیرفعال است';
private function computeScheduleFields(?WeeklySchedule $schedule): array
/**
* وضعیت نوبت‌دهی از دید سایت عمومی، تجمیع‌شده روی همهٔ برنامه‌های پزشک
* (شخصی + هر کلینیک). برنامهٔ شخصیِ خاموش نباید برنامهٔ کلینیکیِ روشن را بپوشاند.
*
* @param WeeklySchedule[] $schedules
*/
private function computeScheduleFields(array $schedules): array
{
$parts = $this->computeScheduleParts($schedule);
// Online booking enabled flag lives in the weekly schedule meta.
// When disabled, free_turn reflects that while hours_of_work is kept.
if ($schedule !== null && !$schedule->getMeta()['online_booking_enabled']) {
return [
'free_turn' => self::APPOINTMENT_DISABLED_LABEL,
'hours_of_work' => $parts['hours_of_work'],
'has_schedule' => false,
];
}
return $parts;
}
private function computeScheduleParts(?WeeklySchedule $schedule): array
{
if ($schedule === null) {
if ($schedules === []) {
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
}
$candidates = [];
foreach ($schedules as $schedule) {
if (!$schedule->getMeta()['online_booking_enabled']) {
continue;
}
$parts = $this->computeScheduleParts($schedule);
if ($parts['has_schedule']) {
$candidates[] = $parts;
}
}
if ($candidates === []) {
$allDisabled = array_filter($schedules, fn(WeeklySchedule $s) => $s->getMeta()['online_booking_enabled']) === [];
if ($allDisabled) {
return [
'free_turn' => self::APPOINTMENT_DISABLED_LABEL,
'hours_of_work' => $this->computeScheduleParts($schedules[array_key_first($schedules)])['hours_of_work'],
'has_schedule' => false,
];
}
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
}
// نزدیک‌ترین نوبت بین همهٔ محل‌ها؛ ساعت کاری همان محل نمایش داده می‌شود
// تا ترکیب ساعت‌های دو محل در یک رشته گمراه‌کننده نشود.
usort($candidates, fn(array $a, array $b) => $a['rank'] <=> $b['rank']);
$best = $candidates[0];
unset($best['rank']);
return $best;
}
private function computeScheduleParts(WeeklySchedule $schedule): array
{
$setting = $schedule->getSetting();
// استخراج ساعت‌های هر روز — key: dayIdx، value: رشته ساعت‌ها یا null
@@ -453,7 +476,7 @@ class Doctor
$hasAnyDay = array_filter($dayTimes) !== [];
if (!$hasAnyDay) {
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false];
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false, 'rank' => [7, '99:99']];
}
// گروه‌بندی روزهای متوالی با ساعت یکسان
@@ -485,11 +508,13 @@ class Doctor
$iranDay = $phpDay === 0 ? 1 : ($phpDay === 6 ? 0 : $phpDay + 1);
$freeTurn = null;
$rank = [7, '99:99'];
for ($i = 0; $i < 7; $i++) {
$idx = ($iranDay + $i) % 7;
if ($dayTimes[$idx] !== null) {
$firstTime = explode(' و ', $dayTimes[$idx])[0];
$freeTurn = self::DAY_NAMES[$idx] . ' ' . $firstTime;
$rank = [$i, explode('', $firstTime)[0]];
break;
}
}
@@ -498,6 +523,8 @@ class Doctor
'free_turn' => $freeTurn ?? 'نوبت آزادی موجود نیست',
'hours_of_work' => implode(' | ', $parts),
'has_schedule' => true,
// فاصله تا نزدیک‌ترین روز کاری + ساعت شروع — برای مقایسهٔ بین برنامه‌ها
'rank' => $rank,
];
}
@@ -509,9 +536,10 @@ class Doctor
return max(0, (int)((time() - $this->activityTime) / (365.25 * 24 * 3600)));
}
public function toListArray(?WeeklySchedule $schedule = null): array
/** @param WeeklySchedule[] $schedules همهٔ برنامه‌های پزشک (شخصی + کلینیک‌ها) */
public function toListArray(array $schedules = []): array
{
$sf = $this->computeScheduleFields($schedule);
$sf = $this->computeScheduleFields($schedules);
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
@@ -533,9 +561,10 @@ class Doctor
];
}
public function toDetailArray(?WeeklySchedule $schedule = null): array
/** @param WeeklySchedule[] $schedules همهٔ برنامه‌های پزشک (شخصی + کلینیک‌ها) */
public function toDetailArray(array $schedules = []): array
{
$sf = $this->computeScheduleFields($schedule);
$sf = $this->computeScheduleFields($schedules);
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,