- 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.
15 KiB
معماری — تسک ۰۰
ساختار فایل
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):
// وضعیت فعلی — درون کنترلر، تکرارشدنی
$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) به همین محاسبه نیاز دارند. کپیکردنش یعنی چهار نسخه با چهار رفتار مرزی متفاوت.
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} — توسعه، نه بازنویسی
// وضعیت فعلی حفظ میشود؛ فقط یک شاخه اضافه میشود
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": []
}
}
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
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 — دو حالت، یک صفحه
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 یعنی کاربر فکر میکند باید کاری بکند.
تست قرارداد اسلاتی — قلب خط سرخ
// 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
AppointmentEditPageاز قبلPageHeaderباbackToدارد → حفظ شودServiceSlotPickerموجود بازاستفاده میشود؛ نسخهٔ موازی ساخته نمیشود- انتخاب چند سرویس با
SearchableSelectچندانتخابی — نه<select multiple> - تاریخ با
PersianDateInputموجود ReserveAppointmentsPageجدول خام دارد (<td style={td}>) — در همین تسک بهDataTableمهاجرت کند، چون داریم دستش میزنیم و توکنهای inline خلاف قاعدهاند- مدت و بافر با فارسی و واحد: «۳۵ دقیقه (+۱۰ دقیقه فاصله)»