'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()), ]; } }