feat: implement service mode completion for nobat724_front
- Add task for completing service mode in clinicpro with detailed objectives and acceptance criteria. - Create architecture documentation for task 00b, outlining involved components and necessary changes. - Develop checklist for task 00b to ensure all requirements are met. - Document implementation notes for task 00b, emphasizing API contract checks and design system adherence. - Update task documentation for task 00b, specifying goals and current issues with service mode.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
# معماری — تسک ۰۰
|
||||
|
||||
## ساختار فایل
|
||||
|
||||
```
|
||||
src/Appointment/
|
||||
├── Service/
|
||||
│ ├── ServiceBookingCalculator.php # جدید — تنها مرجع «مدت مجاز یک ترکیب سرویس»
|
||||
│ ├── ServiceRescheduleService.php # جدید — جابهجایی سرویسآگاه
|
||||
│ ├── ReserveConversionService.php # جدید — تبدیل رزرو به نوبت
|
||||
│ └── SlotCalculatorService.php # ⛔ فقط افزودن، بدون تغییر متدهای موجود
|
||||
└── Controller/AppointmentController.php # توسعهٔ PATCH + دو route جدید
|
||||
|
||||
assets/admin/
|
||||
├── pages/AppointmentEditPage.tsx # توسعه: حالت سرویسی
|
||||
├── pages/ReserveAppointmentsPage.tsx # توسعه: سرویسها + تبدیل
|
||||
├── components/appointments/ServiceSlotPicker.tsx # موجود — استفادهٔ دوباره، بدون تغییر رفتار
|
||||
└── hooks/useDoctorBookingServices.ts # موجود — استفادهٔ دوباره
|
||||
```
|
||||
|
||||
## `ServiceBookingCalculator` — استخراج منطق تکرارشده
|
||||
|
||||
منطق «چند سرویس → مدت کل» امروز **داخل کنترلر** است
|
||||
([AppointmentController::serviceSlots](../../../src/Appointment/Controller/AppointmentController.php#L184)):
|
||||
|
||||
```php
|
||||
// وضعیت فعلی — درون کنترلر، تکرارشدنی
|
||||
$totalMinutes = 0;
|
||||
foreach ($uuids as $u) {
|
||||
$item = $this->itemRepo->findByUuid($u);
|
||||
if ($item === null) { return $this->error(…, 'سرویس یافت نشد', 422, …); }
|
||||
if (!$item->isBookable()) { return $this->error(…, 'این سرویس برای نوبتدهی فعال نیست', 422, …); }
|
||||
$duration = isset($overrides[$u]) && (int)$overrides[$u] > 0
|
||||
? (int) $overrides[$u]
|
||||
: (int) ($item->getDurationMinutes() ?? 0);
|
||||
if ($duration <= 0) { return $this->error(…, 'مدت سرویس تعریف نشده است', 422, …); }
|
||||
$totalMinutes += $duration;
|
||||
}
|
||||
```
|
||||
|
||||
سه مصرفکنندهٔ جدید (PATCH، reschedule، convert-reserve) به همین محاسبه نیاز دارند.
|
||||
کپیکردنش یعنی چهار نسخه با چهار رفتار مرزی متفاوت.
|
||||
|
||||
```php
|
||||
final class ServiceBookingCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly WeeklyScheduleRepository $schedules,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* مدت و بافرِ یک ترکیب سرویس. ترتیب بررسی عمداً: مالکیت محیط اول، بعد بقیه —
|
||||
* وگرنه پیام خطا وجود و مدت سرویسِ محیط دیگر را لو میدهد.
|
||||
*
|
||||
* @param string[] $serviceUuids
|
||||
* @param array<string,int> $durationOverrides override منشی، فقط برای همین محاسبه
|
||||
*/
|
||||
public function calculate(
|
||||
Doctor $doctor,
|
||||
?Clinic $clinic,
|
||||
array $serviceUuids,
|
||||
array $durationOverrides = [],
|
||||
bool $allowInactive = false,
|
||||
): ServiceBookingDuration;
|
||||
|
||||
/** آیا این محیط در حالت سرویسی است. */
|
||||
public function isServiceMode(Doctor $doctor, ?Clinic $clinic): bool;
|
||||
}
|
||||
|
||||
final readonly class ServiceBookingDuration
|
||||
{
|
||||
public function __construct(
|
||||
public int $totalMinutes,
|
||||
public int $bufferMinutes,
|
||||
public array $serviceItems, // ServiceItem[] — به ترتیب ورودی
|
||||
public array $warnings = [], // مثلاً سرویس غیرفعال در نوبت موجود
|
||||
) {}
|
||||
|
||||
public function endFor(int $start): int { return $start + $this->totalMinutes * 60; }
|
||||
}
|
||||
```
|
||||
|
||||
`serviceSlots()` موجود هم باید از همین سرویس استفاده کند — ولی **خروجیاش بیتبهبیت
|
||||
همان بماند**. این refactor بیخطر است چون رفتار جمع ساده حفظ میشود؛ تست موجود
|
||||
`ServiceModeSectionDurationTest` تضمینش است.
|
||||
|
||||
> ⚠️ جمعِ سادهٔ `+=` اشتباه است (مستند بند ۵) ولی **در این تسک اصلاح نمیشود**.
|
||||
> اصلاحش تسک ۰۴ است (`DurationCalculator` با «زمان تنها / زمان اضافه»). اینجا فقط
|
||||
> جای منطق عوض میشود، نه خودش. `ServiceBookingCalculator` نقطهٔ واحدی است که تسک ۰۴
|
||||
> بعداً یک خط در آن عوض میکند.
|
||||
|
||||
## `PATCH /appointment/{uuid}` — توسعه، نه بازنویسی
|
||||
|
||||
```php
|
||||
// وضعیت فعلی حفظ میشود؛ فقط یک شاخه اضافه میشود
|
||||
if ($hasStart || $hasEnd) {
|
||||
if (!($hasStart && $hasEnd)) { /* 422 موجود */ }
|
||||
|
||||
$newStart = …; $newEnd = …;
|
||||
if ($newEnd <= $newStart) { /* 422 موجود */ }
|
||||
|
||||
// ── جدید: فقط در حالت سرویسی ──
|
||||
if ($this->serviceCalc->isServiceMode($doctor, $clinic)) {
|
||||
$uuids = $data['service_item_uuids'] ?? $appointment->currentServiceUuids();
|
||||
$duration = $this->serviceCalc->calculate($doctor, $clinic, $uuids, allowInactive: true);
|
||||
|
||||
if ($newEnd !== $duration->endFor($newStart)) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_SERVICE_DURATION_MISMATCH,
|
||||
sprintf('مدت این نوبت باید %d دقیقه باشد', $duration->totalMinutes),
|
||||
422, 'slot_end'
|
||||
);
|
||||
}
|
||||
$appointment->replaceServiceItems($duration->serviceItems);
|
||||
}
|
||||
// ── پایان بخش جدید ──
|
||||
|
||||
if ($this->appointmentRepo->isSlotTaken(…)) { /* 409 موجود */ }
|
||||
}
|
||||
```
|
||||
|
||||
شرط `isServiceMode` تضمین میکند مسیر اسلاتی **یک بایت هم** رفتارش عوض نشود: در حالت
|
||||
`slot` هیچکدام از خطوط جدید اجرا نمیشوند.
|
||||
|
||||
`allowInactive: true` عمدی است: نوبت موجودی که سرویسش غیرفعال شده باید قابل جابهجایی
|
||||
بماند. غیرفعال بودن با `warnings[]` برگردانده میشود، نه با `422`.
|
||||
|
||||
## `POST /appointment/{uuid}/service-reschedule` — مسیر ترجیحی
|
||||
|
||||
`PATCH` برای سازگاری توسعه یافت، ولی مسیر درست این است: کلاینت **مدت نمیفرستد**.
|
||||
|
||||
```
|
||||
درخواست:
|
||||
{
|
||||
"start": 1754…, // فقط زمان شروع
|
||||
"service_item_uuids": ["…", "…"], // اختیاری؛ نبود = همان سرویسهای فعلی
|
||||
"durations": { "uuid": 25 } // اختیاری، override منشی
|
||||
}
|
||||
|
||||
پاسخ:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "…",
|
||||
"slot_start": 1754…, "slot_end": 1754…,
|
||||
"total_duration_minutes": 35,
|
||||
"buffer_minutes": 10,
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
final class ServiceRescheduleService
|
||||
{
|
||||
public function reschedule(Appointment $appt, ServiceRescheduleRequest $req): Appointment
|
||||
{
|
||||
return $this->em->wrapInTransaction(function () use ($appt, $req) {
|
||||
$doctor = $appt->getDoctor();
|
||||
$clinic = $appt->getClinic();
|
||||
|
||||
if (!$this->serviceCalc->isServiceMode($doctor, $clinic)) {
|
||||
throw new AppException(ErrorCodes::ERR_WRONG_BOOKING_MODE,
|
||||
'این نوبت در حالت نوبتدهی سرویسی نیست', 422);
|
||||
}
|
||||
|
||||
$duration = $this->serviceCalc->calculate($doctor, $clinic,
|
||||
$req->serviceUuids ?? $appt->currentServiceUuids(), $req->overrides, allowInactive: true);
|
||||
|
||||
// زمان باید واقعاً در فهرست زمانهای ممکن باشد — نه فقط «اشغال نیست»
|
||||
$starts = $this->slotCalculator->getServiceStartTimes(
|
||||
$doctor, date('Y-m-d', $req->start), $duration->totalMinutes,
|
||||
$clinic, forManagement: $req->forManagement,
|
||||
excludeAppointmentId: $appt->getId(), // ← پارامتر جدید، پیشفرض null
|
||||
);
|
||||
|
||||
if (!in_array($req->start, array_column($starts, 'start'), true)) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001,
|
||||
'این زمان برای مدت انتخابی در دسترس نیست', 422, 'start');
|
||||
}
|
||||
|
||||
$appt->reschedule($req->start, $duration->endFor($req->start));
|
||||
$appt->replaceServiceItems($duration->serviceItems);
|
||||
$appt->setServiceTotalMinutes($duration->totalMinutes);
|
||||
$appt->setServiceBufferMinutes($duration->bufferMinutes);
|
||||
$this->events->recordReschedule($appt); // AppointmentEvent موجود
|
||||
|
||||
return $appt;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### پارامتر `excludeAppointmentId` — تنها تغییر مجاز در `SlotCalculatorService`
|
||||
|
||||
```php
|
||||
public function getServiceStartTimes(
|
||||
Doctor $doctor, string $date, int $durationMinutes,
|
||||
?Clinic $clinic = null, bool $forManagement = false,
|
||||
?int $excludeAppointmentId = null, // ← جدید، پیشفرض null
|
||||
): array
|
||||
```
|
||||
|
||||
**چرا مجاز است:** پارامتر اختیاری با پیشفرض `null` است، و متد `getServiceStartTimes`
|
||||
فقط در مسیر **سرویسی** استفاده میشود — نه در اسلاتی. هیچ فراخوانی موجودی رفتارش عوض
|
||||
نمیشود.
|
||||
|
||||
**چرا لازم است:** بدون آن، نوبت در حال جابهجایی خودش را اشغال میبیند و زمان فعلیاش
|
||||
هرگز در فهرست نمیآید. کاربر نمیتواند «همان ساعت، سرویس متفاوت» را ثبت کند.
|
||||
|
||||
پیادهسازی: `AppointmentRepository::findBusyIntervals()` هم همان پارامتر را میگیرد —
|
||||
دقیقاً همان الگویی که `isSlotTaken($doctor, $start, $end, $excludeId)` از قبل دارد.
|
||||
پس این الگو در کدبیس ثابتشده است، نه تازه.
|
||||
|
||||
## نوبت رزرو در حالت سرویسی
|
||||
|
||||
امروز: `NewAppointmentDrawer.tsx:72` → `serviceMode = bookingMode === 'service' && !isReserve`
|
||||
|
||||
تغییر: نوبت رزرو **هم** سرویس میپذیرد، ولی زمان نمیگیرد.
|
||||
|
||||
```
|
||||
نوبت رزرو در حالت سرویسی:
|
||||
slot_start = slot_end = نیمهشب روز (رفتار موجود، دستنخورده)
|
||||
is_reserve = true (رفتار موجود)
|
||||
service_items = سرویسهای انتخابی ← جدید
|
||||
service_total_minutes = مدت محاسبهشده ← جدید، برای تبدیل بعدی
|
||||
active_slot_key = NULL (رفتار موجود — رزرو اسلات نمیگیرد)
|
||||
```
|
||||
|
||||
`POST /appointment/{uuid}/convert-reserve`:
|
||||
|
||||
```
|
||||
{ "start": 1754…, "service_item_uuids": [...] } ← سرویسها اختیاری، پیشفرض همانهای رزرو
|
||||
▼
|
||||
├─ حالت اسلاتی: زمان باید در getAvailableSlots باشد
|
||||
└─ حالت سرویسی: زمان باید در getServiceStartTimes(مدت) باشد
|
||||
▼
|
||||
is_reserve = false · slot_start/end واقعی · active_slot_key بازتولید میشود
|
||||
```
|
||||
|
||||
`Appointment::refreshActiveSlotKey()` موجود این را خودکار انجام میدهد چون
|
||||
`isReserve` را میخواند — **بدون تغییر آن متد**. فقط `setIsReserve(false)` باید
|
||||
`refreshActiveSlotKey()` را صدا بزند (اگر نمیزند، این تنها یک خط اضافه است).
|
||||
|
||||
## `AppointmentEditPage` — دو حالت، یک صفحه
|
||||
|
||||
```tsx
|
||||
const { bookingMode, services } = useDoctorBookingServices(doctorUuid, clinicUuid);
|
||||
const isServiceMode = bookingMode === 'service' && !appointment?.is_reserve;
|
||||
|
||||
// حالت اسلاتی: دقیقاً همان سه فیلد امروز — بدون هیچ تغییر
|
||||
{!isServiceMode && (
|
||||
<>
|
||||
<PersianDateInput value={date} onChange={setDate} label="تاریخ" />
|
||||
<TimeField value={start} onChange={setStart} label="ساعت شروع" />
|
||||
<TimeField value={end} onChange={setEnd} label="ساعت پایان" />
|
||||
</>
|
||||
)}
|
||||
|
||||
// حالت سرویسی: انتخاب چند سرویس + picker زمان
|
||||
{isServiceMode && (
|
||||
<ServiceSlotPicker
|
||||
doctorUuid={doctorUuid}
|
||||
clinicUuid={clinicUuid}
|
||||
services={services}
|
||||
selectedUuids={serviceUuids}
|
||||
onServicesChange={setServiceUuids}
|
||||
date={date}
|
||||
onDateChange={setDate}
|
||||
picked={pickedSlot}
|
||||
onPick={setPickedSlot}
|
||||
excludeAppointmentUuid={uuid} // ← prop جدید
|
||||
management
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
`ServiceSlotPicker` موجود فقط یک prop اختیاری میگیرد. رفتار فعلیاش (در
|
||||
`AppointmentCreatePage` و `AppointmentsPage`) با `excludeAppointmentUuid = undefined`
|
||||
دستنخورده میماند.
|
||||
|
||||
ورودی دستی ساعت در حالت سرویسی **پنهان** میشود، نه غیرفعال — فیلد disabled یعنی کاربر
|
||||
فکر میکند باید کاری بکند.
|
||||
|
||||
## تست قرارداد اسلاتی — قلب خط سرخ
|
||||
|
||||
```php
|
||||
// tests/Appointment/SlotModeFrozenTest.php
|
||||
/** @group slot-mode-frozen */
|
||||
final class SlotModeFrozenTest extends WebTestCase
|
||||
{
|
||||
public function testAppointmentSlotsContractIsFrozen(): void
|
||||
{
|
||||
$this->seedFixedSlotSchedule(); // برنامهٔ ثابت، تاریخ ثابت (از args، نه time())
|
||||
$this->client->request('GET', '/api/v1/appointment-slots?doctor_uuid=…&date=…');
|
||||
|
||||
self::assertJsonStringEqualsJsonFile(
|
||||
__DIR__ . '/fixtures/slot-mode-contract.json',
|
||||
$this->client->getResponse()->getContent(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testMonthAvailabilityContractIsFrozen(): void { /* همان الگو */ }
|
||||
|
||||
/** هیچ متد عمومیِ SlotCalculatorService امضایش عوض نشده. */
|
||||
public function testSlotCalculatorPublicApiIsFrozen(): void
|
||||
{
|
||||
$expected = require __DIR__ . '/fixtures/slot-calculator-signatures.php';
|
||||
$actual = $this->reflectPublicSignatures(SlotCalculatorService::class);
|
||||
self::assertSame($expected, $actual);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
متد سوم مهمترین است: پارامتر اختیاری جدید `excludeAppointmentId` **یک بار** در fixture
|
||||
ثبت میشود (در همین تسک) و بعد از آن هیچ تسکی اجازهٔ تغییرش را ندارد.
|
||||
|
||||
fixture ها با تاریخ ثابت ساخته میشوند، نه `time()` — وگرنه تست فردا قرمز میشود.
|
||||
|
||||
## UI — قواعد اجباری
|
||||
|
||||
رجوع: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
|
||||
- `AppointmentEditPage` از قبل `PageHeader` با `backTo` دارد → حفظ شود
|
||||
- `ServiceSlotPicker` موجود بازاستفاده میشود؛ نسخهٔ موازی ساخته نمیشود
|
||||
- انتخاب چند سرویس با `SearchableSelect` چندانتخابی — نه `<select multiple>`
|
||||
- تاریخ با `PersianDateInput` موجود
|
||||
- `ReserveAppointmentsPage` جدول خام دارد (`<td style={td}>`) — در همین تسک به
|
||||
`DataTable` مهاجرت کند، چون داریم دستش میزنیم و توکنهای inline خلاف قاعدهاند
|
||||
- مدت و بافر با فارسی و واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)»
|
||||
@@ -0,0 +1,118 @@
|
||||
# چکلیست — تسک ۰۰ (تکمیل نوبتدهی سرویسی در clinicpro)
|
||||
|
||||
**وضعیت کلی:** ⏳ شروع نشده
|
||||
**آخرین بازبینی:** —
|
||||
|
||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||
خط سرخها: [_shared/red-lines.md](../_shared/red-lines.md) ·
|
||||
UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
|
||||
---
|
||||
|
||||
## ۰. خط سرخ — منطق اسلاتی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۰.۱ | `SlotModeFrozenTest` + سه fixture ساخته شد **پیش از** هر تغییر کد | ⏳ | ترتیب مهم است |
|
||||
| ۰.۲ | fixture ها با تاریخ ثابتاند، نه `time()` | ⏳ | |
|
||||
| ۰.۳ | کامنت «read-only، هیچ تسکی بهروزش نمیکند» بالای هر سه fixture | ⏳ | |
|
||||
| ۰.۴ | هیچ متد موجود `SlotCalculatorService` ویرایش نشد | ⏳ | فقط پارامتر اختیاری `excludeAppointmentId` روی `getServiceStartTimes` |
|
||||
| ۰.۵ | `GET /appointment-slots` بیتبهبیت دستنخورده | ⏳ | |
|
||||
| ۰.۶ | `GET /month-availability/{doctorUuid}` دستنخورده | ⏳ | |
|
||||
| ۰.۷ | `active_slot_key` و `refreshActiveSlotKey()` دستنخورده | ⏳ | تنها استثنا: صدا زدنش در `setIsReserve` |
|
||||
| ۰.۸ | `isSlotTaken` امضا و معنا دستنخورده | ⏳ | |
|
||||
| ۰.۹ | `--group=slot-mode-frozen` در پایان سبز | ⏳ | |
|
||||
|
||||
## ۱. بکاند
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `ServiceBookingCalculator` ساخته شد | ⏳ | |
|
||||
| ۱.۲ | `serviceSlots()` موجود از آن استفاده میکند، **خروجیاش عوض نشده** | ⏳ | |
|
||||
| ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ⏳ | |
|
||||
| ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | ⏳ | |
|
||||
| ۱.۵ | `PATCH /appointment/{uuid}` توسعه یافت — منطق جدید داخل `isServiceMode()` | ⏳ | |
|
||||
| ۱.۶ | `PATCH` مقدار `service_item_uuids[]` میپذیرد | ⏳ | |
|
||||
| ۱.۷ | `ReserveConversionService` + `POST /appointment/{uuid}/convert-reserve` | ⏳ | |
|
||||
| ۱.۸ | نوبت رزرو در حالت سرویسی سرویسها را ذخیره میکند | ⏳ | |
|
||||
| ۱.۹ | `excludeAppointmentId` روی `getServiceStartTimes` و `findBusyIntervals` | ⏳ | همان الگوی `isSlotTaken` |
|
||||
| ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ⏳ | |
|
||||
| ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را همگام میکند | ⏳ | چهار مصرفکننده رویش خواندهاند |
|
||||
| ۱.۱۲ | `setIsReserve()` صدا زدن `refreshActiveSlotKey()` | ⏳ | |
|
||||
| ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | ⏳ | |
|
||||
| ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ⏳ | |
|
||||
| ۱.۱۵ | `allowInactive` فقط برای سرویسهای موجود نوبت، نه uuid های تازه | ⏳ | |
|
||||
| ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ⏳ | |
|
||||
|
||||
## ۲. دیتابیس و مهاجرت
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | `service_total_minutes` و `service_buffer_minutes` (تهیپذیر) | ⏳ | |
|
||||
| ۲.۲ | هیچ ستون موجودی حذف/تغییر نوع/تغییر معنا نداد | ⏳ | |
|
||||
| ۲.۳ | دو کد خطای جدید در `ErrorCodes.php` با پیام فارسی | ⏳ | شمارهٔ واقعی از خود فایل |
|
||||
| ۲.۴ | `app:appointment:backfill-service-duration` — dry-run پیشفرض، idempotent | ⏳ | |
|
||||
| ۲.۵ | backfill مقدار را از خود نوبت میگیرد، نه بازمحاسبه از سرویسها | ⏳ | |
|
||||
| ۲.۶ | migration اجرا شد و `TenantSchemaCoverageTest` سبز | ⏳ | |
|
||||
|
||||
## ۳. UI — پنل ادمین
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `AppointmentEditPage`: حالت سرویسی `ServiceSlotPicker` نشان میدهد | ⏳ | |
|
||||
| ۳.۲ | `AppointmentEditPage`: حالت اسلاتی **دقیقاً** رفتار امروز | ⏳ | سه فیلد ساعت |
|
||||
| ۳.۳ | ورودی دستی ساعت در حالت سرویسی **پنهان**، نه disabled | ⏳ | |
|
||||
| ۳.۴ | `ServiceSlotPicker` موجود بازاستفاده شد؛ نسخهٔ موازی ساخته نشد | ⏳ | فقط prop `excludeAppointmentUuid` |
|
||||
| ۳.۵ | `ReserveAppointmentsPage`: سرویسها + دکمهٔ تبدیل | ⏳ | |
|
||||
| ۳.۶ | `ReserveAppointmentsPage` از جدول خام به `DataTable` مهاجرت کرد | ⏳ | `<td style={td}>` حذف شد |
|
||||
| ۳.۷ | مدت و بافر فارسی با واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)» | ⏳ | |
|
||||
| ۳.۸ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکنهای `styles.css` | ⏳ | |
|
||||
| ۳.۹ | دارکمود (`data-theme="dark"`) بررسی شد | ⏳ | |
|
||||
| ۳.۱۰ | حالت فشرده (`data-density="compact"`) بررسی شد | ⏳ | |
|
||||
| ۳.۱۱ | انتخاب چند سرویس با `SearchableSelect`؛ هیچ `<select>` بومی | ⏳ | |
|
||||
| ۳.۱۲ | `backTo`/`BackButton` روی هر دو صفحه | ⏳ | |
|
||||
| ۳.۱۳ | وضعیت لیست رزروها در URL با `useUrlState` | ⏳ | |
|
||||
| ۳.۱۴ | تاریخ با `PersianDateInput` · مبلغ با `formatRial` | ⏳ | |
|
||||
| ۳.۱۵ | RTL بررسی شد (`ms/me` نه `ml/mr`) | ⏳ | |
|
||||
| ۳.۱۶ | موبایل بررسی شد — بدون اسکرول افقی | ⏳ | |
|
||||
| ۳.۱۷ | همهٔ رشتهها فارسی و از i18n | ⏳ | |
|
||||
| ۳.۱۸ | داده با TanStack Query و استخراج envelope درست | ⏳ | |
|
||||
|
||||
## ۴. تست
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `SlotModeFrozenTest` — سه سنجه | ⏳ | |
|
||||
| ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ⏳ | |
|
||||
| ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | ⏳ | |
|
||||
| ۴.۴ | `PatchServiceDurationTest` — شامل «در حالت اسلاتی هیچکدام اجرا نمیشود» | ⏳ | |
|
||||
| ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | |
|
||||
| ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | ⏳ | |
|
||||
| ۴.۷ | `BookingTenantTest` موجود سبز ماند | ⏳ | |
|
||||
| ۴.۸ | `AppointmentEditPage.test.tsx` — دو حالت | ⏳ | |
|
||||
|
||||
## ۵. مستندات
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `docs/api/appointment.md` — دو endpoint جدید + توسعهٔ PATCH | ⏳ | |
|
||||
| ۵.۲ | ماتریس «کدام endpoint در کدام حالت» | ⏳ | |
|
||||
| ۵.۳ | `docs/architecture/booking-modes.md` ساخته شد | ⏳ | تسک ۰۶ حالت سوم را اضافه میکند |
|
||||
| ۵.۴ | دو کد خطای جدید مستند شد | ⏳ | |
|
||||
|
||||
## ۶. بازبینی پایانی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | همهٔ ردیفهای بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بیدلیل) | ⏳ | |
|
||||
| ۶.۲ | `ddev exec php bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۶.۳ | `ddev exec php bin/phpunit --group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۶.۴ | `phpstan analyse` بدون خطای جدید | ⏳ | |
|
||||
| ۶.۵ | `npx tsc --noEmit` بدون خطا | ⏳ | |
|
||||
| ۶.۶ | `yarn test` سبز | ⏳ | |
|
||||
| ۶.۷ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | |
|
||||
| ۶.۸ | `docs/api/*` بهروز شد | ⏳ | |
|
||||
| ۶.۹ | چکلیست UI (بخش ۳) کامل شد | ⏳ | |
|
||||
| ۶.۱۰ | `nobat724_front` و `clinic-pro-tauri` دستی بررسی شدند | ⏳ | `service_item` تکی همگام است؟ |
|
||||
| ۶.۱۱ | commit شد، سپس `graphify update .` | ⏳ | |
|
||||
| ۶.۱۲ | موارد بهتعویقافتاده با دلیل و تسک مقصد ثبت شدند | ⏳ | |
|
||||
@@ -0,0 +1,123 @@
|
||||
# دیتابیس — تسک ۰۰
|
||||
|
||||
## تغییر `appointments` — دو ستون تهیپذیر
|
||||
|
||||
```sql
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN service_total_minutes SMALLINT NULL,
|
||||
ADD COLUMN service_buffer_minutes SMALLINT NULL;
|
||||
```
|
||||
|
||||
| ستون | معنی | چرا لازم است |
|
||||
|---|---|---|
|
||||
| `service_total_minutes` | مدت محاسبهشدهٔ ترکیب سرویسها در لحظهٔ ثبت | `slot_end - slot_start` عدد را دارد ولی نمیگوید عمدی بود یا دستی؛ و برای نوبت رزرو (که `slot_start = slot_end`) هیچجا مدت را نگه نمیداریم |
|
||||
| `service_buffer_minutes` | `buffer_minutes` مؤثر در لحظهٔ ثبت | تغییر بافر در تنظیمات نباید معنای نوبتهای ثبتشده را عوض کند |
|
||||
|
||||
هر دو **تهیپذیر** و هر دو در حالت اسلاتی `NULL` میمانند. هیچ ستون موجودی حذف، تغییر
|
||||
نوع یا تغییر معنا نمیدهد.
|
||||
|
||||
⛔ `slot_start` و `slot_end` و `active_slot_key` و `is_reserve` دستنخورده. خط سرخ.
|
||||
|
||||
### چرا نه یک ستون JSON
|
||||
|
||||
وسوسه: یک `service_meta JSON` با همهچیز. رد شد چون تسک ۱۴ (گزارش دقت برنامه) روی
|
||||
`plan_total_minutes` تجمعی میزند و JSON را نمیتواند `AVG` کند. دو ستون `SMALLINT`
|
||||
ارزانترند و تسک ۰۷ ستون `plan_total_minutes` را کنارشان اضافه میکند
|
||||
(اسم متفاوت، معنی متفاوت: آن یکی مدت برنامهٔ چندبخشی است).
|
||||
|
||||
## ایندکس
|
||||
|
||||
هیچ ایندکس جدیدی. `idx_appointments_doctor_slot` و `idx_appointments_tenant_slot` موجود
|
||||
همهٔ کوئریهای این تسک را پوشش میدهند.
|
||||
|
||||
## `appointment_service_items` — بدون تغییر schema
|
||||
|
||||
جدول واسط ManyToMany موجود. تنها تغییر، **رفتاری** است:
|
||||
|
||||
```php
|
||||
// Appointment — متد جدید، بدون دست زدن به متدهای موجود
|
||||
/** جایگزینی کامل سرویسها؛ serviceItem تکی هم با اولی همگام میشود. */
|
||||
public function replaceServiceItems(array $items): self
|
||||
{
|
||||
$this->serviceItems->clear();
|
||||
foreach ($items as $item) {
|
||||
if (!$this->serviceItems->contains($item)) { $this->serviceItems->add($item); }
|
||||
}
|
||||
$this->serviceItem = $items[0] ?? null; // ← سازگاری با مصرفکنندهٔ تکی
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return string[] uuid سرویسهای فعلی، به ترتیب */
|
||||
public function currentServiceUuids(): array
|
||||
{
|
||||
$uuids = array_map(fn($i) => $i->getUuid(), $this->serviceItems->toArray());
|
||||
if ($uuids === [] && $this->serviceItem !== null) { $uuids = [$this->serviceItem->getUuid()]; }
|
||||
return $uuids;
|
||||
}
|
||||
```
|
||||
|
||||
همگامسازی `serviceItem` تکی اجباری است: `AppointmentsPage`، `ReserveAppointmentsPage`،
|
||||
`nobat724_front` و `clinic-pro-tauri` هر چهار روی `service_item` تکی خواندهاند. رهاکردنش
|
||||
یعنی نوبت با سرویسهای جدید ولی نام سرویس قدیمی در لیست.
|
||||
|
||||
## کدهای خطای جدید
|
||||
|
||||
در `src/Shared/Constant/ErrorCodes.php`:
|
||||
|
||||
```php
|
||||
public const ERR_SERVICE_DURATION_MISMATCH = 'ERR_APPOINTMENT_010';
|
||||
// پیام: مدت این نوبت با مجموع مدت سرویسهای انتخابی نمیخواند
|
||||
|
||||
public const ERR_WRONG_BOOKING_MODE = 'ERR_APPOINTMENT_011';
|
||||
// پیام: این عملیات با روش نوبتدهی این محیط سازگار نیست
|
||||
```
|
||||
|
||||
شمارهٔ بعدی دامنهٔ `APPOINTMENT` را از خود فایل بگیر، این دو عدد حدسیاند.
|
||||
هر دو کد در تسکهای ۰۶ و ۰۷ هم استفاده میشوند، پس نامشان عمومی است نه مخصوص این تسک.
|
||||
|
||||
## Migration
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
## backfill
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:appointment:backfill-service-duration # dry-run
|
||||
ddev exec php bin/console app:appointment:backfill-service-duration --force
|
||||
```
|
||||
|
||||
برای هر نوبت `pending`/`confirmed` **آیندهٔ** یک محیط سرویسی که `service_total_minutes`
|
||||
ندارد:
|
||||
|
||||
```
|
||||
service_total_minutes = (slot_end - slot_start) / 60
|
||||
service_buffer_minutes = buffer_minutes فعلیِ همان برنامه
|
||||
```
|
||||
|
||||
مقدار از خودِ نوبت گرفته میشود، **نه از مدت سرویسها** — چون نوبت موجود ممکن است با
|
||||
مدت دستی ثبت شده باشد و بازمحاسبه یعنی تغییر گذشته.
|
||||
|
||||
نوبتهای اسلاتی و نوبتهای گذشته رد میشوند. idempotent.
|
||||
|
||||
## fixture های تست خط سرخ
|
||||
|
||||
```
|
||||
tests/Appointment/fixtures/slot-mode-contract.json # پاسخ appointment-slots
|
||||
tests/Appointment/fixtures/month-availability-contract.json # پاسخ month-availability
|
||||
tests/Appointment/fixtures/slot-calculator-signatures.php # امضای متدهای عمومی
|
||||
```
|
||||
|
||||
⛔ این سه فایل بعد از این تسک **read-only** اند. هیچ تسکی اجازهٔ بهروزرسانیشان را ندارد.
|
||||
اگر تستی قرمز شد، کد باید برگردد نه fixture. این جمله را در بالای هر سه فایل بهعنوان
|
||||
کامنت بنویس.
|
||||
|
||||
fixture ها با تاریخ ثابت ساخته میشوند (`2026-01-05` مثلاً)، نه `time()` — وگرنه فردا قرمز.
|
||||
|
||||
## طبقهبندی tenant
|
||||
|
||||
هیچ entity جدیدی. `appointments` از قبل جفت tenant دارد.
|
||||
`TenantSchemaCoverageTest` باید بدون تغییر سبز بماند.
|
||||
@@ -0,0 +1,204 @@
|
||||
# نکات پیادهسازی — تسک ۰۰
|
||||
|
||||
## ۱. اول تست خط سرخ، بعد هر چیز دیگر
|
||||
|
||||
ترتیب کار:
|
||||
|
||||
```
|
||||
۱. tests/Appointment/SlotModeFrozenTest.php + سه fixture ← اول این
|
||||
۲. ddev exec php bin/phpunit --group=slot-mode-frozen ← باید سبز باشد قبل از هر تغییری
|
||||
۳. بقیهٔ تسک
|
||||
۴. دوباره گام ۲ — باید همچنان سبز باشد
|
||||
```
|
||||
|
||||
اگر fixture را بعد از تغییرات بسازی، هیچ چیزی را تضمین نکردهای — snapshot وضعیت
|
||||
تغییریافته را گرفتهای.
|
||||
|
||||
## ۲. `isServiceMode` گِیت همهچیز است
|
||||
|
||||
هر خط کد جدید در مسیر مشترک باید داخل این شرط باشد:
|
||||
|
||||
```php
|
||||
if ($this->serviceCalc->isServiceMode($doctor, $clinic)) {
|
||||
// … منطق جدید
|
||||
}
|
||||
```
|
||||
|
||||
نه بیرونش، نه با `??`، نه با «اگر سرویس دارد». معیار **فقط** `booking_mode` است.
|
||||
نوبت اسلاتی هم میتواند `service_item_id` داشته باشد (فیلدهای Figma نوبتها) — آن دلیل
|
||||
سرویسی بودن نیست.
|
||||
|
||||
اشتباه رایج:
|
||||
|
||||
```php
|
||||
// ❌ نوبت اسلاتیِ دارای سرویس را وارد مسیر جدید میکند
|
||||
if ($appointment->getServiceItems()->count() > 0) { … }
|
||||
```
|
||||
|
||||
## ۳. جمع سادهٔ مدت را همینجا اصلاح نکن
|
||||
|
||||
```php
|
||||
$totalMinutes += $duration; // ← اشتباه است، ولی دست نزن
|
||||
```
|
||||
|
||||
مستند بند ۵ میگوید این فرمول ظرفیت را الکی پر میکند و راهحلش «زمان تنها / زمان اضافه»
|
||||
است — که تسک ۰۴ میسازد. اصلاحش اینجا یعنی:
|
||||
|
||||
- مدت همهٔ نوبتهای چندسرویسیِ در حال رزرو یکشبه کم میشود
|
||||
- سایت و اپ دسکتاپ عدد متفاوت میبینند بدون اینکه چیزی در build بشکند
|
||||
- و هیچ دادهای برای «زمان اضافه» وجود ندارد، پس اصلاح بیورودی غیرممکن است
|
||||
|
||||
کاری که این تسک میکند: محاسبه را به **یک نقطه** منتقل میکند تا تسک ۰۴ یک خط عوض کند.
|
||||
|
||||
## ۴. `excludeAppointmentId` — الگوی موجود را تکرار کن
|
||||
|
||||
`AppointmentRepository::isSlotTaken($doctor, $start, $end, $excludeId)` از قبل این پارامتر
|
||||
را دارد. `findBusyIntervals` هم همان را بگیرد، با همان نام و همان جای پارامتر و همان
|
||||
پیشفرض `null`.
|
||||
|
||||
دو الگوی متفاوت برای یک کار (مثلاً یکی `?int $excludeId`، دیگری `array $excludeIds`)
|
||||
یعنی اولین کسی که هر دو را میبیند یکی را اشتباه صدا میزند.
|
||||
|
||||
## ۵. اعتبارسنجی زمان: عضویت در فهرست، نه «اشغال نبودن»
|
||||
|
||||
```php
|
||||
// ❌ ناکافی
|
||||
if ($this->appointmentRepo->isSlotTaken($doctor, $start, $end, $excludeId)) { /* 409 */ }
|
||||
|
||||
// ✅
|
||||
$starts = $this->slotCalculator->getServiceStartTimes(…);
|
||||
if (!in_array($req->start, array_column($starts, 'start'), true)) { /* 422 */ }
|
||||
```
|
||||
|
||||
`isSlotTaken` فقط تداخل با نوبت دیگر را میگوید. `getServiceStartTimes` علاوه بر آن
|
||||
شیفت، تعطیلی، `date_override`، پنجرهٔ رزرو و بافر را هم اعمال میکند. با شرط اول،
|
||||
منشی میتواند نوبت را ساعت ۳ بامداد بگذارد.
|
||||
|
||||
## ۶. `warnings[]` بهجای `422` برای سرویس غیرفعال در نوبت موجود
|
||||
|
||||
```php
|
||||
$duration = $this->serviceCalc->calculate(…, allowInactive: true);
|
||||
// $duration->warnings === ['سرویس «لیزر صورت» دیگر برای نوبتدهی فعال نیست']
|
||||
```
|
||||
|
||||
نوبت موجود با سرویسی که کلینیک غیرفعالش کرده، باید قابل جابهجایی و لغو بماند. `422`
|
||||
یعنی آن نوبت برای همیشه قفل میشود و منشی هیچ کاری نمیتواند بکند.
|
||||
|
||||
ولی **افزودن** سرویس غیرفعال به نوبت → `422`. تفاوتش `allowInactive` است که فقط برای
|
||||
uuid های موجودِ نوبت `true` میشود، نه برای uuid های تازهی درخواست.
|
||||
|
||||
## ۷. `replaceServiceItems` باید `serviceItem` تکی را همگام کند
|
||||
|
||||
```php
|
||||
$this->serviceItem = $items[0] ?? null;
|
||||
```
|
||||
|
||||
چهار مصرفکننده روی `service_item` تکی خواندهاند (`AppointmentsPage`،
|
||||
`ReserveAppointmentsPage`، `nobat724_front/services/response.js`،
|
||||
`clinic-pro-tauri/src/service/response.js`). این دقیقاً همان الگویی است که
|
||||
`ServiceItem::setStaffMembers()` برای `staff` تکی دارد — تکرارش کن.
|
||||
|
||||
## ۸. `refreshActiveSlotKey` پس از `setIsReserve(false)`
|
||||
|
||||
```php
|
||||
public function setIsReserve(bool $v): self
|
||||
{
|
||||
$this->isReserve = $v;
|
||||
$this->refreshActiveSlotKey(); // ← اگر نیست، اضافه کن
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
```
|
||||
|
||||
بدون آن، رزروِ تبدیلشده `active_slot_key = NULL` میماند و دو نفر میتوانند همان ساعت
|
||||
را بگیرند. **این تنها تغییر مجاز در مکانیزم `active_slot_key` است** و فقط چون یک شرط
|
||||
موجود را اعمال میکند، نه عوضش میکند. تست: `ConvertReserveSlotKeyTest`.
|
||||
|
||||
## ۹. تبدیل رزرو، اتمی
|
||||
|
||||
```php
|
||||
$this->em->wrapInTransaction(function () use ($reserve, $req) {
|
||||
$duration = …; // یا اسلات اسلاتی
|
||||
$reserve->setIsReserve(false);
|
||||
$reserve->reschedule($req->start, $end);
|
||||
$reserve->replaceServiceItems($duration->serviceItems);
|
||||
// UniqueConstraintViolationException روی active_slot_key → 409
|
||||
});
|
||||
```
|
||||
|
||||
`try/catch` روی `UniqueConstraintViolationException` و ترجمه به `409 ERR_SLOT_TAKEN` —
|
||||
همان چیزی که `SlotTakenException` موجود در `src/Appointment/Repository/` انجام میدهد.
|
||||
از همان استفاده کن.
|
||||
|
||||
## ۱۰. `ReserveAppointmentsPage` → `DataTable`
|
||||
|
||||
صفحه امروز جدول خام با `<td style={td}>` دارد. چون در این تسک دستش میزنیم، همانجا به
|
||||
`DataTable` مهاجرت کند: توکن inline خلاف [ui-conventions](../_shared/ui-conventions.md)
|
||||
است و در دارکمود میشکند.
|
||||
|
||||
این «scope creep» نیست — قاعدهٔ پروژه است که صفحهٔ دستخورده باید با دیزاینسیستم بخواند.
|
||||
|
||||
## ۱۱. edge case ها
|
||||
|
||||
| حالت | رفتار درست |
|
||||
|---|---|
|
||||
| نوبت سرویسی بدون هیچ سرویس (داده قدیمی) | مدت موجود حفظ · `warnings[]` · **رد نمیشود** |
|
||||
| `PATCH` فقط یادداشت روی نوبت سرویسی | بدون اعتبارسنجی مدت — مثل امروز |
|
||||
| `service-reschedule` روی نوبت اسلاتی | `422 ERR_WRONG_BOOKING_MODE` |
|
||||
| `service-reschedule` روی نوبت رزرو | `422` — مسیرش `convert-reserve` است |
|
||||
| زمان فعلی نوبت با سرویس جدید | با `excludeAppointmentId` در فهرست میآید |
|
||||
| بافر عوض شد بعد از ثبت | نوبت موجود سالم؛ فقط جابهجایی جدید بافر جدید میگیرد |
|
||||
| سرویس محیط دیگر در `service_item_uuids[]` | `404` — `TenantOwnershipChecker` **پیش از** هر بررسی دیگر |
|
||||
| `durations` override با مقدار ۰ یا منفی | نادیده گرفته شود (رفتار موجود `serviceSlots`) |
|
||||
| نوبت گذشته | `service-reschedule` → `422` |
|
||||
| دو درخواست جابهجایی همزمان به یک زمان | `active_slot_key` → یکی `409` |
|
||||
| نوبت در محیطی که وسط کار به اسلاتی برگشت | `booking_mode` قفل است پس رخ نمیدهد؛ ولی اگر داده دستی عوض شد → `422` روشن |
|
||||
|
||||
## ۱۲. تست
|
||||
|
||||
```
|
||||
tests/Appointment/SlotModeFrozenTest.php ← ⭐ اول از همه
|
||||
- قرارداد appointment-slots بیتبهبیت
|
||||
- قرارداد month-availability
|
||||
- امضای متدهای عمومی SlotCalculatorService
|
||||
tests/Appointment/ServiceBookingCalculatorTest.php
|
||||
- جمع مدت چند سرویس (رفتار فعلی حفظ شود)
|
||||
- override منشی
|
||||
- سرویس بدون مدت → 422
|
||||
- سرویس غیرفعال با allowInactive → warning نه خطا
|
||||
- سرویس محیط دیگر → استثنا، بدون افشای وجود
|
||||
tests/Appointment/ServiceRescheduleTest.php ← ⭐
|
||||
- جابهجایی با همان سرویسها → مدت یکسان
|
||||
- حذف یک سرویس → مدت خودکار کم میشود، کلاینت عددی نفرستاده
|
||||
- زمان بیرون getServiceStartTimes → 422
|
||||
- زمان فعلی خود نوبت با excludeAppointmentId در فهرست است
|
||||
- روی نوبت اسلاتی → 422 ERR_WRONG_BOOKING_MODE
|
||||
tests/Appointment/PatchServiceDurationTest.php
|
||||
- slot_end ناسازگار → 422 ERR_SERVICE_DURATION_MISMATCH با مدت درست در پیام
|
||||
- service_item_uuids[] جایگزینی کامل میکند و serviceItem تکی همگام میشود
|
||||
- در حالت اسلاتی هیچکدام از اینها اجرا نمیشود (رفتار امروز)
|
||||
tests/Appointment/ConvertReserveTest.php
|
||||
- رزرو سرویسی → نوبت زماندار با مدت درست
|
||||
- رزرو اسلاتی → مسیر اسلاتی، بدون تغییر
|
||||
- active_slot_key بعد از تبدیل پر میشود
|
||||
- دو تبدیل همزمان → یکی 409
|
||||
tests/Appointment/ServiceModeSectionDurationTest.php ← موجود، باید سبز بماند
|
||||
tests/Appointment/BookingTenantTest.php ← موجود، باید سبز بماند
|
||||
assets/admin/pages/AppointmentEditPage.test.tsx
|
||||
- حالت اسلاتی: سه فیلد ساعت هست، ServiceSlotPicker نیست
|
||||
- حالت سرویسی: ServiceSlotPicker هست، فیلد ساعت پنهان است
|
||||
- تغییر سرویسها، slot انتخابی را باطل میکند
|
||||
```
|
||||
|
||||
## ۱۳. مستندات
|
||||
|
||||
`docs/api/appointment.md`:
|
||||
- بخش «روشهای نوبتدهی» با ماتریس «کدام endpoint در کدام حالت»
|
||||
- دو endpoint جدید
|
||||
- توسعهٔ `PATCH` و پارامتر `exclude_appointment_uuid`
|
||||
- دو کد خطای جدید
|
||||
|
||||
`docs/api/appointment-settings.md`: یادآوری قفل بودن `booking_mode` پس از اولین ثبت.
|
||||
|
||||
و یک سند کوتاه `docs/architecture/booking-modes.md` که ماتریس کامل را ثبت کند —
|
||||
تسک ۰۶ حالت سومی به همین ماتریس اضافه میکند.
|
||||
@@ -0,0 +1,143 @@
|
||||
# تسک ۰۰ — تکمیل نوبتدهی سرویسی در clinicpro
|
||||
|
||||
**فاز:** ۰ (تثبیت وضعیت فعلی) · **وابستگی:** — · **زمان:** ۱۴-۱۸ ساعت
|
||||
**پیشنیاز همهٔ تسکهای ۰۱ به بعد**
|
||||
|
||||
---
|
||||
|
||||
## ⛔ خط سرخ
|
||||
|
||||
منطق اسلاتی (`booking_mode = 'slot'`) در این تسک **به هیچ عنوان** دستکاری نمیشود.
|
||||
فهرست کامل قفلشدهها: [_shared/red-lines.md](../_shared/red-lines.md).
|
||||
|
||||
این تسک fixture و تست `--group=slot-mode-frozen` را **میسازد** — همان تستی که همهٔ
|
||||
تسکهای بعدی باید سبز نگهش دارند.
|
||||
|
||||
---
|
||||
|
||||
## هدف
|
||||
|
||||
حالت `booking_mode = 'service'` در مسیر **رزرو** کار میکند، ولی در بقیهٔ چرخهٔ عمر نوبت
|
||||
غایب است. این تسک آن را کامل میکند تا موتور چندمنبعی (تسک ۰۶) روی پایهٔ سالم ساخته شود.
|
||||
|
||||
## وضعیت فعلی — چه کار میکند و چه نمیکند
|
||||
|
||||
### ✅ کار میکند
|
||||
|
||||
| مسیر | فایل |
|
||||
|---|---|
|
||||
| انتخاب سرویس و اسلات در رزرو عمومی | `GET /api/v1/appointment-booking-services/{doctorUuid}` · `GET /api/v1/appointment-service-slots` |
|
||||
| محاسبهٔ زمانهای شروع بر اساس مدت سرویس | `SlotCalculatorService::getServiceStartTimes()` |
|
||||
| ثبت نوبت با چند سرویس | `POST /api/v1/appointment` + `appointment_service_items` |
|
||||
| توگل روش نوبتدهی در تنظیمات | `assets/admin/components/schedule/ScheduleSection.tsx` |
|
||||
| ساخت نوبت از پنل | `assets/admin/pages/AppointmentCreatePage.tsx` + `components/appointments/ServiceSlotPicker.tsx` + `hooks/useDoctorBookingServices.ts` |
|
||||
| ساخت سریع از drawer | `assets/admin/components/NewAppointmentDrawer.tsx` |
|
||||
| رزرو از سایت | `nobat724_front/components/appointment/*` |
|
||||
|
||||
### ❌ کار نمیکند — شکافهای این تسک
|
||||
|
||||
**۱. ویرایش و جابهجایی نوبت، حالت سرویسی را نمیشناسد.**
|
||||
|
||||
`PATCH /api/v1/appointment/{uuid}` ([AppointmentController.php:1077](../../../src/Appointment/Controller/AppointmentController.php#L1077)):
|
||||
|
||||
```php
|
||||
$hasStart = array_key_exists('slot_start', $data);
|
||||
$hasEnd = array_key_exists('slot_end', $data);
|
||||
// … فقط این دو بررسی میشوند:
|
||||
if ($newEnd <= $newStart) { /* 422 */ }
|
||||
if ($this->appointmentRepo->isSlotTaken($doctor, $newStart, $newEnd, $id)) { /* 409 */ }
|
||||
```
|
||||
|
||||
سه مشکل:
|
||||
- مدت دلخواه پذیرفته میشود؛ هیچ بررسیای که `slot_end - slot_start` با مجموع مدت
|
||||
سرویسهای نوبت بخواند وجود ندارد
|
||||
- `buffer_minutes` نادیده گرفته میشود — نوبت جدید میتواند چسبیده به نوبت بعدی بنشیند
|
||||
- فقط `service_item_uuid` تکی بهروز میشود؛ `service_items` (ManyToMany) دستنخورده
|
||||
میماند → نوبت با سرویسهای قبلی و مدت جدید ناسازگار میشود
|
||||
|
||||
**۲. `AppointmentEditPage.tsx` ورودی دستی ساعت دارد.**
|
||||
|
||||
سه فیلد `date`/`start`/`end` آزاد + یک `SearchableSelect` تکی برای سرویس
|
||||
([AppointmentEditPage.tsx:74-76](../../../assets/admin/pages/AppointmentEditPage.tsx#L74)).
|
||||
هیچ `ServiceSlotPicker` ای نیست، هیچ چند-سرویسی نیست.
|
||||
|
||||
نتیجه: منشی نوبت سرویسیِ ۴۵ دقیقهای را ویرایش میکند، ۲۰ دقیقه میگذارد، سیستم قبول
|
||||
میکند، و بیمار بعدی روی نوبت اول مینشیند.
|
||||
|
||||
**۳. نوبت رزرو (`is_reserve`) در حالت سرویسی معنا ندارد.**
|
||||
|
||||
`NewAppointmentDrawer.tsx:72` صریح: `$serviceMode = bookingMode === 'service' && !isReserve`.
|
||||
پس نوبت رزرو همیشه اسلاتی رفتار میکند و `ReserveAppointmentsPage.tsx` فقط
|
||||
`service_item?.name` تکی نشان میدهد. تبدیل رزرو به نوبت واقعی هم مسیر سرویسی ندارد.
|
||||
|
||||
**۴. `patient_facing` بودن مدت جایی نمایش داده نمیشود.**
|
||||
|
||||
پاسخ `appointment-service-slots` مدت کل را میدهد ولی نوبت ثبتشده هیچجا نگه نمیدارد
|
||||
که این مدت از کدام سرویسها و چه بافری آمده. لیست نوبتها فقط `slot_start/slot_end` دارد.
|
||||
|
||||
## دامنه
|
||||
|
||||
**هست:**
|
||||
- `ServiceBookingCalculator` — یک سرویس واحد که «مدت مجاز یک ترکیب سرویس» را حساب میکند
|
||||
(استخراج منطق تکرارشدهٔ `serviceSlots()` از کنترلر)
|
||||
- اعتبارسنجی حالت سرویسی در `PATCH /appointment/{uuid}`
|
||||
- endpoint جابهجایی سرویسآگاه: `POST /api/v1/appointment/{uuid}/service-reschedule`
|
||||
- `ServiceSlotPicker` در `AppointmentEditPage`
|
||||
- حالت سرویسی برای نوبت رزرو + تبدیل رزرو به نوبت
|
||||
- ستونهای `service_total_minutes` و `service_buffer_minutes` روی `appointments`
|
||||
- fixture و تست `--group=slot-mode-frozen`
|
||||
|
||||
**نیست:** بخشهای نوبت، چند منبع، قوانین (تسک ۰۵ به بعد). `nobat724_front` (تسک ۰۰ب).
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/appointment/{uuid}/service-reschedule` | جابهجایی سرویسآگاه: سرویسها + زمان شروع؛ مدت را خودش حساب میکند |
|
||||
| PATCH | `/api/v1/appointment/{uuid}` | **توسعه** — در حالت سرویسی مدت را اعتبارسنجی میکند و `service_item_uuids[]` میپذیرد |
|
||||
| POST | `/api/v1/appointment/{uuid}/convert-reserve` | تبدیل نوبت رزرو به نوبت زماندار (هر دو حالت) |
|
||||
| GET | `/api/v1/appointment-service-slots` | **توسعه** — پارامتر `exclude_appointment_uuid` برای جابهجایی |
|
||||
|
||||
هیچ endpoint اسلاتیای تغییر نمیکند. `GET /appointment-slots` دستنخورده.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: نوبت سرویسیِ «لیزر صورت (۲۰) + بیکینی (۱۵)» با مدت ۳۵ دقیقه.
|
||||
`POST /appointment/{uuid}/service-reschedule` با زمان جدید و همان سرویسها →
|
||||
`200` و `slot_end - slot_start = 35 * 60` دقیقاً.
|
||||
- ✅ موفق: همان endpoint با حذف بیکینی → مدت خودکار ۲۰ دقیقه میشود، بدون اینکه کلاینت
|
||||
عددی بفرستد.
|
||||
- ✅ موفق: `GET /appointment-service-slots?…&exclude_appointment_uuid={uuid}` بازهٔ خودِ
|
||||
نوبت را اشغال حساب نمیکند، پس زمان فعلیاش در فهرست میآید.
|
||||
- ✅ موفق: `AppointmentEditPage` برای نوبت سرویسی، `ServiceSlotPicker` نشان میدهد و
|
||||
ورودی دستی ساعت را **پنهان** میکند؛ برای نوبت اسلاتی، دقیقاً رفتار امروز.
|
||||
- ✅ موفق: نوبت رزرو در حالت سرویسی سرویسهایش را ذخیره میکند و
|
||||
`POST /convert-reserve` با زمان انتخابی، نوبت زماندار با مدت درست میسازد.
|
||||
- ✅ موفق (**خط سرخ**): `ddev exec php bin/phpunit --group=slot-mode-frozen` سبز است و
|
||||
fixture قرارداد اسلاتی بیتبهبیت تغییر نکرده.
|
||||
- ❌ خطا: `PATCH` با `slot_end - slot_start` ناسازگار با مدت سرویسها →
|
||||
`422` `ERR_SERVICE_DURATION_MISMATCH` با پیام فارسی شامل مدت درست.
|
||||
- ❌ خطا: `service-reschedule` روی نوبت **اسلاتی** → `422` `ERR_WRONG_BOOKING_MODE`.
|
||||
- ❌ خطا: `service-reschedule` با زمان شروعی که در `getServiceStartTimes` نیست →
|
||||
`422` با پیام «این زمان برای مدت انتخابی در دسترس نیست».
|
||||
- ❌ خطا: سرویس محیط دیگر در `service_item_uuids[]` → `404` (بدون لو دادن وجودش).
|
||||
- ⚠️ مرزی: نوبتی که سرویسهایش غیرفعال (`bookable=false`) شدهاند → جابهجایی مجاز است
|
||||
با `warnings[]`؛ افزودن سرویس غیرفعال ممنوع.
|
||||
- ⚠️ مرزی: `buffer_minutes` تغییر کرد بعد از ثبت نوبت → نوبت موجود سالم میماند؛
|
||||
فقط جابهجایی جدید بافر جدید را میگیرد.
|
||||
- ⚠️ مرزی: جابهجایی به روزی که برنامهٔ هفتگی آن محیط عوض شده → همان اعتبارسنجی
|
||||
`getServiceStartTimes`، پس خودکار پوشش داده میشود.
|
||||
- ⚠️ مرزی: نوبت سرویسی بدون هیچ سرویس (داده قدیمی) → مدت موجود حفظ میشود و
|
||||
`warnings[]` میگوید سرویس ثبت نشده. **رد نمیشود.**
|
||||
- ⚠️ مرزی: `PATCH` بدون `slot_start` روی نوبت سرویسی (فقط تغییر یادداشت) → بدون
|
||||
اعتبارسنجی مدت، مثل امروز.
|
||||
|
||||
## خروجی
|
||||
|
||||
- `src/Appointment/Service/ServiceBookingCalculator.php` + توسعهٔ کنترلر
|
||||
- `assets/admin/pages/AppointmentEditPage.tsx` توسعهیافته
|
||||
- `assets/admin/pages/ReserveAppointmentsPage.tsx` توسعهیافته
|
||||
- migration دو ستون تهیپذیر
|
||||
- `tests/Appointment/SlotModeFrozenTest.php` + fixture
|
||||
- `docs/api/appointment.md` بهروزرسانی
|
||||
- [checklist.md](checklist.md) کاملشده
|
||||
Reference in New Issue
Block a user