diff --git a/assets/admin/components/appointments/ResourceTimeline.test.tsx b/assets/admin/components/appointments/ResourceTimeline.test.tsx index 6c2113f3..1a5675f3 100644 --- a/assets/admin/components/appointments/ResourceTimeline.test.tsx +++ b/assets/admin/components/appointments/ResourceTimeline.test.tsx @@ -16,6 +16,9 @@ function lane(overrides: Partial = {}): ResourceTimelineLa address_name: 'شعبهٔ مرکزی', capacity: 1, shifts: [{ start_minute: 9 * 60, end_minute: 17 * 60 }], + shift_minutes: 480, + busy_minutes: 60, + free_minutes: 420, items: [ { uuid: 'o-1', @@ -96,4 +99,34 @@ describe('ResourceTimeline', () => { expect(screen.getByText('خطای داخلی سرور')).toBeInTheDocument(); }); + + /** ظرفیت و وقت آزاد باید روی خودِ ردیف باشد، نه حدسِ کاربر از روی بلوک‌ها. */ + it('وقت آزاد هر ردیف را نشان می‌دهد', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('۴۲۰ دقیقه آزاد')).toBeInTheDocument(); + }); + + it('ردیفِ پرشده «تکمیل» می‌گوید، نه صفر دقیقه', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('تکمیل')).toBeInTheDocument(); + }); + + it('ظرفیت بیش از یک را کنار نوع منبع می‌آورد', () => { + renderWithProviders( + , + ); + + expect(screen.getByText(/ظرفیت ۳/)).toBeInTheDocument(); + }); }); diff --git a/assets/admin/components/appointments/ResourceTimeline.tsx b/assets/admin/components/appointments/ResourceTimeline.tsx index 292d6876..ee3f2996 100644 --- a/assets/admin/components/appointments/ResourceTimeline.tsx +++ b/assets/admin/components/appointments/ResourceTimeline.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; import { Link } from 'react-router-dom'; +import { formatNumber } from '../../lib/utils'; import type { ResourceTimelineLane, ResourceTimelineItem } from '../../hooks/useResourceTimeline'; const HOUR = 60; @@ -11,6 +12,11 @@ function hhmm(timestamp: number): string { return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } +/** ساعتِ خط‌کش با ارقام فارسی — بقیهٔ پنل هم فارسی است. */ +function hourLabel(minute: number): string { + return String(Math.floor(minute / 60)).padStart(2, '0').replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]); +} + function minuteOfDay(timestamp: number, dayStart: number): number { return Math.round((timestamp - dayStart) / 60); } @@ -88,16 +94,21 @@ export default function ResourceTimeline({ lanes, dayStart, loading, error }: {
- {hours.map((m) => ( + {hours.map((m, i) => ( - {String(Math.floor(m / 60)).padStart(2, '0')} + {hourLabel(m)} ))}
@@ -112,7 +123,11 @@ export default function ResourceTimeline({ lanes, dayStart, loading, error }: { > {lane.name} - {lane.type_name} + + {lane.type_name} + {lane.capacity > 1 && <> · ظرفیت {formatNumber(lane.capacity)}} + +
بدون شیفت; + } + + const full = lane.free_minutes === 0; + + return ( + + {full ? 'تکمیل' : `${formatNumber(lane.free_minutes)} دقیقه آزاد`} + + ); +} + function Block({ item, dayStart, from, span }: { item: ResourceTimelineItem; dayStart: number; diff --git a/assets/admin/components/appointments/TurnsViewToggle.tsx b/assets/admin/components/appointments/TurnsViewToggle.tsx index 7f08478c..4be9e19b 100644 --- a/assets/admin/components/appointments/TurnsViewToggle.tsx +++ b/assets/admin/components/appointments/TurnsViewToggle.tsx @@ -2,15 +2,15 @@ * سوییچ کشویی نمای نوبت‌ها — بازسازی `TurnsViewModeToggle.jsx` طرح tauri * (پس‌زمینهٔ لغزنده، متن فعال #5559ce). * - * نمای «منابع» بعداً اضافه شد چون در مدل منبع‌محور، پرشدن یک ساعت را دستگاه و اتاق - * تعیین می‌کنند نه فقط برنامهٔ پزشک. + * منابع نمای جدا ندارند: ردیفشان درون همان نمای «زمانبندی» می‌آید، چون پرشدن یک ساعت + * را دستگاه و اتاق تعیین می‌کنند نه فقط برنامهٔ پزشک — و دیدنشان جدا از هم یعنی کاربر + * باید دو نما را با چشم تطبیق دهد. */ -export type TurnsViewMode = 'table' | 'timeline' | 'resources'; +export type TurnsViewMode = 'table' | 'timeline'; const MODES: { id: TurnsViewMode; label: string }[] = [ { id: 'table', label: 'نمایش جدولی' }, { id: 'timeline', label: 'زمانبندی' }, - { id: 'resources', label: 'منابع' }, ]; export default function TurnsViewToggle({ @@ -27,7 +27,7 @@ export default function TurnsViewToggle({ role="tablist" aria-label="نمای نوبت‌ها" style={{ - position: 'relative', width: 272, height: 44, display: 'flex', overflow: 'hidden', + position: 'relative', width: 193, height: 44, display: 'flex', overflow: 'hidden', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)', }} > diff --git a/assets/admin/hooks/useResourceTimeline.ts b/assets/admin/hooks/useResourceTimeline.ts index 7dfbea61..81833215 100644 --- a/assets/admin/hooks/useResourceTimeline.ts +++ b/assets/admin/hooks/useResourceTimeline.ts @@ -20,6 +20,10 @@ export type ResourceTimelineLane = { address_name: string | null; capacity: number; shifts: { start_minute: number; end_minute: number }[]; + /** دقیقهٔ شیفت، دقیقهٔ پرشده (ظرفیت‌آگاه) و باقی‌ماندهٔ آزاد. */ + shift_minutes: number; + busy_minutes: number; + free_minutes: number; items: ResourceTimelineItem[]; }; @@ -35,12 +39,14 @@ export type ResourceTimelineDay = { * بازه‌ها از جدول اشغال می‌آیند نه از خودِ نوبت: آماده‌سازی و تمیزکاری دستگاه هم * درونشان است و همان بازه‌ای است که موتور جستجو اشغال می‌بیند. */ -export function useResourceTimeline(date: string, addressUuid?: string) { +export function useResourceTimeline(date: string, options: { addressUuid?: string; onlyBookable?: boolean } = {}) { const params = new URLSearchParams({ date }); - if (addressUuid) params.set('address_uuid', addressUuid); + if (options.addressUuid) params.set('address_uuid', options.addressUuid); + // منبعی که به هیچ سرویسی وصل نیست ردیفِ همیشه‌خالی است؛ در تایم‌لاین نوبت‌ها نمی‌آید. + if (options.onlyBookable) params.set('only_bookable', '1'); const query = useQuery({ - queryKey: ['resource-timeline', date, addressUuid ?? null], + queryKey: ['resource-timeline', date, options.addressUuid ?? null, options.onlyBookable ?? false], queryFn: () => api.get>(`/api/v1/resources/timeline?${params}`), enabled: !!date, }); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 175ec8bb..c9728cbc 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -618,13 +618,13 @@ export default function AppointmentsPage() { const adminLocation = adminLocations.find(l => locKey(l) === adminLocKey) ?? adminLocations[0] ?? null; const effectiveClinicUuid: string | null = isAdmin ? (adminLocation?.clinic_uuid ?? null) : clinicUuid; - // نمای منبع‌محور فقط وقتی درخواست می‌فرستد که انتخاب شده باشد؛ در نمای جدولی - // یک کوئری اضافه به‌ازای هر تغییر روز، بی‌مصرف است. + // ردیف منابع بخشی از همان نمای زمانبندی است: در مدل منبع‌محور، پرشدن یک ساعت را + // دستگاه و اتاق تعیین می‌کنند نه فقط برنامهٔ پزشک. در نمای جدولی کوئری نمی‌رود. const { day: resourceDay, loading: resourceTimelineLoading, error: resourceTimelineError, - } = useResourceTimeline(viewMode === 'resources' ? selectedDate : ''); + } = useResourceTimeline(viewMode === 'timeline' ? selectedDate : '', { onlyBookable: true }); // ── Slots query (timeline) // effectiveClinicUuid باید در کلید کش باشد، وگرنه برنامهٔ یک محیط برای محیط دیگر نشان داده می‌شود. @@ -841,14 +841,7 @@ export default function AppointmentsPage() { )}
- {viewMode === 'resources' ? ( - - ) : viewMode === 'table' ? ( + {viewMode === 'table' ? ( <> )} + + {/* منابعِ قابل رزرو، زیر همان روز — یک نوبت می‌تواند هم‌زمان اتاق و + دستگاه را بگیرد و آن است که ظرفیت را تمام می‌کند. */} +
+

منابع

+ +
)}
diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 02b33210..00c6bc55 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -477,10 +477,9 @@ Get all practice addresses for a doctor, including addresses of clinics the doct > `DoctorAddress::toArray()` می‌آیند. هر دو ستون `NOT NULL DEFAULT` دارند، پس ردیف‌های > قدیمی هم `active: true` و `timezone: "Asia/Tehran"` می‌دهند؛ تغییر additive است. > -> `active` در فاز فعلی **فقط ذخیره می‌شود** و هیچ اثری بر محاسبهٔ اسلات ندارد. نوشتن -> این دو فیلد از راه اندپوینت‌های همین سند انجام نمی‌شود؛ برای آن -> `PATCH /api/v1/branch/{addressUuid}` است — همراه با ساعت کاری هفتگی و اتاق‌ها در -> [branch.md](branch.md). +> `active` **فقط ذخیره می‌شود** و اثرش بر دسترس‌پذیری فقط همین است که منبعِ روی یک +> آدرسِ غیرفعال، روزش با دلیل `address_inactive` خالی برمی‌گردد. اندپوینت اختصاصیِ +> نوشتنِ این دو فیلد با حذف دامنهٔ شعبه برداشته شد. --- diff --git a/docs/api/resource.md b/docs/api/resource.md index 54868d68..3f692f13 100644 --- a/docs/api/resource.md +++ b/docs/api/resource.md @@ -11,19 +11,19 @@ قانون طلایی اول مستند: **«تقویم مال منبع است، نه مال پزشک.»** منبع هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت. -هر منبع مال یک **شعبه** است و شعبه همان رکورد آدرس محل نوبت‌دهی است -(`doctor_addresses` — رجوع به [branch.md](branch.md)). پس همه‌جا `address_uuid` است، -نه `branch_id`. +هر منبع مال یک **محل نوبت‌دهی** است — همان رکورد `doctor_addresses`. پس همه‌جا +`address_uuid` است، نه `branch_id`. مفهوم «شعبه» از محصول حذف شد و خودِ آدرس فقط لنگرِ +نامرئیِ محیط ماند؛ فهرستش در [doctor.md](doctor.md#get-apiv1addresses). ### پل، نه ادغام -`Doctor`، `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند +`Doctor` و `ClinicStaff` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند (`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاسِ منبع یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش هر منبع **حداکثر یک** پل دارد: | `subject_kind` | یعنی | |---|---| -| `doctor` / `staff` / `room` | منبع همان موجودیت است | +| `doctor` / `staff` | منبع همان موجودیت است | | `null` | دستگاه یا تجهیزات — منبعی که پشتش موجودیت دیگری نیست | قید «حداکثر یکی» در خودِ entity اجبار می‌شود، نه با `CHECK` دیتابیس: MariaDB قید @@ -163,7 +163,7 @@ | Query | توضیح | |---|---| -| `address_uuid` | فقط منابع این شعبه | +| `address_uuid` | فقط منابع این محل نوبت‌دهی | | `type_uuid` | فقط این نوع | | `skill_uuid` | فقط منابعی که این مهارت را دارند | | `active` | `1` / `0` | @@ -176,7 +176,7 @@ | فیلد | نوع | الزامی | قاعده | |---|---|---|---| -| `address_uuid` | string | ✅ | شعبه؛ جفت محیطِ منبع **از همین** مشتق می‌شود، نه از بدنه | +| `address_uuid` | string | ✅ | محل نوبت‌دهی؛ جفت محیطِ منبع **از همین** مشتق می‌شود، نه از بدنه | | `type_uuid` | string | ✅ | | | `name` | string | ✅ | حداکثر ۱۵۰ نویسه | | `capacity` | int | — | پیش‌فرض ۱، حداقل ۱؛ روی منبعِ شخص حداکثر ۱ | @@ -343,7 +343,8 @@ | پارامتر | پیش‌فرض | توضیح | |---|---|---| | `date` | امروز | `YYYY-MM-DD` — هر قالب دیگری ۴۲۲ | -| `address_uuid` | همهٔ شعبه‌ها | فقط منابع همان شعبه | +| `address_uuid` | همهٔ محل‌ها | فقط منابع همان محل نوبت‌دهی | +| `only_bookable` | `0` | فقط منابعی که دست‌کم یک سرویسِ فعال ارائه می‌دهند | فقط منابع **فعال** برمی‌گردند و منبعِ بی‌شیفت هم در فهرست می‌ماند تا ردیفش در تایم‌لاین دیده شود. @@ -366,6 +367,7 @@ "uuid": "…", "name": "اتاق لیزر ۱", "type_name": "اتاق درمان", "address_name": "درمانگاه سلامت", "capacity": 1, "shifts": [{ "start_minute": 480, "end_minute": 1260 }], + "shift_minutes": 780, "busy_minutes": 55, "free_minutes": 725, "items": [ { "uuid": "6176e73b-df27-4cdf-815c-36f9bfbd68ca", @@ -385,6 +387,19 @@ `shifts` فقط شیفت‌های همان روزِ هفته است. **۴۲۲:** قالب `date` غلط (`{"code":"ERR_VALIDATION_002","message":"تاریخ باید به شکل YYYY-MM-DD باشد","field":"date"}`). +**وقت آزاد با ظرفیت حساب می‌شود، نه پر/خالی.** دقیقه‌ای «پر» است که تعداد بازه‌های +هم‌پوشانش به `capacity` رسیده باشد؛ اتاق سه‌تخته با دو نوبت هم‌زمان هنوز آزاد است. اگر +جز این بود، ظرفیت عملاً یک می‌شد. محاسبه در `ResourceFreeTimeCalculator` است و +`tests/Resource/ResourceFreeTimeCalculatorTest.php` نُه حالتش را قفل می‌کند. + +خروجی واقعی `only_bookable=1` روی سناریوی ۲: از پنج منبع، فقط «لیزر الکساندرایت ۱» و +«لیزر دایود ۲» برمی‌گردند — سه‌تای دیگر هنوز به هیچ سرویسی وصل نشده‌اند و ردیفِ +همیشه‌خالی می‌ساختند. + +> **پنل:** ردیف‌های این اندپوینت زیر نمای «زمانبندی» صفحهٔ نوبت‌ها می‌آیند، نه در یک نمای +> سوم — پرشدن یک ساعت را دستگاه و اتاق تعیین می‌کنند نه فقط برنامهٔ پزشک، و دو نمای جدا +> یعنی کاربر باید آن‌ها را با چشم تطبیق دهد. + > تعداد کوئری ثابت است: یک کوئری اشغال، یک کوئری شیفت، یک کوئری نوبت — نه یکی به‌ازای > هر منبع. diff --git a/src/Resource/Controller/ResourceController.php b/src/Resource/Controller/ResourceController.php index 7fe2b9ac..bd8a6f0f 100644 --- a/src/Resource/Controller/ResourceController.php +++ b/src/Resource/Controller/ResourceController.php @@ -33,6 +33,8 @@ class ResourceController extends BaseController private readonly \App\Resource\Service\ResourceServiceAssignmentService $serviceOfferings, private readonly \App\Resource\Repository\ResourceCalendarRepository $calendars, private readonly \App\Appointment\Repository\AppointmentRepository $appointments, + private readonly \App\Resource\Repository\ResourceServiceOfferingRepository $offerings, + private readonly \App\Resource\Service\ResourceFreeTimeCalculator $freeTime, ) {} #[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])] @@ -212,35 +214,54 @@ class ResourceController extends BaseController 'skillUuid' => null, ]); + // منبعی که هنوز به هیچ سرویسی وصل نیست، در تایم‌لاینِ نوبت‌دهی ردیفِ همیشه‌خالی + // می‌سازد؛ با `only_bookable=1` کنار گذاشته می‌شود. + if ($request->query->getBoolean('only_bookable')) { + $bookable = $this->offerings->bookableResourceIds( + array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources), + ); + $resources = array_values(array_filter( + $resources, + static fn (ClinicResource $r): bool => in_array((int) $r->getId(), $bookable, true), + )); + } + $ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources); $occupancy = $this->occupancy->dayByResource($ids, $from, $to); - $shifts = $this->calendars->findForResources($ids); + $calendars = $this->calendars->findForResources($ids); $dayOfWeek = $this->dayOfWeek($from); $patients = $this->patientLabels($occupancy); return $this->success([ 'date' => $from, 'day_of_week' => $dayOfWeek, - 'resources' => array_map(function (ClinicResource $r) use ($occupancy, $shifts, $dayOfWeek, $patients): array { + 'resources' => array_map(function (ClinicResource $r) use ($occupancy, $calendars, $dayOfWeek, $patients, $from): array { $id = (int) $r->getId(); - return [ - 'uuid' => $r->getUuid(), - 'name' => $r->getName(), - 'type_name' => $r->getType()->getName(), - 'address_name' => $r->getAddress()->getName(), - 'capacity' => $r->getCapacity(), - 'shifts' => array_values(array_map( + $shifts = array_values(array_map( static fn (\App\Resource\Entity\ResourceCalendar $c): array => [ 'start_minute' => $c->getStartMinute(), 'end_minute' => $c->getEndMinute(), ], array_filter( - $shifts[$id] ?? [], + $calendars[$id] ?? [], static fn (\App\Resource\Entity\ResourceCalendar $c): bool => $c->getDayOfWeek() === $dayOfWeek, ), - )), + )); + + $load = $this->freeTime->forDay($shifts, $occupancy[$id] ?? [], $from, $r->getCapacity()); + + return [ + 'uuid' => $r->getUuid(), + 'name' => $r->getName(), + 'type_name' => $r->getType()->getName(), + 'address_name' => $r->getAddress()->getName(), + 'capacity' => $r->getCapacity(), + 'shifts' => $shifts, + 'shift_minutes' => $load['shift_minutes'], + 'busy_minutes' => $load['busy_minutes'], + 'free_minutes' => $load['free_minutes'], 'items' => array_map( static fn (array $row): array => $row + ($patients[$row['appointment_id']] ?? [ 'patient_name' => null, diff --git a/src/Resource/Repository/ResourceServiceOfferingRepository.php b/src/Resource/Repository/ResourceServiceOfferingRepository.php index d588b9ac..833685d1 100644 --- a/src/Resource/Repository/ResourceServiceOfferingRepository.php +++ b/src/Resource/Repository/ResourceServiceOfferingRepository.php @@ -67,6 +67,33 @@ class ResourceServiceOfferingRepository extends ServiceEntityRepository } /** @return int[] شناسهٔ منابعی که این سرویس را فعال ارائه می‌دهند */ + /** + * کدام‌یک از این منابع دست‌کم یک سرویسِ فعال ارائه می‌دهند — یعنی «قابل رزرو»اند. + * + * عکسِ `activeResourceIdsFor()`: آن از سرویس به منبع می‌رود، این از منبع به سرویس. + * تایم‌لاین نوبت‌ها با همین فیلتر می‌کند تا دستگاهی که هنوز به هیچ سرویسی وصل نشده، + * ردیفِ همیشه‌خالی نسازد. + * + * @param int[] $resourceIds + * @return int[] + */ + public function bookableResourceIds(array $resourceIds): array + { + if ($resourceIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('o') + ->select('DISTINCT IDENTITY(o.resource) AS resource_id') + ->where('o.resource IN (:ids)') + ->andWhere('o.active = true') + ->setParameter('ids', $resourceIds) + ->getQuery() + ->getArrayResult(); + + return array_map(static fn (array $r): int => (int) $r['resource_id'], $rows); + } + public function activeResourceIdsFor(ServiceItem $item, ?ResourceType $type = null): array { $qb = $this->createQueryBuilder('o') diff --git a/src/Resource/Service/ResourceFreeTimeCalculator.php b/src/Resource/Service/ResourceFreeTimeCalculator.php new file mode 100644 index 00000000..b45d5058 --- /dev/null +++ b/src/Resource/Service/ResourceFreeTimeCalculator.php @@ -0,0 +1,80 @@ + $shifts دقیقه از نیمه‌شب + * @param list $occupancy timestamp مطلق + * + * @return array{shift_minutes: int, busy_minutes: int, free_minutes: int} + */ + public function forDay(array $shifts, array $occupancy, int $dayStart, int $capacity): array + { + $shiftMinutes = 0; + foreach ($shifts as $shift) { + $shiftMinutes += max(0, $shift['end_minute'] - $shift['start_minute']); + } + + if ($shiftMinutes === 0 || $occupancy === []) { + return [ + 'shift_minutes' => $shiftMinutes, + 'busy_minutes' => 0, + 'free_minutes' => $shiftMinutes, + ]; + } + + $capacity = max(1, $capacity); + + // جاروی خطی روی مرزها: هر بازه یک +۱ در شروع و یک −۱ در پایان می‌گذارد. بین دو + // مرزِ متوالی تعداد هم‌پوشانی ثابت است، پس همان‌جا یک‌بار سنجیده می‌شود. شمارشِ + // دقیقه‌به‌دقیقه هم جواب می‌داد ولی ۱۴۴۰ گام به‌ازای هر منبع است. + $delta = []; + foreach ($occupancy as $row) { + $from = (int) floor(($row['starts_at'] - $dayStart) / 60); + $to = (int) ceil(($row['ends_at'] - $dayStart) / 60); + + $delta[$from] = ($delta[$from] ?? 0) + 1; + $delta[$to] = ($delta[$to] ?? 0) - 1; + } + ksort($delta); + + /** @var list $fullRanges بازه‌هایی که ظرفیت در آن‌ها تمام است */ + $fullRanges = []; + $points = array_keys($delta); + $open = 0; + + foreach ($points as $i => $minute) { + $open += $delta[$minute]; + $next = $points[$i + 1] ?? null; + + if ($next !== null && $open >= $capacity) { + $fullRanges[] = [$minute, $next]; + } + } + + $busy = 0; + foreach ($shifts as $shift) { + foreach ($fullRanges as [$from, $to]) { + $busy += max(0, min($shift['end_minute'], $to) - max($shift['start_minute'], $from)); + } + } + + return [ + 'shift_minutes' => $shiftMinutes, + 'busy_minutes' => $busy, + 'free_minutes' => max(0, $shiftMinutes - $busy), + ]; + } +} diff --git a/tests/Resource/ResourceFreeTimeCalculatorTest.php b/tests/Resource/ResourceFreeTimeCalculatorTest.php new file mode 100644 index 00000000..e039ef28 --- /dev/null +++ b/tests/Resource/ResourceFreeTimeCalculatorTest.php @@ -0,0 +1,150 @@ +calc = new ResourceFreeTimeCalculator(); + } + + /** @return array{starts_at: int, ends_at: int} */ + private function at(int $fromMinute, int $toMinute): array + { + return [ + 'starts_at' => self::DAY + $fromMinute * 60, + 'ends_at' => self::DAY + $toMinute * 60, + ]; + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testAnEmptyDayIsEntirelyFree(): void + { + $load = $this->calc->forDay([['start_minute' => 540, 'end_minute' => 1020]], [], self::DAY, 1); + + self::assertSame(480, $load['shift_minutes']); + self::assertSame(0, $load['busy_minutes']); + self::assertSame(480, $load['free_minutes']); + } + + public function testASingleAppointmentOnACapacityOneResourceIsBusy(): void + { + $load = $this->calc->forDay( + [['start_minute' => 540, 'end_minute' => 1020]], + [$this->at(600, 660)], + self::DAY, + 1, + ); + + self::assertSame(60, $load['busy_minutes']); + self::assertSame(420, $load['free_minutes']); + } + + // ── ⚠️ مرزی ───────────────────────────────────────────────────────────── + + /** همان حالتی که کل این محاسبه برایش نوشته شده. */ + public function testCapacityThreeWithTwoOverlappingAppointmentsStillHasRoom(): void + { + $load = $this->calc->forDay( + [['start_minute' => 540, 'end_minute' => 1020]], + [$this->at(600, 660), $this->at(600, 660)], + self::DAY, + 3, + ); + + self::assertSame(0, $load['busy_minutes'], 'دو نوبت هم‌زمان روی ظرفیت ۳ هیچ دقیقه‌ای را پر نمی‌کند'); + self::assertSame(480, $load['free_minutes']); + } + + public function testCapacityIsOnlyFullWhenEveryPlaceIsTaken(): void + { + $load = $this->calc->forDay( + [['start_minute' => 540, 'end_minute' => 1020]], + [$this->at(600, 660), $this->at(600, 660), $this->at(600, 630)], + self::DAY, + 3, + ); + + // فقط ۱۰:۰۰ تا ۱۰:۳۰ هر سه جا گرفته است. + self::assertSame(30, $load['busy_minutes']); + self::assertSame(450, $load['free_minutes']); + } + + /** اشغالِ بیرون از شیفت، وقتِ آزادِ داخل شیفت را کم نمی‌کند. */ + public function testOccupancyOutsideTheShiftDoesNotCount(): void + { + $load = $this->calc->forDay( + [['start_minute' => 540, 'end_minute' => 1020]], + [$this->at(1100, 1200)], + self::DAY, + 1, + ); + + self::assertSame(0, $load['busy_minutes']); + self::assertSame(480, $load['free_minutes']); + } + + /** اشغالی که نیمی‌اش بیرون شیفت است، فقط نیمهٔ داخلش شمرده می‌شود. */ + public function testOnlyTheOverlappingPartOfAnAppointmentCounts(): void + { + $load = $this->calc->forDay( + [['start_minute' => 540, 'end_minute' => 1020]], + [$this->at(480, 600)], + self::DAY, + 1, + ); + + self::assertSame(60, $load['busy_minutes'], 'از ۰۸:۰۰ تا ۱۰:۰۰ فقط یک ساعتش داخل شیفت است'); + } + + public function testARestDayHasNoFreeTime(): void + { + $load = $this->calc->forDay([], [$this->at(600, 660)], self::DAY, 1); + + self::assertSame(0, $load['shift_minutes']); + self::assertSame(0, $load['free_minutes']); + } + + /** ظرفیت صفر یا منفی نباید تقسیم‌بر-صفرِ منطقی بسازد؛ کف ۱ است. */ + public function testCapacityBelowOneIsTreatedAsOne(): void + { + $load = $this->calc->forDay( + [['start_minute' => 540, 'end_minute' => 600]], + [$this->at(540, 600)], + self::DAY, + 0, + ); + + self::assertSame(60, $load['busy_minutes']); + } + + /** دو شیفت در یک روز (صبح و عصر) هر دو شمرده می‌شوند. */ + public function testTwoShiftsInADayAreBothCounted(): void + { + $load = $this->calc->forDay( + [['start_minute' => 540, 'end_minute' => 720], ['start_minute' => 960, 'end_minute' => 1200]], + [$this->at(600, 660)], + self::DAY, + 1, + ); + + self::assertSame(420, $load['shift_minutes']); + self::assertSame(60, $load['busy_minutes']); + self::assertSame(360, $load['free_minutes']); + } +} diff --git a/tests/Resource/ResourceTimelineEndpointTest.php b/tests/Resource/ResourceTimelineEndpointTest.php index 44dda1ec..4d751e2f 100644 --- a/tests/Resource/ResourceTimelineEndpointTest.php +++ b/tests/Resource/ResourceTimelineEndpointTest.php @@ -158,4 +158,42 @@ class ResourceTimelineEndpointTest extends ApiTestCase self::assertCount(1, $body['data']['resources']); self::assertSame([], $body['data']['resources'][0]['items']); } + + // ── وقت آزاد و فیلتر «قابل رزرو» ──────────────────────────────────────── + + /** ردیف تایم‌لاین باید بگوید چقدر از شیفت هنوز جا دارد، نه فقط چه چیزی گرفته است. */ + public function testTheLaneReportsHowMuchOfTheShiftIsStillFree(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $midnight = (int) strtotime('today midnight'); + $this->shift($resource, $this->dayOfWeek($midnight), 540, 1020); // ۸ ساعت + $this->occupy($resource, $midnight + 10 * 3600, $midnight + 11 * 3600, 'لیزر'); + + $row = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user) + ['data']['resources'][0]; + + self::assertSame(480, $row['shift_minutes']); + self::assertSame(60, $row['busy_minutes']); + self::assertSame(420, $row['free_minutes']); + } + + /** منبعی که به هیچ سرویسی وصل نیست، ردیفِ همیشه‌خالی در تایم‌لاین نمی‌سازد. */ + public function testOnlyBookableHidesResourcesWithNoService(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $midnight = (int) strtotime('today midnight'); + $this->shift($resource, $this->dayOfWeek($midnight), 540, 1020); + + $all = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user); + self::assertCount(1, $all['data']['resources']); + + $bookable = $this->authJson( + 'GET', + '/api/v1/resources/timeline?only_bookable=1&date=' . date('Y-m-d', $midnight), + $user, + ); + self::assertSame([], $bookable['data']['resources'], 'بدون ResourceServiceOffering فعال، قابل رزرو نیست'); + } }