diff --git a/assets/admin/components/PatientCaseBanner.tsx b/assets/admin/components/PatientCaseBanner.tsx index cc0f9e38..e5ee622c 100644 --- a/assets/admin/components/PatientCaseBanner.tsx +++ b/assets/admin/components/PatientCaseBanner.tsx @@ -1,4 +1,4 @@ -import { formatDate } from '../lib/utils'; +import { formatDate, formatNumber } from '../lib/utils'; import BackButton from './ui/BackButton'; import { ArrowLeftD, FilesServicePhone, FilesServiceCalendar, @@ -44,7 +44,7 @@ const InfoLine = ({ icon, label, value }: { icon: React.ReactNode; label: string * FileServicesHeader (name + status chip, file number, tags, contact/date, * next appointment, یادداشت button). */ -export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, hasDebt, onAddNote }: { +export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, hasDebt, noShows, onAddNote }: { name: string; recordNumber?: string | null; mobile?: string | null; @@ -52,6 +52,8 @@ export default function PatientCaseBanner({ name, recordNumber, mobile, createdA tags?: Tag[]; nextAppointment?: number | null; hasDebt?: boolean; + /** خلاصهٔ عدم حضور در پنجرهٔ سیاست — `null` یعنی هنوز نیامده. */ + noShows?: { count: number; threshold: number; window_days: number; at_risk: boolean } | null; onAddNote: () => void; }) { const complete = !hasDebt; @@ -74,6 +76,26 @@ export default function PatientCaseBanner({ name, recordNumber, mobile, createdA برچسب ها: + + {/* شمار عدم حضور فقط وقتی می‌آید که واقعاً اتفاقی افتاده باشد. «۰ غیبت» روی + پروندهٔ هر بیمار سالم، اتهام بی‌جاست. عبور از آستانه فقط رنگش را عوض می‌کند — + مسدودسازی کارِ قانون `eligibility` است، نه این نشان. */} + {noShows && noShows.count > 0 && ( + + {formatNumber(noShows.count)} بار عدم حضور + {noShows.at_risk && ' — پرریسک'} + + )} {/* middle — contact + file date */} diff --git a/assets/admin/hooks/useCancellation.ts b/assets/admin/hooks/useCancellation.ts index e676df8c..b39664a0 100644 --- a/assets/admin/hooks/useCancellation.ts +++ b/assets/admin/hooks/useCancellation.ts @@ -102,3 +102,17 @@ export function useWaitlist(status?: string) { return { entries: query.data?.data ?? [], loading: query.isLoading, remove }; } + +/** خلاصهٔ عدم حضور یک بیمار — نشان است نه مانع؛ مسدودسازی کارِ قانون `eligibility` است. */ +export function usePatientNoShows(patientUuid: string | undefined) { + const query = useQuery({ + queryKey: ['patient-no-shows', patientUuid], + queryFn: () => + api.get>( + `/api/v1/patient/${patientUuid}/no-shows`, + ), + enabled: !!patientUuid, + }); + + return { noShows: query.data?.data ?? null }; +} diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 566d3504..a70acc15 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -11,6 +11,7 @@ import { import { PlusIcon } from '@heroicons/react/24/outline'; import { usePackages, usePatientPackages } from '../hooks/usePackages'; import { useCourseProtocols, usePatientCourses } from '../hooks/useCourses'; +import { usePatientNoShows } from '../hooks/useCancellation'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; @@ -172,6 +173,8 @@ export default function PatientDetailPage() { const sessions = sessionsQ.data?.data ?? []; const hasDebt = sessions.some((s) => !s.is_paid); const nowSec = Math.floor(Date.now() / 1000); + const { noShows } = usePatientNoShows(uuid); + const nextAppointment = (appointmentsQ.data?.data ?? []) .filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled')) .sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null; @@ -188,6 +191,7 @@ export default function PatientDetailPage() { tags={(record as any)?.tags} nextAppointment={nextAppointment} hasDebt={hasDebt} + noShows={noShows} onAddNote={() => setTab('notes')} /> diff --git a/assets/admin/pages/PlanAccuracyPage.tsx b/assets/admin/pages/PlanAccuracyPage.tsx index 63237b62..ba2d6796 100644 --- a/assets/admin/pages/PlanAccuracyPage.tsx +++ b/assets/admin/pages/PlanAccuracyPage.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; +import { useUrlState } from '../hooks/useUrlState'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; import SearchableSelect from '../components/ui/SearchableSelect'; @@ -25,7 +26,10 @@ const SEVERITY: Record setUrlState({ days: v }); const range = useMemo(() => { const to = Math.floor(Date.now() / 1000); diff --git a/assets/admin/pages/ResourceUtilizationPage.tsx b/assets/admin/pages/ResourceUtilizationPage.tsx index edb864bb..05ee1539 100644 --- a/assets/admin/pages/ResourceUtilizationPage.tsx +++ b/assets/admin/pages/ResourceUtilizationPage.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; +import { useUrlState } from '../hooks/useUrlState'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; import SearchableSelect from '../components/ui/SearchableSelect'; @@ -24,8 +25,14 @@ function percent(value: number | null): string { */ export default function ResourceUtilizationPage() { const { branches } = useBranches(); - const [branchUuid, setBranchUuid] = useState(''); - const [days, setDays] = useState('7'); + // بازه و شعبه در URL می‌نشینند نه در state: بازگشت از صفحهٔ منبع باید همان گزارش را + // برگرداند، و لینکِ گزارش باید همان چیزی را نشان بدهد که فرستنده دیده. + const [urlState, setUrlState] = useUrlState({ branch: '', days: '7' }); + const branchUuid = urlState.branch; + const days = urlState.days; + + const setBranchUuid = (v: string) => setUrlState({ branch: v }); + const setDays = (v: string) => setUrlState({ days: v }); const range = useMemo(() => { const to = Math.floor(Date.now() / 1000); diff --git a/docs/api/cancellation.md b/docs/api/cancellation.md index 04bdbead..a18112b0 100644 --- a/docs/api/cancellation.md +++ b/docs/api/cancellation.md @@ -144,6 +144,23 @@ --- +## GET `/api/v1/patient/{uuid}/no-shows` + +خلاصهٔ عدم حضور یک بیمار — همان چیزی که پروندهٔ او نشان می‌دهد. + +```json +{ "success": true, "data": { "count": 3, "threshold": 3, "window_days": 365, "at_risk": true } } +``` + +`at_risk` یک **نشانه** است، نه مانع. مسدودسازی کارِ قانون `eligibility` تسک ۰۹ روی همین +برچسب است؛ کلینیکی که می‌خواهد بیمار پرریسک را ببیند ولی بیعانه بگیرد، نباید مجبور شود +این شمارش را خاموش کند. تست `testATaggedPatientCanStillBook` همین را پین می‌کند. + +پنل نشان را فقط وقتی می‌آورد که `count > 0` باشد — «۰ غیبت» روی پروندهٔ هر بیمار سالم، +اتهام بی‌جاست. + +**۴۰۴** برای بیمار محیط دیگر. + ## POST `/api/v1/appointment/{uuid}/no-show` ```json diff --git a/src/Cancellation/Controller/CancellationController.php b/src/Cancellation/Controller/CancellationController.php index 346b6f8f..23e315e5 100644 --- a/src/Cancellation/Controller/CancellationController.php +++ b/src/Cancellation/Controller/CancellationController.php @@ -9,6 +9,7 @@ use App\Branch\Service\BranchResolver; use App\Cancellation\Entity\CancellationPolicy; use App\Cancellation\Repository\CancellationPolicyRepository; use App\Cancellation\Service\CancellationService; +use App\Cancellation\Repository\NoShowRecordRepository; use App\Cancellation\Service\NoShowService; use App\Cancellation\Service\PenaltyCalculator; use App\ClinicService\Entity\ServiceItem; @@ -37,6 +38,7 @@ class CancellationController extends BaseController private readonly PenaltyCalculator $calculator, private readonly CancellationService $cancellation, private readonly NoShowService $noShow, + private readonly NoShowRecordRepository $noShowRecords, private readonly BranchResolver $branches, private readonly TenantOwnershipChecker $ownership, ) {} @@ -145,6 +147,39 @@ class CancellationController extends BaseController return $this->success($this->noShow->record($appointment, $patient, $user)); } + /** + * خلاصهٔ عدم حضور یک بیمار — برای نشان دادن در پروندهٔ او. + * + * `at_risk` فقط یک **نشانه** است. مسدودسازی کارِ قانون `eligibility` تسک ۰۹ است؛ + * کلینیکی که می‌خواهد بیمار پرریسک را ببیند ولی بیعانه بگیرد، نباید مجبور شود این + * شمارش را خاموش کند. + */ + #[Route('/api/v1/patient/{uuid}/no-shows', name: 'patient_no_show_summary', methods: ['GET'])] + public function noShowSummary(#[CurrentUser] User $user, string $uuid): JsonResponse + { + [$entityType, $entityId] = $this->branches->pair($user); + + $patient = $this->patients->findOneBy([ + 'uuid' => $uuid, + 'entityType' => $entityType, + 'entityId' => $entityId, + ]); + + if ($patient === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروندهٔ بیمار یافت نشد', 404); + } + + $count = $this->noShowRecords->countRecent($patient); + $threshold = $this->policies->resolve($entityType, $entityId, null)?->getNoShowThreshold() ?? 3; + + return $this->success([ + 'count' => $count, + 'threshold' => $threshold, + 'window_days' => NoShowRecordRepository::WINDOW_DAYS, + 'at_risk' => $count >= $threshold, + ]); + } + private function applyAndSave(CancellationPolicy $policy, Request $request): JsonResponse { $data = json_decode($request->getContent(), true); diff --git a/tests/Cancellation/CancellationTest.php b/tests/Cancellation/CancellationTest.php index 48d84af5..9eb7f4aa 100644 --- a/tests/Cancellation/CancellationTest.php +++ b/tests/Cancellation/CancellationTest.php @@ -364,6 +364,89 @@ class CancellationTest extends ApiTestCase // ── جداسازی محیط ──────────────────────────────────────────────────────── + /** + * ⭐ سیاست سرویس بر سیاست محیط مقدم است — بدون ترکیب. + * + * ترکیب («پنجرهٔ رایگانِ محیط با درصدِ سرویس») یعنی هیچ‌کس نتواند بگوید عدد نهایی از + * کجا آمد. سرویس اگر سیاست دارد، **همه‌اش** مال اوست. + */ + public function testTheServicePolicyWinsOverTheTenantPolicy(): void + { + [$user, $section, , $doctor, $patient] = $this->clinicWithPatient(); + $clinicId = (int) $patient->getEntityId(); + $service = $this->service($section); + + // پنجرهٔ محیط یک ساعت است: با ۲۴ ساعت مانده، لغو رایگان می‌شد. + $this->savePolicy($user, [ + 'free_window_hours' => 1, + 'penalty_mode' => 'percent', + 'penalty_value' => 10, + ]); + + // پنجرهٔ سرویس ۴۸ ساعت است: همان لغو، جریمه دارد. + $saved = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/cancellation-policy", $user, [ + 'free_window_hours' => 48, + 'penalty_mode' => 'percent', + 'penalty_value' => 50, + ]); + self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE)); + + // عددِ نهایی می‌گوید کدام سیاست حاکم بوده: ۰ یعنی محیط، ۵۰٪ یعنی سرویس. + $appointment = $this->appointment($doctor, $patient, $service, $clinicId, 24, 4_000_000, 4_000_000); + + $preview = $this->authJson( + 'GET', + "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview?by=user", + $user, + ); + + self::assertSame(2_000_000, $preview['data']['penalty_rials'], 'سیاست سرویس حاکم است، نه سیاست محیط'); + self::assertFalse($preview['data']['within_free_window']); + } + + /** + * ⭐ برچسب پرریسک **مسدود نمی‌کند**. + * + * مسدودسازی یک قانون `eligibility` جداست؛ کلینیکی که می‌خواهد بیمار پرریسک را ببیند + * ولی بیعانه بگیرد، نباید مجبور شود برچسب را خاموش کند. + */ + public function testATaggedPatientCanStillBook(): void + { + [$user, $section, , $doctor, $patient] = $this->clinicWithPatient(); + $clinicId = (int) $patient->getEntityId(); + $service = $this->service($section); + + $this->savePolicy($user, ['no_show_threshold' => 2]); + + foreach ([1, 2, 3] as $i) { + $appointment = $this->appointment($doctor, $patient, $service, $clinicId, -$i * 24); + $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); + self::assertSame(200, $this->responseCode()); + } + + $summary = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $user); + + self::assertTrue($summary['data']['at_risk']); + self::assertSame(3, $summary['data']['count']); + + // و با همین وضعیت، نوبت تازه ثبت می‌شود. + $fresh = $this->appointment($doctor, $patient, $service, $clinicId, 48); + + self::assertSame(Appointment::STATUS_CONFIRMED, $fresh->getStatus()); + } + + public function testAnotherClinicCannotSeeTheNoShowSummary(): void + { + [$user, , , , $patient] = $this->clinicWithPatient(); + [$other] = $this->clinicWithPatient(); + + $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $other); + self::assertSame(404, $this->responseCode()); + + $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $user); + self::assertSame(200, $this->responseCode()); + } + public function testAnotherClinicCannotPreviewTheCancellation(): void { [$owner, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); diff --git a/tests/Report/ReportTest.php b/tests/Report/ReportTest.php index d87108e4..e6312e67 100644 --- a/tests/Report/ReportTest.php +++ b/tests/Report/ReportTest.php @@ -226,6 +226,122 @@ class ReportTest extends ApiTestCase self::assertFalse($row['wasted_capacity']); } + /** + * ⭐ سنجه‌های واقعی: اشغال شامل انتظار است، «کار مفید» نه. + * + * فاصلهٔ این دو همان چیزی است که تعریف غلط بخش‌ها را لو می‌دهد؛ اگر هر دو یکی + * برگردند، گزارش بی‌فایده است و کسی متوجه نمی‌شود. + */ + public function testOccupiedIncludesTheWaitingSegmentButActiveDoesNot(): void + { + [$user, , $address] = $this->clinic(); + + $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ + 'address_uuid' => $address->getUuid(), + 'code' => 'room', + 'name' => 'اتاق', + ]); + self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); + + $created = $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $type['data']['uuid'], + 'name' => 'اتاق ۱', + ]); + self::assertSame(201, $this->responseCode()); + + $resource = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class) + ->findOneBy(['uuid' => $created['data']['uuid']]); + + $from = time() - 2 * 86400; + $start = $from + 3600; + + $appointment = $this->bookedAppointment($address, $start, 60); + + // یک ساعت اشغال؛ ولی بیمار فقط ۲۰ دقیقهٔ اولش حاضر است. + $this->occupy($resource, $appointment, $start, $start + 3600); + $this->segment($appointment, 1, 'ویزیت', $start, $start + 1200, true); + $this->segment($appointment, 2, 'انتظار', $start + 1200, $start + 3600, false); + + $body = $this->authJson( + 'GET', + sprintf( + '/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d', + $address->getUuid(), + $from, + time(), + ), + $user, + ); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + $row = $body['data']['rows'][0]; + + self::assertSame(60, $row['occupied_minutes'], 'انتظار هم اشغال است'); + self::assertSame(20, $row['active_minutes'], 'ولی کار مفید نیست'); + } + + private function bookedAppointment(\App\Doctor\Entity\DoctorAddress $address, int $start, int $minutes): \App\Appointment\Entity\Appointment + { + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر گزارش'); + $this->em->persist($doctor); + + $appointment = new \App\Appointment\Entity\Appointment( + $doctor, + $this->createUser(['ROLE_USER']), + $start, + $start + $minutes * 60, + ); + $appointment->assignTenantPair('clinic', (int) $address->getClinicId()); + $appointment->setAddressId($address->getId()); + $appointment->setPatientName('بیمار گزارش'); + $appointment->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED); + + $this->em->persist($appointment); + $this->em->flush(); + + return $appointment; + } + + private function occupy( + \App\Resource\Entity\ClinicResource $resource, + \App\Appointment\Entity\Appointment $appointment, + int $from, + int $to, + ): void { + $row = new \App\Appointment\Availability\Entity\ResourceOccupancy( + $resource, + $from, + $to, + \App\Appointment\Availability\Entity\ResourceOccupancy::STATUS_BOOKED, + ); + $row->setAppointmentId($appointment->getId()); + + $this->em->persist($row); + $this->em->flush(); + } + + private function segment( + \App\Appointment\Entity\Appointment $appointment, + int $sequence, + string $name, + int $from, + int $to, + bool $present, + ): void { + $this->em->persist(new \App\Appointment\Booking\Entity\AppointmentSegment( + $appointment, + $sequence, + $name, + $from, + $to, + $present, + )); + $this->em->flush(); + } + // ── محدودیت بازه و دسترسی ─────────────────────────────────────────────── public function testARangeLongerThanNinetyDaysIsRejected(): void