feat(course): show how the course is actually going, not just how it was planned

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 13:51:34 +03:30
co-authored by Claude Opus 5
parent 83704f9d30
commit e9e61adfee
7 changed files with 267 additions and 8 deletions
@@ -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<typeof import('react-router-dom')>('react-router-dom')),
useParams: () => ({ courseUuid: 'c-1' }),
}));
import { api } from '../lib/api';
import TreatmentCoursePage from './TreatmentCoursePage';
const get = api.get as ReturnType<typeof vi.fn>;
const DAY = 86400;
const now = () => Math.floor(Date.now() / 1000);
function course(over: Record<string, unknown> = {}) {
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<string, unknown>) {
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(<TreatmentCoursePage />);
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(<TreatmentCoursePage />);
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(<TreatmentCoursePage />);
expect(await screen.findByText('۳۰ روز')).toBeInTheDocument();
});
});
+87 -1
View File
@@ -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<CourseSessionRow>[] = [
{
key: 'session_number',
@@ -59,6 +101,26 @@ export default function TreatmentCoursePage() {
<span style={{ fontSize: 13 }}>{s.slot_start === null ? '—' : formatDate(s.slot_start)}</span>
),
},
{
key: 'gap',
header: 'فاصله با قبلی',
render: (s) => {
const gap = gapBefore(s);
if (gap === null) return <span style={{ color: 'var(--text-3)' }}></span>;
const tooLong = course !== undefined && gap > course.max_days;
return (
<span
style={{ fontSize: 13, color: tooLong ? 'var(--warning)' : undefined }}
title={tooLong ? `بیش از حداکثر ${formatNumber(course!.max_days)} روزِ پروتکل` : undefined}
>
{formatNumber(gap)} روز
</span>
);
},
},
{
key: 'params',
header: 'پارامتر',
@@ -111,6 +173,22 @@ export default function TreatmentCoursePage() {
)}
</div>
{overdue !== null && (
<span
style={{
fontSize: 13,
lineHeight: 1.8,
color: 'var(--warning)',
background: 'var(--warning-bg)',
borderRadius: 'var(--r-sm)',
padding: '8px 10px',
}}
>
{formatNumber(overdue)} روز از آخرین جلسه گذشته بیشتر از حداکثر{' '}
{formatNumber(course.max_days)} روزِ پروتکل. جلسهٔ بعدی را دوباره زمانبندی کنید.
</span>
)}
{course.abandon_reason && (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>دلیل رهاکردن: {course.abandon_reason}</span>
)}
@@ -150,6 +228,14 @@ export default function TreatmentCoursePage() {
{suggestion.suggested_slots.map((s) => formatDate(s.start)).join('، ')}
</span>
)}
{/* ترجیح است نه الزام: موتور همان منبع را جلوتر می‌آورد ولی اگر آزاد نباشد
منبع دیگری می‌دهد. متن هم همین را می‌گوید تا انتظار اشتباه نسازد. */}
{course.preferred_resource_name && (
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>
ترجیح دوره: {course.preferred_resource_name} اگر آزاد نباشد منبع دیگری
پیشنهاد میشود.
</span>
)}
{suggestion.warning && (
<span style={{ color: 'var(--warning)' }}>{suggestion.warning}</span>
)}
+1
View File
@@ -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;
+7
View File
@@ -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 |
|---|---|---|
@@ -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 .` | ✅ | دو کامیت جدا |
| ۶.۱۱ | موارد به‌تعویق با دلیل | ✅ | ترجیح منبع (۱.۹/۱.۱۰/۱.۱۱/۳.۷/۴.۸) وابسته به بدهی تسک ۰۶ · بازچینی پس از لغو (۳.۸) تسک ۱۳ · رویدادها (۰.۳/۱.۱۶) تسک ۱۴ |
+3
View File
@@ -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,
+69
View File
@@ -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();