From 5e87bbc18bc5c3107485939ff8ef938ccffa9ad1 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 2 Aug 2026 16:14:04 +0330 Subject: [PATCH] feat(appointments): resources share the doctors' timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three views become two. The resource lanes were a separate tab, which meant reading a doctor's free hour on one screen and the laser's on another and matching them by eye — while in the resource-first model it is the device and the room that decide whether that hour is really free. They now sit under the same "زمانبندی" view, below the doctor's slots. Each lane says how much of its shift is still free, and that number respects capacity: a minute counts as busy only once the overlapping bookings reach the resource's capacity, so a three-bed room with two appointments is still open. Treating it otherwise would silently turn every multi-capacity resource into a single-capacity one. ResourceFreeTimeCalculator does the sweep and carries nine cases of its own. only_bookable=1 keeps resources with no service offering out of the view; they could only ever render an empty lane. On the seeded clinic that is five resources down to two. Two ruler defects the screenshot caught: hours rendered in Latin digits, and the last label was half-clipped by the container so 21 read as 2. Co-Authored-By: Claude Opus 5 --- .../appointments/ResourceTimeline.test.tsx | 33 ++++ .../appointments/ResourceTimeline.tsx | 43 ++++- .../appointments/TurnsViewToggle.tsx | 10 +- assets/admin/hooks/useResourceTimeline.ts | 12 +- assets/admin/pages/AppointmentsPage.tsx | 27 ++-- docs/api/doctor.md | 7 +- docs/api/resource.md | 31 +++- .../Controller/ResourceController.php | 43 +++-- .../ResourceServiceOfferingRepository.php | 27 ++++ .../Service/ResourceFreeTimeCalculator.php | 80 ++++++++++ .../ResourceFreeTimeCalculatorTest.php | 150 ++++++++++++++++++ .../Resource/ResourceTimelineEndpointTest.php | 38 +++++ 12 files changed, 455 insertions(+), 46 deletions(-) create mode 100644 src/Resource/Service/ResourceFreeTimeCalculator.php create mode 100644 tests/Resource/ResourceFreeTimeCalculatorTest.php 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 فعال، قابل رزرو نیست'); + } }