Merge branch 'dev' into main
# Conflicts: # docs/api/doctor.md
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user