# نکات پیاده‌سازی — تسک ۱۲ ## ۱. `book-all` همه یا هیچ ```php $this->em->wrapInTransaction(function () { /* همهٔ hold ها و confirm ها */ }); ``` اگر جلسهٔ ۵ وقت نداشت، جلسات ۱ تا ۴ هم rollback می‌شوند. رزرو نیمه‌کاره یعنی بیمار پیامک چهار نوبت می‌گیرد، فکر می‌کند دوره‌اش کامل رزرو شده، و چهار ماه بعد می‌فهمد نه. ⚠️ ولی رویدادها (پیامک) با `DispatchAfterCurrentBusStamp` بعد از commit می‌روند (تسک ۰۷)، پس در حالت rollback هیچ پیامکی نرفته. این وابستگی را جدی بگیر: اگر کسی در تسک ۰۷ `dispatch` را قبل از commit گذاشته باشد، اینجا هشت پیامک اشتباه می‌رود. ## ۲. لنگر متحرک، نه تاریخ ثابت ```php // ❌ فاصله از شروع دوره $target = $course->getStartedAt() + $n * $idealDays * 86400; // ✅ فاصله از جلسهٔ قبلی $anchor = $slot->start; // در هر تکرار حلقه به‌روز می‌شود ``` اگر جلسهٔ ۲ چهار روز دیرتر افتاد، جلسهٔ ۳ هم باید چهار روز جابه‌جا شود — وگرنه فاصلهٔ ۲ به ۳ می‌شود ۲۴ روز و از حداقل ۲۱ رد نمی‌شود ولی از نظر درمانی غلط است. ## ۳. لنگر پیشنهاد بعدی: آخرین جلسهٔ **انجام‌شده** ```php private function lastCompletedAt(TreatmentCourse $course): ?int { // status = completed، نه booked return $this->sessionRepo->maxCompletedAt($course); } ``` اگر از آخرین جلسهٔ `booked` حساب کنی، بیمار که نوبتش را لغو کرد یا نیامد، پیشنهاد بعدی غلط می‌شود. فقط جلسهٔ واقعاً انجام‌شده لنگر است. جلسهٔ اول دوره: لنگر `time()` است، یا `started_at`. ## ۴. snapshot پروتکل چهار فیلد فاصله و `params` هر جلسه کپی می‌شوند. تست: ```php // tests/Course/ProtocolSnapshotTest.php $course = $this->starter->start($patient, $service); // protocol: 8 جلسه، 28 روز $protocol->setIdealDays(14)->setSessionCount(4); $this->em->flush(); self::assertSame(28, $course->getIdealDays()); self::assertCount(8, $course->getSessions()); ``` قانون پنجم مستند. بدون این، کلینیک که پروتکل را عوض کند، دوره‌های در جریان ۵۰ بیمار یک‌شبه بی‌معنا می‌شوند. ## ۵. سقف ۹۰ روز و پیام روشن ۸ جلسه × ۲۸ روز = ۲۲۴ روز. جستجوی تسک ۰۶ فقط ۹۰ روز است. پس `book-all` معمولاً ۳ تا ۴ جلسه رزرو می‌کند و بقیه `planned` می‌مانند. پاسخ باید صریح بگوید: ```json { "booked_count": 3, "remaining_planned": 5, "message": "۳ جلسهٔ نخست رزرو شد. بقیهٔ جلسات خارج از بازهٔ مجاز رزرو (۹۰ روز) هستند و بعداً قابل رزروند." } ``` بدون این پیام، کاربر فکر می‌کند سیستم خراب است. ## ۶. `same_as_previous` اجباری نیست ```php foreach ($preferred as $id) { if (in_array($id, $freeIds, true)) return $id; } return $this->fallback->pick(…); // ← نه throw ``` اگر اپراتور جلسهٔ اول مرخصی است، بیمار نباید دو هفته منتظر بماند. ترجیح، نه الزام. اگر کلینیکی الزام واقعی داشت، آن یک قانون `resource` با `specific_resource` است (تسک ۰۹). ## ۷. تعامل با `spacing` تسک ۰۹ ```php $effectiveMin = max($course->getMinDays(), $this->policies->minDaysFor($ctx) ?? 0); $effectiveMax = min($course->getMaxDays(), $this->policies->maxDaysFor($ctx) ?? PHP_INT_MAX); if ($effectiveMin > $effectiveMax) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'قوانین کلینیک با پروتکل این دوره سازگار نیستند', 422); } ``` سخت‌گیرانه‌تر برنده. و اگر ترکیبشان بازهٔ تهی ساخت، خطای روشن — نه جستجوی بی‌نتیجه. ## ۸. اتصال به پکیج اگر `TreatmentCourse.package` پر باشد، هر `confirm` جلسه یک واحد اعتبار مصرف می‌کند (تسک ۱۱). `book-all` هشت جلسه یعنی هشت مصرف — پس پیش از شروع: ```php if ($course->getPackage() !== null) { $balance = $this->ledger->balance($course->getPackage()); if ($balance < count($plannedSessions)) { // خطا نیست — هشدار $result->addWarning(sprintf('اعتبار پکیج (%d) کمتر از جلسات باقی‌مانده (%d) است', $balance, $count)); } } ``` هشدار نه خطا: بیمار می‌تواند بقیه را نقدی بپردازد. ## ۹. edge case ها | حالت | رفتار درست | |---|---| | دورهٔ فعال دوم برای همان سرویس | `422` با uuid دورهٔ موجود در `meta` | | لغو جلسهٔ وسط دوره | `CourseSession` → `planned`، `appointment_id` → NULL، بقیه دست‌نخورده | | عدم حضور (`no_show`) در جلسه | `CourseSession` → `skipped`؛ لنگر همان جلسهٔ قبلی می‌ماند | | جلسهٔ آخر `completed` | دوره → `completed` خودکار + رویداد `CourseCompleted` | | `abandon` دورهٔ نیمه‌کاره | جلسات `booked` **لغو نمی‌شوند** خودکار — پاسخ شامل تعدادشان و لینک | | بیمار ۶۰ روز غیبت (> max) | `warning` در پیشنهاد؛ رزرو **مسدود نمی‌شود** | | پروتکل با `session_count = 1` | `422` — همان نوبت تکی است | | `params` با مقدار آرایه | `422` — فقط اسکالر | | حذف پروتکلی که دورهٔ فعال دارد | `422` (FK RESTRICT) — `active=false` مسیر درست | | دوره روی سرویسی که `bookable=false` شد | جلسات موجود می‌مانند؛ جلسهٔ جدید رزرو نمی‌شود، پیام روشن | سطر «abandon» عمدی است: لغو خودکار هشت نوبت آیندهٔ بیمار بدون تأیید صریح، عملی برگشت‌ناپذیر روی داده و ظرفیت کلینیک است. کاربر باید خودش تصمیم بگیرد. ## ۱۰. تست ``` tests/Course/CourseStarterTest.php - ۸ جلسهٔ planned با params درست - سرویس بدون پروتکل → 422 - دورهٔ فعال دوم → 422 با meta tests/Course/ProtocolSnapshotTest.php ← ⭐ قانون پنجم tests/Course/CourseSchedulerTest.php ← ⭐ - book-all: لنگر متحرک (فاصله از جلسهٔ قبلی، نه از شروع) - نزدیک‌ترین به ایده‌آل انتخاب می‌شود، نه اولین - شکست جلسهٔ N → rollback همهٔ ۱..N-1 - سقف ۹۰ روز → جلسات باقی planned + پیام tests/Course/NextSuggestionTest.php - لنگر = آخرین completed، نه booked - عبور از max → warning tests/Course/CourseProgressTest.php - completed/total/next_session_number/next_params tests/Course/SameResourcePreferenceTest.php - منبع جلسهٔ اول ترجیح داده می‌شود - منبع مشغول → fallback به least_gap، بدون خطا tests/Course/CoursePolicyInteractionTest.php - قانون سخت‌گیرانه‌تر برنده - بازهٔ تهی → 422 روشن tests/Course/CoursePackageTest.php - هر جلسه یک واحد مصرف - اعتبار کمتر از جلسات → warning نه error tests/Course/CourseLifecycleTest.php - لغو وسط دوره · no_show → skipped · جلسهٔ آخر → completed خودکار - abandon نوبت‌های booked را لغو نمی‌کند ``` ## ۱۱. مستندات `docs/api/course.md` بساز. حتماً بنویس: قاعدهٔ «سخت‌گیرانه‌تر برنده» بین پروتکل و قانون، رفتار سقف ۹۰ روز، و اینکه `abandon` نوبت‌ها را لغو نمی‌کند.