From 4bca6599396cc483be79a5cfffa89823c00c8904 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 31 Jul 2026 19:58:29 +0330 Subject: [PATCH] feat(admin): price lists and the appointment invoice card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 08's pricing chain was reachable only through the API, so a clinic could not define a price list or see what a booked appointment was actually charged. Price lists - Draft / active / expired are shown as three states because they mean three different things operationally: a draft has no effect on today's price at all - Activation is a separate action rather than a checkbox in the form, matching the backend rule that creating a list must not change anything - "Copy" seeds a new list from an existing one starting the day the old one ends, since most lists are last quarter's with a few numbers moved - "All branches" is an explicit option, not an empty field Invoice card - Renders the recorded chain down to the final amount, hiding zero rows so the card stays readable - A missing invoice renders as a normal state, not an error: an appointment that was never confirmed has no invoice - Says outright that the numbers are from the appointment's own date and later tariff changes do not move them — otherwise someone who edited a price yesterday reads today's older number as a bug Also corrects task 08's checklist: its test section carried a copy-pasted "no UI was built" note against rows whose tests have existed since the task shipped. Replaced with the real test names and the two that genuinely are not covered. Co-Authored-By: Claude Opus 5 (1M context) --- assets/admin/App.tsx | 2 + .../AppointmentInvoiceCard.test.tsx | 70 ++++ .../components/AppointmentInvoiceCard.tsx | 110 ++++++ .../components/layout/SettingsLayout.tsx | 1 + assets/admin/hooks/usePriceLists.ts | 131 +++++++ assets/admin/pages/AppointmentDetailPage.tsx | 3 + assets/admin/pages/PriceListsPage.tsx | 355 ++++++++++++++++++ .../task-08-pricing-snapshot/checklist.md | 51 +-- 8 files changed, 700 insertions(+), 23 deletions(-) create mode 100644 assets/admin/components/AppointmentInvoiceCard.test.tsx create mode 100644 assets/admin/components/AppointmentInvoiceCard.tsx create mode 100644 assets/admin/hooks/usePriceLists.ts create mode 100644 assets/admin/pages/PriceListsPage.tsx 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` → ۱۲ تست. ## ۵. مستندات