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,191 @@
|
||||
# معماری — تسک ۱۳
|
||||
|
||||
## ساختار فایل
|
||||
|
||||
```
|
||||
src/Cancellation/
|
||||
├── Entity/{CancellationPolicy, NoShowRecord}.php
|
||||
├── Service/
|
||||
│ ├── CancellationPolicyResolver.php # اختصاصیترین سیاست
|
||||
│ ├── PenaltyCalculator.php
|
||||
│ ├── CancellationService.php # ارکستراتور لغو
|
||||
│ └── NoShowTracker.php
|
||||
└── Controller/CancellationController.php
|
||||
|
||||
src/Waitlist/
|
||||
├── Entity/WaitlistEntry.php
|
||||
├── Service/
|
||||
│ ├── WaitlistService.php
|
||||
│ └── WaitlistMatcher.php # تطبیق ظرفیت آزاد با درخواستها
|
||||
├── MessageHandler/NotifyWaitlistHandler.php
|
||||
└── Controller/WaitlistController.php
|
||||
```
|
||||
|
||||
## `CancellationPolicy`
|
||||
|
||||
```php
|
||||
class CancellationPolicy
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
private ?ServiceItem $service = null; // null = پیشفرض محیط
|
||||
private int $freeWindowHours = 24; // تا چند ساعت قبل، رایگان
|
||||
private string $penaltyMode = 'percent'; // none | percent | fixed
|
||||
private int $penaltyValue = 0;
|
||||
private bool $depositRefundable = false; // پس از پنجرهٔ رایگان
|
||||
private bool $creditRefundable = true; // اعتبار پکیج (تسک ۱۱)
|
||||
private int $noShowThreshold = 3; // بعد از چند بار، برچسب پرریسک
|
||||
private ?string $riskTagUuid = null; // TenantTag موجود
|
||||
}
|
||||
```
|
||||
|
||||
`riskTagUuid` به `TenantTag` موجود اشاره میکند، نه یک ستون `is_risky` روی بیمار.
|
||||
دلیل: سیستم برچسب از قبل هست، در `DiscountRule.target_tag_uuid` و `FieldRegistry`
|
||||
(`patient.tags`) استفاده میشود، و قانون `eligibility` تسک ۰۹ میتواند رویش شرط بگذارد.
|
||||
ستون بولین جدید یعنی یک مفهوم موازی که هیچکدام از آنها نمیبینند.
|
||||
|
||||
## `PenaltyCalculator`
|
||||
|
||||
```php
|
||||
public function forCancellation(Appointment $appt, string $by, int $now): PenaltyResult
|
||||
{
|
||||
// لغو توسط کلینیک: هرگز جریمه
|
||||
if ($by === Appointment::STATUS_CANCELLED_BY_DOCTOR) {
|
||||
return PenaltyResult::free();
|
||||
}
|
||||
|
||||
$policy = $this->resolver->forAppointment($appt);
|
||||
$hoursLeft = intdiv($appt->getSlotStart() - $now, 3600);
|
||||
|
||||
if ($hoursLeft >= $policy->getFreeWindowHours()) {
|
||||
return PenaltyResult::free();
|
||||
}
|
||||
|
||||
$paid = $this->paymentRepo->totalPaidFor($appt);
|
||||
$penalty = match ($policy->getPenaltyMode()) {
|
||||
'percent' => intdiv($this->snapshotFinal($appt) * $policy->getPenaltyValue(), 100),
|
||||
'fixed' => $policy->getPenaltyValue(),
|
||||
default => 0,
|
||||
};
|
||||
|
||||
return new PenaltyResult(
|
||||
penaltyRials: min($penalty, $paid), // ← سقف: مبلغ پرداختی
|
||||
depositRefundable: $policy->isDepositRefundable(),
|
||||
creditRefundable: $policy->isCreditRefundable(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`min($penalty, $paid)` مهم است: جریمهٔ بیشتر از پرداختی یعنی بدهی — که مسئلهٔ حسابداری
|
||||
است، نه لغو. برای نوبت نقدی (`$paid = 0`) جریمه صفر میشود و در پاسخ یک
|
||||
`note: 'جریمه در مراجعهٔ بعدی محاسبه میشود'` میآید.
|
||||
|
||||
## `cancellation-preview` — اجباری پیش از لغو
|
||||
|
||||
```
|
||||
GET /appointment/{uuid}/cancellation-preview
|
||||
▼
|
||||
{
|
||||
"hours_left": 6,
|
||||
"free_window_hours": 24,
|
||||
"penalty_rials": 1200000,
|
||||
"deposit_refundable": false,
|
||||
"credit_refundable": true,
|
||||
"refund_rials": 800000,
|
||||
"message": "لغو در کمتر از ۲۴ ساعت باقیمانده ۵۰٪ جریمه دارد."
|
||||
}
|
||||
```
|
||||
|
||||
بدون این endpoint، کاربر لغو میکند و بعد جریمه میبیند. UI باید preview را در
|
||||
`ConfirmDialog` نشان دهد.
|
||||
|
||||
## `CancellationService` — ترتیب
|
||||
|
||||
```php
|
||||
$this->em->wrapInTransaction(function () use ($appt, $by, $reason) {
|
||||
$penalty = $this->penalty->forCancellation($appt, $by, time());
|
||||
|
||||
$this->transition($appt, $by); // ۱ وضعیت
|
||||
$this->occupancyWriter->release($appt); // ۲ آزادسازی منابع (تسک ۰۷)
|
||||
$this->refundDeposit($appt, $penalty); // ۳ بیعانه
|
||||
$this->chargePenalty($appt, $penalty); // ۴ جریمه در wallet_transactions
|
||||
$this->refundCredit($appt, $penalty); // ۵ اعتبار پکیج (تسک ۱۱)
|
||||
$this->courseLinker->releaseSession($appt); // ۶ جلسهٔ دوره (تسک ۱۲)
|
||||
$this->events->dispatch(new AppointmentCancelled($appt->getUuid())); // ۷ بعد از commit
|
||||
});
|
||||
```
|
||||
|
||||
مرحلهٔ ۷ رویداد است که `NotifyWaitlistHandler` به آن گوش میدهد — لیست انتظار async
|
||||
مطلع میشود، نه در تراکنش لغو.
|
||||
|
||||
## `WaitlistEntry`
|
||||
|
||||
```php
|
||||
class WaitlistEntry
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
private PatientRecord $patient;
|
||||
private ServiceItem $service;
|
||||
private ?Branch $branch = null;
|
||||
private int $desiredFrom; // بازهٔ دلخواه
|
||||
private int $desiredTo;
|
||||
private array $preferredDayParts = []; // ['morning','afternoon','evening']
|
||||
private int $priority = 0;
|
||||
private ?int $notifiedAt = null;
|
||||
private int $notifyCount = 0;
|
||||
private string $status = 'waiting'; // waiting | notified | converted | expired
|
||||
}
|
||||
```
|
||||
|
||||
## `WaitlistMatcher` — همه مطلع میشوند، صف انحصاری نه
|
||||
|
||||
```php
|
||||
public function onCapacityFreed(int $from, int $to, ServiceItem $service, ?Branch $branch): void
|
||||
{
|
||||
$matches = $this->repo->findMatching($from, $to, $service, $branch, limit: 10);
|
||||
foreach ($matches as $entry) {
|
||||
$this->bus->dispatch(new NotifyWaitlistMessage($entry->getUuid()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**تصمیم: broadcast، نه قفل انحصاری.**
|
||||
|
||||
| گزینه | مشکل |
|
||||
|---|---|
|
||||
| قفل انحصاری برای نفر اول (مثلاً ۳۰ دقیقه) | نفر اول ممکن است شب باشد و پیام را نبیند؛ ظرفیت ۳۰ دقیقه بلوکه و بعد نفر دوم، و همینطور — یک ساعت خالی میتواند سه ساعت معطل بماند |
|
||||
| **اطلاع به همه، اولین رزروکننده میبرد** ✅ | ظرفیت سریع پر میشود؛ هزینهاش این است که چند نفر پیام میگیرند و جا نیست |
|
||||
|
||||
هزینهٔ گزینهٔ دوم با یک جملهٔ صریح در پیامک قابل مدیریت است:
|
||||
«یک وقت آزاد شد. اولین نفری که رزرو کند آن را میگیرد.»
|
||||
|
||||
سقف ۱۰ نفر برای جلوگیری از انبوه پیامک. `priority` ترتیب را تعیین میکند (بیمار وفادار
|
||||
یا پکیجدار میتواند اولویت بگیرد).
|
||||
|
||||
## `NoShowTracker`
|
||||
|
||||
```php
|
||||
public function record(Appointment $appt): void
|
||||
{
|
||||
$this->em->persist(new NoShowRecord($appt));
|
||||
$count = $this->repo->countForPatient($appt->patientRecord(), since: $this->windowStart());
|
||||
$policy = $this->resolver->forTenant($appt->tenantPair());
|
||||
|
||||
if ($count >= $policy->getNoShowThreshold() && $policy->getRiskTagUuid() !== null) {
|
||||
$this->tagService->attach($appt->patientRecord(), $policy->getRiskTagUuid());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
پنجرهٔ شمارش: ۱۲ ماه گذشته (نه کل عمر). بیماری که سه سال پیش سه بار نیامده، امروز
|
||||
پرریسک نیست.
|
||||
|
||||
## پنل ادمین
|
||||
|
||||
- `CancellationPolicyPage.tsx` — سیاست محیط + جدول override سرویسها
|
||||
- `WaitlistPage.tsx` — لیست درخواستها با فیلتر بازه/سرویس، و تب «قابل تطبیق» که
|
||||
ظرفیتهای آزاد شده و کاندیدهایشان را نشان میدهد
|
||||
- در `AppointmentDetailPage.tsx` دکمهٔ لغو → `ConfirmDialog` با محتوای preview
|
||||
- در `PatientDetailPage.tsx` نشان «پرریسک» + شمارش عدم حضور
|
||||
- `ReserveAppointmentsPage.tsx` موجود میماند (نوبت رزرو روزی) — مفهوم متفاوتی است و
|
||||
ادغامشان با لیست انتظار خارج از دامنهٔ این تسک است
|
||||
@@ -0,0 +1,141 @@
|
||||
# دیتابیس — تسک ۱۳
|
||||
|
||||
## `cancellation_policies`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
|
||||
| `service_item_id` | INT NULL | NULL = پیشفرض محیط — FK ON DELETE CASCADE |
|
||||
| `free_window_hours` | SMALLINT NOT NULL DEFAULT 24 | |
|
||||
| `penalty_mode` | VARCHAR(10) NOT NULL DEFAULT 'none' | `none`\|`percent`\|`fixed` |
|
||||
| `penalty_value` | INT NOT NULL DEFAULT 0 | درصد ۰..۱۰۰ یا ریال |
|
||||
| `deposit_refundable` | TINYINT(1) NOT NULL DEFAULT 0 | پس از پنجرهٔ رایگان |
|
||||
| `credit_refundable` | TINYINT(1) NOT NULL DEFAULT 1 | اعتبار پکیج |
|
||||
| `no_show_threshold` | SMALLINT NOT NULL DEFAULT 3 | |
|
||||
| `risk_tag_uuid` | VARCHAR(36) NULL | ارجاع به `tenant_tags.uuid` — بدون FK، الگوی موجود پروژه |
|
||||
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
|
||||
| `created_at`/`updated_at` | INT NOT NULL | |
|
||||
|
||||
```sql
|
||||
UNIQUE KEY uniq_cancel_policy_scope (entity_type, entity_id, service_item_id)
|
||||
KEY idx_cancel_policies_tenant (entity_type, entity_id, active)
|
||||
```
|
||||
|
||||
`risk_tag_uuid` بدون FK — همان الگوی `DiscountRule.target_tag_uuid` موجود.
|
||||
|
||||
## `no_show_records`
|
||||
|
||||
```sql
|
||||
CREATE TABLE no_show_records (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
uuid VARCHAR(36) NOT NULL UNIQUE,
|
||||
entity_type VARCHAR(10) NOT NULL,
|
||||
entity_id INT NOT NULL,
|
||||
patient_record_id INT NOT NULL,
|
||||
appointment_id INT NOT NULL,
|
||||
recorded_at INT NOT NULL,
|
||||
recorded_by INT NULL,
|
||||
UNIQUE KEY uniq_no_show_appointment (appointment_id), -- یک بار per نوبت
|
||||
KEY idx_no_show_patient (patient_record_id, recorded_at), -- کوئری شمارش ۱۲ ماه
|
||||
KEY idx_no_show_tenant (entity_type, entity_id, recorded_at),
|
||||
CONSTRAINT fk_ns_patient FOREIGN KEY (patient_record_id) REFERENCES patient_records(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ns_appt FOREIGN KEY (appointment_id) REFERENCES appointments(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
جدول جدا و نه یک ستون شمارنده روی بیمار — همان استدلال دفتر اعتبار تسک ۱۱:
|
||||
شمارنده، «چه زمانی و کدام نوبت» را از دست میدهد و پنجرهٔ ۱۲ ماهه غیرقابل محاسبه میشود.
|
||||
|
||||
`uniq_no_show_appointment`: تغییر وضعیت به `no_show` ممکن است دوبار اتفاق بیفتد
|
||||
(idempotency)؛ رکورد دوم ثبت نشود.
|
||||
|
||||
## `waitlist_entries`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `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 CASCADE |
|
||||
| `service_item_id` | INT NOT NULL | FK ON DELETE CASCADE |
|
||||
| `branch_id` | INT NULL | FK ON DELETE CASCADE |
|
||||
| `desired_from` | INT NOT NULL | |
|
||||
| `desired_to` | INT NOT NULL | |
|
||||
| `preferred_day_parts` | JSON NULL | `["morning","evening"]` |
|
||||
| `priority` | SMALLINT NOT NULL DEFAULT 0 | |
|
||||
| `status` | VARCHAR(12) NOT NULL DEFAULT 'waiting' | `waiting`\|`notified`\|`converted`\|`expired` |
|
||||
| `notified_at` | INT NULL | آخرین اطلاع |
|
||||
| `notify_count` | SMALLINT NOT NULL DEFAULT 0 | سقف برای جلوگیری از اسپم |
|
||||
| `converted_appointment_id` | INT NULL | FK ON DELETE SET NULL |
|
||||
| `created_at`/`updated_at` | INT NOT NULL | |
|
||||
|
||||
```sql
|
||||
KEY idx_waitlist_match (service_item_id, branch_id, status, desired_from, desired_to)
|
||||
KEY idx_waitlist_tenant (entity_type, entity_id, status, created_at)
|
||||
KEY idx_waitlist_patient (patient_record_id, status)
|
||||
```
|
||||
|
||||
`idx_waitlist_match` کوئری داغ است: «چه کسانی منتظر این سرویس در این بازهاند؟»
|
||||
|
||||
```sql
|
||||
SELECT * FROM waitlist_entries
|
||||
WHERE service_item_id = ? AND (branch_id = ? OR branch_id IS NULL)
|
||||
AND status = 'waiting'
|
||||
AND desired_from <= :freedEnd AND desired_to >= :freedStart
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
LIMIT 10
|
||||
```
|
||||
|
||||
`preferred_day_parts` در PHP فیلتر میشود (JSON قابل ایندکس مطمئن نیست و نتیجه ≤ ۱۰ ردیف است).
|
||||
|
||||
## هیچ تغییری در `appointments`
|
||||
|
||||
وضعیتهای `cancelled_by_user`, `cancelled_by_doctor`, `no_show` از قبل هستند.
|
||||
`deposit_amount_rials` هم.
|
||||
|
||||
## جریمه در دفتر مالی موجود
|
||||
|
||||
جدول جدید ندارد. `WalletTransaction` موجود استفاده میشود:
|
||||
|
||||
```php
|
||||
new WalletTransaction(
|
||||
user: $appt->getUser(),
|
||||
amountRials: -$penalty,
|
||||
kind: 'cancellation_penalty', // ← مقدار جدید در enum موجود
|
||||
reference: $appt->getUuid(),
|
||||
);
|
||||
$tx->setRecordedEntity($appt->getEntityType(), $appt->getEntityId()); // per-محیط، طبق tenancy.md
|
||||
```
|
||||
|
||||
`setRecordedEntity` اجباری است، وگرنه جریمه در دفتر همهٔ محیطها دیده میشود
|
||||
(`docs/architecture/tenancy.md`، بخش کیف پول).
|
||||
|
||||
## Migration
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
ddev exec php bin/console app:cancellation:seed-default-policy --force
|
||||
```
|
||||
|
||||
`seed-default-policy` برای هر محیط یک سیاست پیشفرض محافظهکار میسازد:
|
||||
`free_window_hours=24, penalty_mode=none, deposit_refundable=true, credit_refundable=true`.
|
||||
|
||||
**پیشفرض بدون جریمه** عمدی است: فعال شدن ناگهانی جریمه روی بیماران موجود، شکایت است.
|
||||
کلینیک خودش باید فعالش کند.
|
||||
|
||||
## پاکسازی
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:waitlist:expire # روزانه
|
||||
```
|
||||
|
||||
ورودیهایی که `desired_to` گذشته → `status='expired'`.
|
||||
|
||||
## طبقهبندی tenant
|
||||
|
||||
| جدول | وضعیت |
|
||||
|---|---|
|
||||
| `cancellation_policies`, `no_show_records`, `waitlist_entries` | جفت tenant |
|
||||
@@ -0,0 +1,157 @@
|
||||
# نکات پیادهسازی — تسک ۱۳
|
||||
|
||||
## ۱. پیشفرض بدون جریمه
|
||||
|
||||
`seed-default-policy` باید `penalty_mode='none'` بسازد. اگر پیشفرض جریمهدار باشد،
|
||||
لحظهٔ deploy همهٔ بیماران با نوبت آیندهٔ نزدیک مشمول جریمه میشوند و کلینیک خبر ندارد.
|
||||
|
||||
فعالسازی جریمه یک تصمیم کسبوکاری صریح است، نه پیشفرض فنی.
|
||||
|
||||
## ۲. لغو توسط کلینیک هرگز جریمه ندارد
|
||||
|
||||
```php
|
||||
if ($by === Appointment::STATUS_CANCELLED_BY_DOCTOR) {
|
||||
return PenaltyResult::free(); // ← اول از همه، پیش از هر محاسبه
|
||||
}
|
||||
```
|
||||
|
||||
این شرط باید **اولین** خط باشد. اگر بعد از محاسبهٔ پنجرهٔ زمانی بیاید، یک refactor
|
||||
میتواند ترتیب را عوض کند و کلینیک از بیمار برای لغو خودش جریمه بگیرد.
|
||||
|
||||
## ۳. سقف جریمه = مبلغ پرداختی
|
||||
|
||||
```php
|
||||
penaltyRials: min($penalty, $paid)
|
||||
```
|
||||
|
||||
جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ `Invoice`/`Claim` است نه لغو. برای
|
||||
نوبت نقدی (`$paid = 0`) جریمه صفر میشود و پاسخ یک `note` میگیرد.
|
||||
|
||||
اگر روزی «بدهی لغو» لازم شد، یک تسک جدا با اتصال به `Billing` — نه یک مقدار منفی
|
||||
پنهان در کیف پول.
|
||||
|
||||
## ۴. `setRecordedEntity` روی تراکنش کیف پول
|
||||
|
||||
```php
|
||||
$tx->setRecordedEntity($appt->getEntityType(), $appt->getEntityId());
|
||||
```
|
||||
|
||||
فراموش کردنش یعنی کلینیک الف جریمهٔ ثبتشده در کلینیک ب را میبیند — دقیقاً همان نشتی
|
||||
که `PatientWalletTenantTest` میسنجد. آن تست باید بعد از این تسک هم سبز بماند.
|
||||
|
||||
## ۵. لیست انتظار: broadcast، با جملهٔ صریح
|
||||
|
||||
تصمیم معماری (جدول کامل در `architecture.md`): ظرفیت آزادشده به حداکثر ۱۰ نفر اطلاع
|
||||
داده میشود و اولین رزروکننده میبرد.
|
||||
|
||||
متن پیامک اجباراً شامل این جمله:
|
||||
|
||||
> «یک وقت در تاریخ X آزاد شد. اولین نفری که رزرو کند آن را میگیرد.»
|
||||
|
||||
بدون این جمله، ۹ نفر فکر میکنند نوبتشان تضمین شده و شکایت میکنند. با آن، انتظار
|
||||
درست تنظیم میشود.
|
||||
|
||||
`notify_count` سقف دارد (پیشنهاد: ۳). بیماری که سه بار مطلع شده و رزرو نکرده، دیگر
|
||||
پیام نمیگیرد تا خودش لیست را تازه کند.
|
||||
|
||||
## ۶. اطلاعرسانی async، بیرون تراکنش لغو
|
||||
|
||||
```php
|
||||
// CancellationService — داخل تراکنش فقط dispatch
|
||||
$this->events->dispatch(
|
||||
(new Envelope(new AppointmentCancelled($appt->getUuid())))
|
||||
->with(new DispatchAfterCurrentBusStamp())
|
||||
);
|
||||
|
||||
// NotifyWaitlistHandler — بیرون، async
|
||||
public function __invoke(AppointmentCancelled $event): void
|
||||
{
|
||||
$appt = $this->repo->findByUuid($event->appointmentUuid);
|
||||
$this->matcher->onCapacityFreed($appt->getSlotStart(), $appt->getSlotEnd(), …);
|
||||
}
|
||||
```
|
||||
|
||||
ده پیامک داخل تراکنش لغو یعنی لغو کند میشود و اگر پیامک شکست خورد، لغو rollback
|
||||
میشود — که غلط است. لغو موفق است حتی اگر هیچ پیامکی نرود.
|
||||
|
||||
`messenger:consume async` از قبل در استک هست.
|
||||
|
||||
## ۷. برچسب پرریسک، نه مسدودسازی
|
||||
|
||||
```php
|
||||
$this->tagService->attach($patient, $policy->getRiskTagUuid());
|
||||
// نه: $patient->setBlocked(true)
|
||||
```
|
||||
|
||||
مسدودسازی یک تصمیم است که کلینیک باید بگیرد، و ابزارش از قبل ساخته میشود: یک قانون
|
||||
`eligibility` (تسک ۰۹) با شرط `patient.tags in ['پرریسک']` و اثر `deny`.
|
||||
|
||||
اگر اینجا مسدود کنی، دو مکانیزم موازی برای یک کار داری و کلینیک نمیتواند خاموشش کند.
|
||||
|
||||
## ۸. پنجرهٔ شمارش عدم حضور
|
||||
|
||||
```php
|
||||
private function windowStart(): int { return time() - 365 * 86400; }
|
||||
```
|
||||
|
||||
۱۲ ماه، نه کل تاریخ. سه عدم حضور در سال ۱۴۰۲ امروز بیمعناست. مقدار را ثابت نگه دار
|
||||
(نه تنظیمپذیر) تا شمارش بین کلینیکها قابل مقایسه بماند؛ اگر لازم شد، ستون اضافه کن.
|
||||
|
||||
## ۹. edge case ها
|
||||
|
||||
| حالت | رفتار درست |
|
||||
|---|---|
|
||||
| لغو دوبارهٔ همان نوبت | idempotent — همان وضعیت، بدون جریمهٔ دوم |
|
||||
| لغو نوبت گذشته | `422` — برای گذشته `no_show`/`completed` |
|
||||
| جریمه > پرداختی | سقف = پرداختی + `note` |
|
||||
| نوبت نقدی | جریمه صفر + `note: 'در مراجعهٔ بعدی محاسبه میشود'` |
|
||||
| لغو توسط کلینیک | بدون جریمه، بیعانه کامل، اعتبار کامل |
|
||||
| لغو جلسهٔ دوره | اعتبار **طبق سیاست**، نه همیشه؛ `CourseSession` → `planned` |
|
||||
| بیمار در لیست انتظار که خودش نوبت گرفت | ورودی → `converted` خودکار (روی رویداد `AppointmentBooked`) |
|
||||
| ظرفیت آزادشده که هیچکس منتظرش نیست | هیچ کاری — لاگ debug، نه هشدار |
|
||||
| ده نفر مطلع، هیچکس رزرو نکرد | ورودیها `waiting` میمانند، `notify_count++` |
|
||||
| ثبت لیست انتظار برای بازهٔ گذشته | `422` |
|
||||
| `desired_to - desired_from` بزرگتر از ۹۰ روز | `422` — همان سقف جستجو |
|
||||
| سیاست سرویس و سیاست محیط هر دو | سرویس (اختصاصیتر) برنده |
|
||||
|
||||
سطر «بیمار در لیست انتظار که خودش نوبت گرفت» را فراموش نکن: بدون آن، بیمار نوبت دارد و
|
||||
همچنان پیامک «وقت آزاد شد» میگیرد.
|
||||
|
||||
## ۱۰. تست
|
||||
|
||||
```
|
||||
tests/Cancellation/PenaltyCalculatorTest.php ← ⭐
|
||||
- داخل پنجرهٔ رایگان → صفر
|
||||
- بیرون پنجره → درصد درست
|
||||
- لغو توسط کلینیک → همیشه صفر (حتی ۱ ساعت قبل)
|
||||
- جریمه > پرداختی → سقف
|
||||
- نوبت نقدی → صفر + note
|
||||
tests/Cancellation/CancellationServiceTest.php
|
||||
- اشغال منابع آزاد میشود
|
||||
- جریمه در wallet_transactions با recorded_entity
|
||||
- لغو دوباره → idempotent
|
||||
- لغو گذشته → 422
|
||||
tests/Cancellation/PolicyResolverTest.php
|
||||
- سیاست سرویس بر محیط اولویت دارد
|
||||
tests/Cancellation/NoShowTrackerTest.php
|
||||
- سومین no_show → برچسب پرریسک
|
||||
- عدم حضور قدیمیتر از ۱۲ ماه شمرده نمیشود
|
||||
- همان نوبت دوبار → یک رکورد
|
||||
- بیمار پرریسک مسدود نمیشود (رزرو موفق)
|
||||
tests/Waitlist/WaitlistMatcherTest.php
|
||||
- لغو → حداکثر ۱۰ نفر مطلع، به ترتیب priority سپس created_at
|
||||
- فیلتر preferred_day_parts
|
||||
- notify_count سقف دارد
|
||||
tests/Waitlist/WaitlistConversionTest.php
|
||||
- بیمار خودش نوبت گرفت → converted
|
||||
tests/Waitlist/WaitlistAsyncTest.php
|
||||
- شکست پیامک، لغو را rollback نمیکند
|
||||
tests/Patient/PatientWalletTenantTest.php ← موجود، باید سبز بماند
|
||||
tests/Course/CourseLifecycleTest.php ← موجود، سیاست اعتبار اعمال شود
|
||||
```
|
||||
|
||||
## ۱۱. مستندات
|
||||
|
||||
`docs/api/cancellation.md` و `docs/api/waitlist.md`. در اولی حتماً بنویس که
|
||||
`cancellation-preview` پیش از لغو اجباری است و لغو توسط کلینیک هرگز جریمه ندارد.
|
||||
در دومی تصمیم broadcast و دلیلش.
|
||||
@@ -0,0 +1,75 @@
|
||||
# تسک ۱۳ — سیاست لغو، عدم حضور، لیست انتظار
|
||||
|
||||
**فاز:** ۳ (کسبوکار) · **وابستگی:** ۰۷ · **زمان:** ۱۰-۱۲ ساعت
|
||||
|
||||
---
|
||||
|
||||
## هدف
|
||||
|
||||
مستند بند ۱۱: «هر کلینیک تنظیم میکند: تا چند ساعت قبل لغو رایگان است، جریمه چقدر است،
|
||||
بیعانه برمیگردد یا نه، بعد از چند بار عدم حضور بیمار پرریسک علامت بخورد.»
|
||||
و بند ۱۷: «رقابت روی ساعتهای پرتقاضا → پیشنهاد خودکار ساعت جایگزین» و
|
||||
بند ۱۸ فاز ۳: «لیست انتظار».
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
- لغو کار میکند (`cancelled_by_user` / `cancelled_by_doctor`) ولی **بدون سیاست**:
|
||||
هیچ جریمهای، هیچ محدودیت زمانی، هیچ رفتاری با بیعانه
|
||||
- `no_show` وضعیت هست ولی هیچ اثری ندارد
|
||||
- `Appointment.is_reserve` وجود دارد: «نوبت رزرو» روزی (بدون ساعت) — یک لیست انتظار
|
||||
ابتدایی که `ReserveAppointmentsPage.tsx` نمایشش میدهد
|
||||
- بیعانه ثبت میشود (`deposit_required`, `deposit_amount_rials`) ولی بازگشتش دستی است
|
||||
|
||||
## دامنه
|
||||
|
||||
**هست:**
|
||||
- `CancellationPolicy` per محیط/سرویس: پنجرهٔ لغو رایگان، درصد/مبلغ جریمه، رفتار بیعانه
|
||||
- `NoShowPolicy`: بعد از N بار، برچسب پرریسک روی بیمار (استفاده از `TenantTag` موجود)
|
||||
- محاسبهٔ جریمه در لحظهٔ لغو + ثبت در دفتر مالی موجود
|
||||
- `Waitlist` — لیست انتظار برای بازهٔ زمانی مشخص (توسعهٔ `is_reserve` موجود)
|
||||
- اطلاعرسانی خودکار به لیست انتظار وقتی ظرفیت آزاد میشود
|
||||
|
||||
**نیست:** پیشبینی عدم حضور (فاز ۴ مستند — خارج از دامنه).
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| GET/PUT | `/api/v1/cancellation-policy` | سیاست محیط |
|
||||
| PUT | `/api/v1/service-item/{uuid}/cancellation-policy` | override سرویس |
|
||||
| GET | `/api/v1/appointment/{uuid}/cancellation-preview` | جریمه و بازگشت **پیش از** لغو |
|
||||
| POST | `/api/v1/appointment/{uuid}/cancel` | لغو با اعمال سیاست |
|
||||
| GET/POST | `/api/v1/waitlist` | ثبت در لیست انتظار |
|
||||
| DELETE | `/api/v1/waitlist/{uuid}` | |
|
||||
| GET | `/api/v1/waitlist/matches` | (پنل) درخواستهای قابل تطبیق با ظرفیت آزاد |
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: سیاست «لغو رایگان تا ۲۴ ساعت قبل، پس از آن ۵۰٪ جریمه، بیعانه برنمیگردد» →
|
||||
`GET /cancellation-preview` برای نوبت ۴۸ ساعت بعد: `penalty_rials: 0, deposit_refundable: true`؛
|
||||
برای نوبت ۶ ساعت بعد: `penalty_rials: <۵۰٪>, deposit_refundable: false`.
|
||||
- ✅ موفق: `POST /cancel` جریمه را در `wallet_transactions` (الگوی موجود) ثبت میکند و
|
||||
اشغال منابع را آزاد میکند.
|
||||
- ✅ موفق: سومین `no_show` بیمار → برچسب «پرریسک» (`TenantTag`) خودکار اضافه میشود و
|
||||
در `PatientDetailPage` دیده میشود.
|
||||
- ✅ موفق: بیمار در لیست انتظار برای «۵ مرداد، بعدازظهر» است؛ نوبتی در آن بازه لغو
|
||||
میشود → یک پیامک به او میرود و رکورد `notified_at` پر میشود.
|
||||
- ✅ موفق: لغو دورهٔ درمان (تسک ۱۲) → اعتبار پکیج **طبق سیاست** برمیگردد، نه همیشه.
|
||||
- ❌ خطا: `cancel` نوبتی که قبلاً لغو شده → `409` idempotent (همان وضعیت برگردد).
|
||||
- ❌ خطا: `cancel` نوبت گذشته → `422`؛ برای گذشته `no_show` یا `completed` معنی دارد.
|
||||
- ❌ خطا: ثبت در لیست انتظار برای بازهٔ گذشته → `422`.
|
||||
- ⚠️ مرزی: جریمه بیشتر از مبلغ پرداختی → سقف = مبلغ پرداختی.
|
||||
- ⚠️ مرزی: لغو توسط **کلینیک** (`cancelled_by_doctor`) → هرگز جریمه ندارد و بیعانه
|
||||
کامل برمیگردد.
|
||||
- ⚠️ مرزی: نوبت بدون پرداخت (نقدی سر جلسه) → جریمه ثبت میشود بهعنوان بدهی، نه کسر.
|
||||
- ⚠️ مرزی: لیست انتظار با ده نفر برای یک بازه → **همه** مطلع میشوند (اولین رزروکننده
|
||||
میبرد) — نه صف انحصاری. تصمیم و دلیلش در implementation_notes.
|
||||
- ⚠️ مرزی: بیمار پرریسک → **مسدود نمیشود**؛ فقط برچسب. مسدودسازی یک قانون
|
||||
`eligibility` (تسک ۰۹) روی همان برچسب است.
|
||||
|
||||
## خروجی
|
||||
|
||||
- `src/Cancellation/` + `src/Waitlist/`
|
||||
- `assets/admin/pages/CancellationPolicyPage.tsx` + `WaitlistPage.tsx`
|
||||
- توسعهٔ `AppointmentDetailPage.tsx` با پیشنمایش لغو
|
||||
- `docs/api/cancellation.md` + `docs/api/waitlist.md`
|
||||
Reference in New Issue
Block a user