Files
hamedandClaude Opus 5 9a95bc59d4 fix(appointment): make resource bookings independent of the doctor's calendar
Booking a device is not booking its doctor: the operator runs it and the doctor
only supervises. But bookAtomically locked the doctor row and isSlotTaken checked
overlap against the doctor alone, ignoring which resource was chosen, so a clinic
whose devices share one supervisor could not run two of them at once. Every
tenant in the database is in that position — clinic 2's six resources all point
at doctor 6.

Resource bookings now skip the doctor lock and carry no active_slot_key; their
guarantee comes from resource_occupancy, which understands capacity and seats.
Both direct paths write occupancy rows the way the hold engine already did, so
ResourceBookingSlotService stops being the only thing holding two sources of
truth together, and cancelling releases the seat.

Occupancy is bucketed in five-minute slices, which is coarser than a booking
time: a booking ending 12:35:04 spilled four seconds into the 12:35 bucket and
collided with the next one starting at that same second, despite zero real
overlap. This surfaced on real rows 76 and 77 during backfill. Resource bookings
now snap both ends of their window down to the bucket grid — schedule-driven
slots are already aligned, so only manually entered times move.

The seat is claimed after persist because it needs the appointment id; losing
the race removes the appointment rather than leaving a booking with no device
behind it.

app:appointment:backfill-resource-occupancy gives existing resource-backed
appointments their missing occupancy and clears the doctor keys that no longer
mean anything. It reports conflicts between two old bookings instead of picking
a loser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:57:00 +03:30

584 lines
30 KiB
PHP

<?php
namespace App\Appointment\Entity;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Insurance\Enum\ServiceCategory;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\AppointmentRepository;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: AppointmentRepository::class)]
#[ORM\Table(name: 'appointments')]
// tenant پیشرو — لیست‌های پنل همیشه محیط‌محورند
#[ORM\Index(columns: ['entity_type', 'entity_id', 'slot_start'], name: 'idx_appointments_tenant_slot')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_appointments_tenant_status')]
// بدون tenant — عمدی: یکتاییِ اسلات و تقویم سطح پزشک‌اند، نه محیط
#[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')]
#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')]
#[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')]
class Appointment
{
/**
* محیطِ مالکِ نوبت. denormalization عمدی روی clinic/doctor موجود: فیلترِ خودکارِ
* tenant و ایندکسِ tenant-پیشرو هر دو به ستون واقعی نیاز دارند و با شرطِ
* IF(clinic_id IS NULL, …) ساخته نمی‌شوند.
*/
use TenantOwnedTrait;
// Status machine: pending → confirmed → completed
// ↘ cancelled_by_doctor / cancelled_by_user
// pending → expired (cron)
// confirmed → no_show
// Day-of clinic workflow (Figma نوبت‌ها): confirmed → following_up → salon → completed
public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED_BY_DOCTOR = 'cancelled_by_doctor';
public const STATUS_CANCELLED_BY_USER = 'cancelled_by_user';
public const STATUS_EXPIRED = 'expired';
public const STATUS_NO_SHOW = 'no_show';
public const STATUS_FOLLOWING_UP = 'following_up'; // در حال پیگیری
public const STATUS_SALON = 'salon'; // سالن (در اتاق انتظار)
public const PAYMENT_TTL = 900; // 15 minutes to pay before a pending booking expires
public const ALLOWED_TRANSITIONS = [
self::STATUS_PENDING => [self::STATUS_CONFIRMED, self::STATUS_FOLLOWING_UP, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_EXPIRED],
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_FOLLOWING_UP, self::STATUS_SALON, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
self::STATUS_FOLLOWING_UP => [self::STATUS_CONFIRMED, self::STATUS_SALON, self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
self::STATUS_SALON => [self::STATUS_COMPLETED, self::STATUS_FOLLOWING_UP, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
];
/**
* Statuses in which an appointment occupies its (doctor, slot_start) — kept
* in lockstep with AppointmentRepository::isSlotTaken (a slot is taken only
* by a confirmed booking or a still-live pending one). While occupying, the
* row carries a non-null, unique active_slot_key so two live bookings on the
* same slot cannot coexist even under a race. Every other status (expired,
* completed, no_show, cancelled_*) releases the slot → key NULL.
*/
private const SLOT_OCCUPYING_STATUSES = [
self::STATUS_PENDING,
self::STATUS_CONFIRMED,
];
/**
* Statuses that make a slot unavailable for a *new* booking, as seen by the
* public availability view (AppointmentRepository::isSlotTaken). Broader than
* SLOT_OCCUPYING_STATUSES: besides a live booking, a slot is also spoken for
* once the visit has been consumed (completed / in-progress / no_show). Only
* cancelled_* and expired truly release it. STATUS_PENDING is handled
* separately in the query because it blocks only while not yet expired.
*/
public const SLOT_BLOCKING_STATUSES = [
self::STATUS_CONFIRMED,
self::STATUS_COMPLETED,
self::STATUS_FOLLOWING_UP,
self::STATUS_SALON,
self::STATUS_NO_SHOW,
];
// Optimistic locking
#[ORM\Version]
#[ORM\Column(type: 'integer')]
private int $version = 1;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private Doctor $doctor;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private User $user;
#[ORM\Column(name: 'slot_start', type: 'integer')]
private int $slotStart;
#[ORM\Column(name: 'slot_end', type: 'integer')]
private int $slotEnd;
#[ORM\Column(type: 'string', length: 30)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(name: 'active_slot_key', type: 'string', length: 64, nullable: true, unique: true)]
private ?string $activeSlotKey = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $note = null;
#[ORM\Column(name: 'expires_at', type: 'integer', nullable: true)]
private ?int $expiresAt = null;
#[ORM\Column(name: 'patient_name', type: 'string', length: 150, nullable: true)]
private ?string $patientName = null;
#[ORM\Column(name: 'patient_mobile', type: 'string', length: 20, nullable: true)]
private ?string $patientMobile = null;
#[ORM\Column(name: 'patient_national_code', type: 'string', length: 20, nullable: true)]
private ?string $patientNationalCode = null;
#[ORM\Column(name: 'patient_gender', type: 'string', length: 10, nullable: true)]
private ?string $patientGender = null;
#[ORM\Column(name: 'patient_reason', type: 'text', nullable: true)]
private ?string $patientReason = null;
#[ORM\Column(name: 'address_id', type: 'integer', nullable: true)]
private ?int $addressId = null;
/**
* محیط رزرو: null یعنی مطب شخصی پزشک، مقدار یعنی همان کلینیک. مبنای واحدِ
* تشخیص پرونده — از روی آدرس حدس زده نمی‌شود، چون با چند برنامهٔ هم‌زمان
* حدس‌زدن یعنی چسباندنِ خاموشِ نوبت به پروندهٔ محیط اشتباه.
*/
#[ORM\ManyToOne(targetEntity: \App\Clinic\Entity\Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\Clinic\Entity\Clinic $clinic = null;
#[ORM\Column(name: 'booking_representation_id', type: 'integer', nullable: true)]
private ?int $bookingRepresentationId = null;
// ── Clinic-workflow fields (Figma نوبت‌ها) ────────────────────────────────
/** بخش — clinic service section this appointment belongs to. */
#[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceSection::class)]
#[ORM\JoinColumn(name: 'service_section_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\ClinicService\Entity\ServiceSection $serviceSection = null;
/** سرویس — سرویس اصلی/اولِ نوبت (برای سازگاری با مصرف‌کننده‌های موجود). */
#[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\ClinicService\Entity\ServiceItem $serviceItem = null;
/**
* منبعی که این نوبت رویش رزرو شده — دستگاه، اتاق، یا خودِ پزشک به‌عنوان منبع.
*
* تهی‌پذیر چون نوبت‌های پیش از مدل منبع‌محور منبعی ندارند و migration نباید آن‌ها
* را بشکند. اشغالِ واقعیِ منابع همچنان در `resource_occupancy` است؛ این ستون
* می‌گوید نوبت **برای** کدام منبع گرفته شده، نه اینکه چه چیزهایی اشغال شده‌اند.
*/
#[ORM\ManyToOne(targetEntity: \App\Resource\Entity\ClinicResource::class)]
#[ORM\JoinColumn(name: 'resource_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\Resource\Entity\ClinicResource $resource = null;
/**
* گزینهٔ سرویس («لیزر پا» زیر «لیزر») — همان `ServiceItem` عضو گروه.
*
* جدا از `serviceItem` نگه داشته می‌شود چون مدت و قیمت از ترکیب منبع+سرویس+گزینه
* حل می‌شوند و بدون دانستن گزینه، بازتولید همان عدد ممکن نیست.
*/
#[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_option_item_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\ClinicService\Entity\ServiceItem $serviceOptionItem = null;
/** سرویس‌های نوبت — امکان انتخاب چند سرویس. serviceItem بالا همان سرویسِ اول است. */
#[ORM\ManyToMany(targetEntity: \App\ClinicService\Entity\ServiceItem::class)]
#[ORM\JoinTable(name: 'appointment_service_items')]
private Collection $serviceItems;
/** پرسنل — staff member assigned to the appointment. */
#[ORM\ManyToOne(targetEntity: \App\Staff\Entity\ClinicStaff::class)]
#[ORM\JoinColumn(name: 'staff_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\Staff\Entity\ClinicStaff $staff = null;
/** بیعانه مورد نیاز است. */
#[ORM\Column(name: 'deposit_required', type: 'boolean', options: ['default' => false])]
private bool $depositRequired = false;
#[ORM\Column(name: 'deposit_amount_rials', type: 'integer', nullable: true)]
private ?int $depositAmountRials = null;
#[ORM\Column(name: 'visit_price_rials', type: 'integer', nullable: true)]
private ?int $visitPriceRials = null;
/**
* نوع خدمتِ بیمه‌ایِ این نوبت (سرپایی/بستری) — مبنای انتخاب درصد پوشش.
* null یعنی هنوز انتخاب نشده؛ محاسبه به نوع پیش‌فرضِ tenant برمی‌گردد.
*/
#[ORM\Column(name: 'insurance_service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)]
private ?ServiceCategory $insuranceServiceCategory = null;
/** بیمهٔ پایهٔ انتخاب‌شده؛ ارجاع خام int مثل TenantInsurance. */
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
private ?int $insuranceBaseId = null;
/** بیمهٔ تکمیلی روی باقیماندهٔ بعد از بیمهٔ پایه محاسبه می‌شود، نه روی کل مبلغ. */
#[ORM\Column(name: 'insurance_supplementary_id', type: 'integer', nullable: true)]
private ?int $insuranceSupplementaryId = null;
/**
* Reserve-list entry (نوبت رزرو): booked for a day, not a time slot.
* slotStart/slotEnd hold that day's midnight so date queries keep working.
*/
#[ORM\Column(name: 'is_reserve', type: 'boolean', options: ['default' => false])]
private bool $isReserve = false;
/**
* مدتِ محاسبه‌شدهٔ ترکیب سرویس‌ها در لحظهٔ ثبت — فقط در حالت نوبت‌دهی سرویسی.
*
* `slot_end - slot_start` همین عدد را دارد ولی نمی‌گوید عمدی بود یا دستی؛ و برای نوبت
* رزرو (که `slot_start == slot_end` است) هیچ‌جای دیگری مدت نگه‌داشته نمی‌شود، پس تبدیل
* رزرو به نوبت زمان‌دار بدون این ستون مدت را از دست می‌دهد.
*
* در حالت اسلاتی همیشه NULL می‌ماند.
*/
#[ORM\Column(name: 'service_total_minutes', type: 'smallint', nullable: true)]
private ?int $serviceTotalMinutes = null;
/**
* `buffer_minutes` مؤثر در لحظهٔ ثبت. تغییر بافر در تنظیمات نباید معنای نوبت‌های
* ثبت‌شده را عوض کند (قانون پنجم مستند: هر چیزی که ثبت شد همان‌طور می‌ماند).
*/
#[ORM\Column(name: 'service_buffer_minutes', type: 'smallint', nullable: true)]
private ?int $serviceBufferMinutes = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->doctor = $doctor;
$this->user = $user;
$this->slotStart = $slotStart;
$this->slotEnd = $slotEnd;
$this->createdAt = time();
$this->updatedAt = time();
$this->serviceItems = new ArrayCollection();
$this->refreshActiveSlotKey();
}
/**
* Recompute the unique active-slot key from the current status. Non-null
* while the appointment occupies the slot; null once it is cancelled.
*
* کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط
* جداست (WeeklySchedule با UNIQUE(doctor_id, entity_type, entity_id)) و می‌تواند با محیط
* دیگر هم‌پوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی
* اجازهٔ رزرو هم‌زمان همان پزشک در مطب و کلینیک — نه رفع باگ.
*/
private function refreshActiveSlotKey(): void
{
// Reserve-list entries are day-level wishes, not slot bookings — they
// never occupy a slot, so several reserves may share the same day.
// نوبتِ منبع‌دار کلید نمی‌گیرد: تضمینش از `resource_occupancy` می‌آید که ظرفیت و
// بافر را می‌فهمد، در حالی که این کلید فقط پزشک را می‌شناسد. نگه‌داشتنِ هر دو
// یعنی کلینیکی که چند دستگاه زیر نظر یک پزشک دارد، در هر ساعت فقط یکی‌شان را
// می‌تواند رزرو کند.
$this->activeSlotKey = !$this->isReserve
&& $this->resource === null
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getUser(): User { return $this->user; }
public function getSlotStart(): int { return $this->slotStart; }
public function getSlotEnd(): int { return $this->slotEnd; }
public function getStatus(): string { return $this->status; }
public function getNote(): ?string { return $this->note; }
public function getVersion(): int { return $this->version; }
public function getExpiresAt(): ?int { return $this->expiresAt; }
public function getPatientName(): ?string { return $this->patientName; }
public function getPatientMobile(): ?string { return $this->patientMobile; }
public function getPatientNationalCode(): ?string { return $this->patientNationalCode; }
public function getPatientGender(): ?string { return $this->patientGender; }
public function getPatientReason(): ?string { return $this->patientReason; }
public function getAddressId(): ?int { return $this->addressId; }
public function getClinic(): ?\App\Clinic\Entity\Clinic { return $this->clinic; }
public function getBookingRepresentationId(): ?int { return $this->bookingRepresentationId; }
public function setNote(?string $v): self { $this->note = $v; return $this; }
public function setBookingRepresentationId(?int $v): self { $this->bookingRepresentationId = $v; return $this; }
public function setAddressId(?int $v): self { $this->addressId = $v; return $this; }
public function setClinic(?\App\Clinic\Entity\Clinic $v): self { $this->clinic = $v; return $this; }
public function setPatientName(?string $v): self { $this->patientName = $v; return $this; }
public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; }
public function setPatientNationalCode(?string $v): self { $this->patientNationalCode = $v; return $this; }
public function setPatientGender(?string $v): self { $this->patientGender = $v; return $this; }
public function setPatientReason(?string $v): self { $this->patientReason = $v; return $this; }
public function getServiceSection(): ?\App\ClinicService\Entity\ServiceSection { return $this->serviceSection; }
public function getServiceItem(): ?\App\ClinicService\Entity\ServiceItem { return $this->serviceItem; }
public function getResource(): ?\App\Resource\Entity\ClinicResource { return $this->resource; }
public function setResource(?\App\Resource\Entity\ClinicResource $v): self
{
$this->resource = $v;
$this->updatedAt = time();
// منبع در کلید اسلات اثر دارد، و نوبت معمولاً اول ساخته و بعد منبعش ست
// می‌شود — بدون این، کلیدِ ساخته‌شده در سازنده باقی می‌ماند.
$this->refreshActiveSlotKey();
return $this;
}
public function getServiceOptionItem(): ?\App\ClinicService\Entity\ServiceItem { return $this->serviceOptionItem; }
public function setServiceOptionItem(?\App\ClinicService\Entity\ServiceItem $v): self
{
$this->serviceOptionItem = $v;
$this->updatedAt = time();
return $this;
}
/** @return Collection<int,\App\ClinicService\Entity\ServiceItem> */
public function getServiceItems(): Collection { return $this->serviceItems; }
public function addServiceItem(\App\ClinicService\Entity\ServiceItem $item): self
{
if (!$this->serviceItems->contains($item)) {
$this->serviceItems->add($item);
}
// سرویسِ اصلی = اولین سرویس، تا مصرف‌کننده‌های موجود کار کنند.
if ($this->serviceItem === null) {
$this->serviceItem = $item;
}
return $this;
}
/**
* جایگزینی کامل سرویس‌های نوبت (برای ویرایش و جابه‌جایی سرویس‌آگاه).
*
* برخلاف {@see addServiceItem()}، ستون تکیِ `serviceItem` را **بی‌قید** با اولین عضو
* هم‌گام می‌کند: چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند (`AppointmentsPage`،
* `ReserveAppointmentsPage`، `nobat724_front/services/response.js`،
* `clinic-pro-tauri/src/service/response.js`) و رهاکردنش یعنی نوبت با سرویس‌های جدید
* ولی نامِ سرویس قدیمی در فهرست‌ها. همان الگوی `ServiceItem::setStaffMembers()`.
*
* @param \App\ClinicService\Entity\ServiceItem[] $items
*/
public function replaceServiceItems(array $items): self
{
$this->serviceItems->clear();
foreach ($items as $item) {
if (!$this->serviceItems->contains($item)) {
$this->serviceItems->add($item);
}
}
$this->serviceItem = $items[0] ?? null;
$this->updatedAt = time();
return $this;
}
/**
* uuid سرویس‌های فعلی، به ترتیب. نوبت‌های پیش از چند-سرویسی‌شدن فقط `serviceItem`
* تکی دارند، پس آن هم پوشش داده می‌شود.
*
* @return string[]
*/
public function currentServiceUuids(): array
{
$uuids = array_map(
fn(\App\ClinicService\Entity\ServiceItem $i) => $i->getUuid(),
$this->serviceItems->toArray(),
);
if ($uuids === [] && $this->serviceItem !== null) {
$uuids = [$this->serviceItem->getUuid()];
}
return array_values($uuids);
}
public function getStaff(): ?\App\Staff\Entity\ClinicStaff { return $this->staff; }
public function isDepositRequired(): bool { return $this->depositRequired; }
public function getDepositAmountRials(): ?int { return $this->depositAmountRials; }
public function getVisitPriceRials(): ?int { return $this->visitPriceRials; }
public function getInsuranceServiceCategory(): ?ServiceCategory { return $this->insuranceServiceCategory; }
public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; }
public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; }
public function isReserve(): bool { return $this->isReserve; }
public function getServiceTotalMinutes(): ?int { return $this->serviceTotalMinutes; }
public function getServiceBufferMinutes(): ?int { return $this->serviceBufferMinutes; }
/** هر دو با هم ست می‌شوند: مدت بی‌بافر و بافر بی‌مدت هیچ‌کدام معنا ندارند. */
public function setServiceDuration(?int $totalMinutes, ?int $bufferMinutes): self
{
$this->serviceTotalMinutes = $totalMinutes;
$this->serviceBufferMinutes = $totalMinutes === null ? null : $bufferMinutes;
$this->updatedAt = time();
return $this;
}
public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; }
public function setServiceItem(?\App\ClinicService\Entity\ServiceItem $v): self { $this->serviceItem = $v; return $this; }
public function setStaff(?\App\Staff\Entity\ClinicStaff $v): self { $this->staff = $v; return $this; }
public function setDepositRequired(bool $v): self { $this->depositRequired = $v; return $this; }
public function setDepositAmountRials(?int $v): self { $this->depositAmountRials = $v; return $this; }
public function setVisitPriceRials(?int $v): self { $this->visitPriceRials = $v; return $this; }
public function setInsuranceServiceCategory(?ServiceCategory $v): self { $this->insuranceServiceCategory = $v; $this->updatedAt = time(); return $this; }
public function setInsuranceBaseId(?int $v): self { $this->insuranceBaseId = $v; $this->updatedAt = time(); return $this; }
public function setInsuranceSupplementaryId(?int $v): self { $this->insuranceSupplementaryId = $v; $this->updatedAt = time(); return $this; }
/**
* Move the appointment to a new slot (جا به جایی نوبت) and/or flip its
* reserve flag (انتقال به لیست رزرو و بالعکس). Goes through here — not raw
* setters — so active_slot_key stays consistent with the new slot.
*/
public function rescheduleTo(int $slotStart, int $slotEnd, ?bool $isReserve = null): self
{
$this->slotStart = $slotStart;
$this->slotEnd = $slotEnd;
if ($isReserve !== null) {
$this->isReserve = $isReserve;
}
$this->updatedAt = time();
$this->refreshActiveSlotKey();
return $this;
}
public function markPendingWithTtl(int $ttl): self
{
$this->expiresAt = time() + $ttl;
$this->updatedAt = time();
return $this;
}
/**
* پرداخت موفق: نگه‌داشتِ موقتِ درگاه برداشته می‌شود ولی نوبت «ثبت‌شده» می‌ماند تا
* پزشک/منشی آن را قطعی کند. بدون این، همان قواعد انقضا (پنجرهٔ پرداخت یا گذشتنِ
* ساعت نوبت) نوبتِ پرداخت‌شده را هم منقضی می‌کردند.
*/
public function clearPaymentWindow(): self
{
$this->expiresAt = null;
$this->updatedAt = time();
return $this;
}
/** آیا پنجرهٔ ۱۵ دقیقه‌ایِ پرداخت گذشته یا زمان اسلات رد شده است؟ */
public function isPaymentWindowExpired(int $now): bool
{
if ($this->expiresAt !== null && $now > $this->expiresAt) {
return true;
}
return $now >= $this->slotStart;
}
public function canTransitionTo(string $newStatus): bool
{
return in_array($newStatus, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
}
public function transitionTo(string $newStatus): self
{
if (!$this->canTransitionTo($newStatus)) {
throw new \LogicException(sprintf(
'Cannot transition appointment from "%s" to "%s"',
$this->status, $newStatus
));
}
$this->status = $newStatus;
$this->updatedAt = time();
if ($newStatus !== self::STATUS_PENDING) {
$this->expiresAt = null;
}
$this->refreshActiveSlotKey();
return $this;
}
public function toArray(): array
{
$firstAddress = $this->doctor->getAddresses()->first() ?: null;
return [
'uuid' => $this->uuid,
'doctor' => [
'uuid' => $this->doctor->getUuid(),
'name' => $this->doctor->getName(),
'specialties' => array_map(
fn($s) => ['uuid' => $s->getUuid(), 'name' => $s->getName()],
$this->doctor->getSpecialties()->toArray()
),
],
'address' => $firstAddress?->toArray(),
'address_id' => $this->addressId,
// محلِ نوبت‌دهی این نوبت. null = مطب شخصی. کلاینت بدون این نمی‌داند روش
// نوبت‌دهی را از کدام برنامه بپرسد: یک پزشک می‌تواند در مطب اسلاتی و در
// کلینیک سرویسی باشد و محیط جاریِ پنل لزوماً محیط این نوبت نیست.
'clinic_uuid' => $this->clinic?->getUuid(),
'user' => [
'uuid' => $this->user->getUuid(),
'mobile' => $this->user->getMobileNumber(),
],
'slot_start' => $this->slotStart,
'slot_end' => $this->slotEnd,
'status' => $this->status,
'note' => $this->note,
'expires_at' => $this->expiresAt,
'patient_name' => $this->patientName,
'patient_mobile' => $this->patientMobile,
'patient_national_code' => $this->patientNationalCode,
'patient_gender' => $this->patientGender,
'patient_reason' => $this->patientReason,
'service_section' => $this->serviceSection ? ['uuid' => $this->serviceSection->getUuid(), 'name' => $this->serviceSection->getName()] : null,
'service_item' => $this->serviceItem ? ['uuid' => $this->serviceItem->getUuid(), 'name' => $this->serviceItem->getName()] : null,
// price_rials لازم است تا مودالِ «قطعی کردن نوبت» بتواند هزینه‌ها را قبل از
// ساخته‌شدنِ مراجعه نشان دهد.
'service_items' => array_map(
fn(\App\ClinicService\Entity\ServiceItem $i) => [
'uuid' => $i->getUuid(),
'name' => $i->getName(),
'price_rials' => $i->getPriceRials(),
// نوع خدمت و پرچم پوشش تا مودال بتواند سهم بیمهٔ هر خدمت را
// مثل سرور حساب کند (درصد به‌ازای نوع خدمت است).
'service_category' => $i->getServiceCategory()->value,
'insurance_covered' => $i->isInsuranceCovered(),
],
$this->serviceItems->toArray()
),
'staff' => $this->staff ? ['uuid' => $this->staff->getUuid(), 'full_name' => $this->staff->getFullName()] : null,
'deposit_required' => $this->depositRequired,
'deposit_amount_rials' => $this->depositAmountRials,
'visit_price_rials' => $this->visitPriceRials,
'insurance_service_category' => $this->insuranceServiceCategory?->value,
'insurance_service_category_label' => $this->insuranceServiceCategory?->label(),
'insurance_base_id' => $this->insuranceBaseId,
'insurance_supplementary_id' => $this->insuranceSupplementaryId,
'is_reserve' => $this->isReserve,
// نوبت‌های پیش از مدل منبع‌محور منبع ندارند؛ کلاینت باید با null کنار بیاید.
'resource' => $this->resource === null ? null : [
'uuid' => $this->resource->getUuid(),
'name' => $this->resource->getName(),
'type' => $this->resource->getType()->getCode(),
],
'service_option' => $this->serviceOptionItem === null ? null : [
'uuid' => $this->serviceOptionItem->getUuid(),
'name' => $this->serviceOptionItem->getName(),
],
// فقط در حالت نوبت‌دهی سرویسی پر می‌شوند؛ در حالت اسلاتی null.
'service_total_minutes' => $this->serviceTotalMinutes,
'service_buffer_minutes' => $this->serviceBufferMinutes,
'version' => $this->version,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}