- 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.
214 lines
8.6 KiB
Markdown
214 lines
8.6 KiB
Markdown
# معماری — تسک ۱۲
|
||
|
||
## ساختار فایل
|
||
|
||
```
|
||
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`
|
||
- نوار پیشرفت باید فاصلهٔ واقعی بین جلسات را هم نشان دهد (۲۸ · ۳۱ · ۲۶ روز) — کلینیک از
|
||
همان میفهمد بیمار منظم است یا نه
|