Merge branch 'dev' into main

# Conflicts:
#	docs/api/doctor.md
This commit is contained in:
hamed
2026-07-19 16:15:30 +03:30
1026 changed files with 190049 additions and 15130 deletions
+20 -9
View File
@@ -13,11 +13,14 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* اصلاح یک‌بارمصرف نامِ پزشکانِ ایمپورت‌شدهٔ IRIMC که با پیشوند «دکتر» ذخیره شده‌اند.
* فقط source='irimc' را دست می‌زند؛ پزشکان manual/seed را تغییر نمی‌دهد.
* حذف پیشوند «دکتر» از نام پزشکانی که با عنوان ذخیره شده‌اند. نام پزشک هرگز نباید
* عنوان داشته باشد؛ لایهٔ نمایش خودش تصمیم می‌گیرد چطور نشانش دهد.
*
* پیش‌فرض فقط source='irimc' است. `--all` هر منبعی (seed/manual) را هم پاک می‌کند —
* لازم است چون مسیرهای ثبت‌نام تا پیش از این عنوان را حذف نمی‌کردند.
*
* php bin/console app:doctors:fix-irimc-names --dry-run
* php bin/console app:doctors:fix-irimc-names
* php bin/console app:doctors:fix-irimc-names --all
*/
#[AsCommand(
name: 'app:doctors:fix-irimc-names',
@@ -33,19 +36,21 @@ class FixIrimcNamesCommand extends Command
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
$this->addOption('all', null, InputOption::VALUE_NONE, 'Every doctor, not just source=irimc');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$all = (bool) $input->getOption('all');
$qb = $this->em->getRepository(Doctor::class)->createQueryBuilder('d');
if (!$all) {
$qb->where('d.source = :src')->setParameter('src', 'irimc');
}
/** @var Doctor[] $doctors */
$doctors = $this->em->getRepository(Doctor::class)->createQueryBuilder('d')
->where('d.source = :src')
->setParameter('src', 'irimc')
->getQuery()
->getResult();
$doctors = $qb->getQuery()->getResult();
$fixed = 0;
foreach ($doctors as $doctor) {
@@ -63,7 +68,13 @@ class FixIrimcNamesCommand extends Command
$this->em->flush();
}
$io->success(sprintf('%d نام %s (از %d پزشک IRIMC).', $fixed, $dryRun ? 'قابل اصلاح' : 'اصلاح شد', count($doctors)));
$io->success(sprintf(
'%d نام %s (از %d پزشک %s).',
$fixed,
$dryRun ? 'قابل اصلاح' : 'اصلاح شد',
count($doctors),
$all ? 'بررسی‌شده' : 'IRIMC',
));
return Command::SUCCESS;
}
+19 -12
View File
@@ -26,6 +26,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use App\Shared\Util\PersianText;
#[OA\Tag(name: 'Doctors')]
class DoctorController extends BaseController
@@ -105,7 +106,7 @@ class DoctorController extends BaseController
}
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['title'] ?? $data['name'] ?? '');
$name = PersianText::stripDoctorTitle($data['title'] ?? $data['name'] ?? '');
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام دکتر الزامی است', 422, 'title');
@@ -123,8 +124,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 +176,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 +201,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 +258,19 @@ class DoctorController extends BaseController
$scheduleMap = [];
foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) {
$scheduleMap[$schedule->getDoctor()->getId()] = $schedule;
$scheduleMap[$schedule->getDoctor()->getId()][] = $schedule;
}
$locationMap = $this->doctorRepo->findLocationsByDoctors($result['items']);
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()] ?? [],
$locationMap[$d->getId()] ?? null
),
$result['items']
),
$result['total'],
$result['page'],
$result['limit']
@@ -343,13 +351,12 @@ class DoctorController extends BaseController
}
$data = json_decode($request->getContent(), true) ?? [];
if (!empty($data['title'])) $doctor->setName($data['title']);
if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title']));
$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(
+66 -24
View File
@@ -7,6 +7,7 @@ use App\Auth\Entity\User;
use App\DoctorService\Entity\DoctorService;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Shared\Util\DisplayName;
use App\Specialty\Entity\Specialty;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -142,6 +143,8 @@ class Doctor
public function __construct(User $user, string $name)
{
DisplayName::assertReal($name);
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->name = $name;
@@ -273,6 +276,7 @@ class Doctor
public function setName(string $v): self
{
DisplayName::assertReal($v);
$this->name = $v;
return $this;
}
@@ -414,29 +418,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 +480,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 +512,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 +527,8 @@ class Doctor
'free_turn' => $freeTurn ?? 'نوبت آزادی موجود نیست',
'hours_of_work' => implode(' | ', $parts),
'has_schedule' => true,
// فاصله تا نزدیک‌ترین روز کاری + ساعت شروع — برای مقایسهٔ بین برنامه‌ها
'rank' => $rank,
];
}
@@ -509,9 +540,15 @@ class Doctor
return max(0, (int)((time() - $this->activityTime) / (365.25 * 24 * 3600)));
}
public function toListArray(?WeeklySchedule $schedule = null): array
/** @param WeeklySchedule[] $schedules همهٔ برنامه‌های پزشک (شخصی + کلینیک‌ها) */
/**
* @param array{city: ?array, province: ?array}|null $location
* شهر/استان از DoctorRepository::findLocationsByDoctors — این Entity به آدرس
* کلینیک دسترسی ندارد، پس مکان دسته‌ای بیرون حل و تزریق می‌شود.
*/
public function toListArray(array $schedules = [], ?array $location = null): array
{
$sf = $this->computeScheduleFields($schedule);
$sf = $this->computeScheduleFields($schedules);
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
@@ -530,12 +567,17 @@ class Doctor
'hours_of_work' => $sf['hours_of_work'],
'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
'owner_status' => $this->ownerStatus,
// آرایه — هم‌شکل با city/state در پاسخ جزئیات پزشک و پاسخ لیست کلینیک‌ها.
// پزشک بدون مکان آرایهٔ خالی می‌گیرد (نه null) تا مصرف‌کننده شرط یکسانی بنویسد.
'city' => isset($location['city']) ? [$location['city']] : [],
'state' => isset($location['province']) ? [$location['province']] : [],
];
}
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,
@@ -62,6 +62,32 @@ class DoctorAddressRepository extends ServiceEntityRepository
->getSingleScalarResult();
}
/**
* آدرس‌های قابل‌انتخاب در یک context. مطب شخصی فقط آدرس‌های شخصی خود پزشک را
* می‌بیند و کلینیک فقط آدرس‌های خودش — این دو هرگز union نمی‌شوند.
*
* @return DoctorAddress[]
*/
public function findForContext(Doctor $doctor, ?int $clinicId): array
{
$qb = $this->createQueryBuilder('a');
if ($clinicId === null) {
$qb->where('a.doctor = :doctor')
->andWhere('a.type = :personal')
->setParameter('doctor', $doctor)
->setParameter('personal', DoctorAddress::TYPE_PERSONAL);
} else {
$qb->where('a.clinicId = :clinicId')
->andWhere('a.type = :clinic')
->setParameter('clinicId', $clinicId)
->setParameter('clinic', DoctorAddress::TYPE_CLINIC);
}
return $qb->orderBy('a.id', 'ASC')->getQuery()->getResult();
}
/** @deprecated آدرس‌های دو محیط را union می‌کند؛ از findForContext() استفاده کن. */
public function findAvailableForDoctor(Doctor $doctor, array $clinicIds): array
{
$qb = $this->createQueryBuilder('a');
@@ -138,6 +138,88 @@ class DoctorRepository extends ServiceEntityRepository
*
* @return int[]
*/
/**
* شهر/استان دسته‌ای پزشکان برای پاسخ لیست — دو کوئری ثابت، نه یکی به‌ازای هر پزشک.
*
* همان قاعده‌ای که فیلتر city_id/state_id در findWithFilters اعمال می‌کند اینجا هم
* برقرار است: اول آدرس شخصی خود پزشک، و اگر نداشت آدرس کلینیکی که عضو آن است
* (آدرس کلینیک ردیفی از DoctorAddress با doctor IS NULL و clinicId پرشده است).
* بدون این fallback، پزشکی که فقط از طریق کلینیک مکان دارد در فیلتر city_id
* می‌آمد ولی در پاسخ شهرش خالی بود.
*
* @param Doctor[] $doctors
* @return array<int, array{city: ?array, province: ?array}>
*/
public function findLocationsByDoctors(array $doctors): array
{
$ids = array_values(array_filter(array_map(fn(Doctor $d) => $d->getId(), $doctors)));
if (!$ids) {
return [];
}
$locationFields = [
'c.id AS cityId', 'c.uuid AS cityUuid', 'c.name AS cityName',
'p.id AS provinceId', 'p.uuid AS provinceUuid', 'p.name AS provinceName',
];
$ownRows = $this->getEntityManager()->createQueryBuilder()
->select('IDENTITY(da.doctor) AS doctorId', ...$locationFields)
->from(DoctorAddress::class, 'da')
->join('da.city', 'c')
->leftJoin('da.province', 'p')
->where('da.doctor IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getArrayResult();
$map = $this->indexLocationRows($ownRows, []);
$missing = array_values(array_diff($ids, array_keys($map)));
if ($missing) {
$clinicRows = $this->getEntityManager()->createQueryBuilder()
->select('cd.id AS doctorId', ...$locationFields)
->from(Clinic::class, 'cl')
->join('cl.doctors', 'cd')
->join(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL')
->join('ca.city', 'c')
->leftJoin('ca.province', 'p')
->where('cd.id IN (:ids)')
->setParameter('ids', $missing)
->getQuery()
->getArrayResult();
$map = $this->indexLocationRows($clinicRows, $map);
}
return $map;
}
/** اولین مکانِ هر پزشک برنده است — پزشک چند-مطبی یک شهر اصلی می‌گیرد. */
private function indexLocationRows(array $rows, array $map): array
{
foreach ($rows as $row) {
$doctorId = (int) $row['doctorId'];
if (isset($map[$doctorId])) {
continue;
}
$map[$doctorId] = [
'city' => [
'uuid' => $row['cityUuid'],
'id' => (string) $row['cityId'],
'name' => $row['cityName'],
'parent' => $row['provinceId'] !== null ? (string) $row['provinceId'] : null,
],
'province' => $row['provinceId'] !== null ? [
'uuid' => $row['provinceUuid'],
'id' => (string) $row['provinceId'],
'name' => $row['provinceName'],
] : null,
];
}
return $map;
}
private function doctorIdsViaClinicLocation(string $field, int $locationId): array
{
$column = $field === 'city' ? 'ca.city' : 'ca.province';