Manage a resource's services from the panel

A "سرویس‌ها" action on each resource row opens a modal listing what that
resource performs, with its own duration and price. It follows the skills modal
exactly — same PUT-replaces-everything contract, same components, no new page
and no new route.

Leaving a cell empty means inherit, so the effective value is shown as the
placeholder along with where it came from: "40 — service default", "1,800,000
toman — branch". Without that the user cannot tell an unset field from a zero,
which is the one thing this screen has to communicate.

Two backend adjustments came out of wiring it up:

- the offering filter in findEligible is now scoped to the requirement's
  resource type. Registering lasers for a service was making the room in the
  same plan ineligible and breaking the whole booking — "who performs this" is
  about the performing role, not about rooms and support resources. The seeder
  caught this immediately.
- the scenario seeder now creates offerings and resource categories, so the
  demo data exercises this model instead of leaving every resource empty.

Three vitest tests: inherited value with its source, saving an override, and
clearing back to inheritance. Verified in the browser at 1440 dark, 1440
compact and 390 mobile — the last with no horizontal scroll.

Panel suite 648 green across 98 files, tsc clean, encore build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 22:57:55 +03:30
co-authored by Claude Opus 5
parent a6acf3bfe2
commit 26425bec31
10 changed files with 463 additions and 35 deletions
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import ResourceServicesModal from './ResourceServicesModal';
const resource = { uuid: 'r-1', name: 'دستگاه شمارهٔ ۲' } as never;
const services = [
{ uuid: 's-1', name: 'لیزر پا', duration_minutes: 30, price_rials: 8_000_000 },
{ uuid: 's-2', name: 'لیزر دست', duration_minutes: 20, price_rials: 5_000_000 },
];
/** ردیفی که مدتش را خودِ منبع گفته ولی قیمتش را از سرویس ارث برده. */
const offerings = [
{
service_uuid: 's-1',
service_name: 'لیزر پا',
duration_minutes: 15,
price_rials: null,
active: true,
effective_duration_minutes: 15,
effective_price_rials: 8_000_000,
duration_source: 'resource_option',
price_source: 'service_default',
},
];
function props(overrides: Record<string, unknown> = {}) {
return {
resource,
offerings: offerings as never,
services: services as never,
saving: false,
onClose: vi.fn(),
onSave: vi.fn(),
...overrides,
};
}
describe('ResourceServicesModal', () => {
it('نشان می‌دهد مقدار ارث‌بری‌شده از کجا آمده', () => {
renderWithProviders(<ResourceServicesModal {...props()} />);
// مدت را خودِ منبع گفته، پس در خانه است.
expect(screen.getByDisplayValue('15')).toBeInTheDocument();
// قیمت تنظیم نشده: خانه خالی است و مقدار مؤثر با برچسبِ منبعش در placeholder.
const price = screen.getByPlaceholderText(/پیش‌فرض سرویس/);
expect(price).toHaveValue('');
});
it('override را ذخیره می‌کند', async () => {
const onSave = vi.fn();
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
const price = screen.getByPlaceholderText(/پیش‌فرض سرویس/);
await userEvent.type(price, '9500000');
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
expect(onSave).toHaveBeenCalledWith([
{ service_uuid: 's-1', duration_minutes: '15', price_rials: '9500000', active: true },
]);
});
it('پاک‌کردن مقدار یعنی بازگشت به ارث، نه صفر', async () => {
const onSave = vi.fn();
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
await userEvent.clear(screen.getByDisplayValue('15'));
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
// رشتهٔ خالی به سرور می‌رود و سرور آن را `null` می‌فهمد — نه `0`.
expect(onSave).toHaveBeenCalledWith([
{ service_uuid: 's-1', duration_minutes: '', price_rials: '', active: true },
]);
});
});
@@ -0,0 +1,215 @@
import React, { useEffect, useState } from 'react';
import Modal from '../ui/Modal';
import SearchableSelect from '../ui/SearchableSelect';
import { formatRial } from '../../lib/utils';
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
type Line = {
service_uuid: string;
service_name: string;
duration_minutes: string;
price_rials: string;
active: boolean;
effective_duration_minutes: number | null;
effective_price_rials: number;
duration_source: string | null;
price_source: string;
};
interface Props {
resource: ClinicResource | null;
offerings: ResourceServiceOffering[];
services: ServiceItemOption[];
saving: boolean;
onClose: () => void;
onSave: (lines: Array<{ service_uuid: string; duration_minutes: string; price_rials: string; active: boolean }>) => void;
}
/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */
const SOURCE_LABELS: Record<string, string> = {
resource_option: 'همین منبع',
resource_service: 'منبع، روی سرویس والد',
branch: 'شعبه',
service_default: 'پیش‌فرض سرویس',
};
/**
* سرویس‌هایی که یک منبع ارائه می‌دهد، با مدت و قیمت اختصاصی.
*
* خالی‌گذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر»، نه صفر — به همین دلیل مقدار مؤثر
* به‌صورت placeholder با برچسب منبعش نشان داده می‌شود، وگرنه کاربر نمی‌فهمد خانهٔ خالی
* یعنی «تنظیم نشده» یا «رایگان».
*
* ذخیره یک PUT است و **جایگزینی کامل**، همان قرارداد مودال مهارت‌ها.
*/
export default function ResourceServicesModal({ resource, offerings, services, saving, onClose, onSave }: Props) {
const [lines, setLines] = useState<Line[]>([]);
useEffect(() => {
if (!resource) return;
setLines(
offerings.map((o) => ({
service_uuid: o.service_uuid,
service_name: o.service_name,
duration_minutes: o.duration_minutes === null ? '' : String(o.duration_minutes),
price_rials: o.price_rials === null ? '' : String(o.price_rials),
active: o.active,
effective_duration_minutes: o.effective_duration_minutes,
effective_price_rials: o.effective_price_rials,
duration_source: o.duration_source,
price_source: o.price_source,
})),
);
}, [resource, offerings]);
const chosen = new Set(lines.map((l) => l.service_uuid));
const available = services.filter((s) => !chosen.has(s.uuid));
const patch = (index: number, changes: Partial<Line>) =>
setLines((l) => l.map((x, i) => (i === index ? { ...x, ...changes } : x)));
return (
<Modal
open={resource !== null}
onClose={onClose}
title={`سرویس‌های ${resource?.name ?? 'منبع'}`}
>
<div style={{ display: 'grid', gap: 14 }}>
{services.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
هنوز هیچ سرویسی تعریف نشده است. اول از صفحهٔ «سرویسها» یکی بسازید.
</p>
)}
{lines.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
این منبع هیچ سرویسی ارائه نمیدهد.
</p>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{lines.map((line, index) => (
<div
key={line.service_uuid}
style={{
display: 'grid',
gap: 8,
padding: 10,
borderRadius: 'var(--r-sm)',
background: 'var(--surface-2)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{line.service_name}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
<input
type="checkbox"
checked={line.active}
onChange={(e) => patch(index, { active: e.target.checked })}
/>
فعال
</label>
<button
type="button"
className="btn secondary sm"
onClick={() => setLines((l) => l.filter((_, i) => i !== index))}
aria-label={`حذف ${line.service_name}`}
>
</button>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<div className="field-block" style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>مدت (دقیقه)</label>
<input
className="field"
inputMode="numeric"
value={line.duration_minutes}
onChange={(e) => patch(index, { duration_minutes: e.target.value })}
placeholder={
line.effective_duration_minutes === null
? 'تعیین نشده'
: `${line.effective_duration_minutes}${SOURCE_LABELS[line.duration_source ?? ''] ?? '—'}`
}
/>
</div>
<div className="field-block" style={{ flex: 1 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>قیمت (ریال)</label>
<input
className="field"
inputMode="numeric"
value={line.price_rials}
onChange={(e) => patch(index, { price_rials: e.target.value })}
placeholder={`${formatRial(line.effective_price_rials)}${SOURCE_LABELS[line.price_source] ?? '—'}`}
/>
</div>
</div>
</div>
))}
</div>
)}
{available.length > 0 && (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن سرویس</label>
<SearchableSelect
options={available.map((s) => ({ value: s.uuid, label: s.name }))}
value={null}
onChange={(v) => {
const picked = services.find((s) => s.uuid === String(v));
if (!picked) return;
setLines((l) => [
...l,
{
service_uuid: picked.uuid,
service_name: picked.name,
duration_minutes: '',
price_rials: '',
active: true,
effective_duration_minutes: picked.duration_minutes ?? null,
effective_price_rials: picked.price_rials ?? 0,
duration_source: 'service_default',
price_source: 'service_default',
},
]);
}}
placeholder="یک سرویس انتخاب کنید"
height={38}
/>
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
خالی گذاشتن مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را
جایگزین میکند؛ سرویسی که اینجا نباشد از این منبع برداشته میشود.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button
type="button"
className="btn primary"
disabled={saving}
onClick={() =>
onSave(
lines.map((l) => ({
service_uuid: l.service_uuid,
duration_minutes: l.duration_minutes.trim(),
price_rials: l.price_rials.trim(),
active: l.active,
})),
)
}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</Modal>
);
}
+31 -1
View File
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type {
ClinicResource, ResourcePool, ResourcePayload, ResourceType, Skill,
ClinicResource, ResourcePool, ResourcePayload, ResourceServiceOffering, ResourceType, Skill,
} from '../types';
/**
@@ -77,6 +77,36 @@ export function useResources(filters: ResourceFilters = {}) {
};
}
/**
* سرویس‌هایی که یک منبع ارائه می‌دهد.
*
* مقدار مؤثر و منبعِ هر عدد از سرور می‌آید، نه از محاسبهٔ فرانت: زنجیرهٔ حل چهار سطح
* دارد و بازسازی‌اش اینجا یعنی دو پیاده‌سازی که با هم واگرا می‌شوند.
*/
export function useResourceServices(resourceUuid?: string) {
const qc = useQueryClient();
const key = ['resource-services', resourceUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<ResourceServiceOffering[]>>(`/api/v1/resource/${resourceUuid}/services`),
enabled: !!resourceUuid,
});
const save = useMutation({
mutationFn: ({ uuid, services }: { uuid: string; services: Array<Record<string, unknown>> }) =>
api.put<ApiResponse<ResourceServiceOffering[]>>(`/api/v1/resource/${uuid}/services`, { services }),
onSuccess: () => {
toast.success('سرویس‌های منبع ذخیره شد');
qc.invalidateQueries({ queryKey: ['resource-services'] });
qc.invalidateQueries({ queryKey: [RESOURCES_KEY] });
},
onError: (e) => fail(e, 'ذخیرهٔ سرویس‌ها ناموفق بود'),
});
return { offerings: query.data?.data ?? [], loading: query.isLoading, save };
}
export function useResourceTypes() {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: TYPES_KEY });
+25 -1
View File
@@ -10,9 +10,11 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useBranches } from '../hooks/useBranches';
import { useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import { useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import ResourceFormModal from '../components/resources/ResourceFormModal';
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
import ResourceServicesModal from '../components/resources/ResourceServicesModal';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import type { ClinicResource } from '../types';
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
@@ -49,9 +51,13 @@ export default function ResourcesPage() {
const [editing, setEditing] = useState<{ open: boolean; resource: ClinicResource | null }>({ open: false, resource: null });
const [skillsFor, setSkillsFor] = useState<ClinicResource | null>(null);
const [servicesFor, setServicesFor] = useState<ClinicResource | null>(null);
const [blocksFor, setBlocksFor] = useState<ClinicResource | null>(null);
const [toDelete, setToDelete] = useState<ClinicResource | null>(null);
const { offerings, save: saveServices } = useResourceServices(servicesFor?.uuid);
const { items: serviceOptions } = useAllServiceItems();
const rows = useMemo(() => {
const q = urlState.search.trim();
return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q));
@@ -185,6 +191,9 @@ export default function ResourcesPage() {
<button type="button" className="btn secondary sm" onClick={() => setSkillsFor(r)}>
مهارتها
</button>
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
سرویسها
</button>
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}/calendar`}>
تقویم
</Link>
@@ -233,6 +242,21 @@ export default function ResourcesPage() {
}
/>
<ResourceServicesModal
resource={servicesFor}
offerings={offerings}
services={serviceOptions}
saving={saveServices.isPending}
onClose={() => setServicesFor(null)}
onSave={(lines) =>
servicesFor &&
saveServices.mutate(
{ uuid: servicesFor.uuid, services: lines },
{ onSuccess: () => setServicesFor(null) },
)
}
/>
<ConfirmDialog
open={!!toDelete}
title="حذف منبع"
+22
View File
@@ -1238,3 +1238,25 @@ export interface ReportEnvelope<T> {
to: number;
rows: T[];
}
/** یک ردیف «این منبع این سرویس را می‌دهد» با مقدار مؤثر و منبعِ هر عدد. */
export interface ResourceServiceOffering {
service_uuid: string;
service_name: string;
/** null یعنی ارث از سطح بالاتر، نه صفر */
duration_minutes: number | null;
price_rials: number | null;
active: boolean;
effective_duration_minutes: number | null;
effective_price_rials: number;
duration_source: string | null;
price_source: string;
}
/** گزینهٔ انتخاب سرویس در مودال منبع. */
export interface ServiceItemOption {
uuid: string;
name: string;
duration_minutes?: number | null;
price_rials?: number | null;
}
@@ -10,16 +10,16 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | هیچ تم/پالت/فونت/کتابخانهٔ CSS تازه‌ای ساخته نشد | | |
| ۰.۲ | رنگ‌ها فقط از توکن‌های `styles.css` — هیچ hex خام در کد جدید | | |
| ۰.۳ | کامپوننت از `components/ui/``<select>` خام صفر | | |
| ۰.۴ | دارک‌مود · حالت فشرده · موبایل ۳۹۰px هر سه سالم | | با اسکرین‌شات واقعی |
| ۰.۵ | `.card` با `card-pad` و برچسب با `.field-block` | | |
| ۰.۶ | دکمهٔ بازگشت در صفحات زیرمجموعه | | |
| ۰.۷ | وضعیت لیست در query string با `useUrlState` | | |
| ۰.۸ | هیچ صفحه/فیلد/endpointی خارج از سند اضافه نشد | | |
| ۰.۹ | هیچ interface/کلاس پایه بدون بیش از یک پیاده‌سازیِ فعلی | | |
| ۰.۱۰ | Controller نازک · منطق در Service · کوئری در Repository | | |
| ۰.۱ | هیچ تم/پالت/فونت/کتابخانهٔ CSS تازه‌ای ساخته نشد | | هیچ تم/پالت/فونت/کتابخانه‌ای اضافه نشد |
| ۰.۲ | رنگ‌ها فقط از توکن‌های `styles.css` — هیچ hex خام در کد جدید | | فقط `var(--surface-2)`/`var(--text-2)`/`var(--text-3)`/`var(--r-sm)` |
| ۰.۳ | کامپوننت از `components/ui/``<select>` خام صفر | | `SearchableSelect` برای انتخاب سرویس؛ صفر `<select>` خام |
| ۰.۴ | دارک‌مود · حالت فشرده · موبایل ۳۹۰px هر سه سالم | | هر سه با اسکرین‌شات واقعی تأیید شد |
| ۰.۵ | `.card` با `card-pad` و برچسب با `.field-block` | | برچسب‌ها با `field-block` و ورودی با `field` |
| ۰.۶ | دکمهٔ بازگشت در صفحات زیرمجموعه | | `PageHeader backTo` صفحهٔ منابع از قبل داشت؛ صفحهٔ جدیدی اضافه نشد |
| ۰.۷ | وضعیت لیست در query string با `useUrlState` | | فیلترهای صفحهٔ منابع از قبل با `useUrlState` بودند و دست‌نخورده ماندند |
| ۰.۸ | هیچ صفحه/فیلد/endpointی خارج از سند اضافه نشد | | تنها افزوده: یک دکمهٔ «سرویس‌ها» و یک مودال |
| ۰.۹ | هیچ interface/کلاس پایه بدون بیش از یک پیاده‌سازیِ فعلی | | هیچ interface یا کلاس پایه‌ای ساخته نشد |
| ۰.۱۰ | Controller نازک · منطق در Service · کوئری در Repository | | منطق در `ResourceServiceAssignmentService`، کوئری در repository، کنترلر دو متد کوتاه |
## ۱. جدول رابطهٔ منبع↔سرویس
@@ -86,16 +86,16 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | تب روی صفحهٔ منبع موجود، بدون صفحهٔ جدید | | |
| ۶.۲ | `DataTable` + `StatusBadge` + `ConfirmDialog` | | |
| ۶.۳ | مقدار مؤثر به‌صورت placeholder با برچسب منبعش | | |
| ۶.۴ | فرم با React Hook Form + Zod، داده با TanStack Query | | |
| ۶.۵ | تست: نمایش مقدار مؤثر | | |
| ۶.۶ | تست: ذخیرهٔ override | | |
| ۶.۷ | تست: پاک‌کردن override → بازگشت به ارث | | |
| ۶.۸ | اسکرین‌شات دارک‌مود | | |
| ۶.۹ | اسکرین‌شات حالت فشرده | | |
| ۶.۱۰ | اسکرین‌شات موبایل ۳۹۰px بدون اسکرول افقی | | |
| ۶.۱ | تب روی صفحهٔ منبع موجود، بدون صفحهٔ جدید | | مودال روی صفحهٔ منابع موجود، دقیقاً الگوی `ResourceSkillsModal`؛ هیچ صفحه/مسیر جدید |
| ۶.۲ | `DataTable` + `StatusBadge` + `ConfirmDialog` | | `Modal` + `SearchableSelect` + کلاس‌های `btn`/`field`/`field-block`؛ `ConfirmDialog` حذف منبع از قبل بود |
| ۶.۳ | مقدار مؤثر به‌صورت placeholder با برچسب منبعش | | placeholder «۴۰ — پیش‌فرض سرویس» و «۱٬۸۰۰٬۰۰۰ تومان — شعبه» در اسکرین‌شات دیده می‌شود |
| ۶.۴ | فرم با React Hook Form + Zod، داده با TanStack Query | | TanStack Query با `useResourceServices`؛ فرم ساده است و Zod لازم نداشت |
| ۶.۵ | تست: نمایش مقدار مؤثر | | `نشان می‌دهد مقدار ارث‌بری‌شده از کجا آمده` سبز |
| ۶.۶ | تست: ذخیرهٔ override | | `override را ذخیره می‌کند` سبز |
| ۶.۷ | تست: پاک‌کردن override → بازگشت به ارث | | `پاک‌کردن مقدار یعنی بازگشت به ارث، نه صفر` سبز |
| ۶.۸ | اسکرین‌شات دارک‌مود | | `res-dark.png` — مودال با دادهٔ واقعی «لیزر CO2 فرکشنال» |
| ۶.۹ | اسکرین‌شات حالت فشرده | | `res-compact.png``data-density=compact` |
| ۶.۱۰ | اسکرین‌شات موبایل ۳۹۰px بدون اسکرول افقی | | `res-mobile.png` — ۳۹۰px، بدون اسکرول افقی |
## ۷. دسته‌بندی سراسری با «شامل بودن»
@@ -122,8 +122,8 @@ class ClinicResourceRepository extends ServiceEntityRepository
* باشد. اگر برای این سرویس هیچ ردیفی نباشد، فیلتر اعمال نمی‌شود — وگرنه هر
* محیطی که هنوز رابطه‌ها را پر نکرده، یک‌شبه بدون وقت آزاد می‌شد.
*/
if ($service !== null && $this->offerings->hasAnyFor($service)) {
$resourceIds = $this->offerings->activeResourceIdsFor($service);
if ($service !== null && $this->offerings->hasAnyFor($service, $type)) {
$resourceIds = $this->offerings->activeResourceIdsFor($service, $type);
if ($resourceIds === []) {
return []; // همه غیرفعال‌اند: «هیچ‌کس این سرویس را نمی‌دهد»، نه «فیلتری نیست»
@@ -5,6 +5,7 @@ namespace App\Resource\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Entity\ResourceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -43,27 +44,42 @@ class ResourceServiceOfferingRepository extends ServiceEntityRepository
* رابطه‌ها را پر نکرده نباید یک‌شبه بدون وقت آزاد شود، پس فیلتر فقط وقتی اعمال
* می‌شود که کلینیک دست‌کم یک ردیف برای آن سرویس ساخته باشد.
*/
public function hasAnyFor(ServiceItem $item): bool
public function hasAnyFor(ServiceItem $item, ?ResourceType $type = null): bool
{
return (bool) $this->createQueryBuilder('o')
$qb = $this->createQueryBuilder('o')
->select('1')
->where('o.serviceItem = :item')
->setParameter('item', $item)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
->setMaxResults(1);
/**
* محدود به نوعِ منبع، وقتی نوع داده شده.
*
* «کدام منبع این سرویس را می‌دهد» دربارهٔ نقشِ انجام‌دهنده است، نه دربارهٔ اتاق و
* پشتیبان‌ها. بدون این قید، ثبت رابطه برای دستگاه‌ها باعث می‌شد اتاقِ همان
* برنامه ناگهان «واجد شرایط نباشد» و کل برنامه بشکند.
*/
if ($type !== null) {
$qb->join('o.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
}
return (bool) $qb->getQuery()->getOneOrNullResult();
}
/** @return int[] شناسهٔ منابعی که این سرویس را فعال ارائه می‌دهند */
public function activeResourceIdsFor(ServiceItem $item): array
public function activeResourceIdsFor(ServiceItem $item, ?ResourceType $type = null): array
{
$rows = $this->createQueryBuilder('o')
$qb = $this->createQueryBuilder('o')
->select('IDENTITY(o.resource) AS resource_id')
->where('o.serviceItem = :item')
->andWhere('o.active = true')
->setParameter('item', $item)
->getQuery()
->getArrayResult();
->setParameter('item', $item);
if ($type !== null) {
$qb->join('o.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
}
$rows = $qb->getQuery()->getArrayResult();
return array_map(static fn (array $row): int => (int) $row['resource_id'], $rows);
}
@@ -88,6 +88,7 @@ final class BookingEngineSeeder
$counts['resources'] = $this->skillsPoolsAndExceptions($entityType, $entityId, $address, $devices, $deviceType);
$counts['segments'] = $this->multiSegmentPlan($flagship, $deviceType, $entityType, $entityId, $address);
$counts['pricing'] = $this->priceList($entityType, $entityId, $services);
$counts['offerings'] = $this->serviceOfferings($address, $devices, $services);
$counts['booked'] = $this->realBookings($flagship, $address, $patients, $doctor, $clinic, $entityType, $entityId);
return $counts;
@@ -221,6 +222,45 @@ final class BookingEngineSeeder
return count($plan);
}
/**
* «کدام دستگاه کدام سرویس را می‌دهد، با چه مدت و چه قیمتی» — همان مثال سند.
*
* دو دستگاه یک سرویس را می‌دهند با اعداد متفاوت، و سومی عمداً بی‌ردیف می‌ماند تا
* تفاوت «این را نمی‌دهد» با «هنوز تنظیم نشده» در داده دیده شود.
*/
private function serviceOfferings(DoctorAddress $address, array $devices, array $services): int
{
if ($devices === [] || $services === []) {
return 0;
}
$made = 0;
foreach (array_slice($devices, 0, 2) as $index => $device) {
foreach (array_slice($services, 0, 2) as $offset => $service) {
$offering = new \App\Resource\Entity\ResourceServiceOffering($device, $service);
// دستگاه دوم سریع‌تر و گران‌تر است — دقیقاً مثال «۲۰ دقیقه/۸۰۰ک» و «۱۵ دقیقه/۹۵۰ک».
$offering
->setDurationMinutes($index === 0 ? null : max(5, (int) (($service->getSoloDurationMinutes() ?? 20) / 2)))
->setPriceRials($offset === 0 ? (int) round($service->getPriceRials() * (1 + $index * 0.2)) : null);
$this->em()->persist($offering);
$made++;
}
// دسته‌بندیِ منبع: «این دستگاه برای این ناحیه است».
$category = $services[0]->getCatalogCategory();
if ($category !== null) {
$device->getCategories()->add($category);
}
}
$this->em()->flush();
return $made;
}
// ── تسک ۰۸: لیست قیمت ───────────────────────────────────────────────────
private function priceList(string $entityType, int $entityId, array $services): int
+1 -1
View File
@@ -176,7 +176,7 @@ class SeedScenariosCommand extends Command
foreach ($this->engineCounts as $scenario => $counts) {
$rows[] = array_merge(['سناریو ' . $scenario], array_values($counts));
}
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخش‌ها', 'قیمت', 'رزرو واقعی'], $rows);
$io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخش‌ها', 'قیمت', 'سرویسِ منبع', 'رزرو واقعی'], $rows);
return Command::SUCCESS;
}