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