feat: support multiple services per appointment (checkbox selection)

Appointments could only reference a single service (ManyToOne). Add an
appointment_service_items join table (ManyToMany) so an appointment can
carry several services; the first stays the primary service_item for
backward compatibility, and toArray now also returns service_items[].

Both create endpoints (my/appointment, admin/appointment) accept
service_item_uuids[] and attach all of them. A new duration_from_services
flag gates the slot_end recompute: service-booking mode sends it true
(slot_end = start + Σ durations); slot mode omits it so the manual end
time is preserved. The admin endpoint previously ignored services entirely.

Frontend: in slot mode the single service dropdown becomes a checkbox list
filtered by the selected section (multi-select); service mode sends the
duration flag. Migration + backend/entity tests + docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 10:10:47 +03:30
co-authored by Claude Opus 4.8
parent d76fd73529
commit 4642506c0f
9 changed files with 315 additions and 65 deletions
+28 -1
View File
@@ -4,6 +4,8 @@ namespace App\Appointment\Entity;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
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;
@@ -135,11 +137,16 @@ class Appointment
#[ORM\JoinColumn(name: 'service_section_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\ClinicService\Entity\ServiceSection $serviceSection = null;
/** سرویس — concrete service item to perform. */
/** سرویس — سرویس اصلی/اولِ نوبت (برای سازگاری با مصرف‌کننده‌های موجود). */
#[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;
/** سرویس‌های نوبت — امکان انتخاب چند سرویس. 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')]
@@ -174,6 +181,7 @@ class Appointment
$this->slotEnd = $slotEnd;
$this->createdAt = time();
$this->updatedAt = time();
$this->serviceItems = new ArrayCollection();
$this->refreshActiveSlotKey();
}
@@ -219,6 +227,21 @@ class Appointment
public function getServiceSection(): ?\App\ClinicService\Entity\ServiceSection { return $this->serviceSection; }
public function getServiceItem(): ?\App\ClinicService\Entity\ServiceItem { return $this->serviceItem; }
/** @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;
}
public function getStaff(): ?\App\Staff\Entity\ClinicStaff { return $this->staff; }
public function isDepositRequired(): bool { return $this->depositRequired; }
public function getDepositAmountRials(): ?int { return $this->depositAmountRials; }
@@ -317,6 +340,10 @@ class Appointment
'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,
'service_items' => array_map(
fn(\App\ClinicService\Entity\ServiceItem $i) => ['uuid' => $i->getUuid(), 'name' => $i->getName()],
$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,