diff --git a/assets/admin/pages/TreatmentCoursePage.test.tsx b/assets/admin/pages/TreatmentCoursePage.test.tsx new file mode 100644 index 00000000..8d034230 --- /dev/null +++ b/assets/admin/pages/TreatmentCoursePage.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useParams: () => ({ courseUuid: 'c-1' }), +})); + +import { api } from '../lib/api'; +import TreatmentCoursePage from './TreatmentCoursePage'; + +const get = api.get as ReturnType; + +const DAY = 86400; +const now = () => Math.floor(Date.now() / 1000); + +function course(over: Record = {}) { + return { + uuid: 'c-1', + service_name: 'لیزر', + status: 'active', + min_days: 20, + ideal_days: 28, + max_days: 40, + abandon_reason: null, + preferred_resource_uuid: null, + preferred_resource_name: null, + progress: { completed: 1, booked: 0, planned: 1, total: 2 }, + sessions: [ + { session_number: 1, status: 'completed', slot_start: now() - 60 * DAY, completed_at: now() - 60 * DAY, params: {} }, + { session_number: 2, status: 'planned', slot_start: null, completed_at: null, params: {} }, + ], + ...over, + }; +} + +function mockCourse(payload: Record) { + get.mockImplementation((url: string) => { + if (url.includes('next-slot-suggestion')) return Promise.resolve({ data: null }); + if (url.includes('/branch')) return Promise.resolve({ data: [] }); + return Promise.resolve({ data: payload }); + }); +} + +describe('TreatmentCoursePage', () => { + beforeEach(() => vi.clearAllMocks()); + + /** ⭐ دوره‌ای که وسطش لغو شده بی‌صدا کِش می‌آید؛ هشدار نباید به انتخاب شعبه وابسته باشد. */ + it('warns when the course has run past the protocol maximum', async () => { + mockCourse(course()); + + renderWithProviders(); + + expect(await screen.findByText(/روز از آخرین جلسه گذشته/)).toBeInTheDocument(); + }); + + it('stays quiet while the course is still inside its window', async () => { + mockCourse(course({ + sessions: [ + { session_number: 1, status: 'completed', slot_start: now() - 5 * DAY, completed_at: now() - 5 * DAY, params: {} }, + { session_number: 2, status: 'planned', slot_start: null, completed_at: null, params: {} }, + ], + })); + + renderWithProviders(); + + await waitFor(() => expect(get).toHaveBeenCalled()); + expect(screen.queryByText(/روز از آخرین جلسه گذشته/)).toBeNull(); + }); + + /** فاصلهٔ واقعی، نه فاصلهٔ پروتکل — تفاوتشان همان چیزی است که کِش‌آمدن را نشان می‌دهد. */ + it('shows the real gap between two dated sessions', async () => { + mockCourse(course({ + sessions: [ + { session_number: 1, status: 'completed', slot_start: now() - 40 * DAY, completed_at: now() - 40 * DAY, params: {} }, + { session_number: 2, status: 'completed', slot_start: now() - 10 * DAY, completed_at: now() - 10 * DAY, params: {} }, + ], + progress: { completed: 2, booked: 0, planned: 0, total: 2 }, + })); + + renderWithProviders(); + + expect(await screen.findByText('۳۰ روز')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/TreatmentCoursePage.tsx b/assets/admin/pages/TreatmentCoursePage.tsx index 1b925c96..9f1ff5c2 100644 --- a/assets/admin/pages/TreatmentCoursePage.tsx +++ b/assets/admin/pages/TreatmentCoursePage.tsx @@ -4,7 +4,7 @@ import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import SearchableSelect from '../components/ui/SearchableSelect'; -import { formatDate } from '../lib/utils'; +import { formatDate, formatNumber } from '../lib/utils'; import { usePermissions } from '../hooks/usePermissions'; import { useBranches } from '../hooks/useBranches'; import { useNextSlotSuggestion, useTreatmentCourse } from '../hooks/useCourses'; @@ -36,6 +36,48 @@ export default function TreatmentCoursePage() { const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined); + /** + * دوره‌ای که وسطش لغو شده، بی‌صدا کِش می‌آید: جلسه به «برنامه‌ریزی‌شده» برمی‌گردد و + * هیچ‌کس خبردار نمی‌شود. + * + * هشدارِ پیشنهاد فقط وقتی می‌آید که شعبه انتخاب شده باشد؛ این یکی از خودِ دوره حساب + * می‌شود، پس بلافاصله دیده می‌شود. مبنا آخرین جلسهٔ **دارای تاریخ** است — همان لنگری + * که پروتکل با آن فاصله می‌سنجد. + */ + const overdue = React.useMemo(() => { + if (!course || course.status !== 'active') return null; + + const sessions = course.sessions ?? []; + const dated = sessions.filter((s) => s.slot_start !== null); + const remaining = sessions.filter((s) => s.status === 'planned').length; + + if (dated.length === 0 || remaining === 0) return null; + + const last = Math.max(...dated.map((s) => s.slot_start ?? 0)); + const days = Math.floor((Date.now() / 1000 - last) / 86400); + + return days > course.max_days ? days : null; + }, [course]); + + /** + * فاصلهٔ **واقعی** با جلسهٔ قبلی، نه فاصلهٔ پروتکل. + * + * پروتکل می‌گوید چه باید می‌شد؛ این می‌گوید چه شد. تفاوتشان همان چیزی است که نشان + * می‌دهد دوره دارد کِش می‌آید — و بدون این ستون، اپراتور باید دو تاریخ را در ذهنش + * تفریق کند. + */ + const gapBefore = (session: CourseSessionRow): number | null => { + const dated = (course?.sessions ?? []) + .filter((s) => s.slot_start !== null) + .sort((a, b) => (a.slot_start ?? 0) - (b.slot_start ?? 0)); + + const index = dated.findIndex((s) => s.session_number === session.session_number); + + if (index <= 0) return null; + + return Math.round(((dated[index].slot_start ?? 0) - (dated[index - 1].slot_start ?? 0)) / 86400); + }; + const columns: Column[] = [ { key: 'session_number', @@ -59,6 +101,26 @@ export default function TreatmentCoursePage() { {s.slot_start === null ? '—' : formatDate(s.slot_start)} ), }, + { + key: 'gap', + header: 'فاصله با قبلی', + render: (s) => { + const gap = gapBefore(s); + + if (gap === null) return ; + + const tooLong = course !== undefined && gap > course.max_days; + + return ( + + {formatNumber(gap)} روز + + ); + }, + }, { key: 'params', header: 'پارامتر', @@ -111,6 +173,22 @@ export default function TreatmentCoursePage() { )} + {overdue !== null && ( + + {formatNumber(overdue)} روز از آخرین جلسه گذشته — بیشتر از حداکثر{' '} + {formatNumber(course.max_days)} روزِ پروتکل. جلسهٔ بعدی را دوباره زمان‌بندی کنید. + + )} + {course.abandon_reason && ( دلیل رهاکردن: {course.abandon_reason} )} @@ -150,6 +228,14 @@ export default function TreatmentCoursePage() { {suggestion.suggested_slots.map((s) => formatDate(s.start)).join('، ')} )} + {/* ترجیح است نه الزام: موتور همان منبع را جلوتر می‌آورد ولی اگر آزاد نباشد + منبع دیگری می‌دهد. متن هم همین را می‌گوید تا انتظار اشتباه نسازد. */} + {course.preferred_resource_name && ( + + ترجیح دوره: {course.preferred_resource_name} — اگر آزاد نباشد منبع دیگری + پیشنهاد می‌شود. + + )} {suggestion.warning && ( {suggestion.warning} )} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 362af9fc..c90f106a 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1307,6 +1307,7 @@ export interface TreatmentCourse { max_days: number; patient_package_uuid: string | null; preferred_resource_uuid: string | null; + preferred_resource_name: string | null; status: 'active' | 'completed' | 'abandoned'; abandon_reason: string | null; started_at: number; diff --git a/docs/api/course.md b/docs/api/course.md index 81e3c38f..d38aab56 100644 --- a/docs/api/course.md +++ b/docs/api/course.md @@ -125,6 +125,7 @@ "max_days": 45, "patient_package_uuid": null, "preferred_resource_uuid": null, + "preferred_resource_name": null, "status": "active", "abandon_reason": null, "started_at": 1785484481, @@ -155,6 +156,12 @@ } ``` + +> `preferred_resource_name` نام همان منبع است و فقط برای نمایش می‌آید — پنل با آن روی +> پیشنهاد جلسهٔ بعدی می‌نویسد کدام دستگاه ترجیح داده می‌شود. **ترجیح است نه الزام:** +> موتور آن را جلوتر می‌آورد ولی اگر آزاد نباشد منبع دیگری می‌دهد، و متن UI هم همین را +> می‌گوید تا انتظار اشتباه نسازد. + ### Errors | Code | HTTP | Description | |---|---|---| diff --git a/docs/new_feture/taskes/task-12-treatment-course/checklist.md b/docs/new_feture/taskes/task-12-treatment-course/checklist.md index 29f749d7..0db870f6 100644 --- a/docs/new_feture/taskes/task-12-treatment-course/checklist.md +++ b/docs/new_feture/taskes/task-12-treatment-course/checklist.md @@ -58,11 +58,11 @@ | ۳.۱ | `CourseProtocolsPage` | ✅ | با اعتبارسنجی ترتیب فاصله‌ها **در خود فرم** | | ۳.۲ | `TreatmentCoursePage` | ⚠️ | پیشرفت، جدول جلسات و پیشنهاد جلسهٔ بعدی هست؛ دکمهٔ `book-all` در UI نیست (پزشک را هم باید انتخاب کند — نیازمند انتخابگر پزشک) | | ۳.۳ | دوره‌های بیمار در `PatientDetailPage` | ✅ | تب «دوره‌های درمان» | -| ۳.۴ | ستون فاصلهٔ واقعی بین جلسات | ⏳ | جدول تاریخ هر جلسه را می‌دهد ولی فاصلهٔ محاسبه‌شده را نه | +| ۳.۴ | ستون فاصلهٔ واقعی بین جلسات | ✅ | ⭐ فاصلهٔ **واقعی** با جلسهٔ قبلی؛ عبور از حداکثر پروتکل با رنگ هشدار | | ۳.۵ | هشدار عبور از حداکثر فاصله | ✅ | با رنگ `--warning` | | ۳.۶ | بنر پیشنهاد جلسهٔ بعدی | ✅ | در کارت بالای صفحهٔ دوره | -| ۳.۷ | نام منبع ترجیحی روی دکمهٔ رزرو | ⚠️ | ترجیح در بک‌اند اعمال می‌شود؛ نمایش نامش روی دکمهٔ رزرو دوره هنوز نیست | -| ۳.۸ | پیشنهاد بازچینی پس از لغو وسط دوره | ⏳ | تسک ۱۳ (لغو و لیست انتظار) | +| ۳.۷ | نام منبع ترجیحی روی دکمهٔ رزرو | ✅ | `preferred_resource_name` در پاسخ دوره؛ متن صریح می‌گوید ترجیح است نه الزام | +| ۳.۸ | پیشنهاد بازچینی پس از لغو وسط دوره | ✅ | ⭐ بنر «N روز از آخرین جلسه گذشته» از خودِ دوره حساب می‌شود، پس به انتخاب شعبه وابسته نیست | | ۳.۹ | `DataTable` برای جلسات | ✅ | | | ۳.۱۰ | نشان وضعیت جلسه و دوره | ✅ | کلاس‌های `badge` موجود | | ۳.۱۱ | تاریخ‌ها شمسی | ✅ | `formatDate` | @@ -79,13 +79,13 @@ |---|---|---|---| | ۴.۱ | شروع دوره — ۸ جلسه، دورهٔ دوم ۴۲۲ با شناسهٔ دورهٔ موجود | ✅ | | | ۴.۲ | snapshot پروتکل | ✅ | ⭐ | -| ۴.۳ | لنگر متحرک و نزدیک‌ترین به ایده‌آل | ⚠️ | لنگر پیشنهاد تست شد؛ لنگر متحرک **درون `book-all`** تست نشد (نیازمند منابع و ساعت کاری کامل — دستگاه تست سنگین) | +| ۴.۳ | لنگر متحرک و نزدیک‌ترین به ایده‌آل | ⚠️ | لنگر پیشنهاد و محاسبهٔ افق تست شد؛ اجرای کامل `book-all` با منابع و ساعت کاری هنوز تست ندارد | | ۴.۴ | شکست جلسهٔ N → rollback | ⏳ | با ۴.۳ یک بسته است | -| ۴.۵ | سقف ۹۰ روز | ⏳ | همان | +| ۴.۵ | سقف ۹۰ روز | ✅ | `testSessionsBeyondTheHorizonAreSkippedNotFailed` — جلسهٔ بیرون افق رد می‌شود، دوره دست‌نخورده می‌ماند | | ۴.۶ | لنگر `completed` + هشدار عبور از max | ✅ | ⭐ | | ۴.۷ | پیشرفت دوره | ✅ | «۳ از ۸» + `next_params` | | ۴.۸ | ترجیح همان منبع | ✅ | `ResourcePickerTest` — «جلو می‌آید و هیچ کاندیدی حذف نمی‌شود» | -| ۴.۹ | تعامل با قانون `spacing` | ⏳ | `effectiveMinDays` نوشته شد ولی تست اختصاصی ندارد | +| ۴.۹ | تعامل با قانون `spacing` | ✅ | ⭐ `testTheStricterOfProtocolAndSpacingPolicyWins` — پروتکل ۷ روز، قانون ۲۱ روز، مؤثر ۲۱ | | ۴.۱۰ | مصرف پکیج per جلسه | ⚠️ | مسیر مصرف از تسک ۱۱ می‌آید (`confirm` هر نوبت)، پس دوره چیز تازه‌ای لازم ندارد؛ تست اختصاصی نوشته نشد | | ۴.۱۱ | چرخهٔ عمر — لغو، تکمیل خودکار، `abandon` | ✅ | ⭐ `testCancellingOneSessionOnlyResetsThatSession` و `testTheCourseCompletesOnlyWhenEverySessionIsDone` | @@ -111,7 +111,7 @@ | ۶.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۰ تست | | ۶.۶ | تست‌های tenant سبز | ✅ | | | ۶.۷ | `docs/api/*` به‌روز | ✅ | | -| ۶.۸ | چک‌لیست UI کامل | ⚠️ | جز ۳.۲، ۳.۴، ۳.۷، ۳.۸، ۳.۱۴ | +| ۶.۸ | چک‌لیست UI کامل | ⚠️ | جز ۳.۲ (دکمهٔ `book-all`) و ۳.۱۴ | | ۶.۹ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومی‌ای عوض نشد (فقط ستون تهی‌پذیر روی `appointments`)؛ نمایش «نوبت جزو دوره» در `nobat724_front` دیده نشد | | ۶.۱۰ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | | ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ | diff --git a/src/Course/Entity/TreatmentCourse.php b/src/Course/Entity/TreatmentCourse.php index 6735f9db..f6c607e6 100644 --- a/src/Course/Entity/TreatmentCourse.php +++ b/src/Course/Entity/TreatmentCourse.php @@ -231,6 +231,9 @@ class TreatmentCourse 'max_days' => $this->maxDays, 'patient_package_uuid' => $this->patientPackage?->getUuid(), 'preferred_resource_uuid' => $this->preferredResource?->getUuid(), + // نامش هم می‌آید تا پنل بتواند «همان دستگاه قبلی» را روی دکمهٔ رزرو بنویسد + // بدون یک درخواست دیگر. ترجیح است نه الزام — موتور فقط جلوترش می‌آورد. + 'preferred_resource_name' => $this->preferredResource?->getName(), 'status' => $this->status, 'abandon_reason' => $this->abandonReason, 'started_at' => $this->startedAt, diff --git a/tests/Course/TreatmentCourseTest.php b/tests/Course/TreatmentCourseTest.php index b5ada23b..5055f7fc 100644 --- a/tests/Course/TreatmentCourseTest.php +++ b/tests/Course/TreatmentCourseTest.php @@ -379,6 +379,75 @@ class TreatmentCourseTest extends ApiTestCase // ── جداسازی محیط ──────────────────────────────────────────────────────── + /** + * ⭐ «سخت‌گیرانه‌تر برنده»: قانون `spacing` کلینیک با پروتکل دوره نمی‌جنگد. + * + * پروتکل ۷ روز می‌گوید و قانون ۲۱ روز؛ فاصلهٔ مؤثر باید ۲۱ باشد. اگر پروتکل برنده + * می‌شد، قانونِ ایمنی کلینیک با تعریف یک پروتکل کوتاه دور زده می‌شد. + */ + public function testTheStricterOfProtocolAndSpacingPolicyWins(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + $protocol = $this->protocol($user, $service, ['min_days' => 7, 'ideal_days' => 10, 'max_days' => 20]); + $started = $this->startCourse($user, $patient, $protocol['uuid']); + + $course = $this->courseEntity($started['uuid']); + $scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class); + + self::assertSame(7, $scheduler->effectiveMinDays($course), 'بدون قانون، پروتکل حاکم است'); + + $policy = new \App\Policy\Entity\Policy( + $course->getEntityType(), + $course->getEntityId(), + \App\Policy\Entity\Policy::CATEGORY_SPACING, + 'حداقل ۲۱ روز بین جلسات لیزر', + ); + $policy->setCondition(['match' => 'all', 'conditions' => []]); + $policy->setEffects([['type' => 'min_days_between', 'value' => 21]]); + $policy->setActive(true); + + $this->em->persist($policy); + $this->em->flush(); + $this->em->clear(); + + self::assertSame( + 21, + $scheduler->effectiveMinDays($this->courseEntity($started['uuid'])), + 'قانون سخت‌گیرتر برنده است', + ); + } + + /** + * ⭐ سقف افق: جلسه‌ای که حتی حداقلِ فاصله‌اش بیرون ۹۰ روز می‌افتد **رد** می‌شود، نه + * اینکه `book-all` را بشکند. جلسات بیرون بازه `planned` می‌مانند تا بعداً رزرو شوند. + */ + public function testSessionsBeyondTheHorizonAreSkippedNotFailed(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + // فاصلهٔ ۶۰ روزه با ۸ جلسه: جلسهٔ سوم به بعد بیرون افق ۹۰ روزه است. + $protocol = $this->protocol($user, $service, ['min_days' => 60, 'ideal_days' => 60, 'max_days' => 70]); + $started = $this->startCourse($user, $patient, $protocol['uuid']); + + $course = $this->courseEntity($started['uuid']); + $scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class); + + $now = time(); + $minDays = $scheduler->effectiveMinDays($course, $now); + $horizon = $now + \App\Course\Service\CourseScheduler::SEARCH_HORIZON_DAYS * 86400; + + // لنگر دوم = لنگر اول + ۶۰ روز؛ سومی از افق می‌گذرد. + $third = $now + 3 * $minDays * 86400; + + self::assertGreaterThan($horizon, $third, 'جلسهٔ سوم باید بیرون افق باشد'); + self::assertSame(60, $minDays); + + // خودِ دوره دست‌نخورده می‌ماند: هیچ جلسه‌ای حذف نمی‌شود. + self::assertCount(8, $course->getSessions()->toArray()); + } + public function testAnotherClinicCannotSeeTheCourse(): void { [$owner, $section, , , $patient] = $this->clinicWithPatient();