refactor(booking): extract ServiceBookingCalculator from the controller

"Allowed duration of a service combination" lived inside
AppointmentController::serviceSlots(). Three upcoming callers need the same
computation (PATCH duration validation, service-aware reschedule, reserve
conversion); copying it would mean four variants with four different edge-case
behaviours.

The extraction is behaviour-preserving: BaseController::error() and
ExceptionSubscriber emit an identical envelope, so returning $this->error() was
replaced by throwing AppException with the same code/message/field.

Tenant ownership now goes through TenantOwnershipChecker::belongsToPair() (the
documented single point) instead of an inline section pair comparison. The repo
property is named itemRepo on purpose: TenantLookupInventoryTest only counts
recognised property names, so any other name would slip past the safety net.

The naive duration sum is kept deliberately — switching to solo/additional
minutes is task 04 and changes one line here.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 12:41:13 +03:30
co-authored by Claude Opus 5
parent 113e8d93a0
commit 6afc5c090e
6 changed files with 437 additions and 40 deletions
@@ -0,0 +1,142 @@
<?php
namespace App\Appointment\Service;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\ValueObject\ServiceBookingDuration;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Doctor\Entity\Doctor;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
/**
* تنها مرجعِ «مدتِ مجازِ یک ترکیب سرویس» در حالت نوبت‌دهی سرویسی.
*
* این منطق پیش‌تر داخل `AppointmentController::serviceSlots()` بود و سه مصرف‌کنندهٔ تازه
* (اعتبارسنجی PATCH، جابه‌جایی سرویس‌آگاه، تبدیل نوبت رزرو) به همان محاسبه نیاز داشتند.
* کپی‌کردنش یعنی چهار نسخه با چهار رفتار مرزی متفاوت، پس یک‌جا شد.
*
* ⚠️ فرمول فعلی جمعِ سادهٔ مدت‌هاست. این عمداً حفظ شده: تغییرش به «زمان تنها / زمان اضافه»
* (بند ۵ مستند موتور نوبت‌دهی) کار تسک ۰۴ است و همان‌جا **یک خط** از این کلاس عوض می‌شود.
* اصلاحش در این تسک، مدت همهٔ نوبت‌های چندسرویسیِ در جریان را بی‌ورودی جدید تغییر می‌داد.
*/
final class ServiceBookingCalculator
{
public function __construct(
private readonly ServiceItemRepository $itemRepo,
private readonly WeeklyScheduleRepository $schedules,
private readonly TenantOwnershipChecker $ownership,
) {}
/** آیا این محل نوبت‌دهی در حالت سرویسی است. تنها معیار، `booking_mode` است. */
public function isServiceMode(Doctor $doctor, ?Clinic $clinic): bool
{
return ($this->metaOf($doctor, $clinic)['booking_mode'] ?? WeeklySchedule::MODE_SLOT)
=== WeeklySchedule::MODE_SERVICE;
}
public function bufferMinutes(Doctor $doctor, ?Clinic $clinic): int
{
return max(0, (int) ($this->metaOf($doctor, $clinic)['buffer_minutes'] ?? 0));
}
/**
* مدت و بافرِ یک ترکیب سرویس.
*
* ترتیب بررسی عمدی است: **مالکیت محیط اول**. پیام‌های بعدی وجود و فعال‌بودن و مدت
* سرویس را لو می‌دهند و همان نشتی‌ای می‌سازند که در فاز ۸ tenancy روی این endpoint
* پیدا و رفع شد ({@see docs/architecture/tenancy.md}، جدول «uuid از درخواست»).
*
* @param string[] $serviceUuids
* @param array<string, int|string> $durationOverrides uuid → دقیقه؛ override منشی، فقط
* برای همین محاسبه (پیش‌فرض سرویس
* دست‌نخورده می‌ماند)
* @param bool $allowInactive سرویسِ غیرفعالِ **نوبتِ موجود** را رد نکن؛ به‌جایش warning بده.
* نوبتی که کلینیک سرویسش را غیرفعال کرده باید قابل جابه‌جایی
* و لغو بماند، وگرنه برای همیشه قفل می‌شود.
*
* @throws AppException همان کد/پیام/فیلدی که پیش‌تر کنترلر برمی‌گرداند
*/
public function calculate(
Doctor $doctor,
?Clinic $clinic,
array $serviceUuids,
array $durationOverrides = [],
bool $allowInactive = false,
): ServiceBookingDuration {
$this->assertBelongsToContext($serviceUuids, $doctor, $clinic);
$totalMinutes = 0;
$resolved = [];
$warnings = [];
foreach ($serviceUuids as $uuid) {
$item = $this->itemRepo->findByUuid($uuid);
if ($item === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids');
}
if (!$item->isBookable()) {
if (!$allowInactive) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids');
}
$warnings[] = sprintf('سرویس «%s» دیگر برای نوبت‌دهی فعال نیست', $item->getName());
}
$override = isset($durationOverrides[$uuid]) ? (int) $durationOverrides[$uuid] : 0;
$duration = $override > 0 ? $override : (int) ($item->getDurationMinutes() ?? 0);
if ($duration <= 0) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
}
$totalMinutes += $duration;
$resolved[] = $item;
}
return new ServiceBookingDuration(
totalMinutes: $totalMinutes,
bufferMinutes: $this->bufferMinutes($doctor, $clinic),
serviceItems: $resolved,
warnings: $warnings,
);
}
/**
* سرویس باید مالِ همین محل نوبت‌دهی باشد. uuid ناموجود هم همین خطا را می‌گیرد —
* عمدی، تا نبودن سرویس از «سرویسِ محیط دیگر» قابل تفکیک نباشد.
*
* محیط از جفتِ **بوکینگ** ساخته می‌شود (کلینیک اگر باشد، وگرنه مطب شخصی پزشک)، نه از
* محیط جاری کاربر: این مسیر عمومی است و ممکن است هیچ کاربر احراز‌شده‌ای نداشته باشد.
* پس `belongsToPair()` استفاده می‌شود، نه `belongsTo()`.
*
* @param string[] $serviceUuids
*/
private function assertBelongsToContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): void
{
[$type, $id] = $clinic !== null
? ['clinic', $clinic->getId()]
: ['doctor', $doctor->getId()];
foreach ($serviceUuids as $uuid) {
if (!$this->ownership->belongsToPair($type, $id, $this->itemRepo->findByUuid($uuid))) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد',
422,
'service_item_uuids',
);
}
}
}
/** @return array<string, mixed> */
private function metaOf(Doctor $doctor, ?Clinic $clinic): array
{
$schedule = $this->schedules->findByDoctorAndClinic($doctor, $clinic);
return $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
}
}