From 6c2e075eea1f9377d253571013043d1821e8237c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 12:47:48 +0330 Subject: [PATCH] feat(booking): persist service duration and allow full service replacement Appointment gains: - replaceServiceItems(): full replacement that unconditionally syncs the legacy single serviceItem column. addServiceItem() only fills it when null, which would leave stale service names in the four consumers that read service_item (admin lists, public site, desktop app). - currentServiceUuids(): input-order uuids, falling back to the single column for appointments created before multi-service support. - service_total_minutes / service_buffer_minutes (both nullable, NULL in slot mode). slot_end - slot_start carries the number but cannot say whether it was intentional, and a reserve entry has slot_start == slot_end so its duration had nowhere to live. Existing columns untouched: slot_start, slot_end, active_slot_key, is_reserve verified unchanged via SHOW COLUMNS. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) --- .../checklist.md | 19 +- migrations/Version20260730091332.php | 35 ++++ src/Appointment/Entity/Appointment.php | 79 ++++++++ .../AppointmentServiceFieldsTest.php | 172 ++++++++++++++++++ 4 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 migrations/Version20260730091332.php create mode 100644 tests/Appointment/AppointmentServiceFieldsTest.php diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md index 10510508..ff00d92d 100644 --- a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md +++ b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۰ (تکمیل نوبت‌دهی سرویسی در clinicpro) -**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۱ از ۱۰ (خط سرخ) تمام شد +**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۳ از ۱۰ تمام شد (خط سرخ · ServiceBookingCalculator · فیلدهای سرویسی نوبت) **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · @@ -37,9 +37,9 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۱.۷ | `ReserveConversionService` + `POST /appointment/{uuid}/convert-reserve` | ⏳ | | | ۱.۸ | نوبت رزرو در حالت سرویسی سرویس‌ها را ذخیره می‌کند | ⏳ | | | ۱.۹ | `excludeAppointmentId` روی `getServiceStartTimes` و `findBusyIntervals` | ⏳ | همان الگوی `isSlotTaken` | -| ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ⏳ | | -| ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را هم‌گام می‌کند | ⏳ | چهار مصرف‌کننده رویش خوانده‌اند | -| ۱.۱۲ | `setIsReserve()` صدا زدن `refreshActiveSlotKey()` | ⏳ | | +| ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ✅ | کنار `addServiceItem()` موجود؛ `currentServiceUuids()` ترتیب ورودی را حفظ می‌کند و برای نوبت قدیمیِ فقط-تکی هم uuid می‌دهد | +| ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را هم‌گام می‌کند | ✅ | برخلاف `addServiceItem()` که فقط وقتی `null` است ست می‌کند، این یکی **بی‌قید** بازنویسی می‌کند (و با فهرست خالی `null` می‌شود). چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند | +| ۱.۱۲ | ~~`setIsReserve()` صدا زدن `refreshActiveSlotKey()`~~ | ✅ | **موردی برای انجام نبود:** `setIsReserve()` وجود ندارد؛ toggle رزرو از قبل با `rescheduleTo($start,$end,$isReserve)` است که خودش `refreshActiveSlotKey()` را صدا می‌زند | | ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | ⏳ | | | ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ✅ | `belongsToPair()` (نه `belongsTo()`): مسیر عمومی است و کاربر احراز‌شده ندارد. جای مقایسهٔ دستیِ `getSection()->getEntityType()` نشست — نقطهٔ واحد `tenancy.md`. ⚠️ نام property باید `itemRepo` باشد وگرنه `TenantLookupInventoryTest` آن را **نمی‌شمارد** و از تور ایمنی فرار می‌کند | | ۱.۱۵ | `allowInactive` فقط برای سرویس‌های موجود نوبت، نه uuid های تازه | ✅ | پرچم روی `calculate()`؛ غیرفعال‌بودن → `warnings[]` به‌جای ۴۲۲. مدتِ نامعتبر همچنان حتی با `allowInactive` رد می‌شود (تست دارد) | @@ -49,12 +49,12 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | `service_total_minutes` و `service_buffer_minutes` (تهی‌پذیر) | ⏳ | | -| ۲.۲ | هیچ ستون موجودی حذف/تغییر نوع/تغییر معنا نداد | ⏳ | | +| ۲.۱ | `service_total_minutes` و `service_buffer_minutes` (تهی‌پذیر) | ✅ | `SMALLINT DEFAULT NULL` هر دو · `migrations/Version20260730091332.php` · `setServiceDuration()` هر دو را با هم ست می‌کند (بافرِ بی‌مدت معنا ندارد) | +| ۲.۲ | هیچ ستون موجودی حذف/تغییر نوع/تغییر معنا نداد | ✅ | تأیید با `SHOW COLUMNS`: `slot_start` int NOT NULL · `slot_end` int NOT NULL · `active_slot_key` varchar(64) UNI · `is_reserve` tinyint default 0 — همه دست‌نخورده | | ۲.۳ | دو کد خطای جدید در `ErrorCodes.php` با پیام فارسی | ⏳ | شمارهٔ واقعی از خود فایل | | ۲.۴ | `app:appointment:backfill-service-duration` — dry-run پیش‌فرض، idempotent | ⏳ | | | ۲.۵ | backfill مقدار را از خود نوبت می‌گیرد، نه بازمحاسبه از سرویس‌ها | ⏳ | | -| ۲.۶ | migration اجرا شد و `TenantSchemaCoverageTest` سبز | ⏳ | | +| ۲.۶ | migration اجرا شد و `TenantSchemaCoverageTest` سبز | ✅ | dev با migration؛ **db_test با `ALTER` دستی** — تاریخچهٔ migration آن DB از dev جدا است (`Table users already exists`) و این روش نگهداشتِ موجودِ همان DB است | ## ۳. UI — پنل ادمین @@ -83,13 +83,14 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `SlotModeFrozenTest` — سه سنجه | ⏳ | | +| ۴.۱ | `SlotModeFrozenTest` — سه سنجه | ✅ | `OK (3 tests, 8 assertions)` | | ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ✅ | ۱۳ تست / ۲۹ assertion سبز. شامل: جمع مدت + بافر · `endFor` بدون بافر · override منشی بدون تغییر پیش‌فرض سرویس · چهار مسیر خطا با کد/پیام/فیلد دقیق · uuid ناموجود از سرویسِ محیط دیگر **قابل تفکیک نیست** · `allowInactive` → warning · فهرست خالی → صفر · override نامعتبر (۰ و منفی) → fallback · پزشک بی‌برنامه → پیش‌فرض `slot` | | ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | ⏳ | | | ۴.۴ | `PatchServiceDurationTest` — شامل «در حالت اسلاتی هیچ‌کدام اجرا نمی‌شود» | ⏳ | | | ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | | | ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | ⏳ | | -| ۴.۷ | `BookingTenantTest` موجود سبز ماند | ⏳ | | +| ۴.۷ | `BookingTenantTest` موجود سبز ماند | ✅ | داخل `tests/Appointment` — کل ۳۰۹ تست `tests/Appointment` + `tests/Shared` سبز | +| ۴.۹ | `AppointmentServiceFieldsTest` — متدها و ستون‌های جدید | ✅ | ۹ تست / ۲۴ assertion. شامل: هم‌گامی ستون تکی · حفظ ترتیب · فهرست خالی → `null` · حالت اسلاتی هر دو ستون `null` · مدتِ `null` بافر را هم `null` می‌کند · تکراری‌ها dedup · نوبت قدیمیِ فقط-تکی · بقای مقادیر پس از flush/clear | | ۴.۸ | `AppointmentEditPage.test.tsx` — دو حالت | ⏳ | | ## ۵. مستندات diff --git a/migrations/Version20260730091332.php b/migrations/Version20260730091332.php new file mode 100644 index 00000000..fdc51de9 --- /dev/null +++ b/migrations/Version20260730091332.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE appointments ADD service_total_minutes SMALLINT DEFAULT NULL, ADD service_buffer_minutes SMALLINT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE appointments DROP service_total_minutes, DROP service_buffer_minutes'); + } +} diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 06fe321f..c5726acb 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -206,6 +206,25 @@ class Appointment #[ORM\Column(name: 'is_reserve', type: 'boolean', options: ['default' => false])] private bool $isReserve = false; + /** + * مدتِ محاسبه‌شدهٔ ترکیب سرویس‌ها در لحظهٔ ثبت — فقط در حالت نوبت‌دهی سرویسی. + * + * `slot_end - slot_start` همین عدد را دارد ولی نمی‌گوید عمدی بود یا دستی؛ و برای نوبت + * رزرو (که `slot_start == slot_end` است) هیچ‌جای دیگری مدت نگه‌داشته نمی‌شود، پس تبدیل + * رزرو به نوبت زمان‌دار بدون این ستون مدت را از دست می‌دهد. + * + * در حالت اسلاتی همیشه NULL می‌ماند. + */ + #[ORM\Column(name: 'service_total_minutes', type: 'smallint', nullable: true)] + private ?int $serviceTotalMinutes = null; + + /** + * `buffer_minutes` مؤثر در لحظهٔ ثبت. تغییر بافر در تنظیمات نباید معنای نوبت‌های + * ثبت‌شده را عوض کند (قانون پنجم مستند: هر چیزی که ثبت شد همان‌طور می‌ماند). + */ + #[ORM\Column(name: 'service_buffer_minutes', type: 'smallint', nullable: true)] + private ?int $serviceBufferMinutes = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -289,6 +308,51 @@ class Appointment } return $this; } + + /** + * جایگزینی کامل سرویس‌های نوبت (برای ویرایش و جابه‌جایی سرویس‌آگاه). + * + * برخلاف {@see addServiceItem()}، ستون تکیِ `serviceItem` را **بی‌قید** با اولین عضو + * هم‌گام می‌کند: چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند (`AppointmentsPage`، + * `ReserveAppointmentsPage`، `nobat724_front/services/response.js`، + * `clinic-pro-tauri/src/service/response.js`) و رهاکردنش یعنی نوبت با سرویس‌های جدید + * ولی نامِ سرویس قدیمی در فهرست‌ها. همان الگوی `ServiceItem::setStaffMembers()`. + * + * @param \App\ClinicService\Entity\ServiceItem[] $items + */ + 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; + } + + /** + * uuid سرویس‌های فعلی، به ترتیب. نوبت‌های پیش از چند-سرویسی‌شدن فقط `serviceItem` + * تکی دارند، پس آن هم پوشش داده می‌شود. + * + * @return string[] + */ + public function currentServiceUuids(): array + { + $uuids = array_map( + fn(\App\ClinicService\Entity\ServiceItem $i) => $i->getUuid(), + $this->serviceItems->toArray(), + ); + + if ($uuids === [] && $this->serviceItem !== null) { + $uuids = [$this->serviceItem->getUuid()]; + } + + return array_values($uuids); + } public function getStaff(): ?\App\Staff\Entity\ClinicStaff { return $this->staff; } public function isDepositRequired(): bool { return $this->depositRequired; } public function getDepositAmountRials(): ?int { return $this->depositAmountRials; } @@ -297,6 +361,18 @@ class Appointment public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; } public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; } public function isReserve(): bool { return $this->isReserve; } + public function getServiceTotalMinutes(): ?int { return $this->serviceTotalMinutes; } + public function getServiceBufferMinutes(): ?int { return $this->serviceBufferMinutes; } + + /** هر دو با هم ست می‌شوند: مدت بی‌بافر و بافر بی‌مدت هیچ‌کدام معنا ندارند. */ + public function setServiceDuration(?int $totalMinutes, ?int $bufferMinutes): self + { + $this->serviceTotalMinutes = $totalMinutes; + $this->serviceBufferMinutes = $totalMinutes === null ? null : $bufferMinutes; + $this->updatedAt = time(); + + return $this; + } public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; } public function setServiceItem(?\App\ClinicService\Entity\ServiceItem $v): self { $this->serviceItem = $v; return $this; } @@ -430,6 +506,9 @@ class Appointment 'insurance_base_id' => $this->insuranceBaseId, 'insurance_supplementary_id' => $this->insuranceSupplementaryId, 'is_reserve' => $this->isReserve, + // فقط در حالت نوبت‌دهی سرویسی پر می‌شوند؛ در حالت اسلاتی null. + 'service_total_minutes' => $this->serviceTotalMinutes, + 'service_buffer_minutes' => $this->serviceBufferMinutes, 'version' => $this->version, 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, diff --git a/tests/Appointment/AppointmentServiceFieldsTest.php b/tests/Appointment/AppointmentServiceFieldsTest.php new file mode 100644 index 00000000..92753c67 --- /dev/null +++ b/tests/Appointment/AppointmentServiceFieldsTest.php @@ -0,0 +1,172 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر فیلدهای سرویسی'); + $this->em->persist($doctor); + // بخش به شناسهٔ پزشک نیاز دارد، پس پزشک باید پیش از ساختش flush شود. + $this->em->flush(); + + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش تست'); + $this->em->persist($section); + + $items = []; + for ($i = 1; $i <= $count; $i++) { + $item = new ServiceItem($section, 'سرویس ' . $i, 0); + $item->setDurationMinutes(10 * $i)->setBookable(true); + $this->em->persist($item); + $items[] = $item; + } + $this->em->flush(); + + return [$doctor, $items]; + } + + private function appointmentFor(Doctor $doctor): \App\Appointment\Entity\Appointment + { + $patient = $this->createUser(['ROLE_USER']); + $start = strtotime('+5 days 10:00'); + + return $this->newAppointment($doctor, $patient, $start, $start + 1800); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testReplaceServiceItemsSyncsTheLegacySingleColumn(): void + { + [$doctor, $items] = $this->doctorWithServices(3); + $appt = $this->appointmentFor($doctor); + + $appt->replaceServiceItems([$items[0], $items[1]]); + self::assertCount(2, $appt->getServiceItems()); + self::assertSame($items[0], $appt->getServiceItem(), 'سرویس تکی = اولین عضو'); + + // جایگزینی کامل: اعضای قبلی می‌روند و تکی هم به‌روز می‌شود. + $appt->replaceServiceItems([$items[2]]); + self::assertCount(1, $appt->getServiceItems()); + self::assertSame($items[2], $appt->getServiceItem(), 'تکی باید با عضو جدید هم‌گام شود'); + } + + public function testCurrentServiceUuidsKeepsInputOrder(): void + { + [$doctor, $items] = $this->doctorWithServices(3); + $appt = $this->appointmentFor($doctor); + + $appt->replaceServiceItems([$items[2], $items[0], $items[1]]); + + self::assertSame( + [$items[2]->getUuid(), $items[0]->getUuid(), $items[1]->getUuid()], + $appt->currentServiceUuids(), + ); + } + + public function testSetServiceDurationIsExposedInToArray(): void + { + [$doctor] = $this->doctorWithServices(1); + $appt = $this->appointmentFor($doctor); + + $appt->setServiceDuration(45, 10); + + self::assertSame(45, $appt->getServiceTotalMinutes()); + self::assertSame(10, $appt->getServiceBufferMinutes()); + + $array = $appt->toArray(); + self::assertSame(45, $array['service_total_minutes']); + self::assertSame(10, $array['service_buffer_minutes']); + } + + // ── ❌ خطا / مسیر نادرست ───────────────────────────────────────────────── + + public function testReplaceWithEmptyListClearsTheLegacySingleColumn(): void + { + [$doctor, $items] = $this->doctorWithServices(2); + $appt = $this->appointmentFor($doctor); + $appt->replaceServiceItems([$items[0]]); + + $appt->replaceServiceItems([]); + + self::assertCount(0, $appt->getServiceItems()); + self::assertNull($appt->getServiceItem(), 'سرویس تکی نباید یادگارِ انتخاب قبلی بماند'); + self::assertSame([], $appt->currentServiceUuids()); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testSlotModeAppointmentKeepsBothColumnsNull(): void + { + [$doctor] = $this->doctorWithServices(1); + $appt = $this->appointmentFor($doctor); + + self::assertNull($appt->getServiceTotalMinutes()); + self::assertNull($appt->getServiceBufferMinutes()); + self::assertNull($appt->toArray()['service_total_minutes']); + } + + public function testClearingDurationAlsoClearsBuffer(): void + { + [$doctor] = $this->doctorWithServices(1); + $appt = $this->appointmentFor($doctor); + $appt->setServiceDuration(45, 10); + + // بافرِ بی‌مدت معنا ندارد؛ حتی اگر مقدار بافر پاس داده شود باید NULL شود. + $appt->setServiceDuration(null, 10); + + self::assertNull($appt->getServiceTotalMinutes()); + self::assertNull($appt->getServiceBufferMinutes()); + } + + public function testDuplicateItemsInReplaceAreDeduplicated(): void + { + [$doctor, $items] = $this->doctorWithServices(1); + $appt = $this->appointmentFor($doctor); + + $appt->replaceServiceItems([$items[0], $items[0]]); + + self::assertCount(1, $appt->getServiceItems()); + } + + public function testLegacyAppointmentWithOnlySingleItemStillReportsItsUuid(): void + { + [$doctor, $items] = $this->doctorWithServices(1); + $appt = $this->appointmentFor($doctor); + + // نوبت پیش از چند-سرویسی‌شدن: فقط ستون تکی پر است. + $appt->setServiceItem($items[0]); + + self::assertCount(0, $appt->getServiceItems()); + self::assertSame([$items[0]->getUuid()], $appt->currentServiceUuids()); + } + + public function testReplacePersistsThroughFlush(): void + { + [$doctor, $items] = $this->doctorWithServices(2); + $appt = $this->appointmentFor($doctor); + $appt->replaceServiceItems([$items[0], $items[1]])->setServiceDuration(30, 5); + $this->em->persist($appt); + $this->em->flush(); + $this->em->clear(); + + $reloaded = static::getContainer() + ->get(\App\Appointment\Repository\AppointmentRepository::class) + ->findByUuid($appt->getUuid()); + + self::assertNotNull($reloaded); + self::assertCount(2, $reloaded->getServiceItems()); + self::assertSame(30, $reloaded->getServiceTotalMinutes()); + self::assertSame(5, $reloaded->getServiceBufferMinutes()); + } +}