Files
clinicpro/src/Doctor/Entity/Doctor.php
T
hamedandClaude Opus 5 2da5b5188c feat(doctors): search every specialty a doctor has, and expose the tree
`GET /api/v1/doctors` could not answer either question the public search box
asks. Typing a specialty name returned nothing, because `name` only matched
`d.name`. And `specialty_id` matched one id exactly, so a parent group only
found doctors who happened to carry the parent — which they usually do, but
only as a side effect of `expandWithAncestors` running on save. A doctor
imported through any other path has no denormalised parent, and a search
guarantee resting on a save-time side effect is not a guarantee.

`expandWithDescendants` mirrors the existing ancestor walk over the same cached
parentMap, so no extra query. It deliberately keeps unknown ids instead of
dropping them like its mirror does: the result feeds an `IN (...)`, and an empty
array turns the filter into a no-op that returns every doctor — an unknown id
must mean "nothing", never "everything".

Both specialty filters use their own EXISTS alias rather than the shared `s`
join. Two conditions on one alias force a single join row to satisfy both, so a
doctor filtered by specialty A while searching the name of specialty B was
silently dropped. Verified by reverting to the shared alias and watching
testFilterOnOneSpecialtyWhileSearchingTheNameOfAnother fail.

toListArray now carries specialties[].parent_id so a client can tell the main
specialty from a sub-specialty instead of printing all of them. It is a string,
matching toDetailArray and the sibling `id` key — one concept should not have
two types across two endpoints. Reading the id off the parent proxy costs no
query; measured 6→11 queries with four more doctors both with and without the
field. That growth is a pre-existing N+1 (findWithFilters does not fetch-join
specialties, unlike findByClinic) and is left untouched here.

Also drops the phantom `search` parameter from the OpenAPI annotation — it was
advertised but never read, so a client sending it got an unfiltered list — and
documents the six live parameters that were missing.

Note for deploy: DoctorRepository gained a constructor argument, so a stale
container fails with ArgumentCountError until cache:clear runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:44:32 +03:30

657 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Doctor\Entity;
use App\Appointment\Entity\WeeklySchedule;
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\Shared\Util\PersianText;
use App\Specialty\Entity\Specialty;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use App\Doctor\Repository\DoctorRepository;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: DoctorRepository::class)]
#[ORM\Table(name: 'doctors')]
#[ORM\UniqueConstraint(name: 'idx_doctors_user', columns: ['user_id'])]
#[ORM\UniqueConstraint(name: 'uniq_doctors_source_code', columns: ['source', 'medical_system_code'])]
#[ORM\UniqueConstraint(name: 'uniq_doctors_source_profile', columns: ['source', 'source_profile_id'])]
#[ORM\Index(columns: ['active_doctor_appointment'], name: 'idx_doctors_active')]
#[ORM\Index(columns: ['owner_status'], name: 'idx_doctors_owner')]
class Doctor
{
public const DEGREES = ['expert', 'general', 'specialist', 'subspecialistplus'];
public const GENDERS = ['man', 'woman'];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\OneToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private User $user;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 10, nullable: true)]
private ?string $gender = null;
#[ORM\Column(name: 'medical_system_code', type: 'string', length: 25, nullable: true)]
private ?string $medicalSystemCode = null;
#[ORM\Column(name: 'mobile_number', type: 'string', length: 15, nullable: true)]
private ?string $mobileNumber = null;
#[ORM\Column(name: 'activity_time', type: 'integer', nullable: true)]
private ?int $activityTime = null;
#[ORM\Column(type: 'string', length: 30, nullable: true)]
private ?string $degree = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $info = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $images = null;
#[ORM\Column(name: 'social_media', type: 'json', nullable: true)]
private ?array $socialMedia = null;
#[ORM\Column(name: 'doctor_rate', type: 'float')]
private float $doctorRate = 3.5;
#[ORM\Column(name: 'doctor_rate_percentage', type: 'float')]
private float $doctorRatePercentage = 60.0;
#[ORM\Column(name: 'active_doctor_appointment', type: 'boolean')]
private bool $activeDoctorAppointment = true;
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
private ?int $representationId = null;
#[ORM\Column(name: 'notification_mobile', type: 'string', length: 15, nullable: true)]
private ?string $notificationMobile = null;
// ── Profile ownership (IRIMC import) ───────────────────────────────────────
// owner_status: claimed | unclaimed | pending_transfer
#[ORM\Column(name: 'owner_status', type: 'string', length: 20, options: ['default' => 'claimed'])]
private string $ownerStatus = 'claimed';
// source: manual | irimc
#[ORM\Column(type: 'string', length: 20, options: ['default' => 'manual'])]
private string $source = 'manual';
// شناسه رکورد مبدأ (profile_url یا کد نظام پزشکی) برای idempotency و ممیزی
#[ORM\Column(name: 'source_ref', type: 'string', length: 100, nullable: true)]
private ?string $sourceRef = null;
// شناسهٔ پایدارِ پروفایل مبدأ (UUID داخل source_ref) — شناسهٔ authoritative نظام
// پزشکی و کلید اصلی idempotency: یک پروفایل هرگز دو رکورد نمی‌سازد حتی اگر کدش عوض شود.
#[ORM\Column(name: 'source_profile_id', type: 'string', length: 36, nullable: true)]
private ?string $sourceProfileId = null;
// شناسه کاربری که این پروفایلِ بدون‌مالک را وارد/مدیریت کرده (مثلاً کاربر سیستمی)
#[ORM\Column(name: 'managed_by', type: 'integer', nullable: true)]
private ?int $managedBy = null;
#[ORM\Column(name: 'claimed_at', type: 'integer', nullable: true)]
private ?int $claimedAt = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\ManyToMany(targetEntity: Specialty::class)]
#[ORM\JoinTable(
name: 'doctor_specialties',
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'specialty_id', referencedColumnName: 'id')]
)]
private Collection $specialties;
#[ORM\ManyToMany(targetEntity: DoctorService::class)]
#[ORM\JoinTable(
name: 'doctor_expertise',
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'service_id', referencedColumnName: 'id')]
)]
private Collection $services;
#[ORM\ManyToMany(targetEntity: Province::class)]
#[ORM\JoinTable(
name: 'doctor_provinces',
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'province_id', referencedColumnName: 'id')]
)]
private Collection $provinces;
#[ORM\ManyToMany(targetEntity: City::class)]
#[ORM\JoinTable(
name: 'doctor_cities',
joinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'city_id', referencedColumnName: 'id')]
)]
private Collection $cities;
#[ORM\OneToMany(targetEntity: DoctorAddress::class, mappedBy: 'doctor', cascade: ['remove'])]
private Collection $addresses;
public function __construct(User $user, string $name)
{
DisplayName::assertReal($name);
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->name = $name;
$this->createdAt = time();
$this->updatedAt = time();
$this->specialties = new ArrayCollection();
$this->services = new ArrayCollection();
$this->provinces = new ArrayCollection();
$this->cities = new ArrayCollection();
$this->addresses = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getUser(): User
{
return $this->user;
}
public function getName(): string
{
return $this->name;
}
public function getGender(): ?string
{
return $this->gender;
}
public function getMedicalSystemCode(): ?string
{
return $this->medicalSystemCode;
}
public function getMobileNumber(): ?string
{
return $this->mobileNumber;
}
public function getActivityTime(): ?int
{
return $this->activityTime;
}
public function getDegree(): ?string
{
return $this->degree;
}
public function getInfo(): ?string
{
return $this->info;
}
public function getImages(): ?array
{
return $this->images;
}
public function getSocialMedia(): ?array
{
return $this->socialMedia;
}
public function getDoctorRate(): float
{
return $this->doctorRate;
}
public function getDoctorRatePercentage(): float
{
return $this->doctorRatePercentage;
}
public function isActiveDoctorAppointment(): bool
{
return $this->activeDoctorAppointment;
}
public function getRepresentationId(): ?int
{
return $this->representationId;
}
public function getNotificationMobile(): ?string
{
return $this->notificationMobile;
}
public function getOwnerStatus(): string
{
return $this->ownerStatus;
}
public function getSource(): string
{
return $this->source;
}
public function getSourceRef(): ?string
{
return $this->sourceRef;
}
public function getSourceProfileId(): ?string
{
return $this->sourceProfileId;
}
public function getManagedBy(): ?int
{
return $this->managedBy;
}
public function getClaimedAt(): ?int
{
return $this->claimedAt;
}
public function getCreatedAt(): int
{
return $this->createdAt;
}
public function getUpdatedAt(): int
{
return $this->updatedAt;
}
public function getSpecialties(): Collection
{
return $this->specialties;
}
public function getServices(): Collection
{
return $this->services;
}
public function getProvinces(): Collection
{
return $this->provinces;
}
public function getCities(): Collection
{
return $this->cities;
}
public function getAddresses(): Collection
{
return $this->addresses;
}
public function setName(string $v): self
{
DisplayName::assertReal($v);
$this->name = $v;
return $this;
}
public function setGender(?string $v): self
{
$this->gender = $v;
$this->touch();
return $this;
}
public function setMedicalSystemCode(?string $v): self
{
$this->medicalSystemCode = $v;
$this->touch();
return $this;
}
public function setMobileNumber(?string $v): self
{
$this->mobileNumber = $v;
$this->touch();
return $this;
}
public function setActivityTime(?int $v): self
{
$this->activityTime = $v;
$this->touch();
return $this;
}
public function setDegree(?string $v): self
{
$this->degree = $v;
$this->touch();
return $this;
}
public function setInfo(?string $v): self
{
$this->info = $v;
$this->touch();
return $this;
}
public function setImages(?array $v): self
{
$this->images = $v;
$this->touch();
return $this;
}
public function setSocialMedia(?array $v): self
{
$this->socialMedia = $v;
$this->touch();
return $this;
}
public function setDoctorRate(float $v): self
{
$this->doctorRate = $v;
$this->touch();
return $this;
}
public function setDoctorRatePercentage(float $v): self
{
$this->doctorRatePercentage = $v;
$this->touch();
return $this;
}
public function setActiveDoctorAppointment(bool $v): self
{
$this->activeDoctorAppointment = $v;
$this->touch();
return $this;
}
public function setRepresentationId(?int $v): self
{
$this->representationId = $v;
$this->touch();
return $this;
}
public function setNotificationMobile(?string $v): self
{
$this->notificationMobile = $v;
$this->touch();
return $this;
}
public function setOwnerStatus(string $v): self
{
$this->ownerStatus = $v;
$this->touch();
return $this;
}
public function setSource(string $v): self
{
$this->source = $v;
$this->touch();
return $this;
}
public function setSourceRef(?string $v): self
{
$this->sourceRef = $v;
$this->touch();
return $this;
}
public function setSourceProfileId(?string $v): self
{
$this->sourceProfileId = $v;
$this->touch();
return $this;
}
public function setManagedBy(?int $v): self
{
$this->managedBy = $v;
$this->touch();
return $this;
}
public function setClaimedAt(?int $v): self
{
$this->claimedAt = $v;
$this->touch();
return $this;
}
/**
* انتقال مالکیت پروفایلِ بدون‌مالک به کاربر واقعی پزشک.
* user_id را پر می‌کند، مدیریت سیستمی را برمی‌دارد و وضعیت را claimed می‌کند.
*/
public function transferOwnershipTo(User $user): self
{
$this->user = $user;
$this->managedBy = null;
$this->ownerStatus = 'claimed';
$this->claimedAt = time();
$this->touch();
return $this;
}
/** امتیاز فقط برای پروفایل claimed واقعی است؛ unclaimed/pending_transfer مقدار پیش‌فرض جعلی دارد. */
public function hasPublicRating(): bool
{
return $this->ownerStatus === 'claimed';
}
private function touch(): void
{
$this->updatedAt = time();
}
private const DAY_NAMES = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
private const APPOINTMENT_DISABLED_LABEL = 'نوبت‌دهی آنلاین غیرفعال است';
/**
* وضعیت نوبت‌دهی از دید سایت عمومی، تجمیع‌شده روی همهٔ برنامه‌های پزشک
* (شخصی + هر کلینیک). برنامهٔ شخصیِ خاموش نباید برنامهٔ کلینیکیِ روشن را بپوشاند.
*
* @param WeeklySchedule[] $schedules
*/
private function computeScheduleFields(array $schedules): array
{
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
$dayTimes = [];
for ($i = 0; $i < 7; $i++) {
$activeSessions = array_values(array_filter(
$setting[$i]['sessions'] ?? [],
fn($s) => ($s['active'] ?? false) && !empty($s['start_time'])
));
$dayTimes[$i] = empty($activeSessions)
? null
: implode(' و ', array_map(fn($s) => $s['start_time'] . '' . $s['end_time'], $activeSessions));
}
$hasAnyDay = array_filter($dayTimes) !== [];
if (!$hasAnyDay) {
return ['free_turn' => 'نوبت آزادی موجود نیست', 'hours_of_work' => 'برنامه کاری تنظیم نشده', 'has_schedule' => false, 'rank' => [7, '99:99']];
}
// گروه‌بندی روزهای متوالی با ساعت یکسان
// مثال: شنبه–پنجشنبه ۹–۱۳ و ۱۴–۱۸ | جمعه تعطیل
$groups = [];
$current = ['start' => 0, 'times' => $dayTimes[0]];
for ($i = 1; $i < 7; $i++) {
if ($dayTimes[$i] === $current['times']) {
continue; // ادامه همان گروه
}
if ($current['times'] !== null) {
$groups[] = ['start' => $current['start'], 'end' => $i - 1, 'times' => $current['times']];
}
$current = ['start' => $i, 'times' => $dayTimes[$i]];
}
if ($current['times'] !== null) {
$groups[] = ['start' => $current['start'], 'end' => 6, 'times' => $current['times']];
}
$parts = [];
foreach ($groups as $g) {
$parts[] = $g['start'] === $g['end']
? self::DAY_NAMES[$g['start']]
: self::DAY_NAMES[$g['start']] . ' تا ' . self::DAY_NAMES[$g['end']];
}
// نزدیک‌ترین روز کاری — PHP date('w'): 0=Sun,6=Sat → ایندکس ایرانی: 0=Sat,...,6=Fri
$phpDay = (int) date('w');
$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;
}
}
return [
'free_turn' => $freeTurn ?? 'نوبت آزادی موجود نیست',
'hours_of_work' => implode(' | ', $parts),
'has_schedule' => true,
// فاصله تا نزدیک‌ترین روز کاری + ساعت شروع — برای مقایسهٔ بین برنامه‌ها
'rank' => $rank,
];
}
public function getExperience(): int
{
if ($this->activityTime === null) {
return 0;
}
return max(0, (int)((time() - $this->activityTime) / (365.25 * 24 * 3600)));
}
/** @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($schedules);
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
// نامِ آمادهٔ نمایش با عنوان «دکتر»؛ name خام می‌ماند (برای فرم ویرایش).
'display_name' => PersianText::withDoctorTitle($this->name),
'gender' => $this->gender,
'degree' => $this->degree,
'img' => $this->images ?? [],
// parent_id لازم است تا کلاینت «تخصص اصلی» را از زیرتخصص تشخیص دهد؛
// بدون آن کارت پزشک ناچار است همهٔ نام‌ها را پشت‌سرهم چاپ کند.
//
// رشته است نه عدد، تا با toDetailArray هم‌شکل بماند — کلاینت نباید برای
// یک مفهوم دو قاعدهٔ نوع بنویسد. خواندنِ شناسه از proxy والد کوئری اضافه
// نمی‌زند؛ شناسه از قبل معلوم است.
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(),
'id' => (string) $s->getId(),
'name' => $s->getName(),
'parent_id' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null,
], $this->specialties->toArray()),
'satisfaction' => $this->hasPublicRating() ? (string) $this->doctorRatePercentage : null,
'point' => $this->hasPublicRating() ? (string) $this->doctorRate : null,
'free_turn' => $sf['free_turn'],
'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']] : [],
];
}
/** @param WeeklySchedule[] $schedules همهٔ برنامه‌های پزشک (شخصی + کلینیک‌ها) */
public function toDetailArray(array $schedules = []): array
{
$sf = $this->computeScheduleFields($schedules);
return [
'id' => (string) $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
// نامِ آمادهٔ نمایش با عنوان «دکتر»؛ name خام می‌ماند (برای فرم ویرایش).
'display_name' => PersianText::withDoctorTitle($this->name),
'gender' => $this->gender,
'experience' => $this->getExperience(),
'activity_time' => $this->activityTime !== null ? (string) $this->activityTime : null,
'medical_system_code' => $this->medicalSystemCode,
'detail' => $this->info,
'degree' => $this->degree,
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(),
'id' => (string) $s->getId(),
'name' => $s->getName(),
'parent_id' => $s->getParent()?->getId() !== null ? (string) $s->getParent()->getId() : null,
], $this->specialties->toArray()),
'active' => $this->activeDoctorAppointment && $sf['has_schedule'],
// فلگ خام فعال/غیرفعال بودن پزشک (مستقل از داشتن برنامه) — کلاینت عمومی
// مثل nobat724 با این می‌تواند صفحهٔ پزشک غیرفعال را 404 کند.
'is_active' => $this->activeDoctorAppointment,
'img' => $this->images ?? [],
'social_media' => $this->socialMedia,
'expertise' => array_map(fn(DoctorService $ds) => [
'uuid' => $ds->getUuid(),
'id' => (string) $ds->getId(),
'name' => $ds->getName(),
], $this->services->toArray()),
'satisfaction' => $this->hasPublicRating() ? (string) $this->doctorRatePercentage : null,
'point' => $this->hasPublicRating() ? (string) $this->doctorRate : null,
'owner_status' => $this->ownerStatus,
'free_turn' => $sf['free_turn'],
'hours_of_work' => $sf['hours_of_work'],
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()),
'average_rate' => ['total_rates' => null],
'state' => array_map(fn(Province $p) => [
'uuid' => $p->getUuid(),
'id' => (string) $p->getId(),
'name' => $p->getName(),
], $this->provinces->toArray()),
'city' => array_map(fn(City $c) => [
'uuid' => $c->getUuid(),
'id' => (string) $c->getId(),
'name' => $c->getName(),
'parent' => $c->getProvince() !== null ? (string) $c->getProvince()->getId() : null,
], $this->cities->toArray()),
];
}
}