diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index a54b39bf..0e4fca2f 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -75,7 +75,6 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; import ResourceBookingPage from './pages/ResourceBookingPage'; -import PriceListsPage from './pages/PriceListsPage'; import ResourcesPage from './pages/ResourcesPage'; import ResourceTypesPage from './pages/ResourceTypesPage'; import CatalogCategoriesPage from './pages/CatalogCategoriesPage'; @@ -292,7 +291,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/assets/admin/components/AppointmentInvoiceCard.tsx b/assets/admin/components/AppointmentInvoiceCard.tsx index 6f399f0f..603237c5 100644 --- a/assets/admin/components/AppointmentInvoiceCard.tsx +++ b/assets/admin/components/AppointmentInvoiceCard.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { formatDate, formatRial } from '../lib/utils'; -import { useAppointmentInvoice } from '../hooks/usePriceLists'; +import { useAppointmentInvoice } from '../hooks/useAppointmentInvoice'; interface Props { appointmentUuid: string; diff --git a/assets/admin/components/ServiceTariffModal.tsx b/assets/admin/components/ServiceTariffModal.tsx deleted file mode 100644 index 74176733..00000000 --- a/assets/admin/components/ServiceTariffModal.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { useMemo, useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import { toast } from 'sonner'; -import { api } from '../lib/api'; -import { formatRial, formatYear, rialToToman, tomanToRial } from '../lib/utils'; -import Modal from './ui/Modal'; -import PriceInput from './ui/PriceInput'; -import SearchableSelect from './ui/SearchableSelect'; -import type { ServiceItem } from '../types'; - -interface TariffRow { - uuid: string; - year: number; - price_rials: number; - is_active: boolean; -} - -interface TariffResponse { - current_year: number; - default_price_rials: number; - data: TariffRow[]; -} - -export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) { - const qc = useQueryClient(); - const [year, setYear] = useState(''); - const [price, setPrice] = useState(0); - const [editYear, setEditYear] = useState(null); - const [editPrice, setEditPrice] = useState(0); - - const { data, isLoading } = useQuery<{ data: TariffResponse }>({ - queryKey: ['service-tariffs', item?.uuid], - queryFn: () => api.get(`/api/v1/service-items/${item!.uuid}/tariffs`), - enabled: !!item, - }); - - const resp = (data as any)?.data as TariffResponse | undefined; - const tariffs = useMemo(() => [...(resp?.data ?? [])].sort((a, b) => b.year - a.year), [resp]); - const currentYear = resp?.current_year; - - // سال‌های قابل انتخاب: ۵ سال گذشته تا ۲ سال آینده، منهای سال‌های ثبت‌شده. - const yearOptions = useMemo(() => { - if (!currentYear) return []; - const used = new Set(tariffs.map((t) => t.year)); - const opts: { value: number; label: string }[] = []; - for (let y = currentYear + 2; y >= currentYear - 5; y--) { - if (used.has(y)) continue; - opts.push({ value: y, label: y === currentYear ? `${formatYear(y)} (سال جاری)` : formatYear(y) }); - } - return opts; - }, [currentYear, tariffs]); - - const addMut = useMutation({ - mutationFn: () => api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, { price_rials: tomanToRial(price) }), - onSuccess: () => { - toast.success('تعرفه ذخیره شد'); - setYear(''); - setPrice(0); - qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] }); - qc.invalidateQueries({ queryKey: ['service-items'] }); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const editMut = useMutation({ - mutationFn: (vars: { year: number; price: number }) => - api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${vars.year}`, { price_rials: tomanToRial(vars.price) }), - onSuccess: () => { - toast.success('تعرفه ویرایش شد'); - setEditYear(null); - qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] }); - qc.invalidateQueries({ queryKey: ['service-items'] }); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const startEdit = (t: TariffRow) => { setEditYear(t.year); setEditPrice(rialToToman(t.price_rials)); }; - - return ( - -
- {resp && ( -
- قیمت پایه‌ی سرویس همان تعرفه‌ی سال جاری ({currentYear ? formatYear(currentYear) : '—'}) است و همه‌جا از همین استفاده می‌شود. تعرفه‌ی سال‌های دیگر فقط برای صورتحساب همان سال به‌کار می‌رود. -
- )} - - {/* افزودن تعرفه‌ی جدید */} -
-
افزودن تعرفه‌ی سال
-
-
- - setYear(v != null && v !== '' ? Number(v) : '')} - placeholder="انتخاب سال" - noOptionsMessage="سالی باقی نمانده" - height={40} - /> -
-
- - -
- -
-
- - {/* فهرست تعرفه‌ها */} - {isLoading ? ( -
در حال بارگذاری...
- ) : tariffs.length === 0 ? ( -
هنوز تعرفه‌ای ثبت نشده است.
- ) : ( -
- {tariffs.map((t) => { - const isCurrent = t.year === currentYear; - const isEditing = editYear === t.year; - return ( -
- - سال {formatYear(t.year)} - {isCurrent && جاری} - - - {isEditing ? ( - <> -
- -
- - - - ) : ( - <> - {formatRial(t.price_rials)} - - - )} -
- ); - })} -
- )} -
-
- ); -} diff --git a/assets/admin/components/layout/settingsMenu.ts b/assets/admin/components/layout/settingsMenu.ts index 54bda3b9..403f54a5 100644 --- a/assets/admin/components/layout/settingsMenu.ts +++ b/assets/admin/components/layout/settingsMenu.ts @@ -37,7 +37,6 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'service-categories', label: 'دسته‌بندی‌ها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, - { key: 'price-lists', label: 'لیست‌های قیمت', icon: BanknotesIcon, to: '/admin/price-lists', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] }, diff --git a/assets/admin/hooks/useAppointmentInvoice.ts b/assets/admin/hooks/useAppointmentInvoice.ts new file mode 100644 index 00000000..4a0ffaeb --- /dev/null +++ b/assets/admin/hooks/useAppointmentInvoice.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, type ApiResponse } from '../lib/api'; + +export interface PriceSnapshotLine { + label: string; + rials: number; + kind?: string; +} + +export interface PriceSnapshot { + base_rials: number; + items_rials: number; + discount_rials: number; + insurance_base_rials: number; + insurance_supplementary_rials: number; + tax_rials: number; + final_rials: number; + deposit_rials: number; + created_at?: number; + breakdown: { + discounts: PriceSnapshotLine[]; + sources: Record; + }; +} + +/** فاکتور یک نوبت — همان اعداد لحظهٔ ثبت، حتی اگر قیمت سرویس بعداً عوض شده باشد. */ +export function useAppointmentInvoice(appointmentUuid: string | undefined) { + const query = useQuery({ + queryKey: ['appointment-invoice', appointmentUuid], + queryFn: () => + api.get>(`/api/v1/appointment/${appointmentUuid}/price-snapshot`), + enabled: !!appointmentUuid, + // نوبتِ بدون فاکتور ۴۰۴ می‌دهد و آن خطا نیست، یعنی «هنوز ثبت نشده». + retry: false, + }); + + return { invoice: query.data?.data, loading: query.isLoading, missing: query.isError }; +} diff --git a/assets/admin/hooks/usePriceLists.ts b/assets/admin/hooks/usePriceLists.ts deleted file mode 100644 index fe664c9e..00000000 --- a/assets/admin/hooks/usePriceLists.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { api, ApiError, type ApiResponse } from '../lib/api'; - -/** - * لیست قیمت بازه‌دار و فاکتور نوبت. - * - * لیست تا فعال نشده هیچ اثری ندارد؛ ساختن پیش‌نویس نباید قیمت امروز را عوض کند. پس - * `create` و `activate` عمداً دو عمل جدا هستند، نه یک فرم با تیک «فعال». - */ -export interface PriceListItem { - service_uuid: string; - service_name?: string; - price_rials: number; -} - -export interface PriceList { - uuid: string; - name: string; - address_uuid: string | null; - address_name: string | null; - valid_from: number; - valid_to: number; - active: boolean; - items: PriceListItem[]; - created_at: number; -} - -export interface PriceSnapshotLine { - label: string; - rials: number; - kind?: string; -} - -export interface PriceSnapshot { - base_rials: number; - items_rials: number; - discount_rials: number; - insurance_base_rials: number; - insurance_supplementary_rials: number; - tax_rials: number; - final_rials: number; - deposit_rials: number; - created_at?: number; - breakdown: { - discounts: PriceSnapshotLine[]; - sources: Record; - }; -} - -const KEY = ['price-lists']; - -function fail(e: unknown, fallback: string) { - toast.error(e instanceof ApiError ? e.message : fallback); -} - -export function usePriceLists() { - const qc = useQueryClient(); - - const query = useQuery({ - queryKey: KEY, - queryFn: () => api.get>('/api/v1/price-lists'), - }); - - const invalidate = () => qc.invalidateQueries({ queryKey: KEY }); - - const create = useMutation({ - mutationFn: (body: Record) => - api.post>('/api/v1/price-lists', body), - onSuccess: () => { - toast.success('لیست قیمت ساخته شد — تا فعال نشود اثری ندارد'); - invalidate(); - }, - onError: (e) => fail(e, 'ساخت لیست قیمت ناموفق بود'), - }); - - const update = useMutation({ - mutationFn: ({ uuid, body }: { uuid: string; body: Record }) => - api.patch>(`/api/v1/price-list/${uuid}`, body), - onSuccess: () => { - toast.success('لیست قیمت به‌روزرسانی شد'); - invalidate(); - }, - onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'), - }); - - const setItems = useMutation({ - mutationFn: ({ uuid, items }: { uuid: string; items: PriceListItem[] }) => - api.put>(`/api/v1/price-list/${uuid}/items`, { items }), - onSuccess: () => { - toast.success('قیمت‌ها ذخیره شد'); - invalidate(); - }, - onError: (e) => fail(e, 'ذخیرهٔ قیمت‌ها ناموفق بود'), - }); - - /** تداخل بازه با لیست فعالِ هم‌دامنه اینجا ۴۲۲ می‌گیرد؛ پیام سرور دقیق‌تر است. */ - const activate = useMutation({ - mutationFn: (uuid: string) => api.post>(`/api/v1/price-list/${uuid}/activate`, {}), - onSuccess: () => { - toast.success('لیست قیمت فعال شد'); - invalidate(); - }, - onError: (e) => fail(e, 'فعال‌سازی ناموفق بود'), - }); - - const remove = useMutation({ - mutationFn: (uuid: string) => api.delete>(`/api/v1/price-list/${uuid}`), - onSuccess: () => { - toast.success('لیست قیمت حذف شد'); - invalidate(); - }, - onError: (e) => fail(e, 'حذف ناموفق بود'), - }); - - return { lists: query.data?.data ?? [], loading: query.isLoading, create, update, setItems, activate, remove }; -} - -/** فاکتور یک نوبت — همان اعداد لحظهٔ ثبت، حتی اگر قیمت‌ها بعداً عوض شده باشند. */ -export function useAppointmentInvoice(appointmentUuid: string | undefined) { - const query = useQuery({ - queryKey: ['appointment-invoice', appointmentUuid], - queryFn: () => - api.get>(`/api/v1/appointment/${appointmentUuid}/price-snapshot`), - enabled: !!appointmentUuid, - // نوبتِ بدون فاکتور ۴۰۴ می‌دهد و آن خطا نیست، یعنی «هنوز ثبت نشده». - retry: false, - }); - - return { invoice: query.data?.data, loading: query.isLoading, missing: query.isError }; -} diff --git a/assets/admin/pages/ClinicServicesPage.test.tsx b/assets/admin/pages/ClinicServicesPage.test.tsx index c8ee2390..5aee7547 100644 --- a/assets/admin/pages/ClinicServicesPage.test.tsx +++ b/assets/admin/pages/ClinicServicesPage.test.tsx @@ -59,14 +59,14 @@ describe('ClinicServicesPage (خدمات)', () => { expect(screen.getByText('سرویس جدید')).toBeInTheDocument(); }); - it('opens the ⋮ actions menu with insurance and tariff entries for a service', async () => { + it('opens the ⋮ actions menu with the insurance entry and no tariff entry', async () => { renderWithProviders(, { route: '/admin/clinic-services' }); fireEvent.click(await screen.findByText('کندلا ۲۰۲۱')); fireEvent.click(await screen.findByTitle('عملیات')); expect(await screen.findByText('ویرایش')).toBeInTheDocument(); - expect(screen.getByText('تعرفه‌های سالانه')).toBeInTheDocument(); expect(screen.getByText('پوشش بیمه')).toBeInTheDocument(); + expect(screen.queryByText('تعرفه‌های سالانه')).not.toBeInTheDocument(); }); it('فرم ویرایش سرویس دیگر سوییچ بیمه ندارد و به بخش پوشش بیمه ارجاع می‌دهد', async () => { diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx index 44061229..934e51e5 100644 --- a/assets/admin/pages/ClinicServicesPage.tsx +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -19,7 +19,6 @@ import { usePermissions } from '../hooks/usePermissions'; import PageHeader from '../components/ui/PageHeader'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; -import ServiceTariffModal from '../components/ServiceTariffModal'; import ServiceInsuranceModal from '../components/ServiceInsuranceModal'; import ServiceItemFormModal from '../components/ServiceItemFormModal'; import FeatureGate from '../components/ui/FeatureGate'; @@ -55,7 +54,6 @@ function ClinicServicesPageInner() { const [sectionModal, setSectionModal] = useState<'create' | ServiceSection | null>(null); const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null); const [toggleItem, setToggleItem] = useState(null); - const [tariffItem, setTariffItem] = useState(null); const [insuranceItem, setInsuranceItem] = useState(null); const [search, setSearch] = useState(''); const [showInactive, setShowInactive] = useState(true); @@ -282,7 +280,6 @@ function ClinicServicesPageInner() {
{ e.stopPropagation(); setMenuOpen(null); }} />
e.stopPropagation()} style={{ position: 'absolute', top: 40, insetInlineStart: 8, zIndex: 41, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow)', padding: 6, minWidth: 176, display: 'flex', flexDirection: 'column', gap: 2 }}> -
@@ -368,7 +365,6 @@ function ClinicServicesPageInner() { onClose={() => setItemModal(null)} onManageInsurance={setInsuranceItem} /> - setTariffItem(null)} /> setInsuranceItem(null)} /> diff --git a/assets/admin/pages/PriceListsPage.tsx b/assets/admin/pages/PriceListsPage.tsx deleted file mode 100644 index 4ac4a820..00000000 --- a/assets/admin/pages/PriceListsPage.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; -import PageHeader from '../components/ui/PageHeader'; -import DataTable, { type Column } from '../components/ui/DataTable'; -import Modal from '../components/ui/Modal'; -import PriceInput from '../components/ui/PriceInput'; -import SearchableSelect from '../components/ui/SearchableSelect'; -import PersianDateInput from '../components/ui/PersianDateInput'; -import { formatDate, isoToUnix, unixToIso } from '../lib/utils'; -import { useUrlState } from '../hooks/useUrlState'; -import { usePermissions } from '../hooks/usePermissions'; -import { useAddresses } from '../hooks/useAddresses'; -import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists'; -import { useAllServiceItems } from '../hooks/useServiceCatalog'; -import SettingsLayout from '../components/layout/SettingsLayout'; - -interface Draft { - uuid?: string; - name: string; - address_uuid: string | null; - valid_from: number; - valid_to: number; - items: PriceListItem[]; -} - -const DAY = 86400; - -function emptyDraft(): Draft { - const now = Math.floor(Date.now() / 1000); - return { name: '', address_uuid: null, valid_from: now, valid_to: now + 90 * DAY, items: [] }; -} - -/** - * لیست‌های قیمت بازه‌دار. - * - * وضعیت هر لیست سه حالت دارد و هر سه معنای عملیاتی متفاوتی دارند: پیش‌نویس هیچ اثری - * روی قیمت امروز ندارد، فعال حاکم است، و منقضی فقط تاریخچه است. - */ -export default function PriceListsPage() { - const { lists, loading, create, update, setItems, activate, remove } = usePriceLists(); - const { addresses } = useAddresses(); - const { items: services } = useAllServiceItems(); - const { can } = usePermissions(); - const canManage = can('appointment_settings', 'update'); - - const [urlState, setUrlState] = useUrlState({ search: '' }); - const [draft, setDraft] = useState(null); - - const now = Math.floor(Date.now() / 1000); - - const rows = useMemo(() => { - const q = urlState.search.trim(); - return lists.filter((l) => q === '' || l.name.includes(q)); - }, [lists, urlState.search]); - - const statusOf = (list: PriceList) => { - if (!list.active) return { label: 'پیش‌نویس', className: 'badge' }; - if (list.valid_to < now) return { label: 'منقضی', className: 'badge red' }; - return { label: 'فعال', className: 'badge green' }; - }; - - const columns: Column[] = [ - { - key: 'name', - header: 'لیست', - render: (l) => ( -
- {l.name} - - {l.address_uuid === null ? 'همهٔ شعبه‌ها' : l.address_name ?? 'یک شعبه'} - -
- ), - }, - { - key: 'range', - header: 'بازهٔ اعتبار', - render: (l) => ( - - {formatDate(l.valid_from)} تا {formatDate(l.valid_to)} - - ), - }, - { - key: 'items', - header: 'تعداد قیمت', - render: (l) => {l.items.length}, - }, - { - key: 'status', - header: 'وضعیت', - render: (l) => { - const status = statusOf(l); - return ( - - - {status.label} - - ); - }, - }, - ]; - - const save = async () => { - if (!draft) return; - - const body = { - name: draft.name, - address_uuid: draft.address_uuid, - valid_from: draft.valid_from, - valid_to: draft.valid_to, - }; - - const saved = draft.uuid - ? await update.mutateAsync({ uuid: draft.uuid, body }) - : await create.mutateAsync(body); - - await setItems.mutateAsync({ uuid: saved.data.uuid, items: draft.items }); - - setDraft(null); - }; - - return ( - -
- setDraft(emptyDraft())}> - لیست تازه - - ) : undefined - } - /> - -
- setUrlState({ search: v })} - searchPlaceholder="جستجو در لیست‌ها..." - emptyMessage="هنوز لیست قیمتی ساخته نشده است" - actions={(l) => - canManage ? ( -
- - - {/* «کپی از لیست قبلی»: بیشتر لیست‌ها نسخهٔ کمی‌تغییریافتهٔ قبلی‌اند. */} - - - {!l.active && ( - - )} - - {!l.active && ( - - )} -
- ) : null - } - /> -
- - setDraft(null)} - footer={ - <> - - - - } - > - {draft && ( -
-
- - setDraft({ ...draft, name: e.target.value })} - placeholder="مثلاً: تعرفهٔ نیمهٔ دوم ۱۴۰۵" - /> -
- -
- - setDraft({ ...draft, address_uuid: v ? String(v) : null })} - options={[ - { value: '', label: 'همهٔ شعبه‌ها' }, - ...addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })), - ]} - /> - - لیستِ یک شعبه بر لیست عمومی مقدم است و با آن تداخل حساب نمی‌شود. - -
- -
-
- - setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })} - /> -
-
- - setDraft({ ...draft, valid_to: isoToUnix(iso) ?? draft.valid_to })} - /> -
-
- - {draft.valid_to <= draft.valid_from && ( - - پایان بازه باید بعد از شروع آن باشد. - - )} - -
-

قیمت‌ها

- - {draft.items.map((item, index) => ( -
-
- - setDraft({ - ...draft, - items: draft.items.map((it, i) => - i === index ? { ...it, service_uuid: String(v ?? '') } : it, - ), - }) - } - options={services.map((s) => ({ value: s.uuid, label: s.name }))} - placeholder="خدمت" - /> -
- -
- - setDraft({ - ...draft, - items: draft.items.map((it, i) => (i === index ? { ...it, price_rials: v } : it)), - }) - } - suffix="ریال" - /> -
- - -
- ))} - - -
- - - خدمتی که در این لیست نیاید، قیمتش از تعرفهٔ سال یا خودِ خدمت خوانده می‌شود — - پس هیچ‌وقت بی‌قیمت نمی‌ماند. - -
- )} -
-
-
- ); -} diff --git a/assets/admin/pages/ServiceDetailPage.test.tsx b/assets/admin/pages/ServiceDetailPage.test.tsx index 44687324..0a3e4c59 100644 --- a/assets/admin/pages/ServiceDetailPage.test.tsx +++ b/assets/admin/pages/ServiceDetailPage.test.tsx @@ -45,13 +45,6 @@ const mockApi = (item: unknown = ITEM, notFound = false) => { if (url.includes('/service-item/')) { return notFound ? Promise.reject(new Error('یافت نشد')) : Promise.resolve({ success: true, data: item }); } - if (url.includes('/tariffs')) return Promise.resolve({ success: true, data: { - current_year: 1404, default_price_rials: 8_500_000, - data: [ - { uuid: 't1', year: 1404, price_rials: 8_500_000, is_active: true }, - { uuid: 't2', year: 1403, price_rials: 7_000_000, is_active: false }, - ], - } }); if (url.includes('/tenant-insurances')) return Promise.resolve({ data: { data: [ { uuid: 'ins1', insurance_name: 'بیمه ایران', insurance_kind: 'basic', coverage_percent: 70 }, ] } }); @@ -104,13 +97,12 @@ describe('ServiceDetailPage (جزئیات سرویس)', () => { expect(screen.getByText(/۱۴۰۲/)).toBeInTheDocument(); }); - it('تب تعرفه‌ها فهرست سال‌ها را با نشان «سال جاری» می‌آورد', async () => { + /** تعرفهٔ سالانه حذف شد: قیمت فقط روی خودِ سرویس تعریف می‌شود. */ + it('دیگر تب تعرفه‌ها ندارد', async () => { render(); - fireEvent.click(await screen.findByText('تعرفه‌ها')); + await screen.findByRole('heading', { name: 'سرم ۵۰۰cc' }); - expect(await screen.findByText('سال جاری')).toBeInTheDocument(); - expect(screen.getByText('۱۴۰۴')).toBeInTheDocument(); - expect(screen.getByText('۱۴۰۳')).toBeInTheDocument(); + expect(screen.queryByText('تعرفه‌ها')).not.toBeInTheDocument(); }); it('تب بیمه‌ها قراردادها و درصد پوشش را می‌آورد', async () => { @@ -225,8 +217,8 @@ describe('ServiceDetailPage (جزئیات سرویس)', () => { /** تب در URL می‌نشیند تا «بازگشت» و رفرش همان نما را بدهند. */ it('تب انتخاب‌شده از URL خوانده می‌شود', async () => { - render('/admin/clinic-services/it1?tab=tariffs'); + render('/admin/clinic-services/it1?tab=insurance'); - expect(await screen.findByText('سال جاری')).toBeInTheDocument(); + expect(await screen.findByText('بیمه ایران')).toBeInTheDocument(); }); }); diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index 22b4a027..7c608d05 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -16,24 +16,10 @@ import FeatureGate from '../components/ui/FeatureGate'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import { usePermissions } from '../hooks/usePermissions'; import { useUrlState } from '../hooks/useUrlState'; -import ServiceTariffModal from '../components/ServiceTariffModal'; import ServiceInsuranceModal from '../components/ServiceInsuranceModal'; import ServiceItemFormModal from '../components/ServiceItemFormModal'; import ServiceCategoryTab from '../components/ServiceCategoryTab'; -interface Tariff { - uuid: string; - year: number; - price_rials: number; - is_active: boolean; -} - -interface TariffList { - current_year: number; - default_price_rials: number; - data: Tariff[]; -} - interface TenantInsurance { uuid: string; insurance_name: string | null; @@ -51,7 +37,6 @@ interface CoverageRow { const TABS = [ { id: 'info', label: 'اطلاعات سرویس' }, - { id: 'tariffs', label: 'تعرفه‌ها' }, { id: 'insurance', label: 'بیمه‌ها' }, { id: 'categories',label: 'دسته‌بندی‌ها' }, { id: 'goods', label: 'کالاهای مرتبط' }, @@ -164,57 +149,6 @@ function InfoTab({ item }: { item: ServiceItem }) { ); } -function TariffsTab({ item, onManage, canUpdate }: { item: ServiceItem; onManage: () => void; canUpdate: boolean }) { - const { data, isLoading } = useQuery>({ - queryKey: ['service-tariffs', item.uuid], - queryFn: () => api.get(`/api/v1/service-items/${item.uuid}/tariffs`), - }); - - const list = data?.data; - const tariffs = list?.data ?? []; - - return ( -
-
-
- تعرفه‌های سالانه -
- قیمت هر سال شمسی؛ قیمت سال جاری مبنای صورتحساب است. -
-
- {canUpdate && } -
- - {isLoading ? ( -
در حال بارگذاری...
- ) : tariffs.length === 0 ? ( -
- -

تعرفه‌ای ثبت نشده است

-
- ) : ( -
- {tariffs.map((t) => ( -
- - {formatYear(t.year)} - {t.year === list?.current_year && سال جاری} - {!t.is_active && غیرفعال} - - {formatRial(t.price_rials)} -
- ))} -
- )} -
- ); -} - function InsuranceTab({ item, onManage, canUpdate }: { item: ServiceItem; onManage: () => void; canUpdate: boolean }) { const { data: contractsData, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({ queryKey: ['tenant-insurances'], @@ -460,7 +394,6 @@ function ServiceDetailPageInner() { const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId; const setTab = (id: TabId) => setUrlState({ tab: id }); const [editOpen, setEditOpen] = useState(false); - const [tariffOpen, setTariffOpen] = useState(false); const [insuranceOpen, setInsuranceOpen] = useState(false); const [toggleOpen, setToggleOpen] = useState(false); @@ -541,7 +474,6 @@ function ServiceDetailPageInner() {
{tab === 'info' && } - {tab === 'tariffs' && setTariffOpen(true)} canUpdate={canUpdate} />} {tab === 'insurance' && setInsuranceOpen(true)} canUpdate={canUpdate} />} {tab === 'categories' && ( setEditOpen(false)} onManageInsurance={() => setInsuranceOpen(true)} /> - setTariffOpen(false)} /> setInsuranceOpen(false)} /> **قیمت واحد:** هنگام ساخت سرویس، یک تعرفه برای **سال جاری** با همان `price_rials` به‌صورت خودکار ثبت می‌شود. قیمت سرویس = تعرفه‌ی سال جاری است و همه‌جا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده می‌شود. +> **قیمت واحد:** `price_rials` تنها منبع قیمت است و همه‌جا (صورتحساب، مراجعه، مطالبات) از همین عدد استفاده می‌شود. --- @@ -313,49 +313,11 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س --- -## تعرفه‌ی نسخه‌دار سالانه (Tariff) — فاز ۳ سیستم صورتحساب - -هر خدمت می‌تواند برای هر سال شمسی یک تعرفه داشته باشد. اگر تعرفه‌ی سالی ثبت نشود، به `price_rials` خود خدمت fallback می‌شود (`TariffService::resolvePrice`). سال جاری شمسی سمت سرور با `IntlDateFormatter` (تقویم persian) محاسبه می‌شود. - -### GET /api/v1/service-items/{uuid}/tariffs - -لیست تعرفه‌های یک خدمت + قیمت پیش‌فرض + سال جاری. - -**Permission:** `IS_AUTHENTICATED_FULLY` (مالک خدمت) - -```json -{ - "success": true, - "data": { - "current_year": 1405, - "default_price_rials": 500000, - "data": [ - { "uuid": "…", "service_item_id": 12, "year": 1405, "price_rials": 600000, "is_active": true }, - { "uuid": "…", "service_item_id": 12, "year": 1404, "price_rials": 500000, "is_active": true } - ] - } -} -``` - -### PUT /api/v1/service-items/{uuid}/tariffs/{year} - -ثبت/به‌روزرسانی تعرفه‌ی یک سال (upsert). `year` بین ۱۳۹۰ تا ۱۵۰۰. - -**Body:** -```json -{ "price_rials": 600000 } -``` - -**Response 200:** `{ success, data: { …tariff } }` - -> اگر `year` برابر **سال جاری** باشد، `ServiceItem.price_rials` هم با همین مقدار همگام می‌شود (قیمت واحد). تعرفه‌ی سال‌های دیگر فقط برای محاسبه‌ی صورتحساب همان سال (`TariffService::resolvePrice`) به‌کار می‌رود و قیمت پایه‌ی سرویس را تغییر نمی‌دهد. هم‌چنین `PATCH /service-item/{uuid}` با تغییر `price_rials`، تعرفه‌ی سال جاری را upsert می‌کند. - -**Errors:** -| Code | HTTP | توضیح | -|------|------|-------| -| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد | -| ERR_VALIDATION_001 | 422 | سال نامعتبر | +## قیمت خدمت — تنها یک منبع +قیمت هر خدمت فقط `ServiceItem.price_rials` است و با `PATCH /api/v1/service-item/{uuid}` +عوض می‌شود. تعرفهٔ نسخه‌دار سالانه (`GET|PUT /api/v1/service-items/{uuid}/tariffs[/{year}]`) +حذف شده و آن مسیرها `404` می‌دهند؛ جزئیات زنجیرهٔ محاسبه در [pricing.md](pricing.md). --- @@ -485,17 +447,20 @@ caller's personal ones. - **حلقهٔ پیش‌نیاز** هنگام ثبت `422` می‌گیرد، نه در اعتبارسنجی انتخاب: «الف نیازمند ب» و «ب نیازمند الف» اگر هر دو ذخیره می‌شدند، هیچ انتخابی هرگز معتبر نمی‌شد. -## قیمت و مدت اختصاصی شعبه +## مدت اختصاصی شعبه `PUT /api/v1/service-item/{uuid}/branch-overrides` — جایگزینی کامل. ```json -{ "overrides": [{ "address_uuid": "…", "price_rials": 900000, "solo_duration_minutes": 25 }] } +{ "overrides": [{ "address_uuid": "…", "solo_duration_minutes": 25, "additional_duration_minutes": 10 }] } ``` هر فیلد تهی‌پذیر است و `null` یعنی «همان مقدار خودِ سرویس» — **نه صفر**. override فقط وقتی اعمال می‌شود که `branch_uuid` به `validate` داده شود. +**قیمت اینجا نیست.** `price_rials` از این اندپوینت حذف شده؛ ارسالش نادیده گرفته می‌شود +و در پاسخ هم نمی‌آید. + ## دستهٔ درختی `GET /api/v1/service-categories/tree` · `POST/PATCH/DELETE /api/v1/service-category[/{uuid}]` diff --git a/docs/api/pricing.md b/docs/api/pricing.md index 3fc57f0f..0d89b08c 100644 --- a/docs/api/pricing.md +++ b/docs/api/pricing.md @@ -1,20 +1,19 @@ -# Pricing API — لیست قیمت بازه‌دار و فاکتور تفکیک‌شده +# Pricing API — پیش‌نمایش قیمت و فاکتور تفکیک‌شده > **Base:** `/api/v1` · **Auth:** JWT > مکمل [clinic-services.md](clinic-services.md) و [appointment-booking.md](appointment-booking.md). --- -## دو شکافی که پر شد +## تنها یک منبع قیمت -زنجیرهٔ قیمت از قبل وجود داشت و کار می‌کرد -(`ServiceItem → Tariff → بیمه → DiscountRule → Invoice → Payment`). دو چیز کم بود: +قیمت هر سرویس فقط از خودِ سرویس می‌آید: `ServiceItem.price_rials`، همان عددی که در +[صفحهٔ سرویس‌ها](clinic-services.md) ویرایش می‌شود. -۱. **`Tariff` فقط سال دارد.** تغییر تعرفه از اول مهر قابل بیان نبود. حالا `PriceList` - بازهٔ دقیق می‌گیرد و `Tariff` لایهٔ پشتیبان می‌ماند. -۲. **روی نوبت فقط یک عدد بود.** بعد از تغییر قیمت یا تخفیف نمی‌شد گفت آن ۲٬۴۰۰٬۰۰۰ - ریال از چه تشکیل شده بود. حالا `PriceSnapshot` فاکتور تفکیک‌شدهٔ لحظهٔ ثبت را - نگه می‌دارد. +لایه‌های پیشین — **لیست قیمت بازه‌دار** (`price_lists`)، **تعرفهٔ سالانه** +(`service_tariffs`) و **قیمت اختصاصی شعبه** (`service_branch_overrides.price_rials`) — +حذف شده‌اند: هرکدام جواب متفاوتی به «این سرویس چند است؟» می‌دادند و یک تاریخ می‌توانست +چند قیمت داشته باشد. override شعبه سر جایش است ولی فقط **مدت** را تعیین می‌کند. ## زنجیرهٔ قیمت‌گذاری @@ -22,17 +21,11 @@ قیمت پایه → + آیتم‌ها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه ``` -برای **هر** سرویس، اولین منبعی که پیدا شود برنده است: +`breakdown.sources` برای هر سرویس همیشه `service_item` است — قرارداد پاسخ حفظ شده تا +مصرف‌کننده‌ها نشکنند. -| اولویت | منبع | از کجا | -|---|---|---| -| ۱ | override شعبه | تسک ۰۴ | -| ۲ | لیست قیمتِ حاکم بر آن تاریخ | همین تسک | -| ۳ | `Tariff` سال | لایهٔ موجود | -| ۴ | `ServiceItem.price_rials` | همیشه هست | - -مرحلهٔ چهارم ضامن است که **هرگز صفر یا خطا** برنگردد — تاریخی که هیچ لیستی نمی‌پوشاند -باید قیمت بدهد. `breakdown.sources` می‌گوید هر قیمت از کدام لایه آمده. +`PriceSnapshot` فاکتور تفکیک‌شدهٔ لحظهٔ ثبت را نگه می‌دارد: بعد از تغییر قیمت یا تخفیف، +باید بشود گفت آن ۲٬۴۰۰٬۰۰۰ ریال از چه تشکیل شده بود. ### دو تصمیم محاسباتی @@ -66,7 +59,8 @@ } ``` -`at` اختیاری است (پیش‌فرض الان) و تعیین می‌کند کدام لیست قیمت حاکم است. +`at` اختیاری است (پیش‌فرض الان) و در پارامترهای درخواست می‌ماند؛ چون قیمت دیگر بازه‌ای +نیست، روی عدد خروجی اثری ندارد. **۲۰۰:** همان شکلی که `price_snapshot` دارد — عمداً یکی، تا «قیمتی که نشان دادیم» و «قیمتی که ثبت کردیم» نتوانند واگرا شوند. @@ -76,13 +70,15 @@ "base_rials": 10000000, "items_rials": 2000000, "discount_rials": 1200000, "insurance_base_rials": 2160000, "insurance_supplementary_rials": 4320000, "tax_rials": 432000, "final_rials": 4752000, "deposit_rials": 1425600, - "breakdown": { "discounts": [ … ], "sources": { "": "price_list" } } + "breakdown": { "discounts": [ … ], "sources": { "": "service_item" } } } ``` --- -## لیست قیمت +## اندپوینت‌های حذف‌شده + +این مسیرها دیگر وجود ندارند و `404` می‌دهند: | متد | مسیر | |---|---| @@ -90,15 +86,10 @@ | GET/PATCH/DELETE | `/api/v1/price-list/{uuid}` | | PUT | `/api/v1/price-list/{uuid}/items` | | POST | `/api/v1/price-list/{uuid}/activate` | +| GET | `/api/v1/service-items/{uuid}/tariffs` | +| PUT | `/api/v1/service-items/{uuid}/tariffs/{year}` | -`address_uuid` تهی‌پذیر است: `null` یعنی «همهٔ شعبه‌های این محیط». لیستِ مخصوصِ یک شعبه -بر لیست عمومی **مقدم** است و با آن **تداخل حساب نمی‌شود** — وگرنه تعریف استثنا برای یک -شعبه ناممکن می‌شد. - -**لیست تا فعال نشده هیچ اثری ندارد.** ساختن پیش‌نویس نباید قیمت امروز را عوض کند. - -`activate` بازهٔ هم‌پوشان با لیست فعالِ **هم‌دامنه** را `422` می‌کند: یک تاریخ نباید دو -قیمت داشته باشد. +برای تغییر قیمت، `PATCH /api/v1/service-item/{uuid}` با `price_rials` را صدا بزنید. --- @@ -107,7 +98,7 @@ `GET /api/v1/appointment/{uuid}/price-snapshot` فاکتور هنگام `POST /appointment-confirm` و با قیمت‌های **همان لحظه** ثبت می‌شود. اگر -بعداً محاسبه می‌شد، تغییر تعرفه بین ثبت و صدور فاکتور عدد دیگری می‌داد. +بعداً محاسبه می‌شد، تغییر قیمت سرویس بین ثبت و صدور فاکتور عدد دیگری می‌داد. > **قانون پنجم مستند:** «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.» > `PriceSnapshot` هیچ setter ای ندارد و کلید یکتای `appointment_id` دو فاکتور برای یک @@ -123,13 +114,12 @@ | جدول | وضعیت | |---|---| -| `price_lists` · `price_snapshots` | جفت محیط | -| `price_list_items` | `AGGREGATE_CHILDREN` — ریشه `PriceList` | +| `price_snapshots` | جفت محیط | ## تست‌ها ```bash -ddev exec php bin/phpunit tests/Pricing # ۱۲ تست +ddev exec php bin/phpunit tests/Pricing # ۱۱ تست ``` مهم‌ترینش `testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange` است: نوبت ثبت diff --git a/docs/api/secretary.md b/docs/api/secretary.md index bf9a9824..ed5d792b 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -144,7 +144,7 @@ Create a secretary for a doctor. | `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete | | `inventory` | `InventoryController` (items + packages) | view/create/update/delete | | `tags` | `TenantTagController` (لیست با `tags.view` یا `patients.view`؛ نوشتن‌ها با `tags.*`) | view/create/update/delete | -| `services` | `ClinicServiceController` (sections + items + tariffs). owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل می‌شود چون `EntityContextResolver` منشی را نمی‌شناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا می‌شود | view/create/update/delete | +| `services` | `ClinicServiceController` (sections + items). owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل می‌شود چون `EntityContextResolver` منشی را نمی‌شناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا می‌شود | view/create/update/delete | | `staff` | `StaffController` (resolveEntity منشی‌آگاه) | view/create/update/delete | | `discounts` | `DiscountController` (CRUD؛ `suggestions` جزو flowِ جلسه است و با discounts گِیت نمی‌شود) | view/create/update/delete | | `sms` | `SmsWalletController` (balance/charge/logs/settings). endpointهای admin (قالب/ارسال) همچنان `ROLE_ADMIN` | view/create/update | diff --git a/migrations/Version20260802140757.php b/migrations/Version20260802140757.php new file mode 100644 index 00000000..7bc04501 --- /dev/null +++ b/migrations/Version20260802140757.php @@ -0,0 +1,47 @@ +addSql('ALTER TABLE price_lists DROP FOREIGN KEY `FK_23EF97C5F5B7AF75`'); + $this->addSql('ALTER TABLE price_list_items DROP FOREIGN KEY `FK_8C05724A5688DED7`'); + $this->addSql('ALTER TABLE price_list_items DROP FOREIGN KEY `FK_8C05724ADDEB00C2`'); + $this->addSql('DROP TABLE price_list_items'); + $this->addSql('DROP TABLE price_lists'); + $this->addSql('DROP TABLE service_tariffs'); + $this->addSql('ALTER TABLE service_branch_overrides DROP price_rials'); + } + + public function down(Schema $schema): void + { + // Schema only — the dropped rows are gone for good. + $this->addSql('CREATE TABLE price_lists (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, name VARCHAR(150) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, starts_at INT NOT NULL, ends_at INT NOT NULL, active TINYINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, entity_id INT NOT NULL, address_id INT DEFAULT NULL, INDEX IDX_23EF97C5F5B7AF75 (address_id), INDEX idx_price_list_range (starts_at, ends_at), INDEX idx_price_list_tenant (entity_type, entity_id, active), UNIQUE INDEX UNIQ_23EF97C5D17F50A6 (uuid), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE price_list_items (id INT AUTO_INCREMENT NOT NULL, price_rials BIGINT NOT NULL, price_list_id INT NOT NULL, service_item_id INT NOT NULL, INDEX IDX_8C05724ADDEB00C2 (service_item_id), UNIQUE INDEX uniq_price_list_service (price_list_id, service_item_id), INDEX IDX_8C05724A5688DED7 (price_list_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE service_tariffs (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_520_ci`, service_item_id INT NOT NULL, year SMALLINT NOT NULL, price_rials INT NOT NULL, is_active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, INDEX idx_tariff_service_active (service_item_id, is_active), UNIQUE INDEX UNIQ_FAAAF536D17F50A6 (uuid), UNIQUE INDEX uniq_service_tariff_year (service_item_id, year), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_520_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE price_lists ADD CONSTRAINT `FK_23EF97C5F5B7AF75` FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE price_list_items ADD CONSTRAINT `FK_8C05724A5688DED7` FOREIGN KEY (price_list_id) REFERENCES price_lists (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE price_list_items ADD CONSTRAINT `FK_8C05724ADDEB00C2` FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE service_branch_overrides ADD price_rials BIGINT DEFAULT NULL'); + } +} diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 99f4c4c8..a96b0a49 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -212,7 +212,7 @@ class Appointment #[ORM\Column(name: 'insurance_service_category', type: 'string', length: 30, nullable: true, enumType: ServiceCategory::class)] private ?ServiceCategory $insuranceServiceCategory = null; - /** بیمهٔ پایهٔ انتخاب‌شده؛ ارجاع خام int مثل TenantInsurance/Tariff. */ + /** بیمهٔ پایهٔ انتخاب‌شده؛ ارجاع خام int مثل TenantInsurance. */ #[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)] private ?int $insuranceBaseId = null; diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php index 2e75ccf4..fcf8f787 100644 --- a/src/Billing/Service/InvoiceService.php +++ b/src/Billing/Service/InvoiceService.php @@ -7,7 +7,6 @@ use App\Billing\Entity\InvoiceItem; use App\Billing\Event\InvoiceFinalized; use App\Billing\Repository\InvoiceRepository; use App\Billing\ValueObject\Money; -use App\ClinicService\Service\TariffService; use App\Insurance\Enum\ServiceCategory; use App\Insurance\Repository\InsuranceRepository; use App\Insurance\Service\TenantInsuranceService; @@ -19,7 +18,6 @@ class InvoiceService { public function __construct( private readonly InvoiceRepository $invoiceRepo, - private readonly TariffService $tariffService, private readonly TenantInsuranceService $tenantInsuranceService, private readonly BillingCalculator $calculator, private readonly PatientSessionRepository $sessionRepo, @@ -41,7 +39,7 @@ class InvoiceService /** * ساخت Invoice از یک Encounter (PatientSession). - * تعرفه‌ی هر خدمت از Tariff سال جاری (با fallback)، پوشش از قرارداد بیمه‌ی tenant. + * قیمت هر خدمت از خودِ سرویس، پوشش از قرارداد بیمه‌ی tenant. * ویزیت به‌عنوان یک آیتم جداگانه با همان قانون پوشش لحاظ می‌شود. */ public function createFromSession(PatientSession $session, string $entityType, int $entityId): Invoice @@ -76,7 +74,7 @@ class InvoiceService foreach ($session->getServices() as $sessionService) { $item = $sessionService->getServiceItem(); $qty = max(1, $sessionService->getQuantity()); - $unitPrice = $this->tariffService->resolvePrice($item); + $unitPrice = $item->getPriceRials(); $total = new Money($unitPrice * $qty); $baseRule = $this->tenantInsuranceService->coverageRuleForService($entityType, $entityId, $baseId, $item->getId()); diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index 4f8dd3c1..cca49c3e 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -8,7 +8,6 @@ use App\ClinicService\Entity\ServiceItemAuditLog; use App\ClinicService\Entity\ServiceSection; use App\Insurance\Entity\TenantServiceCoverage; use App\Insurance\Enum\ServiceCategory; -use App\ClinicService\Entity\Tariff; use App\Clinic\Security\ClinicDoctorAccessChecker; use App\Secretary\Security\SecretaryAccessChecker; use Doctrine\ORM\EntityManagerInterface; @@ -16,9 +15,7 @@ use App\ClinicService\Repository\CatalogCategoryRepository; use App\ClinicService\Repository\ServiceItemAuditLogRepository; use App\ClinicService\Repository\ServiceItemRepository; use App\ClinicService\Repository\ServiceSectionRepository; -use App\ClinicService\Repository\TariffRepository; use App\ClinicService\Service\ServiceItemAuditService; -use App\ClinicService\Service\TariffService; use App\Inventory\Repository\InventoryItemRepository; use App\Inventory\Repository\InventoryPackageRepository; use App\Shared\Constant\ErrorCodes; @@ -44,8 +41,6 @@ class ClinicServiceController extends BaseController private readonly ServiceItemRepository $itemRepo, private readonly ClinicStaffRepository $staffRepo, private readonly SubscriptionService $subscriptionService, - private readonly TariffRepository $tariffRepo, - private readonly TariffService $tariffService, private readonly InventoryPackageRepository $packageRepo, private readonly \App\Appointment\Plan\Repository\SegmentTemplateRepository $segmentRepo, private readonly InventoryItemRepository $inventoryItemRepo, @@ -426,8 +421,6 @@ class ClinicServiceController extends BaseController $this->itemRepo->save($item); - // قیمت سرویس همان تعرفه‌ی سال جاری است؛ هنگام ساخت، تعرفه‌ی سال جاری ثبت می‌شود. - $this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials()); $this->auditService->logCreate($item, $user); return $this->success($this->serializeItems([$item])[0], 201); @@ -447,9 +440,8 @@ class ClinicServiceController extends BaseController $data = json_decode($request->getContent(), true) ?? []; $before = $this->auditService->snapshot($item); - $priceChanged = false; if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); } - if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); $priceChanged = true; } + if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); } if (isset($data['active'])) { $item->setActive((bool) $data['active']); } if (array_key_exists('staff_uuids', $data) || array_key_exists('staff_uuid', $data)) { $staffError = $this->applyStaffMembers($item, $data, $entityType, $entityId); @@ -484,11 +476,6 @@ class ClinicServiceController extends BaseController $this->itemRepo->save($item); - // اگر قیمت پایه تغییر کرد، تعرفه‌ی سال جاری هم همگام می‌شود (قیمت واحد). - if ($priceChanged) { - $this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials()); - } - $this->auditService->logChanges($item, $before, $this->auditService->snapshot($item), $user); return $this->success($this->serializeItems([$item])[0]); @@ -508,59 +495,6 @@ class ClinicServiceController extends BaseController ); } - // ── Tariffs (تعرفه‌ی نسخه‌دار سالانه) ────────────────────────────────────── - - #[Route('/api/v1/service-items/{uuid}/tariffs', methods: ['GET'])] - #[IsGranted('IS_AUTHENTICATED_FULLY')] - public function listTariffs(string $uuid, #[CurrentUser] User $user): JsonResponse - { - $this->denyServices($user, 'view'); - [$entityType, $entityId] = $this->resolveEntity($user); - - $item = $this->itemRepo->findByUuid($uuid); - if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) { - return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404); - } - - $tariffs = $this->tariffRepo->findByService($item->getId()); - - return $this->success([ - 'current_year' => $this->tariffService->currentJalaliYear(), - 'default_price_rials' => $item->getPriceRials(), - 'data' => array_map(fn($t) => $t->toArray(), $tariffs), - ]); - } - - #[Route('/api/v1/service-items/{uuid}/tariffs/{year}', methods: ['PUT'], requirements: ['year' => '\d+'])] - #[IsGranted('IS_AUTHENTICATED_FULLY')] - public function setTariff(string $uuid, int $year, Request $request, #[CurrentUser] User $user): JsonResponse - { - $this->denyServices($user, 'update'); - [$entityType, $entityId] = $this->resolveEntity($user); - - $item = $this->itemRepo->findByUuid($uuid); - if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) { - return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404); - } - - if ($year < 1390 || $year > 1500) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال نامعتبر است', 422); - } - - $data = json_decode($request->getContent(), true) ?? []; - $price = (int) ($data['price_rials'] ?? 0); - - $tariff = $this->tariffService->upsert($item->getId(), $year, $price); - - // تعرفه‌ی سال جاری = قیمت پایه‌ی سرویس (قیمت واحد در همه‌جا). - if ($year === $this->tariffService->currentJalaliYear()) { - $item->setPriceRials($price); - $this->itemRepo->save($item); - } - - return $this->success(['data' => $tariff->toArray()]); - } - // ── Helpers ────────────────────────────────────────────────────────────── /** diff --git a/src/ClinicService/Controller/ServiceCatalogController.php b/src/ClinicService/Controller/ServiceCatalogController.php index 29765c12..7714b2bf 100644 --- a/src/ClinicService/Controller/ServiceCatalogController.php +++ b/src/ClinicService/Controller/ServiceCatalogController.php @@ -446,7 +446,7 @@ class ServiceCatalogController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid'); } - foreach (['price_rials', 'solo_duration_minutes', 'additional_duration_minutes'] as $field) { + foreach (['solo_duration_minutes', 'additional_duration_minutes'] as $field) { $value = $row[$field] ?? null; if ($value !== null && (!is_numeric($value) || (int) $value < 0)) { @@ -464,7 +464,6 @@ class ServiceCatalogController extends BaseController // `isset()` خودش null را رد می‌کند، پس مقایسهٔ اضافه لازم نیست. // `null` یعنی «همان مقدار خودِ سرویس» — صفر نیست. - $override->setPriceRials(isset($row['price_rials']) ? (int) $row['price_rials'] : null); $override->setSoloDurationMinutes(isset($row['solo_duration_minutes']) ? (int) $row['solo_duration_minutes'] : null); $override->setAdditionalDurationMinutes(isset($row['additional_duration_minutes']) ? (int) $row['additional_duration_minutes'] : null); diff --git a/src/ClinicService/Entity/ServiceBranchOverride.php b/src/ClinicService/Entity/ServiceBranchOverride.php index d6f15975..54b98c92 100644 --- a/src/ClinicService/Entity/ServiceBranchOverride.php +++ b/src/ClinicService/Entity/ServiceBranchOverride.php @@ -9,7 +9,9 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** - * قیمت و مدتِ اختصاصیِ یک سرویس در یک شعبه. + * مدتِ اختصاصیِ یک سرویس در یک شعبه. + * + * قیمت اینجا نیست: تنها منبع قیمت `ServiceItem::priceRials` است. * * «شعبه» همان `doctor_addresses` است ({@see docs/new_feture/taskes/_shared/branch-is-doctor-address.md}). * هر ستون تهی‌پذیر است و `null` یعنی «همان مقدار خودِ سرویس» — نه صفر. @@ -38,9 +40,6 @@ class ServiceBranchOverride #[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private DoctorAddress $address; - #[ORM\Column(name: 'price_rials', type: 'bigint', nullable: true)] - private ?int $priceRials = null; - #[ORM\Column(name: 'solo_duration_minutes', type: 'smallint', nullable: true)] private ?int $soloDurationMinutes = null; @@ -68,11 +67,9 @@ class ServiceBranchOverride public function getUuid(): string { return $this->uuid; } public function getItem(): ServiceItem { return $this->item; } public function getAddress(): DoctorAddress { return $this->address; } - public function getPriceRials(): ?int { return $this->priceRials === null ? null : (int) $this->priceRials; } public function getSoloDurationMinutes(): ?int { return $this->soloDurationMinutes; } public function getAdditionalDurationMinutes(): ?int { return $this->additionalDurationMinutes; } - public function setPriceRials(?int $v): self { $this->priceRials = $v; $this->touch(); return $this; } public function setSoloDurationMinutes(?int $v): self { $this->soloDurationMinutes = $v; $this->touch(); return $this; } public function setAdditionalDurationMinutes(?int $v): self { $this->additionalDurationMinutes = $v; $this->touch(); return $this; } @@ -85,7 +82,6 @@ class ServiceBranchOverride 'item_uuid' => $this->item->getUuid(), 'address_uuid' => $this->address->getUuid(), 'address_name' => $this->address->getName(), - 'price_rials' => $this->getPriceRials(), 'solo_duration_minutes' => $this->soloDurationMinutes, 'additional_duration_minutes' => $this->additionalDurationMinutes, ]; diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index d7d4a04c..811cee14 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -104,7 +104,7 @@ class ServiceItem /** * پکیج کالای مصرفی این خدمت ({@see \App\Inventory\Entity\InventoryPackage}). - * ارجاع خام int بدون FK — همان الگوی Tariff و TenantServiceCoverage — تا دامنهٔ + * ارجاع خام int بدون FK — همان الگوی TenantServiceCoverage — تا دامنهٔ * ClinicService به Inventory وابسته نشود. */ #[ORM\Column(name: 'inventory_package_id', type: 'integer', nullable: true)] diff --git a/src/ClinicService/Entity/Tariff.php b/src/ClinicService/Entity/Tariff.php deleted file mode 100644 index 3afb630b..00000000 --- a/src/ClinicService/Entity/Tariff.php +++ /dev/null @@ -1,71 +0,0 @@ -uuid = Uuid::v4()->toRfc4122(); - $this->serviceItemId = $serviceItemId; - $this->year = $year; - $this->priceRials = $priceRials; - $this->createdAt = time(); - $this->updatedAt = time(); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getServiceItemId(): int { return $this->serviceItemId; } - public function getYear(): int { return $this->year; } - public function getPriceRials(): int { return $this->priceRials; } - public function isActive(): bool { return $this->isActive; } - - public function setPriceRials(int $v): self { $this->priceRials = $v; $this->updatedAt = time(); return $this; } - public function setActive(bool $v): self { $this->isActive = $v; $this->updatedAt = time(); return $this; } - - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'service_item_id' => $this->serviceItemId, - 'year' => $this->year, - 'price_rials' => $this->priceRials, - 'is_active' => $this->isActive, - ]; - } -} diff --git a/src/ClinicService/Repository/TariffRepository.php b/src/ClinicService/Repository/TariffRepository.php deleted file mode 100644 index fc1c3bd2..00000000 --- a/src/ClinicService/Repository/TariffRepository.php +++ /dev/null @@ -1,48 +0,0 @@ -findOneBy([ - 'serviceItemId' => $serviceItemId, - 'year' => $year, - 'isActive' => true, - ]); - } - - /** @return Tariff[] */ - public function findByService(int $serviceItemId): array - { - return $this->createQueryBuilder('t') - ->where('t.serviceItemId = :sid') - ->setParameter('sid', $serviceItemId) - ->orderBy('t.year', 'DESC') - ->getQuery() - ->getResult(); - } - - public function findByUuid(string $uuid): ?Tariff - { - return $this->findOneBy(['uuid' => $uuid]); - } - - public function save(Tariff $entity, bool $flush = true): void - { - $this->getEntityManager()->persist($entity); - if ($flush) { - $this->getEntityManager()->flush(); - } - } -} diff --git a/src/ClinicService/Service/DurationCalculator.php b/src/ClinicService/Service/DurationCalculator.php index 67e38e96..46acee51 100644 --- a/src/ClinicService/Service/DurationCalculator.php +++ b/src/ClinicService/Service/DurationCalculator.php @@ -77,16 +77,16 @@ final class DurationCalculator } /** + * قیمت فقط از خودِ سرویس می‌آید؛ شعبه دیگر قیمت اختصاصی ندارد. + * * @param ServiceItem[] $items - * @param array $overrides */ - public function totalPriceRials(array $items, array $overrides = []): int + public function totalPriceRials(array $items): int { $total = 0; foreach ($items as $item) { - $override = $overrides[(int) $item->getId()] ?? null; - $total += $override?->getPriceRials() ?? $item->getPriceRials(); + $total += $item->getPriceRials(); } return $total; @@ -128,7 +128,7 @@ final class DurationCalculator 'minutes' => $isAnchor ? $solos[$index] : ($override?->getAdditionalDurationMinutes() ?? $item->effectiveAdditionalMinutes() ?? $solos[$index]), - 'price_rials' => $override?->getPriceRials() ?? $item->getPriceRials(), + 'price_rials' => $item->getPriceRials(), ]; } diff --git a/src/ClinicService/Service/ResourceServiceResolver.php b/src/ClinicService/Service/ResourceServiceResolver.php index 24fd887d..f31e20a0 100644 --- a/src/ClinicService/Service/ResourceServiceResolver.php +++ b/src/ClinicService/Service/ResourceServiceResolver.php @@ -16,11 +16,11 @@ use App\Resource\Repository\ResourceServiceOfferingRepository; * * ۱. منبع + گزینه → `ResourceServiceOffering(resource, option)` * ۲. منبع + سرویس → `ResourceServiceOffering(resource, parent)` - * ۳. شعبه + آیتم → `ServiceBranchOverride(item, address)` + * ۳. شعبه + آیتم → `ServiceBranchOverride(item, address)` — فقط مدت * ۴. پیش‌فرض آیتم → `ServiceItem` * * **مدت و قیمت جدا حل می‌شوند.** منبعی که فقط مدتش فرق دارد نباید قیمتش هم از همان - * سطح بیاید؛ اگر با هم حل شوند، اولین override باعث می‌شود تعرفهٔ شعبه بی‌صدا نادیده گرفته شود. + * سطح بیاید. شعبه دیگر قیمت اختصاصی ندارد: قیمت روی خودِ سرویس مدیریت می‌شود. * * سطحِ والد را **صدازننده** می‌دهد، نه یک کوئری معکوس روی گروه‌ها: جریان رزرو هر دو را * از قبل در دست دارد (سرویس انتخاب‌شده و گزینه‌اش)، و کوئری معکوس فقط یک راه اضافه برای @@ -66,7 +66,6 @@ final class ResourceServiceResolver [$price, $priceSource] = $this->first([ [$optionOffering?->getPriceRials(), ResolvedServiceSpec::SOURCE_RESOURCE_OPTION], [$parentOffering?->getPriceRials(), ResolvedServiceSpec::SOURCE_RESOURCE_SERVICE], - [$branch?->getPriceRials(), ResolvedServiceSpec::SOURCE_BRANCH], [$item->getPriceRials(), ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT], ]); diff --git a/src/ClinicService/Service/ServiceSelectionValidator.php b/src/ClinicService/Service/ServiceSelectionValidator.php index b92aa2c2..60b62b98 100644 --- a/src/ClinicService/Service/ServiceSelectionValidator.php +++ b/src/ClinicService/Service/ServiceSelectionValidator.php @@ -55,7 +55,7 @@ final class ServiceSelectionValidator 'valid' => $errors === [], 'errors' => $errors, 'total_duration_minutes' => $this->durations->totalMinutes($selected, $overrides), - 'total_price_rials' => $this->durations->totalPriceRials($selected, $overrides), + 'total_price_rials' => $this->durations->totalPriceRials($selected), 'breakdown' => $this->durations->breakdown($selected, $overrides), ]; } diff --git a/src/ClinicService/Service/TariffService.php b/src/ClinicService/Service/TariffService.php deleted file mode 100644 index c6e71c7c..00000000 --- a/src/ClinicService/Service/TariffService.php +++ /dev/null @@ -1,54 +0,0 @@ -currentJalaliYear(); - - $tariff = $service->getId() !== null - ? $this->tariffRepo->findForServiceYear($service->getId(), $year) - : null; - - return $tariff?->getPriceRials() ?? $service->getPriceRials(); - } - - public function upsert(int $serviceItemId, int $year, int $priceRials): Tariff - { - $tariff = $this->tariffRepo->findForServiceYear($serviceItemId, $year); - if ($tariff === null) { - $tariff = new Tariff($serviceItemId, $year, $priceRials); - } else { - $tariff->setPriceRials($priceRials)->setActive(true); - } - $this->tariffRepo->save($tariff); - return $tariff; - } - - public function currentJalaliYear(): int - { - $fmt = new \IntlDateFormatter( - 'en_US@calendar=persian', - \IntlDateFormatter::FULL, - \IntlDateFormatter::NONE, - 'Asia/Tehran', - \IntlDateFormatter::TRADITIONAL, - 'yyyy' - ); - return (int) $fmt->format(time()); - } -} diff --git a/src/ClinicService/ValueObject/ResolvedServiceSpec.php b/src/ClinicService/ValueObject/ResolvedServiceSpec.php index 420b69a4..3cc25d95 100644 --- a/src/ClinicService/ValueObject/ResolvedServiceSpec.php +++ b/src/ClinicService/ValueObject/ResolvedServiceSpec.php @@ -17,7 +17,7 @@ final readonly class ResolvedServiceSpec /** ردیف همان منبع ولی روی سرویسِ والد — وقتی گزینه مقدار خودش را ندارد. */ public const SOURCE_RESOURCE_SERVICE = 'resource_service'; - /** `ServiceBranchOverride` — تنظیم این شعبه، مستقل از اینکه کدام منبع کار را می‌کند. */ + /** `ServiceBranchOverride` — مدتِ این شعبه، مستقل از اینکه کدام منبع کار را می‌کند. */ public const SOURCE_BRANCH = 'branch'; /** مقدار خودِ `ServiceItem`. */ diff --git a/src/Pricing/Controller/PricingController.php b/src/Pricing/Controller/PricingController.php index a353a13d..15b24453 100644 --- a/src/Pricing/Controller/PricingController.php +++ b/src/Pricing/Controller/PricingController.php @@ -7,12 +7,7 @@ use App\Auth\Entity\User; use App\Doctor\Service\AddressResolver; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Repository\ServiceItemRepository; -use App\Pricing\Entity\PriceList; -use App\Pricing\Entity\PriceListItem; -use App\Pricing\Repository\PriceListItemRepository; -use App\Pricing\Repository\PriceListRepository; use App\Pricing\Repository\PriceSnapshotRepository; -use App\Patient\Repository\PatientRecordRepository; use App\Pricing\Service\PricingEngine; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; @@ -31,160 +26,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class PricingController extends BaseController { public function __construct( - private readonly PriceListRepository $lists, - private readonly PriceListItemRepository $listItems, private readonly PriceSnapshotRepository $snapshots, private readonly ServiceItemRepository $items, private readonly PricingEngine $engine, - private readonly AddressResolver $branches, + private readonly AddressResolver $branches, private readonly TenantOwnershipChecker $ownership, private readonly EntityManagerInterface $em, ) {} - #[Route('/api/v1/price-lists', name: 'price_list_index', methods: ['GET'])] - public function index(#[CurrentUser] User $user): JsonResponse - { - [$entityType, $entityId] = $this->branches->pair($user); - - return $this->success(array_map( - static fn (PriceList $l): array => $l->toArray(), - $this->lists->findForPair($entityType, $entityId), - )); - } - - #[Route('/api/v1/price-lists', name: 'price_list_create', methods: ['POST'])] - public function create(#[CurrentUser] User $user, Request $request): JsonResponse - { - $data = json_decode($request->getContent(), true); - - if (!is_array($data) || !is_string($data['name'] ?? null) || trim($data['name']) === '') { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام لیست قیمت الزامی است', 422, 'name'); - } - - if (!is_numeric($data['starts_at'] ?? null) || !is_numeric($data['ends_at'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بازهٔ تاریخ الزامی است', 422, 'starts_at'); - } - - [$entityType, $entityId] = $this->branches->pair($user); - - try { - $list = new PriceList($entityType, $entityId, trim($data['name']), (int) $data['starts_at'], (int) $data['ends_at']); - } catch (\InvalidArgumentException) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پایان بازه باید بعد از شروع آن باشد', 422, 'ends_at'); - } - - if (is_string($data['address_uuid'] ?? null)) { - $list->setAddress($this->branches->resolve($user, $data['address_uuid'])); - } - - $this->em->persist($list); - $this->em->flush(); - - return $this->success($list->toArray(), 201); - } - - #[Route('/api/v1/price-list/{uuid}', name: 'price_list_show', methods: ['GET'])] - public function show(#[CurrentUser] User $user, string $uuid): JsonResponse - { - return $this->success($this->requireList($user, $uuid)->toArray()); - } - - #[Route('/api/v1/price-list/{uuid}', name: 'price_list_update', methods: ['PATCH'])] - public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $data = json_decode($request->getContent(), true); - $list = $this->requireList($user, $uuid); - - if (!is_array($data)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); - } - - if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { - $list->setName(trim($data['name'])); - } - - if (array_key_exists('active', $data)) { - $list->setActive((bool) $data['active']); - } - - $this->em->flush(); - - return $this->success($list->toArray()); - } - - #[Route('/api/v1/price-list/{uuid}', name: 'price_list_delete', methods: ['DELETE'])] - public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $this->em->remove($this->requireList($user, $uuid)); - $this->em->flush(); - - return $this->success(null); - } - - #[Route('/api/v1/price-list/{uuid}/items', name: 'price_list_items_replace', methods: ['PUT'])] - public function replaceItems(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse - { - $data = json_decode($request->getContent(), true); - - if (!is_array($data) || !is_array($data['items'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد items الزامی است', 422, 'items'); - } - - $list = $this->requireList($user, $uuid); - $resolved = []; - - foreach ($data['items'] as $row) { - if (!is_array($row) || !is_string($row['service_uuid'] ?? null) || !is_numeric($row['price_rials'] ?? null)) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'service_uuid و price_rials الزامی‌اند', 422, 'items'); - } - - if ((int) $row['price_rials'] < 0) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'قیمت نمی‌تواند منفی باشد', 422, 'price_rials'); - } - - $resolved[] = [$this->requireItem($user, $row['service_uuid']), (int) $row['price_rials']]; - } - - $this->listItems->deleteForList($list); - $list->getItems()->clear(); - - foreach ($resolved as [$service, $price]) { - $item = new PriceListItem($list, $service, $price); - $this->em->persist($item); - $list->getItems()->add($item); - } - - $list->touch(); - $this->em->flush(); - - return $this->success($list->toArray()); - } - - /** - * فعال‌سازی با بررسی تداخل: دو لیستِ فعالِ هم‌پوشان یعنی یک تاریخ دو قیمت دارد و - * هیچ‌کس نمی‌تواند بگوید کدام درست است. - */ - #[Route('/api/v1/price-list/{uuid}/activate', name: 'price_list_activate', methods: ['POST'])] - public function activate(#[CurrentUser] User $user, string $uuid): JsonResponse - { - $list = $this->requireList($user, $uuid); - $conflicts = $this->lists->findOverlapping($list); - - if ($conflicts !== []) { - return $this->error( - ErrorCodes::ERR_VALIDATION_001, - sprintf('بازهٔ این لیست با «%s» هم‌پوشانی دارد', $conflicts[0]->getName()), - 422, - 'starts_at', - ); - } - - $list->setActive(true); - $this->em->flush(); - - return $this->success($list->toArray()); - } - #[Route('/api/v1/pricing/quote', name: 'pricing_quote', methods: ['POST'])] public function quote(#[CurrentUser] User $user, Request $request): JsonResponse { @@ -236,18 +85,6 @@ class PricingController extends BaseController return $this->success($snapshot->toArray()); } - private function requireList(User $user, string $uuid): PriceList - { - $list = $this->lists->findByUuid($uuid); - [$entityType, $entityId] = $this->branches->pair($user); - - if ($list === null || !$this->ownership->belongsToPair($entityType, $entityId, $list)) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'لیست قیمت یافت نشد', 404); - } - - return $list; - } - private function requireItem(User $user, string $uuid): ServiceItem { $item = $this->items->findByUuid($uuid); diff --git a/src/Pricing/Entity/PriceList.php b/src/Pricing/Entity/PriceList.php deleted file mode 100644 index 7ec6c138..00000000 --- a/src/Pricing/Entity/PriceList.php +++ /dev/null @@ -1,125 +0,0 @@ - false])] - private bool $active = false; - - #[ORM\Column(name: 'created_at', type: 'integer')] - private int $createdAt; - - #[ORM\Column(name: 'updated_at', type: 'integer')] - private int $updatedAt; - - /** @var Collection */ - #[ORM\OneToMany(targetEntity: PriceListItem::class, mappedBy: 'priceList', cascade: ['persist', 'remove'], orphanRemoval: true)] - private Collection $items; - - public function __construct(string $entityType, int $entityId, string $name, int $startsAt, int $endsAt) - { - if ($endsAt <= $startsAt) { - throw new \InvalidArgumentException('Price list end must be after its start.'); - } - - $this->uuid = Uuid::v4()->toRfc4122(); - $this->name = $name; - $this->startsAt = $startsAt; - $this->endsAt = $endsAt; - $this->createdAt = time(); - $this->updatedAt = time(); - $this->items = new ArrayCollection(); - - $this->assignTenantPair($entityType, $entityId); - } - - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getAddress(): ?DoctorAddress { return $this->address; } - public function getName(): string { return $this->name; } - public function getStartsAt(): int { return $this->startsAt; } - public function getEndsAt(): int { return $this->endsAt; } - public function isActive(): bool { return $this->active; } - - /** @return Collection */ - public function getItems(): Collection { return $this->items; } - - public function setAddress(?DoctorAddress $v): self { $this->address = $v; $this->touch(); return $this; } - public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } - public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } - - public function covers(int $at): bool - { - return $this->active && $at >= $this->startsAt && $at < $this->endsAt; - } - - public function overlaps(int $startsAt, int $endsAt): bool - { - return $startsAt < $this->endsAt && $endsAt > $this->startsAt; - } - - public function touch(): void { $this->updatedAt = time(); } - - public function toArray(): array - { - return [ - 'uuid' => $this->uuid, - 'name' => $this->name, - 'address_uuid' => $this->address?->getUuid(), - 'address_name' => $this->address?->getName(), - 'starts_at' => $this->startsAt, - 'ends_at' => $this->endsAt, - 'active' => $this->active, - 'items' => array_map( - static fn (PriceListItem $i): array => $i->toArray(), - $this->items->toArray(), - ), - ]; - } -} diff --git a/src/Pricing/Entity/PriceListItem.php b/src/Pricing/Entity/PriceListItem.php deleted file mode 100644 index 931abd7e..00000000 --- a/src/Pricing/Entity/PriceListItem.php +++ /dev/null @@ -1,58 +0,0 @@ -priceList = $priceList; - $this->serviceItem = $serviceItem; - $this->priceRials = $priceRials; - } - - public function getId(): ?int { return $this->id; } - public function getPriceList(): PriceList { return $this->priceList; } - public function getServiceItem(): ServiceItem { return $this->serviceItem; } - public function getPriceRials(): int { return (int) $this->priceRials; } - - public function toArray(): array - { - return [ - 'service_uuid' => $this->serviceItem->getUuid(), - 'service_name' => $this->serviceItem->getName(), - 'price_rials' => $this->getPriceRials(), - ]; - } -} diff --git a/src/Pricing/Repository/PriceListItemRepository.php b/src/Pricing/Repository/PriceListItemRepository.php deleted file mode 100644 index 950e69b1..00000000 --- a/src/Pricing/Repository/PriceListItemRepository.php +++ /dev/null @@ -1,59 +0,0 @@ - - */ -class PriceListItemRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, PriceListItem::class); - } - - public function deleteForList(PriceList $list): int - { - return (int) $this->createQueryBuilder('i') - ->delete() - ->where('i.priceList = :list') - ->setParameter('list', $list) - ->getQuery() - ->execute(); - } - - /** - * قیمت چند سرویس در یک لیست — یک کوئری، نه یکی per سرویس. - * - * @param ServiceItem[] $services - * @return array شناسهٔ سرویس => قیمت - */ - public function priceMap(PriceList $list, array $services): array - { - if ($services === []) { - return []; - } - - $rows = $this->createQueryBuilder('i') - ->select('IDENTITY(i.serviceItem) AS service_id, i.priceRials AS price') - ->where('i.priceList = :list') - ->andWhere('i.serviceItem IN (:services)') - ->setParameter('list', $list) - ->setParameter('services', $services) - ->getQuery() - ->getArrayResult(); - - $map = []; - foreach ($rows as $row) { - $map[(int) $row['service_id']] = (int) $row['price']; - } - - return $map; - } -} diff --git a/src/Pricing/Repository/PriceListRepository.php b/src/Pricing/Repository/PriceListRepository.php deleted file mode 100644 index b622aeb5..00000000 --- a/src/Pricing/Repository/PriceListRepository.php +++ /dev/null @@ -1,102 +0,0 @@ - - */ -class PriceListRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, PriceList::class); - } - - public function findByUuid(string $uuid): ?PriceList - { - return $this->findOneBy(['uuid' => $uuid]); - } - - /** @return PriceList[] */ - public function findForPair(string $entityType, int $entityId): array - { - return $this->createQueryBuilder('p') - ->where('p.entityType = :type') - ->andWhere('p.entityId = :id') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->orderBy('p.startsAt', 'DESC') - ->getQuery() - ->getResult(); - } - - /** - * لیست قیمتِ حاکم بر یک لحظه. - * - * لیستِ مخصوصِ همان شعبه بر لیست عمومیِ محیط مقدم است — وگرنه تعریف استثنا برای - * یک شعبه هیچ اثری نداشت. - */ - public function findCovering(string $entityType, int $entityId, ?DoctorAddress $address, int $at): ?PriceList - { - $rows = $this->createQueryBuilder('p') - ->where('p.entityType = :type') - ->andWhere('p.entityId = :id') - ->andWhere('p.active = true') - ->andWhere('p.startsAt <= :at') - ->andWhere('p.endsAt > :at') - ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->setParameter('at', $at) - ->getQuery() - ->getResult(); - - $general = null; - - foreach ($rows as $list) { - if ($address !== null && $list->getAddress()?->getId() === $address->getId()) { - return $list; - } - - if ($list->getAddress() === null) { - $general = $list; - } - } - - return $general; - } - - /** - * لیست‌های فعالِ هم‌پوشان با یک بازه — برای جلوگیری از دو قیمتِ هم‌زمان. - * - * @return PriceList[] - */ - public function findOverlapping(PriceList $candidate): array - { - $qb = $this->createQueryBuilder('p') - ->where('p.entityType = :type') - ->andWhere('p.entityId = :id') - ->andWhere('p.active = true') - ->andWhere('p.startsAt < :ends') - ->andWhere('p.endsAt > :starts') - ->setParameter('type', $candidate->getEntityType()) - ->setParameter('id', $candidate->getEntityId()) - ->setParameter('starts', $candidate->getStartsAt()) - ->setParameter('ends', $candidate->getEndsAt()); - - if ($candidate->getId() !== null) { - $qb->andWhere('p.id != :self')->setParameter('self', $candidate->getId()); - } - - // فقط لیست‌هایی که دامنهٔ یکسانی دارند با هم تداخل دارند: لیست عمومی و لیست - // یک شعبه عمداً کنار هم زندگی می‌کنند و اولویت دارند، نه تداخل. - return array_values(array_filter( - $qb->getQuery()->getResult(), - static fn (PriceList $other): bool => $other->getAddress()?->getId() === $candidate->getAddress()?->getId(), - )); - } -} diff --git a/src/Pricing/Service/PricingEngine.php b/src/Pricing/Service/PricingEngine.php index 486606ca..1f15eb21 100644 --- a/src/Pricing/Service/PricingEngine.php +++ b/src/Pricing/Service/PricingEngine.php @@ -3,14 +3,8 @@ namespace App\Pricing\Service; use App\ClinicService\Entity\ServiceItem; -use App\ClinicService\Repository\ServiceBranchOverrideRepository; -use App\ClinicService\Repository\TariffRepository; use App\Doctor\Entity\DoctorAddress; -use App\Patient\Entity\PatientRecord; -use App\Pricing\Repository\PriceListItemRepository; -use App\Pricing\Repository\PriceListRepository; use App\Pricing\ValueObject\PriceQuote; -use App\Representation\Service\JalaliDateService; /** * زنجیرهٔ قیمت‌گذاری بند ۱۲ مستند. @@ -19,27 +13,14 @@ use App\Representation\Service\JalaliDateService; * قیمت پایه → + آیتم‌ها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه * ``` * - * ## زنجیرهٔ منبع قیمت + * ## منبع قیمت * - * برای هر سرویس، اولین چیزی که پیدا شود برنده است: - * - * ۱. override شعبه ({@see \App\ClinicService\Entity\ServiceBranchOverride}) — تسک ۰۴ - * ۲. لیست قیمتِ حاکم بر آن تاریخ — همین تسک - * ۳. `Tariff` سال — لایهٔ موجود - * ۴. `ServiceItem::priceRials` — همیشه هست - * - * مرحلهٔ چهارم ضامن است که **هرگز صفر یا خطا** برنگردد: تاریخی که هیچ لیستی نمی‌پوشاند - * باید قیمت بدهد، نه استثنا. + * تنها منبع قیمت، `ServiceItem::priceRials` است — قیمت روی خودِ سرویس مدیریت می‌شود. + * لایه‌های پیشینِ «لیست قیمت»، «تعرفهٔ سالانه» و «قیمت اختصاصی شعبه» حذف شده‌اند تا یک + * تاریخ هرگز دو قیمت نداشته باشد. */ final class PricingEngine { - public function __construct( - private readonly PriceListRepository $priceLists, - private readonly PriceListItemRepository $priceListItems, - private readonly ServiceBranchOverrideRepository $overrides, - private readonly TariffRepository $tariffs, - private readonly JalaliDateService $jalali, - ) {} /** * @param ServiceItem[] $items آیتم‌های انتخاب‌شده (بدون خودِ سرویس) @@ -57,18 +38,13 @@ final class PricingEngine int $at, array $policy = [], ): PriceQuote { - $entityType = $address->tenantEntityType(); - $entityId = $address->tenantEntityId(); - - $list = $this->priceLists->findCovering($entityType, $entityId, $address, $at); - $sources = []; - $base = $this->priceFor($service, $address, $list, $at, $sources); + $base = $this->priceFor($service, $sources); $itemsTotal = 0; foreach ($items as $item) { - $itemsTotal += $this->priceFor($item, $address, $list, $at, $sources); + $itemsTotal += $this->priceFor($item, $sources); } $subtotal = $base + $itemsTotal; @@ -121,39 +97,8 @@ final class PricingEngine /** * @param array $sources */ - private function priceFor( - ServiceItem $service, - DoctorAddress $address, - ?\App\Pricing\Entity\PriceList $list, - int $at, - array &$sources, - ): int { - $override = $this->overrides->mapForAddress([(int) $service->getId()], $address)[(int) $service->getId()] ?? null; - - if ($override?->getPriceRials() !== null) { - $sources[$service->getUuid()] = 'branch_override'; - - return $override->getPriceRials(); - } - - if ($list !== null) { - $price = $this->priceListItems->priceMap($list, [$service])[(int) $service->getId()] ?? null; - - if ($price !== null) { - $sources[$service->getUuid()] = 'price_list'; - - return $price; - } - } - - $tariff = $this->tariffs->findForServiceYear((int) $service->getId(), $this->jalali->jalaliYear($at)); - - if ($tariff !== null) { - $sources[$service->getUuid()] = 'tariff'; - - return (int) $tariff->getPriceRials(); - } - + private function priceFor(ServiceItem $service, array &$sources): int + { $sources[$service->getUuid()] = 'service_item'; return $service->getPriceRials(); diff --git a/src/Shared/Command/BookingEngineSeeder.php b/src/Shared/Command/BookingEngineSeeder.php index c5b9721b..ea512b70 100644 --- a/src/Shared/Command/BookingEngineSeeder.php +++ b/src/Shared/Command/BookingEngineSeeder.php @@ -18,8 +18,6 @@ use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceItemRelation; use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; -use App\Pricing\Entity\PriceList; -use App\Pricing\Entity\PriceListItem; use App\Pricing\Service\PriceSnapshotService; use App\Pricing\ValueObject\PriceQuote; use App\Resource\Entity\ClinicResource; @@ -87,7 +85,6 @@ final class BookingEngineSeeder $counts['catalog'] = $this->catalog($entityType, $entityId, $services, $address); $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); @@ -127,9 +124,9 @@ final class BookingEngineSeeder $this->em()->persist(new ServiceItemRelation($services[0], $services[1], ServiceItemRelation::TYPE_INCOMPATIBLE)); } - // قیمت و مدت این سرویس در این شعبه فرق دارد — تا override واقعاً تست شود. + // مدت این سرویس در این شعبه فرق دارد — تا override واقعاً تست شود. $override = new ServiceBranchOverride($services[1] ?? $services[0], $address); - $override->setPriceRials((int) round(($services[1] ?? $services[0])->getPriceRials() * 1.2)); + $override->setSoloDurationMinutes(max(5, (($services[1] ?? $services[0])->getSoloDurationMinutes() ?? 20) + 10)); $this->em()->persist($override); $this->em()->flush(); @@ -261,25 +258,6 @@ final class BookingEngineSeeder return $made; } - // ── تسک ۰۸: لیست قیمت ─────────────────────────────────────────────────── - - private function priceList(string $entityType, int $entityId, array $services): int - { - $list = new PriceList($entityType, $entityId, 'تعرفهٔ نیم‌سال دوم', strtotime('-30 days'), strtotime('+180 days')); - $list->setActive(true); - $this->em()->persist($list); - $this->em()->flush(); - - foreach ($services as $service) { - // قیمت لیست عمداً با قیمت پایهٔ سرویس فرق دارد: اگر یکی بودند، معلوم نمی‌شد - // snapshot از کدام منبع خوانده است. - $this->em()->persist(new PriceListItem($list, $service, (int) round($service->getPriceRials() * 0.9))); - } - $this->em()->flush(); - - return count($services); - } - // ── تسک ۰۹: سیاست‌ها، یکی از هر دسته ──────────────────────────────────── // ── تسک ۰۶ و ۰۷: رزرو واقعی روی تقویم منابع ───────────────────────────── diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index a65cf544..444a1ad9 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -109,12 +109,10 @@ final class GlobalTables \App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class, - \App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class, // یال «این دسته شامل آن دسته است» جزئی از تعریف دستهٔ والد است؛ هر دو سرِ یال // در یک محیط‌اند و سازندهٔ یال همین را اجبار می‌کند. \App\ClinicService\Entity\CatalogCategoryInclude::class => \App\ClinicService\Entity\CatalogCategory::class, - \App\Pricing\Entity\PriceListItem::class => \App\Pricing\Entity\PriceList::class, \App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class, \App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class, diff --git a/tests/ClinicService/ResourceServiceResolverTest.php b/tests/ClinicService/ResourceServiceResolverTest.php index fb14e5b6..ee693f83 100644 --- a/tests/ClinicService/ResourceServiceResolverTest.php +++ b/tests/ClinicService/ResourceServiceResolverTest.php @@ -81,10 +81,10 @@ class ResourceServiceResolverTest extends ApiTestCase return $offering; } - private function branchOverride(ServiceItem $item, ?int $minutes, ?int $price): void + private function branchOverride(ServiceItem $item, ?int $minutes): void { $override = new ServiceBranchOverride($item, $this->address); - $override->setSoloDurationMinutes($minutes)->setPriceRials($price); + $override->setSoloDurationMinutes($minutes); $this->em->persist($override); $this->em->flush(); } @@ -100,7 +100,7 @@ class ResourceServiceResolverTest extends ApiTestCase { $this->offer($this->option, 15, 9_500_000); $this->offer($this->service, 40, 12_000_000); - $this->branchOverride($this->option, 50, 11_000_000); + $this->branchOverride($this->option, 50); $spec = $this->resolve(); @@ -113,7 +113,7 @@ class ResourceServiceResolverTest extends ApiTestCase public function testLevelTwoResourcePlusServiceWinsWhenTheOptionHasNothing(): void { $this->offer($this->service, 40, 12_000_000); - $this->branchOverride($this->option, 50, 11_000_000); + $this->branchOverride($this->option, 50); $spec = $this->resolve(); @@ -122,15 +122,17 @@ class ResourceServiceResolverTest extends ApiTestCase self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_SERVICE, $spec->durationSource); } - public function testLevelThreeBranchWinsWhenTheResourceHasNothing(): void + /** شعبه فقط مدت می‌دهد؛ قیمتش را از خودِ سرویس می‌گیرد. */ + public function testLevelThreeBranchWinsForDurationOnly(): void { - $this->branchOverride($this->option, 50, 11_000_000); + $this->branchOverride($this->option, 50); $spec = $this->resolve(); self::assertSame(50, $spec->durationMinutes); - self::assertSame(11_000_000, $spec->priceRials); self::assertSame(ResolvedServiceSpec::SOURCE_BRANCH, $spec->durationSource); + self::assertSame(8_000_000, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->priceSource); } public function testLevelFourFallsBackToTheItemItself(): void @@ -147,17 +149,17 @@ class ResourceServiceResolverTest extends ApiTestCase public function testDurationAndPriceResolveIndependently(): void { - // منبع فقط مدت را می‌گوید؛ قیمت باید تا سطح شعبه پایین برود. + // منبع فقط مدت را می‌گوید؛ قیمت باید تا خودِ سرویس پایین برود. $this->offer($this->option, 15, null); - $this->branchOverride($this->option, null, 11_000_000); + $this->branchOverride($this->option, null); $spec = $this->resolve(); self::assertSame(15, $spec->durationMinutes); self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->durationSource); - self::assertSame(11_000_000, $spec->priceRials); - self::assertSame(ResolvedServiceSpec::SOURCE_BRANCH, $spec->priceSource); + self::assertSame(8_000_000, $spec->priceRials); + self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->priceSource); } public function testAnInactiveOfferingIsSkippedEntirely(): void diff --git a/tests/ClinicService/ServiceSelectionTest.php b/tests/ClinicService/ServiceSelectionTest.php index 388565c0..3e5c24c2 100644 --- a/tests/ClinicService/ServiceSelectionTest.php +++ b/tests/ClinicService/ServiceSelectionTest.php @@ -255,7 +255,8 @@ class ServiceSelectionTest extends ApiTestCase self::assertStringContainsString('حلقه', $body['errors'][0]['message']); } - public function testBranchOverrideChangesPriceAndDuration(): void + /** شعبه فقط مدت را عوض می‌کند؛ قیمت همیشه از خودِ سرویس است. */ + public function testBranchOverrideChangesDurationButNotPrice(): void { [$user, , $section, $address] = $this->clinicWithSection(); $face = $this->item($section, 'صورت', 15, 8, 500_000); @@ -263,7 +264,6 @@ class ServiceSelectionTest extends ApiTestCase $this->authJson('PUT', "/api/v1/service-item/{$face->getUuid()}/branch-overrides", $user, [ 'overrides' => [[ 'address_uuid' => $address->getUuid(), - 'price_rials' => 900_000, 'solo_duration_minutes' => 25, ]], ]); @@ -274,7 +274,7 @@ class ServiceSelectionTest extends ApiTestCase self::assertSame(15, $plain['data']['total_duration_minutes']); $atBranch = $this->validate($user, [$face->getUuid()], ['branch_uuid' => $address->getUuid()]); - self::assertSame(900_000, $atBranch['data']['total_price_rials']); + self::assertSame(500_000, $atBranch['data']['total_price_rials'], 'قیمت شعبه‌ای وجود ندارد'); self::assertSame(25, $atBranch['data']['total_duration_minutes']); } diff --git a/tests/Pricing/PricingTest.php b/tests/Pricing/PricingTest.php index ef1290ef..ff50968d 100644 --- a/tests/Pricing/PricingTest.php +++ b/tests/Pricing/PricingTest.php @@ -10,7 +10,10 @@ use App\Doctor\Entity\DoctorAddress; use App\Tests\ApiTestCase; /** - * لیست قیمت بازه‌دار و فاکتور تفکیک‌شده — بند ۱۲ و قانون پنجم مستند. + * زنجیرهٔ قیمت‌گذاری و فاکتور تفکیک‌شده — بند ۱۲ و قانون پنجم مستند. + * + * قیمت تنها یک منبع دارد: `ServiceItem::priceRials`. لیست قیمت، تعرفهٔ سالانه و قیمت + * اختصاصی شعبه حذف شده‌اند. */ class PricingTest extends ApiTestCase { @@ -56,20 +59,7 @@ class PricingTest extends ApiTestCase ]); } - private function priceList(User $user, string $name, int $from, int $to, ?string $addressUuid = null): array - { - $body = $this->authJson('POST', '/api/v1/price-lists', $user, array_filter([ - 'name' => $name, - 'starts_at' => $from, - 'ends_at' => $to, - 'address_uuid' => $addressUuid, - ])); - self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); - - return $body['data']; - } - - /** بدون هیچ لیست قیمتی، قیمت خودِ سرویس برمی‌گردد — هرگز صفر یا خطا. */ + /** قیمت همیشه از خودِ سرویس می‌آید — هرگز صفر یا خطا. */ public function testFallsBackToTheServicePrice(): void { $c = $this->clinic(); @@ -83,99 +73,42 @@ class PricingTest extends ApiTestCase self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]); } - /** لیست قیمت فقط در بازهٔ خودش حاکم است. */ - public function testPriceListAppliesOnlyInsideItsRange(): void + /** override شعبه فقط مدت را عوض می‌کند؛ قیمت همچنان از خودِ سرویس می‌آید. */ + public function testBranchOverrideNoLongerChangesThePrice(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'بوتاکس', 5_000_000); - $from = strtotime('+10 days'); - $to = strtotime('+40 days'); - - $list = $this->priceList($c['user'], 'نیمهٔ دوم', $from, $to); - - $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ - 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 8_000_000]], - ]); - self::assertSame(200, $this->responseCode()); - - $this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']); - self::assertSame(200, $this->responseCode()); - - $inside = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); - self::assertSame(8_000_000, $inside['data']['base_rials'], 'داخل بازه: قیمت جدید'); - - $before = $this->quote($c['user'], $service, $c['address'], ['at' => $from - 86400]); - self::assertSame(5_000_000, $before['data']['base_rials'], 'پیش از بازه: قیمت قبلی'); - } - - /** دو لیست فعالِ هم‌پوشان یعنی یک تاریخ دو قیمت — هنگام فعال‌سازی رد می‌شود. */ - public function testOverlappingActiveListsAreRejected(): void - { - $c = $this->clinic(); - $from = strtotime('+10 days'); - - $first = $this->priceList($c['user'], 'اول', $from, $from + 30 * 86400); - $this->authJson('POST', "/api/v1/price-list/{$first['uuid']}/activate", $c['user']); - self::assertSame(200, $this->responseCode()); - - $second = $this->priceList($c['user'], 'دوم', $from + 10 * 86400, $from + 50 * 86400); - $body = $this->authJson('POST', "/api/v1/price-list/{$second['uuid']}/activate", $c['user']); - - self::assertSame(422, $this->responseCode()); - self::assertStringContainsString('هم‌پوشانی', $body['errors'][0]['message']); - } - - /** لیستِ یک شعبه با لیست عمومی تداخل ندارد و بر آن مقدم است. */ - public function testBranchListWinsOverTheGeneralList(): void - { - $c = $this->clinic(); - $service = $this->service($c['section'], 'بوتاکس', 5_000_000); - - $from = strtotime('+10 days'); - $to = $from + 30 * 86400; - - $general = $this->priceList($c['user'], 'عمومی', $from, $to); - $this->authJson('PUT', "/api/v1/price-list/{$general['uuid']}/items", $c['user'], [ - 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]], - ]); - $this->authJson('POST', "/api/v1/price-list/{$general['uuid']}/activate", $c['user']); - self::assertSame(200, $this->responseCode()); - - $branch = $this->priceList($c['user'], 'شعبهٔ مرکزی', $from, $to, $c['address']->getUuid()); - $this->authJson('PUT', "/api/v1/price-list/{$branch['uuid']}/items", $c['user'], [ - 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_000_000]], - ]); - $this->authJson('POST', "/api/v1/price-list/{$branch['uuid']}/activate", $c['user']); - self::assertSame(200, $this->responseCode(), 'لیست شعبه با لیست عمومی تداخل ندارد'); - - $body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); - - self::assertSame(9_000_000, $body['data']['base_rials']); - } - - /** override شعبه (تسک ۰۴) بر لیست قیمت مقدم است. */ - public function testBranchOverrideBeatsThePriceList(): void - { - $c = $this->clinic(); - $service = $this->service($c['section'], 'بوتاکس', 5_000_000); - - $from = strtotime('+10 days'); - $list = $this->priceList($c['user'], 'عمومی', $from, $from + 30 * 86400); - $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ - 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]], - ]); - $this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']); - $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [ - 'overrides' => [['address_uuid' => $c['address']->getUuid(), 'price_rials' => 11_000_000]], + 'overrides' => [[ + 'address_uuid' => $c['address']->getUuid(), + 'solo_duration_minutes' => 45, + ]], ]); self::assertSame(200, $this->responseCode()); - $body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); + $body = $this->quote($c['user'], $service, $c['address']); - self::assertSame(11_000_000, $body['data']['base_rials']); - self::assertSame('branch_override', $body['data']['breakdown']['sources'][$service->getUuid()]); + self::assertSame(5_000_000, $body['data']['base_rials']); + self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]); + } + + /** قیمتِ ارسالی برای override نادیده گرفته می‌شود — شعبه دیگر قیمت ندارد. */ + public function testBranchOverridePayloadHasNoPriceField(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'لیزر', 3_000_000); + + $body = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [ + 'overrides' => [[ + 'address_uuid' => $c['address']->getUuid(), + 'price_rials' => 9_000_000, + ]], + ]); + + self::assertSame(200, $this->responseCode()); + self::assertArrayNotHasKey('price_rials', $body['data'][0]); + self::assertSame(3_000_000, $this->quote($c['user'], $service, $c['address'])['data']['base_rials']); } public function testFullChainAppliesInOrder(): void @@ -305,20 +238,6 @@ class PricingTest extends ApiTestCase self::assertSame(404, $this->responseCode()); } - public function testNegativePriceIsRejected(): void - { - $c = $this->clinic(); - $service = $this->service($c['section'], 'ویزیت', 1_000_000); - $list = $this->priceList($c['user'], 'تست', strtotime('+1 day'), strtotime('+30 days')); - - $body = $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ - 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => -100]], - ]); - - self::assertSame(422, $this->responseCode()); - self::assertSame('price_rials', $body['errors'][0]['field']); - } - /** * ⭐ قانون پنجم مستند: «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.» * @@ -477,20 +396,4 @@ class PricingTest extends ApiTestCase self::assertSame(0, $snapshot['data']['items_rials']); self::assertSame($snapshot['data']['base_rials'], $snapshot['data']['final_rials']); } - - public function testDraftListHasNoEffectUntilActivated(): void - { - $c = $this->clinic(); - $service = $this->service($c['section'], 'بوتاکس', 5_000_000); - - $from = strtotime('+2 days'); - $list = $this->priceList($c['user'], 'پیش‌نویس', $from, $from + 30 * 86400); - $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [ - 'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_999_999]], - ]); - - $body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]); - - self::assertSame(5_000_000, $body['data']['base_rials'], 'پیش‌نویس نباید قیمت را عوض کند'); - } } diff --git a/tests/Shared/TenantLookupInventoryTest.php b/tests/Shared/TenantLookupInventoryTest.php index c6de6aa8..4f8de2b8 100644 --- a/tests/Shared/TenantLookupInventoryTest.php +++ b/tests/Shared/TenantLookupInventoryTest.php @@ -45,7 +45,7 @@ class TenantLookupInventoryTest extends TestCase // ownsSession / getEntityType روی صورتحساب، پرونده و مطالبه 'src/Billing/Controller/BillingController.php' => 3, // ownsSection ×۹ و مقایسهٔ مستقیم جفت ×۳ (پکیج، کالا، پرسنل) - 'src/ClinicService/Controller/ClinicServiceController.php' => 10, + 'src/ClinicService/Controller/ClinicServiceController.php' => 8, 'src/Discount/Controller/DiscountController.php' => 1, // قرارداد بیمه با جفت، و سرویس با getSection()->getEntityType() 'src/Insurance/Controller/InsuranceController.php' => 5,