refactor(pricing): make the service the only price source

Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.

- drop PriceList/PriceListItem, their repositories and the seven
  /api/v1/price-list(s) endpoints; PricingController keeps only quote and
  the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
  /service-items/{uuid}/tariffs endpoints; creating or repricing a service
  no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
  duration columns, which DurationCalculator and ServiceSelectionValidator
  still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
  reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
  tariff modal and the service detail tariffs tab; useAppointmentInvoice
  moves to its own hook file

Migration drops price_lists, price_list_items, service_tariffs and the
override price column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 18:00:48 +03:30
co-authored by Claude Opus 5
parent f06efe26c0
commit 4fe0c4f9bf
41 changed files with 208 additions and 1835 deletions
-2
View File
@@ -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() {
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
<Route path="price-lists" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PriceListsPage /></RoleRoute>} />
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></RoleRoute>} />
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
@@ -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;
@@ -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<number | ''>('');
const [price, setPrice] = useState(0);
const [editYear, setEditYear] = useState<number | null>(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 (
<Modal open={!!item} onClose={onClose} title={`تعرفه‌های سالانه — ${item?.name ?? ''}`} size="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{resp && (
<div style={{
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-subtle)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
}}>
<span>قیمت پایهی سرویس همان تعرفهی سال جاری ({currentYear ? formatYear(currentYear) : '—'}) است و همهجا از همین استفاده میشود. تعرفهی سالهای دیگر فقط برای صورتحساب همان سال بهکار میرود.</span>
</div>
)}
{/* افزودن تعرفه‌ی جدید */}
<div style={{ padding: 14, borderRadius: 'var(--r)', border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 10 }}>افزودن تعرفهی سال</div>
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr auto', gap: 10, alignItems: 'end' }}>
<div style={{ minWidth: 0 }}>
<label className="field-label">سال (شمسی)</label>
<SearchableSelect
options={yearOptions}
value={year === '' ? '' : year}
onChange={(v) => setYear(v != null && v !== '' ? Number(v) : '')}
placeholder="انتخاب سال"
noOptionsMessage="سالی باقی نمانده"
height={40}
/>
</div>
<div style={{ minWidth: 0 }}>
<label className="field-label">تعرفه (تومان)</label>
<PriceInput className="input" style={{ height: 40 }} value={price} onChange={setPrice} placeholder="مبلغ" min={0} />
</div>
<button className="btn primary" style={{ height: 40 }} disabled={year === '' || addMut.isPending} onClick={() => addMut.mutate()}>
{addMut.isPending ? '...' : 'افزودن'}
</button>
</div>
</div>
{/* فهرست تعرفه‌ها */}
{isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : tariffs.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', textAlign: 'center', padding: '12px 0' }}>هنوز تعرفهای ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{tariffs.map((t) => {
const isCurrent = t.year === currentYear;
const isEditing = editYear === t.year;
return (
<div key={t.uuid} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
borderRadius: 9, border: '1px solid var(--border)',
background: isCurrent ? 'var(--primary-subtle)' : 'var(--bg)',
}}>
<span style={{ fontWeight: 700, fontSize: 13, minWidth: 70 }}>
سال {formatYear(t.year)}
{isCurrent && <span className="badge green" style={{ fontSize: 9.5, marginInlineStart: 6 }}><span className="bdot" />جاری</span>}
</span>
{isEditing ? (
<>
<div style={{ flex: 1 }}>
<PriceInput className="input" style={{ height: 36 }} value={editPrice} onChange={setEditPrice} min={0} />
</div>
<button className="btn primary sm" disabled={editMut.isPending} onClick={() => editMut.mutate({ year: t.year, price: editPrice })} title="ذخیره">
<CheckIcon style={{ width: 14 }} />
</button>
<button className="btn sm" onClick={() => setEditYear(null)} title="انصراف">
<XMarkIcon style={{ width: 14 }} />
</button>
</>
) : (
<>
<span style={{ flex: 1, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }} dir="ltr">{formatRial(t.price_rials)}</span>
<button className="btn sm ghost" onClick={() => startEdit(t)} title="ویرایش">
<PencilIcon style={{ width: 14 }} />
</button>
</>
)}
</div>
);
})}
</div>
)}
</div>
</Modal>
);
}
@@ -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'] },
@@ -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<string, unknown>;
};
}
/** فاکتور یک نوبت — همان اعداد لحظهٔ ثبت، حتی اگر قیمت سرویس بعداً عوض شده باشد. */
export function useAppointmentInvoice(appointmentUuid: string | undefined) {
const query = useQuery({
queryKey: ['appointment-invoice', appointmentUuid],
queryFn: () =>
api.get<ApiResponse<PriceSnapshot>>(`/api/v1/appointment/${appointmentUuid}/price-snapshot`),
enabled: !!appointmentUuid,
// نوبتِ بدون فاکتور ۴۰۴ می‌دهد و آن خطا نیست، یعنی «هنوز ثبت نشده».
retry: false,
});
return { invoice: query.data?.data, loading: query.isLoading, missing: query.isError };
}
-131
View File
@@ -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<string, unknown>;
};
}
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<ApiResponse<PriceList[]>>('/api/v1/price-lists'),
});
const invalidate = () => qc.invalidateQueries({ queryKey: KEY });
const create = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post<ApiResponse<PriceList>>('/api/v1/price-lists', body),
onSuccess: () => {
toast.success('لیست قیمت ساخته شد — تا فعال نشود اثری ندارد');
invalidate();
},
onError: (e) => fail(e, 'ساخت لیست قیمت ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
api.patch<ApiResponse<PriceList>>(`/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<ApiResponse<PriceList>>(`/api/v1/price-list/${uuid}/items`, { items }),
onSuccess: () => {
toast.success('قیمت‌ها ذخیره شد');
invalidate();
},
onError: (e) => fail(e, 'ذخیرهٔ قیمت‌ها ناموفق بود'),
});
/** تداخل بازه با لیست فعالِ هم‌دامنه اینجا ۴۲۲ می‌گیرد؛ پیام سرور دقیق‌تر است. */
const activate = useMutation({
mutationFn: (uuid: string) => api.post<ApiResponse<PriceList>>(`/api/v1/price-list/${uuid}/activate`, {}),
onSuccess: () => {
toast.success('لیست قیمت فعال شد');
invalidate();
},
onError: (e) => fail(e, 'فعال‌سازی ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/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<ApiResponse<PriceSnapshot>>(`/api/v1/appointment/${appointmentUuid}/price-snapshot`),
enabled: !!appointmentUuid,
// نوبتِ بدون فاکتور ۴۰۴ می‌دهد و آن خطا نیست، یعنی «هنوز ثبت نشده».
retry: false,
});
return { invoice: query.data?.data, loading: query.isLoading, missing: query.isError };
}
@@ -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(<ClinicServicesPage />, { 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 () => {
@@ -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<ServiceItem | null>(null);
const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null);
const [insuranceItem, setInsuranceItem] = useState<ServiceItem | null>(null);
const [search, setSearch] = useState('');
const [showInactive, setShowInactive] = useState(true);
@@ -282,7 +280,6 @@ function ClinicServicesPageInner() {
<div style={{ position: 'fixed', inset: 0, zIndex: 40 }} onClick={(e) => { e.stopPropagation(); setMenuOpen(null); }} />
<div onClick={(e) => 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 }}>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setItemModal(item); }}><PencilIcon style={{ width: 15 }} /> ویرایش</button>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setTariffItem(item); }}><BanknotesIcon style={{ width: 15 }} /> تعرفههای سالانه</button>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setInsuranceItem(item); }}><ShieldCheckIcon style={{ width: 15 }} /> پوشش بیمه</button>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setToggleItem(item); }}>{item.active ? <EyeSlashIcon style={{ width: 15 }} /> : <EyeIcon style={{ width: 15 }} />}{item.active ? ' غیرفعال‌کردن' : ' فعال‌کردن'}</button>
</div>
@@ -368,7 +365,6 @@ function ClinicServicesPageInner() {
onClose={() => setItemModal(null)}
onManageInsurance={setInsuranceItem}
/>
<ServiceTariffModal item={tariffItem} onClose={() => setTariffItem(null)} />
<ServiceInsuranceModal item={insuranceItem} onClose={() => setInsuranceItem(null)} />
-358
View File
@@ -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<Draft | null>(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<PriceList>[] = [
{
key: 'name',
header: 'لیست',
render: (l) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{l.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{l.address_uuid === null ? 'همهٔ شعبه‌ها' : l.address_name ?? 'یک شعبه'}
</span>
</div>
),
},
{
key: 'range',
header: 'بازهٔ اعتبار',
render: (l) => (
<span style={{ fontSize: 13 }}>
{formatDate(l.valid_from)} تا {formatDate(l.valid_to)}
</span>
),
},
{
key: 'items',
header: 'تعداد قیمت',
render: (l) => <span style={{ fontSize: 13 }}>{l.items.length}</span>,
},
{
key: 'status',
header: 'وضعیت',
render: (l) => {
const status = statusOf(l);
return (
<span className={status.className}>
<span className="bdot" />
{status.label}
</span>
);
},
},
];
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 (
<SettingsLayout active="price-lists">
<div className="fade-in">
<PageHeader
title="لیست‌های قیمت"
description="قیمت هر خدمت در یک بازهٔ زمانی. لیست تا فعال نشود روی هیچ فاکتوری اثر ندارد."
backTo="/admin/settings-menu"
action={
canManage ? (
<button type="button" className="btn primary sm" onClick={() => setDraft(emptyDraft())}>
<PlusIcon style={{ width: 15 }} /> لیست تازه
</button>
) : undefined
}
/>
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در لیست‌ها..."
emptyMessage="هنوز لیست قیمتی ساخته نشده است"
actions={(l) =>
canManage ? (
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
uuid: l.uuid,
name: l.name,
address_uuid: l.address_uuid,
valid_from: l.valid_from,
valid_to: l.valid_to,
items: l.items,
})
}
>
ویرایش
</button>
{/* «کپی از لیست قبلی»: بیشتر لیست‌ها نسخهٔ کمی‌تغییریافتهٔ قبلی‌اند. */}
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
name: `${l.name} — نسخهٔ تازه`,
address_uuid: l.address_uuid,
valid_from: l.valid_to + DAY,
valid_to: l.valid_to + 90 * DAY,
items: l.items,
})
}
>
کپی
</button>
{!l.active && (
<button
type="button"
className="btn primary sm"
disabled={activate.isPending}
onClick={() => activate.mutate(l.uuid)}
>
فعالسازی
</button>
)}
{!l.active && (
<button
type="button"
className="btn secondary sm"
disabled={remove.isPending}
onClick={() => remove.mutate(l.uuid)}
aria-label="حذف لیست"
>
<TrashIcon style={{ width: 15 }} />
</button>
)}
</div>
) : null
}
/>
</div>
<Modal
open={draft !== null}
title={draft?.uuid ? 'ویرایش لیست قیمت' : 'لیست قیمت تازه'}
size="lg"
onClose={() => setDraft(null)}
footer={
<>
<button
type="button"
className="btn primary"
disabled={
!draft ||
draft.name.trim() === '' ||
draft.valid_to <= draft.valid_from ||
create.isPending ||
update.isPending ||
setItems.isPending
}
onClick={save}
>
ذخیره
</button>
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
انصراف
</button>
</>
}
>
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field-block">
<label htmlFor="pl-name">نام لیست</label>
<input
id="pl-name"
className="input"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="مثلاً: تعرفهٔ نیمهٔ دوم ۱۴۰۵"
/>
</div>
<div className="field-block">
<label>شعبه</label>
<SearchableSelect
value={draft.address_uuid ?? ''}
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
options={[
{ value: '', label: 'همهٔ شعبه‌ها' },
...addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
]}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
لیستِ یک شعبه بر لیست عمومی مقدم است و با آن تداخل حساب نمیشود.
</span>
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="field-block" style={{ minWidth: 180 }}>
<label>از تاریخ</label>
<PersianDateInput
value={unixToIso(draft.valid_from)}
onChange={(iso) => setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })}
/>
</div>
<div className="field-block" style={{ minWidth: 180 }}>
<label>تا تاریخ</label>
<PersianDateInput
value={unixToIso(draft.valid_to)}
onChange={(iso) => setDraft({ ...draft, valid_to: isoToUnix(iso) ?? draft.valid_to })}
/>
</div>
</div>
{draft.valid_to <= draft.valid_from && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
پایان بازه باید بعد از شروع آن باشد.
</span>
)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>قیمتها</h3>
{draft.items.map((item, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ minWidth: 220 }}>
<SearchableSelect
value={item.service_uuid}
onChange={(v) =>
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="خدمت"
/>
</div>
<div style={{ maxWidth: 200 }}>
<PriceInput
value={item.price_rials}
onChange={(v) =>
setDraft({
...draft,
items: draft.items.map((it, i) => (i === index ? { ...it, price_rials: v } : it)),
})
}
suffix="ریال"
/>
</div>
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft({ ...draft, items: draft.items.filter((_, i) => i !== index) })}
aria-label="حذف قیمت"
>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
))}
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({ ...draft, items: [...draft.items, { service_uuid: '', price_rials: 0 }] })
}
>
<PlusIcon style={{ width: 15 }} /> افزودن قیمت
</button>
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
خدمتی که در این لیست نیاید، قیمتش از تعرفهٔ سال یا خودِ خدمت خوانده میشود
پس هیچوقت بیقیمت نمیماند.
</span>
</div>
)}
</Modal>
</div>
</SettingsLayout>
);
}
+6 -14
View File
@@ -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();
});
});
-69
View File
@@ -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<ApiResponse<TariffList>>({
queryKey: ['service-tariffs', item.uuid],
queryFn: () => api.get(`/api/v1/service-items/${item.uuid}/tariffs`),
});
const list = data?.data;
const tariffs = list?.data ?? [];
return (
<div className="card card-pad">
<div className="card-title-row">
<div>
<b style={{ fontSize: 14 }}>تعرفههای سالانه</b>
<div className="muted" style={{ fontSize: 12 }}>
قیمت هر سال شمسی؛ قیمت سال جاری مبنای صورتحساب است.
</div>
</div>
{canUpdate && <button className="btn primary sm" onClick={onManage}>مدیریت تعرفهها</button>}
</div>
{isLoading ? (
<div className="muted" style={{ fontSize: 13, padding: '12px 0' }}>در حال بارگذاری...</div>
) : tariffs.length === 0 ? (
<div className="empty" style={{ padding: '28px 0' }}>
<BanknotesIcon style={{ width: 30, height: 30 }} />
<p className="muted">تعرفهای ثبت نشده است</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
{tariffs.map((t) => (
<div key={t.uuid} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
padding: '10px 12px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: t.year === list?.current_year ? 'var(--primary-soft)' : 'var(--surface)',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<b>{formatYear(t.year)}</b>
{t.year === list?.current_year && <span className="badge green" style={{ fontSize: 10 }}>سال جاری</span>}
{!t.is_active && <span className="badge gray" style={{ fontSize: 10 }}>غیرفعال</span>}
</span>
<b style={{ fontSize: 13.5, color: 'var(--primary)' }}>{formatRial(t.price_rials)}</b>
</div>
))}
</div>
)}
</div>
);
}
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() {
</div>
{tab === 'info' && <InfoTab item={item} />}
{tab === 'tariffs' && <TariffsTab item={item} onManage={() => setTariffOpen(true)} canUpdate={canUpdate} />}
{tab === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} canUpdate={canUpdate} />}
{tab === 'categories' && (
<ServiceCategoryTab
@@ -559,7 +491,6 @@ function ServiceDetailPageInner() {
onClose={() => setEditOpen(false)}
onManageInsurance={() => setInsuranceOpen(true)}
/>
<ServiceTariffModal item={tariffOpen ? item : null} onClose={() => setTariffOpen(false)} />
<ServiceInsuranceModal item={insuranceOpen ? item : null} onClose={() => setInsuranceOpen(false)} />
<ConfirmDialog
+1 -1
View File
@@ -8,7 +8,7 @@
صورتحساب (`Invoice`) از یک Encounter (`PatientSession`) ساخته می‌شود. برای هر آیتم سهم بیمه‌ی پایه، بیمه‌ی مکمل و بیمار با `BillingCalculator` محاسبه می‌شود:
- تعرفه‌ی خدمت از `Tariff` سال جاری (با fallback به `ServiceItem.priceRials`).
- قیمت خدمت از `ServiceItem.priceRials` — تنها منبع قیمت.
- قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
- درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ resolve توضیح‌داده‌شده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته می‌شود: هر خدمت با `ServiceItem.service_category` خودش، و **ویزیت** با `PatientSession.insurance_service_category` (نوعی که سرِ پذیرش انتخاب شده؛ در نبودش سرپایی).
- `invoices.service_category` همان نوع را snapshot می‌کند و در `toArray()` به‌صورت `service_category` / `service_category_label` برمی‌گردد.
+10 -45
View File
@@ -267,7 +267,7 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
**Response 201:** ServiceItem object (شامل `insurance_covered`)
> **قیمت واحد:** هنگام ساخت سرویس، یک تعرفه برای **سال جاری** با همان `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}]`
+24 -34
View File
@@ -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": { "<service-uuid>": "price_list" } }
"breakdown": { "discounts": [ ], "sources": { "<service-uuid>": "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` است: نوبت ثبت
+1 -1
View File
@@ -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 |
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Drop every price source other than the service itself.
*
* Price lists, annual tariffs and per-branch price overrides each answered
* "what does this service cost?" differently, so one date could carry several
* answers. Price is now owned solely by `service_items.price_rials`; branch
* overrides keep their duration columns.
*/
final class Version20260802140757 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop price_lists, price_list_items, service_tariffs and service_branch_overrides.price_rials';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+1 -1
View File
@@ -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;
+2 -4
View File
@@ -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());
@@ -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 ──────────────────────────────────────────────────────────────
/**
@@ -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);
@@ -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,
];
+1 -1
View File
@@ -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)]
-71
View File
@@ -1,71 +0,0 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\TariffRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: TariffRepository::class)]
#[ORM\Table(name: 'service_tariffs')]
#[ORM\UniqueConstraint(name: 'uniq_service_tariff_year', columns: ['service_item_id', 'year'])]
#[ORM\Index(columns: ['service_item_id', 'is_active'], name: 'idx_tariff_service_active')]
class Tariff
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'service_item_id', type: 'integer')]
private int $serviceItemId;
#[ORM\Column(type: 'smallint')]
private int $year;
#[ORM\Column(name: 'price_rials', type: 'integer')]
private int $priceRials = 0;
#[ORM\Column(name: 'is_active', type: 'boolean')]
private bool $isActive = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(int $serviceItemId, int $year, int $priceRials = 0)
{
$this->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,
];
}
}
@@ -1,48 +0,0 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\Tariff;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TariffRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Tariff::class);
}
public function findForServiceYear(int $serviceItemId, int $year): ?Tariff
{
return $this->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();
}
}
}
@@ -77,16 +77,16 @@ final class DurationCalculator
}
/**
* قیمت فقط از خودِ سرویس می‌آید؛ شعبه دیگر قیمت اختصاصی ندارد.
*
* @param ServiceItem[] $items
* @param array<int, ServiceBranchOverride> $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(),
];
}
@@ -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],
]);
@@ -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),
];
}
@@ -1,54 +0,0 @@
<?php
namespace App\ClinicService\Service;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\Tariff;
use App\ClinicService\Repository\TariffRepository;
class TariffService
{
public function __construct(
private readonly TariffRepository $tariffRepo,
) {}
/**
* تعرفه‌ی یک خدمت برای یک سال شمسی.
* اگر تعرفه‌ی آن سال ثبت نشده باشد، به priceRials خود خدمت fallback می‌شود.
*/
public function resolvePrice(ServiceItem $service, ?int $year = null): int
{
$year ??= $this->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());
}
}
@@ -17,7 +17,7 @@ final readonly class ResolvedServiceSpec
/** ردیف همان منبع ولی روی سرویسِ والد — وقتی گزینه مقدار خودش را ندارد. */
public const SOURCE_RESOURCE_SERVICE = 'resource_service';
/** `ServiceBranchOverride` — تنظیم این شعبه، مستقل از اینکه کدام منبع کار را می‌کند. */
/** `ServiceBranchOverride` — مدتِ این شعبه، مستقل از اینکه کدام منبع کار را می‌کند. */
public const SOURCE_BRANCH = 'branch';
/** مقدار خودِ `ServiceItem`. */
@@ -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,8 +26,6 @@ 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,
@@ -41,150 +34,6 @@ class PricingController extends BaseController
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);
-125
View File
@@ -1,125 +0,0 @@
<?php
namespace App\Pricing\Entity;
use App\Doctor\Entity\DoctorAddress;
use App\Pricing\Repository\PriceListRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* لیست قیمت با **بازهٔ تاریخ** — بند ۱۲ مستند.
*
* `Tariff` موجود فقط «سال» دارد، پس تغییر تعرفه از اول مهر قابل بیان نیست. این جدول
* بازهٔ دقیق می‌گیرد و `Tariff` به‌عنوان لایهٔ پشتیبان سرِ جایش می‌ماند.
*
* `address` تهی‌پذیر است: `null` یعنی «همهٔ شعبه‌های این محیط». قیمت اختصاصی یک شعبه
* از {@see \App\ClinicService\Entity\ServiceBranchOverride} می‌آید که بر این مقدم است.
*/
#[ORM\Entity(repositoryClass: PriceListRepository::class)]
#[ORM\Table(name: 'price_lists')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_price_list_tenant')]
#[ORM\Index(columns: ['starts_at', 'ends_at'], name: 'idx_price_list_range')]
class PriceList
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?DoctorAddress $address = null;
#[ORM\Column(type: 'string', length: 150)]
private string $name;
#[ORM\Column(name: 'starts_at', type: 'integer')]
private int $startsAt;
#[ORM\Column(name: 'ends_at', type: 'integer')]
private int $endsAt;
/** تا فعال نشده هیچ اثری ندارد؛ ساختنِ پیش‌نویس نباید قیمت امروز را عوض کند. */
#[ORM\Column(type: 'boolean', options: ['default' => 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<int, PriceListItem> */
#[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<int, PriceListItem> */
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(),
),
];
}
}
-58
View File
@@ -1,58 +0,0 @@
<?php
namespace App\Pricing\Entity;
use App\ClinicService\Entity\ServiceItem;
use App\Pricing\Repository\PriceListItemRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* قیمت یک سرویس در یک لیست قیمت. فرزند aggregate با ریشهٔ {@see PriceList} که خودش
* جفت محیط دارد؛ uuid از request نمی‌گیرد.
*/
#[ORM\Entity(repositoryClass: PriceListItemRepository::class)]
#[ORM\Table(name: 'price_list_items')]
#[ORM\UniqueConstraint(name: 'uniq_price_list_service', columns: ['price_list_id', 'service_item_id'])]
class PriceListItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: PriceList::class, inversedBy: 'items')]
#[ORM\JoinColumn(name: 'price_list_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private PriceList $priceList;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $serviceItem;
#[ORM\Column(name: 'price_rials', type: 'bigint')]
private int $priceRials;
public function __construct(PriceList $priceList, ServiceItem $serviceItem, int $priceRials)
{
if ($priceRials < 0) {
throw new \InvalidArgumentException('Price cannot be negative.');
}
$this->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(),
];
}
}
@@ -1,59 +0,0 @@
<?php
namespace App\Pricing\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Pricing\Entity\PriceList;
use App\Pricing\Entity\PriceListItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PriceListItem>
*/
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<int, int> شناسهٔ سرویس => قیمت
*/
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;
}
}
@@ -1,102 +0,0 @@
<?php
namespace App\Pricing\Repository;
use App\Doctor\Entity\DoctorAddress;
use App\Pricing\Entity\PriceList;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PriceList>
*/
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(),
));
}
}
+8 -63
View File
@@ -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<string, string> $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();
+2 -24
View File
@@ -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);
}
// ── تسک ۰۹: سیاست‌ها، یکی از هر دسته ────────────────────────────────────
// ── تسک ۰۶ و ۰۷: رزرو واقعی روی تقویم منابع ─────────────────────────────
-2
View File
@@ -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,
@@ -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
+3 -3
View File
@@ -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']);
}
+32 -129
View File
@@ -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'], 'پیش‌نویس نباید قیمت را عوض کند');
}
}
+1 -1
View File
@@ -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,