The public list GET /api/v1/doctors only excluded inactive doctors when an explicit `active` filter was passed; with no param it returned everyone (deactivated doctors just ranked lower). Deactivated doctors (admin toggled active_doctor_appointment off) leaked onto nobat724. - DoctorRepository::findWithFilters: default (no `active` param) now filters activeDoctorAppointment = true. The active=1 (bookable) and active=0 (admin, inactive-only) escape hatches are unchanged. - Doctor::toDetailArray: expose raw `is_active` (= activeDoctorAppointment, independent of schedule) so public clients can 404 a deactivated doctor's profile page; distinct from `active` (flag && has_schedule). - Tests + docs/api/doctor.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
650 lines
24 KiB
PHP
650 lines
24 KiB
PHP
<?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 ?? [],
|
||
'specialties' => array_map(fn(Specialty $s) => [
|
||
'uuid' => $s->getUuid(),
|
||
'id' => (string) $s->getId(),
|
||
'name' => $s->getName(),
|
||
], $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()),
|
||
];
|
||
}
|
||
}
|