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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 18:00:48 +03:30
co-authored by Claude Opus 5
parent f06efe26c0
commit 4fe0c4f9bf
41 changed files with 208 additions and 1835 deletions
@@ -59,14 +59,14 @@ describe('ClinicServicesPage (خدمات)', () => {
expect(screen.getByText('سرویس جدید')).toBeInTheDocument();
});
it('opens the ⋮ actions menu with insurance and tariff entries for a service', async () => {
it('opens the ⋮ actions menu with the insurance entry and no tariff entry', async () => {
renderWithProviders(<ClinicServicesPage />, { route: '/admin/clinic-services' });
fireEvent.click(await screen.findByText('کندلا ۲۰۲۱'));
fireEvent.click(await screen.findByTitle('عملیات'));
expect(await screen.findByText('ویرایش')).toBeInTheDocument();
expect(screen.getByText('تعرفه‌های سالانه')).toBeInTheDocument();
expect(screen.getByText('پوشش بیمه')).toBeInTheDocument();
expect(screen.queryByText('تعرفه‌های سالانه')).not.toBeInTheDocument();
});
it('فرم ویرایش سرویس دیگر سوییچ بیمه ندارد و به بخش پوشش بیمه ارجاع می‌دهد', async () => {
@@ -19,7 +19,6 @@ import { usePermissions } from '../hooks/usePermissions';
import PageHeader from '../components/ui/PageHeader';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal';
import FeatureGate from '../components/ui/FeatureGate';
@@ -55,7 +54,6 @@ function ClinicServicesPageInner() {
const [sectionModal, setSectionModal] = useState<'create' | ServiceSection | null>(null);
const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null);
const [toggleItem, setToggleItem] = useState<ServiceItem | null>(null);
const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null);
const [insuranceItem, setInsuranceItem] = useState<ServiceItem | null>(null);
const [search, setSearch] = useState('');
const [showInactive, setShowInactive] = useState(true);
@@ -282,7 +280,6 @@ function ClinicServicesPageInner() {
<div style={{ position: 'fixed', inset: 0, zIndex: 40 }} onClick={(e) => { e.stopPropagation(); setMenuOpen(null); }} />
<div onClick={(e) => e.stopPropagation()} style={{ position: 'absolute', top: 40, insetInlineStart: 8, zIndex: 41, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow)', padding: 6, minWidth: 176, display: 'flex', flexDirection: 'column', gap: 2 }}>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setItemModal(item); }}><PencilIcon style={{ width: 15 }} /> ویرایش</button>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setTariffItem(item); }}><BanknotesIcon style={{ width: 15 }} /> تعرفههای سالانه</button>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setInsuranceItem(item); }}><ShieldCheckIcon style={{ width: 15 }} /> پوشش بیمه</button>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setToggleItem(item); }}>{item.active ? <EyeSlashIcon style={{ width: 15 }} /> : <EyeIcon style={{ width: 15 }} />}{item.active ? ' غیرفعال‌کردن' : ' فعال‌کردن'}</button>
</div>
@@ -368,7 +365,6 @@ function ClinicServicesPageInner() {
onClose={() => setItemModal(null)}
onManageInsurance={setInsuranceItem}
/>
<ServiceTariffModal item={tariffItem} onClose={() => setTariffItem(null)} />
<ServiceInsuranceModal item={insuranceItem} onClose={() => setInsuranceItem(null)} />
-358
View File
@@ -1,358 +0,0 @@
import React, { useMemo, useState } from 'react';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import PriceInput from '../components/ui/PriceInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import PersianDateInput from '../components/ui/PersianDateInput';
import { formatDate, isoToUnix, unixToIso } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useAddresses } from '../hooks/useAddresses';
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import SettingsLayout from '../components/layout/SettingsLayout';
interface Draft {
uuid?: string;
name: string;
address_uuid: string | null;
valid_from: number;
valid_to: number;
items: PriceListItem[];
}
const DAY = 86400;
function emptyDraft(): Draft {
const now = Math.floor(Date.now() / 1000);
return { name: '', address_uuid: null, valid_from: now, valid_to: now + 90 * DAY, items: [] };
}
/**
* لیست‌های قیمت بازه‌دار.
*
* وضعیت هر لیست سه حالت دارد و هر سه معنای عملیاتی متفاوتی دارند: پیش‌نویس هیچ اثری
* روی قیمت امروز ندارد، فعال حاکم است، و منقضی فقط تاریخچه است.
*/
export default function PriceListsPage() {
const { lists, loading, create, update, setItems, activate, remove } = usePriceLists();
const { addresses } = useAddresses();
const { items: services } = useAllServiceItems();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [draft, setDraft] = useState<Draft | null>(null);
const now = Math.floor(Date.now() / 1000);
const rows = useMemo(() => {
const q = urlState.search.trim();
return lists.filter((l) => q === '' || l.name.includes(q));
}, [lists, urlState.search]);
const statusOf = (list: PriceList) => {
if (!list.active) return { label: 'پیش‌نویس', className: 'badge' };
if (list.valid_to < now) return { label: 'منقضی', className: 'badge red' };
return { label: 'فعال', className: 'badge green' };
};
const columns: Column<PriceList>[] = [
{
key: 'name',
header: 'لیست',
render: (l) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{l.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{l.address_uuid === null ? 'همهٔ شعبه‌ها' : l.address_name ?? 'یک شعبه'}
</span>
</div>
),
},
{
key: 'range',
header: 'بازهٔ اعتبار',
render: (l) => (
<span style={{ fontSize: 13 }}>
{formatDate(l.valid_from)} تا {formatDate(l.valid_to)}
</span>
),
},
{
key: 'items',
header: 'تعداد قیمت',
render: (l) => <span style={{ fontSize: 13 }}>{l.items.length}</span>,
},
{
key: 'status',
header: 'وضعیت',
render: (l) => {
const status = statusOf(l);
return (
<span className={status.className}>
<span className="bdot" />
{status.label}
</span>
);
},
},
];
const save = async () => {
if (!draft) return;
const body = {
name: draft.name,
address_uuid: draft.address_uuid,
valid_from: draft.valid_from,
valid_to: draft.valid_to,
};
const saved = draft.uuid
? await update.mutateAsync({ uuid: draft.uuid, body })
: await create.mutateAsync(body);
await setItems.mutateAsync({ uuid: saved.data.uuid, items: draft.items });
setDraft(null);
};
return (
<SettingsLayout active="price-lists">
<div className="fade-in">
<PageHeader
title="لیست‌های قیمت"
description="قیمت هر خدمت در یک بازهٔ زمانی. لیست تا فعال نشود روی هیچ فاکتوری اثر ندارد."
backTo="/admin/settings-menu"
action={
canManage ? (
<button type="button" className="btn primary sm" onClick={() => setDraft(emptyDraft())}>
<PlusIcon style={{ width: 15 }} /> لیست تازه
</button>
) : undefined
}
/>
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در لیست‌ها..."
emptyMessage="هنوز لیست قیمتی ساخته نشده است"
actions={(l) =>
canManage ? (
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
uuid: l.uuid,
name: l.name,
address_uuid: l.address_uuid,
valid_from: l.valid_from,
valid_to: l.valid_to,
items: l.items,
})
}
>
ویرایش
</button>
{/* «کپی از لیست قبلی»: بیشتر لیست‌ها نسخهٔ کمی‌تغییریافتهٔ قبلی‌اند. */}
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
name: `${l.name} — نسخهٔ تازه`,
address_uuid: l.address_uuid,
valid_from: l.valid_to + DAY,
valid_to: l.valid_to + 90 * DAY,
items: l.items,
})
}
>
کپی
</button>
{!l.active && (
<button
type="button"
className="btn primary sm"
disabled={activate.isPending}
onClick={() => activate.mutate(l.uuid)}
>
فعالسازی
</button>
)}
{!l.active && (
<button
type="button"
className="btn secondary sm"
disabled={remove.isPending}
onClick={() => remove.mutate(l.uuid)}
aria-label="حذف لیست"
>
<TrashIcon style={{ width: 15 }} />
</button>
)}
</div>
) : null
}
/>
</div>
<Modal
open={draft !== null}
title={draft?.uuid ? 'ویرایش لیست قیمت' : 'لیست قیمت تازه'}
size="lg"
onClose={() => setDraft(null)}
footer={
<>
<button
type="button"
className="btn primary"
disabled={
!draft ||
draft.name.trim() === '' ||
draft.valid_to <= draft.valid_from ||
create.isPending ||
update.isPending ||
setItems.isPending
}
onClick={save}
>
ذخیره
</button>
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
انصراف
</button>
</>
}
>
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field-block">
<label htmlFor="pl-name">نام لیست</label>
<input
id="pl-name"
className="input"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="مثلاً: تعرفهٔ نیمهٔ دوم ۱۴۰۵"
/>
</div>
<div className="field-block">
<label>شعبه</label>
<SearchableSelect
value={draft.address_uuid ?? ''}
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
options={[
{ value: '', label: 'همهٔ شعبه‌ها' },
...addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
]}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
لیستِ یک شعبه بر لیست عمومی مقدم است و با آن تداخل حساب نمیشود.
</span>
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="field-block" style={{ minWidth: 180 }}>
<label>از تاریخ</label>
<PersianDateInput
value={unixToIso(draft.valid_from)}
onChange={(iso) => setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })}
/>
</div>
<div className="field-block" style={{ minWidth: 180 }}>
<label>تا تاریخ</label>
<PersianDateInput
value={unixToIso(draft.valid_to)}
onChange={(iso) => setDraft({ ...draft, valid_to: isoToUnix(iso) ?? draft.valid_to })}
/>
</div>
</div>
{draft.valid_to <= draft.valid_from && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
پایان بازه باید بعد از شروع آن باشد.
</span>
)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>قیمتها</h3>
{draft.items.map((item, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ minWidth: 220 }}>
<SearchableSelect
value={item.service_uuid}
onChange={(v) =>
setDraft({
...draft,
items: draft.items.map((it, i) =>
i === index ? { ...it, service_uuid: String(v ?? '') } : it,
),
})
}
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
placeholder="خدمت"
/>
</div>
<div style={{ maxWidth: 200 }}>
<PriceInput
value={item.price_rials}
onChange={(v) =>
setDraft({
...draft,
items: draft.items.map((it, i) => (i === index ? { ...it, price_rials: v } : it)),
})
}
suffix="ریال"
/>
</div>
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft({ ...draft, items: draft.items.filter((_, i) => i !== index) })}
aria-label="حذف قیمت"
>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
))}
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({ ...draft, items: [...draft.items, { service_uuid: '', price_rials: 0 }] })
}
>
<PlusIcon style={{ width: 15 }} /> افزودن قیمت
</button>
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
خدمتی که در این لیست نیاید، قیمتش از تعرفهٔ سال یا خودِ خدمت خوانده میشود
پس هیچوقت بیقیمت نمیماند.
</span>
</div>
)}
</Modal>
</div>
</SettingsLayout>
);
}
+6 -14
View File
@@ -45,13 +45,6 @@ const mockApi = (item: unknown = ITEM, notFound = false) => {
if (url.includes('/service-item/')) {
return notFound ? Promise.reject(new Error('یافت نشد')) : Promise.resolve({ success: true, data: item });
}
if (url.includes('/tariffs')) return Promise.resolve({ success: true, data: {
current_year: 1404, default_price_rials: 8_500_000,
data: [
{ uuid: 't1', year: 1404, price_rials: 8_500_000, is_active: true },
{ uuid: 't2', year: 1403, price_rials: 7_000_000, is_active: false },
],
} });
if (url.includes('/tenant-insurances')) return Promise.resolve({ data: { data: [
{ uuid: 'ins1', insurance_name: 'بیمه ایران', insurance_kind: 'basic', coverage_percent: 70 },
] } });
@@ -104,13 +97,12 @@ describe('ServiceDetailPage (جزئیات سرویس)', () => {
expect(screen.getByText(/۱۴۰۲/)).toBeInTheDocument();
});
it('تب تعرفه‌ها فهرست سال‌ها را با نشان «سال جاری» می‌آورد', async () => {
/** تعرفهٔ سالانه حذف شد: قیمت فقط روی خودِ سرویس تعریف می‌شود. */
it('دیگر تب تعرفه‌ها ندارد', async () => {
render();
fireEvent.click(await screen.findByText('تعرفه‌ها'));
await screen.findByRole('heading', { name: 'سرم ۵۰۰cc' });
expect(await screen.findByText('سال جاری')).toBeInTheDocument();
expect(screen.getByText('۱۴۰۴')).toBeInTheDocument();
expect(screen.getByText('۱۴۰۳')).toBeInTheDocument();
expect(screen.queryByText('تعرفه‌ها')).not.toBeInTheDocument();
});
it('تب بیمه‌ها قراردادها و درصد پوشش را می‌آورد', async () => {
@@ -225,8 +217,8 @@ describe('ServiceDetailPage (جزئیات سرویس)', () => {
/** تب در URL می‌نشیند تا «بازگشت» و رفرش همان نما را بدهند. */
it('تب انتخاب‌شده از URL خوانده می‌شود', async () => {
render('/admin/clinic-services/it1?tab=tariffs');
render('/admin/clinic-services/it1?tab=insurance');
expect(await screen.findByText('سال جاری')).toBeInTheDocument();
expect(await screen.findByText('بیمه ایران')).toBeInTheDocument();
});
});
-69
View File
@@ -16,24 +16,10 @@ import FeatureGate from '../components/ui/FeatureGate';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import { usePermissions } from '../hooks/usePermissions';
import { useUrlState } from '../hooks/useUrlState';
import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal';
import ServiceCategoryTab from '../components/ServiceCategoryTab';
interface Tariff {
uuid: string;
year: number;
price_rials: number;
is_active: boolean;
}
interface TariffList {
current_year: number;
default_price_rials: number;
data: Tariff[];
}
interface TenantInsurance {
uuid: string;
insurance_name: string | null;
@@ -51,7 +37,6 @@ interface CoverageRow {
const TABS = [
{ id: 'info', label: 'اطلاعات سرویس' },
{ id: 'tariffs', label: 'تعرفه‌ها' },
{ id: 'insurance', label: 'بیمه‌ها' },
{ id: 'categories',label: 'دسته‌بندی‌ها' },
{ id: 'goods', label: 'کالاهای مرتبط' },
@@ -164,57 +149,6 @@ function InfoTab({ item }: { item: ServiceItem }) {
);
}
function TariffsTab({ item, onManage, canUpdate }: { item: ServiceItem; onManage: () => void; canUpdate: boolean }) {
const { data, isLoading } = useQuery<ApiResponse<TariffList>>({
queryKey: ['service-tariffs', item.uuid],
queryFn: () => api.get(`/api/v1/service-items/${item.uuid}/tariffs`),
});
const list = data?.data;
const tariffs = list?.data ?? [];
return (
<div className="card card-pad">
<div className="card-title-row">
<div>
<b style={{ fontSize: 14 }}>تعرفههای سالانه</b>
<div className="muted" style={{ fontSize: 12 }}>
قیمت هر سال شمسی؛ قیمت سال جاری مبنای صورتحساب است.
</div>
</div>
{canUpdate && <button className="btn primary sm" onClick={onManage}>مدیریت تعرفهها</button>}
</div>
{isLoading ? (
<div className="muted" style={{ fontSize: 13, padding: '12px 0' }}>در حال بارگذاری...</div>
) : tariffs.length === 0 ? (
<div className="empty" style={{ padding: '28px 0' }}>
<BanknotesIcon style={{ width: 30, height: 30 }} />
<p className="muted">تعرفهای ثبت نشده است</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
{tariffs.map((t) => (
<div key={t.uuid} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
padding: '10px 12px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: t.year === list?.current_year ? 'var(--primary-soft)' : 'var(--surface)',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<b>{formatYear(t.year)}</b>
{t.year === list?.current_year && <span className="badge green" style={{ fontSize: 10 }}>سال جاری</span>}
{!t.is_active && <span className="badge gray" style={{ fontSize: 10 }}>غیرفعال</span>}
</span>
<b style={{ fontSize: 13.5, color: 'var(--primary)' }}>{formatRial(t.price_rials)}</b>
</div>
))}
</div>
)}
</div>
);
}
function InsuranceTab({ item, onManage, canUpdate }: { item: ServiceItem; onManage: () => void; canUpdate: boolean }) {
const { data: contractsData, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({
queryKey: ['tenant-insurances'],
@@ -460,7 +394,6 @@ function ServiceDetailPageInner() {
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
const setTab = (id: TabId) => setUrlState({ tab: id });
const [editOpen, setEditOpen] = useState(false);
const [tariffOpen, setTariffOpen] = useState(false);
const [insuranceOpen, setInsuranceOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
@@ -541,7 +474,6 @@ function ServiceDetailPageInner() {
</div>
{tab === 'info' && <InfoTab item={item} />}
{tab === 'tariffs' && <TariffsTab item={item} onManage={() => setTariffOpen(true)} canUpdate={canUpdate} />}
{tab === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} canUpdate={canUpdate} />}
{tab === 'categories' && (
<ServiceCategoryTab
@@ -559,7 +491,6 @@ function ServiceDetailPageInner() {
onClose={() => setEditOpen(false)}
onManageInsurance={() => setInsuranceOpen(true)}
/>
<ServiceTariffModal item={tariffOpen ? item : null} onClose={() => setTariffOpen(false)} />
<ServiceInsuranceModal item={insuranceOpen ? item : null} onClose={() => setInsuranceOpen(false)} />
<ConfirmDialog