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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 12:47:48 +03:30
co-authored by Claude Opus 5
parent aaa864f408
commit 6c2e075eea
4 changed files with 296 additions and 9 deletions
@@ -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` — دو حالت | ⏳ | |
## ۵. مستندات
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* حالت نوبت‌دهی سرویسی: مدت و بافرِ محاسبه‌شده در لحظهٔ ثبت را روی خودِ نوبت نگه می‌دارد.
*
* هر دو ستون تهی‌پذیرند و در حالت اسلاتی NULL می‌مانند؛ هیچ ستون موجودی حذف نمی‌شود،
* نوعش عوض نمی‌شود و معنایش تغییر نمی‌کند. `slot_start`/`slot_end`/`active_slot_key`
* دست‌نخورده‌اند (خط سرخ فاز موتور چندمنبعی).
*
* @see docs/new_feture/taskes/task-00-service-mode-completion/database.md
*/
final class Version20260730091332 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add service_total_minutes and service_buffer_minutes to appointments (service booking mode)';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+79
View File
@@ -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,
@@ -0,0 +1,172 @@
<?php
namespace App\Tests\Appointment;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* فیلدهای سرویسیِ نوبت: جایگزینی کامل سرویس‌ها، هم‌گامی ستون تکیِ `serviceItem`، و
* مدت/بافرِ ثبت‌شده در لحظهٔ ثبت.
*/
class AppointmentServiceFieldsTest extends ApiTestCase
{
private function doctorWithServices(int $count): array
{
$owner = $this->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());
}
}