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