From 9e4ee118312dd39ce78d93a3e1b6979512698418 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 13 Jul 2026 13:28:51 +0330 Subject: [PATCH] =?UTF-8?q?feat(services):=20match=20Figma=20=D8=AE=D8=AF?= =?UTF-8?q?=D9=85=D8=A7=D8=AA=20page=20in=20settings=20shell=20+=20multi-s?= =?UTF-8?q?taff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the clinic services page (sections → services) inside the settings sub-navigation shell (SettingsLayout, "خدمات" active) to match the Figma settings design. Restyle section cards to show the service count and a status toggle with edit/delete actions, and service cards with labelled price/duration and personnel chips. A service can now have multiple personnel: add an additive many-to-many ServiceItem↔ClinicStaff (staffMembers, EAGER) while keeping the legacy single `staff` column mirrored for backward compatibility. Endpoints accept `staff_uuids[]` (falling back to the legacy single `staff_uuid`) and return `staff_members[]`; the section list now reports `items_count`. Backfill-safe: pre-migration rows fall back to the single staff in toArray. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/pages/ClinicServicesPage.test.tsx | 61 ++++++++ assets/admin/pages/ClinicServicesPage.tsx | 144 ++++++++++++------ assets/admin/types/index.ts | 5 + docs/api/clinic-services.md | 13 +- migrations/Version20260713093327.php | 35 +++++ .../Controller/ClinicServiceController.php | 56 +++++-- src/ClinicService/Entity/ServiceItem.php | 61 +++++++- src/ClinicService/Entity/ServiceSection.php | 14 +- .../Repository/ServiceItemRepository.php | 28 ++++ .../ServiceItemMultiStaffTest.php | 118 ++++++++++++++ 10 files changed, 464 insertions(+), 71 deletions(-) create mode 100644 assets/admin/pages/ClinicServicesPage.test.tsx create mode 100644 migrations/Version20260713093327.php create mode 100644 tests/ClinicService/ServiceItemMultiStaffTest.php diff --git a/assets/admin/pages/ClinicServicesPage.test.tsx b/assets/admin/pages/ClinicServicesPage.test.tsx new file mode 100644 index 00000000..c94d3748 --- /dev/null +++ b/assets/admin/pages/ClinicServicesPage.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import ClinicServicesPage from './ClinicServicesPage'; + +const get = api.get as ReturnType; + +beforeEach(() => { + // FeatureGate → useSubscription only fetches for doctor/clinic/secretary roles + useAuthStore.setState({ primaryRole: 'doctor' }); + get.mockReset(); + get.mockImplementation((url: string) => { + if (url.includes('/subscription/my')) return Promise.resolve({ success: true, data: { + subscription: null, used_trial: false, + effective_plan: { name: 'professional', level: 2, max_secretaries: 10, features: { services: true } }, + } }); + if (url.includes('/payment/config')) return Promise.resolve({ success: true, data: { test_mode: true, gateways: [] } }); + if (url.includes('/service-sections')) return Promise.resolve({ success: true, data: [ + { uuid: 'sec1', name: 'کندلا ۲۰۲۱', active: true, items_count: 10 }, + ] }); + if (url.includes('/service-items/sec1')) return Promise.resolve({ success: true, data: [ + { uuid: 'it1', name: 'فول بادی', price_rials: 35_000_000, active: true, duration_minutes: 50, + staff: { uuid: 'st1', full_name: 'مریم امینی' }, + staff_members: [{ uuid: 'st1', full_name: 'مریم امینی' }, { uuid: 'st2', full_name: 'سحر رحمانی' }] }, + ] }); + if (url.includes('/staff')) return Promise.resolve({ success: true, data: [ + { uuid: 'st1', full_name: 'مریم امینی', active: true }, + { uuid: 'st2', full_name: 'سحر رحمانی', active: true }, + ] }); + return Promise.resolve({ success: true, data: [] }); + }); +}); + +describe('ClinicServicesPage (خدمات)', () => { + it('renders section cards with the service count inside the settings shell', async () => { + renderWithProviders(, { route: '/admin/clinic-services' }); + // settings shell menu + section card + expect(await screen.findByText('کندلا ۲۰۲۱')).toBeInTheDocument(); + expect(screen.getByText(/۱۰ سرویس/)).toBeInTheDocument(); + expect(screen.getByText('بخش جدید')).toBeInTheDocument(); + }); + + it('drills into a section and shows a service with all its personnel chips', async () => { + renderWithProviders(, { route: '/admin/clinic-services' }); + fireEvent.click(await screen.findByText('کندلا ۲۰۲۱')); + + expect(await screen.findByText('فول بادی')).toBeInTheDocument(); + expect(screen.getByText('مریم امینی')).toBeInTheDocument(); + expect(screen.getByText('سحر رحمانی')).toBeInTheDocument(); + expect(screen.getByText('سرویس جدید')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx index a59402e2..7f34c386 100644 --- a/assets/admin/pages/ClinicServicesPage.tsx +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -4,7 +4,9 @@ import { PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon, ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon, EllipsisHorizontalIcon, CheckCircleIcon, XCircleIcon, ChevronRightIcon, + XMarkIcon, UsersIcon, ClockIcon, } from '@heroicons/react/24/outline'; +import SettingsLayout from '../components/layout/SettingsLayout'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -12,11 +14,10 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { ServiceSection, ServiceItem, ClinicStaff } from '../types'; -import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; +import { formatRial, formatNumber, rialToToman, tomanToRial } from '../lib/utils'; import Modal from '../components/ui/Modal'; import PriceInput from '../components/ui/PriceInput'; import ConfirmDialog from '../components/ui/ConfirmDialog'; -import PageHeader from '../components/ui/PageHeader'; import ServiceTariffModal from '../components/ServiceTariffModal'; import ServiceInsuranceModal from '../components/ServiceInsuranceModal'; import SearchableSelect from '../components/ui/SearchableSelect'; @@ -26,7 +27,7 @@ const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزا const itemSchema = z.object({ name: z.string().min(1, 'نام سرویس الزامی است'), price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'), - staff_uuid: z.string().optional(), + staff_uuids: z.array(z.string()).optional(), insurance_covered: z.boolean().optional(), insurance_price_rials: z.coerce.number().min(0).optional(), duration_minutes: z.coerce.number().min(0).optional(), @@ -102,13 +103,24 @@ function ClinicServicesPageInner() { const allItems = itemsData?.data ?? EMPTY_ITEMS; const allStaff = staffData?.data ?? []; - const editingStaff = itemModal && typeof itemModal === 'object' ? itemModal.staff : null; + const sectionForm = useForm({ resolver: zodResolver(sectionSchema) }); + const itemForm = useForm({ resolver: zodResolver(itemSchema) }); + + const selectedStaffUuids = itemForm.watch('staff_uuids') ?? []; + const editingMembers = itemModal && typeof itemModal === 'object' + ? (itemModal.staff_members ?? (itemModal.staff ? [itemModal.staff] : [])) + : []; const staffOptions = allStaff - .filter((s) => s.active || s.uuid === editingStaff?.uuid) + .filter((s) => s.active || editingMembers.some((m) => m.uuid === s.uuid)) + .filter((s) => !selectedStaffUuids.includes(s.uuid)) .map((s) => ({ value: s.uuid, label: s.active ? s.full_name : `${s.full_name} (غیرفعال)`, })); + const staffNameOf = (uuid: string) => + allStaff.find((s) => s.uuid === uuid)?.full_name + ?? editingMembers.find((m) => m.uuid === uuid)?.full_name + ?? uuid; const items = allItems.filter((it) => { if (!showInactive && !it.active) return false; @@ -117,9 +129,6 @@ function ClinicServicesPageInner() { }); const activeCount = allItems.filter((i) => i.active).length; - const sectionForm = useForm({ resolver: zodResolver(sectionSchema) }); - const itemForm = useForm({ resolver: zodResolver(itemSchema) }); - const createSection = useMutation({ mutationFn: (body: SectionForm) => api.post('/api/v1/service-section', body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['service-sections'] }); setSectionModal(null); sectionForm.reset(); toast.success('بخش ایجاد شد'); }, @@ -195,7 +204,7 @@ function ClinicServicesPageInner() { itemForm.reset({ name: item.name, price_rials: rialToToman(item.price_rials), - staff_uuid: item.staff?.uuid ?? '', + staff_uuids: (item.staff_members ?? (item.staff ? [item.staff] : [])).map((s) => s.uuid), insurance_covered: item.insurance_covered ?? false, insurance_price_rials: rialToToman(item.insurance_price_rials ?? 0), duration_minutes: item.duration_minutes ?? undefined, @@ -204,14 +213,12 @@ function ClinicServicesPageInner() { }; const openCreateItem = () => { - itemForm.reset({ name: '', price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined }); + itemForm.reset({ name: '', price_rials: 0, staff_uuids: [], insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined }); setItemModal('create'); }; return ( <> - - {!selectedSection ? ( /* ═════════ نمای بخش‌ها ═════════ */ <> @@ -225,37 +232,46 @@ function ClinicServicesPageInner() { {sectionsLoading ? (
در حال بارگذاری...
) : sections.length === 0 ? ( -
- -
بخشی ثبت نشده است
- +
+ +
برای اضافه کردن بخش روی دکمه بخش جدید کلیک نمایید.
) : ( -
+
{sections.map((s) => (
setSelectedSection(s)} style={{ - cursor: 'pointer', background: 'var(--surface)', borderRadius: 8, + cursor: 'pointer', background: 'var(--surface)', borderRadius: 'var(--r)', boxShadow: '0 1px 24.8px rgba(204,204,204,0.18)', - border: '1px solid var(--border)', padding: '18px 14px', + border: '1px solid var(--border)', padding: '16px 16px 12px', opacity: s.active ? 1 : 0.62, }} > -
{s.name}
-
e.stopPropagation()}> - -
@@ -350,12 +366,19 @@ function ClinicServicesPageInner() { )} - +
+ + قیمت پایه: + + {formatRial(item.price_rials)} +
- زمان متوسط: + + زمان متوسط: + {item.duration_minutes - ? {Number(item.duration_minutes).toLocaleString('fa-IR')} دقیقه + ? {formatNumber(Number(item.duration_minutes))} دقیقه : }
@@ -363,12 +386,16 @@ function ClinicServicesPageInner() { )} -
- پرسنل: +
+ + پرسنل: +
- {item.staff - ? {item.staff.full_name} - : } + {(item.staff_members && item.staff_members.length > 0) + ? item.staff_members.map((m) => {m.full_name}) + : item.staff + ? {item.staff.full_name} + : }
@@ -460,13 +487,34 @@ function ClinicServicesPageInner() { itemForm.setValue('staff_uuid', v != null ? String(v) : '')} - placeholder="انتخاب (اختیاری)" - noOptionsMessage="پرسنلی ثبت نشده" + value={''} + onChange={(v) => { + if (v != null) itemForm.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); + }} + placeholder="افزودن پرسنل (اختیاری)" + noOptionsMessage="پرسنلی باقی نمانده" height={42} - isClearable /> + {selectedStaffUuids.length > 0 && ( +
+ {selectedStaffUuids.map((uuid) => ( + + {staffNameOf(uuid)} + + + ))} +
+ )}
@@ -563,8 +611,10 @@ function ClinicServicesPageInner() { export default function ClinicServicesPage() { return ( - - - + + + + + ); } diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index db673b6c..f7dcc5d6 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -421,13 +421,18 @@ export interface ServiceSection { uuid: string; name: string; active: boolean; + /** number of services in this section (only present in the list endpoint) */ + items_count?: number; } export interface ServiceItem { uuid: string; name: string; price_rials: number; + /** primary staff (first member) — kept for backward compatibility */ staff: { uuid: string; full_name: string } | null; + /** all personnel assigned to this service */ + staff_members?: { uuid: string; full_name: string }[]; active: boolean; insurance_covered?: boolean; insurance_price_rials?: number | null; diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index c7e85790..1537e7b9 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.md @@ -23,6 +23,7 @@ "entity_id": 5, "name": "آزمایشگاه", "active": true, + "items_count": 10, "created_at": 1718000000, "updated_at": 1718000000 } @@ -30,6 +31,8 @@ } ``` +> `items_count` تعداد سرویس‌های همان بخش است (فقط در این endpoint لیستی برگردانده می‌شود). + --- ## POST /api/v1/service-section @@ -87,6 +90,11 @@ "section_uuid": "...", "staff_uuid": "...", "staff_name": "علی محمدی", + "staff": { "uuid": "...", "full_name": "علی محمدی" }, + "staff_members": [ + { "uuid": "...", "full_name": "علی محمدی" }, + { "uuid": "...", "full_name": "سحر رحمانی" } + ], "name": "رادیوگرافی مستقیم", "price_rials": 500000, "active": true, @@ -126,7 +134,8 @@ | section_uuid | UUID | ✅ | | name | string | ✅ | | price_rials | integer | ❌ (پیش‌فرض 0) — «قیمت پایه» | -| staff_uuid | UUID | ❌ — پرسنل مسئول | +| staff_uuids | UUID[] | ❌ — پرسنل مسئول (چند نفر). ترجیح داده می‌شود | +| staff_uuid | UUID | ❌ — legacy تک‌پرسنل (اگر `staff_uuids` نباشد استفاده می‌شود) | | insurance_covered | boolean | ❌ (پیش‌فرض false) — آیا خدمت شامل بیمه می‌شود | | insurance_price_rials | integer\|null | ❌ — سهم/قیمت بیمار با بیمه | | duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) | @@ -155,7 +164,7 @@ > فیلد `duration_minutes` (زمان متوسط، دقیقه) در پاسخِ `toArray` و در ساخت/ویرایش پشتیبانی می‌شود؛ `""`/`null` آن را پاک می‌کند. -> `staff_uuid` باید به پرسنل متعلق به همان tenant (`entity_type`/`entity_id` کاربر) اشاره کند؛ ربط‌دادن پرسنل tenant دیگر → `422 ERR_VALIDATION_001` (`field: staff_uuid`). همین قید روی `POST /service-item` نیز اعمال می‌شود. +> **پرسنل چندنفره:** یک سرویس می‌تواند چند پرسنل داشته باشد. `staff_uuids` (آرایه) ترجیح داده می‌شود؛ در نبود آن، `staff_uuid` تک‌نفره به‌صورت backward-compatible پذیرفته می‌شود. پاسخ همیشه `staff_members[]` (کامل) و `staff`/`staff_uuid`/`staff_name` (نفر اول، برای سازگاری) را برمی‌گرداند. هر پرسنل باید متعلق به همان tenant (`entity_type`/`entity_id`) باشد؛ در غیر این صورت → `422 ERR_VALIDATION_001` (`field: staff_uuids`). همین قید روی `POST /service-item` نیز اعمال می‌شود. --- diff --git a/migrations/Version20260713093327.php b/migrations/Version20260713093327.php new file mode 100644 index 00000000..cf356d37 --- /dev/null +++ b/migrations/Version20260713093327.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE service_item_staff (service_item_id INT NOT NULL, clinic_staff_id INT NOT NULL, INDEX IDX_FE98D1F6DDEB00C2 (service_item_id), INDEX IDX_FE98D1F6BE704C14 (clinic_staff_id), PRIMARY KEY (service_item_id, clinic_staff_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE service_item_staff ADD CONSTRAINT FK_FE98D1F6DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE service_item_staff ADD CONSTRAINT FK_FE98D1F6BE704C14 FOREIGN KEY (clinic_staff_id) REFERENCES clinic_staff (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE service_item_staff DROP FOREIGN KEY FK_FE98D1F6DDEB00C2'); + $this->addSql('ALTER TABLE service_item_staff DROP FOREIGN KEY FK_FE98D1F6BE704C14'); + $this->addSql('DROP TABLE service_item_staff'); + } +} diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index ec7abc8f..23ea14ec 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -50,9 +50,12 @@ class ClinicServiceController extends BaseController [$entityType, $entityId] = $this->resolveEntity($user); $this->assertServicesGate($entityType, $entityId); + $sectionEntities = $this->sectionRepo->findByEntity($entityType, $entityId); + $counts = $this->itemRepo->countBySections($sectionEntities); + $sections = array_map( - fn(ServiceSection $s) => $s->toArray(), - $this->sectionRepo->findByEntity($entityType, $entityId) + fn(ServiceSection $s) => $s->toArray($counts[$s->getUuid()] ?? 0), + $sectionEntities ); return $this->success($sections); @@ -158,12 +161,9 @@ class ClinicServiceController extends BaseController $item = new ServiceItem($section, $name, (int) ($data['price_rials'] ?? 0)); - if (!empty($data['staff_uuid'])) { - $staff = $this->staffRepo->findByUuid($data['staff_uuid']); - if ($staff === null || $staff->getEntityType() !== $entityType || $staff->getEntityId() !== $entityId) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پرسنل انتخاب‌شده متعلق به شما نیست', 422, 'staff_uuid'); - } - $item->setStaff($staff); + $staffError = $this->applyStaffMembers($item, $data, $entityType, $entityId); + if ($staffError !== null) { + return $staffError; } if (isset($data['insurance_covered'])) { @@ -201,15 +201,11 @@ class ClinicServiceController extends BaseController if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); } if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); $priceChanged = true; } if (isset($data['active'])) { $item->setActive((bool) $data['active']); } - if (array_key_exists('staff_uuid', $data)) { - $staff = null; - if ($data['staff_uuid']) { - $staff = $this->staffRepo->findByUuid($data['staff_uuid']); - if ($staff === null || $staff->getEntityType() !== $entityType || $staff->getEntityId() !== $entityId) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پرسنل انتخاب‌شده متعلق به شما نیست', 422, 'staff_uuid'); - } + if (array_key_exists('staff_uuids', $data) || array_key_exists('staff_uuid', $data)) { + $staffError = $this->applyStaffMembers($item, $data, $entityType, $entityId); + if ($staffError !== null) { + return $staffError; } - $item->setStaff($staff); } if (isset($data['insurance_covered'])) { $item->setInsuranceCovered((bool) $data['insurance_covered']); @@ -312,6 +308,34 @@ class ClinicServiceController extends BaseController // ── Helpers ────────────────────────────────────────────────────────────── + /** + * Resolve and assign the service's personnel from the payload, scoped to the + * tenant. Accepts `staff_uuids` (array, preferred) or the legacy single + * `staff_uuid`. Returns a 422 JsonResponse if any staff is missing or not + * owned by the tenant, otherwise null. + */ + private function applyStaffMembers(ServiceItem $item, array $data, string $entityType, int $entityId): ?JsonResponse + { + $uuids = []; + if (array_key_exists('staff_uuids', $data) && is_array($data['staff_uuids'])) { + $uuids = $data['staff_uuids']; + } elseif (!empty($data['staff_uuid'])) { + $uuids = [$data['staff_uuid']]; + } + + $members = []; + foreach (array_values(array_unique(array_filter($uuids))) as $uuid) { + $staff = $this->staffRepo->findByUuid((string) $uuid); + if ($staff === null || $staff->getEntityType() !== $entityType || $staff->getEntityId() !== $entityId) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پرسنل انتخاب‌شده متعلق به شما نیست', 422, 'staff_uuids'); + } + $members[] = $staff; + } + + $item->setStaffMembers($members); + return null; + } + private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) { diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index 169880f5..f5c31922 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -4,6 +4,8 @@ namespace App\ClinicService\Entity; use App\ClinicService\Repository\ServiceItemRepository; use App\Staff\Entity\ClinicStaff; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; @@ -23,10 +25,21 @@ class ServiceItem #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] private ServiceSection $section; + // Legacy single-staff column, kept for backward compatibility with existing + // consumers (reception/session). Mirrors the first entry of $staffMembers. #[ORM\ManyToOne(targetEntity: ClinicStaff::class)] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] private ?ClinicStaff $staff = null; + /** + * @var Collection personnel assigned to this service. + * EAGER so hydration always populates the typed property (avoids the + * "accessed before initialization" pitfall on lazy typed collections). + */ + #[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'service_item_staff')] + private Collection $staffMembers; + #[ORM\Column(type: 'string', length: 200)] private string $name; @@ -59,6 +72,7 @@ class ServiceItem $this->priceRials = $priceRials; $this->createdAt = time(); $this->updatedAt = time(); + $this->staffMembers = new ArrayCollection(); } public function getId(): ?int { return $this->id; } @@ -75,6 +89,33 @@ class ServiceItem public function getUpdatedAt(): int { return $this->updatedAt; } public function setStaff(?ClinicStaff $staff): self { $this->staff = $staff; $this->updatedAt = time(); return $this; } + + /** @return Collection */ + public function getStaffMembers(): Collection + { + // Doctrine hydrates without the constructor; guard the typed property. + return $this->staffMembers ??= new ArrayCollection(); + } + + /** + * Replace the assigned personnel. Also mirrors the first member into the + * legacy single {@see $staff} column so back-compat consumers keep working. + * + * @param ClinicStaff[] $members + */ + public function setStaffMembers(array $members): self + { + $collection = $this->getStaffMembers(); + $collection->clear(); + foreach ($members as $m) { + if (!$collection->contains($m)) { + $collection->add($m); + } + } + $this->staff = $members[0] ?? null; + $this->updatedAt = time(); + return $this; + } public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; } public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; } public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; } @@ -84,14 +125,26 @@ class ServiceItem public function toArray(): array { + // Prefer the multi-staff collection; fall back to the legacy single + // staff so rows created before the migration still expose personnel. + $members = array_values($this->getStaffMembers()->toArray()); + if (empty($members) && $this->staff !== null) { + $members = [$this->staff]; + } + $primary = $members[0] ?? null; + return [ 'uuid' => $this->uuid, 'section_uuid' => $this->section->getUuid(), - 'staff_uuid' => $this->staff?->getUuid(), - 'staff_name' => $this->staff?->getFullName(), - 'staff' => $this->staff !== null - ? ['uuid' => $this->staff->getUuid(), 'full_name' => $this->staff->getFullName()] + 'staff_uuid' => $primary?->getUuid(), + 'staff_name' => $primary?->getFullName(), + 'staff' => $primary !== null + ? ['uuid' => $primary->getUuid(), 'full_name' => $primary->getFullName()] : null, + 'staff_members' => array_map( + fn(ClinicStaff $s) => ['uuid' => $s->getUuid(), 'full_name' => $s->getFullName()], + $members + ), 'name' => $this->name, 'price_rials' => $this->priceRials, 'active' => $this->active, diff --git a/src/ClinicService/Entity/ServiceSection.php b/src/ClinicService/Entity/ServiceSection.php index e2c5b12f..5d2b3de8 100644 --- a/src/ClinicService/Entity/ServiceSection.php +++ b/src/ClinicService/Entity/ServiceSection.php @@ -65,9 +65,13 @@ class ServiceSection public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; } public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; } - public function toArray(): array + /** + * @param int|null $itemsCount when provided, added as `items_count` + * (number of services in this section) + */ + public function toArray(?int $itemsCount = null): array { - return [ + $data = [ 'uuid' => $this->uuid, 'entity_type' => $this->entityType, 'entity_id' => $this->entityId, @@ -76,5 +80,11 @@ class ServiceSection 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, ]; + + if ($itemsCount !== null) { + $data['items_count'] = $itemsCount; + } + + return $data; } } diff --git a/src/ClinicService/Repository/ServiceItemRepository.php b/src/ClinicService/Repository/ServiceItemRepository.php index bdd86632..3f7f1574 100644 --- a/src/ClinicService/Repository/ServiceItemRepository.php +++ b/src/ClinicService/Repository/ServiceItemRepository.php @@ -29,6 +29,34 @@ class ServiceItemRepository extends ServiceEntityRepository ->getResult(); } + /** + * Count services per section in a single query (avoids N+1 in the section list). + * + * @param ServiceSection[] $sections + * @return array section uuid → number of services + */ + public function countBySections(array $sections): array + { + if (empty($sections)) { + return []; + } + + $rows = $this->createQueryBuilder('i') + ->select('s.uuid AS uuid, COUNT(i.id) AS cnt') + ->join('i.section', 's') + ->where('i.section IN (:sections)') + ->setParameter('sections', $sections) + ->groupBy('s.uuid') + ->getQuery() + ->getScalarResult(); + + $counts = []; + foreach ($rows as $row) { + $counts[$row['uuid']] = (int) $row['cnt']; + } + return $counts; + } + public function save(ServiceItem $item): void { $this->getEntityManager()->persist($item); diff --git a/tests/ClinicService/ServiceItemMultiStaffTest.php b/tests/ClinicService/ServiceItemMultiStaffTest.php new file mode 100644 index 00000000..ae282636 --- /dev/null +++ b/tests/ClinicService/ServiceItemMultiStaffTest.php @@ -0,0 +1,118 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + $s1 = new ClinicStaff('doctor', $doctor->getId(), 'مریم امینی'); + $s2 = new ClinicStaff('doctor', $doctor->getId(), 'سحر رحمانی'); + $this->em->persist($s1); + $this->em->persist($s2); + $this->em->flush(); + + $section = new ServiceSection('doctor', $doctor->getId(), 'کندلا'); + $item = new ServiceItem($section, 'فول بادی', 3_500_000); + $item->setStaffMembers([$s1, $s2]); // staff already managed (as in the controller) + $this->em->persist($section); + $this->em->persist($item); + $this->em->flush(); + $sectionUuid = $section->getUuid(); + $itemUuid = $item->getUuid(); + $this->em->clear(); + + /** @var ServiceItemRepository $repo */ + $repo = static::getContainer()->get(ServiceItemRepository::class); + $reloaded = $repo->findByUuid($itemUuid); // uuid — db_test is never reset + self::assertNotNull($reloaded); + self::assertCount(2, $reloaded->getStaffMembers()); + + $arr = $reloaded->toArray(); + self::assertCount(2, $arr['staff_members']); + // primary (legacy single) mirrors the first member + self::assertSame('مریم امینی', $arr['staff']['full_name']); + + // batch count + $sectionEntity = $reloaded->getSection(); + $counts = $repo->countBySections([$sectionEntity]); + self::assertSame(1, $counts[$sectionUuid]); + + // endpoint exposes items_count + $resp = $this->authJson('GET', '/api/v1/service-sections', $owner); + self::assertSame(200, $this->responseCode()); + $row = $resp['data'][0] ?? []; + self::assertSame(1, $row['items_count'] ?? null); + } + + public function testCreateAndUpdateItemWithMultipleStaffViaApi(): void + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + $a = new ClinicStaff('doctor', $doctor->getId(), 'الف'); + $b = new ClinicStaff('doctor', $doctor->getId(), 'ب'); + $c = new ClinicStaff('doctor', $doctor->getId(), 'ج'); + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش'); + foreach ([$a, $b, $c, $section] as $e) { $this->em->persist($e); } + $this->em->flush(); + + // create with two staff members + $created = $this->authJson('POST', '/api/v1/service-item', $owner, [ + 'section_uuid' => $section->getUuid(), + 'name' => 'سرویس چندنفره', + 'price_rials' => 1_000, + 'staff_uuids' => [$a->getUuid(), $b->getUuid()], + ]); + self::assertSame(201, $this->responseCode()); + self::assertCount(2, $created['data']['staff_members']); + + // update: replace with a single different staff + $itemUuid = $created['data']['uuid']; + $updated = $this->authJson('PATCH', '/api/v1/service-item/' . $itemUuid, $owner, [ + 'staff_uuids' => [$c->getUuid()], + ]); + self::assertSame(200, $this->responseCode()); + self::assertCount(1, $updated['data']['staff_members']); + self::assertSame('ج', $updated['data']['staff_members'][0]['full_name']); + } + + public function testLegacySingleStaffFallsBackInToArray(): void + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + $staff = new ClinicStaff('doctor', $doctor->getId(), 'کاربر قدیمی'); + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش'); + $item = new ServiceItem($section, 'خدمت قدیمی'); + $item->setStaff($staff); // legacy single-staff path, no members + $this->em->persist($staff); + $this->em->persist($section); + $this->em->persist($item); + $this->em->flush(); + + $arr = $item->toArray(); + self::assertCount(1, $arr['staff_members']); + self::assertSame('کاربر قدیمی', $arr['staff_members'][0]['full_name']); + } +}