refactor(resource): drop the branch domain from resources
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) <noreply@anthropic.com>
This commit is contained in:
@@ -193,7 +193,6 @@ const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
|
||||
tenant_holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات مجموعه، این روز تعطیل ثبت شده' },
|
||||
exception: { title: 'استثنای تقویم', hint: 'کل ساعت کاری این روز با استثنای منبع پوشیده شده است' },
|
||||
resource_inactive: { title: 'این منبع غیرفعال است', hint: 'برای نوبتدهی، منبع را از صفحهٔ «منابع» فعال کنید' },
|
||||
address_inactive: { title: 'شعبهٔ این منبع غیرفعال است', hint: 'تا وقتی شعبه غیرفعال باشد، منابعش نوبت نمیگیرند' },
|
||||
};
|
||||
|
||||
export default function TurnsTimeline({
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [typeUuid, setTypeUuid] = useState<string | null>(null);
|
||||
const [supervisorUuid, setSupervisorUuid] = useState<string | null>(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({
|
||||
</Field>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
|
||||
<Field label="شعبه">
|
||||
<SearchableSelect
|
||||
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
value={addressUuid}
|
||||
onChange={(v) => setAddressUuid(v ? String(v) : null)}
|
||||
placeholder="شعبه را انتخاب کنید"
|
||||
isDisabled={isEdit}
|
||||
height={38}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="نوع منبع">
|
||||
<SearchableSelect
|
||||
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
||||
@@ -126,8 +113,8 @@ export default function ResourceFormModal({
|
||||
height={38}
|
||||
/>
|
||||
</Field>
|
||||
{/* ناظر برخلاف شعبه و نوع در ویرایش هم قابل تغییر است: پزشکِ مسئولِ یک
|
||||
دستگاه عوض میشود، ولی محیطِ منبع نه. */}
|
||||
{/* ناظر برخلاف نوع در ویرایش هم قابل تغییر است: پزشکِ مسئولِ یک دستگاه
|
||||
عوض میشود، ولی محیطِ منبع نه. */}
|
||||
<Field label="پزشک ناظر">
|
||||
<SearchableSelect
|
||||
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
||||
@@ -142,7 +129,7 @@ export default function ResourceFormModal({
|
||||
|
||||
{isEdit && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
|
||||
شعبه و نوع منبع پس از ساخت تغییر نمیکنند؛ برای جابهجایی، منبع تازه بسازید.
|
||||
نوع منبع پس از ساخت تغییر نمیکند؛ برای تغییرش، منبع تازه بسازید.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ interface Props {
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
resource_option: 'همین منبع',
|
||||
resource_service: 'منبع، روی سرویس والد',
|
||||
branch: 'شعبه',
|
||||
branch: 'تنظیم محل نوبتدهی',
|
||||
service_default: 'پیشفرض سرویس',
|
||||
};
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ export interface HoldResult {
|
||||
}
|
||||
|
||||
export const REASON_LABELS: Record<string, string> = {
|
||||
no_capacity_in_range: 'در این بازه هیچ ظرفیتی نیست — بازه را بزرگتر کنید یا شعبهٔ دیگری را امتحان کنید.',
|
||||
no_working_hours: 'شعبه در این بازه ساعت کاری ندارد.',
|
||||
no_capacity_in_range: 'در این بازه هیچ ظرفیتی نیست — بازه را بزرگتر کنید یا سرویس دیگری را امتحان کنید.',
|
||||
no_working_hours: 'در این بازه ساعت کاری تعریف نشده است.',
|
||||
no_eligible_resource: 'هیچ منبعی شرایط بخشهای این خدمت را ندارد.',
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ApiResponse<ResourcePool>>('/api/v1/resource-pools', d),
|
||||
onSuccess: () => { toast.success('استخر افزوده شد'); invalidate(); },
|
||||
onError: (e) => fail(e, 'افزودن استخر ناموفق بود'),
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-block" style={{ minWidth: 200, margin: 0 }}>
|
||||
<label>شعبه</label>
|
||||
<SearchableSelect
|
||||
value={branchUuid}
|
||||
onChange={(v) => {
|
||||
setBranchUuid(String(v ?? ''));
|
||||
setPickedSlot(null);
|
||||
}}
|
||||
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
placeholder="انتخاب شعبه"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-block" style={{ minWidth: 160, margin: 0 }}>
|
||||
<label>بازه</label>
|
||||
<SearchableSelect
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('ResourceDetailPage', () => {
|
||||
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();
|
||||
|
||||
@@ -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() {
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={resource.name}
|
||||
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}${resource.address_name ? ` · ${resource.address_name}` : ''}`}
|
||||
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}`}
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]}
|
||||
action={
|
||||
@@ -175,7 +173,6 @@ export default function ResourceDetailPage() {
|
||||
<ResourceFormModal
|
||||
open={editOpen}
|
||||
resource={resource}
|
||||
addresses={addresses}
|
||||
types={types}
|
||||
saving={update.isPending}
|
||||
onClose={() => setEditOpen(false)}
|
||||
@@ -216,7 +213,6 @@ function InfoTab({ resource, canUpdate, toggling, onToggleActive }: {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
|
||||
<div className="card card-pad">
|
||||
<Row label="شعبه">{resource.address_name || '—'}</Row>
|
||||
<Row label="نوع منبع">{resource.type_name}</Row>
|
||||
<Row label="ظرفیت همزمان">{resource.capacity} نفر</Row>
|
||||
<Row label="آمادهسازی / تمیزکاری">
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'address_name', header: 'شعبه', render: (p) => <span style={{ fontSize: 13 }}>{p.address_name || '—'}</span> },
|
||||
{
|
||||
key: 'members',
|
||||
header: 'اعضا',
|
||||
@@ -69,7 +66,7 @@ export default function ResourcePoolsPage() {
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="استخر منابع"
|
||||
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید در یک شعبه و از یک نوع باشند."
|
||||
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید از یک نوع باشند."
|
||||
backTo="/admin/resources"
|
||||
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
|
||||
action={
|
||||
@@ -116,7 +113,6 @@ export default function ResourcePoolsPage() {
|
||||
|
||||
<CreatePoolModal
|
||||
open={creating}
|
||||
addresses={addresses}
|
||||
types={types}
|
||||
saving={create.isPending}
|
||||
onClose={() => 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<typeof useAddresses>['addresses'];
|
||||
types: ReturnType<typeof useResourceTypes>['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<string | null>(null);
|
||||
const [typeUuid, setTypeUuid] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName('');
|
||||
setAddressUuid(null);
|
||||
setTypeUuid(null);
|
||||
}, [open]);
|
||||
|
||||
const invalid = name.trim() === '' || !addressUuid || !typeUuid;
|
||||
const invalid = name.trim() === '' || !typeUuid;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="افزودن استخر منابع">
|
||||
@@ -177,17 +170,6 @@ function CreatePoolModal({
|
||||
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="لیزرهای آلکساندرایت" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>شعبه</label>
|
||||
<SearchableSelect
|
||||
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
value={addressUuid}
|
||||
onChange={(v) => setAddressUuid(v ? String(v) : null)}
|
||||
placeholder="شعبه را انتخاب کنید"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>نوع منبع</label>
|
||||
<SearchableSelect
|
||||
@@ -200,7 +182,7 @@ function CreatePoolModal({
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
|
||||
شعبه و نوع پس از ساخت تغییر نمیکنند — اعضا باید با هر دو بخوانند.
|
||||
نوع پس از ساخت تغییر نمیکند — اعضا باید با آن بخوانند.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
@@ -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 ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
@@ -229,11 +211,9 @@ function PoolMembersModal({
|
||||
}) {
|
||||
const [chosen, setChosen] = useState<string[]>([]);
|
||||
|
||||
// فهرست انتخاب به همان شعبه و نوعِ استخر محدود است — همان قاعدهای که سرور با ۴۲۲
|
||||
// اجبار میکند، پس کاربر اصلاً به آن خطا نمیخورد.
|
||||
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;
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('ResourcesPage', () => {
|
||||
it('passes the URL filters straight to the server', async () => {
|
||||
mockApi();
|
||||
renderWithProviders(<ResourcesPage />, {
|
||||
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(<ResourcesPage />, { 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
*/
|
||||
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() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'address_name', header: 'شعبه', render: (r) => <span style={{ fontSize: 13 }}>{r.address_name || '—'}</span> },
|
||||
{
|
||||
key: 'capacity',
|
||||
header: 'ظرفیت همزمان',
|
||||
@@ -127,16 +123,6 @@ export default function ResourcesPage() {
|
||||
emptyMessage="هیچ منبعی با این فیلترها یافت نشد"
|
||||
headerExtra={
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginRight: 'auto' }}>
|
||||
<div style={{ minWidth: 180 }}>
|
||||
<SearchableSelect
|
||||
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
||||
value={urlState.address || null}
|
||||
onChange={(v) => setUrlState({ address: v ? String(v) : '' })}
|
||||
placeholder="همهٔ شعبهها"
|
||||
isClearable
|
||||
height={36}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 170 }}>
|
||||
<SearchableSelect
|
||||
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
||||
@@ -185,7 +171,6 @@ export default function ResourcesPage() {
|
||||
<ResourceFormModal
|
||||
open={createOpen}
|
||||
resource={null}
|
||||
addresses={addresses}
|
||||
types={types}
|
||||
saving={create.isPending}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
| `national_holiday` | تعطیل رسمی کشور |
|
||||
| `tenant_holiday` | این محیط آن روز را تعطیل اعلام کرده |
|
||||
| `exception` | مرخصی/غیبت/سرویس بخشی یا تمام روز را بریده |
|
||||
| `resource_inactive` / `address_inactive` | منبع یا محل نوبتدهی غیرفعال است |
|
||||
| `resource_inactive` | خودِ منبع غیرفعال است (آدرس دیگر گیت نیست — منابع دامنهٔ شعبه ندارند) |
|
||||
|
||||
**۴۲۲:** نبودِ `from`/`to` · `to < from` · بازهٔ بیش از ۹۲ روز (خروجی واقعی):
|
||||
|
||||
|
||||
+26
-3
@@ -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 \
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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), 'نوع منبع یافت نشد');
|
||||
|
||||
@@ -16,7 +16,7 @@ final readonly class DayAvailability
|
||||
* @param list<TimeInterval> $intervals بازههای آزاد، بهصورت timestamp مطلق
|
||||
* @param list<string> $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,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
|
||||
/**
|
||||
* منابع دامنهٔ «شعبه» ندارند.
|
||||
*
|
||||
* آدرس هنوز زیرِ منبع هست — منطقهٔ زمانی و جفتِ محیط از آن میآیند — ولی از کاربر
|
||||
* پرسیده نمیشود و روشن/خاموش بودنش هیچ چیزی را گیت نمیکند. پیش از این، یک ردیفِ
|
||||
* `doctor_addresses.active = 0` همهٔ دستگاههای کلینیک را با پیامی خاموش میکرد که هیچ
|
||||
* جای پنل راهی برای رفعش نداشت.
|
||||
*/
|
||||
class ResourceWithoutBranchTest extends ResourceTestCase
|
||||
{
|
||||
private function nextSaturday(): int
|
||||
{
|
||||
return (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran')))
|
||||
->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user