From e9e61adfee327b8d03e855a6aa4407fffc686f22 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 13:51:34 +0330 Subject: [PATCH] feat(course): show how the course is actually going, not just how it was planned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps on the treatment-course page, all of them about the difference between the protocol and reality. The sessions table listed each date but not the gap between them, leaving the operator to subtract two Jalali dates in their head. It now shows the real gap and colours it as a warning past the protocol maximum. A course cancelled mid-way stretches silently: the session goes back to planned and nobody is told. The suggestion endpoint does warn, but only once a branch is picked, so the warning could go unseen indefinitely. The page now derives "N days since the last session, past the protocol maximum" from the course itself, so it shows immediately. The course's preferred resource was applied by the engine but never named in the UI. The API now returns preferred_resource_name alongside the uuid, and the text says plainly that it is a preference — the engine moves it up the list, it does not hold the slot. Two backend tests that were owed: the stricter of the protocol spacing and a spacing policy wins (protocol 7 days, policy 21, effective 21 — otherwise a clinic's safety rule could be bypassed by writing a short protocol), and a session whose earliest possible date falls outside the 90-day horizon is skipped rather than failing book-all, leaving the course untouched. Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/pages/TreatmentCoursePage.test.tsx | 93 +++++++++++++++++++ assets/admin/pages/TreatmentCoursePage.tsx | 88 +++++++++++++++++- assets/admin/types/index.ts | 1 + docs/api/course.md | 7 ++ .../task-12-treatment-course/checklist.md | 14 +-- src/Course/Entity/TreatmentCourse.php | 3 + tests/Course/TreatmentCourseTest.php | 69 ++++++++++++++ 7 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 assets/admin/pages/TreatmentCoursePage.test.tsx 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();