feat(branch): branch working hours and rooms on the existing address entity
Task 01 planned a new `branches` table with `doctor_addresses.branch_id` bridging to it. That plan was wrong: the branch already exists and is called `DoctorAddress`. It carries name, address, telephone, coordinates, city/province FKs and an owner (`forDoctor` / `forClinic` + `type`), and the whole system already consumes it with exactly that meaning — `WeeklySchedule.sessions[].location_id` points at `doctor_addresses.id`, `appointment-booking-locations` calls each row a booking location, and nine CRUD endpoints plus four admin pages manage them. A parallel table would mean two sources of truth for one physical place and a branch that `location_id` never references. So no `branches` table and no duplicate branch CRUD. Only the three genuinely missing pieces: - `doctor_addresses.active` / `.timezone`, both NOT NULL with a default so existing rows need no backfill and no current behaviour changes. `active` is stored only — applying it to slot calculation is task 03, since touching `SlotCalculatorService` is off limits in this phase. - `branch_working_hours`, keyed to `doctor_addresses.id`. Minutes from midnight rather than "09:00" strings so range intersection stays arithmetic. PUT replaces all seven days; validation of the whole week runs before any DELETE, so an invalid sixth day cannot wipe the five valid ones and then answer 422. - `rooms`, with `capacity` as concurrency (a three-bed injection room is one resource with capacity 3, not three resources) and a deletion-guard iterator so tasks 02 and 07 can add reasons without editing RoomService. `BranchWorkingHours` first registered as an aggregate child of `DoctorAddress`; TenantSchemaCoverageTest rejected it correctly, because that root is itself declared global. It now carries a real tenant pair instead, derived in the constructor from the address's `type` — a total mapping, and the address is only ever listed in its own context, so nothing is hidden wrongly. RoomController checks ownership explicitly rather than trusting TenantFilter: hard isolation only applies to a *chosen* context, so a doctor who had not selected one could PATCH another clinic's room. Caught by RoomCrudTest::testForeignRoomIsNotFound, which failed with 200 before the fix. 35 tests, 97 assertions. Slot-mode frozen contract still green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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\:
|
||||
|
||||
@@ -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 میماند.
|
||||
@@ -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` برای شهر
|
||||
(هرگز `<select>` بومی)
|
||||
- `RoomsPage.tsx` — زیرصفحهٔ شعبه، `<BackButton fallback="/admin/branches" />`
|
||||
- مسیرها در `App.tsx`: `/admin/branches`, `/admin/branches/new`, `/admin/branches/:uuid`,
|
||||
`/admin/branches/:uuid/rooms`
|
||||
سه صفحهٔ جدید، همه با الگوهای موجود (`_shared/ui-conventions.md`):
|
||||
|
||||
| صفحه | مسیر | نکات |
|
||||
|---|---|---|
|
||||
| `BranchesPage` | `/admin/branches` | `DataTable` + `PageHeader` با `backTo="/admin/settings-menu"` · وضعیت در URL با `useUrlState` · هر ردیف دو اکشن: «ساعت کاری» و «اتاقها» |
|
||||
| `BranchWorkingHoursPage` | `/admin/branches/:addressUuid/working-hours` | `<PageHeader backTo="/admin/branches">` · هفت کارت روز، هر کارت چند بازه با افزودن/حذف · ذخیره = یک PUT |
|
||||
| `BranchRoomsPage` | `/admin/branches/:addressUuid/rooms` | `DataTable` + `Modal` برای ساخت/ویرایش + `ConfirmDialog` برای حذف |
|
||||
|
||||
`BranchesPage` **آدرس نمیسازد و ویرایش نمیکند** — آن کار در `ClinicDetailPage` و
|
||||
`DoctorDetailPage` از قبل هست. این صفحه فقط دروازهٔ ساعت کاری و اتاق است، بهعلاوهٔ
|
||||
سوییچ `active` و انتخاب `timezone`.
|
||||
|
||||
نقشها: `RoleRoute roles={['clinic', 'doctor', 'secretary']}` با
|
||||
`permission={['appointment_settings', 'view']}` — ساعت کاری شعبه از جنس تنظیمات نوبت است و
|
||||
مجوز جدید ساختن یعنی یک ستون تازه در جدول مجوزها بدون نیاز واقعی.
|
||||
|
||||
ورودی منو: یک آیتم در `SettingsMenuPage.tsx` کنار «تنظیمات نوبتدهی».
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# چکلیست — تسک ۰۱ (شعبه و اتاق)
|
||||
|
||||
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** —
|
||||
**وضعیت کلی:** 🔄 در حال انجام · **آخرین بازبینی:** —
|
||||
|
||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) ·
|
||||
[branch-is-doctor-address.md](../_shared/branch-is-doctor-address.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -13,80 +14,97 @@
|
||||
|---|---|---|---|
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۰.۲ | `SlotCalculatorService` دستنخورده | ⏳ | این تسک به آن کاری ندارد |
|
||||
| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دستنخورده | ⏳ | شعبه **بالای** آدرس مینشیند |
|
||||
| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | ⏳ | فقط `branch_id` تهیپذیر اضافه شد |
|
||||
| ۰.۳ | `location_id` در JSON برنامهٔ هفتگی دستنخورده | ⏳ | شعبه = همان `doctor_addresses.id` |
|
||||
| ۰.۴ | `DoctorAddress` هیچ ستونی حذف/تغییر نداد | ⏳ | فقط `active` و `timezone` با `DEFAULT` |
|
||||
| ۰.۵ | `active=false` هیچ اثری بر محاسبهٔ اسلات ندارد | ⏳ | اعمالش تسک ۰۳ است |
|
||||
|
||||
## ۱. بکاند
|
||||
## ۱. طرح
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `Branch` + `BranchWorkingHours` + `Room` entity | ⏳ | |
|
||||
| ۱.۲ | `BranchService` با گاردهای حذف قابل توسعه (`DeletionGuardInterface`) | ⏳ | تسک ۰۲ و ۰۷ گارد اضافه میکنند |
|
||||
| ۱.۳ | `WorkingHoursService` — اعتبارسنجی و `sequence` سمت سرور | ⏳ | |
|
||||
| ۱.۴ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | ⏳ | |
|
||||
| ۱.۵ | هشت endpoint ساخته شد | ⏳ | |
|
||||
| ۱.۶ | پزشک مستقل هم شعبه دارد (مطب = شعبه) | ⏳ | نه فقط `entity_type=clinic` |
|
||||
| ۱.۷ | `app:branch:backfill` — dry-run پیشفرض، idempotent | ⏳ | |
|
||||
| ۱.۸ | کنترلر نازک · `BaseController` · `success/paginated/error` | ⏳ | |
|
||||
| ۱.۹ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | |
|
||||
| ۱.۱ | ⛔ جدول `branches` **ساخته نشد** — دلیل مکتوب | ✅ | `_shared/branch-is-doctor-address.md` |
|
||||
| ۱.۲ | `task.md` · `architecture.md` · `database.md` · `implementation_notes.md` تصحیح شد | ✅ | |
|
||||
| ۱.۳ | ارجاعهای `branch_id` در تسکهای ۰۲/۰۴/۰۷/۰۸/۰۹/۱۰/۱۳ با سند حاکم پوشش داده شد | ✅ | ۲۴ ارجاع — یک سند واحد در `_shared` بهجای ویرایش ۲۴ نقطه |
|
||||
|
||||
## ۲. دیتابیس
|
||||
## ۲. بکاند
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | `branches` · `branch_working_hours` · `rooms` | ⏳ | |
|
||||
| ۲.۲ | `entity_type, entity_id` ستون **اول** ایندکسهای لیست | ⏳ | |
|
||||
| ۲.۳ | `timezone` روی شعبه از روز اول | ⏳ | افزودن بعدی = backfill زماندار |
|
||||
| ۲.۴ | `rooms.capacity` — ظرفیت همزمان | ⏳ | اتاق سهتخته = یک ردیف با ۳ |
|
||||
| ۲.۵ | `branch_working_hours` در `GlobalTables::AGGREGATE_CHILDREN` | ⏳ | |
|
||||
| ۲.۶ | `TenantSchemaCoverageTest` سبز | ⏳ | |
|
||||
| ۲.۱ | `DoctorAddress` += `active` + `timezone` | ⏳ | |
|
||||
| ۲.۲ | `timezone` با `DateTimeZone::listIdentifiers()` اعتبارسنجی میشود، نه regex | ⏳ | |
|
||||
| ۲.۳ | `BranchWorkingHours` entity (فرزند aggregate) | ⏳ | |
|
||||
| ۲.۴ | `Room` entity با `TenantOwnedTrait` و جفت مشتق از آدرس در سازنده | ⏳ | نه از بدنهٔ request |
|
||||
| ۲.۵ | `BranchResolver` — تکنقطهٔ uuid آدرس → محیط جاری، ۴۰۴ نه ۴۰۳ | ⏳ | `TenantFilter` روی `doctor_addresses` کار نمیکند |
|
||||
| ۲.۶ | `WorkingHoursService` — اعتبارسنجی کامل **قبل از** حذف (اتمی) | ⏳ | |
|
||||
| ۲.۷ | ساعت با `start_minute`/`end_minute` عددی، نه رشتهٔ `"09:00"` | ⏳ | |
|
||||
| ۲.۸ | `sequence` سمت سرور تخصیص مییابد، نه کلاینت | ⏳ | |
|
||||
| ۲.۹ | `RoomService` با گارد حذف قابل توسعه (آرایهٔ تزریقی، نه زنجیرهٔ `if`) | ⏳ | تسک ۰۲ و ۰۷ گارد اضافه میکنند |
|
||||
| ۲.۱۰ | شش endpoint ساخته شد | ⏳ | صفر endpoint CRUD شعبه — موجود است |
|
||||
| ۲.۱۱ | پزشک مستقل هم شعبه دارد | ⏳ | `type='personal'` از قبل کار میکند |
|
||||
| ۲.۱۲ | کنترلر نازک · `BaseController` · `success/paginated/error` | ⏳ | |
|
||||
|
||||
## ۳. UI
|
||||
## ۳. دیتابیس
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `BranchesPage` · `BranchFormPage` · `RoomsPage` | ⏳ | |
|
||||
| ۳.۲ | `DataTable` با skeleton و empty state فارسی | ⏳ | |
|
||||
| ۳.۳ | `PageHeader` با `backTo` روی زیرصفحهها | ⏳ | |
|
||||
| ۳.۴ | شهر/استان با `SearchableSelect` — هیچ `<select>` بومی | ⏳ | |
|
||||
| ۳.۵ | وضعیت لیست در 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` — هیچ `<select>` بومی | ⏳ | |
|
||||
| ۴.۵ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
|
||||
| ۴.۶ | هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکن | ⏳ | |
|
||||
| ۴.۷ | دارکمود و حالت فشرده بررسی شد | ⏳ | |
|
||||
| ۴.۸ | RTL و موبایل بررسی شد | ⏳ | |
|
||||
| ۴.۹ | هشدار UI: «هیچ شعبهٔ فعالی باقی نمیماند» | ⏳ | |
|
||||
| ۴.۱۰ | مسیرها در `App.tsx` + ورودی در `SettingsMenuPage` | ⏳ | |
|
||||
| ۴.۱۱ | مجوز موجود `appointment_settings` استفاده شد، نه مجوز تازه | ⏳ | |
|
||||
|
||||
## ۵. مستندات
|
||||
## ۵. تست
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | ⏳ | |
|
||||
| ۵.۲ | تفسیر «شعبهٔ بدون ساعت کاری = تعریفنشده، نه همیشهباز» نوشته شد | ⏳ | تسک ۰۳ رویش حساب میکند |
|
||||
| ۵.۳ | `docs/architecture/tenancy.md` جدول طبقهبندی بهروز شد | ⏳ | |
|
||||
| ۵.۱ | `WorkingHoursTest` — هفت روز، `end<=start`، همپوشانی، `0..1440`، آرایهٔ خالی | ⏳ | |
|
||||
| ۵.۲ | اتمی بودن: بازهٔ نامعتبر در روز ششم → ۴۲۲ و شش روز قبلی دستنخورده | ⏳ | |
|
||||
| ۵.۳ | `RoomCrudTest` — جفت tenant مشتق، `capacity=0` → ۴۲۲ | ⏳ | |
|
||||
| ۵.۴ | آدرس/اتاق محیط دیگر → ۴۰۴ (نه ۴۰۳) | ⏳ | |
|
||||
| ۵.۵ | `BranchAddressFieldsTest` — پیشفرضها، `timezone` نامعتبر → ۴۲۲ | ⏳ | |
|
||||
| ۵.۶ | `phpstan analyse src/Branch` بدون خطا | ⏳ | |
|
||||
|
||||
## ۶. بازبینی پایانی
|
||||
## ۶. مستندات
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | |
|
||||
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
||||
| ۶.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ⏳ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ⏳ | |
|
||||
| ۶.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | ⏳ | این تسک قرارداد عمومی عوض نمیکند |
|
||||
| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
||||
| ۶.۱ | `docs/api/branch.md` + ثبت در `docs/api/README.md` | ⏳ | JSON واقعی از curl |
|
||||
| ۶.۲ | «شعبهٔ بدون ساعت کاری = تعریفنشده، نه همیشهباز» نوشته شد | ⏳ | تسک ۰۳ رویش حساب میکند |
|
||||
| ۶.۳ | «`active` در این فاز بیاثر بر اسلات» نوشته شد | ⏳ | |
|
||||
| ۶.۴ | `docs/api/doctor.md` — دو فیلد جدید در پاسخ ۹ endpoint آدرس | ⏳ | تغییر قرارداد است |
|
||||
| ۶.۵ | `docs/architecture/tenancy.md` جدول طبقهبندی بهروز شد | ⏳ | |
|
||||
|
||||
## ۷. بازبینی پایانی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۷.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
||||
| ۷.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۷.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۷.۴ | `phpstan` بدون خطای جدید (مقایسه با کامیت پیش از تسک) | ⏳ | |
|
||||
| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
||||
| ۷.۶ | `TenantSchemaCoverageTest` + `TenantLookupInventoryTest` سبز | ⏳ | |
|
||||
| ۷.۷ | `docs/api/*` بهروز | ⏳ | |
|
||||
| ۷.۸ | چکلیست UI کامل | ⏳ | |
|
||||
| ۷.۹ | `nobat724_front` و `clinic-pro-tauri` بررسی شدند | ⏳ | دو فیلد جدید additive است |
|
||||
| ۷.۱۰ | commit، سپس `graphify update .`، سپس commit جدا | ⏳ | |
|
||||
| ۷.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
||||
|
||||
@@ -2,65 +2,62 @@
|
||||
|
||||
MariaDB 11.8 · Doctrine ORM 3.6 · همهٔ timestamp ها `INT` (Unix)
|
||||
|
||||
## `branches`
|
||||
> ⛔ **جدول `branches` ساخته نمیشود.** «شعبه» همان `doctor_addresses` است — دلیل کامل در
|
||||
> [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md). آنچه در
|
||||
> نسخهٔ اول این فایل بهعنوان جدول `branches` و ستون `doctor_addresses.branch_id` آمده بود
|
||||
> حذف شد.
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | ارجاع خارجی |
|
||||
| `entity_type` | VARCHAR(10) NOT NULL | `doctor` \| `clinic` |
|
||||
| `entity_id` | INT NOT NULL | |
|
||||
| `name` | VARCHAR(150) NOT NULL | |
|
||||
| `phone` | VARCHAR(20) NULL | |
|
||||
| `address` | TEXT NULL | |
|
||||
| `city_id` | INT NULL | FK منطقی به `categories.id` با `bundle='city'` |
|
||||
| `province_id` | INT NULL | همان الگو با `bundle='state'` |
|
||||
| `latitude` | DOUBLE NULL | |
|
||||
| `longitude` | DOUBLE NULL | |
|
||||
| `timezone` | VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran' | |
|
||||
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
|
||||
| `created_at` / `updated_at` | INT NOT NULL | |
|
||||
## تغییر جدول موجود — `doctor_addresses`
|
||||
|
||||
ایندکسها:
|
||||
```sql
|
||||
KEY idx_branches_tenant (entity_type, entity_id, active)
|
||||
UNIQUE KEY uniq_branches_uuid (uuid)
|
||||
ALTER TABLE doctor_addresses
|
||||
ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
ADD COLUMN timezone VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran';
|
||||
```
|
||||
|
||||
> `entity_type, entity_id` ستونهای اولاند — شرطِ `TenantFilter` وگرنه از ایندکس استفاده نمیکند.
|
||||
هیچ ستونی حذف یا تغییر نوع نمیدهد. هر دو `NOT NULL DEFAULT` دارند، پس ردیفهای موجود
|
||||
بینیاز از backfill درست میشوند و هیچ رفتار فعلی عوض نمیشود.
|
||||
|
||||
`timezone` از روز اول هست چون مستند بند ۹ میگوید ذخیرهسازی UTC و نمایش محلی؛ امروز همهجا
|
||||
`Asia/Tehran` است ولی افزودن ستون بعداً یعنی backfill روی دادههای زماندار.
|
||||
`timezone` از همین حالا اضافه میشود چون بند ۹ مستند ذخیرهسازی UTC با نمایش محلی میخواهد؛
|
||||
افزودنش بعد از اینکه دادههای زماندار روی شعبه نشستند یعنی backfill پرریسک.
|
||||
|
||||
⚠️ `active` در این تسک **فقط ذخیره** میشود؛ فیلتر شدنش در محاسبهٔ اسلات کارِ تسک ۰۳ است
|
||||
(هر تغییری در `SlotCalculatorService` در فاز فعلی ممنوع است — `_shared/red-lines.md`).
|
||||
|
||||
## `branch_working_hours`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
|
||||
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه |
|
||||
| `address_id` | INT NOT NULL | FK → `doctor_addresses.id` ON DELETE CASCADE |
|
||||
| `day_of_week` | TINYINT NOT NULL | ۰=شنبه … ۶=جمعه — همان قرارداد `SlotCalculatorService` |
|
||||
| `sequence` | TINYINT NOT NULL DEFAULT 0 | بازهٔ چندم آن روز |
|
||||
| `start_minute` | SMALLINT NOT NULL | ۰..۱۴۴۰ |
|
||||
| `start_minute` | SMALLINT NOT NULL | ۰..۱۴۴۰ از نیمهشب |
|
||||
| `end_minute` | SMALLINT NOT NULL | > `start_minute` |
|
||||
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | |
|
||||
|
||||
```sql
|
||||
UNIQUE KEY uniq_branch_day_seq (branch_id, day_of_week, sequence)
|
||||
KEY idx_bwh_branch_day (branch_id, day_of_week, active)
|
||||
UNIQUE KEY uniq_bwh_address_day_seq (address_id, day_of_week, sequence)
|
||||
KEY idx_bwh_address_day (address_id, day_of_week, active)
|
||||
```
|
||||
|
||||
بدون ستون tenant — فرزند aggregate با ریشهٔ `branches` است و uuid از request نمیگیرد
|
||||
(همیشه از راه `/branch/{uuid}/working-hours` لود میشود). در `GlobalTables::AGGREGATE_CHILDREN`
|
||||
با ریشهٔ صریح ثبت شود.
|
||||
بدون ستون tenant — **فرزند aggregate** با ریشهٔ `DoctorAddress`. هرگز با uuid از request
|
||||
لود نمیشود؛ تنها راه رسیدن به آن `/branch/{addressUuid}/working-hours` است که آدرس را از
|
||||
`TenantFilter` رد میکند. در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح ثبت میشود.
|
||||
|
||||
دقت: چون `doctor_addresses` خودش `TenantOwnedTrait` ندارد (مالکیتش با `type` +
|
||||
`doctor_id`/`clinic_id` است)، `TenantFilter` روی خودِ آدرس هم اعمال نمیشود.
|
||||
پس فیلتر محیط برای این endpoint **دستی** است: `DoctorAddressRepository::findForContext()`
|
||||
که از قبل همین کار را میکند و در `clinic/{uuid}/addresses` هم همینطور استفاده شده.
|
||||
|
||||
## `rooms`
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|---|---|---|
|
||||
| `id` | INT PK AI | |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | **از request میآید** → پس جفت tenant خودش را دارد |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10)/INT NOT NULL | از `branch` در سازنده مشتق میشود |
|
||||
| `branch_id` | INT NOT NULL | FK → `branches.id` ON DELETE CASCADE |
|
||||
| `uuid` | VARCHAR(36) UNIQUE | **از request میآید** → پس جفت tenant لازم دارد |
|
||||
| `entity_type` / `entity_id` | VARCHAR(10)/INT NOT NULL | در سازنده از `DoctorAddress` مشتق میشود |
|
||||
| `address_id` | INT NOT NULL | FK → `doctor_addresses.id` ON DELETE CASCADE |
|
||||
| `name` | VARCHAR(120) NOT NULL | |
|
||||
| `room_type` | VARCHAR(60) NULL | متن آزاد، تعریف کلینیک |
|
||||
| `capacity` | SMALLINT NOT NULL DEFAULT 1 | ظرفیت همزمان |
|
||||
@@ -70,39 +67,55 @@ KEY idx_bwh_branch_day (branch_id, day_of_week, active)
|
||||
|
||||
```sql
|
||||
KEY idx_rooms_tenant (entity_type, entity_id, active)
|
||||
KEY idx_rooms_branch (branch_id, active)
|
||||
KEY idx_rooms_address (address_id, active)
|
||||
```
|
||||
|
||||
## تغییر جدول موجود
|
||||
`entity_type, entity_id` ستونهای اولِ ایندکساند — شرط `TenantFilter` وگرنه از ایندکس
|
||||
استفاده نمیکند.
|
||||
|
||||
```sql
|
||||
ALTER TABLE doctor_addresses
|
||||
ADD COLUMN branch_id INT NULL,
|
||||
ADD CONSTRAINT fk_doctor_addresses_branch
|
||||
FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE SET NULL,
|
||||
ADD KEY idx_doctor_addresses_branch (branch_id);
|
||||
اشتقاق جفت در سازنده (نه از بدنهٔ request):
|
||||
|
||||
```php
|
||||
$isClinic = $address->getType() === DoctorAddress::TYPE_CLINIC;
|
||||
$this->assignTenantPair(
|
||||
$isClinic ? 'clinic' : 'doctor',
|
||||
$isClinic ? (int) $address->getClinicId() : (int) $address->getDoctor()->getId(),
|
||||
);
|
||||
```
|
||||
|
||||
هیچ ستونی حذف یا تغییر نوع نمیدهد. `location_id` در JSON برنامهٔ هفتگی دستنخورده میماند.
|
||||
|
||||
## Migration
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
ddev exec php bin/console app:branch:backfill # dry-run
|
||||
ddev exec php bin/console app:branch:backfill --force
|
||||
```
|
||||
|
||||
هیچ command backfill لازم نیست — `app:branch:backfill` نسخهٔ اول برای پر کردن `branch_id`
|
||||
از `doctor_addresses` بود؛ حالا که جدول موازی ساخته نمیشود، موضوعش منتفی است.
|
||||
|
||||
⚠️ **`db_test` تاریخچهٔ migration جدا دارد** و `migrate` رویش با
|
||||
`Table 'users' already exists` میشکند. ستونها را دستی اضافه کن وگرنه کل تستسوئیت با
|
||||
`Unknown column` قرمز میشود:
|
||||
|
||||
```bash
|
||||
ddev mysql -uroot -proot -e "ALTER TABLE db_test.doctor_addresses \
|
||||
ADD active TINYINT(1) NOT NULL DEFAULT 1, \
|
||||
ADD timezone VARCHAR(40) NOT NULL DEFAULT 'Asia/Tehran';"
|
||||
```
|
||||
و بعد از تولید migration، همان `CREATE TABLE` های `branch_working_hours` و `rooms` را هم
|
||||
روی `db_test` اجرا کن.
|
||||
|
||||
## طبقهبندی tenant
|
||||
|
||||
| جدول | وضعیت | ثبت در |
|
||||
|---|---|---|
|
||||
| `branches` | جفت tenant | `TenantOwnedTrait` |
|
||||
| `doctor_addresses` | از قبل طبقهبندیشده — دست نمیخورد | همانجای فعلی |
|
||||
| `rooms` | جفت tenant | `TenantOwnedTrait` (uuid از request میآید) |
|
||||
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `Branch` |
|
||||
| `branch_working_hours` | فرزند aggregate | `GlobalTables::AGGREGATE_CHILDREN` → ریشه `DoctorAddress` |
|
||||
|
||||
بعد از migration:
|
||||
```bash
|
||||
ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php
|
||||
ddev exec php bin/phpunit tests/Shared/TenantLookupInventoryTest.php
|
||||
```
|
||||
دومی هم لازم است: repository جدیدی که `findByUuid` دارد باید در `REVIEWED` ثبت شود.
|
||||
|
||||
@@ -1,36 +1,59 @@
|
||||
# نکات پیادهسازی — تسک ۰۱
|
||||
|
||||
## ۱. چرا شعبه بالای آدرس مینشیند، نه جای آن
|
||||
## ۱. شعبه ساخته نمیشود — پیدا میشود
|
||||
|
||||
سه مصرفکنندهٔ زنده به `doctor_addresses.id` وابستهاند:
|
||||
نسخهٔ اول این فایل استدلال میکرد «چرا شعبه بالای آدرس مینشیند». استدلال درست بود ولی
|
||||
نتیجهاش غلط: اگر آدرس همان مکان فیزیکی است و همهجا هم به همان معنا مصرف میشود، لایهٔ
|
||||
بالایی چیزی جز یک جدول دوم برای همان نام و تلفن نیست. سه مصرفکنندهٔ زندهای که
|
||||
`doctor_addresses.id` را میخوانند —
|
||||
|
||||
1. `WeeklySchedule.setting[day].sessions[].location_id` (JSON)
|
||||
2. `SlotCalculatorService::buildSessionSlots()` که آن را در هر اسلات کپی میکند
|
||||
3. `AppointmentController::bookingLocations()` که به سایت عمومی `location_uuid` میدهد
|
||||
|
||||
عوض کردن این قرارداد در build هیچکدام از سه ریپو خطا نمیدهد — فقط در runtime آدرس گم میشود.
|
||||
پس `branch_id` روی آدرس اضافه میشود و آدرس همانجا میماند.
|
||||
— دلیل اصلیاند که «شعبه» همان آدرس است، نه دلیلِ ساختن لایهٔ دوم.
|
||||
جزئیات کامل: [`_shared/branch-is-doctor-address.md`](../_shared/branch-is-doctor-address.md).
|
||||
|
||||
## ۲. پزشک مستقل هم شعبه دارد
|
||||
|
||||
وسوسه میشود که شعبه را فقط برای `entity_type=clinic` بسازیم. نکن. اگر پزشک مستقل شعبه
|
||||
نداشته باشد، تسک ۰۲ باید دو مسیر کد برای «منبع مال شعبه» و «منبع مال پزشک» داشته باشد و
|
||||
تسک ۰۶ هر دو را جدا حساب کند. مطب شخصی = شعبهای با `entity_type=doctor`.
|
||||
خوشبختانه از قبل درست است: `DoctorAddress::forDoctor()` با `type = 'personal'` وجود دارد و
|
||||
`findForContext()` هم آدرسهای شخصی و هم کلینیکی را برمیگرداند. پس تسک ۰۲ لازم نیست دو
|
||||
مسیر کد برای «منبع مال شعبه» و «منبع مال پزشک» داشته باشد. مطب شخصی = آدرسی با
|
||||
`type='personal'`.
|
||||
|
||||
## ۳. حذف شعبه
|
||||
## ۳. `TenantFilter` روی `doctor_addresses` کار نمیکند
|
||||
|
||||
هرگز `CASCADE` روی حذف شعبه به منابع و نوبتها نده. `DELETE` فقط وقتی مجاز است که:
|
||||
مهمترین تلهٔ این تسک. `doctor_addresses` ستون `entity_type`/`entity_id` ندارد، پس:
|
||||
|
||||
- هیچ `Room` فعالی نداشته باشد، **و**
|
||||
- هیچ `Resource` فعالی (تسک ۰۲) نداشته باشد، **و**
|
||||
- هیچ نوبت آیندهٔ فعالی روی منابعش نباشد (تسک ۰۷)
|
||||
```php
|
||||
// ❌ آدرس محیط دیگر را هم برمیگرداند — filter اینجا تور ایمنی نیست
|
||||
$address = $this->addresses->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
تا آن تسکها نیامدهاند، فقط شرط اول را چک کن ولی سرویس را طوری بنویس که افزودن دو شرط
|
||||
بعدی یک خط باشد (لیست `DeletionGuardInterface` و تزریق آرایهای از گاردها).
|
||||
// ✅ از BranchResolver رد شو
|
||||
$address = $this->branches->resolve($uuid); // 404 اگر محیط جاری نباشد
|
||||
```
|
||||
|
||||
`active=false` مسیر اصلی است، نه `DELETE`.
|
||||
`Room` خودش `TenantOwnedTrait` دارد (uuidش از request میآید) پس روی آن filter کار میکند؛
|
||||
ولی `BranchWorkingHours` فرزند aggregate است و **هیچ** فیلتری ندارد — تنها محافظش این است
|
||||
که فقط از راه `BranchResolver` قابل دسترسی باشد. `RequestReachableChildTenantTest` همین را
|
||||
اجبار میکند: هیچ endpointی نباید uuid فرزند را مستقیم بگیرد.
|
||||
|
||||
## ۴. ساعت کاری — دقیقه، نه رشته
|
||||
## ۴. حذف
|
||||
|
||||
روی حذف آدرس هیچ دست نمیبریم (endpointهایش موجودند). فقط:
|
||||
|
||||
- `branch_working_hours.address_id` و `rooms.address_id` هر دو `ON DELETE CASCADE` — حذف
|
||||
آدرس ساعت و اتاقش را هم میبرد. این درست است: ساعت کاری بدون مکان معنا ندارد.
|
||||
- حذف **اتاق**: در این تسک بیقید (منبعی هنوز وجود ندارد). سرویس را طوری بنویس که افزودن
|
||||
گاردهای تسک ۰۲ (منبع فعال) و ۰۷ (نوبت آینده) یک خط باشد — آرایهٔ تزریقی از
|
||||
`RoomDeletionGuardInterface`، نه زنجیرهٔ `if`.
|
||||
- `active=false` مسیر اصلی است، نه `DELETE`.
|
||||
|
||||
⚠️ CASCADE روی حذف آدرس + وجود نوبت روی اتاقهای آن = دادهٔ گمشده. تا تسک ۰۷ که نوبت به
|
||||
اتاق وصل میشود، این ریسک وجود ندارد؛ آنجا باید گاردِ حذف آدرس اضافه شود. در checklist با
|
||||
مقصد صریح ثبت شده.
|
||||
|
||||
## ۵. ساعت کاری — دقیقه، نه رشته
|
||||
|
||||
```php
|
||||
// ❌ اشتباه: مقایسهٔ رشتهای در تسک ۰۶ میشکند ("9:00" < "10:00" غلط است)
|
||||
@@ -44,52 +67,71 @@ private int $startMinute = 540;
|
||||
- `0 <= start < end <= 1440`
|
||||
- بازههای یک روز نباید همپوشانی داشته باشند (مرتب کن، بعد `prev.end <= next.start`)
|
||||
- `sequence` را خود سرویس بعد از مرتبسازی تخصیص میدهد، نه کلاینت
|
||||
- **اعتبارسنجی کاملِ هر هفت روز قبل از هر `DELETE`** — وگرنه یک بازهٔ نامعتبر در روز ششم،
|
||||
شش روز درست را هم پاک میکند و ۴۲۲ برمیگرداند
|
||||
|
||||
## ۵. تفسیر «شعبه بدون ساعت کاری»
|
||||
## ۶. تفسیر «شعبه بدون ساعت کاری»
|
||||
|
||||
تصمیم صریح: **تعریفنشده، نه همیشهباز.** تسک ۰۳ وقتی برای شعبهای ساعتی پیدا نکرد، به
|
||||
رفتار فعلی برمیگردد (برنامهٔ پزشک تنها مرجع است). این باعث میشود همهٔ دادههای موجود
|
||||
بدون ساعت کاری شعبه دقیقاً مثل امروز کار کنند.
|
||||
تصمیم صریح: **تعریفنشده، نه همیشهباز.** تسک ۰۳ وقتی برای آدرسی ساعتی پیدا نکرد، به
|
||||
رفتار فعلی برمیگردد (برنامهٔ پزشک تنها مرجع است). پس همهٔ دادهٔ موجود — که هیچ ساعت کاری
|
||||
شعبه ندارد — دقیقاً مثل امروز کار میکند. این خطِ دفاعیِ «منطق اسلاتی دست نمیخورد» است.
|
||||
|
||||
این نکته را در `docs/api/branch.md` بنویس، وگرنه اولین کسی که کش را دیباگ میکند فکر میکند
|
||||
باگ است.
|
||||
همینطور `active=false` روی آدرس در این تسک **هیچ اثری بر اسلات ندارد**؛ فقط ذخیره میشود.
|
||||
اعمالش در تسک ۰۳ است. اگر همینجا اعمال شود، `SlotCalculatorService` عوض میشود که در
|
||||
`_shared/red-lines.md` ممنوع است.
|
||||
|
||||
## ۶. edge case ها
|
||||
هر دو نکته در `docs/api/branch.md` نوشته شود، وگرنه اولین کسی که دیباگ میکند فکر میکند باگ است.
|
||||
|
||||
## ۷. edge case ها
|
||||
|
||||
| حالت | رفتار درست |
|
||||
|---|---|
|
||||
| شعبه در محیط A، اتاق ساختهشده با uuid شعبهٔ محیط B | `404` — `TenantOwnershipChecker::belongsTo` قبل از هر کاری |
|
||||
| دو شعبه همنام در یک محیط | مجاز (نام یکتا نیست؛ آدرس فرق دارد) |
|
||||
| `capacity = 0` | `422` — حداقل ۱ |
|
||||
| آدرس محیط A، اتاق ساختهشده با uuid آدرس محیط B | `404` — `BranchResolver` قبل از هر کاری |
|
||||
| دو آدرس همنام در یک محیط | مجاز (نام یکتا نیست) |
|
||||
| `capacity = 0` یا منفی | `422` — حداقل ۱ |
|
||||
| ساعت کاری روز جمعه خالی | معتبر — یعنی شعبه جمعه بسته است |
|
||||
| شعبهای که تنها شعبهٔ محیط است و غیرفعال میشود | مجاز، ولی هشدار در UI: «هیچ شعبهٔ فعالی باقی نمیماند» |
|
||||
| ساعت شبانهروزی | `start=0, end=1440` — نه دو ردیف |
|
||||
| `PUT` با آرایهٔ خالی | همهٔ ساعتها پاک میشوند — شعبه کامل بسته |
|
||||
| ساعت شبانهروزی | `start=0, end=1440` — یک ردیف، نه دو |
|
||||
| آدرسی که تنها آدرس فعال محیط است و غیرفعال میشود | مجاز، ولی هشدار در UI |
|
||||
| `timezone` نامعتبر مثل `"Tehran"` | `422` — با `DateTimeZone::listIdentifiers()` چک کن، نه regex |
|
||||
|
||||
## ۷. تست
|
||||
## ۸. تست
|
||||
|
||||
```
|
||||
tests/Branch/BranchCrudTest.php
|
||||
- ساخت شعبه با نقش مالک کلینیک → 201 و tenant درست
|
||||
- ساخت با نقش منشیِ بدون محیط انتخابشده → 403
|
||||
- دیدن شعبهٔ محیط دیگر → 404 (نه 403)
|
||||
tests/Branch/WorkingHoursTest.php
|
||||
- هفت روز معتبر → 200 و بازخوانی یکسان
|
||||
- هفت روز معتبر → 200 و بازخوانی یکسان (کلیدهای 0..6)
|
||||
- end <= start → 422
|
||||
- دو بازهٔ همپوشان در یک روز → 422
|
||||
- بازهٔ شبانهروزی 0..1440 → 200
|
||||
tests/Branch/BranchDeletionTest.php
|
||||
- حذف شعبهٔ دارای اتاق فعال → 422
|
||||
- حذف شعبهٔ خالی → 204
|
||||
tests/Shared/TenantSchemaCoverageTest.php ← باید سبز بماند
|
||||
- آرایهٔ خالی → 200 و صفر ردیف
|
||||
- بازهٔ نامعتبر در روز ششم → 422 و شش روز قبلی دستنخورده (اتمی بودن)
|
||||
- uuid آدرس محیط دیگر → 404
|
||||
tests/Branch/RoomCrudTest.php
|
||||
- ساخت با نقش مالک کلینیک → 201 و جفت tenant مشتق از آدرس
|
||||
- capacity=0 → 422
|
||||
- آدرس محیط دیگر → 404
|
||||
- ویرایش/حذف اتاق محیط دیگر → 404
|
||||
tests/Branch/BranchAddressFieldsTest.php
|
||||
- آدرس موجود بدون مقدار → active=true و timezone='Asia/Tehran'
|
||||
- timezone نامعتبر → 422
|
||||
tests/Appointment/SlotModeFrozenTest.php ← باید سبز بماند (اسلات دستنخورده)
|
||||
tests/Shared/TenantSchemaCoverageTest.php ← باید سبز بماند
|
||||
tests/Shared/TenantLookupInventoryTest.php ← repository جدید باید ثبت شود
|
||||
```
|
||||
|
||||
اجرا:
|
||||
```bash
|
||||
ddev exec php bin/phpunit tests/Branch
|
||||
ddev exec php bin/phpunit --group=slot-mode-frozen
|
||||
ddev exec php vendor/bin/phpstan analyse src/Branch
|
||||
```
|
||||
|
||||
## ۸. مستندات
|
||||
⚠️ قبل از اجرای تست، ستونها و جدولها را دستی روی `db_test` بساز — رجوع به بخش
|
||||
Migration در [database.md](database.md). `db_test` تاریخچهٔ migration جدا دارد.
|
||||
|
||||
`docs/api/branch.md` بساز (الگو: `docs/api/staff.md`). در `docs/api/README.md` هم اضافه کن.
|
||||
در `docs/architecture/tenancy.md` جدول طبقهبندی را با سه جدول جدید بهروز کن.
|
||||
## ۹. مستندات
|
||||
|
||||
`docs/api/branch.md` بساز (الگو: `docs/api/staff.md`). در `docs/api/README.md` اضافه کن.
|
||||
`docs/api/doctor.md` را برای دو فیلد جدید `DoctorAddress::toArray()` بهروز کن — این فیلدها
|
||||
در پاسخ ۹ endpoint موجود آدرس ظاهر میشوند، پس تغییر قرارداد است.
|
||||
در `docs/architecture/tenancy.md` جدول طبقهبندی را با دو جدول جدید بهروز کن.
|
||||
|
||||
@@ -1,61 +1,97 @@
|
||||
# تسک ۰۱ — شعبه (Branch) و اتاق (Room)
|
||||
# تسک ۰۱ — شعبه و اتاق
|
||||
|
||||
**فاز:** ۱ (هسته) · **وابستگی:** — · **زمان:** ۱۰-۱۲ ساعت
|
||||
**فاز:** ۱ (هسته) · **وابستگی:** ۰۰ · **زمان:** ۶-۸ ساعت (بازتخمین — رجوع به بخش «تصحیح طرح»)
|
||||
|
||||
---
|
||||
|
||||
## ⛔ تصحیح طرح (اجرای ۱۴۰۵/۰۵/۰۸)
|
||||
|
||||
نسخهٔ اول این تسک یک جدول `branches` تازه میخواست و `doctor_addresses.branch_id` را به
|
||||
آن وصل میکرد. **این طرح غلط بود: «شعبه» از قبل وجود دارد و نامش `DoctorAddress` است.**
|
||||
|
||||
| `branches` پیشنهادی | واقعیت `doctor_addresses` |
|
||||
|---|---|
|
||||
| `name` · `phone` · `address` | ✅ `name` · `telephone` · `address` |
|
||||
| `city_id` · `province_id` | ✅ **FK به entity `City`/`Province`** — بهتر از ارجاع خام به `categories` که طرح اول میخواست |
|
||||
| `latitude` · `longitude` | ✅ هر دو |
|
||||
| جفت tenant | ⚠️ `DoctorAddress::forDoctor(Doctor)` / `forClinic(int $clinicId)` + ستون `type` — همان اطلاعات، با شکل دیگر |
|
||||
| `timezone` | ❌ غایب |
|
||||
| `active` | ❌ غایب |
|
||||
| ساعت کاری | ❌ غایب — **واقعاً جدید** |
|
||||
| اتاق | ❌ غایب — **واقعاً جدید** |
|
||||
|
||||
و اینها هم از قبل هستند:
|
||||
|
||||
- `WeeklySchedule.setting[day].sessions[].location_id` → `doctor_addresses.id`
|
||||
- `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`
|
||||
|
||||
ساختن `branches` بالای این یعنی: دو جدول برای یک مکان فیزیکی، دو منبع حقیقت برای
|
||||
نام/آدرس/تلفن/مختصات، هر مصرفکننده باید تصمیم بگیرد کدام را بخواند، و branchی که
|
||||
`location_id` هرگز به آن اشاره نمیکند. نقض قاعدهٔ #۸ پروژه
|
||||
(«اول بگرد، بعد توسعه بده، در آخر بساز»).
|
||||
|
||||
**پس در این تسک هیچ جدول `branches` ساخته نمیشود و هیچ endpoint CRUD شعبه اضافه نمیشود.**
|
||||
|
||||
---
|
||||
|
||||
## هدف
|
||||
|
||||
سطح سوم مکان را به مدل اضافه کن: امروز `(entity_type, entity_id)` میگوید داده مال کدام
|
||||
محیط است، ولی نمیگوید در کدام **ساختمان** و کدام **اتاق**. مستند بند ۴ سه سطح میخواهد
|
||||
و «منابع همیشه مال شعبهاند چون فیزیکیاند» — بدون شعبه، تسک ۰۲ جایی برای نشستن ندارد.
|
||||
سه چیزِ واقعاً غایب را اضافه کن تا تسکهای ۰۲ و ۰۳ جایی برای نشستن داشته باشند:
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
- محل مراجعه امروز `DoctorAddress` است و `location_id` هر شیفت در
|
||||
`WeeklySchedule.setting[day].sessions[].location_id` به `doctor_addresses.id` اشاره میکند
|
||||
(`SlotCalculatorService::buildSessionSlots()` آن را در هر اسلات کپی میکند).
|
||||
- `Clinic` هیچ فیلد شعبهای ندارد؛ یک آدرس متنی تخت دارد.
|
||||
- ساعت کاری شعبه وجود ندارد — ساعت کاری فقط روی برنامهٔ پزشک است.
|
||||
۱. `active` و `timezone` روی `DoctorAddress` — یک شعبهٔ بسته باید بتواند بسته شود، و
|
||||
بند ۹ مستند ذخیرهسازی UTC با نمایش محلی میخواهد.
|
||||
۲. **ساعت کاری هفتگی شعبه** — امروز ساعت کاری فقط روی برنامهٔ پزشک است. تسک ۰۳ برای
|
||||
کسر لایهها به ساعت کاری شعبه نیاز دارد.
|
||||
۳. **اتاق** — با ظرفیت همزمان. تسک ۰۲ اتاق را بهعنوان یک `resource_type` منعکس میکند.
|
||||
|
||||
## دامنه
|
||||
|
||||
**هست:** entity های `Branch` و `Room`، ساعت کاری هفتگی شعبه، CRUD پنل ادمین،
|
||||
پل زدن `DoctorAddress.branch_id` برای اینکه شیفتهای موجود بدون تغییر به شعبه نگاشت شوند.
|
||||
**هست:** دو ستون روی `DoctorAddress` · جدول و entity `BranchWorkingHours` · جدول و entity
|
||||
`Room` · سرویس اعتبارسنجی ساعت · endpoint ساعت کاری و CRUD اتاق · UI ادمین برای هر دو.
|
||||
|
||||
**نیست:** استفاده از شعبه در محاسبهٔ اسلات (تسک ۰۳)، اتاق بهعنوان منبع قابل رزرو (تسک ۰۲).
|
||||
**نیست:** جدول `branches` (رد شد) · CRUD شعبه (موجود) · استفاده از ساعت شعبه در محاسبهٔ
|
||||
اسلات (تسک ۰۳) · اتاق بهعنوان منبع قابل رزرو (تسک ۰۲).
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/branches` | لیست شعب محیط جاری (paginated) |
|
||||
| POST | `/api/v1/branch` | ساخت شعبه |
|
||||
| GET | `/api/v1/branch/{uuid}` | جزئیات + ساعت کاری |
|
||||
| PATCH | `/api/v1/branch/{uuid}` | ویرایش |
|
||||
| DELETE | `/api/v1/branch/{uuid}` | حذف (فقط بدون منبع/اتاق فعال) |
|
||||
| PUT | `/api/v1/branch/{uuid}/working-hours` | ثبت ساعت کاری هفتگی |
|
||||
| GET | `/api/v1/branch/{uuid}/rooms` | اتاقهای شعبه |
|
||||
| POST/PATCH/DELETE | `/api/v1/room[/{uuid}]` | CRUD اتاق |
|
||||
| GET | `/api/v1/branch/{addressUuid}/working-hours` | ساعت کاری هفتگی یک شعبه |
|
||||
| PUT | `/api/v1/branch/{addressUuid}/working-hours` | جایگزینی کامل هفت روز |
|
||||
| GET | `/api/v1/branch/{addressUuid}/rooms` | اتاقهای شعبه |
|
||||
| POST | `/api/v1/room` | ساخت اتاق |
|
||||
| PATCH | `/api/v1/room/{uuid}` | ویرایش |
|
||||
| DELETE | `/api/v1/room/{uuid}` | حذف (فقط بدون منبع فعال — گارد در تسک ۰۲ تکمیل میشود) |
|
||||
|
||||
`{addressUuid}` همان uuid رکورد `doctor_addresses` است. «شعبه» و «آدرس» یک چیزند.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: کلینیک با توکن مالک `POST /api/v1/branch` میزند → `201` و شعبه با
|
||||
`entity_type=clinic, entity_id=<id>` ثبت میشود. `GET /api/v1/branches` همان را برمیگرداند.
|
||||
- ✅ موفق: `PUT /branch/{uuid}/working-hours` با هفت روز → `200`؛ `GET /branch/{uuid}` همان
|
||||
ساختار را با کلیدهای `0..6` (۰=شنبه) برمیگرداند.
|
||||
- ❌ خطا: کلینیک B با uuid شعبهٔ کلینیک A → `404` با `ERR_NOT_FOUND_001` (نه ۴۰۳ — طبق
|
||||
رفتار `TenantFilter`).
|
||||
- ❌ خطا: `DELETE` شعبهای که اتاق فعال دارد → `422` با پیام فارسی «شعبه دارای اتاق فعال است».
|
||||
- ⚠️ مرزی: پزشک مستقل (محیط `doctor`) هم میتواند شعبه بسازد — «مطب» یک شعبه است.
|
||||
اولین شعبه از روی `DoctorAddress` موجود ساخته میشود، نه دستی.
|
||||
- ⚠️ مرزی: ساعت کاری با `end_time <= start_time` → `422`.
|
||||
- ⚠️ مرزی: شعبه بدون ساعت کاری معتبر است (وراثت: تسک ۰۳ آن را «همیشه باز» تفسیر نمیکند،
|
||||
«تعریفنشده» تفسیر میکند).
|
||||
- ✅ موفق: کلینیک `PUT /branch/{uuid}/working-hours` با هفت روز میفرستد → `200`؛
|
||||
`GET` همان ساختار را با کلیدهای `0..6` (۰=شنبه، همان قرارداد `SlotCalculatorService`)
|
||||
برمیگرداند.
|
||||
- ✅ موفق: `POST /api/v1/room` با `address_uuid` و `capacity: 3` → `201`، و جفت tenant
|
||||
اتاق **از آدرس مشتق** میشود نه از بدنهٔ درخواست.
|
||||
- ✅ موفق: `active` پیشفرض `true` و `timezone` پیشفرض `Asia/Tehran` — هیچ آدرس موجودی
|
||||
رفتارش عوض نمیشود.
|
||||
- ❌ خطا: آدرس محیط دیگر → `404` (رفتار `TenantFilter`، نه ۴۰۳).
|
||||
- ❌ خطا: ساعت با `end_minute <= start_minute` → `422`.
|
||||
- ❌ خطا: دو بازهٔ همپوشان در یک روز → `422`.
|
||||
- ❌ خطا: `capacity <= 0` → `422`.
|
||||
- ⚠️ مرزی: بازهٔ شبانهروزی `0..1440` → `200` (یک ردیف، نه دو).
|
||||
- ⚠️ مرزی: روز بدون هیچ بازه → معتبر، یعنی شعبه آن روز بسته است.
|
||||
- ⚠️ مرزی: شعبهٔ **بدون هیچ ساعت کاری** → «تعریفنشده»، نه «همیشهباز». تسک ۰۳ در این
|
||||
حالت به رفتار فعلی برمیگردد (برنامهٔ پزشک تنها مرجع). این تصمیم باید در
|
||||
`docs/api/branch.md` نوشته شود.
|
||||
- ⚠️ مرزی: `PUT` با آرایهٔ خالی → همهٔ ساعتهای آن شعبه پاک میشوند (بستنِ کامل شعبه).
|
||||
|
||||
## خروجی
|
||||
|
||||
- `src/Branch/` کامل با تست
|
||||
- `assets/admin/pages/BranchesPage.tsx` + `BranchFormPage.tsx` + `RoomsPage.tsx`
|
||||
- `src/Branch/` — `BranchWorkingHours`، `Room`، سرویسها، کنترلرها
|
||||
- دو ستون روی `DoctorAddress` + migration
|
||||
- `assets/admin/pages/BranchWorkingHoursPage.tsx` + `BranchRoomsPage.tsx`
|
||||
- `docs/api/branch.md`
|
||||
- migration + دستور `app:branch:backfill` برای ساخت شعبهٔ اولیه از آدرسهای موجود
|
||||
- [checklist.md](checklist.md) کاملشده
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Branch working hours and rooms, both keyed to doctor_addresses — the existing
|
||||
* "branch" entity. No parallel branches table is created; see
|
||||
* docs/new_feture/taskes/_shared/branch-is-doctor-address.md.
|
||||
*
|
||||
* Both new doctor_addresses columns are NOT NULL with a default, so existing rows
|
||||
* become correct without a backfill and no current behaviour changes.
|
||||
*/
|
||||
final class Version20260730125038 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add branch working hours and rooms; add active/timezone to doctor_addresses';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE branch_working_hours (id INT AUTO_INCREMENT NOT NULL, day_of_week SMALLINT NOT NULL, sequence SMALLINT DEFAULT 0 NOT NULL, start_minute SMALLINT NOT NULL, end_minute SMALLINT NOT NULL, active TINYINT DEFAULT 1 NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, address_id INT NOT NULL, INDEX IDX_E8C43E37F5B7AF75 (address_id), INDEX idx_bwh_address_day (address_id, day_of_week, active), INDEX idx_bwh_tenant (entity_type, entity_id), UNIQUE INDEX uniq_bwh_address_day_seq (address_id, day_of_week, sequence), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('CREATE TABLE rooms (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(120) NOT NULL, room_type VARCHAR(60) DEFAULT NULL, capacity SMALLINT DEFAULT 1 NOT NULL, floor VARCHAR(20) DEFAULT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, address_id INT NOT NULL, UNIQUE INDEX UNIQ_7CA11A96D17F50A6 (uuid), INDEX IDX_7CA11A96F5B7AF75 (address_id), INDEX idx_rooms_tenant (entity_type, entity_id, active), INDEX idx_rooms_address (address_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE branch_working_hours ADD CONSTRAINT FK_E8C43E37F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE rooms ADD CONSTRAINT FK_7CA11A96F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE doctor_addresses ADD active TINYINT DEFAULT 1 NOT NULL, ADD timezone VARCHAR(40) DEFAULT \'Asia/Tehran\' NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE branch_working_hours DROP FOREIGN KEY FK_E8C43E37F5B7AF75');
|
||||
$this->addSql('ALTER TABLE rooms DROP FOREIGN KEY FK_7CA11A96F5B7AF75');
|
||||
$this->addSql('DROP TABLE branch_working_hours');
|
||||
$this->addSql('DROP TABLE rooms');
|
||||
$this->addSql('ALTER TABLE doctor_addresses DROP active, DROP timezone');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Repository\BranchWorkingHoursRepository;
|
||||
use App\Branch\Repository\RoomRepository;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Branch\Service\WorkingHoursService;
|
||||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* شعبه = {@see DoctorAddress}. ساختن/ویرایش/حذف آدرس از قبل در ClinicController و
|
||||
* AppointmentSettingsController هست و اینجا تکرار نمیشود؛ این کنترلر فقط چیزهایی را
|
||||
* میدهد که آنجا نیست: فهرست شعبههای محیط جاری با شمارش، دو ویژگی تازهٔ
|
||||
* active/timezone، و ساعت کاری هفتگی.
|
||||
*/
|
||||
#[OA\Tag(name: 'Branch')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class BranchController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly WorkingHoursService $workingHours,
|
||||
private readonly BranchWorkingHoursRepository $hoursRepo,
|
||||
private readonly RoomRepository $roomRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
/** @param 'view'|'update' $action */
|
||||
private function denyUnlessGranted(User $user, string $action): void
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action);
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/branches', name: 'branch_list', methods: ['GET'])]
|
||||
public function list(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'view');
|
||||
|
||||
$addresses = $this->branches->listForContext($user);
|
||||
$ids = array_map(static fn (DoctorAddress $a): int => (int) $a->getId(), $addresses);
|
||||
|
||||
// دو کوئری گروهی بهجای دو کوئری per شعبه.
|
||||
$hourCounts = $this->hoursRepo->countByAddressIds($ids);
|
||||
$roomCounts = $this->roomRepo->countActiveByAddressIds($ids);
|
||||
|
||||
$rows = array_map(static function (DoctorAddress $address) use ($hourCounts, $roomCounts): array {
|
||||
$id = (int) $address->getId();
|
||||
$row = $address->toArray();
|
||||
|
||||
$row['working_hours_defined'] = ($hourCounts[$id] ?? 0) > 0;
|
||||
$row['rooms_count'] = $roomCounts[$id] ?? 0;
|
||||
|
||||
return $row;
|
||||
}, $addresses);
|
||||
|
||||
return $this->success($rows);
|
||||
}
|
||||
|
||||
/** فقط دو ویژگی شعبهای؛ نام/آدرس/تلفن همانجایی ویرایش میشوند که همیشه. */
|
||||
#[Route('/api/v1/branch/{addressUuid}', name: 'branch_update', methods: ['PATCH'])]
|
||||
public function update(#[CurrentUser] User $user, string $addressUuid, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'update');
|
||||
|
||||
$address = $this->branches->resolve($user, $addressUuid);
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
if (array_key_exists('active', $data)) {
|
||||
$address->setActive((bool) $data['active']);
|
||||
}
|
||||
|
||||
if (array_key_exists('timezone', $data)) {
|
||||
if (!is_string($data['timezone'])) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'منطقهٔ زمانی نامعتبر است', 422, 'timezone');
|
||||
}
|
||||
|
||||
try {
|
||||
$address->setTimezone($data['timezone']);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'منطقهٔ زمانی نامعتبر است', 422, 'timezone');
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($address->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/branch/{addressUuid}/working-hours', name: 'branch_working_hours_show', methods: ['GET'])]
|
||||
public function showWorkingHours(#[CurrentUser] User $user, string $addressUuid): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'view');
|
||||
|
||||
$address = $this->branches->resolve($user, $addressUuid);
|
||||
|
||||
return $this->success([
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'timezone' => $address->getTimezone(),
|
||||
'defined' => $this->workingHours->isDefined($address),
|
||||
'days' => $this->workingHours->read($address),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* جایگزینی کامل هفت روز. آرایهٔ خالی یعنی شعبه کاملاً بسته است — نه «تغییری نده».
|
||||
*/
|
||||
#[Route('/api/v1/branch/{addressUuid}/working-hours', name: 'branch_working_hours_replace', methods: ['PUT'])]
|
||||
public function replaceWorkingHours(#[CurrentUser] User $user, string $addressUuid, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'update');
|
||||
|
||||
$address = $this->branches->resolve($user, $addressUuid);
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_array($data['days'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد days الزامی است', 422, 'days');
|
||||
}
|
||||
|
||||
$days = $this->workingHours->replace($address, $data['days']);
|
||||
|
||||
return $this->success([
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'timezone' => $address->getTimezone(),
|
||||
'defined' => $days !== array_fill_keys(WorkingHoursService::DAYS, []),
|
||||
'days' => $days,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Entity\Room;
|
||||
use App\Branch\Repository\RoomRepository;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Branch\Service\RoomService;
|
||||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Branch')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class RoomController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly RoomRepository $rooms,
|
||||
private readonly RoomService $roomService,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
/** @param 'view'|'update' $action */
|
||||
private function denyUnlessGranted(User $user, string $action): void
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action);
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* مالکیت صریح سنجیده میشود و به TenantFilter تکیه نمیکنیم: جداسازی سختِ فیلتر
|
||||
* فقط روی محیطِ «انتخابشده» اعمال میشود ({@see EntityContext::$chosen}) و پزشکی
|
||||
* که هنوز محیطی برنگزیده، اتاق کلینیک دیگر را میدید — با تست
|
||||
* RoomCrudTest::testForeignRoomIsNotFound گرفته شد.
|
||||
*
|
||||
* ۴۰۴ نه ۴۰۳، همان رفتار فیلتر: وجود دادهٔ محیط بیگانه لو نمیرود.
|
||||
*/
|
||||
private function requireRoom(User $user, string $uuid): Room
|
||||
{
|
||||
$room = $this->rooms->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($room === null || !$this->ownership->belongsToPair($entityType, $entityId, $room)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'اتاق یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $room;
|
||||
}
|
||||
|
||||
#[Route('/api/v1/branch/{addressUuid}/rooms', name: 'branch_rooms_list', methods: ['GET'])]
|
||||
public function list(#[CurrentUser] User $user, string $addressUuid): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'view');
|
||||
|
||||
$address = $this->branches->resolve($user, $addressUuid);
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (Room $room): array => $room->toArray(),
|
||||
$this->rooms->findForAddress($address),
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/room', name: 'room_create', methods: ['POST'])]
|
||||
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'update');
|
||||
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_string($data['address_uuid'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid');
|
||||
}
|
||||
|
||||
// جفت محیط اتاق از همین آدرس مشتق میشود، نه از بدنهٔ درخواست.
|
||||
$address = $this->branches->resolve($user, $data['address_uuid']);
|
||||
$room = $this->roomService->create($address, $data);
|
||||
|
||||
return $this->success($room->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/room/{uuid}', name: 'room_update', methods: ['PATCH'])]
|
||||
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'update');
|
||||
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$room = $this->roomService->update($this->requireRoom($user, $uuid), $data);
|
||||
|
||||
return $this->success($room->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/room/{uuid}', name: 'room_delete', methods: ['DELETE'])]
|
||||
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'update');
|
||||
|
||||
$this->roomService->delete($this->requireRoom($user, $uuid));
|
||||
|
||||
return $this->success(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Entity;
|
||||
|
||||
use App\Branch\Repository\BranchWorkingHoursRepository;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* ساعت کاری هفتگی یک شعبه — و «شعبه» همان {@see DoctorAddress} است، نه جدولی جدا
|
||||
* ({@see docs/new_feture/taskes/_shared/branch-is-doctor-address.md}).
|
||||
*
|
||||
* uuid ندارد (از request قابل ارجاع نیست) ولی جفت محیط دارد. اول بهعنوان فرزند
|
||||
* aggregate با ریشهٔ DoctorAddress ثبت شد و TenantSchemaCoverageTest درست ردش کرد:
|
||||
* ریشهاش خودش در GlobalTables::ENTITIES سراسری است، پس آن مسیر هیچ تضمینی نمیداد.
|
||||
* جفت گرفتن ممکن است چون type آدرس نگاشتی کامل به محیط دارد — personal ⇒ (doctor,
|
||||
* doctorId) و clinic ⇒ (clinic, clinicId) — و آدرس هم فقط در همان محیط فهرست میشود،
|
||||
* پس هیچ ردیفی بیدلیل پنهان نمیشود. نتیجه: TenantFilter واقعاً پوششش میدهد و
|
||||
* {@see \App\Branch\Service\BranchResolver} لایهٔ دوم است نه تنها لایه.
|
||||
*
|
||||
* زمانها «دقیقه از نیمهشب» است نه رشتهٔ "09:00": تقاطع دو بازه محاسبهٔ عددی است و
|
||||
* مقایسهٔ رشتهای در «9:00» < «10:00» غلط جواب میدهد.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: BranchWorkingHoursRepository::class)]
|
||||
#[ORM\Table(name: 'branch_working_hours')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_bwh_address_day_seq', columns: ['address_id', 'day_of_week', 'sequence'])]
|
||||
#[ORM\Index(columns: ['address_id', 'day_of_week', 'active'], name: 'idx_bwh_address_day')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_bwh_tenant')]
|
||||
class BranchWorkingHours
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const MINUTES_IN_DAY = 1440;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
|
||||
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private DoctorAddress $address;
|
||||
|
||||
/** ۰=شنبه … ۶=جمعه — همان قرارداد SlotCalculatorService */
|
||||
#[ORM\Column(name: 'day_of_week', type: 'smallint')]
|
||||
private int $dayOfWeek;
|
||||
|
||||
/** بازهٔ چندم آن روز؛ سرور تخصیصش میدهد، نه کلاینت */
|
||||
#[ORM\Column(type: 'smallint', options: ['default' => 0])]
|
||||
private int $sequence = 0;
|
||||
|
||||
#[ORM\Column(name: 'start_minute', type: 'smallint')]
|
||||
private int $startMinute;
|
||||
|
||||
#[ORM\Column(name: 'end_minute', type: 'smallint')]
|
||||
private int $endMinute;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
private bool $active = true;
|
||||
|
||||
public function __construct(
|
||||
DoctorAddress $address,
|
||||
int $dayOfWeek,
|
||||
int $startMinute,
|
||||
int $endMinute,
|
||||
int $sequence = 0,
|
||||
) {
|
||||
$this->address = $address;
|
||||
$this->dayOfWeek = $dayOfWeek;
|
||||
$this->startMinute = $startMinute;
|
||||
$this->endMinute = $endMinute;
|
||||
$this->sequence = $sequence;
|
||||
|
||||
$this->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getAddress(): DoctorAddress { return $this->address; }
|
||||
public function getDayOfWeek(): int { return $this->dayOfWeek; }
|
||||
public function getSequence(): int { return $this->sequence; }
|
||||
public function getStartMinute(): int { return $this->startMinute; }
|
||||
public function getEndMinute(): int { return $this->endMinute; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
|
||||
public function setActive(bool $v): self { $this->active = $v; return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'sequence' => $this->sequence,
|
||||
'start_minute' => $this->startMinute,
|
||||
'end_minute' => $this->endMinute,
|
||||
'start_time' => self::formatMinute($this->startMinute),
|
||||
'end_time' => self::formatMinute($this->endMinute),
|
||||
'active' => $this->active,
|
||||
];
|
||||
}
|
||||
|
||||
/** ۱۴۴۰ به «۲۴:۰۰» تبدیل میشود، نه «۰۰:۰۰» — پایانِ روز است نه آغازش. */
|
||||
public static function formatMinute(int $minute): string
|
||||
{
|
||||
return sprintf('%02d:%02d', intdiv($minute, 60), $minute % 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Entity;
|
||||
|
||||
use App\Branch\Repository\RoomRepository;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* اتاق یک شعبه. برخلاف {@see BranchWorkingHours} جفت محیط دارد، چون uuidش از request
|
||||
* میآید و بدون جفت، TenantFilter نمیتواند اتاق محیط دیگر را پنهان کند.
|
||||
*
|
||||
* جفت در سازنده از خودِ آدرس مشتق میشود نه از بدنهٔ درخواست — پس هیچ نقطهٔ ساختی
|
||||
* نمیتواند فراموشش کند و کلاینت هم نمیتواند اتاقی را به محیط دیگری بچسباند.
|
||||
*
|
||||
* capacity یعنی چند بیمار همزمان: اتاق تزریق سهتخته «یک منبع با ظرفیت ۳» است، نه
|
||||
* سه منبع (بند ۶ مستند). تسک ۰۲ همین معنا را روی Resource تکرار میکند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: RoomRepository::class)]
|
||||
#[ORM\Table(name: 'rooms')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_rooms_tenant')]
|
||||
#[ORM\Index(columns: ['address_id', 'active'], name: 'idx_rooms_address')]
|
||||
class Room
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
|
||||
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private DoctorAddress $address;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 120)]
|
||||
private string $name;
|
||||
|
||||
/** متن آزاد — نوع اتاق را خود کلینیک تعریف میکند، نه یک enum سراسری */
|
||||
#[ORM\Column(name: 'room_type', type: 'string', length: 60, nullable: true)]
|
||||
private ?string $roomType = null;
|
||||
|
||||
#[ORM\Column(type: 'smallint', options: ['default' => 1])]
|
||||
private int $capacity = 1;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20, nullable: true)]
|
||||
private ?string $floor = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(DoctorAddress $address, string $name)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->address = $address;
|
||||
$this->name = $name;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
|
||||
$this->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getAddress(): DoctorAddress { return $this->address; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getRoomType(): ?string { return $this->roomType; }
|
||||
public function getCapacity(): int { return $this->capacity; }
|
||||
public function getFloor(): ?string { return $this->floor; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
|
||||
public function setRoomType(?string $v): self { $this->roomType = $v; $this->touch(); return $this; }
|
||||
public function setFloor(?string $v): self { $this->floor = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
|
||||
/** @throws \InvalidArgumentException روی ظرفیت کمتر از ۱ */
|
||||
public function setCapacity(int $v): self
|
||||
{
|
||||
if ($v < 1) {
|
||||
throw new \InvalidArgumentException('Room capacity must be at least 1.');
|
||||
}
|
||||
|
||||
$this->capacity = $v;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'address_uuid' => $this->address->getUuid(),
|
||||
'address_name' => $this->address->getName(),
|
||||
'name' => $this->name,
|
||||
'room_type' => $this->roomType,
|
||||
'capacity' => $this->capacity,
|
||||
'floor' => $this->floor,
|
||||
'active' => $this->active,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Repository;
|
||||
|
||||
use App\Branch\Entity\BranchWorkingHours;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<BranchWorkingHours>
|
||||
*/
|
||||
class BranchWorkingHoursRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, BranchWorkingHours::class);
|
||||
}
|
||||
|
||||
/** @return BranchWorkingHours[] مرتب بر روز و سپس بازه */
|
||||
public function findForAddress(DoctorAddress $address): array
|
||||
{
|
||||
return $this->createQueryBuilder('h')
|
||||
->where('h.address = :address')
|
||||
->setParameter('address', $address)
|
||||
->orderBy('h.dayOfWeek', 'ASC')
|
||||
->addOrderBy('h.sequence', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function deleteForAddress(DoctorAddress $address): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('h')
|
||||
->delete()
|
||||
->where('h.address = :address')
|
||||
->setParameter('address', $address)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* آدرسهایی که ساعت کاری تعریفشده دارند — برای نشان دادن وضعیت در لیست شعبهها
|
||||
* بدون N+۱ کوئری.
|
||||
*
|
||||
* @param int[] $addressIds
|
||||
* @return array<int, int> شناسهٔ آدرس => تعداد بازهها
|
||||
*/
|
||||
public function countByAddressIds(array $addressIds): array
|
||||
{
|
||||
if ($addressIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('h')
|
||||
->select('IDENTITY(h.address) AS address_id, COUNT(h.id) AS total')
|
||||
->where('h.address IN (:ids)')
|
||||
->setParameter('ids', $addressIds)
|
||||
->groupBy('h.address')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[(int) $row['address_id']] = (int) $row['total'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Repository;
|
||||
|
||||
use App\Branch\Entity\Room;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Room>
|
||||
*/
|
||||
class RoomRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Room::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* uuid از request میآید، ولی Room جفت محیط دارد پس TenantFilter اتاق محیط دیگر را
|
||||
* پیش از رسیدن به اینجا حذف میکند — همان دلیلی که در TenantLookupInventoryTest
|
||||
* برای این lookup ثبت شده.
|
||||
*/
|
||||
public function findByUuid(string $uuid): ?Room
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return Room[] */
|
||||
public function findForAddress(DoctorAddress $address): array
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->where('r.address = :address')
|
||||
->setParameter('address', $address)
|
||||
->orderBy('r.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function countActiveForAddress(DoctorAddress $address): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('r')
|
||||
->select('COUNT(r.id)')
|
||||
->where('r.address = :address')
|
||||
->andWhere('r.active = true')
|
||||
->setParameter('address', $address)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $addressIds
|
||||
* @return array<int, int> شناسهٔ آدرس => تعداد اتاق فعال
|
||||
*/
|
||||
public function countActiveByAddressIds(array $addressIds): array
|
||||
{
|
||||
if ($addressIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('r')
|
||||
->select('IDENTITY(r.address) AS address_id, COUNT(r.id) AS total')
|
||||
->where('r.address IN (:ids)')
|
||||
->andWhere('r.active = true')
|
||||
->setParameter('ids', $addressIds)
|
||||
->groupBy('r.address')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[(int) $row['address_id']] = (int) $row['total'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Doctor\Repository\DoctorAddressRepository;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* تکنقطهٔ تبدیل «uuid شعبه در request» به یک {@see DoctorAddress} از محیط جاری.
|
||||
*
|
||||
* لازم است چون doctor_addresses جفت (entity_type, entity_id) ندارد و در
|
||||
* GlobalTables::ENTITIES سراسری اعلام شده، پس TenantFilter رویش کار نمیکند:
|
||||
* findOneBy(['uuid' => …]) آدرس کلینیک دیگری را هم برمیگرداند. هر سه کنترلر این
|
||||
* دامنه از اینجا رد میشوند تا این بررسی جایی جا نیفتد.
|
||||
*/
|
||||
final class BranchResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorAddressRepository $addresses,
|
||||
private readonly EntityContextResolver $contexts,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* جفت محیطِ این درخواست.
|
||||
*
|
||||
* منشی جدا حساب میشود چون EntityContextResolver او را مالک هیچ محیطی نمیشناسد —
|
||||
* همان استثنایی که ClinicServiceController::resolveEntity() هم دارد. مجوزش جداگانه
|
||||
* با denyUnlessGranted سنجیده میشود، اینجا فقط «کدام محیط» است.
|
||||
*
|
||||
* @return array{0: string, 1: int}
|
||||
* @throws AppException وقتی محیطی حل نشود
|
||||
*/
|
||||
public function pair(User $user): array
|
||||
{
|
||||
[$type, $id] = $user->hasRole('ROLE_SECRETARY')
|
||||
? $this->secretaryAccess->resolveOwnerEntity($user)
|
||||
: $this->contexts->resolve($user, $this->requestedClinicUuid())->toEntityPair();
|
||||
|
||||
if ($id === null) {
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری انتخاب نشده است', 403);
|
||||
}
|
||||
|
||||
return [$type, (int) $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* ۴۰۴ میدهد نه ۴۰۳ — همان رفتار TenantFilter: وجودِ دادهٔ محیط دیگر لو نمیرود.
|
||||
*
|
||||
* @throws AppException
|
||||
*/
|
||||
public function resolve(User $user, string $addressUuid): DoctorAddress
|
||||
{
|
||||
[$entityType, $entityId] = $this->pair($user);
|
||||
|
||||
$address = $this->addresses->findByUuidForEntityPair($addressUuid, $entityType, $entityId);
|
||||
|
||||
if ($address === null) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'شعبه یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
/** @return DoctorAddress[] شعبههای محیط جاری */
|
||||
public function listForContext(User $user): array
|
||||
{
|
||||
[$entityType, $entityId] = $this->pair($user);
|
||||
|
||||
return $this->addresses->findForEntityPair($entityType, $entityId);
|
||||
}
|
||||
|
||||
private function requestedClinicUuid(): ?string
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if ($request === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fromQuery = $request->query->get('clinic_uuid');
|
||||
if (is_string($fromQuery) && $fromQuery !== '') {
|
||||
return $fromQuery;
|
||||
}
|
||||
|
||||
if (!in_array($request->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
|
||||
return is_array($body) && is_string($body['clinic_uuid'] ?? null) && $body['clinic_uuid'] !== ''
|
||||
? $body['clinic_uuid']
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Service;
|
||||
|
||||
use App\Branch\Entity\Room;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* دلیلی که یک اتاق را غیرقابلحذف میکند.
|
||||
*
|
||||
* الان هیچ پیادهسازیای ندارد و این عمدی است: در این فاز اتاق هیچ وابستهٔ زندهای
|
||||
* ندارد. تسک ۰۲ (منبعِ فعال روی اتاق) و تسک ۰۷ (نوبت آیندهٔ آن منابع) هرکدام یک
|
||||
* پیادهسازی اضافه میکنند و RoomService دست نمیخورد — بهجای زنجیرهٔ if که هر تسک
|
||||
* یک شرط به آن سنجاق کند.
|
||||
*/
|
||||
interface RoomDeletionGuardInterface
|
||||
{
|
||||
/** @throws AppException وقتی حذف مجاز نیست */
|
||||
public function assertDeletable(Room $room): void;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Service;
|
||||
|
||||
use App\Branch\Entity\Room;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
final class RoomService
|
||||
{
|
||||
/**
|
||||
* @param iterable<RoomDeletionGuardInterface> $deletionGuards
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
#[AutowireIterator('app.room_deletion_guard')]
|
||||
private readonly iterable $deletionGuards = [],
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function create(DoctorAddress $address, array $data): Room
|
||||
{
|
||||
$room = new Room($address, $this->assertName($data['name'] ?? null));
|
||||
$this->applyOptional($room, $data);
|
||||
|
||||
$this->em->persist($room);
|
||||
$this->em->flush();
|
||||
|
||||
return $room;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function update(Room $room, array $data): Room
|
||||
{
|
||||
if (array_key_exists('name', $data)) {
|
||||
$room->setName($this->assertName($data['name']));
|
||||
}
|
||||
|
||||
$this->applyOptional($room, $data);
|
||||
$this->em->flush();
|
||||
|
||||
return $room;
|
||||
}
|
||||
|
||||
public function delete(Room $room): void
|
||||
{
|
||||
foreach ($this->deletionGuards as $guard) {
|
||||
$guard->assertDeletable($room);
|
||||
}
|
||||
|
||||
$this->em->remove($room);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function applyOptional(Room $room, array $data): void
|
||||
{
|
||||
if (array_key_exists('capacity', $data)) {
|
||||
$room->setCapacity($this->assertCapacity($data['capacity']));
|
||||
}
|
||||
|
||||
if (array_key_exists('room_type', $data)) {
|
||||
$room->setRoomType($this->trimOrNull($data['room_type']));
|
||||
}
|
||||
|
||||
if (array_key_exists('floor', $data)) {
|
||||
$room->setFloor($this->trimOrNull($data['floor']));
|
||||
}
|
||||
|
||||
if (array_key_exists('active', $data)) {
|
||||
$room->setActive((bool) $data['active']);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertName(mixed $value): string
|
||||
{
|
||||
$name = is_string($value) ? trim($value) : '';
|
||||
|
||||
if ($name === '') {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'نام اتاق الزامی است', 422, 'name');
|
||||
}
|
||||
|
||||
if (mb_strlen($name) > 120) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام اتاق حداکثر ۱۲۰ نویسه است', 422, 'name');
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function assertCapacity(mixed $value): int
|
||||
{
|
||||
if (!is_numeric($value) || (int) $value < 1) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'ظرفیت اتاق حداقل ۱ است', 422, 'capacity');
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function trimOrNull(mixed $value): ?string
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Branch\Service;
|
||||
|
||||
use App\Branch\Entity\BranchWorkingHours;
|
||||
use App\Branch\Repository\BranchWorkingHoursRepository;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* ساعت کاری هفتگی شعبه — اعتبارسنجی و ذخیره.
|
||||
*
|
||||
* قرارداد نوشتن PUT است نه PATCH: بدنه تمام حقیقتِ هفت روز است و آرایهٔ خالی یعنی
|
||||
* «شعبه کاملاً بسته». دلیل: ساعت کاری یک شکل واحد است، و merge تفاضلی روی هفت روز و
|
||||
* چند بازه در هر روز، دو کلاینت همزمان را به وضعیتهای ناسازگار میرساند.
|
||||
*
|
||||
* «شعبهٔ بدون هیچ ساعت کاری» = تعریفنشده، نه همیشهباز. تسک ۰۳ در آن حالت به رفتار
|
||||
* فعلی برمیگردد (برنامهٔ پزشک تنها مرجع) تا دادهٔ موجود دقیقاً مثل امروز کار کند.
|
||||
*/
|
||||
final class WorkingHoursService
|
||||
{
|
||||
public const DAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
public function __construct(
|
||||
private readonly BranchWorkingHoursRepository $hours,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, list<array<string, mixed>>> کلیدهای ۰..۶ همیشه هر هفت روز
|
||||
*/
|
||||
public function read(DoctorAddress $address): array
|
||||
{
|
||||
$result = array_fill_keys(self::DAYS, []);
|
||||
|
||||
foreach ($this->hours->findForAddress($address) as $row) {
|
||||
$result[$row->getDayOfWeek()][] = $row->toArray();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function isDefined(DoctorAddress $address): bool
|
||||
{
|
||||
return $this->hours->findForAddress($address) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* جایگزینی کامل هفت روز.
|
||||
*
|
||||
* @param array<int|string, mixed> $days نگاشت روز => فهرست بازهها
|
||||
* @return array<int, list<array<string, mixed>>>
|
||||
* @throws AppException روی هر ورودی نامعتبر — پیش از هر تغییری در دیتابیس
|
||||
*/
|
||||
public function replace(DoctorAddress $address, array $days): array
|
||||
{
|
||||
$normalized = $this->validate($days);
|
||||
|
||||
// اعتبارسنجی کاملِ هر هفت روز قبل از DELETE: بازهٔ نامعتبر در روز ششم نباید
|
||||
// شش روز درستِ قبلی را هم پاک کند و بعد ۴۲۲ برگرداند.
|
||||
$this->hours->deleteForAddress($address);
|
||||
|
||||
foreach ($normalized as $dayOfWeek => $ranges) {
|
||||
foreach ($ranges as $sequence => $range) {
|
||||
$this->em->persist(new BranchWorkingHours(
|
||||
$address,
|
||||
$dayOfWeek,
|
||||
$range['start_minute'],
|
||||
$range['end_minute'],
|
||||
$sequence,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->read($address);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $days
|
||||
* @return array<int, list<array{start_minute: int, end_minute: int}>> مرتبشده، بدون همپوشانی
|
||||
* @throws AppException
|
||||
*/
|
||||
private function validate(array $days): array
|
||||
{
|
||||
$normalized = array_fill_keys(self::DAYS, []);
|
||||
|
||||
foreach ($days as $rawDay => $ranges) {
|
||||
$day = $this->assertDay($rawDay);
|
||||
|
||||
if (!is_array($ranges)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('بازههای روز %d باید یک آرایه باشد', $day),
|
||||
422,
|
||||
(string) $rawDay,
|
||||
);
|
||||
}
|
||||
|
||||
$normalized[$day] = $this->assertRanges($day, $ranges);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function assertDay(int|string $rawDay): int
|
||||
{
|
||||
if (!is_numeric($rawDay) || !in_array((int) $rawDay, self::DAYS, true)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'روز هفته باید عددی بین ۰ (شنبه) و ۶ (جمعه) باشد',
|
||||
422,
|
||||
'day_of_week',
|
||||
);
|
||||
}
|
||||
|
||||
return (int) $rawDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $ranges
|
||||
* @return list<array{start_minute: int, end_minute: int}>
|
||||
*/
|
||||
private function assertRanges(int $day, array $ranges): array
|
||||
{
|
||||
$parsed = [];
|
||||
|
||||
foreach ($ranges as $range) {
|
||||
if (!is_array($range)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('بازهٔ روز %d ساختار درستی ندارد', $day),
|
||||
422,
|
||||
'start_minute',
|
||||
);
|
||||
}
|
||||
|
||||
$start = $this->assertMinute($range['start_minute'] ?? null, $day, 'start_minute');
|
||||
$end = $this->assertMinute($range['end_minute'] ?? null, $day, 'end_minute');
|
||||
|
||||
if ($end <= $start) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('در روز %d، پایان بازه باید بعد از شروع آن باشد', $day),
|
||||
422,
|
||||
'end_minute',
|
||||
);
|
||||
}
|
||||
|
||||
$parsed[] = ['start_minute' => $start, 'end_minute' => $end];
|
||||
}
|
||||
|
||||
usort($parsed, static fn (array $a, array $b): int => $a['start_minute'] <=> $b['start_minute']);
|
||||
|
||||
// sequence از همین ترتیب مشتق میشود، پس تشخیص همپوشانی فقط مقایسهٔ همسایههاست.
|
||||
foreach ($parsed as $i => $range) {
|
||||
if ($i > 0 && $range['start_minute'] < $parsed[$i - 1]['end_minute']) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('بازههای روز %d با هم همپوشانی دارند', $day),
|
||||
422,
|
||||
'start_minute',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function assertMinute(mixed $value, int $day, string $field): int
|
||||
{
|
||||
if (!is_numeric($value)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_002,
|
||||
sprintf('در روز %d مقدار %s الزامی است', $day, $field),
|
||||
422,
|
||||
$field,
|
||||
);
|
||||
}
|
||||
|
||||
$minute = (int) $value;
|
||||
|
||||
if ($minute < 0 || $minute > BranchWorkingHours::MINUTES_IN_DAY) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('در روز %d مقدار %s باید بین ۰ و ۱۴۴۰ باشد', $day, $field),
|
||||
422,
|
||||
$field,
|
||||
);
|
||||
}
|
||||
|
||||
return $minute;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ class DoctorAddress
|
||||
public const TYPE_PERSONAL = 'personal';
|
||||
public const TYPE_CLINIC = 'clinic';
|
||||
|
||||
public const DEFAULT_TIMEZONE = 'Asia/Tehran';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -58,6 +60,12 @@ class DoctorAddress
|
||||
#[ORM\JoinColumn(name: 'province_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Province $province = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 40, options: ['default' => self::DEFAULT_TIMEZONE])]
|
||||
private string $timezone = self::DEFAULT_TIMEZONE;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -99,6 +107,25 @@ class DoctorAddress
|
||||
public function getLongitude(): ?float { return $this->longitude; }
|
||||
public function getCity(): ?City { return $this->city; }
|
||||
public function getProvince(): ?Province { return $this->province; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getTimezone(): string { return $this->timezone; }
|
||||
|
||||
/** جفت محیط این آدرس — `rooms` و منابع تسک ۰۲ جفتشان را از همین میگیرند، نه از request. */
|
||||
public function tenantEntityType(): string
|
||||
{
|
||||
return $this->type === self::TYPE_CLINIC ? 'clinic' : 'doctor';
|
||||
}
|
||||
|
||||
public function tenantEntityId(): int
|
||||
{
|
||||
$id = $this->type === self::TYPE_CLINIC ? $this->clinicId : $this->doctor?->getId();
|
||||
|
||||
if ($id === null) {
|
||||
throw new \LogicException('DoctorAddress without an owner cannot derive a tenant pair.');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function setName(?string $v): self { $this->name = $v; return $this; }
|
||||
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
|
||||
@@ -107,6 +134,20 @@ class DoctorAddress
|
||||
public function setLongitude(?float $v): self { $this->longitude = $v; $this->touch(); return $this; }
|
||||
public function setCity(?City $v): self { $this->city = $v; $this->touch(); return $this; }
|
||||
public function setProvince(?Province $v): self { $this->province = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
|
||||
/** @throws \InvalidArgumentException روی شناسهٔ ناشناختهٔ منطقهٔ زمانی */
|
||||
public function setTimezone(string $v): self
|
||||
{
|
||||
if (!in_array($v, \DateTimeZone::listIdentifiers(), true)) {
|
||||
throw new \InvalidArgumentException(sprintf('Unknown timezone "%s".', $v));
|
||||
}
|
||||
|
||||
$this->timezone = $v;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
@@ -125,6 +166,8 @@ class DoctorAddress
|
||||
],
|
||||
'address' => $this->address,
|
||||
'telephone' => $this->telephone,
|
||||
'active' => $this->active,
|
||||
'timezone' => $this->timezone,
|
||||
'city' => $this->city !== null ? [
|
||||
'id' => (string) $this->city->getId(),
|
||||
'name' => $this->city->getName(),
|
||||
|
||||
@@ -41,6 +41,65 @@ class DoctorAddressRepository extends ServiceEntityRepository
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* آدرسهای یک محیط با جفت (entity_type, entity_id) — همان واژگانی که Room و منابع
|
||||
* تسک ۰۲ با آن ذخیره میشوند.
|
||||
*
|
||||
* قرینهٔ findForContext() است ولی Doctor لازم ندارد: در محیط کلینیک، آن متد
|
||||
* پارامتر doctor را نادیده میگیرد و مجبور کردن فراخوان به ساختن یک Doctor
|
||||
* الکی، همان نوع کدی است که بعداً کسی با getReference() پرش میکند.
|
||||
*
|
||||
* @return DoctorAddress[]
|
||||
*/
|
||||
public function findForEntityPair(string $entityType, int $entityId): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a');
|
||||
|
||||
if ($entityType === 'clinic') {
|
||||
$qb->where('a.clinicId = :entityId')
|
||||
->andWhere('a.type = :type')
|
||||
->setParameter('type', DoctorAddress::TYPE_CLINIC);
|
||||
} else {
|
||||
$qb->where('IDENTITY(a.doctor) = :entityId')
|
||||
->andWhere('a.type = :type')
|
||||
->setParameter('type', DoctorAddress::TYPE_PERSONAL);
|
||||
}
|
||||
|
||||
return $qb->setParameter('entityId', $entityId)
|
||||
->orderBy('a.id', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* یک آدرس با uuid، محدود به محیط دادهشده.
|
||||
*
|
||||
* `doctor_addresses` جفت محیط ندارد (عمداً — در GlobalTables::ENTITIES ثبت شده)
|
||||
* پس TenantFilter رویش اعمال نمیشود و `findOneBy(['uuid' => …])` آدرس محیط دیگر
|
||||
* را هم برمیگرداند. هر مسیری که uuid آدرس را از request میگیرد باید از این
|
||||
* متد یا از {@see \App\Branch\Service\BranchResolver} رد شود.
|
||||
*/
|
||||
public function findByUuidForEntityPair(string $uuid, string $entityType, int $entityId): ?DoctorAddress
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->where('a.uuid = :uuid')
|
||||
->setParameter('uuid', $uuid);
|
||||
|
||||
if ($entityType === 'clinic') {
|
||||
$qb->andWhere('a.clinicId = :entityId')
|
||||
->andWhere('a.type = :type')
|
||||
->setParameter('type', DoctorAddress::TYPE_CLINIC);
|
||||
} else {
|
||||
$qb->andWhere('IDENTITY(a.doctor) = :entityId')
|
||||
->andWhere('a.type = :type')
|
||||
->setParameter('type', DoctorAddress::TYPE_PERSONAL);
|
||||
}
|
||||
|
||||
return $qb->setParameter('entityId', $entityId)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findOneByClinic(int $clinicId): ?DoctorAddress
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Branch;
|
||||
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
|
||||
/**
|
||||
* دو ویژگی تازهٔ شعبه (`active` / `timezone`) و فهرست شعبههای محیط جاری.
|
||||
*/
|
||||
class BranchFieldsTest extends BranchTestCase
|
||||
{
|
||||
/** ردیفهای موجود بدون backfill درست میشوند؛ هیچ رفتار فعلی عوض نمیشود. */
|
||||
public function testExistingBranchGetsSafeDefaults(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
self::assertTrue($address->isActive());
|
||||
self::assertSame(DoctorAddress::DEFAULT_TIMEZONE, $address->getTimezone());
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/branches', $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($body['data'][0]['active']);
|
||||
self::assertSame('Asia/Tehran', $body['data'][0]['timezone']);
|
||||
}
|
||||
|
||||
public function testListReportsWorkingHoursAndRoomCounts(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$before = $this->authJson('GET', '/api/v1/branches', $user);
|
||||
self::assertFalse($before['data'][0]['working_hours_defined']);
|
||||
self::assertSame(0, $before['data'][0]['rooms_count']);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [
|
||||
'days' => [1 => [['start_minute' => 540, 'end_minute' => 780]]],
|
||||
]);
|
||||
$this->authJson('POST', '/api/v1/room', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'name' => 'اتاق ۱',
|
||||
]);
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/branches', $user);
|
||||
|
||||
self::assertTrue($after['data'][0]['working_hours_defined']);
|
||||
self::assertSame(1, $after['data'][0]['rooms_count']);
|
||||
}
|
||||
|
||||
/** فقط اتاق فعال شمرده میشود — اتاق غیرفعال ظرفیت واقعی شعبه نیست. */
|
||||
public function testInactiveRoomIsNotCounted(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
$room = $this->authJson('POST', '/api/v1/room', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'name' => 'اتاق بسته',
|
||||
]);
|
||||
$this->authJson('PATCH', "/api/v1/room/{$room['data']['uuid']}", $user, ['active' => false]);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/branches', $user);
|
||||
|
||||
self::assertSame(0, $body['data'][0]['rooms_count']);
|
||||
}
|
||||
|
||||
public function testListShowsOnlyTheCurrentContextBranches(): void
|
||||
{
|
||||
[$doctorUser, , $doctorAddress] = $this->doctorWithAddress('مطب شخصی');
|
||||
$this->clinicWithAddress('شعبهٔ کلینیک بیگانه');
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/branches', $doctorUser);
|
||||
|
||||
self::assertCount(1, $body['data']);
|
||||
self::assertSame($doctorAddress->getUuid(), $body['data'][0]['uuid']);
|
||||
}
|
||||
|
||||
public function testBranchIsDeactivated(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, ['active' => false]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertFalse($body['data']['active']);
|
||||
}
|
||||
|
||||
public function testTimezoneIsUpdated(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, [
|
||||
'timezone' => 'Asia/Dubai',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('Asia/Dubai', $body['data']['timezone']);
|
||||
}
|
||||
|
||||
/** با DateTimeZone::listIdentifiers سنجیده میشود، نه با regex. */
|
||||
public function testUnknownTimezoneIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, ['timezone' => 'Tehran']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('timezone', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testForeignBranchCannotBePatched(): void
|
||||
{
|
||||
[$doctorUser] = $this->doctorWithAddress();
|
||||
[, , $foreignAddress] = $this->clinicWithAddress();
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/branch/{$foreignAddress->getUuid()}", $doctorUser, ['active' => false]);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/** شمارشها گروهیاند: تعداد کوئریها با تعداد شعبهها رشد نمیکند. */
|
||||
public function testListQueryCountDoesNotGrowWithBranches(): void
|
||||
{
|
||||
// یک کرنل برای هر دو اندازهگیری، وگرنه reboot دادهٔ کوئریها را میریزد.
|
||||
$this->client->disableReboot();
|
||||
|
||||
[$user, $doctor] = $this->doctorWithAddress();
|
||||
|
||||
$queriesForOne = $this->countQueries(
|
||||
fn () => $this->authJson('GET', '/api/v1/branches', $user)
|
||||
);
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$extra = DoctorAddress::forDoctor($doctor);
|
||||
$extra->setName("شعبهٔ $i");
|
||||
$this->em->persist($extra);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
$queriesForFive = $this->countQueries(
|
||||
fn () => $this->authJson('GET', '/api/v1/branches', $user)
|
||||
);
|
||||
|
||||
self::assertCount(5, json_decode($this->client->getResponse()->getContent(), true)['data']);
|
||||
self::assertSame($queriesForOne, $queriesForFive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Branch;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* فیکسچرهای مشترک دامنهٔ شعبه. «شعبه» همان DoctorAddress است، پس هر تست به یک آدرس
|
||||
* از محیط جاری و یک آدرس از محیط بیگانه نیاز دارد تا مرز ۴۰۴ را واقعاً بسنجد.
|
||||
*/
|
||||
abstract class BranchTestCase extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor, 2: DoctorAddress} */
|
||||
protected function doctorWithAddress(string $name = 'مطب مرکزی'): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, 'دکتر شعبه');
|
||||
$doctor->setMobileNumber($user->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$address->setName($name);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $doctor, $address];
|
||||
}
|
||||
|
||||
/** @return array{0: User, 1: Clinic, 2: DoctorAddress} */
|
||||
protected function clinicWithAddress(string $name = 'شعبهٔ کلینیک'): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک تست شعبه');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName($name);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $clinic, $address];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Branch;
|
||||
|
||||
use App\Branch\Entity\Room;
|
||||
|
||||
class RoomCrudTest extends BranchTestCase
|
||||
{
|
||||
/** @param array<string, mixed> $body */
|
||||
private function createRoom(\App\Auth\Entity\User $user, string $addressUuid, array $body = []): array
|
||||
{
|
||||
return $this->authJson('POST', '/api/v1/room', $user, $body + [
|
||||
'address_uuid' => $addressUuid,
|
||||
'name' => 'اتاق تزریق',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testRoomIsCreatedWithTenantPairDerivedFromTheBranch(): void
|
||||
{
|
||||
[$clinicUser, $clinic, $address] = $this->clinicWithAddress();
|
||||
|
||||
$body = $this->createRoom($clinicUser, $address->getUuid(), ['capacity' => 3, 'floor' => '2']);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(3, $body['data']['capacity']);
|
||||
self::assertSame('2', $body['data']['floor']);
|
||||
self::assertSame($address->getUuid(), $body['data']['address_uuid']);
|
||||
|
||||
$room = $this->em->getRepository(Room::class)->findOneBy(['uuid' => $body['data']['uuid']]);
|
||||
self::assertSame('clinic', $room->getEntityType());
|
||||
self::assertSame($clinic->getId(), $room->getEntityId());
|
||||
}
|
||||
|
||||
public function testPersonalBranchRoomBelongsToTheDoctor(): void
|
||||
{
|
||||
[$doctorUser, $doctor, $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->createRoom($doctorUser, $address->getUuid());
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$room = $this->em->getRepository(Room::class)->findOneBy(['uuid' => $body['data']['uuid']]);
|
||||
self::assertSame('doctor', $room->getEntityType());
|
||||
self::assertSame($doctor->getId(), $room->getEntityId());
|
||||
}
|
||||
|
||||
public function testCapacityDefaultsToOne(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->createRoom($user, $address->getUuid());
|
||||
|
||||
self::assertSame(1, $body['data']['capacity']);
|
||||
self::assertTrue($body['data']['active']);
|
||||
}
|
||||
|
||||
public function testZeroCapacityIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->createRoom($user, $address->getUuid(), ['capacity' => 0]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('capacity', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testBlankNameIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/room', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'name' => ' ',
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('name', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testMissingAddressUuidIsRejected(): void
|
||||
{
|
||||
[$user] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/room', $user, ['name' => 'اتاق']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('address_uuid', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
/** جفت محیط از آدرس میآید، پس نمیشود اتاق را روی شعبهٔ محیط دیگر نشاند. */
|
||||
public function testRoomCannotBeCreatedOnAForeignBranch(): void
|
||||
{
|
||||
[$doctorUser] = $this->doctorWithAddress();
|
||||
[, , $foreignAddress] = $this->clinicWithAddress();
|
||||
|
||||
$this->createRoom($doctorUser, $foreignAddress->getUuid());
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testRoomIsUpdated(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
$created = $this->createRoom($user, $address->getUuid());
|
||||
|
||||
$body = $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, [
|
||||
'name' => 'اتاق پانسمان',
|
||||
'capacity' => 2,
|
||||
'room_type' => 'پانسمان',
|
||||
'active' => false,
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('اتاق پانسمان', $body['data']['name']);
|
||||
self::assertSame(2, $body['data']['capacity']);
|
||||
self::assertSame('پانسمان', $body['data']['room_type']);
|
||||
self::assertFalse($body['data']['active']);
|
||||
}
|
||||
|
||||
/** رشتهٔ خالی روی فیلد اختیاری یعنی «پاک کن»، نه ذخیرهٔ رشتهٔ خالی. */
|
||||
public function testBlankOptionalFieldBecomesNull(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
$created = $this->createRoom($user, $address->getUuid(), ['room_type' => 'تزریق']);
|
||||
|
||||
$body = $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, ['room_type' => '']);
|
||||
|
||||
self::assertNull($body['data']['room_type']);
|
||||
}
|
||||
|
||||
public function testRoomIsDeleted(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
$created = $this->createRoom($user, $address->getUuid());
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/room/{$created['data']['uuid']}", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, ['name' => 'x']);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testForeignRoomIsNotFound(): void
|
||||
{
|
||||
[$clinicUser, , $clinicAddress] = $this->clinicWithAddress();
|
||||
$created = $this->createRoom($clinicUser, $clinicAddress->getUuid());
|
||||
|
||||
[$doctorUser] = $this->doctorWithAddress();
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $doctorUser, ['name' => 'دزدیدهشده']);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/room/{$created['data']['uuid']}", $doctorUser);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testBranchRoomsAreListedForItsOwnerOnly(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
$this->createRoom($user, $address->getUuid(), ['name' => 'اتاق ۱']);
|
||||
$this->createRoom($user, $address->getUuid(), ['name' => 'اتاق ۲']);
|
||||
|
||||
$body = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/rooms", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(2, $body['data']);
|
||||
|
||||
[, , $foreignAddress] = $this->clinicWithAddress();
|
||||
$this->authJson('GET', "/api/v1/branch/{$foreignAddress->getUuid()}/rooms", $user);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Branch;
|
||||
|
||||
use App\Branch\Entity\BranchWorkingHours;
|
||||
|
||||
/**
|
||||
* ساعت کاری هفتگی شعبه — GET/PUT روی /api/v1/branch/{addressUuid}/working-hours
|
||||
*/
|
||||
class WorkingHoursTest extends BranchTestCase
|
||||
{
|
||||
/** @param array<int, list<array{start_minute: int, end_minute: int}>> $days */
|
||||
private function put(\App\Auth\Entity\User $user, string $addressUuid, array $days): array
|
||||
{
|
||||
return $this->authJson('PUT', "/api/v1/branch/$addressUuid/working-hours", $user, ['days' => $days]);
|
||||
}
|
||||
|
||||
public function testEmptyBranchReportsSevenEmptyDaysAndUndefined(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertFalse($body['data']['defined'], 'شعبهٔ بدون ساعت باید «تعریفنشده» باشد، نه همیشهباز');
|
||||
self::assertSame(range(0, 6), array_map('intval', array_keys($body['data']['days'])));
|
||||
foreach ($body['data']['days'] as $ranges) {
|
||||
self::assertSame([], $ranges);
|
||||
}
|
||||
}
|
||||
|
||||
public function testFullWeekIsStoredAndReadBackIdentically(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$days = [];
|
||||
foreach (range(0, 6) as $day) {
|
||||
$days[$day] = [
|
||||
['start_minute' => 540, 'end_minute' => 780], // 09:00-13:00
|
||||
['start_minute' => 960, 'end_minute' => 1200], // 16:00-20:00
|
||||
];
|
||||
}
|
||||
|
||||
$written = $this->put($user, $address->getUuid(), $days);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($written, JSON_UNESCAPED_UNICODE));
|
||||
self::assertTrue($written['data']['defined']);
|
||||
|
||||
$read = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
|
||||
|
||||
self::assertSame($written['data']['days'], $read['data']['days']);
|
||||
self::assertSame('09:00', $read['data']['days'][0][0]['start_time']);
|
||||
self::assertSame('20:00', $read['data']['days'][0][1]['end_time']);
|
||||
self::assertSame([0, 1], array_column($read['data']['days'][0], 'sequence'));
|
||||
}
|
||||
|
||||
/** PUT قرارداد جایگزینی کامل دارد: آرایهٔ خالی یعنی شعبه بسته، نه «تغییری نده». */
|
||||
public function testEmptyPayloadClosesTheBranch(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
$this->put($user, $address->getUuid(), [3 => [['start_minute' => 600, 'end_minute' => 700]]]);
|
||||
|
||||
$body = $this->put($user, $address->getUuid(), []);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertFalse($body['data']['defined']);
|
||||
self::assertSame([], $body['data']['days'][3]);
|
||||
}
|
||||
|
||||
public function testEndBeforeStartIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->put($user, $address->getUuid(), [0 => [['start_minute' => 800, 'end_minute' => 800]]]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('end_minute', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testOverlappingRangesInOneDayAreRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->put($user, $address->getUuid(), [2 => [
|
||||
['start_minute' => 540, 'end_minute' => 780],
|
||||
['start_minute' => 700, 'end_minute' => 900],
|
||||
]]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertStringContainsString('همپوشانی', $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
/** بازهٔ چسبیده مجاز است: پایان یکی = شروع بعدی، همپوشانی نیست. */
|
||||
public function testTouchingRangesAreAccepted(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$this->put($user, $address->getUuid(), [2 => [
|
||||
['start_minute' => 540, 'end_minute' => 780],
|
||||
['start_minute' => 780, 'end_minute' => 900],
|
||||
]]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAllDayRangeIsOneRow(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->put($user, $address->getUuid(), [
|
||||
5 => [['start_minute' => 0, 'end_minute' => BranchWorkingHours::MINUTES_IN_DAY]],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $body['data']['days'][5]);
|
||||
self::assertSame('24:00', $body['data']['days'][5][0]['end_time']);
|
||||
}
|
||||
|
||||
public function testMinuteBeyondOneDayIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$this->put($user, $address->getUuid(), [1 => [['start_minute' => 0, 'end_minute' => 1441]]]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testInvalidDayKeyIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->put($user, $address->getUuid(), [7 => [['start_minute' => 0, 'end_minute' => 60]]]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('day_of_week', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
/**
|
||||
* اتمی بودن: بازهٔ نامعتبر در روز ششم نباید روزهای درستِ قبل را پاک کند.
|
||||
* بدون اعتبارسنجیِ کاملِ پیش از DELETE، این تست هفتهٔ ذخیرهشده را خالی میبیند.
|
||||
*/
|
||||
public function testInvalidLaterDayLeavesTheStoredWeekUntouched(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$valid = [];
|
||||
foreach (range(0, 6) as $day) {
|
||||
$valid[$day] = [['start_minute' => 540, 'end_minute' => 780]];
|
||||
}
|
||||
$this->put($user, $address->getUuid(), $valid);
|
||||
|
||||
$broken = $valid;
|
||||
$broken[5] = [['start_minute' => 900, 'end_minute' => 100]];
|
||||
$this->put($user, $address->getUuid(), $broken);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
$read = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
|
||||
|
||||
self::assertTrue($read['data']['defined']);
|
||||
foreach (range(0, 6) as $day) {
|
||||
self::assertCount(1, $read['data']['days'][$day], "روز $day نباید پاک شده باشد");
|
||||
}
|
||||
}
|
||||
|
||||
public function testMissingDaysFieldIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->doctorWithAddress();
|
||||
|
||||
$body = $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, ['x' => 1]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('days', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
/** آدرس محیط دیگر: ۴۰۴ نه ۴۰۳ — وجود دادهٔ محیط بیگانه لو نمیرود. */
|
||||
public function testForeignBranchIsNotFound(): void
|
||||
{
|
||||
[$doctorUser] = $this->doctorWithAddress();
|
||||
[, , $foreignAddress] = $this->clinicWithAddress();
|
||||
|
||||
$this->authJson('GET', "/api/v1/branch/{$foreignAddress->getUuid()}/working-hours", $doctorUser);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testForeignBranchCannotBeWritten(): void
|
||||
{
|
||||
[$doctorUser] = $this->doctorWithAddress();
|
||||
[, , $foreignAddress] = $this->clinicWithAddress();
|
||||
|
||||
$this->put($doctorUser, $foreignAddress->getUuid(), [0 => [['start_minute' => 0, 'end_minute' => 60]]]);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicOwnerManagesItsOwnBranch(): void
|
||||
{
|
||||
[$clinicUser, , $address] = $this->clinicWithAddress();
|
||||
|
||||
$body = $this->put($clinicUser, $address->getUuid(), [
|
||||
0 => [['start_minute' => 480, 'end_minute' => 1020]],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame('08:00', $body['data']['days'][0][0]['start_time']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user