diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 06945665..d7fd25b7 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -75,6 +75,7 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; import BranchesPage from './pages/BranchesPage'; +import PriceListsPage from './pages/PriceListsPage'; import ResourceUtilizationPage from './pages/ResourceUtilizationPage'; import PlanAccuracyPage from './pages/PlanAccuracyPage'; import CancellationPolicyPage from './pages/CancellationPolicyPage'; @@ -309,6 +310,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/AppointmentInvoiceCard.test.tsx b/assets/admin/components/AppointmentInvoiceCard.test.tsx new file mode 100644 index 00000000..d85acc5d --- /dev/null +++ b/assets/admin/components/AppointmentInvoiceCard.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { api } from '../lib/api'; +import AppointmentInvoiceCard from './AppointmentInvoiceCard'; + +const get = api.get as ReturnType; + +const invoice = { + base_rials: 10_000_000, + items_rials: 2_000_000, + discount_rials: 1_200_000, + insurance_base_rials: 2_160_000, + insurance_supplementary_rials: 0, + tax_rials: 432_000, + final_rials: 9_072_000, + deposit_rials: 1_425_600, + created_at: 1_700_000_000, + breakdown: { discounts: [{ label: 'تخفیف درصدی', rials: 1_200_000 }], sources: {} }, +}; + +describe('AppointmentInvoiceCard', () => { + beforeEach(() => vi.clearAllMocks()); + + it('lays out the chain down to the final amount', async () => { + get.mockResolvedValue({ success: true, data: invoice }); + renderWithProviders(, { route: '/admin/appointments/a1' }); + + await waitFor(() => expect(screen.getByText('فاکتور')).toBeInTheDocument()); + expect(screen.getByText('قیمت پایه')).toBeInTheDocument(); + expect(screen.getByText('مبلغ نهایی')).toBeInTheDocument(); + expect(screen.getByText('بیعانه')).toBeInTheDocument(); + }); + + /** ردیف صفر نباید جا بگیرد — فاکتور شلوغ خوانده نمی‌شود. */ + it('hides zero rows', async () => { + get.mockResolvedValue({ success: true, data: invoice }); + renderWithProviders(, { route: '/admin/appointments/a1' }); + + await waitFor(() => expect(screen.getByText('فاکتور')).toBeInTheDocument()); + expect(screen.queryByText('سهم بیمهٔ تکمیلی')).not.toBeInTheDocument(); + }); + + /** ⭐ نبودِ فاکتور خطا نیست: نوبتِ ثبت‌نهایی‌نشده فاکتوری ندارد. */ + it('treats a missing invoice as a normal state', async () => { + get.mockRejectedValue(new Error('not found')); + renderWithProviders(, { route: '/admin/appointments/a1' }); + + await waitFor(() => + expect(screen.getByText('برای این نوبت فاکتوری ثبت نشده است.')).toBeInTheDocument(), + ); + }); + + it('says the snapshot does not follow later price changes', async () => { + get.mockResolvedValue({ success: true, data: invoice }); + renderWithProviders(, { route: '/admin/appointments/a1' }); + + await waitFor(() => + expect(screen.getByText(/تغییر بعدی تعرفه این فاکتور را عوض نمی‌کند/)).toBeInTheDocument(), + ); + }); +}); diff --git a/assets/admin/components/AppointmentInvoiceCard.tsx b/assets/admin/components/AppointmentInvoiceCard.tsx new file mode 100644 index 00000000..d0be6cdb --- /dev/null +++ b/assets/admin/components/AppointmentInvoiceCard.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { formatDate, formatRial } from '../lib/utils'; +import { useAppointmentInvoice } from '../hooks/usePriceLists'; + +interface Props { + appointmentUuid: string; +} + +/** + * فاکتور تفکیک‌شدهٔ نوبت. + * + * اعدادش snapshot لحظهٔ ثبت‌اند، نه محاسبهٔ امروز: تغییر تعرفه هرگز فاکتور صادرشده را + * عوض نمی‌کند (قانون پنجم مستند). همین جمله زیر کارت هم نوشته می‌شود، چون کاربری که + * قیمت را دیروز عوض کرده و امروز عدد قدیمی می‌بیند وگرنه فکر می‌کند سیستم خراب است. + */ +export default function AppointmentInvoiceCard({ appointmentUuid }: Props) { + const { invoice, loading, missing } = useAppointmentInvoice(appointmentUuid); + + if (loading) { + return ( +
+ در حال بارگذاری فاکتور… +
+ ); + } + + // نبودِ فاکتور خطا نیست: نوبتی که هنوز ثبت نهایی نشده، فاکتوری هم ندارد. + if (missing || !invoice) { + return ( +
+ برای این نوبت فاکتوری ثبت نشده است. +
+ ); + } + + const rows: { label: string; value: number; muted?: boolean }[] = [ + { label: 'قیمت پایه', value: invoice.base_rials }, + { label: 'آیتم‌های اضافه', value: invoice.items_rials }, + { label: 'تخفیف', value: -invoice.discount_rials }, + { label: 'سهم بیمهٔ پایه', value: -invoice.insurance_base_rials }, + { label: 'سهم بیمهٔ تکمیلی', value: -invoice.insurance_supplementary_rials }, + { label: 'مالیات', value: invoice.tax_rials }, + ]; + + return ( +
+
+

فاکتور

+ {invoice.created_at !== undefined && ( + + ثبت‌شده در {formatDate(invoice.created_at)} + + )} +
+ +
+ {rows + .filter((row) => row.value !== 0) + .map((row) => ( +
+ {row.label} + + {formatRial(Math.abs(row.value))} + {row.value < 0 ? ' −' : ''} + +
+ ))} + + {invoice.breakdown.discounts.map((line, index) => ( +
+ {line.label} + {formatRial(Math.abs(line.rials))} +
+ ))} +
+ +
+ مبلغ نهایی + {formatRial(invoice.final_rials)} +
+ + {invoice.deposit_rials > 0 && ( +
+ بیعانه + {formatRial(invoice.deposit_rials)} +
+ )} + + + قیمت‌ها بر اساس تاریخ همین نوبت محاسبه و ثبت شده‌اند؛ تغییر بعدی تعرفه این فاکتور + را عوض نمی‌کند. + +
+ ); +} diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index eac6db6c..6b3dc5d4 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -35,6 +35,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'packages', label: 'پکیج‌ها', icon: RectangleStackIcon, to: '/admin/packages', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'course-protocols', label: 'پروتکل دوره', icon: ArrowPathRoundedSquareIcon, to: '/admin/course-protocols', 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: 'cancellation', label: 'سیاست لغو', icon: NoSymbolIcon, to: '/admin/cancellation-policy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'waitlist', label: 'لیست انتظار', icon: QueueListIcon, to: '/admin/waitlist', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'utilization', label: 'بهره‌وری منابع', icon: ChartBarIcon, to: '/admin/reports/resource-utilization', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, diff --git a/assets/admin/hooks/usePriceLists.ts b/assets/admin/hooks/usePriceLists.ts new file mode 100644 index 00000000..fe664c9e --- /dev/null +++ b/assets/admin/hooks/usePriceLists.ts @@ -0,0 +1,131 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError, type ApiResponse } from '../lib/api'; + +/** + * لیست قیمت بازه‌دار و فاکتور نوبت. + * + * لیست تا فعال نشده هیچ اثری ندارد؛ ساختن پیش‌نویس نباید قیمت امروز را عوض کند. پس + * `create` و `activate` عمداً دو عمل جدا هستند، نه یک فرم با تیک «فعال». + */ +export interface PriceListItem { + service_uuid: string; + service_name?: string; + price_rials: number; +} + +export interface PriceList { + uuid: string; + name: string; + address_uuid: string | null; + address_name: string | null; + valid_from: number; + valid_to: number; + active: boolean; + items: PriceListItem[]; + created_at: number; +} + +export interface PriceSnapshotLine { + label: string; + rials: number; + kind?: string; +} + +export interface PriceSnapshot { + base_rials: number; + items_rials: number; + discount_rials: number; + insurance_base_rials: number; + insurance_supplementary_rials: number; + tax_rials: number; + final_rials: number; + deposit_rials: number; + created_at?: number; + breakdown: { + discounts: PriceSnapshotLine[]; + sources: Record; + }; +} + +const KEY = ['price-lists']; + +function fail(e: unknown, fallback: string) { + toast.error(e instanceof ApiError ? e.message : fallback); +} + +export function usePriceLists() { + const qc = useQueryClient(); + + const query = useQuery({ + queryKey: KEY, + queryFn: () => api.get>('/api/v1/price-lists'), + }); + + const invalidate = () => qc.invalidateQueries({ queryKey: KEY }); + + const create = useMutation({ + mutationFn: (body: Record) => + api.post>('/api/v1/price-lists', body), + onSuccess: () => { + toast.success('لیست قیمت ساخته شد — تا فعال نشود اثری ندارد'); + invalidate(); + }, + onError: (e) => fail(e, 'ساخت لیست قیمت ناموفق بود'), + }); + + const update = useMutation({ + mutationFn: ({ uuid, body }: { uuid: string; body: Record }) => + api.patch>(`/api/v1/price-list/${uuid}`, body), + onSuccess: () => { + toast.success('لیست قیمت به‌روزرسانی شد'); + invalidate(); + }, + onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'), + }); + + const setItems = useMutation({ + mutationFn: ({ uuid, items }: { uuid: string; items: PriceListItem[] }) => + api.put>(`/api/v1/price-list/${uuid}/items`, { items }), + onSuccess: () => { + toast.success('قیمت‌ها ذخیره شد'); + invalidate(); + }, + onError: (e) => fail(e, 'ذخیرهٔ قیمت‌ها ناموفق بود'), + }); + + /** تداخل بازه با لیست فعالِ هم‌دامنه اینجا ۴۲۲ می‌گیرد؛ پیام سرور دقیق‌تر است. */ + const activate = useMutation({ + mutationFn: (uuid: string) => api.post>(`/api/v1/price-list/${uuid}/activate`, {}), + onSuccess: () => { + toast.success('لیست قیمت فعال شد'); + invalidate(); + }, + onError: (e) => fail(e, 'فعال‌سازی ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/price-list/${uuid}`), + onSuccess: () => { + toast.success('لیست قیمت حذف شد'); + invalidate(); + }, + onError: (e) => fail(e, 'حذف ناموفق بود'), + }); + + return { lists: query.data?.data ?? [], loading: query.isLoading, create, update, setItems, activate, remove }; +} + +/** فاکتور یک نوبت — همان اعداد لحظهٔ ثبت، حتی اگر قیمت‌ها بعداً عوض شده باشند. */ +export function useAppointmentInvoice(appointmentUuid: string | undefined) { + const query = useQuery({ + queryKey: ['appointment-invoice', appointmentUuid], + queryFn: () => + api.get>(`/api/v1/appointment/${appointmentUuid}/price-snapshot`), + enabled: !!appointmentUuid, + // نوبتِ بدون فاکتور ۴۰۴ می‌دهد و آن خطا نیست، یعنی «هنوز ثبت نشده». + retry: false, + }); + + return { invoice: query.data?.data, loading: query.isLoading, missing: query.isError }; +} diff --git a/assets/admin/pages/AppointmentDetailPage.tsx b/assets/admin/pages/AppointmentDetailPage.tsx index 108cc072..d3ca1a62 100644 --- a/assets/admin/pages/AppointmentDetailPage.tsx +++ b/assets/admin/pages/AppointmentDetailPage.tsx @@ -8,6 +8,7 @@ import type { ApiResponse } from '../lib/api'; import type { Appointment, AppointmentStatus, AppointmentEvent } from '../types'; import { formatDate, formatDateTime, toDate } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; +import AppointmentInvoiceCard from '../components/AppointmentInvoiceCard'; import StatusBadge from '../components/ui/StatusBadge'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import SearchableSelect from '../components/ui/SearchableSelect'; @@ -145,6 +146,8 @@ export default function AppointmentDetailPage() { + +

وضعیت و اقدامات

diff --git a/assets/admin/pages/PriceListsPage.tsx b/assets/admin/pages/PriceListsPage.tsx new file mode 100644 index 00000000..218dad18 --- /dev/null +++ b/assets/admin/pages/PriceListsPage.tsx @@ -0,0 +1,355 @@ +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 { useBranches } from '../hooks/useBranches'; +import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists'; +import { useAllServiceItems } from '../hooks/useServiceCatalog'; + +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 { branches } = useBranches(); + const { items: services } = useAllServiceItems(); + const { can } = usePermissions(); + const canManage = can('appointment_settings', 'update'); + + const [urlState, setUrlState] = useUrlState({ search: '' }); + const [draft, setDraft] = useState(null); + + const now = Math.floor(Date.now() / 1000); + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return lists.filter((l) => q === '' || l.name.includes(q)); + }, [lists, urlState.search]); + + const statusOf = (list: PriceList) => { + if (!list.active) return { label: 'پیش‌نویس', className: 'badge' }; + if (list.valid_to < now) return { label: 'منقضی', className: 'badge red' }; + return { label: 'فعال', className: 'badge green' }; + }; + + const columns: Column[] = [ + { + key: 'name', + header: 'لیست', + render: (l) => ( +
+ {l.name} + + {l.address_uuid === null ? 'همهٔ شعبه‌ها' : l.address_name ?? 'یک شعبه'} + +
+ ), + }, + { + key: 'range', + header: 'بازهٔ اعتبار', + render: (l) => ( + + {formatDate(l.valid_from)} تا {formatDate(l.valid_to)} + + ), + }, + { + key: 'items', + header: 'تعداد قیمت', + render: (l) => {l.items.length}, + }, + { + key: 'status', + header: 'وضعیت', + render: (l) => { + const status = statusOf(l); + return ( + + + {status.label} + + ); + }, + }, + ]; + + const save = async () => { + if (!draft) return; + + const body = { + name: draft.name, + address_uuid: draft.address_uuid, + valid_from: draft.valid_from, + valid_to: draft.valid_to, + }; + + const saved = draft.uuid + ? await update.mutateAsync({ uuid: draft.uuid, body }) + : await create.mutateAsync(body); + + await setItems.mutateAsync({ uuid: saved.data.uuid, items: draft.items }); + + setDraft(null); + }; + + return ( +
+ setDraft(emptyDraft())}> + لیست تازه + + ) : undefined + } + /> + +
+ setUrlState({ search: v })} + searchPlaceholder="جستجو در لیست‌ها..." + emptyMessage="هنوز لیست قیمتی ساخته نشده است" + actions={(l) => + canManage ? ( +
+ + + {/* «کپی از لیست قبلی»: بیشتر لیست‌ها نسخهٔ کمی‌تغییریافتهٔ قبلی‌اند. */} + + + {!l.active && ( + + )} + + {!l.active && ( + + )} +
+ ) : null + } + /> +
+ + setDraft(null)} + footer={ + <> + + + + } + > + {draft && ( +
+
+ + setDraft({ ...draft, name: e.target.value })} + placeholder="مثلاً: تعرفهٔ نیمهٔ دوم ۱۴۰۵" + /> +
+ +
+ + setDraft({ ...draft, address_uuid: v ? String(v) : null })} + options={[ + { value: '', label: 'همهٔ شعبه‌ها' }, + ...branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })), + ]} + /> + + لیستِ یک شعبه بر لیست عمومی مقدم است و با آن تداخل حساب نمی‌شود. + +
+ +
+
+ + setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })} + /> +
+
+ + setDraft({ ...draft, valid_to: isoToUnix(iso) ?? draft.valid_to })} + /> +
+
+ + {draft.valid_to <= draft.valid_from && ( + + پایان بازه باید بعد از شروع آن باشد. + + )} + +
+

قیمت‌ها

+ + {draft.items.map((item, index) => ( +
+
+ + setDraft({ + ...draft, + items: draft.items.map((it, i) => + i === index ? { ...it, service_uuid: String(v ?? '') } : it, + ), + }) + } + options={services.map((s) => ({ value: s.uuid, label: s.name }))} + placeholder="خدمت" + /> +
+ +
+ + setDraft({ + ...draft, + items: draft.items.map((it, i) => (i === index ? { ...it, price_rials: v } : it)), + }) + } + suffix="ریال" + /> +
+ + +
+ ))} + + +
+ + + خدمتی که در این لیست نیاید، قیمتش از تعرفهٔ سال یا خودِ خدمت خوانده می‌شود — + پس هیچ‌وقت بی‌قیمت نمی‌ماند. + +
+ )} +
+
+ ); +} diff --git a/docs/new_feture/taskes/task-08-pricing-snapshot/checklist.md b/docs/new_feture/taskes/task-08-pricing-snapshot/checklist.md index 13848415..40b59ffa 100644 --- a/docs/new_feture/taskes/task-08-pricing-snapshot/checklist.md +++ b/docs/new_feture/taskes/task-08-pricing-snapshot/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۸ (لیست قیمت بازه‌دار و snapshot فاکتور) -**وضعیت کلی:** ✅ بک‌اند و مستندات تکمیل (UI ⏳) · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تمام‌شده — بک‌اند، مستندات و UI · **آخرین بازبینی:** — قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -55,33 +55,38 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `PriceListsPage` · `PriceListFormPage` | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۲ | وضعیت شمسی: پیش‌نویس/فعال/منقضی با `StatusBadge` | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۳ | بازهٔ تاریخ با `PersianDatePicker` | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۴ | قیمت‌ها با `PriceInput` | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۵ | شعبه با `SearchableSelect` | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۶ | **«کپی از لیست قیمت قبلی»** | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۷ | کارت «فاکتور» در `AppointmentDetailPage` با ردیف‌های snapshot | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۸ | متن «قیمت بر اساس تاریخ نوبت محاسبه شده است» | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۹ | هیچ رنگ/شعاع hard-code | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۱۰ | دارک‌مود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۱۱ | RTL و موبایل | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۱۲ | مبالغ با `formatRial` · تاریخ‌ها با `formatDate` | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۱۳ | وضعیت لیست در URL با `useUrlState` | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۳.۱۴ | همهٔ رشته‌ها فارسی | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | +| ۳.۱ | `PriceListsPage` | ✅ | لیست + مودال ویرایش با ردیف‌های قیمت | +| ۳.۲ | وضعیت شمسی: پیش‌نویس/فعال/منقضی | ✅ | سه حالت با معنای عملیاتی متفاوت؛ فعال‌سازی عمل جداست، نه تیک داخل فرم | +| ۳.۳ | بازهٔ تاریخ با `PersianDateInput` | ✅ | تبدیل ISO↔Unix در همان صفحه | +| ۳.۴ | قیمت‌ها با `PriceInput` | ✅ | | +| ۳.۵ | شعبه با `SearchableSelect` | ✅ | «همهٔ شعبه‌ها» گزینهٔ صریح است، نه خالی‌گذاشتن | +| ۳.۶ | «کپی از لیست قیمت قبلی» | ✅ | ⭐ بازه از پایان لیست قبلی شروع می‌شود | +| ۳.۷ | کارت فاکتور در `AppointmentDetailPage` | ✅ | `AppointmentInvoiceCard` — ردیف‌های صفر پنهان می‌شوند | +| ۳.۸ | متن «قیمت بر اساس تاریخ نوبت محاسبه شده» | ✅ | ⭐ وگرنه کاربری که دیروز تعرفه را عوض کرده فکر می‌کند سیستم خراب است | +| ۳.۹ | هیچ رنگ/شعاع hard-code | ✅ | | +| ۳.۱۰ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد | +| ۳.۱۱ | RTL و موبایل | ✅ | جدول لیست‌ها اسکرول افقی داخلی دارد | +| ۳.۱۲ | مبالغ با `formatRial` · تاریخ با `formatDate` | ✅ | | +| ۳.۱۳ | وضعیت لیست در URL | ✅ | `useUrlState` | +| ۳.۱۴ | همهٔ رشته‌ها فارسی | ✅ | | +| ۳.۱۵ | تست فرانت کارت فاکتور | ✅ | چهار تست، شامل «نبودِ فاکتور خطا نیست» | ## ۴. تست | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `PriceResolverTest` — ترتیب پنج‌گانه + fallback | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۴.۲ | `PricingEngineTest` — تخفیف پشت‌سرهم، سقف، منفی → صفر | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۴.۳ | **invariant**: جمع ردیف‌ها = مبلغ نهایی، در همهٔ سناریوها | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۴.۴ | `PriceSnapshotImmutabilityTest` — قانون پنجم | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۴.۵ | `PriceListActivationTest` — تداخل هم‌سطح ۴۲۲، شعبه/محیط بی‌تداخل | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۴.۶ | `DepositCalculatorTest` — درصدی با min/max، اولویت سرویس | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۴.۷ | `QuoteTenantTest` — سرویس محیط دیگر ۴۰۴ | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | -| ۴.۸ | نوبت بدون سرویس (حالت `slot`) → snapshot با `visit_price_rials` | ⏳ | UI این تسک ساخته نشد — اندپوینت‌ها کامل و از API مصرف‌شدنی‌اند. مقصد: پاس UI مالی | +| ۴.۱ | ترتیب لایه‌های قیمت + fallback | ✅ | `testFullChainAppliesInOrder` · `testFallsBackToTheServicePrice` · `testBranchOverrideBeatsThePriceList` | +| ۴.۲ | تخفیف پشت‌سرهم، سقف، منفی → صفر | ✅ | `testDiscountLargerThanTheAmountFloorsAtZero` · `testTotalDiscountCapIsApplied` | +| ۴.۳ | invariant جمع ردیف‌ها = مبلغ نهایی | ⚠️ | زنجیره در `testFullChainAppliesInOrder` عدد‌به‌عدد سنجیده می‌شود؛ invariant به‌صورت property-based روی سناریوهای تصادفی نوشته نشد | +| ۴.۴ | تغییرناپذیری فاکتور (قانون پنجم) | ✅ | ⭐ `testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange` | +| ۴.۵ | فعال‌سازی و تداخل بازه | ✅ | `testOverlappingActiveListsAreRejected` · `testBranchListWinsOverTheGeneralList` · `testDraftListHasNoEffectUntilActivated` | +| ۴.۶ | محاسبهٔ بیعانه | ⚠️ | درصدی و مبلغی هر دو در `PricingEngine` هست و در زنجیرهٔ کامل تست می‌شود؛ تست اختصاصی با min/max ندارد | +| ۴.۷ | سرویس محیط دیگر ۴۰۴ | ✅ | `testForeignServiceIsNotFound` | +| ۴.۸ | نوبت بدون سرویس → فاکتور با `visit_price_rials` | ⚠️ | مسیرش هست (`recordFlatVisit`)؛ تست اختصاصی ندارد | +| ۴.۹ | قیمت منفی رد می‌شود | ✅ | `testNegativePriceIsRejected` | +| ۴.۱۰ | تست فرانت کارت فاکتور | ✅ | چهار تست | + +**اجرا:** `ddev exec php bin/phpunit tests/Pricing` → ۱۲ تست. ## ۵. مستندات