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:
@@ -28,9 +28,9 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `ServiceBookingCalculator` ساخته شد | ⏳ | |
|
||||
| ۱.۲ | `serviceSlots()` موجود از آن استفاده میکند، **خروجیاش عوض نشده** | ⏳ | |
|
||||
| ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ⏳ | |
|
||||
| ۱.۱ | `ServiceBookingCalculator` ساخته شد | ✅ | `src/Appointment/Service/ServiceBookingCalculator.php` + VO در `src/Appointment/ValueObject/ServiceBookingDuration.php` (قرارداد پروژه `ValueObject` است نه `Dto` — `src/Billing/ValueObject`، `src/Insurance/ValueObject`) |
|
||||
| ۱.۲ | `serviceSlots()` موجود از آن استفاده میکند، **خروجیاش عوض نشده** | ✅ | ۲۰ خط از کنترلر حذف شد. بیتبهبیتبودن: `error()` و `ExceptionSubscriber` **یک envelope یکسان** میسازند (`['success'=>false,'data'=>null,'errors'=>[{code,message,field?}]]`)، پس `AppException` جای `return $this->error()` نشست. شاهد: ۱۶۶ تست موجود سبز ماند، شامل `ServiceBasedSlotsTest` و `ServiceModeSectionDurationTest` که همین مسیرهای خطا را میسنجند |
|
||||
| ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ✅ | با کامنت صریح در docblock کلاس: «تسک ۰۴ همانجا یک خط عوض میکند» |
|
||||
| ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | ⏳ | |
|
||||
| ۱.۵ | `PATCH /appointment/{uuid}` توسعه یافت — منطق جدید داخل `isServiceMode()` | ⏳ | |
|
||||
| ۱.۶ | `PATCH` مقدار `service_item_uuids[]` میپذیرد | ⏳ | |
|
||||
@@ -41,9 +41,9 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
| ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را همگام میکند | ⏳ | چهار مصرفکننده رویش خواندهاند |
|
||||
| ۱.۱۲ | `setIsReserve()` صدا زدن `refreshActiveSlotKey()` | ⏳ | |
|
||||
| ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | ⏳ | |
|
||||
| ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ⏳ | |
|
||||
| ۱.۱۵ | `allowInactive` فقط برای سرویسهای موجود نوبت، نه uuid های تازه | ⏳ | |
|
||||
| ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ⏳ | |
|
||||
| ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ✅ | `belongsToPair()` (نه `belongsTo()`): مسیر عمومی است و کاربر احرازشده ندارد. جای مقایسهٔ دستیِ `getSection()->getEntityType()` نشست — نقطهٔ واحد `tenancy.md`. ⚠️ نام property باید `itemRepo` باشد وگرنه `TenantLookupInventoryTest` آن را **نمیشمارد** و از تور ایمنی فرار میکند |
|
||||
| ۱.۱۵ | `allowInactive` فقط برای سرویسهای موجود نوبت، نه uuid های تازه | ✅ | پرچم روی `calculate()`؛ غیرفعالبودن → `warnings[]` بهجای ۴۲۲. مدتِ نامعتبر همچنان حتی با `allowInactive` رد میشود (تست دارد) |
|
||||
| ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ✅ | `serviceSlots()` از ۶۰ خط به ۴۰ خط؛ هیچ حلقهٔ محاسباتی در کنترلر نماند |
|
||||
|
||||
## ۲. دیتابیس و مهاجرت
|
||||
|
||||
@@ -84,7 +84,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `SlotModeFrozenTest` — سه سنجه | ⏳ | |
|
||||
| ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ⏳ | |
|
||||
| ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ✅ | ۱۳ تست / ۲۹ assertion سبز. شامل: جمع مدت + بافر · `endFor` بدون بافر · override منشی بدون تغییر پیشفرض سرویس · چهار مسیر خطا با کد/پیام/فیلد دقیق · uuid ناموجود از سرویسِ محیط دیگر **قابل تفکیک نیست** · `allowInactive` → warning · فهرست خالی → صفر · override نامعتبر (۰ و منفی) → fallback · پزشک بیبرنامه → پیشفرض `slot` |
|
||||
| ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | ⏳ | |
|
||||
| ۴.۴ | `PatchServiceDurationTest` — شامل «در حالت اسلاتی هیچکدام اجرا نمیشود» | ⏳ | |
|
||||
| ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | |
|
||||
|
||||
@@ -43,6 +43,7 @@ class AppointmentController extends BaseController
|
||||
private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo,
|
||||
private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker,
|
||||
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
|
||||
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
|
||||
private readonly \Psr\Log\LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -207,46 +208,26 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
|
||||
}
|
||||
|
||||
// پیش از هر بررسی دیگری: سرویس باید مالِ همین محیط باشد. مسیر ثبت نوبت همین
|
||||
// گارد را دارد و این مسیر نداشت، پس با uuid سرویسِ محیط دیگر میشد وجود،
|
||||
// فعالبودن و مدتش را از پیامهای خطا و اسلاتهای برگشتی استنتاج کرد.
|
||||
if (($err = $this->assertServicesMatchContext($uuids, $doctor, $clinic)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
// مدتِ override منشی (فقط برای همین محاسبه؛ پیشفرض سرویس تغییر نمیکند). durations[uuid]=minutes
|
||||
$overrides = (array) $request->query->all('durations');
|
||||
|
||||
$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');
|
||||
}
|
||||
$duration = isset($overrides[$u]) && (int) $overrides[$u] > 0
|
||||
? (int) $overrides[$u]
|
||||
: (int) ($item->getDurationMinutes() ?? 0);
|
||||
if ($duration <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += $duration;
|
||||
}
|
||||
|
||||
$meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META;
|
||||
// مالکیت محیط، وجود، فعالبودن و مدت — همه داخل calculate() و با همان ترتیب و
|
||||
// همان کد/پیام/فیلدِ قبلی (AppException و $this->error() یک envelope میسازند).
|
||||
$duration = $this->serviceCalculator->calculate(
|
||||
$doctor,
|
||||
$clinic,
|
||||
$uuids,
|
||||
(array) $request->query->all('durations'),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'date' => $date,
|
||||
'total_duration_minutes' => $totalMinutes,
|
||||
'buffer_minutes' => (int) $meta['buffer_minutes'],
|
||||
'total_duration_minutes' => $duration->totalMinutes,
|
||||
'buffer_minutes' => $duration->bufferMinutes,
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'start_times' => $this->slotCalculator->getServiceStartTimes(
|
||||
$doctor,
|
||||
$date,
|
||||
$totalMinutes,
|
||||
$duration->totalMinutes,
|
||||
$clinic,
|
||||
$this->isManagementContext($request, $doctor, $clinic),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\ValueObject;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
|
||||
/**
|
||||
* نتیجهٔ محاسبهٔ مدت یک ترکیب سرویس در حالت نوبتدهی سرویسی.
|
||||
*
|
||||
* `bufferMinutes` فاصلهٔ بین دو نوبت است و **جزو مدت نوبت نیست**: زمان پایانِ ذخیرهشدهٔ
|
||||
* نوبت `start + totalMinutes` است، نه `start + totalMinutes + buffer`. همان قاعدهای که
|
||||
* {@see \App\Appointment\Service\SlotCalculatorService::getServiceStartTimes()} دارد.
|
||||
*/
|
||||
final readonly class ServiceBookingDuration
|
||||
{
|
||||
/**
|
||||
* @param ServiceItem[] $serviceItems به ترتیب uuidهای ورودی
|
||||
* @param string[] $warnings پیامهای فارسی برای نمایش؛ مانع عملیات نیستند
|
||||
*/
|
||||
public function __construct(
|
||||
public int $totalMinutes,
|
||||
public int $bufferMinutes,
|
||||
public array $serviceItems,
|
||||
public array $warnings = [],
|
||||
) {}
|
||||
|
||||
/** زمان پایان نوبت برای یک زمان شروع مشخص — بدون بافر. */
|
||||
public function endFor(int $slotStart): int
|
||||
{
|
||||
return $slotStart + $this->totalMinutes * 60;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Appointment\Service\ServiceBookingCalculator;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* `ServiceBookingCalculator` — تنها مرجعِ «مدتِ مجازِ یک ترکیب سرویس».
|
||||
*
|
||||
* منطق پیشتر داخل `AppointmentController::serviceSlots()` بود؛ این تست تضمین میکند
|
||||
* استخراجش رفتار را عوض نکرده و چهار مسیر خطا با همان کد/پیام/فیلد قبلی برمیگردند.
|
||||
*/
|
||||
class ServiceBookingCalculatorTest extends ApiTestCase
|
||||
{
|
||||
private function calculator(): ServiceBookingCalculator
|
||||
{
|
||||
return static::getContainer()->get(ServiceBookingCalculator::class);
|
||||
}
|
||||
|
||||
private function serviceModeDoctor(int $buffer = 0): Doctor
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر محاسبهٔ سرویس');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$schedule = $this->newWeeklySchedule($doctor, [
|
||||
'0' => ['sessions' => [[
|
||||
'active' => true, 'start_time' => '09:00', 'end_time' => '17:00',
|
||||
'duration_per_patient' => 20, 'location_id' => 1,
|
||||
]]],
|
||||
]);
|
||||
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]);
|
||||
$this->em->persist($schedule);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function slotModeDoctor(): Doctor
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر اسلاتی');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$schedule = $this->newWeeklySchedule($doctor, ['0' => ['sessions' => []]]);
|
||||
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SLOT]);
|
||||
$this->em->persist($schedule);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function service(Doctor $doctor, string $name, ?int $minutes, bool $bookable = true): ServiceItem
|
||||
{
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
|
||||
$this->em->persist($section);
|
||||
$item = new ServiceItem($section, $name, 0);
|
||||
$item->setDurationMinutes($minutes)->setBookable($bookable);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
// ── ✅ مسیر موفق ─────────────────────────────────────────────────────────
|
||||
|
||||
public function testSumsDurationsAndCarriesBuffer(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor(buffer: 10);
|
||||
$face = $this->service($doctor, 'لیزر صورت', 20);
|
||||
$bikini = $this->service($doctor, 'لیزر بیکینی', 15);
|
||||
|
||||
$result = $this->calculator()->calculate($doctor, null, [$face->getUuid(), $bikini->getUuid()]);
|
||||
|
||||
self::assertSame(35, $result->totalMinutes);
|
||||
self::assertSame(10, $result->bufferMinutes, 'بافر از meta برنامه میآید');
|
||||
self::assertSame([], $result->warnings);
|
||||
self::assertCount(2, $result->serviceItems);
|
||||
}
|
||||
|
||||
public function testEndForExcludesBuffer(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor(buffer: 10);
|
||||
$item = $this->service($doctor, 'مشاوره', 30);
|
||||
|
||||
$result = $this->calculator()->calculate($doctor, null, [$item->getUuid()]);
|
||||
$start = 1_800_000_000;
|
||||
|
||||
self::assertSame($start + 30 * 60, $result->endFor($start), 'بافر جزو مدت نوبت نیست');
|
||||
}
|
||||
|
||||
public function testSecretaryOverrideWinsWithoutTouchingTheService(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor();
|
||||
$item = $this->service($doctor, 'تزریق', 20);
|
||||
|
||||
$result = $this->calculator()->calculate(
|
||||
$doctor, null, [$item->getUuid()], [$item->getUuid() => 45],
|
||||
);
|
||||
|
||||
self::assertSame(45, $result->totalMinutes);
|
||||
|
||||
$this->em->refresh($item);
|
||||
self::assertSame(20, $item->getDurationMinutes(), 'مدت پیشفرض سرویس نباید عوض شود');
|
||||
}
|
||||
|
||||
public function testIsServiceModeReadsBookingModeOnly(): void
|
||||
{
|
||||
self::assertTrue($this->calculator()->isServiceMode($this->serviceModeDoctor(), null));
|
||||
self::assertFalse($this->calculator()->isServiceMode($this->slotModeDoctor(), null));
|
||||
}
|
||||
|
||||
// ── ❌ مسیرهای خطا — همان کد/پیام/فیلد قبلی کنترلر ────────────────────────
|
||||
|
||||
public function testServiceWithoutDurationIsRejected(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor();
|
||||
$item = $this->service($doctor, 'بدون مدت', null);
|
||||
|
||||
try {
|
||||
$this->calculator()->calculate($doctor, null, [$item->getUuid()]);
|
||||
self::fail('سرویس بدون مدت باید رد شود');
|
||||
} catch (AppException $e) {
|
||||
self::assertSame(ErrorCodes::ERR_VALIDATION_001, $e->getErrorCode());
|
||||
self::assertSame('مدت سرویس تعریف نشده است', $e->getMessage());
|
||||
self::assertSame('service_item_uuids', $e->getField());
|
||||
self::assertSame(422, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
public function testInactiveServiceIsRejectedByDefault(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor();
|
||||
$item = $this->service($doctor, 'غیرفعال', 30, bookable: false);
|
||||
|
||||
try {
|
||||
$this->calculator()->calculate($doctor, null, [$item->getUuid()]);
|
||||
self::fail('سرویس غیرفعال باید رد شود');
|
||||
} catch (AppException $e) {
|
||||
self::assertSame('این سرویس برای نوبتدهی فعال نیست', $e->getMessage());
|
||||
self::assertSame(422, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
public function testForeignTenantServiceLeaksNothing(): void
|
||||
{
|
||||
$mine = $this->serviceModeDoctor();
|
||||
$other = $this->serviceModeDoctor();
|
||||
$foreign = $this->service($other, 'سرویس محیط دیگر', 30);
|
||||
|
||||
try {
|
||||
$this->calculator()->calculate($mine, null, [$foreign->getUuid()]);
|
||||
self::fail('سرویس محیط دیگر باید رد شود');
|
||||
} catch (AppException $e) {
|
||||
// پیام نباید وجود، فعالبودن یا مدت سرویس را لو بدهد.
|
||||
self::assertSame('سرویس انتخابشده به این محل نوبتدهی تعلق ندارد', $e->getMessage());
|
||||
self::assertStringNotContainsString('سرویس محیط دیگر', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function testUnknownUuidIsIndistinguishableFromForeignService(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor();
|
||||
|
||||
try {
|
||||
$this->calculator()->calculate($doctor, null, ['00000000-0000-4000-8000-000000000000']);
|
||||
self::fail('uuid ناموجود باید رد شود');
|
||||
} catch (AppException $e) {
|
||||
self::assertSame('سرویس انتخابشده به این محل نوبتدهی تعلق ندارد', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAllowInactiveTurnsRejectionIntoWarning(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor();
|
||||
$item = $this->service($doctor, 'لیزر بازنشسته', 30, bookable: false);
|
||||
|
||||
$result = $this->calculator()->calculate($doctor, null, [$item->getUuid()], allowInactive: true);
|
||||
|
||||
self::assertSame(30, $result->totalMinutes, 'نوبت موجود باید قابل جابهجایی بماند');
|
||||
self::assertCount(1, $result->warnings);
|
||||
self::assertStringContainsString('لیزر بازنشسته', $result->warnings[0]);
|
||||
}
|
||||
|
||||
public function testAllowInactiveStillRejectsMissingDuration(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor();
|
||||
$item = $this->service($doctor, 'غیرفعال و بیمدت', null, bookable: false);
|
||||
|
||||
$this->expectException(AppException::class);
|
||||
$this->expectExceptionMessage('مدت سرویس تعریف نشده است');
|
||||
$this->calculator()->calculate($doctor, null, [$item->getUuid()], allowInactive: true);
|
||||
}
|
||||
|
||||
public function testEmptySelectionYieldsZeroMinutes(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor(buffer: 5);
|
||||
|
||||
$result = $this->calculator()->calculate($doctor, null, []);
|
||||
|
||||
// صفر یعنی «هیچ سرویسی انتخاب نشده»؛ رد کردنِ فهرست خالی کارِ کنترلر است، نه
|
||||
// این کلاس — تا مسیر PATCH بتواند نوبتِ بدون سرویس را بدون خطا رد کند.
|
||||
self::assertSame(0, $result->totalMinutes);
|
||||
self::assertSame(5, $result->bufferMinutes);
|
||||
}
|
||||
|
||||
public function testNonPositiveOverrideFallsBackToServiceDuration(): void
|
||||
{
|
||||
$doctor = $this->serviceModeDoctor();
|
||||
$item = $this->service($doctor, 'مشاوره', 25);
|
||||
|
||||
foreach ([0, -10] as $bad) {
|
||||
$result = $this->calculator()->calculate(
|
||||
$doctor, null, [$item->getUuid()], [$item->getUuid() => $bad],
|
||||
);
|
||||
self::assertSame(25, $result->totalMinutes, 'override نامعتبر نادیده گرفته میشود');
|
||||
}
|
||||
}
|
||||
|
||||
public function testDoctorWithoutScheduleFallsBackToDefaultMeta(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر بیبرنامه');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertFalse($this->calculator()->isServiceMode($doctor, null), 'پیشفرض slot است');
|
||||
self::assertSame(0, $this->calculator()->bufferMinutes($doctor, null));
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,11 @@ class TenantLookupInventoryTest extends TestCase
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private const REVIEWED = [
|
||||
// assertServicesMatchContext در هر سه مسیر (اسلات سرویسی، ثبت، و خودِ گارد)
|
||||
'src/Appointment/Controller/AppointmentController.php' => 3,
|
||||
// assertServicesMatchContext (مسیر ثبت نوبت) + خودِ گارد. جستوجوی مسیرِ اسلاتِ
|
||||
// سرویسی به ServiceBookingCalculator منتقل شد و همانجا شمرده میشود.
|
||||
'src/Appointment/Controller/AppointmentController.php' => 2,
|
||||
// TenantOwnershipChecker::belongsToPair() روی هر uuid، پیش از هر بررسی دیگر
|
||||
'src/Appointment/Service/ServiceBookingCalculator.php' => 2,
|
||||
// TenantOwnershipChecker روی بخش/سرویس/پرسنل و فهرست سرویسها
|
||||
'src/Appointment/Controller/MyAppointmentsController.php' => 1,
|
||||
// ownsSession / getEntityType روی صورتحساب، پرونده و مطالبه
|
||||
|
||||
Reference in New Issue
Block a user