Files
clinicpro/src/Doctor/Entity/Doctor.php
T
hamedandClaude Opus 4.8 d0fbe204a1 fix(doctor): hide deactivated doctors from public site
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>
2026-07-23 19:39:16 +03:30

650 lines
24 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 ?? [],
'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()),
];
}
}