diff --git a/config/services.yaml b/config/services.yaml index efe01cbb..5a892271 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -23,6 +23,13 @@ services: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + # Tag every room-deletion reason so RoomService can iterate them without knowing + # who they are. Tasks 02 (active resources) and 07 (future appointments) each add + # one implementation and RoomService itself stays untouched. + _instanceof: + App\Branch\Service\RoomDeletionGuardInterface: + tags: ['app.room_deletion_guard'] + # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name App\: diff --git a/docs/new_feture/taskes/_shared/branch-is-doctor-address.md b/docs/new_feture/taskes/_shared/branch-is-doctor-address.md new file mode 100644 index 00000000..1c2b646c --- /dev/null +++ b/docs/new_feture/taskes/_shared/branch-is-doctor-address.md @@ -0,0 +1,82 @@ +# «شعبه» جدول تازه‌ای نیست — `doctor_addresses` است + +**این سند بر همهٔ تسک‌هایی که `branches` یا `branch_id` می‌گویند حاکم است.** +نسخهٔ اول تسک ۰۱ یک جدول `branches` طراحی کرده بود؛ در اجرا معلوم شد آن موجودیت از قبل +وجود دارد. تسک ۰۱ اصلاح شد و جدول ساخته **نشد**. + +هر جا در تسک‌های ۰۲، ۰۴، ۰۷، ۰۸، ۰۹، ۱۰، ۱۳ نوشته شده `branch_id INT NOT NULL FK → +branches(id)`، بخوانید: + +```sql +address_id INT NOT NULL -- FK → doctor_addresses(id) +``` + +و هر جا `Branch $branch` نوشته شده، بخوانید `DoctorAddress $address`. + +--- + +## چرا + +`App\Doctor\Entity\DoctorAddress` تمام چیزی است که یک شعبه لازم دارد: + +| نیاز شعبه | در `DoctorAddress` | +|---|---| +| نام | `name` | +| آدرس | `address` | +| تلفن | `telephone` | +| مختصات | `latitude` / `longitude` | +| شهر و استان | FK به entity `City` / `Province` | +| مالک (محیط) | `forDoctor(Doctor)` یا `forClinic(int $clinicId)` + ستون `type` | +| فعال/غیرفعال | `active` — **تسک ۰۱ اضافه کرد** | +| منطقهٔ زمانی | `timezone` — **تسک ۰۱ اضافه کرد** | + +و از قبل در کل سیستم به همین معنا مصرف می‌شود: + +- `WeeklySchedule.setting[day].sessions[].location_id` → `doctor_addresses.id` +- `SlotCalculatorService::buildSessionSlots()` آن را در هر اسلات کپی می‌کند +- `GET /api/v1/appointment-booking-locations/{doctorUuid}` هر آدرس را «محل نوبت‌دهی» می‌نامد +- `DoctorAddressRepository::findForContext($doctor, $clinicId)` چند آدرس per محیط می‌دهد +- **۹ endpoint CRUD** موجود: `clinic/{uuid}/addresses` (۴) و `clinic-pro/doctor-address*` (۵) +- UI ادمین: `ClinicDetailPage`، `ClinicFormPage`، `DoctorDetailPage`، `SettingsPage` + +ساختن جدول موازی یعنی دو منبع حقیقت برای نام/آدرس/تلفن/مختصات یک مکان فیزیکی، و شعبه‌ای +که `location_id` هرگز به آن اشاره نمی‌کند — یعنی decorative. قاعدهٔ #۸ پروژه: +«API/جدول جدید فقط وقتی هیچ موجودی — حتی با توسعه — کافی نباشد.» + +--- + +## پیامد برای تسک‌های بعدی + +| تسک | چه چیزی عوض می‌شود | +|---|---| +| ۰۲ منابع | `clinic_resources.address_id` → `doctor_addresses(id)`. `ResourcePool` هم همین. «منابع مال شعبه‌اند» = مال یک آدرس‌اند | +| ۰۳ تقویم منبع | ساعت کاری شعبه از `branch_working_hours` که به `doctor_addresses.id` کلید می‌خورد | +| ۰۴ کاتالوگ | `service_branch_overrides.address_id` | +| ۰۷ رزرو | `appointments.address_id` **از قبل وجود دارد** (`Appointment::$addressId`) — ستون جدید لازم نیست | +| ۰۸ قیمت | `price_lists.address_id` | +| ۰۹/۱۰ قوانین | `policies.address_id` (اختصاصی‌بودن per شعبه) | +| ۱۳ لغو/انتظار | `waitlist_entries.address_id` | + +⚠️ نکتهٔ تسک ۰۷: `Appointment` از قبل `address_id` دارد (ستون `addressId`, تهی‌پذیر) و +`SlotCalculatorService::resolveSlotLocationId()` پرش می‌کند. پس آنجا هم ستون تازه لازم نیست. + +--- + +## جفت tenant اتاق و منابع + +`DoctorAddress` ستون‌های `entity_type`/`entity_id` ندارد؛ مالکیتش با `type` + +`doctor_id`/`clinic_id` بیان می‌شود. موجودیت‌های جدیدی که به آدرس کلید می‌خورند و +`TenantOwnedTrait` دارند، جفتشان را در **سازنده از آدرس مشتق** می‌کنند: + +```php +// App\Branch\Entity\Room::__construct() +$this->assignTenantPair( + $address->getType() === DoctorAddress::TYPE_CLINIC ? 'clinic' : 'doctor', + $address->getType() === DoctorAddress::TYPE_CLINIC + ? (int) $address->getClinicId() + : (int) $address->getDoctor()->getId(), +); +``` + +همان قاعدهٔ `docs/architecture/tenancy.md`: جفت در سازنده از ریشه مشتق می‌شود، نه از +ورودی درخواست — پس هیچ نقطهٔ ساختی نمی‌تواند فراموشش کند و write-once می‌ماند. diff --git a/docs/new_feture/taskes/task-01-branch-room/architecture.md b/docs/new_feture/taskes/task-01-branch-room/architecture.md index a68e4807..af730465 100644 --- a/docs/new_feture/taskes/task-01-branch-room/architecture.md +++ b/docs/new_feture/taskes/task-01-branch-room/architecture.md @@ -1,101 +1,111 @@ # معماری — تسک ۰۱ +> ⛔ نسخهٔ اول این فایل entity `Branch`، `BranchController` (CRUD شعبه)، `BranchService` و +> `BackfillBranchCommand` داشت. همه حذف شدند: «شعبه» = `DoctorAddress` و CRUDش از قبل +> وجود دارد. دلیل: [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md). + ## ساختار فایل ``` src/Branch/ ├── Controller/ -│ ├── BranchController.php # CRUD شعبه + ساعت کاری -│ └── RoomController.php # CRUD اتاق +│ ├── BranchWorkingHoursController.php # GET/PUT ساعت کاری یک آدرس +│ └── RoomController.php # CRUD اتاق ├── Entity/ -│ ├── Branch.php -│ ├── BranchWorkingHours.php -│ └── Room.php +│ ├── BranchWorkingHours.php # فرزند aggregate — بدون جفت tenant +│ └── Room.php # TenantOwnedTrait ├── Repository/ -│ ├── BranchRepository.php │ ├── BranchWorkingHoursRepository.php │ └── RoomRepository.php -├── Service/ -│ ├── BranchService.php # ساخت/ویرایش/حذف + قواعد حذف -│ └── WorkingHoursService.php # اعتبارسنجی و ذخیرهٔ هفت روز -└── Command/ - └── BackfillBranchCommand.php # app:branch:backfill +└── Service/ + ├── WorkingHoursService.php # اعتبارسنجی + جایگزینی هفت روز + ├── RoomService.php # ساخت/ویرایش/حذف + قواعد حذف + └── BranchResolver.php # uuid آدرس → DoctorAddress در محیط جاری + +src/Doctor/Entity/DoctorAddress.php # + active + timezone assets/admin/pages/ -├── BranchesPage.tsx -├── BranchFormPage.tsx # شامل تب ساعت کاری -└── RoomsPage.tsx +├── BranchesPage.tsx # لیست شعبه‌های محیط جاری + دو اکشن +├── BranchWorkingHoursPage.tsx +└── BranchRoomsPage.tsx ``` -## لایه‌بندی +دامنهٔ جدید `Branch` است نه `Doctor`، چون `BranchWorkingHours` و `Room` مفاهیم مکان‌اند و +تسک‌های ۰۲/۰۳ منابع را هم روی همین دامنه می‌سازند. `DoctorAddress` سرِ جایش در `Doctor` +می‌ماند — جابه‌جا کردنش namespace را می‌شکند بدون هیچ سودی. -`BranchController` نازک است: اعتبارسنجی ورودی + `EntityContextResolver` + صدا زدن سرویس. -همهٔ قواعد (حذف امن، یکتایی نام در محیط، نرمال‌سازی ساعت) در `BranchService` و -`WorkingHoursService`. +## `BranchResolver` — چرا لازم است + +`doctor_addresses` **ستون `entity_type`/`entity_id` ندارد**، پس `TenantFilter` رویش اعمال +نمی‌شود. یعنی `findOneBy(['uuid' => $uuid])` آدرس محیط دیگر را هم برمی‌گرداند. هر endpoint +جدیدی که با uuid آدرس شروع می‌شود باید محیط را **دستی** بررسی کند — همان کاری که +`clinic/{uuid}/addresses` با `findByUuidAndClinic()` می‌کند. + +یک نقطهٔ متمرکز به‌جای تکرار در سه کنترلر: ```php -final class BranchService +final class BranchResolver { public function __construct( - private readonly BranchRepository $branches, - private readonly RoomRepository $rooms, - private readonly EntityManagerInterface $em, + private readonly DoctorAddressRepository $addresses, + private readonly EntityContextResolver $context, ) {} - public function create(EntityContext $ctx, BranchInput $input): Branch + /** @throws AppException 404 وقتی آدرس در محیط جاری نیست */ + public function resolve(string $addressUuid): DoctorAddress { - $branch = new Branch($input->name); - $branch->assignTenant($ctx); // ← اجباری، وگرنه flush می‌شکند - // ... - } - - /** حذف فقط وقتی هیچ اتاق یا منبعِ فعالی به شعبه وصل نیست. */ - public function delete(Branch $branch): void - { - if ($this->rooms->countActiveByBranch($branch) > 0) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شعبه دارای اتاق فعال است', 422); + $address = $this->addresses->findOneBy(['uuid' => $addressUuid]); + if ($address === null || !$this->belongsToCurrentContext($address)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404); } - // ... + return $address; } } ``` -## رابطهٔ Branch با DoctorAddress - -`DoctorAddress` حذف نمی‌شود. یک ستون `branch_id` تهی‌پذیر می‌گیرد: - -``` -DoctorAddress.branch_id ──▶ branches.id (nullable, ON DELETE SET NULL) -``` - -دلیل: `location_id` در JSON برنامهٔ هفتگی به `doctor_addresses.id` اشاره دارد و در -`SlotCalculatorService` و `AppointmentController::bookingLocations()` و سایت عمومی مصرف می‌شود. -تغییر آن قرارداد یعنی شکستن سه کلاینت. پس شعبه یک **لایهٔ بالاتر** می‌نشیند و آدرس به آن -لینک می‌شود، نه برعکس. - -`BackfillBranchCommand` برای هر محیطی که آدرس دارد یک شعبه با نام آدرس می‌سازد و -`branch_id` را پر می‌کند. dry-run پیش‌فرض، `--force` برای اجرا. +**۴۰۴ نه ۴۰۳** — همان رفتار `TenantFilter`: وجود دادهٔ محیط دیگر لو نمی‌رود. ## ساعت کاری شعبه -مثل `WeeklySchedule` یک JSON نیست — جدول جداست، چون تسک ۰۳ باید بتواند -`WHERE branch_id = ? AND day = ?` بزند بدون خواندن و decode کردن JSON برای هر روز از ۹۰ روز. +جدول جداست نه JSON مثل `WeeklySchedule`، چون تسک ۰۳ باید +`WHERE address_id = ? AND day_of_week = ?` بزند بدون decode کردن JSON برای هر روز از ۹۰ روز. ```php #[ORM\Entity] #[ORM\Table(name: 'branch_working_hours')] -#[ORM\UniqueConstraint(name: 'uniq_branch_day_seq', columns: ['branch_id', 'day_of_week', 'sequence'])] +#[ORM\UniqueConstraint(name: 'uniq_bwh_address_day_seq', columns: ['address_id', 'day_of_week', 'sequence'])] class BranchWorkingHours { + private DoctorAddress $address; private int $dayOfWeek; // 0=شنبه … 6=جمعه — همان قرارداد SlotCalculatorService private int $startMinute; // دقیقه از نیمه‌شب، 0..1440 private int $endMinute; - private int $sequence; // چند بازه در روز (صبح/عصر) + private int $sequence; // بازهٔ چندم آن روز (صبح/عصر) + private bool $active = true; } ``` -`startMinute`/`endMinute` به‌جای رشتهٔ `"08:30"` ذخیره می‌شوند تا مقایسه و تقاطع در تسک ۰۶ -حسابی باشد نه رشته‌ای. تبدیل به `H:i` فقط در `toArray()`. +`startMinute`/`endMinute` عدد است نه رشتهٔ `"08:30"`، تا تقاطع در تسک ۰۶ حسابی باشد نه +رشته‌ای. تبدیل به `H:i` فقط در `toArray()`. + +### `WorkingHoursService` — جایگزینی کامل، نه تفاضلی + +```php +public function replace(DoctorAddress $address, array $days): array +{ + $rows = $this->validate($days); // اول همه را اعتبارسنجی کن + $this->repository->deleteForAddress($address); // بعد پاک کن + foreach ($rows as $row) { $this->em->persist(...); } + $this->em->flush(); +} +``` + +اعتبارسنجی **قبل از** حذف اتفاق می‌افتد؛ وگرنه یک بازهٔ نامعتبر در روز ششم، پنج روز درست +را هم پاک می‌کند و ۴۲۲ برمی‌گرداند. PUT semantics: بدنه تمام حقیقت است، آرایهٔ خالی = +شعبه کامل بسته. + +قواعد اعتبارسنجی: `0 <= start < end <= 1440` · هیچ دو بازهٔ هم‌پوشان در یک روز +(بازه‌ها را per روز sort و همسایه‌ها را مقایسه کن) · `day_of_week ∈ 0..6`. ## اتاق @@ -103,23 +113,40 @@ class BranchWorkingHours class Room { use TenantOwnedTrait; - private Branch $branch; + private DoctorAddress $address; private string $name; - private ?string $roomType = null; // متن آزاد — نوعِ اتاق را کلینیک تعریف می‌کند + private ?string $roomType = null; // متن آزاد — نوع اتاق را کلینیک تعریف می‌کند private int $capacity = 1; // چند بیمار هم‌زمان (اتاق تزریق سه‌تخته = 3) + private ?string $floor = null; private bool $active = true; } ``` -`capacity` از همین‌جا شروع می‌شود چون مستند بند ۶ صریح می‌گوید سه تخت = **یک منبع با -ظرفیت سه**، نه سه منبع. تسک ۰۲ همین معنا را روی `Resource` تکرار می‌کند و اتاق را -به‌عنوان یک `Resource` با `resource_type=room` منعکس می‌کند. +جفت tenant در **سازنده از آدرس مشتق** می‌شود، نه از بدنهٔ request — پس هیچ نقطهٔ ساختی +نمی‌تواند فراموشش کند. `capacity` از روز اول هست چون مستند بند ۶ صریح می‌گوید سه تخت = +**یک منبع با ظرفیت سه**، نه سه منبع؛ تسک ۰۲ همین معنا را روی `Resource` تکرار می‌کند و +اتاق را به‌عنوان `resource_type=room` منعکس می‌کند. + +حذف اتاق در این تسک فقط `active` را چک می‌کند (اتاق فعال قابل حذف است، منبع هنوز وجود +ندارد). گاردِ «اتاقی که منبع فعال دارد حذف نشود» در تسک ۰۲ اضافه می‌شود — آنجاست که +`Resource.room_id` به وجود می‌آید. این را در checklist به‌عنوان ⏳ با مقصد صریح ثبت کن. ## پنل ادمین -- `BranchesPage.tsx` — `DataTable` + `PageHeader` با `backTo`، وضعیت لیست در URL با `useUrlState` -- `BranchFormPage.tsx` — دو تب: مشخصات / ساعت کاری. `SearchableSelect` برای شهر - (هرگز `` بومی | ⏳ | | -| ۳.۵ | وضعیت لیست در URL با `useUrlState` | ⏳ | | -| ۳.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | ⏳ | | -| ۳.۷ | دارک‌مود و حالت فشرده بررسی شد | ⏳ | | -| ۳.۸ | RTL و موبایل بررسی شد | ⏳ | | -| ۳.۹ | همهٔ رشته‌ها فارسی از i18n | ⏳ | | -| ۳.۱۰ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمی‌ماند» | ⏳ | | -| ۳.۱۱ | مسیرها در `App.tsx` | ⏳ | | +| ۳.۱ | `branch_working_hours` · `rooms` | ⏳ | | +| ۳.۲ | `entity_type, entity_id` ستون **اول** ایندکس `rooms` | ⏳ | | +| ۳.۳ | `timezone` روی آدرس از روز اول | ⏳ | افزودن بعدی = backfill زمان‌دار | +| ۳.۴ | `rooms.capacity` — ظرفیت هم‌زمان | ⏳ | اتاق سه‌تخته = یک ردیف با ۳ | +| ۳.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح | ⏳ | | +| ۳.۶ | ستون‌ها و جدول‌ها روی `db_test` هم ساخته شد | ⏳ | تاریخچهٔ migration جدا | +| ۳.۷ | `TenantSchemaCoverageTest` سبز | ⏳ | | +| ۳.۸ | `TenantLookupInventoryTest` سبز — repository جدید ثبت شد | ⏳ | | -## ۴. تست +## ۴. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `BranchCrudTest` — نقش‌ها، ۴۰۴ نه ۴۰۳ برای محیط دیگر | ⏳ | | -| ۴.۲ | `WorkingHoursTest` — `end<=start`، هم‌پوشانی، شبانه‌روزی `0..1440` | ⏳ | | -| ۴.۳ | `BranchDeletionTest` — شعبهٔ دارای اتاق فعال → ۴۲۲ | ⏳ | | -| ۴.۴ | `capacity=0` → ۴۲۲ | ⏳ | | -| ۴.۵ | `phpstan analyse src/Branch` بدون خطا | ⏳ | | +| ۴.۱ | `BranchesPage` · `BranchWorkingHoursPage` · `BranchRoomsPage` | ⏳ | | +| ۴.۲ | `DataTable` با skeleton و empty state فارسی | ⏳ | | +| ۴.۳ | `PageHeader` با `backTo` روی زیرصفحه‌ها | ⏳ | | +| ۴.۴ | هر `select` با `SearchableSelect` — هیچ `