doctorWithAddress(); $resource = new ClinicResource($address, $this->resourceType($address), 'لیزر CO2'); $resource->setSupervisor($doctor); $this->em->persist($resource); $this->em->flush(); $this->em->persist(new ResourceCalendar( $resource, $this->dayOfWeek($this->midnight()), self::SHIFT_FROM, self::SHIFT_TO, )); $this->em->flush(); return [$user, $doctor, $resource, $address]; } private function offer(ClinicResource $resource, DoctorAddress $address, string $name, ?int $minutes): ServiceItem { $section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name); $this->em->persist($section); $item = new ServiceItem($section, $name, 5_000_000); $item->setBookable(true); $this->em->persist($item); $this->em->flush(); $offering = new ResourceServiceOffering($resource, $item); $offering->setDurationMinutes($minutes); $this->em->persist($offering); $this->em->flush(); return $item; } /** فردا انتخاب می‌شود تا «زمانِ گذشته» نتیجه را کوتاه نکند. */ private function midnight(): int { return (int) strtotime('tomorrow midnight'); } private function date(): string { return date('Y-m-d', $this->midnight()); } /** ۰ = شنبه، همان قرارداد تقویم منبع. */ private function dayOfWeek(int $timestamp): int { return ((int) date('w', $timestamp) + 1) % 7; } private function bookOnResource(Doctor $doctor, User $user, ClinicResource $resource, int $start, int $end): void { $appointment = new Appointment($doctor, $user, $start, $end); $appointment->setResource($resource); $appointment->assignTenant(EntityContext::forBooking($doctor, null)); $this->em->persist($appointment); $this->em->flush(); } // ── ✅ موفق ────────────────────────────────────────────────────────────── public function testTheDayViewReturnsTheResourceShiftAsItsWorkingWindow(): void { [$user, , $resource] = $this->resourceWithShift(); $body = $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date={$this->date()}", $user); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertNull($body['data']['empty_reason']); self::assertCount(1, $body['data']['windows']); self::assertSame($this->midnight() + self::SHIFT_FROM * 60, $body['data']['windows'][0]['start']); self::assertSame($this->midnight() + self::SHIFT_TO * 60, $body['data']['windows'][0]['end']); } public function testServiceSlotsAreChainedFromTheResourceDurationOfEachService(): void { [$user, , $resource, $address] = $this->resourceWithShift(); $laser = $this->offer($resource, $address, 'لیزر زیربغل', 25); $rf = $this->offer($resource, $address, 'RF فرکشنال', 35); $body = $this->authJson('GET', sprintf( '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s&service_item_uuids[]=%s', $resource->getUuid(), $this->date(), $laser->getUuid(), $rf->getUuid(), ), $user); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertSame(60, $body['data']['total_duration_minutes']); // ۸ ساعت شیفت ÷ ۶۰ دقیقه = ۸ زمان، پشت‌سرهم از ۰۹:۰۰. self::assertCount(8, $body['data']['start_times']); self::assertSame('09:00', $body['data']['start_times'][0]['start_time']); self::assertSame('10:00', $body['data']['start_times'][1]['start_time']); } public function testAnAppointmentOnTheResourceRemovesItsTimeFromTheOffer(): void { [$user, $doctor, $resource, $address] = $this->resourceWithShift(); $service = $this->offer($resource, $address, 'لیزر صورت', 60); $occupied = $this->midnight() + 10 * 3600; $this->bookOnResource($doctor, $user, $resource, $occupied, $occupied + 3600); $body = $this->authJson('GET', sprintf( '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', $resource->getUuid(), $this->date(), $service->getUuid(), ), $user); $starts = array_column($body['data']['start_times'], 'start_time'); self::assertNotContains('10:00', $starts); self::assertContains('09:00', $starts); self::assertContains('11:00', $starts); } /** مدتِ فرستاده‌شده فقط همین محاسبه را جابه‌جا می‌کند، نه پیش‌فرضِ سرویس را. */ public function testAPerRequestDurationOverridesTheOffering(): void { [$user, , $resource, $address] = $this->resourceWithShift(); $service = $this->offer($resource, $address, 'کرایوتراپی', 25); $body = $this->authJson('GET', sprintf( '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s&durations[%s]=120', $resource->getUuid(), $this->date(), $service->getUuid(), $service->getUuid(), ), $user); self::assertSame(120, $body['data']['total_duration_minutes']); self::assertCount(4, $body['data']['start_times']); // ۸ ساعت ÷ ۲ ساعت } // ── ⚠️ مرزی ───────────────────────────────────────────────────────────── /** اتاق دوتخته با یک نوبت پر نمی‌شود. */ public function testCapacityKeepsTheTimeOnOfferUntilItIsFull(): void { [$user, $doctor, $resource, $address] = $this->resourceWithShift(); $resource->setCapacity(2); $this->em->flush(); $service = $this->offer($resource, $address, 'اکسیژن‌تراپی', 60); $occupied = $this->midnight() + 10 * 3600; $this->bookOnResource($doctor, $user, $resource, $occupied, $occupied + 3600); $body = $this->authJson('GET', sprintf( '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', $resource->getUuid(), $this->date(), $service->getUuid(), ), $user); self::assertContains('10:00', array_column($body['data']['start_times'], 'start_time')); } /** روزِ بی‌شیفت خطا نیست؛ باید بگوید چرا خالی است. */ public function testADayWithoutAShiftExplainsItself(): void { [$user, , $resource] = $this->resourceWithShift(); $otherDay = date('Y-m-d', $this->midnight() + 3 * 86400); $body = $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date={$otherDay}", $user); self::assertSame(200, $this->responseCode()); self::assertSame([], $body['data']['windows']); self::assertNotNull($body['data']['empty_reason']); } /** رزرو موقتِ موتور منبع‌محور هم وقت را می‌گیرد، نه فقط نوبتِ ثبت‌شده. */ public function testAHoldFromTheResourceEngineAlsoBlocksTheTime(): void { [$user, , $resource, $address] = $this->resourceWithShift(); $service = $this->offer($resource, $address, 'مزوتراپی', 60); $held = $this->midnight() + 12 * 3600; $this->em->persist(new ResourceOccupancy($resource, $held, $held + 3600, ResourceOccupancy::STATUS_HOLD)); $this->em->flush(); $body = $this->authJson('GET', sprintf( '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', $resource->getUuid(), $this->date(), $service->getUuid(), ), $user); self::assertNotContains('12:00', array_column($body['data']['start_times'], 'start_time')); } // ── ❌ خطا ─────────────────────────────────────────────────────────────── public function testAServiceThisResourceDoesNotOfferIsRefused(): void { [$user, , $resource, $address] = $this->resourceWithShift(); $section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش دیگر'); $this->em->persist($section); $foreign = new ServiceItem($section, 'سرویس بی‌ربط', 1_000_000); $foreign->setDurationMinutes(20)->setBookable(true); $this->em->persist($foreign); $this->em->flush(); $this->authJson('GET', sprintf( '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', $resource->getUuid(), $this->date(), $foreign->getUuid(), ), $user); self::assertSame(422, $this->responseCode()); } public function testAnOfferingWithoutADurationIsRefused(): void { [$user, , $resource, $address] = $this->resourceWithShift(); $service = $this->offer($resource, $address, 'بدون مدت', null); $this->authJson('GET', sprintf( '/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s', $resource->getUuid(), $this->date(), $service->getUuid(), ), $user); self::assertSame(422, $this->responseCode()); } public function testAMalformedDateIsRefused(): void { [$user, , $resource] = $this->resourceWithShift(); $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date=05-08-2026", $user); self::assertSame(422, $this->responseCode()); } public function testAnotherEnvironmentDoesNotSeeThisResource(): void { [, , $resource] = $this->resourceWithShift(); [$stranger] = $this->doctorWithAddress(); $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date={$this->date()}", $stranger); self::assertSame(404, $this->responseCode()); } }