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
-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