feat: add service-based booking mode to appointment scheduling
- Introduced a new booking mode in WeeklySchedule to support service-based appointments. - Updated SlotCalculatorService to calculate available start times based on selected service durations and buffer times. - Enhanced AppointmentController to handle service items during booking, calculating slot_end on the server side. - Implemented validation to ensure at least one bookable service exists for doctors in service mode. - Added new API endpoint to retrieve available appointment slots based on selected services. - Updated MyAppointmentsController to accept service items during appointment creation. - Modified ServiceItem entity to include a bookable flag, allowing services to be marked for scheduling. - Created migration to add bookable column to service_items table. - Added tests for service-based slot calculations and validation logic.
This commit is contained in:
@@ -136,6 +136,63 @@ class AppointmentController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی سرویسی: زمانهای خالیِ کافی برای مجموعِ مدت سرویسهای انتخابشده.
|
||||
* فقط سرویسهای «نمایش در نوبتدهی» (bookable) و دارای مدت پذیرفته میشوند.
|
||||
*
|
||||
* GET /api/v1/appointment-service-slots?doctor_uuid=..&date=Y-m-d&service_item_uuids[]=..
|
||||
*/
|
||||
#[Route('/api/v1/appointment-service-slots', methods: ['GET'])]
|
||||
public function serviceSlots(Request $request): JsonResponse
|
||||
{
|
||||
$doctorUuid = trim($request->query->get('doctor_uuid', ''));
|
||||
$date = trim($request->query->get('date', ''));
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
||||
}
|
||||
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
|
||||
if ($mode !== WeeklySchedule::MODE_SERVICE) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبتدهی سرویسی نیست', 422);
|
||||
}
|
||||
|
||||
$uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids'))));
|
||||
if (empty($uuids)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
|
||||
}
|
||||
|
||||
$totalMinutes = 0;
|
||||
foreach ($uuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
}
|
||||
|
||||
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'date' => $date,
|
||||
'total_duration_minutes' => $totalMinutes,
|
||||
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
||||
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/appointment-settings/month-availability/{doctorUuid}', methods: ['GET'])]
|
||||
public function monthAvailability(string $doctorUuid, Request $request): JsonResponse
|
||||
{
|
||||
@@ -224,6 +281,29 @@ class AppointmentController extends BaseController
|
||||
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||
|
||||
// حالت نوبتدهی سرویسی: مدت نوبت = مجموع مدت سرویسهای bookableِ انتخابشده،
|
||||
// و slot_end سمت سرور محاسبه میشود (به مقدار کلاینت اعتماد نمیشود).
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$serviceItem = null;
|
||||
if (!empty($serviceUuids)) {
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
$serviceItem ??= $item;
|
||||
}
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422);
|
||||
}
|
||||
@@ -261,6 +341,7 @@ class AppointmentController extends BaseController
|
||||
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||||
$appointment->setPatientNationalCode($nationalCode);
|
||||
$appointment->setPatientGender($gender);
|
||||
if ($serviceItem !== null) $appointment->setServiceItem($serviceItem);
|
||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||
|
||||
// نمایندهی دامنهی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظهی
|
||||
|
||||
@@ -34,8 +34,19 @@ class AppointmentSettingsController extends BaseController
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorAddressRepository $addressRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* در حالت نوبتدهی سرویسی، پزشک باید حداقل یک سرویسِ «نمایش در نوبتدهی»
|
||||
* (bookable) داشته باشد؛ وگرنه هیچ نوبتی قابلمحاسبه نیست.
|
||||
*/
|
||||
private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool
|
||||
{
|
||||
return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE
|
||||
&& $this->itemRepo->countBookableByEntity('doctor', $doctor->getId()) === 0;
|
||||
}
|
||||
|
||||
// ── Weekly Schedule ───────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/appointment-settings/weekly-schedule', methods: ['POST'])]
|
||||
@@ -69,6 +80,10 @@ class AppointmentSettingsController extends BaseController
|
||||
$schedule->setMeta($data['meta']);
|
||||
}
|
||||
|
||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است', 422, 'booking_mode');
|
||||
}
|
||||
|
||||
$this->scheduleRepo->save($schedule);
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()], 201);
|
||||
@@ -103,6 +118,10 @@ class AppointmentSettingsController extends BaseController
|
||||
$schedule->setMeta($data['meta']);
|
||||
}
|
||||
|
||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor())) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است', 422, 'booking_mode');
|
||||
}
|
||||
|
||||
$this->scheduleRepo->save($schedule);
|
||||
|
||||
return $this->success(['data' => $schedule->toArray()]);
|
||||
|
||||
@@ -69,6 +69,29 @@ class MyAppointmentsController extends BaseController
|
||||
$slotEnd = $slotStart;
|
||||
}
|
||||
|
||||
// حالت نوبتدهی سرویسی: مدت نوبت از مجموعِ مدت سرویسهای انتخابشده تعیین
|
||||
// و slot_end سمت سرور محاسبه میشود (به مقدار کلاینت اعتماد نمیشود).
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$serviceItems = [];
|
||||
if (!empty($serviceUuids) && !$isReserve) {
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
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 (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || (!$isReserve && $slotEnd <= $slotStart) || empty($mobile) || empty($patientName)) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'همه فیلدها الزامی است', 422);
|
||||
}
|
||||
@@ -117,6 +140,10 @@ class MyAppointmentsController extends BaseController
|
||||
}
|
||||
$appointment->$setter($entity);
|
||||
}
|
||||
// در حالت سرویسی، سرویسِ اصلیِ نوبت = اولین سرویسِ انتخابشده.
|
||||
if (!empty($serviceItems)) {
|
||||
$appointment->setServiceItem($serviceItems[0]);
|
||||
}
|
||||
if (!empty($data['deposit_required'])) {
|
||||
$appointment->setDepositRequired(true);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,16 @@ class WeeklySchedule
|
||||
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
||||
|
||||
public const META_KEY = 'meta';
|
||||
|
||||
public const MODE_SLOT = 'slot'; // نوبتدهی اسلاتی (رفتار پیشفرض)
|
||||
public const MODE_SERVICE = 'service'; // نوبتدهی بر اساس مدت سرویس
|
||||
|
||||
public const DEFAULT_META = [
|
||||
'online_booking_enabled' => true,
|
||||
'booking_window_value' => 1,
|
||||
'booking_window_unit' => 'month',
|
||||
'booking_mode' => self::MODE_SLOT,
|
||||
'buffer_minutes' => 0,
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
@@ -82,6 +88,10 @@ class WeeklySchedule
|
||||
'booking_window_unit' => in_array($meta['booking_window_unit'] ?? null, ['week', 'month'], true)
|
||||
? $meta['booking_window_unit']
|
||||
: $current['booking_window_unit'],
|
||||
'booking_mode' => in_array($meta['booking_mode'] ?? null, [self::MODE_SLOT, self::MODE_SERVICE], true)
|
||||
? $meta['booking_mode']
|
||||
: $current['booking_mode'],
|
||||
'buffer_minutes' => max(0, (int)($meta['buffer_minutes'] ?? $current['buffer_minutes'])),
|
||||
];
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\DBAL\LockMode;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -37,6 +38,13 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
$start = $appointment->getSlotStart();
|
||||
$end = $appointment->getSlotEnd();
|
||||
|
||||
// قفلِ per-doctor (SELECT ... FOR UPDATE روی ردیف پزشک): رزروهای
|
||||
// همزمانِ یک پزشک را سریالایز میکند. در حالت نوبتدهی سرویسی که
|
||||
// نوبتها طول متغیر و شروعِ متفاوت دارند، unique-keyِ (doctor,slot_start)
|
||||
// تداخلِ بازهایِ دو رزروِ همزمان را نمیگیرد؛ این قفل تضمین میکند
|
||||
// بررسیِ isSlotTaken و insert بهصورت اتمیک نسبت به سایر رزروها انجام شود.
|
||||
$em->lock($doctor, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
if ($this->isSlotTaken($doctor, $start, $end)) {
|
||||
throw new SlotTakenException();
|
||||
}
|
||||
@@ -87,6 +95,37 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* بازههای اشغالشدهٔ یک پزشک در پنجرهٔ [$from, $to) — برای محاسبهٔ زمانِ خالی
|
||||
* در حالت نوبتدهی سرویسی. همان معیارِ isSlotTaken (blocking یا pendingِ زنده)،
|
||||
* ولی نوبتهای «آزاد» (is_reserve) هیچ بازهای اشغال نمیکنند.
|
||||
*
|
||||
* @return array<array{start:int,end:int}> مرتبشده بر اساس start
|
||||
*/
|
||||
public function findBusyIntervals(Doctor $doctor, int $from, int $to): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('a')
|
||||
->select('a.slotStart AS start, a.slotEnd AS end')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.isReserve = false')
|
||||
->andWhere('a.slotStart < :to')
|
||||
->andWhere('a.slotEnd > :from')
|
||||
->andWhere(
|
||||
'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
|
||||
)
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES)
|
||||
->setParameter('pending', Appointment::STATUS_PENDING)
|
||||
->setParameter('now', time())
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->orderBy('a.slotStart', 'ASC')
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
return array_map(fn($r) => ['start' => (int) $r['start'], 'end' => (int) $r['end']], $rows);
|
||||
}
|
||||
|
||||
/** Check if a slot is already taken (confirmed or pending) */
|
||||
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
|
||||
{
|
||||
|
||||
@@ -80,6 +80,74 @@ class SlotCalculatorService
|
||||
return !empty($this->buildAllSessions($doctor, $date));
|
||||
}
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی سرویسی: زمانهای شروعِ ممکن برای نوبتی به طول $durationMinutes
|
||||
* در یک روز. برخلاف اسلاتِ ثابت، فضای خالی داخل هر session را با توجه به مدت
|
||||
* سرویس (+ بافر) پُر میکند: از ابتدای window شروع، بازههای اشغالشده را رد
|
||||
* میکند و اولین جای پیوستهٔ کافی را برمیگرداند، سپس نوبتهای بعدی را پشتسرهم
|
||||
* (با فاصلهٔ بافر) میچیند.
|
||||
*
|
||||
* زمان پایانِ ذخیرهشدهٔ نوبت = start + duration (بدون بافر)؛ بافر فقط فاصلهٔ
|
||||
* بین دو نوبت است، پس candidate بعدی از start + duration + buffer شروع میشود.
|
||||
*
|
||||
* @return array<array{start:int,end:int,start_time:string,end_time:string,location_id:?int}>
|
||||
*/
|
||||
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes): array
|
||||
{
|
||||
if ($durationMinutes <= 0) return [];
|
||||
|
||||
$buffer = (int)($this->getBookingMeta($doctor)['buffer_minutes'] ?? 0);
|
||||
$durSec = $durationMinutes * 60;
|
||||
$needSec = $durSec + $buffer * 60; // فضای لازم شامل بافر
|
||||
|
||||
$sessions = $this->buildAllSessions($doctor, $date); // window/holiday/override/booking-window رعایت میشود
|
||||
if (empty($sessions)) return [];
|
||||
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
$busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400);
|
||||
$now = time();
|
||||
|
||||
$result = [];
|
||||
foreach ($sessions as $session) {
|
||||
$winStart = $dayStart + $this->parseTime($session['start_time'] ?? '00:00');
|
||||
$winEnd = $dayStart + $this->parseTime($session['end_time'] ?? '00:00');
|
||||
$locationId = $session['slots'][0]['location_id'] ?? null;
|
||||
|
||||
$t = max($winStart, $now);
|
||||
while ($t + $durSec <= $winEnd) {
|
||||
$end = $t + $durSec;
|
||||
$conflict = $this->firstOverlap($t, $t + $needSec, $busy);
|
||||
if ($conflict !== null) {
|
||||
$t = $conflict; // به انتهای بازهٔ اشغالشدهٔ متداخل بپر
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'start' => $t,
|
||||
'end' => $end,
|
||||
'start_time' => date('H:i', $t),
|
||||
'end_time' => date('H:i', $end),
|
||||
'location_id' => $locationId !== null ? (int) $locationId : null,
|
||||
];
|
||||
$t += $needSec; // نوبت بعدی پس از این نوبت + بافر
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* انتهای اولین بازهٔ اشغالشدهای که با [$start, $end) تداخل دارد، یا null.
|
||||
* @param array<array{start:int,end:int}> $busy
|
||||
*/
|
||||
private function firstOverlap(int $start, int $end, array $busy): ?int
|
||||
{
|
||||
foreach ($busy as $b) {
|
||||
if ($b['start'] < $end && $b['end'] > $start) {
|
||||
return $b['end'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking is allowed only when online booking is enabled and the date is
|
||||
* today..(today + window). Past dates are always rejected.
|
||||
|
||||
@@ -176,6 +176,9 @@ class ClinicServiceController extends BaseController
|
||||
$dm = $data['duration_minutes'];
|
||||
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
|
||||
}
|
||||
if (array_key_exists('bookable', $data)) {
|
||||
$item->setBookable((bool) $data['bookable']);
|
||||
}
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
@@ -217,6 +220,9 @@ class ClinicServiceController extends BaseController
|
||||
$dm = $data['duration_minutes'];
|
||||
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
|
||||
}
|
||||
if (array_key_exists('bookable', $data)) {
|
||||
$item->setBookable((bool) $data['bookable']);
|
||||
}
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ class ServiceItem
|
||||
#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)]
|
||||
private ?int $durationMinutes = null;
|
||||
|
||||
/** نمایش این سرویس در نوبتدهی (پزشک ممکن است همهٔ سرویسها را ارائه ندهد). */
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
private bool $bookable = false;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -85,6 +89,7 @@ class ServiceItem
|
||||
public function isInsuranceCovered(): bool { return $this->insuranceCovered; }
|
||||
public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; }
|
||||
public function getDurationMinutes(): ?int { return $this->durationMinutes; }
|
||||
public function isBookable(): bool { return $this->bookable; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
@@ -122,6 +127,7 @@ class ServiceItem
|
||||
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
@@ -151,6 +157,7 @@ class ServiceItem
|
||||
'insurance_covered' => $this->insuranceCovered,
|
||||
'insurance_price_rials' => $this->insurancePriceRials,
|
||||
'duration_minutes' => $this->durationMinutes,
|
||||
'bookable' => $this->bookable,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
|
||||
@@ -57,6 +57,25 @@ class ServiceItemRepository extends ServiceEntityRepository
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* تعداد سرویسهای فعالِ «نمایش در نوبتدهی» (bookable) متعلق به یک entity
|
||||
* (پزشک/کلینیک) — از طریق section.entityType/entityId. برای اجبارِ حالت سرویس.
|
||||
*/
|
||||
public function countBookableByEntity(string $entityType, int $entityId): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('i')
|
||||
->select('COUNT(i.id)')
|
||||
->join('i.section', 's')
|
||||
->where('s.entityType = :type')
|
||||
->andWhere('s.entityId = :id')
|
||||
->andWhere('i.bookable = true')
|
||||
->andWhere('i.active = true')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function save(ServiceItem $item): void
|
||||
{
|
||||
$this->getEntityManager()->persist($item);
|
||||
|
||||
Reference in New Issue
Block a user