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:
@@ -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 };
|
||||
}
|
||||
@@ -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)} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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` است: نوبت ثبت
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,160 +26,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class PricingController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PriceListRepository $lists,
|
||||
private readonly PriceListItemRepository $listItems,
|
||||
private readonly PriceSnapshotRepository $snapshots,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly PricingEngine $engine,
|
||||
private readonly AddressResolver $branches,
|
||||
private readonly AddressResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/price-lists', name: 'price_list_index', methods: ['GET'])]
|
||||
public function index(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (PriceList $l): array => $l->toArray(),
|
||||
$this->lists->findForPair($entityType, $entityId),
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/price-lists', name: 'price_list_create', methods: ['POST'])]
|
||||
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_string($data['name'] ?? null) || trim($data['name']) === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام لیست قیمت الزامی است', 422, 'name');
|
||||
}
|
||||
|
||||
if (!is_numeric($data['starts_at'] ?? null) || !is_numeric($data['ends_at'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بازهٔ تاریخ الزامی است', 422, 'starts_at');
|
||||
}
|
||||
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
try {
|
||||
$list = new PriceList($entityType, $entityId, trim($data['name']), (int) $data['starts_at'], (int) $data['ends_at']);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پایان بازه باید بعد از شروع آن باشد', 422, 'ends_at');
|
||||
}
|
||||
|
||||
if (is_string($data['address_uuid'] ?? null)) {
|
||||
$list->setAddress($this->branches->resolve($user, $data['address_uuid']));
|
||||
}
|
||||
|
||||
$this->em->persist($list);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($list->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/price-list/{uuid}', name: 'price_list_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
return $this->success($this->requireList($user, $uuid)->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/price-list/{uuid}', name: 'price_list_update', methods: ['PATCH'])]
|
||||
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$list = $this->requireList($user, $uuid);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
if (is_string($data['name'] ?? null) && trim($data['name']) !== '') {
|
||||
$list->setName(trim($data['name']));
|
||||
}
|
||||
|
||||
if (array_key_exists('active', $data)) {
|
||||
$list->setActive((bool) $data['active']);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($list->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/price-list/{uuid}', name: 'price_list_delete', methods: ['DELETE'])]
|
||||
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$this->em->remove($this->requireList($user, $uuid));
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(null);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/price-list/{uuid}/items', name: 'price_list_items_replace', methods: ['PUT'])]
|
||||
public function replaceItems(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_array($data['items'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد items الزامی است', 422, 'items');
|
||||
}
|
||||
|
||||
$list = $this->requireList($user, $uuid);
|
||||
$resolved = [];
|
||||
|
||||
foreach ($data['items'] as $row) {
|
||||
if (!is_array($row) || !is_string($row['service_uuid'] ?? null) || !is_numeric($row['price_rials'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'service_uuid و price_rials الزامیاند', 422, 'items');
|
||||
}
|
||||
|
||||
if ((int) $row['price_rials'] < 0) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'قیمت نمیتواند منفی باشد', 422, 'price_rials');
|
||||
}
|
||||
|
||||
$resolved[] = [$this->requireItem($user, $row['service_uuid']), (int) $row['price_rials']];
|
||||
}
|
||||
|
||||
$this->listItems->deleteForList($list);
|
||||
$list->getItems()->clear();
|
||||
|
||||
foreach ($resolved as [$service, $price]) {
|
||||
$item = new PriceListItem($list, $service, $price);
|
||||
$this->em->persist($item);
|
||||
$list->getItems()->add($item);
|
||||
}
|
||||
|
||||
$list->touch();
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($list->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* فعالسازی با بررسی تداخل: دو لیستِ فعالِ همپوشان یعنی یک تاریخ دو قیمت دارد و
|
||||
* هیچکس نمیتواند بگوید کدام درست است.
|
||||
*/
|
||||
#[Route('/api/v1/price-list/{uuid}/activate', name: 'price_list_activate', methods: ['POST'])]
|
||||
public function activate(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$list = $this->requireList($user, $uuid);
|
||||
$conflicts = $this->lists->findOverlapping($list);
|
||||
|
||||
if ($conflicts !== []) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('بازهٔ این لیست با «%s» همپوشانی دارد', $conflicts[0]->getName()),
|
||||
422,
|
||||
'starts_at',
|
||||
);
|
||||
}
|
||||
|
||||
$list->setActive(true);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($list->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/pricing/quote', name: 'pricing_quote', methods: ['POST'])]
|
||||
public function quote(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
@@ -236,18 +85,6 @@ class PricingController extends BaseController
|
||||
return $this->success($snapshot->toArray());
|
||||
}
|
||||
|
||||
private function requireList(User $user, string $uuid): PriceList
|
||||
{
|
||||
$list = $this->lists->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($list === null || !$this->ownership->belongsToPair($entityType, $entityId, $list)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'لیست قیمت یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function requireItem(User $user, string $uuid): ServiceItem
|
||||
{
|
||||
$item = $this->items->findByUuid($uuid);
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ── تسک ۰۹: سیاستها، یکی از هر دسته ────────────────────────────────────
|
||||
|
||||
// ── تسک ۰۶ و ۰۷: رزرو واقعی روی تقویم منابع ─────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'], 'پیشنویس نباید قیمت را عوض کند');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user