feat: implement cancellation policy, no-show tracking, and waitlist management
- Add implementation notes for cancellation and waitlist features. - Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting. - Establish architecture for domain events and outbox pattern to ensure reliable event publishing. - Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports. - Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
# معماری — تسک ۱۲
|
||||
|
||||
## ساختار فایل
|
||||
|
||||
```
|
||||
src/Course/
|
||||
├── Entity/
|
||||
│ ├── CourseProtocol.php
|
||||
│ ├── CourseProtocolStep.php # پارامتر هر جلسه
|
||||
│ ├── TreatmentCourse.php
|
||||
│ └── CourseSession.php
|
||||
├── Service/
|
||||
│ ├── CourseStarter.php # شروع دوره از پروتکل
|
||||
│ ├── CourseScheduler.php # رزرو یکجا + پیشنهاد جلسهٔ بعدی
|
||||
│ ├── CourseProgressCalculator.php
|
||||
│ └── CourseSessionLinker.php # اتصال نوبت ↔ جلسهٔ دوره
|
||||
├── Controller/{CourseProtocolController, TreatmentCourseController}.php
|
||||
└── Repository/…
|
||||
```
|
||||
|
||||
## `CourseProtocol` و `CourseProtocolStep`
|
||||
|
||||
```php
|
||||
class CourseProtocol
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
private ServiceItem $service;
|
||||
private int $sessionCount; // ۸
|
||||
private int $minDays; // ۲۱
|
||||
private int $idealDays; // ۲۸
|
||||
private int $maxDays; // ۴۵
|
||||
private bool $preferSameResource = true;
|
||||
private Collection $steps; // CourseProtocolStep
|
||||
}
|
||||
|
||||
class CourseProtocolStep
|
||||
{
|
||||
private int $sessionNumber; // ۱..۸
|
||||
private array $params = []; // {"energy": 12} — اسکالر، فهرست آزاد
|
||||
private ?int $overrideDurationMinutes = null; // جلسهٔ اول طولانیتر است
|
||||
}
|
||||
```
|
||||
|
||||
`params` آزاد است چون هر تخصص پارامتر خودش را دارد (سطح انرژی، ضخامت، دوز). ولی مثل
|
||||
`ClinicResource.attributes` فقط اسکالر — و هیچ منطقی به مقدارش وابسته نیست، فقط نمایش و
|
||||
ثبت میشود.
|
||||
|
||||
`minDays <= idealDays <= maxDays` قید اجباری.
|
||||
|
||||
## `TreatmentCourse` و `CourseSession`
|
||||
|
||||
```php
|
||||
class TreatmentCourse
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
|
||||
private PatientRecord $patient;
|
||||
private ServiceItem $service;
|
||||
private CourseProtocol $protocol;
|
||||
|
||||
// ── snapshot پروتکل در لحظهٔ شروع (قانون پنجم مستند) ──
|
||||
private int $sessionCount;
|
||||
private int $minDays;
|
||||
private int $idealDays;
|
||||
private int $maxDays;
|
||||
|
||||
private ?PatientPackage $package = null; // تسک ۱۱ — اختیاری
|
||||
private ?ClinicResource $preferredResource = null; // منبع جلسهٔ اول
|
||||
private string $status = self::STATUS_ACTIVE;
|
||||
private int $startedAt;
|
||||
}
|
||||
|
||||
class CourseSession
|
||||
{
|
||||
public const STATUS_PLANNED = 'planned';
|
||||
public const STATUS_BOOKED = 'booked';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
private TreatmentCourse $course;
|
||||
private int $sessionNumber;
|
||||
private array $params = []; // snapshot از CourseProtocolStep
|
||||
private ?Appointment $appointment = null;
|
||||
private string $status = self::STATUS_PLANNED;
|
||||
private ?int $completedAt = null;
|
||||
}
|
||||
```
|
||||
|
||||
چهار فیلد فاصله و `params` **کپی** میشوند نه FK: تغییر پروتکل فردا نباید دورهٔ در جریان
|
||||
را عوض کند. این همان تصمیمی است که در `appointment_segments` و `patient_packages` گرفته شد.
|
||||
|
||||
## `CourseScheduler` — رزرو یکجا
|
||||
|
||||
```php
|
||||
public function bookAll(TreatmentCourse $course): BookAllResult
|
||||
{
|
||||
return $this->em->wrapInTransaction(function () use ($course) {
|
||||
$anchor = $this->lastCompletedAt($course) ?? time();
|
||||
$planned = $course->plannedSessions(); // مرتب بر اساس sessionNumber
|
||||
$holds = [];
|
||||
|
||||
foreach ($planned as $session) {
|
||||
$target = $anchor + $course->getIdealDays() * 86400;
|
||||
|
||||
if ($target > time() + 90 * 86400) {
|
||||
// بیرون از بازهٔ مجاز جستجو — این و بقیه planned میمانند
|
||||
break;
|
||||
}
|
||||
|
||||
$slot = $this->findNearestInRange(
|
||||
$course, $session,
|
||||
min: $anchor + $course->getMinDays() * 86400,
|
||||
ideal: $target,
|
||||
max: $anchor + $course->getMaxDays() * 86400,
|
||||
);
|
||||
|
||||
if ($slot === null) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, sprintf(
|
||||
'برای جلسهٔ %d هیچ وقت مناسبی در بازهٔ مجاز پیدا نشد', $session->getSessionNumber()
|
||||
), 422);
|
||||
}
|
||||
|
||||
$holds[] = $this->holdService->hold($this->holdRequestFor($course, $session, $slot));
|
||||
$anchor = $slot->start; // ← لنگر جلسهٔ بعدی، همین جلسه
|
||||
}
|
||||
|
||||
foreach ($holds as $hold) { $this->bookingService->confirm($hold->uuid, $course->owner()); }
|
||||
return new BookAllResult(count($holds), count($planned) - count($holds));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
سه نکتهٔ حیاتی:
|
||||
|
||||
1. **همه یا هیچ** — کل حلقه در یک تراکنش. استثنا در جلسهٔ ۵ یعنی rollback جلسات ۱ تا ۴.
|
||||
رزرو نیمهکاره بدترین حالت است: بیمار فکر میکند دورهاش رزرو شده.
|
||||
2. **لنگر متحرک** — فاصله از جلسهٔ **قبلی** حساب میشود، نه از شروع دوره. اگر جلسهٔ ۲
|
||||
سه روز دیرتر افتاد، جلسهٔ ۳ هم جابهجا میشود.
|
||||
3. **سقف ۹۰ روز** — محدودیت جستجوی تسک ۰۶. جلسات بیرون بازه `planned` میمانند و بیمار
|
||||
بعداً رزرو میکند. پیام روشن اجباری است.
|
||||
|
||||
## `findNearestInRange` — نزدیکترین به ایدهآل
|
||||
|
||||
```php
|
||||
$slots = $this->availability->search($req->withRange($min, $max));
|
||||
if ($slots === []) return null;
|
||||
|
||||
usort($slots, fn($a, $b) => abs($a->start - $ideal) <=> abs($b->start - $ideal));
|
||||
return $slots[0];
|
||||
```
|
||||
|
||||
نزدیکترین به ایدهآل، نه اولین موجود. ۲۸ روز ایدهآل است؛ روز ۲۱ (حداقل) از نظر
|
||||
درمانی بدتر از روز ۲۷ است.
|
||||
|
||||
## `same_as_previous` — اتصال به تسک ۰۶
|
||||
|
||||
```php
|
||||
// SameAsPreviousPicker (تسک ۰۶) به یک ورودی نیاز دارد که تا حالا نداشت
|
||||
public function pick(array $freeIds, PlannedRequirement $req, OccupancyIndex $idx, AppointmentPlan $plan): int
|
||||
{
|
||||
$preferred = $plan->context()->preferredResourceIds ?? [];
|
||||
foreach ($preferred as $id) {
|
||||
if (in_array($id, $freeIds, true)) return $id;
|
||||
}
|
||||
return $this->fallback->pick($freeIds, $req, $idx, $plan); // least_gap
|
||||
}
|
||||
```
|
||||
|
||||
`preferredResourceIds` از `TreatmentCourse.preferredResource` میآید و در `PlanRequest`
|
||||
حمل میشود. اگر منبع ترجیحی آزاد نبود، **رزرو رد نمیشود** — به `least_gap` برمیگردد.
|
||||
اجبار به همان منبع یعنی بیمار دو هفته منتظر بماند.
|
||||
|
||||
## پیشنهاد جلسهٔ بعدی
|
||||
|
||||
```
|
||||
GET /treatment-course/{uuid}/next-slot-suggestion
|
||||
▼
|
||||
{
|
||||
"session_number": 4,
|
||||
"params": { "energy": 18 },
|
||||
"ideal_date": "1405-06-01",
|
||||
"range": { "min": "1405-05-25", "max": "1405-06-18" },
|
||||
"suggested_slots": [ … سه وقت نزدیک به ایدهآل … ],
|
||||
"warning": null
|
||||
}
|
||||
```
|
||||
|
||||
`warning` وقتی پر میشود که `now > lastCompleted + maxDays`:
|
||||
«از حداکثر فاصلهٔ مجاز (۴۵ روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید.»
|
||||
|
||||
## اتصال به `spacing` تسک ۰۹
|
||||
|
||||
قانون `spacing` و پروتکل دوره هر دو فاصله را محدود میکنند. **قانون برنده است** اگر
|
||||
سختگیرانهتر باشد:
|
||||
|
||||
```php
|
||||
$effectiveMin = max($course->getMinDays(), $policyMinDays ?? 0);
|
||||
```
|
||||
|
||||
دلیل: پروتکل پیشنهاد بالینی است، قانون سیاست کلینیک. سیاست کلینیک نمیتواند شلتر شود.
|
||||
این را در `docs/api/course.md` بنویس.
|
||||
|
||||
## پنل ادمین
|
||||
|
||||
- `CourseProtocolsPage.tsx` — پروتکل per سرویس + جدول پارامتر جلسات
|
||||
- `TreatmentCoursePage.tsx` — نوار پیشرفت («۳ از ۸»)، جدول جلسات با وضعیت و تاریخ،
|
||||
دکمهٔ «رزرو جلسهٔ بعدی» و «رزرو همهٔ جلسات»
|
||||
- کارت دورهها در `PatientDetailPage.tsx`
|
||||
- نوار پیشرفت باید فاصلهٔ واقعی بین جلسات را هم نشان دهد (۲۸ · ۳۱ · ۲۶ روز) — کلینیک از
|
||||
همان میفهمد بیمار منظم است یا نه
|
||||
@@ -0,0 +1,138 @@
|
||||
# دیتابیس — تسک ۱۲
|
||||
|
||||
## `course_protocols`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
|
||||
| `service_item_id` | INT NOT NULL | FK ON DELETE CASCADE |
|
||||
| `session_count` | SMALLINT NOT NULL | |
|
||||
| `min_days` | SMALLINT NOT NULL | |
|
||||
| `ideal_days` | SMALLINT NOT NULL | |
|
||||
| `max_days` | SMALLINT NOT NULL | |
|
||||
| `prefer_same_resource` | TINYINT(1) NOT NULL DEFAULT 1 | |
|
||||
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
|
||||
| `created_at`/`updated_at` | INT NOT NULL | |
|
||||
|
||||
```sql
|
||||
UNIQUE KEY uniq_protocol_service (service_item_id) -- یک پروتکل فعال per سرویس
|
||||
KEY idx_protocols_tenant (entity_type, entity_id, active)
|
||||
```
|
||||
|
||||
قید اپلیکیشنی: `min_days <= ideal_days <= max_days` و `session_count >= 2`
|
||||
(دورهٔ یکجلسهای همان نوبت تکی است).
|
||||
|
||||
## `course_protocol_steps`
|
||||
|
||||
```sql
|
||||
CREATE TABLE course_protocol_steps (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
protocol_id INT NOT NULL,
|
||||
session_number SMALLINT NOT NULL,
|
||||
params JSON NULL, -- {"energy": 12} — اسکالر
|
||||
override_duration_minutes SMALLINT NULL,
|
||||
UNIQUE KEY uniq_step (protocol_id, session_number),
|
||||
CONSTRAINT fk_step_protocol FOREIGN KEY (protocol_id) REFERENCES course_protocols(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
فرزند aggregate با ریشهٔ `CourseProtocol`.
|
||||
|
||||
## `treatment_courses`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
|
||||
| `patient_record_id` | INT NOT NULL | FK ON DELETE RESTRICT |
|
||||
| `service_item_id` | INT NOT NULL | FK ON DELETE RESTRICT |
|
||||
| `protocol_id` | INT NOT NULL | FK ON DELETE RESTRICT |
|
||||
| `session_count` | SMALLINT NOT NULL | **snapshot** |
|
||||
| `min_days` | SMALLINT NOT NULL | **snapshot** |
|
||||
| `ideal_days` | SMALLINT NOT NULL | **snapshot** |
|
||||
| `max_days` | SMALLINT NOT NULL | **snapshot** |
|
||||
| `patient_package_id` | INT NULL | FK → `patient_packages.id` ON DELETE SET NULL |
|
||||
| `preferred_resource_id` | INT NULL | FK → `clinic_resources.id` ON DELETE SET NULL |
|
||||
| `status` | VARCHAR(12) NOT NULL DEFAULT 'active' | `active`\|`completed`\|`abandoned` |
|
||||
| `abandon_reason` | VARCHAR(255) NULL | |
|
||||
| `started_at` | INT NOT NULL | |
|
||||
| `completed_at` | INT NULL | |
|
||||
| `created_at`/`updated_at` | INT NOT NULL | |
|
||||
|
||||
```sql
|
||||
KEY idx_courses_tenant (entity_type, entity_id, status, started_at)
|
||||
KEY idx_courses_patient (patient_record_id, status)
|
||||
UNIQUE KEY uniq_active_course (patient_record_id, service_item_id, status)
|
||||
```
|
||||
|
||||
⚠️ `uniq_active_course` با MariaDB روی مقدار `status` کار نمیکند به شکلی که فقط
|
||||
`active` را یکتا کند (چند ردیف `completed` مجازند). راه درست: **قید اپلیکیشنی** در
|
||||
`CourseStarter` + کلید یکتای جزئی که MariaDB ندارد.
|
||||
|
||||
جایگزین: یک ستون `active_course_key VARCHAR(64) NULL UNIQUE` با همان الگوی
|
||||
`Appointment.active_slot_key`:
|
||||
|
||||
```php
|
||||
$this->activeCourseKey = $this->status === self::STATUS_ACTIVE
|
||||
? sprintf('%d:%d', $this->patient->getId(), $this->service->getId())
|
||||
: null;
|
||||
```
|
||||
|
||||
الگوی اثباتشدهٔ همین کدبیس — استفادهاش کن، دوباره اختراع نکن.
|
||||
|
||||
## `course_sessions`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
|
||||
| `course_id` | INT NOT NULL | FK ON DELETE CASCADE |
|
||||
| `session_number` | SMALLINT NOT NULL | |
|
||||
| `params` | JSON NULL | **snapshot** از `course_protocol_steps` |
|
||||
| `appointment_id` | INT NULL UNIQUE | FK ON DELETE SET NULL |
|
||||
| `status` | VARCHAR(12) NOT NULL DEFAULT 'planned' | `planned`\|`booked`\|`completed`\|`skipped` |
|
||||
| `completed_at` | INT NULL | |
|
||||
| `created_at`/`updated_at` | INT NOT NULL | |
|
||||
|
||||
```sql
|
||||
UNIQUE KEY uniq_course_session (course_id, session_number)
|
||||
UNIQUE KEY uniq_session_appointment (appointment_id)
|
||||
KEY idx_sessions_tenant (entity_type, entity_id, status)
|
||||
KEY idx_sessions_course (course_id, session_number)
|
||||
```
|
||||
|
||||
`uniq_session_appointment`: یک نوبت به بیش از یک جلسهٔ دوره وصل نمیشود.
|
||||
|
||||
## تغییر `appointments`
|
||||
|
||||
```sql
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN course_session_id INT NULL,
|
||||
ADD CONSTRAINT fk_appointments_course_session
|
||||
FOREIGN KEY (course_session_id) REFERENCES course_sessions(id) ON DELETE SET NULL,
|
||||
ADD KEY idx_appointments_course_session (course_session_id);
|
||||
```
|
||||
|
||||
دو طرفه است (`course_sessions.appointment_id` هم وجود دارد) — عمدی: لیست نوبتهای پنل
|
||||
باید بدون JOIN بفهمد نوبت جزو دوره است، و صفحهٔ دوره باید بدون JOIN نوبت را پیدا کند.
|
||||
هر دو در `CourseSessionLinker` **همزمان** ست میشوند؛ هیچ جای دیگری ننویسد.
|
||||
|
||||
## Migration
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
بدون backfill.
|
||||
|
||||
## طبقهبندی tenant
|
||||
|
||||
| جدول | وضعیت |
|
||||
|---|---|
|
||||
| `course_protocols`, `treatment_courses`, `course_sessions` | جفت tenant |
|
||||
| `course_protocol_steps` | `AGGREGATE_CHILDREN` → ریشه `CourseProtocol` |
|
||||
@@ -0,0 +1,173 @@
|
||||
# نکات پیادهسازی — تسک ۱۲
|
||||
|
||||
## ۱. `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` نوبتها را لغو نمیکند.
|
||||
@@ -0,0 +1,78 @@
|
||||
# تسک ۱۲ — دوره درمان
|
||||
|
||||
**فاز:** ۳ (کسبوکار) · **وابستگی:** ۰۷، ۱۱ · **زمان:** ۱۶-۲۰ ساعت
|
||||
|
||||
---
|
||||
|
||||
## هدف
|
||||
|
||||
مستند بند ۱۳: «لیزر معمولاً شش تا هشت جلسه است. طراحی قبلی فقط نوبت تکی میشناخت، در
|
||||
حالی که این حالت اصلی کسبوکار است.»
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
هیچ مفهومی از دوره وجود ندارد. `PatientSession` وجود دارد ولی «مراجعهٔ انجامشده» است،
|
||||
نه جلسهٔ برنامهریزیشدهٔ یک دوره. تسک ۰۴ ستون `session_count` را به `ServiceItem` اضافه
|
||||
کرده ولی هیچ رفتاری به آن وصل نیست.
|
||||
|
||||
## دامنه
|
||||
|
||||
**هست:**
|
||||
- `CourseProtocol` — پروتکل دوره: تعداد جلسه، فاصلهٔ حداقل/ایدهآل/حداکثر، پارامتر هر جلسه
|
||||
- `TreatmentCourse` — دورهٔ یک بیمار
|
||||
- `CourseSession` — جلسات دوره (برنامهریزیشده یا انجامشده)
|
||||
- رزرو کل دوره یکجا، یا جلسهبهجلسه
|
||||
- پیشنهاد تاریخ جلسهٔ بعدی
|
||||
- هشدار عبور از حداکثر فاصله
|
||||
- ردیابی پیشرفت («جلسهٔ ۳ از ۸»)
|
||||
- ترجیح **همان منبع قبلی** (استراتژی `same_as_previous` تسک ۰۶)
|
||||
|
||||
**نیست:** موتور قانون فاصله (تسک ۰۹ — `spacing` از آن استفاده میشود)، پکیج (تسک ۱۱ —
|
||||
اتصال دارد ولی مستقل است).
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| GET/POST | `/api/v1/course-protocols` | پروتکل دوره per سرویس |
|
||||
| GET/PATCH/DELETE | `/api/v1/course-protocol/{uuid}` | |
|
||||
| POST | `/api/v1/treatment-course` | شروع دوره برای بیمار |
|
||||
| GET | `/api/v1/treatment-course/{uuid}` | جزئیات + جلسات + پیشرفت |
|
||||
| GET | `/api/v1/patient/{uuid}/courses` | دورههای بیمار |
|
||||
| POST | `/api/v1/treatment-course/{uuid}/book-all` | رزرو همهٔ جلسات باقیمانده |
|
||||
| GET | `/api/v1/treatment-course/{uuid}/next-slot-suggestion` | پیشنهاد تاریخ جلسهٔ بعدی |
|
||||
| POST | `/api/v1/treatment-course/{uuid}/abandon` | رهاکردن دوره با دلیل |
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: پروتکل «لیزر فولبادی: ۸ جلسه، حداقل ۲۱ / ایدهآل ۲۸ / حداکثر ۴۵ روز،
|
||||
سطح انرژی ۱۲،۱۴،۱۶،۱۸،۲۰،۲۰،۲۲،۲۲» تعریف میشود →
|
||||
`POST /treatment-course` هشت `CourseSession` با وضعیت `planned` میسازد و پارامتر هر
|
||||
جلسه را از پروتکل کپی میکند.
|
||||
- ✅ موفق: `POST /book-all` → هشت نوبت با فاصلهٔ ایدهآل ۲۸ روز رزرو میشود؛ هر جلسه به
|
||||
`CourseSession` متناظر لینک میشود. اگر روز ایدهآل ظرفیت نداشت، **نزدیکترین روز داخل
|
||||
بازهٔ حداقل..حداکثر** انتخاب میشود.
|
||||
- ✅ موفق: بعد از انجام جلسهٔ ۳، `GET /next-slot-suggestion` تاریخ ۲۸ روز بعد از **جلسهٔ ۳**
|
||||
را پیشنهاد میدهد (نه از شروع دوره).
|
||||
- ✅ موفق: بیمار ۵۰ روز از جلسهٔ قبل گذشته → پاسخ شامل
|
||||
`warning: 'از حداکثر فاصلهٔ مجاز (۴۵ روز) عبور شده است'`.
|
||||
- ✅ موفق: جلسهٔ ۲ به بعد، `same_as_previous` اپراتور جلسهٔ ۱ را انتخاب میکند اگر آزاد باشد.
|
||||
- ✅ موفق: پیشرفت — `GET /treatment-course/{uuid}` میدهد
|
||||
`{ completed: 3, total: 8, next_session_number: 4, next_params: { energy: 18 } }`.
|
||||
- ❌ خطا: `book-all` وقتی برای یکی از جلسات هیچ وقتی نیست → **هیچکدام رزرو نمیشود**،
|
||||
`422` با شمارهٔ جلسهٔ مشکلدار. رزرو نیمهکاره ممنوع.
|
||||
- ❌ خطا: شروع دوره برای سرویسی که پروتکل ندارد → `422`.
|
||||
- ⚠️ مرزی: بیمار دورهٔ فعال دیگری برای همان سرویس دارد → `422` با لینک به دورهٔ موجود.
|
||||
- ⚠️ مرزی: لغو یک جلسهٔ وسط دوره → آن `CourseSession` به `planned` برمیگردد، بقیه
|
||||
دستنخورده؛ پیشنهاد بعدی مبنایش آخرین جلسهٔ **انجامشده** است.
|
||||
- ⚠️ مرزی: دورهٔ متصل به پکیج (تسک ۱۱) → هر جلسه یک واحد اعتبار مصرف میکند.
|
||||
- ⚠️ مرزی: تعداد جلسات پروتکل تغییر کرد → دورههای فعال دستنخورده (snapshot).
|
||||
- ⚠️ مرزی: `book-all` بیشتر از بازهٔ ۹۰ روزهٔ مجاز (۸ جلسه × ۲۸ روز = ۲۲۴ روز) →
|
||||
فقط جلساتی که در ۹۰ روز جا میشوند رزرو شوند، بقیه `planned` بمانند + پیام روشن.
|
||||
|
||||
## خروجی
|
||||
|
||||
- `src/Course/`
|
||||
- `assets/admin/pages/CourseProtocolsPage.tsx` + `TreatmentCoursePage.tsx`
|
||||
- کارت «دورههای درمان» در `PatientDetailPage.tsx`
|
||||
- `docs/api/course.md`
|
||||
@@ -0,0 +1,149 @@
|
||||
# جریان کاربری — تسک ۱۲
|
||||
|
||||
## الف) کلینیک پروتکل دوره را تعریف میکند
|
||||
|
||||
```
|
||||
پنل › خدمات › لیزر فولبادی › تب «پروتکل دوره»
|
||||
│
|
||||
تعداد جلسات: ۸
|
||||
فاصلهٔ حداقل / ایدهآل / حداکثر: ۲۱ / ۲۸ / ۴۵ روز
|
||||
☑ تلاش برای انتخاب همان اپراتور جلسات قبل
|
||||
│
|
||||
پارامتر هر جلسه:
|
||||
┌──────┬──────────────┬────────────┐
|
||||
│ جلسه │ سطح انرژی │ مدت خاص │
|
||||
├──────┼──────────────┼────────────┤
|
||||
│ ۱ │ ۱۲ │ ۷۵ دقیقه │ ← جلسهٔ اول طولانیتر (تست و آموزش)
|
||||
│ ۲ │ ۱۴ │ — │
|
||||
│ … │ … │ — │
|
||||
│ ۸ │ ۲۲ │ — │
|
||||
└──────┴──────────────┴────────────┘
|
||||
▼
|
||||
POST /api/v1/course-protocols
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ب) شروع دوره و رزرو کل آن
|
||||
|
||||
```
|
||||
پنل › بیمار › «شروع دورهٔ درمان»
|
||||
سرویس: لیزر فولبادی (پروتکل خودکار بارگذاری میشود)
|
||||
پکیج: «۶ جلسه لیزر» ▾ (اختیاری — تسک ۱۱)
|
||||
▼
|
||||
POST /api/v1/treatment-course
|
||||
→ ۸ CourseSession با وضعیت «برنامهریزیشده»
|
||||
⚠️ «اعتبار پکیج (۶) کمتر از جلسات دوره (۸) است»
|
||||
▼
|
||||
صفحهٔ دوره:
|
||||
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ لیزر فولبادی — ز. احمدی ● دورهٔ فعال │
|
||||
│ ●●●○○○○○ ۳ از ۸ جلسه │
|
||||
├──────┬─────────────┬────────┬─────────┬────────────────┤
|
||||
│ جلسه │ تاریخ │ فاصله │ انرژی │ وضعیت │
|
||||
├──────┼─────────────┼────────┼─────────┼────────────────┤
|
||||
│ ۱ │ ۱۴۰۵/۰۳/۰۵ │ — │ ۱۲ │ ✔ انجامشده │
|
||||
│ ۲ │ ۱۴۰۵/۰۴/۰۲ │ ۲۸ روز │ ۱۴ │ ✔ انجامشده │
|
||||
│ ۳ │ ۱۴۰۵/۰۵/۰۳ │ ۳۱ روز │ ۱۶ │ ✔ انجامشده │
|
||||
│ ۴ │ ۱۴۰۵/۰۵/۳۱ │ ۲۸ روز │ ۱۸ │ ◷ رزروشده │
|
||||
│ ۵ │ — │ — │ ۲۰ │ ○ برنامهریزیشده│
|
||||
└──────┴─────────────┴────────┴─────────┴────────────────┘
|
||||
[رزرو جلسهٔ بعدی] [رزرو همهٔ جلسات باقیمانده]
|
||||
```
|
||||
|
||||
ستون «فاصله» عدد واقعی است، نه ایدهآل. کلینیک از آن میفهمد بیمار منظم است یا نه.
|
||||
|
||||
```
|
||||
«رزرو همهٔ جلسات باقیمانده»
|
||||
▼
|
||||
POST /treatment-course/{uuid}/book-all
|
||||
│
|
||||
├─ لنگر: تاریخ جلسهٔ ۴ (آخرین رزروشده)
|
||||
├─ جلسهٔ ۵: هدف ۲۸ روز بعد → نزدیکترین وقت در بازهٔ ۲۱..۴۵ روز
|
||||
├─ جلسهٔ ۶: لنگر = تاریخ واقعی جلسهٔ ۵
|
||||
├─ جلسهٔ ۷: خارج از ۹۰ روز → planned میماند
|
||||
└─ همه در یک تراکنش
|
||||
▼
|
||||
200 { "booked_count": 2, "remaining_planned": 2,
|
||||
"message": "۲ جلسه رزرو شد. جلسات ۷ و ۸ خارج از بازهٔ مجاز رزرو (۹۰ روز) هستند." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ج) پیشنهاد جلسهٔ بعدی بعد از هر جلسه
|
||||
|
||||
```
|
||||
منشی وضعیت جلسهٔ ۳ را «انجامشده» میکند
|
||||
▼
|
||||
سیستم خودکار بنر نشان میدهد:
|
||||
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ 📅 جلسهٔ بعدی این بیمار │
|
||||
│ جلسهٔ ۴ از ۸ · سطح انرژی: ۱۸ │
|
||||
│ تاریخ پیشنهادی: ۱۴۰۵/۰۵/۳۱ (۲۸ روز بعد) │
|
||||
│ بازهٔ مجاز: ۱۴۰۵/۰۵/۲۴ تا ۱۴۰۵/۰۶/۱۷ │
|
||||
│ │
|
||||
│ ۰۹:۰۰ ▸ ۱۱:۳۰ ▸ ۱۴:۰۰ ▸ │
|
||||
│ [رزرو با اپراتور مریم]│
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
«اپراتور مریم» چون جلسات ۱ تا ۳ با او بود (`same_as_previous`). اگر آزاد نباشد، نامش
|
||||
عوض میشود و رزرو رد نمیشود.
|
||||
|
||||
---
|
||||
|
||||
## د) بیمار دیر میآید — عبور از حداکثر فاصله
|
||||
|
||||
```
|
||||
۶۰ روز از جلسهٔ ۳ گذشته (حداکثر ۴۵ روز)
|
||||
▼
|
||||
GET /treatment-course/{uuid}/next-slot-suggestion
|
||||
▼
|
||||
{
|
||||
"session_number": 4,
|
||||
"warning": "از حداکثر فاصلهٔ مجاز (۴۵ روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید.",
|
||||
"suggested_slots": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
در UI یک نوار زرد بالای پیشنهادها. **رزرو مسدود نمیشود** — تصمیم بالینی است، نه فنی.
|
||||
اگر کلینیکی میخواهد واقعاً مسدود شود، آن یک قانون `eligibility` است (تسک ۰۹).
|
||||
|
||||
---
|
||||
|
||||
## ه) لغو جلسهٔ وسط دوره
|
||||
|
||||
```
|
||||
جلسهٔ ۴ لغو میشود
|
||||
▼
|
||||
├─ Appointment → cancelled_*
|
||||
├─ CourseSession ۴ → planned ، appointment_id → NULL
|
||||
├─ اعتبار پکیج → refund +1 (تسک ۱۱)
|
||||
├─ اشغال منابع → released (تسک ۰۷)
|
||||
└─ جلسات ۵..۸ دستنخورده
|
||||
▼
|
||||
پیشنهاد بعدی: لنگر همان جلسهٔ ۳ (آخرین انجامشده)
|
||||
```
|
||||
|
||||
جلسات بعدی خودکار جابهجا **نمیشوند**. جابهجایی زنجیرهای پنج نوبت آیندهٔ بیمار بدون
|
||||
تأیید، همان مسئلهٔ `abandon` است: عمل برگشتناپذیر روی داده و ظرفیت.
|
||||
|
||||
پنل یک پیشنهاد نشان میدهد: «فاصلهٔ جلسات ۵ تا ۸ با لغو این جلسه از پروتکل خارج شد.
|
||||
[بازچینی جلسات باقیمانده]» — با یک کلیک صریح.
|
||||
|
||||
---
|
||||
|
||||
## و) پایان دوره
|
||||
|
||||
```
|
||||
جلسهٔ ۸ → completed
|
||||
▼
|
||||
├─ TreatmentCourse → completed ، completed_at = now
|
||||
├─ active_course_key → NULL (بیمار میتواند دورهٔ جدید شروع کند)
|
||||
└─ رویداد CourseCompleted (تسک ۱۴)
|
||||
▼
|
||||
کارت بیمار: «دورهٔ لیزر فولبادی تکمیل شد — ۸ جلسه در ۲۳۱ روز»
|
||||
[شروع دورهٔ نگهدارنده]
|
||||
```
|
||||
Reference in New Issue
Block a user