"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>
240 lines
10 KiB
PHP
240 lines
10 KiB
PHP
<?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));
|
|
}
|
|
}
|