From 581a553516fa4617518b87241b0cc84c0bcf6fe6 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 3 Aug 2026 14:57:47 +0330 Subject: [PATCH] refactor(resource): drop the branch domain from resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resources never needed a branch: devices and rooms belong to the clinic itself, and the picker always had exactly one option — a mandatory click that decided nothing. - `address_uuid` is now optional on resource and pool creation; when it is missing the environment's own address is used. Clients still sending it keep working. - The panel no longer asks for or displays a branch anywhere: resource form, list column and filter, pool form and column, detail row, and the resource-first booking page. - Availability no longer gates on `doctor_addresses.active`. That gate shut down every device of a clinic whose address row happened to be inactive, with a message no page in the panel could act on — no endpoint writes that column at all. `address_id` stays on the resource: the timezone and the tenant pair are derived from it. It is simply no longer the user's decision. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/appointments/TurnsTimeline.tsx | 1 - .../resources/ResourceFormModal.tsx | 31 ++--- .../resources/ResourceServicesPanel.tsx | 2 +- assets/admin/hooks/useResourceBooking.ts | 4 +- assets/admin/hooks/useResources.ts | 5 +- assets/admin/pages/ResourceBookingPage.tsx | 23 ++-- .../admin/pages/ResourceDetailPage.test.tsx | 10 +- assets/admin/pages/ResourceDetailPage.tsx | 6 +- assets/admin/pages/ResourcePoolsPage.tsx | 40 ++---- assets/admin/pages/ResourcesPage.test.tsx | 9 +- assets/admin/pages/ResourcesPage.tsx | 17 +-- docs/api/resource-calendar.md | 2 +- docs/api/resource.md | 29 +++- .../Controller/ResourceController.php | 10 +- .../Controller/ResourcePoolController.php | 9 +- .../Service/ResourceAvailabilityService.php | 10 +- src/Resource/Service/ResourceContext.php | 25 ++++ src/Resource/ValueObject/DayAvailability.php | 2 +- tests/Resource/ResourceWithoutBranchTest.php | 128 ++++++++++++++++++ 19 files changed, 246 insertions(+), 117 deletions(-) create mode 100644 tests/Resource/ResourceWithoutBranchTest.php diff --git a/assets/admin/components/appointments/TurnsTimeline.tsx b/assets/admin/components/appointments/TurnsTimeline.tsx index 3834527a..ebbbac49 100644 --- a/assets/admin/components/appointments/TurnsTimeline.tsx +++ b/assets/admin/components/appointments/TurnsTimeline.tsx @@ -193,7 +193,6 @@ const EMPTY_REASON_TEXT: Record = { tenant_holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات مجموعه، این روز تعطیل ثبت شده' }, exception: { title: 'استثنای تقویم', hint: 'کل ساعت کاری این روز با استثنای منبع پوشیده شده است' }, resource_inactive: { title: 'این منبع غیرفعال است', hint: 'برای نوبت‌دهی، منبع را از صفحهٔ «منابع» فعال کنید' }, - address_inactive: { title: 'شعبهٔ این منبع غیرفعال است', hint: 'تا وقتی شعبه غیرفعال باشد، منابعش نوبت نمی‌گیرند' }, }; export default function TurnsTimeline({ diff --git a/assets/admin/components/resources/ResourceFormModal.tsx b/assets/admin/components/resources/ResourceFormModal.tsx index eb896997..51e8c43c 100644 --- a/assets/admin/components/resources/ResourceFormModal.tsx +++ b/assets/admin/components/resources/ResourceFormModal.tsx @@ -3,7 +3,7 @@ import Modal from '../ui/Modal'; import { useQuery } from '@tanstack/react-query'; import SearchableSelect from '../ui/SearchableSelect'; import { api, type ApiResponse } from '../../lib/api'; -import type { Branch, ClinicResource, ResourcePayload, ResourceType } from '../../types'; +import type { ClinicResource, ResourcePayload, ResourceType } from '../../types'; import { useResourceDetail } from '../../hooks/useResources'; import { formatNumber } from '../../lib/utils'; @@ -15,7 +15,6 @@ type AttributeRow = { key: string; value: string }; interface Props { open: boolean; resource: ClinicResource | null; - addresses: Branch[]; types: ResourceType[]; saving: boolean; onClose: () => void; @@ -23,7 +22,7 @@ interface Props { } export default function ResourceFormModal({ - open, resource, addresses, types, saving, onClose, onSave, + open, resource, types, saving, onClose, onSave, }: Props) { // شمار نوبت‌های آینده فقط برای منبعِ موجود معنا دارد و فقط وقتی مودال باز است. const { upcomingAppointments: upcoming } = useResourceDetail(open ? resource?.uuid : undefined); @@ -39,7 +38,6 @@ export default function ResourceFormModal({ const doctorsLoading = doctorsQuery.isLoading; const [name, setName] = useState(''); - const [addressUuid, setAddressUuid] = useState(null); const [typeUuid, setTypeUuid] = useState(null); const [supervisorUuid, setSupervisorUuid] = useState(null); const [capacity, setCapacity] = useState('1'); @@ -51,7 +49,6 @@ export default function ResourceFormModal({ useEffect(() => { if (!open) return; setName(resource?.name ?? ''); - setAddressUuid(resource?.address_uuid ?? null); setTypeUuid(resource?.type_uuid ?? null); setSupervisorUuid(resource?.supervisor?.uuid ?? null); setCapacity(String(resource?.capacity ?? 1)); @@ -68,7 +65,7 @@ export default function ResourceFormModal({ const invalid = name.trim() === '' || !supervisorUuid || - (!isEdit && (!addressUuid || !typeUuid)) || + (!isEdit && !typeUuid) || !Number.isFinite(parsedCapacity) || parsedCapacity < 1; @@ -88,10 +85,10 @@ export default function ResourceFormModal({ supervisor_doctor_uuid: supervisorUuid!, }; - // شعبه و نوع فقط هنگام ساخت فرستاده می‌شوند؛ جفت محیطِ منبع از آدرس مشتق شده و - // جابه‌جا کردنش یعنی همان منبع در محیط دیگری ظاهر شود. + // نوع فقط هنگام ساخت فرستاده می‌شود؛ جفت محیطِ منبع از آدرسِ محیط مشتق می‌شود و + // جابه‌جا کردنش یعنی همان منبع در محیط دیگری ظاهر شود. آدرس پرسیده نمی‌شود: + // منابع دامنهٔ شعبه ندارند و سرور آدرسِ خودِ کلینیک را برمی‌دارد. if (!isEdit) { - payload.address_uuid = addressUuid!; payload.type_uuid = typeUuid!; } @@ -106,16 +103,6 @@ export default function ResourceFormModal({
- - ({ value: b.uuid, label: b.name || 'بدون نام' }))} - value={addressUuid} - onChange={(v) => setAddressUuid(v ? String(v) : null)} - placeholder="شعبه را انتخاب کنید" - isDisabled={isEdit} - height={38} - /> - ({ value: t.uuid, label: t.name }))} @@ -126,8 +113,8 @@ export default function ResourceFormModal({ height={38} /> - {/* ناظر برخلاف شعبه و نوع در ویرایش هم قابل تغییر است: پزشکِ مسئولِ یک - دستگاه عوض می‌شود، ولی محیطِ منبع نه. */} + {/* ناظر برخلاف نوع در ویرایش هم قابل تغییر است: پزشکِ مسئولِ یک دستگاه + عوض می‌شود، ولی محیطِ منبع نه. */} ({ value: d.uuid, label: d.name }))} @@ -142,7 +129,7 @@ export default function ResourceFormModal({ {isEdit && (

- شعبه و نوع منبع پس از ساخت تغییر نمی‌کنند؛ برای جابه‌جایی، منبع تازه بسازید. + نوع منبع پس از ساخت تغییر نمی‌کند؛ برای تغییرش، منبع تازه بسازید.

)} diff --git a/assets/admin/components/resources/ResourceServicesPanel.tsx b/assets/admin/components/resources/ResourceServicesPanel.tsx index 12fa9441..1eceba51 100644 --- a/assets/admin/components/resources/ResourceServicesPanel.tsx +++ b/assets/admin/components/resources/ResourceServicesPanel.tsx @@ -36,7 +36,7 @@ interface Props { const SOURCE_LABELS: Record = { resource_option: 'همین منبع', resource_service: 'منبع، روی سرویس والد', - branch: 'شعبه', + branch: 'تنظیم محل نوبت‌دهی', service_default: 'پیش‌فرض سرویس', }; diff --git a/assets/admin/hooks/useResourceBooking.ts b/assets/admin/hooks/useResourceBooking.ts index b24a67d4..2d64df87 100644 --- a/assets/admin/hooks/useResourceBooking.ts +++ b/assets/admin/hooks/useResourceBooking.ts @@ -47,8 +47,8 @@ export interface HoldResult { } export const REASON_LABELS: Record = { - no_capacity_in_range: 'در این بازه هیچ ظرفیتی نیست — بازه را بزرگ‌تر کنید یا شعبهٔ دیگری را امتحان کنید.', - no_working_hours: 'شعبه در این بازه ساعت کاری ندارد.', + no_capacity_in_range: 'در این بازه هیچ ظرفیتی نیست — بازه را بزرگ‌تر کنید یا سرویس دیگری را امتحان کنید.', + no_working_hours: 'در این بازه ساعت کاری تعریف نشده است.', no_eligible_resource: 'هیچ منبعی شرایط بخش‌های این خدمت را ندارد.', }; diff --git a/assets/admin/hooks/useResources.ts b/assets/admin/hooks/useResources.ts index 8f349842..73436a9e 100644 --- a/assets/admin/hooks/useResources.ts +++ b/assets/admin/hooks/useResources.ts @@ -6,7 +6,7 @@ import type { } from '../types'; /** - * منابع: هر چیزی که ممکن است اشغال باشد. هر منبع مال یک شعبه است، و شعبه همان + * منابع: هر چیزی که ممکن است اشغال باشد. هر منبع مال محیط جاری است؛ لنگرش همان * آدرس محل نوبت‌دهی است — پس فیلترها با `address_uuid` کار می‌کنند نه `branch_id`. */ const RESOURCES_KEY = 'resources'; @@ -203,7 +203,8 @@ export function useResourcePools() { }); const create = useMutation({ - mutationFn: (d: { address_uuid: string; type_uuid: string; name: string }) => + // آدرس فرستاده نمی‌شود: منابع دامنهٔ شعبه ندارند و سرور آدرسِ خودِ محیط را برمی‌دارد. + mutationFn: (d: { type_uuid: string; name: string }) => api.post>('/api/v1/resource-pools', d), onSuccess: () => { toast.success('استخر افزوده شد'); invalidate(); }, onError: (e) => fail(e, 'افزودن استخر ناموفق بود'), diff --git a/assets/admin/pages/ResourceBookingPage.tsx b/assets/admin/pages/ResourceBookingPage.tsx index 9774d7cc..6b010e8d 100644 --- a/assets/admin/pages/ResourceBookingPage.tsx +++ b/assets/admin/pages/ResourceBookingPage.tsx @@ -33,6 +33,13 @@ export default function ResourceBookingPage() { const navigate = useNavigate(); const [params] = useSearchParams(); const { addresses } = useAddresses(); + /** + * محل نوبت‌دهی از خودِ محیط می‌آید، نه از یک انتخابگر. + * + * منابع دامنهٔ شعبه ندارند: دستگاه و اتاق مالِ همین کلینیک‌اند و آن select همیشه یک + * گزینه داشت — یک کلیک اجباری که هیچ تصمیمی نبود. + */ + const branchUuid = addresses[0]?.uuid ?? ''; const { items: services } = useAllServiceItems(); const { create, release, confirm, rebook } = useHold(); @@ -46,7 +53,6 @@ export default function ResourceBookingPage() { const rebookUuid = params.get('rebook'); const [serviceUuid, setServiceUuid] = useState(''); - const [branchUuid, setBranchUuid] = useState(''); const [days, setDays] = useState('7'); const [searching, setSearching] = useState(false); @@ -96,7 +102,7 @@ export default function ResourceBookingPage() { /** * گزینه‌های جایگزین یک نقش: منابعی که در **همین زمان** پیشنهاد شده‌اند. * - * فهرست کاملِ منابع شعبه اینجا غلط است — منبعی که موتور برای این زمان نداده، آزاد + * فهرست کاملِ منابع محیط اینجا غلط است — منبعی که موتور برای این زمان نداده، آزاد * نبوده، و نشان دادنش یعنی اپراتور چیزی انتخاب کند که ۴۰۹ می‌گیرد. */ const optionsFor = (role: string): { value: string; label: string }[] => @@ -154,19 +160,6 @@ export default function ResourceBookingPage() { />
-
- - { - setBranchUuid(String(v ?? '')); - setPickedSlot(null); - }} - options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))} - placeholder="انتخاب شعبه" - /> -
-
{ mockApi(); renderPage(); - await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument()); expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument(); expect(screen.getByText('کار با لیزر · 4')).toBeInTheDocument(); }); @@ -193,7 +193,7 @@ describe('ResourceDetailPage', () => { const user = userEvent.setup(); renderPage(); - await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument()); await user.click(screen.getByRole('button', { name: 'دسته‌بندی‌ها' })); await waitFor(() => expect(screen.getByText(/فقط انتخاب می‌شود/)).toBeInTheDocument()); @@ -221,7 +221,7 @@ describe('ResourceDetailPage', () => { const user = userEvent.setup(); renderPage(); - await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument()); await user.click(screen.getByRole('button', { name: 'غیرفعال‌سازی موقت' })); expect(await screen.findByText(/یک بازهٔ مشخص را می‌بندد/)).toBeInTheDocument(); @@ -238,7 +238,7 @@ describe('ResourceDetailPage', () => { mockApi(); renderPage(); - await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument()); // منبعِ نمونه فعال است، پس اقدام «غیرفعال کردن» پیشنهاد می‌شود. expect(screen.getByRole('button', { name: 'غیرفعال کردن' })).toBeInTheDocument(); @@ -252,7 +252,7 @@ describe('ResourceDetailPage', () => { const user = userEvent.setup(); renderPage(); - await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument()); await user.click(screen.getByRole('button', { name: 'غیرفعال کردن' })); expect(await screen.findByText(/در جستجوی وقت و رزرو نوبت ظاهر نمی‌شود/)).toBeInTheDocument(); diff --git a/assets/admin/pages/ResourceDetailPage.tsx b/assets/admin/pages/ResourceDetailPage.tsx index 3f3f6d5a..7132e2bd 100644 --- a/assets/admin/pages/ResourceDetailPage.tsx +++ b/assets/admin/pages/ResourceDetailPage.tsx @@ -13,7 +13,6 @@ import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesP import ResourceBlocksPanel from '../components/resources/ResourceBlocksPanel'; import { useUrlState } from '../hooks/useUrlState'; import { usePermissions } from '../hooks/usePermissions'; -import { useAddresses } from '../hooks/useAddresses'; import { useResourceDetail, useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources'; import { useAllServiceItems } from '../hooks/useServiceCatalog'; import type { ClinicResource } from '../types'; @@ -48,7 +47,6 @@ export default function ResourceDetailPage() { const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId; const { resource, loading } = useResourceDetail(resourceUuid); - const { addresses } = useAddresses(); const { types } = useResourceTypes(); const { skills } = useSkills(); const { update, setSkills, setCategories } = useResources(); @@ -74,7 +72,7 @@ export default function ResourceDetailPage() {
setEditOpen(false)} @@ -216,7 +213,6 @@ function InfoTab({ resource, canUpdate, toggling, onToggleActive }: { return (
- {resource.address_name || '—'} {resource.type_name} {resource.capacity} نفر diff --git a/assets/admin/pages/ResourcePoolsPage.tsx b/assets/admin/pages/ResourcePoolsPage.tsx index 626daf17..17a18615 100644 --- a/assets/admin/pages/ResourcePoolsPage.tsx +++ b/assets/admin/pages/ResourcePoolsPage.tsx @@ -8,7 +8,6 @@ import SearchableSelect from '../components/ui/SearchableSelect'; import { ActiveBadge } from '../components/ui/StatusBadge'; import { useUrlState } from '../hooks/useUrlState'; import { usePermissions } from '../hooks/usePermissions'; -import { useAddresses } from '../hooks/useAddresses'; import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources'; import type { ResourcePool } from '../types'; import ResourcesSubNav from '../components/resources/ResourcesSubNav'; @@ -16,12 +15,11 @@ import ResourcesSubNav from '../components/resources/ResourcesSubNav'; /** * استخر منابع — گروهی از منابع که جایگزین کامل یکدیگرند. * - * اعضا باید هم‌شعبه و هم‌نوعِ خودِ استخر باشند؛ سرور این را اجبار می‌کند و فرم هم + * اعضا باید هم‌نوعِ خودِ استخر باشند؛ سرور این را اجبار می‌کند و فرم هم * فهرست انتخاب را به همان‌ها محدود می‌کند تا کاربر به خطای ۴۲۲ نخورد. */ export default function ResourcePoolsPage() { const { pools, loading, create, update, remove, setMembers } = useResourcePools(); - const { addresses } = useAddresses(); const { types } = useResourceTypes(); const { can } = usePermissions(); const canUpdate = can('appointment_settings', 'update'); @@ -47,7 +45,6 @@ export default function ResourcePoolsPage() {
), }, - { key: 'address_name', header: 'شعبه', render: (p) => {p.address_name || '—'} }, { key: 'members', header: 'اعضا', @@ -69,7 +66,7 @@ export default function ResourcePoolsPage() {
setCreating(false)} @@ -147,27 +143,24 @@ export default function ResourcePoolsPage() { } function CreatePoolModal({ - open, addresses, types, saving, onClose, onSave, + open, types, saving, onClose, onSave, }: { open: boolean; - addresses: ReturnType['addresses']; types: ReturnType['types']; saving: boolean; onClose: () => void; - onSave: (payload: { address_uuid: string; type_uuid: string; name: string }) => void; + onSave: (payload: { type_uuid: string; name: string }) => void; }) { const [name, setName] = useState(''); - const [addressUuid, setAddressUuid] = useState(null); const [typeUuid, setTypeUuid] = useState(null); useEffect(() => { if (!open) return; setName(''); - setAddressUuid(null); setTypeUuid(null); }, [open]); - const invalid = name.trim() === '' || !addressUuid || !typeUuid; + const invalid = name.trim() === '' || !typeUuid; return ( @@ -177,17 +170,6 @@ function CreatePoolModal({ setName(e.target.value)} placeholder="لیزرهای آلکساندرایت" />
-
- - ({ value: b.uuid, label: b.name || 'بدون نام' }))} - value={addressUuid} - onChange={(v) => setAddressUuid(v ? String(v) : null)} - placeholder="شعبه را انتخاب کنید" - height={38} - /> -
-

- شعبه و نوع پس از ساخت تغییر نمی‌کنند — اعضا باید با هر دو بخوانند. +نوع پس از ساخت تغییر نمی‌کند — اعضا باید با آن بخوانند.

@@ -209,7 +191,7 @@ function CreatePoolModal({ type="button" className="btn primary" disabled={saving || invalid} - onClick={() => onSave({ address_uuid: addressUuid!, type_uuid: typeUuid!, name: name.trim() })} + onClick={() => onSave({ type_uuid: typeUuid!, name: name.trim() })} > {saving ? 'در حال ذخیره...' : 'ذخیره'} @@ -229,11 +211,9 @@ function PoolMembersModal({ }) { const [chosen, setChosen] = useState([]); - // فهرست انتخاب به همان شعبه و نوعِ استخر محدود است — همان قاعده‌ای که سرور با ۴۲۲ - // اجبار می‌کند، پس کاربر اصلاً به آن خطا نمی‌خورد. - const { resources } = useResources( - pool ? { address_uuid: pool.address_uuid, type_uuid: pool.type_uuid } : {}, - ); + // فهرست انتخاب به نوعِ استخر محدود است — همان قاعده‌ای که سرور با ۴۲۲ اجبار می‌کند، + // پس کاربر اصلاً به آن خطا نمی‌خورد. + const { resources } = useResources(pool ? { type_uuid: pool.type_uuid } : {}); useEffect(() => { if (!pool) return; diff --git a/assets/admin/pages/ResourcesPage.test.tsx b/assets/admin/pages/ResourcesPage.test.tsx index 6cc108a6..48527fe6 100644 --- a/assets/admin/pages/ResourcesPage.test.tsx +++ b/assets/admin/pages/ResourcesPage.test.tsx @@ -81,7 +81,7 @@ describe('ResourcesPage', () => { it('passes the URL filters straight to the server', async () => { mockApi(); renderWithProviders(, { - route: '/admin/resources?address=b1&type=t1&skill=s1&status=1', + route: '/admin/resources?type=t1&skill=s1&status=1', }); await waitFor(() => expect(get).toHaveBeenCalled()); @@ -89,13 +89,14 @@ describe('ResourcesPage', () => { const called = get.mock.calls.map((c) => String(c[0])); const listCall = called.find((p) => p.startsWith('/api/v1/resources')); - expect(listCall).toContain('address_uuid=b1'); + // شعبه فیلتر نیست: منابع دامنهٔ شعبه ندارند. + expect(listCall).not.toContain('address_uuid'); expect(listCall).toContain('type_uuid=t1'); expect(listCall).toContain('skill_uuid=s1'); expect(listCall).toContain('active=1'); }); - it('sends address and type only when creating, never when editing', async () => { + it('sends the type only when creating, never when editing', async () => { mockApi(); renderWithProviders(, { route: '/admin/resources' }); @@ -104,7 +105,7 @@ describe('ResourcesPage', () => { fireEvent.click(screen.getByText('افزودن منبع')); fireEvent.change(screen.getByPlaceholderText('لیزر آلکساندرایت ۱'), { target: { value: 'لیزر تازه' } }); - // شعبه و نوع هنوز انتخاب نشده‌اند → ذخیره غیرفعال است. + // نوع هنوز انتخاب نشده است → ذخیره غیرفعال است. expect(screen.getByText('ذخیره').closest('button')).toBeDisabled(); }); diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx index d18446a7..13a3ae4e 100644 --- a/assets/admin/pages/ResourcesPage.tsx +++ b/assets/admin/pages/ResourcesPage.tsx @@ -7,7 +7,6 @@ import SearchableSelect from '../components/ui/SearchableSelect'; import { ActiveBadge } from '../components/ui/StatusBadge'; import { useUrlState } from '../hooks/useUrlState'; import { usePermissions } from '../hooks/usePermissions'; -import { useAddresses } from '../hooks/useAddresses'; import { useResources, useResourceTypes, useSkills } from '../hooks/useResources'; import ResourceFormModal from '../components/resources/ResourceFormModal'; import type { ClinicResource } from '../types'; @@ -29,17 +28,15 @@ const SUBJECT_LABEL: Record = { */ export default function ResourcesPage() { const [urlState, setUrlState] = useUrlState({ - search: '', address: '', type: '', skill: '', status: '', + search: '', type: '', skill: '', status: '', }); - const { addresses } = useAddresses(); const { types } = useResourceTypes(); const { skills } = useSkills(); const { can } = usePermissions(); const canUpdate = can('appointment_settings', 'update'); const { resources, loading, create } = useResources({ - address_uuid: urlState.address || undefined, type_uuid: urlState.type || undefined, skill_uuid: urlState.skill || undefined, active: urlState.status || undefined, @@ -67,7 +64,6 @@ export default function ResourcesPage() {
), }, - { key: 'address_name', header: 'شعبه', render: (r) => {r.address_name || '—'} }, { key: 'capacity', header: 'ظرفیت هم‌زمان', @@ -127,16 +123,6 @@ export default function ResourcesPage() { emptyMessage="هیچ منبعی با این فیلترها یافت نشد" headerExtra={
-
- ({ value: b.uuid, label: b.name || 'بدون نام' }))} - value={urlState.address || null} - onChange={(v) => setUrlState({ address: v ? String(v) : '' })} - placeholder="همهٔ شعبه‌ها" - isClearable - height={36} - /> -
({ value: t.uuid, label: t.name }))} @@ -185,7 +171,6 @@ export default function ResourcesPage() { setCreateOpen(false)} diff --git a/docs/api/resource-calendar.md b/docs/api/resource-calendar.md index f0a387b9..c8c27ba6 100644 --- a/docs/api/resource-calendar.md +++ b/docs/api/resource-calendar.md @@ -156,7 +156,7 @@ | `national_holiday` | تعطیل رسمی کشور | | `tenant_holiday` | این محیط آن روز را تعطیل اعلام کرده | | `exception` | مرخصی/غیبت/سرویس بخشی یا تمام روز را بریده | -| `resource_inactive` / `address_inactive` | منبع یا محل نوبت‌دهی غیرفعال است | +| `resource_inactive` | خودِ منبع غیرفعال است (آدرس دیگر گیت نیست — منابع دامنهٔ شعبه ندارند) | **۴۲۲:** نبودِ `from`/`to` · `to < from` · بازهٔ بیش از ۹۲ روز (خروجی واقعی): diff --git a/docs/api/resource.md b/docs/api/resource.md index a58544a5..6938caa9 100644 --- a/docs/api/resource.md +++ b/docs/api/resource.md @@ -176,7 +176,7 @@ | فیلد | نوع | الزامی | قاعده | |---|---|---|---| -| `address_uuid` | string | ✅ | محل نوبت‌دهی؛ جفت محیطِ منبع **از همین** مشتق می‌شود، نه از بدنه | +| `address_uuid` | string | — | **از ۲۰۲۶-۰۸ اختیاری.** نیامدنش یعنی آدرسِ خودِ محیط؛ جفت محیطِ منبع از همین مشتق می‌شود، نه از بدنه | | `type_uuid` | string | ✅ | | | `supervisor_doctor_uuid` | string | ✅ | پزشکِ ناظرِ منبع. باید پزشکِ همین محیط باشد وگرنه ۴۰۴ | | `name` | string | ✅ | حداکثر ۱۵۰ نویسه | @@ -186,6 +186,29 @@ | `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر | | `active` | bool | — | پیش‌فرض `true` | +### شعبه از منابع حذف شد (2026-08) + +منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیک‌اند، و آن انتخابگر همیشه یک +گزینه داشت — یک کلیک اجباری که هیچ تصمیمی نبود. + +| قبل | حالا | +|---|---| +| `address_uuid` در ساخت منبع و استخر الزامی | اختیاری؛ نیامدنش = آدرسِ خودِ محیط (اولین آدرس) | +| پنل «شعبه» می‌پرسید و ستون/فیلترش را داشت | هیچ‌جای پنل شعبه پرسیده یا نشان داده نمی‌شود | +| `doctor_addresses.active = 0` روزِ منبع را خالی می‌کرد (`address_inactive`) | فعال‌بودنِ آدرس دیگر گیت نیست | + +ستون `address_id` سرِ جایش می‌ماند: منطقهٔ زمانی و جفتِ محیطِ منبع از آن می‌آیند. فقط +دیگر تصمیمِ کاربر نیست. + +حذف گیتِ `address_inactive` یک باگ واقعی را می‌بندد: یک ردیف آدرسِ قدیمی با `active = 0` +همهٔ دستگاه‌های آن کلینیک را با پیامی خاموش می‌کرد که **هیچ صفحه‌ای در پنل راهی برای +روشن‌کردنش نداشت** — هیچ اندپوینتی هم `active` آدرس را نمی‌نویسد. + +محیطی که هیچ آدرسی ندارد، `422` می‌گیرد با پیام «برای این محیط آدرسی ثبت نشده است — +ابتدا آدرس کلینیک را کامل کنید»، نه یک خطای مبهم. + +تست: `tests/Resource/ResourceWithoutBranchTest.php`. + ### `service_section` روی فهرست سرویس‌های منبع (2026-08) `GET /api/v1/resource/{uuid}/services` برای هر سرویس `service_section` را هم می‌دهد @@ -471,7 +494,7 @@ ``` `empty_reason` وقتی `windows` خالی است می‌گوید چرا: `no_shift`، `national_holiday`، -`tenant_holiday`، `exception`، `resource_inactive`، `address_inactive`. خالی‌بودن خطا +`tenant_holiday`، `exception`، `resource_inactive`. خالی‌بودن خطا نیست و کلاینت نباید همه را «تعطیل» بنامد. ### `GET /api/v1/resource/{uuid}/service-slots` (2026-08) @@ -633,7 +656,7 @@ idempotent است: تکیه‌گاهش وجود یا نبودِ منبعِ مت ## تست‌ها ```bash -ddev exec php bin/phpunit tests/Resource # ۱۳۱ تست / ۳۴۲ assertion +ddev exec php bin/phpunit tests/Resource # ۱۳۶ تست / ۳۵۴ assertion ddev exec php vendor/bin/phpstan analyse src/Resource npx vitest run assets/admin/pages/ResourcesPage.test.tsx npx vitest run assets/admin/components/appointments/ResourceDayPanel.test.tsx \ diff --git a/src/Resource/Controller/ResourceController.php b/src/Resource/Controller/ResourceController.php index 47fab478..bfa309e9 100644 --- a/src/Resource/Controller/ResourceController.php +++ b/src/Resource/Controller/ResourceController.php @@ -73,8 +73,8 @@ class ResourceController extends BaseController $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'); + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); } if (!is_string($data['type_uuid'] ?? null)) { @@ -87,7 +87,11 @@ class ResourceController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب پزشک ناظر الزامی است', 422, 'supervisor_doctor_uuid'); } - $address = $this->context->address($user, $data['address_uuid']); + // شعبه از کاربر پرسیده نمی‌شود؛ آدرسِ خودِ محیط مبناست. `address_uuid` فقط برای + // سازگاری با کلاینت‌های قدیمی پذیرفته می‌شود. + $address = is_string($data['address_uuid'] ?? null) && $data['address_uuid'] !== '' + ? $this->context->address($user, $data['address_uuid']) + : $this->context->defaultAddress($user); $type = $this->context->type($user, $data['type_uuid']); $supervisor = $this->context->supervisor($user, $data['supervisor_doctor_uuid']); diff --git a/src/Resource/Controller/ResourcePoolController.php b/src/Resource/Controller/ResourcePoolController.php index 63e73ac7..4d22e8d7 100644 --- a/src/Resource/Controller/ResourcePoolController.php +++ b/src/Resource/Controller/ResourcePoolController.php @@ -50,8 +50,8 @@ class ResourcePoolController extends BaseController $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'); + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); } if (!is_string($data['type_uuid'] ?? null)) { @@ -64,8 +64,11 @@ class ResourcePoolController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام استخر الزامی است', 422, 'name'); } + // مثل منبع: شعبه پرسیده نمی‌شود و آدرسِ خودِ محیط مبناست. $pool = new ResourcePool( - $this->context->address($user, $data['address_uuid']), + is_string($data['address_uuid'] ?? null) && $data['address_uuid'] !== '' + ? $this->context->address($user, $data['address_uuid']) + : $this->context->defaultAddress($user), $this->context->type($user, $data['type_uuid']), $name, ); diff --git a/src/Resource/Service/ResourceAvailabilityService.php b/src/Resource/Service/ResourceAvailabilityService.php index b278d106..abc55391 100644 --- a/src/Resource/Service/ResourceAvailabilityService.php +++ b/src/Resource/Service/ResourceAvailabilityService.php @@ -171,9 +171,13 @@ final class ResourceAvailabilityService return new DayAvailability($midnight, $dayOfWeek, [], ['resource_inactive']); } - if (!$resource->getAddress()->isActive()) { - return new DayAvailability($midnight, $dayOfWeek, [], ['address_inactive']); - } + /** + * فعال‌بودنِ آدرس اینجا سنجیده **نمی‌شود**. + * + * منابع دامنهٔ شعبه ندارند: آدرس فقط حاملِ منطقهٔ زمانی و جفتِ محیط است و هیچ + * جای پنل هم روشن/خاموشش نمی‌کند. وقتی می‌شد، یک ردیفِ قدیمیِ `active = 0` کلِ + * دستگاه‌های کلینیک را با پیامی خاموش می‌کرد که کاربر راهی برای رفعش نداشت. + */ $override = $overrideMap[$midnight] ?? null; $holiday = $holidayMap[$midnight] ?? null; diff --git a/src/Resource/Service/ResourceContext.php b/src/Resource/Service/ResourceContext.php index eed3918c..0a0ed998 100644 --- a/src/Resource/Service/ResourceContext.php +++ b/src/Resource/Service/ResourceContext.php @@ -54,6 +54,31 @@ final class ResourceContext return $this->branches->resolve($user, $addressUuid); } + /** + * آدرسِ خودِ محیط — مطب یا کلینیک. + * + * منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ همین کلینیک‌اند و پرسیدنِ شعبه از + * کاربر یک انتخابِ همیشه‌یکسان بود. آدرس همچنان نگه داشته می‌شود چون منطقهٔ زمانی و + * جفتِ محیطِ منبع از آن مشتق می‌شوند، ولی کاربر دیگر انتخابش نمی‌کند. + * + * محیطی که چند آدرس دارد، اولینش را می‌گیرد؛ ترتیب از خودِ مخزن می‌آید و پایدار است. + */ + public function defaultAddress(User $user): DoctorAddress + { + $addresses = $this->branches->listForContext($user); + + if ($addresses === []) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_002, + 'برای این محیط آدرسی ثبت نشده است — ابتدا آدرس کلینیک را کامل کنید', + 422, + 'address_uuid', + ); + } + + return $addresses[array_key_first($addresses)]; + } + public function type(User $user, string $uuid): ResourceType { return $this->owned($user, $this->types->findByUuid($uuid), 'نوع منبع یافت نشد'); diff --git a/src/Resource/ValueObject/DayAvailability.php b/src/Resource/ValueObject/DayAvailability.php index b4b3d0ae..9de1b179 100644 --- a/src/Resource/ValueObject/DayAvailability.php +++ b/src/Resource/ValueObject/DayAvailability.php @@ -16,7 +16,7 @@ final readonly class DayAvailability * @param list $intervals بازه‌های آزاد، به‌صورت timestamp مطلق * @param list $reasons `national_holiday`، `tenant_holiday`، `no_shift`، * `branch_closed`، `outside_branch_hours`، `exception`، - * `resource_inactive`، `branch_inactive` + * `resource_inactive` */ public function __construct( public int $date, diff --git a/tests/Resource/ResourceWithoutBranchTest.php b/tests/Resource/ResourceWithoutBranchTest.php new file mode 100644 index 00000000..e78cdbcb --- /dev/null +++ b/tests/Resource/ResourceWithoutBranchTest.php @@ -0,0 +1,128 @@ +setTime(0, 0) + ->getTimestamp(); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testAResourceIsCreatedWithoutAskingForABranch(): void + { + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + + $body = $this->authJson('POST', '/api/v1/resource', $user, [ + 'type_uuid' => $type->getUuid(), + 'name' => 'لیزر بدون شعبه', + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), + ]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + // آدرسِ خودِ محیط برداشته می‌شود، نه چیزی که کاربر انتخاب کند. + self::assertSame($address->getUuid(), $body['data']['address_uuid']); + } + + public function testAPoolIsCreatedWithoutABranchToo(): void + { + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + + $body = $this->authJson('POST', '/api/v1/resource-pools', $user, [ + 'type_uuid' => $type->getUuid(), + 'name' => 'لیزرهای آلکساندرایت', + ]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame($address->getUuid(), $body['data']['address_uuid']); + } + + /** کلاینتی که هنوز `address_uuid` می‌فرستد نباید بشکند. */ + public function testAnExplicitAddressIsStillAccepted(): void + { + [$user, $clinic, $address] = $this->clinicWithAddress(); + $second = $this->extraAddress($clinic); + $type = $this->resourceType($address); + + $body = $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $second->getUuid(), + 'type_uuid' => $type->getUuid(), + 'name' => 'لیزر آدرس دوم', + 'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(), + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame($second->getUuid(), $body['data']['address_uuid']); + } + + // ── ⚠️ مرزی ───────────────────────────────────────────────────────────── + + /** آدرسِ خاموش دیگر تقویم منبع را خالی نمی‌کند. */ + public function testAnInactiveAddressNoLongerBlanksTheDay(): void + { + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + $created = $this->createResource($user, $address, $type, ['name' => 'اپراتور مریم']); + $uuid = $created['data']['uuid']; + + $this->authJson('PUT', "/api/v1/resource/$uuid/calendar", $user, [ + 'days' => [0 => [['start_minute' => 540, 'end_minute' => 1020]]], + ]); + self::assertSame(200, $this->responseCode()); + + $address->setActive(false); + $this->em->flush(); + + $saturday = $this->nextSaturday(); + $body = $this->authJson('GET', "/api/v1/resource/$uuid/availability?from=$saturday&to=$saturday", $user); + + self::assertSame(200, $this->responseCode()); + self::assertSame(480, $body['data']['days'][0]['total_minutes']); + self::assertNotContains('address_inactive', $body['data']['days'][0]['reasons']); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + /** محیطی که هیچ آدرسی ندارد، منبع نمی‌سازد — ولی پیامش می‌گوید چه کار کند. */ + public function testAnEnvironmentWithNoAddressIsToldToCompleteItsAddressFirst(): void + { + $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک بی‌آدرس'); + $this->em->persist($clinic); + $this->em->flush(); + + // نوع منبع به آدرس نیاز ندارد؛ فقط جفتِ محیط را می‌خواهد. + $probe = DoctorAddress::forClinic($clinic->getId()); + $type = $this->resourceType($probe); + $this->em->remove($probe); + $this->em->flush(); + + $body = $this->authJson('POST', '/api/v1/resource', $user, [ + 'type_uuid' => $type->getUuid(), + 'name' => 'لیزر بی‌خانمان', + 'supervisor_doctor_uuid' => $this->supervisorFor($probe)->getUuid(), + ]); + + self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertStringContainsString('آدرسی ثبت نشده', $body['errors'][0]['message']); + } +}