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:
@@ -862,6 +862,36 @@ class AdminApiController extends BaseController
|
||||
$patientName = trim($data['patient_name'] ?? '');
|
||||
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
|
||||
|
||||
// سرویسهای نوبت (چند سرویس). `duration_from_services` فقط در نوبتدهی سرویسی
|
||||
// true است و آنگاه slot_end از مجموع مدت سرویسها محاسبه میشود؛ در حالت اسلاتی
|
||||
// سرویسها صرفاً پیوست میشوند و ساعت پایانِ دستی حفظ میماند.
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
|
||||
$serviceItems = [];
|
||||
if (!empty($serviceUuids)) {
|
||||
$itemRepo = $this->em->getRepository(\App\ClinicService\Entity\ServiceItem::class);
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
$item = $itemRepo->findOneBy(['uuid' => $u]);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if ($computeDuration) {
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
}
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
if ($computeDuration) {
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422);
|
||||
}
|
||||
@@ -885,6 +915,9 @@ class AdminApiController extends BaseController
|
||||
$appointment->setPatientName($patientName);
|
||||
$appointment->setPatientMobile($mobile);
|
||||
if (!empty($data['note'])) $appointment->setNote($data['note']);
|
||||
foreach ($serviceItems as $si) {
|
||||
$appointment->addServiceItem($si);
|
||||
}
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
||||
if ($locationId !== null) $appointment->setAddressId($locationId);
|
||||
|
||||
|
||||
@@ -71,8 +71,12 @@ class MyAppointmentsController extends BaseController
|
||||
|
||||
// حالت نوبتدهی سرویسی: مدت نوبت از مجموعِ مدت سرویسهای انتخابشده تعیین
|
||||
// و slot_end سمت سرور محاسبه میشود (به مقدار کلاینت اعتماد نمیشود).
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$serviceItems = [];
|
||||
// `duration_from_services` فقط در نوبتدهی سرویسی true است: آنجا مدت نوبت از
|
||||
// مجموع سرویسها محاسبه و slot_end بازنویسی میشود. در حالت اسلاتی، سرویسها
|
||||
// فقط به نوبت پیوست میشوند و ساعت پایانِ دستی حفظ میشود.
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
|
||||
$serviceItems = [];
|
||||
if (!empty($serviceUuids) && !$isReserve) {
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
@@ -80,16 +84,20 @@ class MyAppointmentsController extends BaseController
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
if ($computeDuration) {
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
if ($computeDuration) {
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || (!$isReserve && $slotEnd <= $slotStart) || empty($mobile) || empty($patientName)) {
|
||||
@@ -140,9 +148,9 @@ class MyAppointmentsController extends BaseController
|
||||
}
|
||||
$appointment->$setter($entity);
|
||||
}
|
||||
// در حالت سرویسی، سرویسِ اصلیِ نوبت = اولین سرویسِ انتخابشده.
|
||||
if (!empty($serviceItems)) {
|
||||
$appointment->setServiceItem($serviceItems[0]);
|
||||
// پیوستِ همهٔ سرویسهای انتخابشده؛ سرویسِ اصلی = اولین سرویس (addServiceItem).
|
||||
foreach ($serviceItems as $si) {
|
||||
$appointment->addServiceItem($si);
|
||||
}
|
||||
if (!empty($data['deposit_required'])) {
|
||||
$appointment->setDepositRequired(true);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user