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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user