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 خلاف قاعدهاند
|
||||
- مدت و بافر با فارسی و واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)»
|
||||
Reference in New Issue
Block a user