diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index dc7888bd..5394d2ce 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -75,6 +75,8 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; import BranchesPage from './pages/BranchesPage'; +import CancellationPolicyPage from './pages/CancellationPolicyPage'; +import WaitlistPage from './pages/WaitlistPage'; import CourseProtocolsPage from './pages/CourseProtocolsPage'; import TreatmentCoursePage from './pages/TreatmentCoursePage'; import PackagesPage from './pages/PackagesPage'; @@ -301,6 +303,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 74b934f1..69eb90a9 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, BanknotesIcon, UsersIcon, ShieldCheckIcon, - TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, + TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, NoSymbolIcon, QueueListIcon, } from '@heroicons/react/24/outline'; import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar'; @@ -35,6 +35,8 @@ 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: '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: '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'] }, diff --git a/assets/admin/hooks/useCancellation.ts b/assets/admin/hooks/useCancellation.ts new file mode 100644 index 00000000..5fbae1f6 --- /dev/null +++ b/assets/admin/hooks/useCancellation.ts @@ -0,0 +1,101 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError, type ApiResponse } from '../lib/api'; +import type { CancellationPolicy, CancellationPreview, CancellationResult, WaitlistEntry } from '../types'; + +/** + * سیاست لغو و لیست انتظار. + * + * پیش‌نمایش لغو همیشه از سرور می‌آید — همان محاسبه‌ای که خودِ لغو انجام می‌دهد، تا عددی + * که کاربر می‌بیند با آنچه کسر می‌شود یکی باشد. + */ +const POLICY_KEY = ['cancellation-policy']; +const WAITLIST_KEY = ['waitlist']; + +function fail(e: unknown, fallback: string) { + toast.error(e instanceof ApiError ? e.message : fallback); +} + +interface PolicyResponse { + default: CancellationPolicy | null; + overrides: CancellationPolicy[]; +} + +export function useCancellationPolicy() { + const qc = useQueryClient(); + + const query = useQuery({ + queryKey: POLICY_KEY, + queryFn: () => api.get>('/api/v1/cancellation-policy'), + }); + + const save = useMutation({ + mutationFn: (body: Record) => + api.put>('/api/v1/cancellation-policy', body), + onSuccess: () => { + toast.success('سیاست لغو ذخیره شد'); + qc.invalidateQueries({ queryKey: POLICY_KEY }); + }, + onError: (e) => fail(e, 'ذخیرهٔ سیاست ناموفق بود'), + }); + + return { + policy: query.data?.data?.default ?? null, + overrides: query.data?.data?.overrides ?? [], + loading: query.isLoading, + save, + }; +} + +export function useCancellationPreview(appointmentUuid: string | undefined, by: 'user' | 'doctor') { + const query = useQuery({ + queryKey: ['cancellation-preview', appointmentUuid, by], + queryFn: () => + api.get>( + `/api/v1/appointment/${appointmentUuid}/cancellation-preview?by=${by}`, + ), + enabled: !!appointmentUuid, + }); + + return { preview: query.data?.data, loading: query.isLoading }; +} + +export function useCancelAppointment() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: ({ uuid, by }: { uuid: string; by: 'user' | 'doctor' }) => + api.post>(`/api/v1/appointment/${uuid}/cancel`, { by }), + onSuccess: (res) => { + const notified = res.data.waitlist_notified; + toast.success( + notified > 0 ? `نوبت لغو شد و ${notified} نفر از لیست انتظار خبر شدند` : 'نوبت لغو شد', + ); + qc.invalidateQueries({ queryKey: ['appointments'] }); + qc.invalidateQueries({ queryKey: WAITLIST_KEY }); + }, + onError: (e) => fail(e, 'لغو نوبت ناموفق بود'), + }); +} + +export function useWaitlist(status?: string) { + const qc = useQueryClient(); + const key = [...WAITLIST_KEY, status ?? '']; + + const query = useQuery({ + queryKey: key, + queryFn: () => + api.get>(`/api/v1/waitlist${status ? `?status=${status}` : ''}`), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/waitlist/${uuid}`), + onSuccess: () => { + toast.success('از لیست انتظار حذف شد'); + qc.invalidateQueries({ queryKey: WAITLIST_KEY }); + }, + onError: (e) => fail(e, 'حذف ناموفق بود'), + }); + + return { entries: query.data?.data ?? [], loading: query.isLoading, remove }; +} diff --git a/assets/admin/pages/CancellationPolicyPage.test.tsx b/assets/admin/pages/CancellationPolicyPage.test.tsx new file mode 100644 index 00000000..4229e21e --- /dev/null +++ b/assets/admin/pages/CancellationPolicyPage.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, 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 CancellationPolicyPage from './CancellationPolicyPage'; + +const get = api.get as ReturnType; +const put = api.put as ReturnType; + +const policy = { + uuid: 'cp1', + service_uuid: null, + service_name: null, + free_window_hours: 24, + penalty_mode: 'percent' as const, + penalty_value: 50, + deposit_refundable: false, + credit_refundable: true, + no_show_threshold: 3, + risk_tag_uuid: null, + active: true, + created_at: 1_700_000_000, +}; + +describe('CancellationPolicyPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + get.mockResolvedValue({ success: true, data: { default: policy, overrides: [] } }); + put.mockResolvedValue({ success: true, data: policy }); + }); + + it('loads the saved policy into the form', async () => { + renderWithProviders(, { route: '/admin/cancellation-policy' }); + + // فیلد درصد فقط وقتی حالت «درصدی» است رندر می‌شود، پس انتظارش هم باید صبر کند. + await waitFor(() => expect(screen.getByLabelText('درصد جریمه')).toHaveValue(50)); + expect(screen.getByLabelText('پنجرهٔ لغو رایگان (ساعت)')).toHaveValue(24); + expect(screen.getByLabelText('آستانهٔ عدم حضور')).toHaveValue(3); + }); + + /** درصد بیرون بازه نباید تا سرور برود و ۴۲۲ بگیرد؛ فرم همان‌جا می‌گوید. */ + it('blocks saving a percentage above one hundred', async () => { + renderWithProviders(, { route: '/admin/cancellation-policy' }); + + await waitFor(() => expect(screen.getByLabelText('درصد جریمه')).toBeInTheDocument()); + + fireEvent.change(screen.getByLabelText('درصد جریمه'), { target: { value: '150' } }); + + expect(screen.getByText('درصد باید بین ۰ تا ۱۰۰ باشد.')).toBeInTheDocument(); + expect(screen.getByText('ذخیرهٔ سیاست')).toBeDisabled(); + expect(put).not.toHaveBeenCalled(); + }); +}); diff --git a/assets/admin/pages/CancellationPolicyPage.tsx b/assets/admin/pages/CancellationPolicyPage.tsx new file mode 100644 index 00000000..76bbfe57 --- /dev/null +++ b/assets/admin/pages/CancellationPolicyPage.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from 'react'; +import PageHeader from '../components/ui/PageHeader'; +import PriceInput from '../components/ui/PriceInput'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { usePermissions } from '../hooks/usePermissions'; +import { useCancellationPolicy } from '../hooks/useCancellation'; + +const MODES = [ + { value: 'none', label: 'بدون جریمه' }, + { value: 'percent', label: 'درصدی' }, + { value: 'fixed', label: 'مبلغ ثابت' }, +]; + +/** + * سیاست لغو محیط. + * + * پیش‌فرض «بدون جریمه» است و همین‌جا هم گفته می‌شود: فعال‌کردن جریمه یک تصمیم + * کسب‌وکاری است، نه چیزی که کسی تصادفی روشنش کند. + */ +export default function CancellationPolicyPage() { + const { policy, loading, save } = useCancellationPolicy(); + const { can } = usePermissions(); + const canManage = can('appointment_settings', 'update'); + + const [freeWindow, setFreeWindow] = useState(24); + const [mode, setMode] = useState<'none' | 'percent' | 'fixed'>('none'); + const [value, setValue] = useState(0); + const [depositRefundable, setDepositRefundable] = useState(false); + const [creditRefundable, setCreditRefundable] = useState(true); + const [threshold, setThreshold] = useState(3); + + useEffect(() => { + if (!policy) return; + setFreeWindow(policy.free_window_hours); + setMode(policy.penalty_mode); + setValue(policy.penalty_value); + setDepositRefundable(policy.deposit_refundable); + setCreditRefundable(policy.credit_refundable); + setThreshold(policy.no_show_threshold); + }, [policy]); + + const percentInvalid = mode === 'percent' && (value < 0 || value > 100); + + return ( +
+ + +
+ {loading && در حال بارگذاری…} + +
+ + setFreeWindow(Number(e.target.value))} + /> + + لغو زودتر از این، همیشه رایگان است. + +
+ +
+ + setMode((v as 'none' | 'percent' | 'fixed') ?? 'none')} + options={MODES} + /> +
+ + {mode === 'percent' && ( +
+ + setValue(Number(e.target.value))} + /> + {percentInvalid && ( + درصد باید بین ۰ تا ۱۰۰ باشد. + )} +
+ )} + + {mode === 'fixed' && ( +
+ + +
+ )} + + + جریمه هرگز از مبلغ پرداختی بیمار بیشتر نمی‌شود؛ نوبت نقدی جریمه‌ای ندارد. + + + + + + +
+ + setThreshold(Number(e.target.value))} + /> + + بعد از این تعداد در یک سال، بیمار برچسب پرریسک می‌گیرد — ولی مسدود نمی‌شود. + +
+ + {canManage && ( +
+ +
+ )} +
+
+ ); +} diff --git a/assets/admin/pages/WaitlistPage.tsx b/assets/admin/pages/WaitlistPage.tsx new file mode 100644 index 00000000..a7b019d2 --- /dev/null +++ b/assets/admin/pages/WaitlistPage.tsx @@ -0,0 +1,131 @@ +import React, { useMemo } from 'react'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { formatDate } from '../lib/utils'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useWaitlist } from '../hooks/useCancellation'; +import type { WaitlistEntry } from '../types'; + +const STATUS: Record = { + waiting: { label: 'در انتظار', className: 'badge' }, + notified: { label: 'خبر داده شد', className: 'badge amber' }, + converted: { label: 'رزرو شد', className: 'badge green' }, + expired: { label: 'منقضی', className: 'badge red' }, +}; + +/** + * لیست انتظار. + * + * ستون «تعداد اطلاع» عمداً دیده می‌شود: وقتی ظرفیتی آزاد می‌شود همه خبر می‌گیرند و + * اولین رزروکننده می‌برد، پس اپراتور باید بداند چه کسی چند بار خبر شده. + */ +export default function WaitlistPage() { + const [urlState, setUrlState] = useUrlState({ search: '', status: '' }); + const { entries, loading, remove } = useWaitlist(urlState.status || undefined); + const { can } = usePermissions(); + const canManage = can('appointment_settings', 'update'); + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return entries.filter((e) => q === '' || e.service_name.includes(q)); + }, [entries, urlState.search]); + + const columns: Column[] = [ + { + key: 'service_name', + header: 'خدمت', + render: (e) => {e.service_name}, + }, + { + key: 'window', + header: 'بازهٔ دلخواه', + render: (e) => ( + + {formatDate(e.desired_from)} تا {formatDate(e.desired_to)} + + ), + }, + { + key: 'preferred_day_parts', + header: 'زمان ترجیحی', + render: (e) => ( + + {e.preferred_day_parts.length === 0 ? 'بی‌تفاوت' : e.preferred_day_parts.join('، ')} + + ), + }, + { + key: 'notify_count', + header: 'تعداد اطلاع', + render: (e) => ( + + {e.notify_count} + {e.notified_at !== null && ( + · {formatDate(e.notified_at)} + )} + + ), + }, + { + key: 'status', + header: 'وضعیت', + render: (e) => ( + + + {STATUS[e.status].label} + + ), + }, + ]; + + return ( +
+ + +
+ setUrlState({ search: v })} + searchPlaceholder="جستجو در خدمات..." + emptyMessage="کسی در لیست انتظار نیست" + headerExtra={ +
+ setUrlState({ status: String(v ?? '') })} + options={[ + { value: '', label: 'همهٔ وضعیت‌ها' }, + ...Object.entries(STATUS).map(([value, meta]) => ({ value, label: meta.label })), + ]} + placeholder="وضعیت" + /> +
+ } + actions={(e) => + canManage ? ( + + ) : null + } + /> +
+
+ ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 3514e755..c4c7fadd 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1323,3 +1323,53 @@ export interface NextSlotSuggestion { suggested_slots: { start: number; end: number }[]; warning: string | null; } + +// ── لغو و لیست انتظار (تسک ۱۳) ─────────────────────────────────────────────── + +export interface CancellationPolicy { + uuid: string; + service_uuid: string | null; + service_name: string | null; + free_window_hours: number; + penalty_mode: 'none' | 'percent' | 'fixed'; + penalty_value: number; + deposit_refundable: boolean; + credit_refundable: boolean; + no_show_threshold: number; + risk_tag_uuid: string | null; + active: boolean; + created_at: number; +} + +export interface CancellationPreview { + penalty_rials: number; + deposit_refundable: boolean; + credit_refundable: boolean; + within_free_window: boolean; + notes: string[]; + paid_rials: number; +} + +export interface CancellationResult extends Omit { + appointment_uuid: string; + status: string; + released_resources: number; + waitlist_notified: number; + penalty_charged: boolean; +} + +export interface WaitlistEntry { + uuid: string; + patient_uuid: string; + service_uuid: string; + service_name: string; + branch_id: number | null; + desired_from: number; + desired_to: number; + preferred_day_parts: string[]; + priority: number; + status: 'waiting' | 'notified' | 'converted' | 'expired'; + notified_at: number | null; + notify_count: number; + created_at: number; +} diff --git a/docs/api/cancellation.md b/docs/api/cancellation.md new file mode 100644 index 00000000..9dba546c --- /dev/null +++ b/docs/api/cancellation.md @@ -0,0 +1,176 @@ +# Cancellation — سیاست لغو، جریمه و عدم حضور + +اندپوینت‌های `src/Cancellation/*`. مستند بند ۱۱: «هر کلینیک تنظیم می‌کند تا چند ساعت قبل +لغو رایگان است، جریمه چقدر است، بیعانه برمی‌گردد یا نه، و بعد از چند بار عدم حضور بیمار +پرریسک علامت بخورد.» + +همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط دیگر). + +--- + +## دو قاعده‌ای که شکستنشان گران است + +۱. **لغو توسط کلینیک هرگز جریمه ندارد.** این شرط اولین خط محاسبه است، نه جایی وسط آن — + اگر بعد از بررسی پنجرهٔ زمانی می‌آمد، یک refactor می‌توانست ترتیب را عوض کند و کلینیک + از بیمار برای لغو خودش جریمه بگیرد. +۲. **جریمه هرگز از مبلغ پرداختی بیشتر نمی‌شود.** جریمهٔ بیشتر یعنی بدهی، و بدهی مسئلهٔ + صورتحساب است نه لغو. برای نوبت نقدی (پرداختی صفر) جریمه صفر می‌شود و پاسخ توضیحش را + در `notes` می‌دهد. + +**پیش‌فرض بدون جریمه است.** اگر پیش‌فرض جریمه‌دار بود، لحظهٔ deploy همهٔ بیماران با نوبت +نزدیک مشمول جریمه می‌شدند و کلینیک خبر نداشت. فعال‌کردن جریمه یک تصمیم کسب‌وکاری صریح است. + +--- + +## GET `/api/v1/cancellation-policy` + +```json +{ + "success": true, + "data": { + "default": { + "uuid": "…", + "service_uuid": null, + "free_window_hours": 24, + "penalty_mode": "percent", + "penalty_value": 50, + "deposit_refundable": false, + "credit_refundable": true, + "no_show_threshold": 3, + "risk_tag_uuid": null, + "active": true, + "created_at": 1785486000 + }, + "overrides": [] + } +} +``` + +## PUT `/api/v1/cancellation-policy` · PUT `/api/v1/service-item/{uuid}/cancellation-policy` + +| Field | Type | Description | +|---|---|---| +| `free_window_hours` | int | تا چند ساعت قبل، لغو رایگان است | +| `penalty_mode` | string | `none` \| `percent` \| `fixed` | +| `penalty_value` | int | درصد ۰..۱۰۰ یا مبلغ ریالی | +| `deposit_refundable` | bool | پس از پنجرهٔ رایگان | +| `credit_refundable` | bool | اعتبار پکیج (تسک ۱۱) | +| `no_show_threshold` | int | بعد از چند بار عدم حضور، برچسب پرریسک | +| `risk_tag_uuid` | string\|null | برچسبی از `tenant_tags` | +| `active` | bool | | + +سیاستِ سرویس و سیاستِ محیط **ترکیب نمی‌شوند**: اگر سرویس سیاست فعال دارد، همان کامل +برنده است. «۲۴ ساعت از محیط ولی ۵۰٪ از سرویس» چیزی است که هیچ اپراتوری نمی‌تواند در +ذهنش شبیه‌سازی کند. + +### Errors +| Code | HTTP | Description | +|---|---|---| +| `ERR_VALIDATION_001` | 422 | درصد بیرون بازهٔ ۰..۱۰۰ یا حالت جریمهٔ ناشناخته | + +--- + +## GET `/api/v1/appointment/{uuid}/cancellation-preview` + +| Query | Type | Description | +|---|---|---| +| `by` | string | `doctor` برای لغو از سمت کلینیک؛ پیش‌فرض بیمار | + +```json +{ + "success": true, + "data": { + "penalty_rials": 2000000, + "deposit_refundable": false, + "credit_refundable": true, + "within_free_window": false, + "notes": [], + "paid_rials": 4000000 + } +} +``` + +همان محاسبه‌ای که خودِ لغو انجام می‌دهد — بیمار نباید عددی ببیند که با آنچه کسر می‌شود +فرق دارد. + +--- + +## POST `/api/v1/appointment/{uuid}/cancel` + +```json +{ "by": "doctor" } +``` + +`by` اختیاری است؛ نبودنش یعنی لغو از سمت بیمار. + +### Response `200` +```json +{ + "success": true, + "data": { + "appointment_uuid": "…", + "status": "cancelled_by_user", + "released_resources": 3, + "waitlist_notified": 2, + "penalty_charged": true, + "penalty_rials": 1000000, + "deposit_refundable": false, + "credit_refundable": true, + "within_free_window": false, + "notes": [] + } +} +``` + +ترتیب کارها: اعتبارسنجی → آزادسازی ظرفیت و بازگشت اعتبار → کسر جریمه → اطلاع به لیست +انتظار. اگر اطلاع‌رسانی اول بود، ده نفر برای ظرفیتی خبر می‌شدند که هنوز آزاد نشده. + +**موجودی ناکافی لغو را شکست نمی‌دهد:** `penalty_charged: false` برمی‌گردد ولی نوبت آزاد +می‌شود. نوبت نباید گروگان پول بماند. + +سیاستی که `credit_refundable: false` دارد، اعتبارِ برگشتهٔ پکیج را با یک ردیف +`adjustment` منفی پس می‌گیرد — ردیف `refund` حذف نمی‌شود، چون دفتر append-only است. + +### Errors +| Code | HTTP | Description | +|---|---|---| +| `ERR_SLOT_TAKEN` | 409 | نوبت قبلاً لغو شده | +| `ERR_VALIDATION_001` | 422 | نوبت گذشته — برای گذشته `no_show` یا `completed` معنا دارد | + +--- + +## POST `/api/v1/appointment/{uuid}/no-show` + +```json +{ + "success": true, + "data": { "recorded": true, "count": 3, "threshold": 3, "tagged": true } +} +``` + +هر عدم حضور یک **ردیف** است نه یک شمارنده: شمارنده «چه زمانی و کدام نوبت» را از دست +می‌دهد و پنجرهٔ ۱۲ ماهه را غیرقابل محاسبه می‌کند. بیماری که سه سال پیش سه بار نیامده، +امروز پرریسک نیست. + +ثبت دوباره روی همان نوبت `recorded: false` می‌دهد و شمارش را بالا نمی‌برد. + +⚠️ **برچسب پرریسک مسدود نمی‌کند.** مسدودسازی یک قانون `eligibility` (تسک ۰۹) روی همین +برچسب است. تفکیکش عمدی است: کلینیکی که می‌خواهد بیمار پرریسک را ببیند ولی بیعانه بگیرد، +نباید مجبور شود برچسب را خاموش کند. + +--- + +## طبقه‌بندی محیط + +| جدول | وضعیت | +|---|---| +| `cancellation_policies` · `no_show_records` | جفت محیط | + +تراکنش جریمه در کیف پول با `entity_type`/`entity_id` نوبت ثبت می‌شود، وگرنه کلینیک الف +جریمهٔ ثبت‌شده در کلینیک ب را می‌دید. + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Cancellation # ۱۴ تست +``` diff --git a/docs/api/waitlist.md b/docs/api/waitlist.md new file mode 100644 index 00000000..cefc815e --- /dev/null +++ b/docs/api/waitlist.md @@ -0,0 +1,97 @@ +# Waitlist — لیست انتظار + +اندپوینت‌های `src/Waitlist/*`. «اگر وقتی در این بازه آزاد شد، خبرم کن» — توسعهٔ همان +ایدهٔ `Appointment.is_reserve` موجود، ولی با بازهٔ صریح و وضعیت. + +--- + +## چرا broadcast و نه صف انحصاری + +ظرفیت آزادشده به **حداکثر ده نفر** خبر داده می‌شود و اولین رزروکننده می‌برد. + +صف انحصاری («فقط نفر اول ۳۰ دقیقه فرصت دارد») روی کاغذ عادلانه‌تر است، ولی در عمل یعنی +وقتی که کسی جوابش را نمی‌دهد نیم ساعت قفل بماند و بعد به نفر دوم برسد — و ظرفیتی که دو +ساعت مانده به نوبت آزاد شده، نیم ساعت وقتِ تلف‌کردنی ندارد. + +در عوض متن پیامک **اجباراً** این را می‌گوید: + +> «یک وقت برای «X» در تاریخ Y آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.» + +هر درخواست حداکثر **سه بار** خبر می‌گیرد؛ بدون سقف، یک بازهٔ پرلغو به منبع اسپم تبدیل +می‌شود. + +--- + +## GET `/api/v1/waitlist` + +| Query | Type | Description | +|---|---|---| +| `status` | string | `waiting` \| `notified` \| `converted` \| `expired` | + +```json +{ + "success": true, + "data": [ + { + "uuid": "…", + "patient_uuid": "…", + "service_uuid": "…", + "service_name": "لیزر", + "branch_id": 12, + "desired_from": 1785600000, + "desired_to": 1785859200, + "preferred_day_parts": ["evening"], + "priority": 0, + "status": "waiting", + "notified_at": null, + "notify_count": 0, + "created_at": 1785486000 + } + ] +} +``` + +## POST `/api/v1/waitlist` + +| Field | Type | Required | Description | +|---|---|---|---| +| `patient_uuid` | string | ✅ | | +| `service_uuid` | string | ✅ | | +| `desired_from` / `desired_to` | int | ✅ | Unix؛ بازه باید در آینده باشد | +| `branch_uuid` | string | — | نبودنش یعنی «هر شعبه» | +| `preferred_day_parts` | string[] | — | `["morning","evening"]` | +| `priority` | int | — | بزرگ‌تر زودتر خبر می‌شود | + +### Errors +| Code | HTTP | Description | +|---|---|---| +| `ERR_VALIDATION_001` | 422 | بازهٔ گذشته یا پایانِ قبل از شروع | +| `ERR_VALIDATION_002` | 422 | فیلد الزامی غایب | +| `ERR_NOT_FOUND_001` | 404 | بیمار یا سرویس خارج از محیط جاری | + +## DELETE `/api/v1/waitlist/{uuid}` + +## GET `/api/v1/waitlist/matches` + +| Query | Type | Required | +|---|---|---| +| `service_uuid` | string | ✅ | +| `start` | int | ✅ | +| `branch_uuid` | string | — | + +چه کسانی منتظر این سرویس در این لحظه‌اند؟ مرتب بر اساس اولویت، بعد قدمت. درخواستی که +شعبهٔ دیگری خواسته در نتیجه نمی‌آید؛ درخواست بی‌شعبه همیشه می‌آید. + +--- + +## اطلاع خودکار هنگام لغو + +`POST /api/v1/appointment/{uuid}/cancel` بعد از آزادسازی ظرفیت، لیست انتظار را خبر +می‌کند و تعدادش را در `waitlist_notified` برمی‌گرداند. جزئیات لغو: +[cancellation.md](cancellation.md) + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Waitlist # ۹ تست +``` diff --git a/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md b/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md index 41b51402..72bdb66e 100644 --- a/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md +++ b/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۱۳ (سیاست لغو، عدم حضور، لیست انتظار) -**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -11,118 +11,120 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۰.۲ | پیش‌فرض سیاست **بدون جریمه** (`penalty_mode='none'`) | ⏳ | ⭐⭐ وگرنه لحظهٔ deploy همه مشمول جریمه | -| ۰.۳ | بیمار پرریسک **مسدود نمی‌شود** — فقط برچسب | ⏳ | ⭐ مسدودسازی = قانون `eligibility` | -| ۰.۴ | `ReserveAppointmentsPage`/`is_reserve` دست‌نخورده | ⏳ | مفهوم متفاوت از لیست انتظار | -| ۰.۵ | وضعیت‌های لغو موجود (`cancelled_by_*`, `no_show`) دست‌نخورده | ⏳ | | +| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۰.۲ | پیش‌فرض سیاست بدون جریمه | ✅ | ⭐⭐ `penaltyMode = 'none'` در خودِ entity، نه در seed | +| ۰.۳ | بیمار پرریسک مسدود نمی‌شود | ✅ | ⭐ فقط برچسب؛ مسدودسازی = قانون `eligibility` تسک ۰۹ | +| ۰.۴ | `is_reserve` و صفحه‌اش دست‌نخورده | ✅ | مفهوم متفاوت؛ ادغام خارج از دامنه | +| ۰.۵ | وضعیت‌های لغو موجود دست‌نخورده | ✅ | همان `cancelled_by_*` و `no_show` | ## ۱. بک‌اند — لغو و جریمه | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۱.۱ | `CancellationPolicy` · `NoShowRecord` | ⏳ | | -| ۱.۲ | `CancellationPolicyResolver` — سرویس بر محیط اولویت دارد | ⏳ | | -| ۱.۳ | `PenaltyCalculator` — شرط «لغو توسط کلینیک» **اولین خط** | ⏳ | ⭐ | -| ۱.۴ | سقف جریمه = مبلغ پرداختی (`min($penalty, $paid)`) | ⏳ | | -| ۱.۵ | نوبت نقدی → جریمه صفر + `note` | ⏳ | | -| ۱.۶ | `GET /cancellation-preview` پیش از لغو | ⏳ | ⭐ | -| ۱.۷ | `CancellationService` هفت مرحله در یک تراکنش | ⏳ | | -| ۱.۸ | جریمه در `WalletTransaction` با `setRecordedEntity()` | ⏳ | ⭐ وگرنه نشتی بین محیط‌ها | -| ۱.۹ | بازگشت اعتبار پکیج **طبق سیاست** (`credit_refundable`)، نه همیشه | ⏳ | تسک ۱۱ `TODO` را برمی‌دارد | -| ۱.۱۰ | `CourseSessionLinker::releaseSession()` صدا زده می‌شود | ⏳ | تسک ۱۲ | -| ۱.۱۱ | لغو دوباره → idempotent | ⏳ | | -| ۱.۱۲ | لغو نوبت گذشته → ۴۲۲ | ⏳ | | +| ۱.۱ | `CancellationPolicy` · `NoShowRecord` | ✅ | | +| ۱.۲ | سرویس بر محیط اولویت دارد | ✅ | `CancellationPolicyRepository::resolve()` — بدون ترکیب | +| ۱.۳ | شرط «لغو توسط کلینیک» اولین خط | ✅ | ⭐ با کامنت توضیح چرا | +| ۱.۴ | سقف جریمه = مبلغ پرداختی | ✅ | | +| ۱.۵ | نوبت نقدی → جریمه صفر + `note` | ✅ | | +| ۱.۶ | `GET /cancellation-preview` | ✅ | ⭐ همان محاسبهٔ لغو واقعی | +| ۱.۷ | `CancellationService` با ترتیب مشخص | ⚠️ | مراحل هست ولی **یک تراکنش سراسری ندارد**: آزادسازی ظرفیت باید حتی اگر کیف پول یا پیامک بشکند انجام شود؛ تراکنش واحد یعنی یک خطای پیامک، ظرفیت را برنگرداند | +| ۱.۸ | جریمه در کیف پول با جفت محیط | ✅ | ⭐ `PatientWalletTenantTest` سبز ماند | +| ۱.۹ | بازگشت اعتبار طبق سیاست | ✅ | `credit_refundable: false` ردیف `refund` را با `adjustment` منفی خنثی می‌کند — دفتر append-only می‌ماند | +| ۱.۱۰ | جلسهٔ دوره آزاد می‌شود | ✅ | از `BookingService::cancel()` که تسک ۱۲ وصلش کرد | +| ۱.۱۱ | لغو دوباره → ۴۰۹ | ✅ | | +| ۱.۱۲ | لغو نوبت گذشته → ۴۲۲ | ✅ | | ## ۲. بک‌اند — عدم حضور | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | `NoShowTracker` با پنجرهٔ **۱۲ ماه** | ⏳ | نه کل تاریخ | -| ۲.۲ | برچسب پرریسک از `TenantTag` موجود، نه ستون بولین جدید | ⏳ | ⭐ | -| ۲.۳ | `UNIQUE(appointment_id)` → یک رکورد per نوبت | ⏳ | | -| ۲.۴ | جدول جدا، نه ستون شمارنده روی بیمار | ⏳ | همان استدلال دفتر اعتبار | +| ۲.۱ | پنجرهٔ ۱۲ ماه | ✅ | `NoShowRecordRepository::WINDOW_DAYS` | +| ۲.۲ | برچسب از `TenantTag` موجود | ✅ | ⭐ هیچ ستون بولین تازه‌ای | +| ۲.۳ | یک رکورد per نوبت | ✅ | کلید یکتا + بررسی پیش از درج | +| ۲.۴ | جدول جدا، نه شمارنده | ✅ | همان استدلال دفتر اعتبار تسک ۱۱ | ## ۳. بک‌اند — لیست انتظار | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `WaitlistEntry` · `WaitlistService` · `WaitlistMatcher` | ⏳ | | -| ۳.۲ | **broadcast** به حداکثر ۱۰ نفر، اولین رزروکننده می‌برد | ⏳ | تصمیم مکتوب | -| ۳.۳ | متن پیامک شامل «اولین نفری که رزرو کند آن را می‌گیرد» | ⏳ | ⭐ اجباری | -| ۳.۴ | `notify_count` سقف دارد (پیشنهاد ۳) | ⏳ | جلوگیری از اسپم | -| ۳.۵ | اطلاع‌رسانی **async** روی رویداد، بیرون تراکنش لغو | ⏳ | ⭐ لغو مستقل از پیامک | -| ۳.۶ | ترتیب: `priority DESC, created_at ASC` | ⏳ | | -| ۳.۷ | `preferred_day_parts` در PHP فیلتر می‌شود | ⏳ | | -| ۳.۸ | بیمار که خودش نوبت گرفت → `converted` خودکار روی رویداد `AppointmentBooked` | ⏳ | ⭐ وگرنه پیامک اضافه می‌گیرد | -| ۳.۹ | `app:waitlist:expire` روزانه | ⏳ | | -| ۳.۱۰ | بازهٔ دلخواه > ۹۰ روز → ۴۲۲ | ⏳ | | -| ۳.۱۱ | هفت endpoint | ⏳ | | +| ۳.۱ | `WaitlistEntry` + `WaitlistNotifier` | ⚠️ | یک notifier به‌جای دو کلاس `Service`/`Matcher`؛ تطبیق یک کوئری در repository است و کلاس جدا فقط لایه بود | +| ۳.۲ | broadcast به حداکثر ۱۰ نفر | ✅ | تصمیم و دلیلش در `waitlist.md` | +| ۳.۳ | جملهٔ «اولین نفر می‌برد» در پیامک | ✅ | ⭐ | +| ۳.۴ | سقف `notify_count` | ✅ | ۳ بار | +| ۳.۵ | پیامک async بیرون تراکنش لغو | ✅ | ⭐ `dispatchAsync` روی messenger؛ لغو تراکنش سراسری هم ندارد (۱.۷) | +| ۳.۶ | ترتیب `priority DESC, created_at ASC` | ✅ | | +| ۳.۷ | فیلتر `preferred_day_parts` | ⏳ | ذخیره و نمایش می‌شود ولی در تطبیق اعمال نمی‌شود — بدون منطقهٔ زمانی شعبه، «عصر» تعریف قطعی ندارد؛ به تسک ۱۴ موکول شد | +| ۳.۸ | `converted` خودکار روی رزرو بیمار | ⏳ | نیازمند رویداد `AppointmentBooked` که تسک ۱۴ می‌سازد | +| ۳.۹ | `app:waitlist:expire` روزانه | ⏳ | ردیف منقضی در تطبیق نمی‌آید (`desiredTo >= now`)، پس اثر عملی ندارد؛ پاکسازی با تسک ۱۴ | +| ۳.۱۰ | بازهٔ بیش از ۹۰ روز → ۴۲۲ | ⏳ | فقط بازهٔ گذشته و وارونه رد می‌شود | +| ۳.۱۱ | هفت endpoint | ✅ | ۱۰ تا: سیاست GET/PUT + override + preview + cancel + no-show + لیست انتظار GET/POST/DELETE/matches | ## ۴. دیتابیس | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | سه جدول | ⏳ | | -| ۴.۲ | `idx_waitlist_match (service_item_id, branch_id, status, desired_from, desired_to)` | ⏳ | | -| ۴.۳ | `idx_no_show_patient (patient_record_id, recorded_at)` | ⏳ | کوئری پنجرهٔ ۱۲ ماه | -| ۴.۴ | `risk_tag_uuid` بدون FK (الگوی `DiscountRule.target_tag_uuid`) | ⏳ | | -| ۴.۵ | `app:cancellation:seed-default-policy` — محافظه‌کار | ⏳ | | -| ۴.۶ | `TenantSchemaCoverageTest` سبز | ⏳ | | +| ۴.۱ | سه جدول | ✅ | `Version20260731081142` | +| ۴.۲ | ایندکس تطبیق لیست انتظار | ✅ | | +| ۴.۳ | ایندکس پنجرهٔ عدم حضور | ✅ | | +| ۴.۴ | `risk_tag_uuid` بدون FK | ✅ | همان الگوی موجود پروژه | +| ۴.۵ | دستور seed سیاست پیش‌فرض | ⏳ | لازم نشد: نبودِ سیاست یعنی «بدون جریمه»، پس رفتار پیش‌فرض از قبل امن است | +| ۴.۶ | `TenantSchemaCoverageTest` سبز | ✅ | | ## ۵. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۵.۱ | `CancellationPolicyPage` — سیاست محیط + جدول override سرویس‌ها | ⏳ | | -| ۵.۲ | `WaitlistPage` — لیست + تب «قابل تطبیق» | ⏳ | | -| ۵.۳ | دکمهٔ لغو → `ConfirmDialog` با محتوای **preview** | ⏳ | ⭐ نه لغو بعد جریمه | -| ۵.۴ | نشان «پرریسک» + شمارش عدم حضور در `PatientDetailPage` | ⏳ | | -| ۵.۵ | `ConfirmDialog` موجود استفاده شد، مودال دست‌ساز نه | ⏳ | | -| ۵.۶ | `DataTable` با فیلتر بازه/سرویس در URL | ⏳ | | -| ۵.۷ | تاریخ‌ها شمسی · مبالغ با `formatRial` | ⏳ | | -| ۵.۸ | `backTo`/`BackButton` روی زیرصفحه‌ها | ⏳ | | -| ۵.۹ | هیچ رنگ/شعاع hard-code — نشان پرریسک از `--danger-bg` | ⏳ | | -| ۵.۱۰ | دارک‌مود و حالت فشرده | ⏳ | | -| ۵.۱۱ | RTL و موبایل | ⏳ | | -| ۵.۱۲ | همهٔ رشته‌ها فارسی | ⏳ | | -| ۵.۱۳ | `ReserveAppointmentsPage` موجود دست‌نخورده ماند | ⏳ | ادغام خارج از دامنه | +| ۵.۱ | `CancellationPolicyPage` | ⚠️ | سیاست محیط کامل است؛ جدول override سرویس‌ها ساخته نشد (اندپوینتش هست) | +| ۵.۲ | `WaitlistPage` | ⚠️ | لیست با فیلتر وضعیت هست؛ تب «قابل تطبیق» ساخته نشد (اندپوینت `matches` هست) | +| ۵.۳ | دکمهٔ لغو با محتوای preview | ⏳ | هوک `useCancellationPreview` و `useCancelAppointment` آماده‌اند؛ اتصال به `AppointmentDetailPage` انجام نشد | +| ۵.۴ | نشان پرریسک در پروندهٔ بیمار | ⏳ | برچسب از `TenantTag` می‌آید و در پرونده دیده می‌شود، ولی شمارش عدم حضور نمایش داده نمی‌شود | +| ۵.۵ | `ConfirmDialog` موجود | ✅ | جای دیگری مودال دست‌ساز ساخته نشد | +| ۵.۶ | فیلتر در URL | ✅ | `useUrlState` | +| ۵.۷ | تاریخ شمسی و مبلغ | ✅ | `formatDate` · `PriceInput` | +| ۵.۸ | `backTo` | ✅ | | +| ۵.۹ | هیچ رنگ hard-code | ✅ | | +| ۵.۱۰ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد | +| ۵.۱۱ | RTL و موبایل | ✅ | جدول لیست انتظار اسکرول افقی داخلی دارد | +| ۵.۱۲ | رشته‌ها فارسی | ✅ | | +| ۵.۱۳ | `ReserveAppointmentsPage` دست‌نخورده | ✅ | | ## ۶. تست | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۶.۱ | `PenaltyCalculatorTest` — پنج حالت شامل «کلینیک همیشه صفر» | ⏳ | ⭐ | -| ۶.۲ | `CancellationServiceTest` — آزادسازی، `recorded_entity`، idempotent، گذشته ۴۲۲ | ⏳ | | -| ۶.۳ | `PolicyResolverTest` — اولویت سرویس | ⏳ | | -| ۶.۴ | `NoShowTrackerTest` — سوم برچسب، قدیمی‌تر از ۱۲ ماه نه، دوبار یک رکورد | ⏳ | | -| ۶.۵ | `NoShowTrackerTest` — بیمار پرریسک **رزرو موفق** دارد | ⏳ | ⭐ | -| ۶.۶ | `WaitlistMatcherTest` — سقف ۱۰، ترتیب، فیلتر روزبخش، `notify_count` | ⏳ | | -| ۶.۷ | `WaitlistConversionTest` | ⏳ | | -| ۶.۸ | `WaitlistAsyncTest` — شکست پیامک لغو را rollback نمی‌کند | ⏳ | ⭐ | -| ۶.۹ | `PatientWalletTenantTest` موجود سبز ماند | ⏳ | ⭐ | -| ۶.۱۰ | `CourseLifecycleTest` موجود — سیاست اعتبار اعمال شد | ⏳ | | +| ۶.۱ | محاسبهٔ جریمه — پنج حالت | ✅ | ⭐ داخل پنجره، بیرون پنجره، کلینیک، سقف پرداختی، بدون سیاست | +| ۶.۲ | لغو — آزادسازی، کیف پول، ۴۰۹، گذشته ۴۲۲ | ✅ | + «موجودی ناکافی لغو را شکست نمی‌دهد» | +| ۶.۳ | اولویت سیاست سرویس بر محیط | ⏳ | `resolve()` نوشته شد ولی تست اختصاصی ندارد | +| ۶.۴ | عدم حضور — سوم برچسب، دوبار یک رکورد | ✅ | ⭐ پنجرهٔ ۱۲ ماه تست نشد | +| ۶.۵ | بیمار پرریسک رزرو موفق دارد | ⏳ | برچسب هیچ‌جا بررسی نمی‌شود، پس مسدودسازی ممکن نیست | +| ۶.۶ | لیست انتظار — ترتیب، سقف اطلاع، شعبه | ✅ | فیلتر روزبخش تست نشد (۳.۷) | +| ۶.۷ | تبدیل به رزرو | ⏳ | با ۳.۸ | +| ۶.۸ | شکست پیامک لغو را rollback نمی‌کند | ⚠️ | معماری‌اش تضمین می‌کند (async، بدون تراکنش سراسری) ولی تست تزریق خطا نوشته نشد | +| ۶.۹ | تست کیف پول موجود سبز ماند | ✅ | ⭐ | +| ۶.۱۰ | سیاست اعتبار روی دوره | ⏳ | مسیرش هست (`credit_refundable`)، تست ترکیبی با دوره نوشته نشد | + +**اجرا:** `tests/Cancellation` → ۱۴ تست · `tests/Waitlist` → ۹ تست. ## ۷. مستندات | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۷.۱ | `docs/api/cancellation.md` — preview اجباری، کلینیک بی‌جریمه | ⏳ | | -| ۷.۲ | `docs/api/waitlist.md` — تصمیم broadcast و دلیلش | ⏳ | | +| ۷.۱ | `docs/api/cancellation.md` | ✅ | دو قاعدهٔ گران با دلیلشان | +| ۷.۲ | `docs/api/waitlist.md` | ✅ | تصمیم broadcast و چرایی رد صف انحصاری | ## ۸. بازبینی پایانی | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۸.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | | -| ۸.۲ | `bin/phpunit` کامل سبز | ⏳ | | -| ۸.۳ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۸.۴ | `phpstan` بدون خطای جدید | ⏳ | | -| ۸.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | | -| ۸.۶ | تست‌های tenant سبز | ⏳ | | -| ۸.۷ | `docs/api/*` به‌روز | ⏳ | | -| ۸.۸ | چک‌لیست UI کامل | ⏳ | | -| ۸.۹ | ⚠️ سایت باید preview لغو را نشان دهد → `nobat724_front` بررسی و تسک ثبت شد | ⏳ | ⭐ | -| ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ⏳ | | -| ۸.۱۱ | commit، سپس `graphify update .` | ⏳ | | -| ۸.۱۲ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | | +| ۸.۱ | هیچ ⏳ بی‌دلیل نمانده | ✅ | ۱۲ مورد ⏳/⚠️ همه با دلیل و تسک مقصد | +| ۸.۲ | `bin/phpunit` کامل سبز | ⚠️ | ۱۳۰۵ تست سبز است، ولی حدود ۴۰٪ اجراهای کامل یک خطای `EntityManager is closed` روی یک تست **تصادفیِ نامرتبط** می‌دهند. در اجرای زیرمجموعه‌ها هرگز تکرار نمی‌شود و تست خطاده هر بار عوض می‌شود. با حذف تست‌های این تسک هم دیده شد ⇒ احتمالاً flake محیط ddev، نه رگرسیون این تسک. **باید جدا بررسی شود** | +| ۸.۳ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۸.۴ | `phpstan` بدون خطای جدید | ✅ | ۱۴ = baseline | +| ۸.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۲ تست | +| ۸.۶ | تست‌های tenant سبز | ✅ | | +| ۸.۷ | `docs/api/*` به‌روز | ✅ | | +| ۸.۸ | چک‌لیست UI کامل | ⚠️ | جز ۵.۱، ۵.۲، ۵.۳، ۵.۴، ۵.۱۰ | +| ۸.۹ | سایت باید preview لغو را نشان دهد | ⏳ | اندپوینت‌ها پنل‌محورند؛ اتصال `nobat724_front` بررسی نشد | +| ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ⏳ | همان | +| ۸.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | +| ۸.۱۲ | موارد به‌تعویق با دلیل | ✅ | روزبخش/تبدیل/انقضا (۳.۷–۳.۹) و رویدادها → تسک ۱۴ · اتصال UI لغو (۵.۳) و نشان پرریسک (۵.۴) · flake تست (۸.۲) | diff --git a/migrations/Version20260731081142.php b/migrations/Version20260731081142.php new file mode 100644 index 00000000..c4213f06 --- /dev/null +++ b/migrations/Version20260731081142.php @@ -0,0 +1,49 @@ +addSql('CREATE TABLE cancellation_policies (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, free_window_hours SMALLINT DEFAULT 24 NOT NULL, penalty_mode VARCHAR(10) DEFAULT \'none\' NOT NULL, penalty_value INT DEFAULT 0 NOT NULL, deposit_refundable TINYINT DEFAULT 0 NOT NULL, credit_refundable TINYINT DEFAULT 1 NOT NULL, no_show_threshold SMALLINT DEFAULT 3 NOT NULL, risk_tag_uuid VARCHAR(36) DEFAULT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, service_item_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_52BE2A1D17F50A6 (uuid), INDEX IDX_52BE2A1DDEB00C2 (service_item_id), INDEX idx_cancel_policies_tenant (entity_type, entity_id, active), UNIQUE INDEX uniq_cancel_policy_scope (entity_type, entity_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE no_show_records (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, recorded_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, patient_record_id INT NOT NULL, appointment_id INT NOT NULL, recorded_by INT DEFAULT NULL, UNIQUE INDEX UNIQ_C373420D17F50A6 (uuid), INDEX IDX_C373420EB76A733 (patient_record_id), INDEX IDX_C37342082D4278B (recorded_by), INDEX idx_no_show_patient (patient_record_id, recorded_at), INDEX idx_no_show_tenant (entity_type, entity_id, recorded_at), UNIQUE INDEX uniq_no_show_appointment (appointment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE waitlist_entries (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, branch_id INT DEFAULT NULL, desired_from INT NOT NULL, desired_to INT NOT NULL, preferred_day_parts JSON DEFAULT NULL, priority SMALLINT DEFAULT 0 NOT NULL, status VARCHAR(12) DEFAULT \'waiting\' NOT NULL, notified_at INT DEFAULT NULL, notify_count SMALLINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, patient_record_id INT NOT NULL, service_item_id INT NOT NULL, converted_appointment_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_E74550EED17F50A6 (uuid), INDEX IDX_E74550EEEB76A733 (patient_record_id), INDEX IDX_E74550EEDDEB00C2 (service_item_id), INDEX IDX_E74550EE4B79C8F8 (converted_appointment_id), INDEX idx_waitlist_match (service_item_id, branch_id, status, desired_from, desired_to), INDEX idx_waitlist_tenant (entity_type, entity_id, status, created_at), INDEX idx_waitlist_patient (patient_record_id, status), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE cancellation_policies ADD CONSTRAINT FK_52BE2A1DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT FK_C373420EB76A733 FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT FK_C373420E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT FK_C37342082D4278B FOREIGN KEY (recorded_by) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT FK_E74550EEEB76A733 FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT FK_E74550EEDDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT FK_E74550EE4B79C8F8 FOREIGN KEY (converted_appointment_id) REFERENCES appointments (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE cancellation_policies DROP FOREIGN KEY FK_52BE2A1DDEB00C2'); + $this->addSql('ALTER TABLE no_show_records DROP FOREIGN KEY FK_C373420EB76A733'); + $this->addSql('ALTER TABLE no_show_records DROP FOREIGN KEY FK_C373420E5B533F9'); + $this->addSql('ALTER TABLE no_show_records DROP FOREIGN KEY FK_C37342082D4278B'); + $this->addSql('ALTER TABLE waitlist_entries DROP FOREIGN KEY FK_E74550EEEB76A733'); + $this->addSql('ALTER TABLE waitlist_entries DROP FOREIGN KEY FK_E74550EEDDEB00C2'); + $this->addSql('ALTER TABLE waitlist_entries DROP FOREIGN KEY FK_E74550EE4B79C8F8'); + $this->addSql('DROP TABLE cancellation_policies'); + $this->addSql('DROP TABLE no_show_records'); + $this->addSql('DROP TABLE waitlist_entries'); + } +} diff --git a/src/Cancellation/Controller/CancellationController.php b/src/Cancellation/Controller/CancellationController.php new file mode 100644 index 00000000..d9e95d7c --- /dev/null +++ b/src/Cancellation/Controller/CancellationController.php @@ -0,0 +1,219 @@ +branches->pair($user); + + return $this->success([ + 'default' => $this->policies->findDefault($entityType, $entityId)?->toArray(), + 'overrides' => array_values(array_map( + static fn (CancellationPolicy $p): array => $p->toArray(), + array_filter( + $this->policies->findForPair($entityType, $entityId), + static fn (CancellationPolicy $p): bool => $p->getServiceItem() !== null, + ), + )), + ]); + } + + /** سیاست پیش‌فرض محیط — ساخته می‌شود اگر نبود. */ + #[Route('/api/v1/cancellation-policy', name: 'cancellation_policy_save', methods: ['PUT'])] + public function save(#[CurrentUser] User $user, Request $request): JsonResponse + { + [$entityType, $entityId] = $this->branches->pair($user); + + $policy = $this->policies->findDefault($entityType, $entityId) + ?? new CancellationPolicy($entityType, $entityId); + + return $this->applyAndSave($policy, $request); + } + + #[Route('/api/v1/service-item/{uuid}/cancellation-policy', name: 'cancellation_policy_service', methods: ['PUT'])] + public function saveForService(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + [$entityType, $entityId] = $this->branches->pair($user); + $service = $this->requireItem($user, $uuid); + + $policy = $this->policies->findForService($entityType, $entityId, $service) + ?? new CancellationPolicy($entityType, $entityId, $service); + + return $this->applyAndSave($policy, $request); + } + + /** + * جریمه و بازگشت **پیش از** لغو. + * + * همان محاسبه‌ای که خودِ لغو انجام می‌دهد؛ بیمار نباید عددی ببیند که با آنچه کسر + * می‌شود فرق دارد. + */ + #[Route('/api/v1/appointment/{uuid}/cancellation-preview', name: 'cancellation_preview', methods: ['GET'])] + public function preview(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $appointment = $this->requireAppointment($user, $uuid); + + $by = $request->query->get('by') === 'doctor' + ? Appointment::STATUS_CANCELLED_BY_DOCTOR + : Appointment::STATUS_CANCELLED_BY_USER; + + return $this->success( + $this->calculator->calculate($appointment, $by)->toArray() + + ['paid_rials' => $this->calculator->paidRials($appointment)], + ); + } + + #[Route('/api/v1/appointment/{uuid}/cancel', name: 'cancellation_cancel', methods: ['POST'])] + public function cancel(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $appointment = $this->requireAppointment($user, $uuid); + $data = json_decode($request->getContent(), true); + + $by = is_array($data) && ($data['by'] ?? null) === 'doctor' + ? Appointment::STATUS_CANCELLED_BY_DOCTOR + : Appointment::STATUS_CANCELLED_BY_USER; + + return $this->success($this->cancellation->cancel($appointment, $by, $user)); + } + + /** ثبت عدم حضور — برچسب پرریسک اگر آستانه رد شود. */ + #[Route('/api/v1/appointment/{uuid}/no-show', name: 'cancellation_no_show', methods: ['POST'])] + public function markNoShow(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $appointment = $this->requireAppointment($user, $uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + $patient = $this->patients->findOneBy([ + 'user' => $appointment->getUser(), + 'entityType' => $entityType, + 'entityId' => $entityId, + ]); + + if ($patient === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروندهٔ بیمار در این محیط یافت نشد', 404); + } + + if ($appointment->getStatus() !== Appointment::STATUS_NO_SHOW) { + $appointment->transitionTo(Appointment::STATUS_NO_SHOW); + $this->appointments->save($appointment); + } + + return $this->success($this->noShow->record($appointment, $patient, $user)); + } + + private function applyAndSave(CancellationPolicy $policy, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + if (is_numeric($data['free_window_hours'] ?? null)) { + $policy->setFreeWindowHours((int) $data['free_window_hours']); + } + + if (isset($data['penalty_mode'])) { + try { + $policy->setPenalty( + (string) $data['penalty_mode'], + is_numeric($data['penalty_value'] ?? null) ? (int) $data['penalty_value'] : 0, + ); + } catch (\InvalidArgumentException $e) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + str_contains($e->getMessage(), 'percentage') + ? 'درصد جریمه باید بین ۰ تا ۱۰۰ باشد' + : 'حالت جریمه نامعتبر است', + 422, + 'penalty_mode', + ); + } + } + + foreach (['deposit_refundable' => 'setDepositRefundable', 'credit_refundable' => 'setCreditRefundable', 'active' => 'setActive'] as $field => $setter) { + if (isset($data[$field])) { + $policy->{$setter}((bool) $data[$field]); + } + } + + if (is_numeric($data['no_show_threshold'] ?? null)) { + $policy->setNoShowThreshold((int) $data['no_show_threshold']); + } + + if (array_key_exists('risk_tag_uuid', $data)) { + $policy->setRiskTagUuid(is_string($data['risk_tag_uuid']) ? $data['risk_tag_uuid'] : null); + } + + $this->policies->save($policy); + + return $this->success($policy->toArray()); + } + + private function requireItem(User $user, string $uuid): ServiceItem + { + $item = $this->items->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($item === null + || $item->getSection()->getEntityType() !== $entityType + || $item->getSection()->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); + } + + return $item; + } + + private function requireAppointment(User $user, string $uuid): Appointment + { + $appointment = $this->appointments->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($appointment === null || !$this->ownership->belongsToPair($entityType, $entityId, $appointment)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404); + } + + return $appointment; + } +} diff --git a/src/Cancellation/Entity/CancellationPolicy.php b/src/Cancellation/Entity/CancellationPolicy.php new file mode 100644 index 00000000..6537902b --- /dev/null +++ b/src/Cancellation/Entity/CancellationPolicy.php @@ -0,0 +1,157 @@ + 24])] + private int $freeWindowHours = 24; + + #[ORM\Column(name: 'penalty_mode', type: 'string', length: 10, options: ['default' => self::MODE_NONE])] + private string $penaltyMode = self::MODE_NONE; + + /** درصد ۰..۱۰۰ یا مبلغ ریالی، بسته به `penaltyMode`. */ + #[ORM\Column(name: 'penalty_value', type: 'integer', options: ['default' => 0])] + private int $penaltyValue = 0; + + #[ORM\Column(name: 'deposit_refundable', type: 'boolean', options: ['default' => false])] + private bool $depositRefundable = false; + + #[ORM\Column(name: 'credit_refundable', type: 'boolean', options: ['default' => true])] + private bool $creditRefundable = true; + + #[ORM\Column(name: 'no_show_threshold', type: 'smallint', options: ['default' => 3])] + private int $noShowThreshold = 3; + + /** بدون FK — همان الگوی `DiscountRule.target_tag_uuid` موجود پروژه. */ + #[ORM\Column(name: 'risk_tag_uuid', type: 'string', length: 36, nullable: true)] + private ?string $riskTagUuid = null; + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $active = true; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(string $entityType, int $entityId, ?ServiceItem $serviceItem = null) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->serviceItem = $serviceItem; + $this->createdAt = time(); + $this->updatedAt = time(); + + $this->assignTenantPair($entityType, $entityId); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getServiceItem(): ?ServiceItem { return $this->serviceItem; } + public function getFreeWindowHours(): int { return $this->freeWindowHours; } + public function getPenaltyMode(): string { return $this->penaltyMode; } + public function getPenaltyValue(): int { return $this->penaltyValue; } + public function isDepositRefundable(): bool { return $this->depositRefundable; } + public function isCreditRefundable(): bool { return $this->creditRefundable; } + public function getNoShowThreshold(): int { return $this->noShowThreshold; } + public function getRiskTagUuid(): ?string { return $this->riskTagUuid; } + public function isActive(): bool { return $this->active; } + + public function setFreeWindowHours(int $v): self { $this->freeWindowHours = max(0, $v); return $this->touch(); } + public function setDepositRefundable(bool $v): self { $this->depositRefundable = $v; return $this->touch(); } + public function setCreditRefundable(bool $v): self { $this->creditRefundable = $v; return $this->touch(); } + public function setRiskTagUuid(?string $v): self { $this->riskTagUuid = $v; return $this->touch(); } + public function setActive(bool $v): self { $this->active = $v; return $this->touch(); } + + public function setNoShowThreshold(int $v): self + { + // آستانهٔ صفر یعنی هر بیماری از همان نوبت اول پرریسک است. + $this->noShowThreshold = max(1, $v); + + return $this->touch(); + } + + /** @throws \InvalidArgumentException روی حالت ناشناخته یا درصد بیرون بازه */ + public function setPenalty(string $mode, int $value): self + { + if (!in_array($mode, self::MODES, true)) { + throw new \InvalidArgumentException(sprintf('Unknown penalty mode "%s".', $mode)); + } + + if ($mode === self::MODE_PERCENT && ($value < 0 || $value > 100)) { + throw new \InvalidArgumentException('A percentage penalty must be between 0 and 100.'); + } + + $this->penaltyMode = $mode; + $this->penaltyValue = $mode === self::MODE_NONE ? 0 : max(0, $value); + + return $this->touch(); + } + + private function touch(): self + { + $this->updatedAt = time(); + + return $this; + } + + /** @return array */ + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'service_uuid' => $this->serviceItem?->getUuid(), + 'service_name' => $this->serviceItem?->getName(), + 'free_window_hours' => $this->freeWindowHours, + 'penalty_mode' => $this->penaltyMode, + 'penalty_value' => $this->penaltyValue, + 'deposit_refundable' => $this->depositRefundable, + 'credit_refundable' => $this->creditRefundable, + 'no_show_threshold' => $this->noShowThreshold, + 'risk_tag_uuid' => $this->riskTagUuid, + 'active' => $this->active, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Cancellation/Entity/NoShowRecord.php b/src/Cancellation/Entity/NoShowRecord.php new file mode 100644 index 00000000..801c4930 --- /dev/null +++ b/src/Cancellation/Entity/NoShowRecord.php @@ -0,0 +1,80 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->patientRecord = $patientRecord; + $this->appointment = $appointment; + $this->recordedBy = $recordedBy; + $this->recordedAt = $at ?? time(); + + $this->assignTenantPair($appointment->getEntityType(), $appointment->getEntityId()); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getPatientRecord(): PatientRecord { return $this->patientRecord; } + public function getAppointment(): Appointment { return $this->appointment; } + public function getRecordedAt(): int { return $this->recordedAt; } + public function getRecordedBy(): ?User { return $this->recordedBy; } + + /** @return array */ + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'appointment_uuid' => $this->appointment->getUuid(), + 'slot_start' => $this->appointment->getSlotStart(), + 'recorded_at' => $this->recordedAt, + ]; + } +} diff --git a/src/Cancellation/Repository/CancellationPolicyRepository.php b/src/Cancellation/Repository/CancellationPolicyRepository.php new file mode 100644 index 00000000..aefa7269 --- /dev/null +++ b/src/Cancellation/Repository/CancellationPolicyRepository.php @@ -0,0 +1,76 @@ + */ +class CancellationPolicyRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CancellationPolicy::class); + } + + public function findByUuid(string $uuid): ?CancellationPolicy + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** سیاست پیش‌فرض محیط — `serviceItem` تهی. */ + public function findDefault(string $entityType, int $entityId): ?CancellationPolicy + { + return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId, 'serviceItem' => null]); + } + + public function findForService(string $entityType, int $entityId, ServiceItem $service): ?CancellationPolicy + { + return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId, 'serviceItem' => $service]); + } + + /** + * سیاست حاکم: override سرویس، وگرنه پیش‌فرض محیط. + * + * ترکیب نمی‌شوند — «۲۴ ساعت از محیط ولی ۵۰٪ از سرویس» چیزی است که هیچ اپراتوری + * نمی‌تواند در ذهنش شبیه‌سازی کند. + */ + public function resolve(string $entityType, int $entityId, ?ServiceItem $service): ?CancellationPolicy + { + if ($service !== null) { + $override = $this->findForService($entityType, $entityId, $service); + + if ($override !== null && $override->isActive()) { + return $override; + } + } + + $default = $this->findDefault($entityType, $entityId); + + return $default?->isActive() === true ? $default : null; + } + + /** @return CancellationPolicy[] */ + 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.serviceItem', 'ASC') + ->getQuery() + ->getResult(); + } + + public function save(CancellationPolicy $policy, bool $flush = true): void + { + $this->getEntityManager()->persist($policy); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Cancellation/Repository/NoShowRecordRepository.php b/src/Cancellation/Repository/NoShowRecordRepository.php new file mode 100644 index 00000000..73317879 --- /dev/null +++ b/src/Cancellation/Repository/NoShowRecordRepository.php @@ -0,0 +1,52 @@ + */ +class NoShowRecordRepository extends ServiceEntityRepository +{ + /** پنجرهٔ شمارش — عدم حضورِ سه سال پیش امروز معنایی ندارد. */ + public const WINDOW_DAYS = 365; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, NoShowRecord::class); + } + + public function findForAppointment(Appointment $appointment): ?NoShowRecord + { + return $this->findOneBy(['appointment' => $appointment]); + } + + public function countRecent(PatientRecord $patient, ?int $now = null): int + { + $since = ($now ?? time()) - self::WINDOW_DAYS * 86400; + + return (int) $this->createQueryBuilder('r') + ->select('COUNT(r.id)') + ->where('r.patientRecord = :patient') + ->andWhere('r.recordedAt >= :since') + ->setParameter('patient', $patient) + ->setParameter('since', $since) + ->getQuery() + ->getSingleScalarResult(); + } + + /** @return NoShowRecord[] */ + public function historyFor(PatientRecord $patient, int $limit = 20): array + { + return $this->createQueryBuilder('r') + ->where('r.patientRecord = :patient') + ->setParameter('patient', $patient) + ->orderBy('r.recordedAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Cancellation/Service/CancellationService.php b/src/Cancellation/Service/CancellationService.php new file mode 100644 index 00000000..4bad92ce --- /dev/null +++ b/src/Cancellation/Service/CancellationService.php @@ -0,0 +1,139 @@ + + * @throws AppException ۴۲۲ روی نوبت گذشته، ۴۰۹ روی نوبتِ از قبل لغوشده + */ + public function cancel(Appointment $appointment, string $status, ?User $actor = null, ?int $now = null): array + { + $now = $now ?? time(); + + if (in_array($appointment->getStatus(), [ + Appointment::STATUS_CANCELLED_BY_USER, + Appointment::STATUS_CANCELLED_BY_DOCTOR, + ], true)) { + // idempotent: همان وضعیت برمی‌گردد، نه یک لغو دوباره. + throw new AppException(ErrorCodes::ERR_SLOT_TAKEN, 'این نوبت قبلاً لغو شده است', 409); + } + + if ($appointment->getSlotStart() < $now) { + // برای گذشته `no_show` یا `completed` معنا دارد، نه لغو. + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نوبت گذشته لغو نمی‌شود؛ وضعیت عدم حضور یا انجام‌شده را ثبت کنید', 422); + } + + $penalty = $this->calculator->calculate($appointment, $status, $now); + + $appointment->transitionTo($status); + $this->em->flush(); + + // آزادسازی منابع و بازگشت اعتبار پکیج و جلسهٔ دوره — همه در `cancel` بوکینگ. + $released = $this->booking->cancel($appointment); + + if (!$penalty->creditRefundable) { + $this->revokeRefundedCredit($appointment); + } + + $charged = $this->chargePenalty($appointment, $penalty, $actor); + + $notified = $this->waitlist->notifyForFreedSlot($appointment); + + return [ + 'appointment_uuid' => $appointment->getUuid(), + 'status' => $appointment->getStatus(), + 'released_resources' => $released, + 'waitlist_notified' => $notified, + 'penalty_charged' => $charged, + ] + $penalty->toArray(); + } + + /** + * جریمه از کیف پول کسر می‌شود، و اگر موجودی نبود **کسر نمی‌شود**. + * + * موجودی ناکافی نباید لغو را شکست بدهد: نوبت باید آزاد شود حتی اگر پول بعداً + * وصول شود. بدهی مسئلهٔ صورتحساب است، نه یک عدد منفی پنهان در کیف پول. + */ + private function chargePenalty(Appointment $appointment, PenaltyResult $penalty, ?User $actor): bool + { + if ($penalty->penaltyRials <= 0) { + return false; + } + + try { + $this->wallet->withdraw( + $appointment->getUser(), + $penalty->penaltyRials, + $actor, + 'جریمهٔ لغو نوبت', + null, + $appointment->getUuid(), + // بدون این، کلینیک الف جریمهٔ ثبت‌شده در کلینیک ب را می‌بیند. + $appointment->getEntityType(), + $appointment->getEntityId(), + ); + } catch (AppException) { + return false; + } + + return true; + } + + /** + * سیاستی که اعتبار را برنمی‌گرداند: ردیف `refund` که `BookingService::cancel()` + * نوشته با یک `adjustment` منفی خنثی می‌شود. + * + * حذف ردیف قبلی ممنوع است — دفتر append-only می‌ماند و تاریخچه نشان می‌دهد + * اعتبار برگشت و بعد طبق سیاست پس گرفته شد. + */ + private function revokeRefundedCredit(Appointment $appointment): void + { + $refund = $this->em->getRepository(\App\Package\Entity\SessionCreditLedger::class) + ->findOneBy([ + 'appointment' => $appointment, + 'kind' => \App\Package\Entity\SessionCreditLedger::KIND_REFUND, + ]); + + if ($refund === null) { + return; + } + + $this->credits->record( + $refund->getPatientPackage(), + \App\Package\Entity\SessionCreditLedger::KIND_ADJUSTMENT, + -$refund->getDelta(), + null, + $refund->getServiceItem(), + 'سیاست لغو: اعتبار این جلسه برنمی‌گردد', + ); + } +} diff --git a/src/Cancellation/Service/NoShowService.php b/src/Cancellation/Service/NoShowService.php new file mode 100644 index 00000000..13c1bed7 --- /dev/null +++ b/src/Cancellation/Service/NoShowService.php @@ -0,0 +1,88 @@ +records->findForAppointment($appointment); + + $policy = $this->policies->resolve( + $appointment->getEntityType(), + $appointment->getEntityId(), + $appointment->getServiceItem(), + ); + + $threshold = $policy?->getNoShowThreshold() ?? 3; + + if ($existing !== null) { + return [ + 'recorded' => false, + 'count' => $this->records->countRecent($patient, $now), + 'threshold' => $threshold, + 'tagged' => false, + ]; + } + + $this->em->persist(new NoShowRecord($patient, $appointment, $actor, $now)); + $this->em->flush(); + + $count = $this->records->countRecent($patient, $now); + $tagged = false; + + if ($count >= $threshold && $policy?->getRiskTagUuid() !== null) { + $tagged = $this->applyRiskTag($patient, $policy->getRiskTagUuid()); + } + + return ['recorded' => true, 'count' => $count, 'threshold' => $threshold, 'tagged' => $tagged]; + } + + /** @return bool `false` یعنی برچسب از قبل بود یا وجود ندارد */ + private function applyRiskTag(PatientRecord $patient, string $tagUuid): bool + { + $tag = $this->em->getRepository(TenantTag::class)->findOneBy(['uuid' => $tagUuid]); + + if ($tag === null || $patient->getTags()->contains($tag)) { + return false; + } + + $patient->getTags()->add($tag); + $this->em->flush(); + + return true; + } + + public function countFor(PatientRecord $patient, ?int $now = null): int + { + return $this->records->countRecent($patient, $now); + } +} diff --git a/src/Cancellation/Service/PenaltyCalculator.php b/src/Cancellation/Service/PenaltyCalculator.php new file mode 100644 index 00000000..a271d043 --- /dev/null +++ b/src/Cancellation/Service/PenaltyCalculator.php @@ -0,0 +1,104 @@ +policies->resolve( + $appointment->getEntityType(), + $appointment->getEntityId(), + $appointment->getServiceItem(), + ); + + if ($policy === null) { + return PenaltyResult::free(true, ['برای این محیط سیاست لغو تعریف نشده است']); + } + + $hoursLeft = ($appointment->getSlotStart() - $now) / 3600; + + if ($hoursLeft >= $policy->getFreeWindowHours()) { + return PenaltyResult::free(true); + } + + $paid = $this->paidRials($appointment); + $penalty = $this->rawPenalty($policy, $appointment, $paid); + + $notes = []; + + // جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ صورتحساب است نه لغو. + if ($penalty > $paid) { + $notes[] = $paid === 0 + ? 'این نوبت پرداختی نداشته، پس جریمه‌ای کسر نمی‌شود' + : 'جریمه تا سقف مبلغ پرداختی کاهش یافت'; + $penalty = $paid; + } + + return new PenaltyResult( + penaltyRials: $penalty, + depositRefundable: $policy->isDepositRefundable(), + creditRefundable: $policy->isCreditRefundable(), + withinFreeWindow: false, + notes: $notes, + ); + } + + private function rawPenalty(CancellationPolicy $policy, Appointment $appointment, int $paid): int + { + return match ($policy->getPenaltyMode()) { + CancellationPolicy::MODE_PERCENT => (int) floor($this->baseFor($appointment, $paid) * $policy->getPenaltyValue() / 100), + CancellationPolicy::MODE_FIXED => $policy->getPenaltyValue(), + default => 0, + }; + } + + /** + * مبنای درصد: مبلغ ثبت‌شدهٔ نوبت، و اگر نبود آنچه واقعاً پرداخت شده. + * + * درصدِ «قیمت امروزِ سرویس» غلط است: بیمار روی قیمت آن روز توافق کرده. + */ + private function baseFor(Appointment $appointment, int $paid): int + { + return (int) ($appointment->getVisitPriceRials() ?? $paid); + } + + /** جمع پرداخت‌های موفق همین نوبت. */ + public function paidRials(Appointment $appointment): int + { + return (int) $this->em->createQueryBuilder() + ->select('COALESCE(SUM(p.amountRials), 0)') + ->from(Payment::class, 'p') + ->where('p.appointment = :appointment') + ->andWhere('p.status = :status') + ->setParameter('appointment', $appointment) + ->setParameter('status', Payment::STATUS_SUCCESS) + ->getQuery() + ->getSingleScalarResult(); + } +} diff --git a/src/Cancellation/ValueObject/PenaltyResult.php b/src/Cancellation/ValueObject/PenaltyResult.php new file mode 100644 index 00000000..070cb154 --- /dev/null +++ b/src/Cancellation/ValueObject/PenaltyResult.php @@ -0,0 +1,39 @@ + $notes */ + public function __construct( + public int $penaltyRials, + public bool $depositRefundable, + public bool $creditRefundable, + public bool $withinFreeWindow, + public array $notes = [], + ) {} + + /** لغو توسط کلینیک، یا داخل پنجرهٔ رایگان. */ + public static function free(bool $withinFreeWindow = true, array $notes = []): self + { + return new self(0, true, true, $withinFreeWindow, $notes); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'penalty_rials' => $this->penaltyRials, + 'deposit_refundable' => $this->depositRefundable, + 'credit_refundable' => $this->creditRefundable, + 'within_free_window' => $this->withinFreeWindow, + 'notes' => $this->notes, + ]; + } +} diff --git a/src/Waitlist/Controller/WaitlistController.php b/src/Waitlist/Controller/WaitlistController.php new file mode 100644 index 00000000..f9dab259 --- /dev/null +++ b/src/Waitlist/Controller/WaitlistController.php @@ -0,0 +1,181 @@ +branches->pair($user); + + $status = $request->query->get('status'); + + return $this->success(array_map( + static fn (WaitlistEntry $e): array => $e->toArray(), + $this->entries->findForPair($entityType, $entityId, is_string($status) && $status !== '' ? $status : null), + )); + } + + #[Route('/api/v1/waitlist', name: 'waitlist_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['patient_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ بیمار الزامی است', 422, 'patient_uuid'); + } + + if (!is_string($data['service_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ سرویس الزامی است', 422, 'service_uuid'); + } + + foreach (['desired_from', 'desired_to'] as $field) { + if (!is_numeric($data[$field] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, sprintf('فیلد %s الزامی است', $field), 422, $field); + } + } + + $from = (int) $data['desired_from']; + $to = (int) $data['desired_to']; + + // بازهٔ گذشته یعنی انتظاری که هرگز به نتیجه نمی‌رسد. + if ($to <= time()) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بازهٔ انتظار باید در آینده باشد', 422, 'desired_to'); + } + + if ($to <= $from) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پایان بازه باید بعد از شروع آن باشد', 422, 'desired_to'); + } + + $patient = $this->requirePatient($user, $data['patient_uuid']); + $service = $this->requireItem($user, $data['service_uuid']); + + $branchId = null; + + if (is_string($data['branch_uuid'] ?? null)) { + $branchId = $this->branches->resolve($user, $data['branch_uuid'])->getId(); + } + + $entry = new WaitlistEntry($patient, $service, $from, $to, $branchId); + + if (is_array($data['preferred_day_parts'] ?? null)) { + $entry->setPreferredDayParts($data['preferred_day_parts']); + } + + if (is_numeric($data['priority'] ?? null)) { + $entry->setPriority((int) $data['priority']); + } + + $this->entries->save($entry); + + return $this->success($entry->toArray(), 201); + } + + #[Route('/api/v1/waitlist/{uuid}', name: 'waitlist_delete', methods: ['DELETE'])] + public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $entry = $this->requireEntry($user, $uuid); + + $this->em->remove($entry); + $this->em->flush(); + + return $this->success(null); + } + + /** + * درخواست‌هایی که با یک زمان مشخص می‌خوانند — ابزار پنل هنگام آزاد شدن ظرفیت. + */ + #[Route('/api/v1/waitlist/matches', name: 'waitlist_matches', methods: ['GET'])] + public function matches(#[CurrentUser] User $user, Request $request): JsonResponse + { + $serviceUuid = $request->query->get('service_uuid'); + $start = $request->query->get('start'); + + if (!is_string($serviceUuid) || !is_numeric($start)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلدهای service_uuid و start الزامی‌اند', 422, 'service_uuid'); + } + + $service = $this->requireItem($user, $serviceUuid); + $branch = $request->query->get('branch_uuid'); + $branchId = is_string($branch) ? $this->branches->resolve($user, $branch)->getId() : null; + + return $this->success(array_map( + static fn (WaitlistEntry $e): array => $e->toArray(), + $this->entries->findMatching($service, (int) $start, $branchId), + )); + } + + private function requirePatient(User $user, string $uuid): PatientRecord + { + $patient = $this->patients->findOneBy(['uuid' => $uuid]); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($patient === null + || $patient->getEntityType() !== $entityType + || $patient->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404); + } + + return $patient; + } + + private function requireItem(User $user, string $uuid): ServiceItem + { + $item = $this->items->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($item === null + || $item->getSection()->getEntityType() !== $entityType + || $item->getSection()->getEntityId() !== $entityId + ) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); + } + + return $item; + } + + private function requireEntry(User $user, string $uuid): WaitlistEntry + { + $entry = $this->entries->findByUuid($uuid); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($entry === null || !$this->ownership->belongsToPair($entityType, $entityId, $entry)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست لیست انتظار یافت نشد', 404); + } + + return $entry; + } +} diff --git a/src/Waitlist/Entity/WaitlistEntry.php b/src/Waitlist/Entity/WaitlistEntry.php new file mode 100644 index 00000000..3e58a34e --- /dev/null +++ b/src/Waitlist/Entity/WaitlistEntry.php @@ -0,0 +1,198 @@ +|null `["morning","evening"]` */ + #[ORM\Column(name: 'preferred_day_parts', type: 'json', nullable: true)] + private ?array $preferredDayParts = null; + + #[ORM\Column(type: 'smallint', options: ['default' => 0])] + private int $priority = 0; + + #[ORM\Column(type: 'string', length: 12, options: ['default' => self::STATUS_WAITING])] + private string $status = self::STATUS_WAITING; + + #[ORM\Column(name: 'notified_at', type: 'integer', nullable: true)] + private ?int $notifiedAt = null; + + #[ORM\Column(name: 'notify_count', type: 'smallint', options: ['default' => 0])] + private int $notifyCount = 0; + + #[ORM\ManyToOne(targetEntity: Appointment::class)] + #[ORM\JoinColumn(name: 'converted_appointment_id', nullable: true, onDelete: 'SET NULL')] + private ?Appointment $convertedAppointment = null; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct( + PatientRecord $patientRecord, + ServiceItem $serviceItem, + int $desiredFrom, + int $desiredTo, + ?int $branchId = null, + ) { + if ($desiredTo <= $desiredFrom) { + throw new \InvalidArgumentException('The waitlist window must end after it starts.'); + } + + $this->uuid = Uuid::v4()->toRfc4122(); + $this->patientRecord = $patientRecord; + $this->serviceItem = $serviceItem; + $this->desiredFrom = $desiredFrom; + $this->desiredTo = $desiredTo; + $this->branchId = $branchId; + $this->createdAt = time(); + $this->updatedAt = time(); + + $this->assignTenantPair($patientRecord->getEntityType(), $patientRecord->getEntityId()); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getPatientRecord(): PatientRecord { return $this->patientRecord; } + public function getServiceItem(): ServiceItem { return $this->serviceItem; } + public function getBranchId(): ?int { return $this->branchId; } + public function getDesiredFrom(): int { return $this->desiredFrom; } + public function getDesiredTo(): int { return $this->desiredTo; } + public function getPreferredDayParts(): array { return $this->preferredDayParts ?? []; } + public function getPriority(): int { return $this->priority; } + public function getStatus(): string { return $this->status; } + public function getNotifiedAt(): ?int { return $this->notifiedAt; } + public function getNotifyCount(): int { return $this->notifyCount; } + + /** @param list $parts */ + public function setPreferredDayParts(array $parts): self + { + $this->preferredDayParts = $parts === [] ? null : array_values(array_filter($parts, 'is_string')); + + return $this->touch(); + } + + public function setPriority(int $v): self { $this->priority = $v; return $this->touch(); } + + public function markNotified(?int $at = null): self + { + $this->status = self::STATUS_NOTIFIED; + $this->notifiedAt = $at ?? time(); + $this->notifyCount++; + + return $this->touch(); + } + + public function markConverted(Appointment $appointment): self + { + $this->status = self::STATUS_CONVERTED; + $this->convertedAppointment = $appointment; + + return $this->touch(); + } + + public function markExpired(): self + { + $this->status = self::STATUS_EXPIRED; + + return $this->touch(); + } + + /** هنوز منتظر است و سقف اطلاع‌رسانی را رد نکرده. */ + public function isNotifiable(?int $now = null): bool + { + $now = $now ?? time(); + + return in_array($this->status, [self::STATUS_WAITING, self::STATUS_NOTIFIED], true) + && $this->notifyCount < self::MAX_NOTIFICATIONS + && $this->desiredTo >= $now; + } + + public function covers(int $start): bool + { + return $start >= $this->desiredFrom && $start <= $this->desiredTo; + } + + private function touch(): self + { + $this->updatedAt = time(); + + return $this; + } + + /** @return array */ + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'patient_uuid' => $this->patientRecord->getUuid(), + 'service_uuid' => $this->serviceItem->getUuid(), + 'service_name' => $this->serviceItem->getName(), + 'branch_id' => $this->branchId, + 'desired_from' => $this->desiredFrom, + 'desired_to' => $this->desiredTo, + 'preferred_day_parts' => $this->preferredDayParts ?? [], + 'priority' => $this->priority, + 'status' => $this->status, + 'notified_at' => $this->notifiedAt, + 'notify_count' => $this->notifyCount, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Waitlist/Repository/WaitlistEntryRepository.php b/src/Waitlist/Repository/WaitlistEntryRepository.php new file mode 100644 index 00000000..60635fca --- /dev/null +++ b/src/Waitlist/Repository/WaitlistEntryRepository.php @@ -0,0 +1,92 @@ + */ +class WaitlistEntryRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, WaitlistEntry::class); + } + + public function findByUuid(string $uuid): ?WaitlistEntry + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** + * چه کسانی منتظر این سرویس در این لحظه‌اند؟ — کوئری داغِ لحظهٔ لغو. + * + * شعبهٔ تهی یعنی «هر شعبه»؛ کسی که شعبه مشخص کرده فقط برای همان شعبه خبر می‌شود. + * + * @return WaitlistEntry[] مرتب بر اساس اولویت، بعد قدمت + */ + public function findMatching(ServiceItem $service, int $start, ?int $branchId, ?int $now = null): array + { + $now = $now ?? time(); + + $qb = $this->createQueryBuilder('w') + ->where('w.serviceItem = :service') + ->andWhere('w.status IN (:open)') + ->andWhere('w.desiredFrom <= :start') + ->andWhere('w.desiredTo >= :start') + ->andWhere('w.desiredTo >= :now') + ->andWhere('w.notifyCount < :maxNotifications') + ->setParameter('service', $service) + ->setParameter('open', [WaitlistEntry::STATUS_WAITING, WaitlistEntry::STATUS_NOTIFIED]) + ->setParameter('start', $start) + ->setParameter('now', $now) + ->setParameter('maxNotifications', WaitlistEntry::MAX_NOTIFICATIONS) + ->orderBy('w.priority', 'DESC') + ->addOrderBy('w.createdAt', 'ASC'); + + // شعبهٔ تهی روی خودِ ردیف یعنی «هر شعبه»؛ پس وقتی ظرفیت یک شعبهٔ مشخص آزاد + // می‌شود، هم بی‌قیدها خبر می‌شوند هم آن‌هایی که همان شعبه را خواسته‌اند. + if ($branchId !== null) { + $qb->andWhere('w.branchId IS NULL OR w.branchId = :branch') + ->setParameter('branch', $branchId); + } + + return $qb->getQuery()->getResult(); + } + + /** @return WaitlistEntry[] */ + public function findForPair(string $entityType, int $entityId, ?string $status = null): array + { + $qb = $this->createQueryBuilder('w') + ->where('w.entityType = :type') + ->andWhere('w.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('w.priority', 'DESC') + ->addOrderBy('w.createdAt', 'DESC'); + + if ($status !== null) { + $qb->andWhere('w.status = :status')->setParameter('status', $status); + } + + return $qb->getQuery()->getResult(); + } + + /** @return WaitlistEntry[] */ + public function findForPatient(PatientRecord $patient): array + { + return $this->findBy(['patientRecord' => $patient], ['createdAt' => 'DESC']); + } + + public function save(WaitlistEntry $entry, bool $flush = true): void + { + $this->getEntityManager()->persist($entry); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Waitlist/Service/WaitlistNotifier.php b/src/Waitlist/Service/WaitlistNotifier.php new file mode 100644 index 00000000..1925b1a6 --- /dev/null +++ b/src/Waitlist/Service/WaitlistNotifier.php @@ -0,0 +1,91 @@ +getServiceItem(); + + if ($service === null) { + return 0; + } + + $matches = $this->entries->findMatching( + $service, + $appointment->getSlotStart(), + $appointment->getAddressId(), + $now, + ); + + $notified = 0; + + foreach (array_slice($matches, 0, self::MAX_RECIPIENTS) as $entry) { + if (!$entry->isNotifiable($now)) { + continue; + } + + $this->notify($entry, $appointment->getSlotStart()); + $notified++; + } + + if ($notified > 0) { + $this->em->flush(); + } + + return $notified; + } + + private function notify(WaitlistEntry $entry, int $slotStart): void + { + $mobile = $entry->getPatientRecord()->getUser()->getMobileNumber(); + + if ($mobile !== '') { + $this->sms->dispatchAsync($mobile, $this->messageFor($entry, $slotStart)); + } + + $entry->markNotified(); + } + + /** جملهٔ «اولین نفر می‌برد» اجباری است — وگرنه انتظارِ اشتباه می‌سازد. */ + private function messageFor(WaitlistEntry $entry, int $slotStart): string + { + return sprintf( + 'یک وقت برای «%s» در تاریخ %s آزاد شد. اولین نفری که رزرو کند آن را می‌گیرد.', + $entry->getServiceItem()->getName(), + $this->jalali->formatDateTime($slotStart), + ); + } +} diff --git a/tests/Cancellation/CancellationTest.php b/tests/Cancellation/CancellationTest.php new file mode 100644 index 00000000..48d84af5 --- /dev/null +++ b/tests/Cancellation/CancellationTest.php @@ -0,0 +1,391 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک لغو'); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر لغو'); + $this->em->persist($doctor); + + $patientUser = $this->createUser(['ROLE_USER']); + $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); + $this->em->persist($patient); + $this->em->flush(); + + return [$user, $section, $address, $doctor, $patient]; + } + + private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem + { + $item = new ServiceItem($section, $name); + $item->setSoloDurationMinutes(30); + $item->setPriceRials(4_000_000); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + /** @param array $body */ + private function savePolicy(User $user, array $body): array + { + $saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, $body); + self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE)); + + return $saved['data']; + } + + /** نوبتی در آینده، با مبلغ ثبت‌شده و در صورت نیاز پرداخت موفق. */ + private function appointment( + Doctor $doctor, + PatientRecord $patient, + ServiceItem $service, + int $clinicId, + int $hoursAhead, + int $price = 4_000_000, + int $paid = 0, + ): Appointment { + $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); + + $start = time() + $hoursAhead * 3600 + (++$this->slotCursor) * 60; + + $appointment = new Appointment( + $em->getRepository(Doctor::class)->find($doctor->getId()), + $em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(), + $start, + $start + 1800, + ); + $appointment->assignTenantPair('clinic', $clinicId); + $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); + $appointment->setVisitPriceRials($price); + $appointment->setPatientName('بیمار لغو'); + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + + $em->persist($appointment); + $em->flush(); + + if ($paid > 0) { + $payment = new Payment($appointment->getUser(), $paid, 'zarinpal', Payment::TYPE_APPOINTMENT); + $payment->assignTenantPair('clinic', $clinicId); + $payment->setAppointment($appointment); + $payment->setStatus(Payment::STATUS_SUCCESS); + $em->persist($payment); + $em->flush(); + } + + return $appointment; + } + + private function wallet(): WalletService + { + return static::getContainer()->get(WalletService::class); + } + + // ── پیش‌نمایش ─────────────────────────────────────────────────────────── + + public function testInsideTheFreeWindowThereIsNoPenalty(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->savePolicy($user, [ + 'free_window_hours' => 24, + 'penalty_mode' => 'percent', + 'penalty_value' => 50, + 'deposit_refundable' => false, + ]); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 48, paid: 4_000_000); + + $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user); + + self::assertSame(200, $this->responseCode(), json_encode($preview, JSON_UNESCAPED_UNICODE)); + self::assertSame(0, $preview['data']['penalty_rials']); + self::assertTrue($preview['data']['deposit_refundable']); + self::assertTrue($preview['data']['within_free_window']); + } + + public function testOutsideTheFreeWindowThePercentagePenaltyApplies(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->savePolicy($user, [ + 'free_window_hours' => 24, + 'penalty_mode' => 'percent', + 'penalty_value' => 50, + 'deposit_refundable' => false, + ]); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 6, paid: 4_000_000); + + $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; + + self::assertSame(2_000_000, $preview['penalty_rials']); + self::assertFalse($preview['deposit_refundable']); + self::assertFalse($preview['within_free_window']); + } + + /** ⭐ لغو توسط کلینیک هرگز جریمه ندارد، حتی یک ساعت مانده به نوبت. */ + public function testTheClinicCancellingItsOwnAppointmentIsAlwaysFree(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 100]); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000); + + $preview = $this->authJson( + 'GET', + "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview?by=doctor", + $user, + )['data']; + + self::assertSame(0, $preview['penalty_rials']); + self::assertTrue($preview['deposit_refundable']); + } + + /** ⭐ جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ لغو نیست. */ + public function testThePenaltyNeverExceedsWhatWasPaid(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->savePolicy($user, [ + 'free_window_hours' => 24, + 'penalty_mode' => 'fixed', + 'penalty_value' => 9_000_000, + ]); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2, paid: 1_000_000); + + $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; + + self::assertSame(1_000_000, $preview['penalty_rials']); + self::assertNotEmpty($preview['notes']); + } + + /** نوبت نقدی: جریمه صفر می‌شود و پاسخ توضیحش را می‌دهد. */ + public function testAnUnpaidAppointmentIsNotCharged(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2); + + $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; + + self::assertSame(0, $preview['penalty_rials']); + self::assertStringContainsString('پرداختی نداشته', implode(' ', $preview['notes'])); + } + + public function testWithoutAPolicyNothingIsCharged(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000); + + $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; + + self::assertSame(0, $preview['penalty_rials']); + } + + // ── لغو واقعی ─────────────────────────────────────────────────────────── + + public function testCancellingChargesTheWalletAndReleasesTheSlot(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 25]); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000); + + // کیف پول باید موجودی داشته باشد وگرنه جریمه کسر نمی‌شود. + $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); + $this->wallet()->charge($em->getRepository(\App\Auth\Entity\User::class)->find($appointment->getUser()->getId()), 5_000_000); + + $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(1_000_000, $body['data']['penalty_rials']); + self::assertTrue($body['data']['penalty_charged']); + self::assertSame('cancelled_by_user', $body['data']['status']); + + $patientUser = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) + ->getRepository(\App\Auth\Entity\User::class) + ->find($appointment->getUser()->getId()); + + self::assertSame(4_000_000, $this->wallet()->balance($patientUser), 'جریمه باید از کیف پول کسر شود'); + } + + /** موجودی ناکافی نباید لغو را شکست بدهد؛ نوبت باید آزاد شود. */ + public function testAnEmptyWalletDoesNotBlockTheCancellation(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000); + + $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); + + self::assertSame(200, $this->responseCode()); + self::assertSame(2_000_000, $body['data']['penalty_rials']); + self::assertFalse($body['data']['penalty_charged'], 'موجودی نبود، پس کسر نشد — ولی نوبت لغو شد'); + self::assertSame('cancelled_by_user', $body['data']['status']); + } + + public function testCancellingTwiceIsRejected(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 30); + + $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); + self::assertSame(200, $this->responseCode()); + + $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); + self::assertSame(409, $this->responseCode()); + } + + /** برای گذشته `no_show` یا `completed` معنا دارد، نه لغو. */ + public function testAPastAppointmentCannotBeCancelled(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5); + + $this->em->getConnection()->executeStatement( + 'UPDATE appointments SET slot_start = ?, slot_end = ? WHERE uuid = ?', + [time() - 7200, time() - 5400, $appointment->getUuid()], + ); + $this->em->clear(); + + $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); + + self::assertSame(422, $this->responseCode()); + } + + // ── عدم حضور ──────────────────────────────────────────────────────────── + + /** ⭐ سومین عدم حضور برچسب پرریسک می‌گذارد — ولی بیمار را مسدود نمی‌کند. */ + public function testTheThirdNoShowTagsThePatient(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $tag = new TenantTag('clinic', (int) $address->getClinicId(), 'پرریسک', '#dc2626'); + $this->em->persist($tag); + $this->em->flush(); + + $this->savePolicy($user, ['no_show_threshold' => 3, 'risk_tag_uuid' => $tag->getUuid()]); + + $last = null; + + for ($i = 1; $i <= 3; $i++) { + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2); + $last = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); + self::assertSame(200, $this->responseCode(), json_encode($last, JSON_UNESCAPED_UNICODE)); + } + + self::assertSame(3, $last['data']['count']); + self::assertTrue($last['data']['tagged']); + + $reloaded = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) + ->getRepository(PatientRecord::class) + ->find($patient->getId()); + + $tagNames = array_map(static fn (TenantTag $t): string => $t->getName(), $reloaded->getTags()->toArray()); + + self::assertContains('پرریسک', $tagNames); + } + + /** ثبت دوباره روی همان نوبت، عدم حضور دوم نمی‌سازد. */ + public function testRecordingTheSameNoShowTwiceCountsOnce(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2); + + $first = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); + $second = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); + + self::assertTrue($first['data']['recorded']); + self::assertFalse($second['data']['recorded']); + self::assertSame(1, $second['data']['count']); + } + + // ── جداسازی محیط ──────────────────────────────────────────────────────── + + public function testAnotherClinicCannotPreviewTheCancellation(): void + { + [$owner, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + [$other] = $this->clinicWithPatient(); + + $service = $this->service($section); + $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5); + + $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $other); + + self::assertSame(404, $this->responseCode()); + } + + public function testAPercentageAboveOneHundredIsRejected(): void + { + [$user] = $this->clinicWithPatient(); + + $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [ + 'penalty_mode' => 'percent', + 'penalty_value' => 150, + ]); + + self::assertSame(422, $this->responseCode()); + } +} diff --git a/tests/Waitlist/WaitlistTest.php b/tests/Waitlist/WaitlistTest.php new file mode 100644 index 00000000..f885796c --- /dev/null +++ b/tests/Waitlist/WaitlistTest.php @@ -0,0 +1,300 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک انتظار'); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر انتظار'); + $this->em->persist($doctor); + + $patientUser = $this->createUser(['ROLE_USER']); + $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); + $this->em->persist($patient); + $this->em->flush(); + + return [$user, $section, $address, $doctor, $patient]; + } + + private function extraPatient(int $clinicId): PatientRecord + { + $user = $this->createUser(['ROLE_USER']); + $patient = new PatientRecord('clinic', $clinicId, $user, 'clinic', $clinicId); + $this->em->persist($patient); + $this->em->flush(); + + return $patient; + } + + private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem + { + $item = new ServiceItem($section, $name); + $item->setSoloDurationMinutes(30); + $item->setPriceRials(4_000_000); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + /** @param array $extra */ + private function join(User $user, PatientRecord $patient, ServiceItem $service, int $from, int $to, array $extra = []): array + { + $body = $this->authJson('POST', '/api/v1/waitlist', $user, $extra + [ + 'patient_uuid' => $patient->getUuid(), + 'service_uuid' => $service->getUuid(), + 'desired_from' => $from, + 'desired_to' => $to, + ]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + return $body['data']; + } + + private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, DoctorAddress $address, int $start): Appointment + { + $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); + + $start += (++$this->slotCursor) * 60; + + $appointment = new Appointment( + $em->getRepository(Doctor::class)->find($doctor->getId()), + $em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(), + $start, + $start + 1800, + ); + $appointment->assignTenantPair('clinic', (int) $address->getClinicId()); + $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); + $appointment->setAddressId($address->getId()); + $appointment->setPatientName('بیمار نوبت'); + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + + $em->persist($appointment); + $em->flush(); + + return $appointment; + } + + private function notifier(): WaitlistNotifier + { + return static::getContainer()->get(WaitlistNotifier::class); + } + + // ── ثبت ───────────────────────────────────────────────────────────────── + + public function testJoiningTheWaitlistStoresTheWindow(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $from = time() + 86400; + $to = $from + 3 * 86400; + $entry = $this->join($user, $patient, $service, $from, $to, ['preferred_day_parts' => ['evening']]); + + self::assertSame('waiting', $entry['status']); + self::assertSame($from, $entry['desired_from']); + self::assertSame(['evening'], $entry['preferred_day_parts']); + self::assertSame(0, $entry['notify_count']); + + $list = $this->authJson('GET', '/api/v1/waitlist', $user); + self::assertCount(1, $list['data']); + } + + /** انتظار برای بازهٔ گذشته هرگز به نتیجه نمی‌رسد. */ + public function testAPastWindowIsRejected(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->authJson('POST', '/api/v1/waitlist', $user, [ + 'patient_uuid' => $patient->getUuid(), + 'service_uuid' => $service->getUuid(), + 'desired_from' => time() - 5 * 86400, + 'desired_to' => time() - 86400, + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testAnInvertedWindowIsRejected(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $this->authJson('POST', '/api/v1/waitlist', $user, [ + 'patient_uuid' => $patient->getUuid(), + 'service_uuid' => $service->getUuid(), + 'desired_from' => time() + 5 * 86400, + 'desired_to' => time() + 86400, + ]); + + self::assertSame(422, $this->responseCode()); + } + + // ── تطبیق و اطلاع ─────────────────────────────────────────────────────── + + public function testMatchesFindsEveryoneWaitingForThatMoment(): void + { + [$user, $section, $address, , $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $from = time() + 86400; + $to = $from + 3 * 86400; + + $this->join($user, $patient, $service, $from, $to); + $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to); + + // کسی که بازه‌اش پوشش نمی‌دهد نباید بیاید. + $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $to + 86400, $to + 5 * 86400); + + $start = $from + 3600; + $matches = $this->authJson( + 'GET', + sprintf('/api/v1/waitlist/matches?service_uuid=%s&start=%d', $service->getUuid(), $start), + $user, + ); + + self::assertSame(200, $this->responseCode(), json_encode($matches, JSON_UNESCAPED_UNICODE)); + self::assertCount(2, $matches['data']); + } + + /** ⭐ همه خبر می‌شوند — نه فقط نفر اول. */ + public function testCancellingAnAppointmentNotifiesEveryMatchingEntry(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $slot = time() + 2 * 86400; + $from = $slot - 86400; + $to = $slot + 86400; + + $first = $this->join($user, $patient, $service, $from, $to); + $second = $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to); + + $appointment = $this->appointment($doctor, $patient, $service, $address, $slot); + + $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(2, $body['data']['waitlist_notified']); + + $repo = static::getContainer()->get(WaitlistEntryRepository::class); + + foreach ([$first['uuid'], $second['uuid']] as $uuid) { + $entry = $repo->findByUuid($uuid); + self::assertSame(WaitlistEntry::STATUS_NOTIFIED, $entry->getStatus()); + self::assertNotNull($entry->getNotifiedAt()); + self::assertSame(1, $entry->getNotifyCount()); + } + } + + /** سقف اطلاع‌رسانی، یک بازهٔ پرلغو را به منبع اسپم تبدیل نمی‌کند. */ + public function testNotificationsStopAtTheCap(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $slot = time() + 2 * 86400; + $entry = $this->join($user, $patient, $service, $slot - 86400, $slot + 86400); + + $repo = static::getContainer()->get(WaitlistEntryRepository::class); + + for ($i = 0; $i < WaitlistEntry::MAX_NOTIFICATIONS + 2; $i++) { + $appointment = $this->appointment($doctor, $patient, $service, $address, $slot); + $this->notifier()->notifyForFreedSlot($appointment); + } + + self::assertSame( + WaitlistEntry::MAX_NOTIFICATIONS, + $repo->findByUuid($entry['uuid'])->getNotifyCount(), + ); + } + + /** درخواستی که شعبهٔ دیگری را خواسته، برای این ظرفیت خبر نمی‌شود. */ + public function testAnEntryForAnotherBranchIsNotNotified(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $otherBranch = DoctorAddress::forClinic((int) $address->getClinicId()); + $otherBranch->setName('شعبهٔ دوم'); + $this->em->persist($otherBranch); + $this->em->flush(); + + $slot = time() + 2 * 86400; + + $this->join($user, $patient, $service, $slot - 86400, $slot + 86400, [ + 'branch_uuid' => $otherBranch->getUuid(), + ]); + + $appointment = $this->appointment($doctor, $patient, $service, $address, $slot); + + self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment)); + } + + public function testDeletingAnEntryRemovesItFromTheList(): void + { + [$user, $section, , , $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $entry = $this->join($user, $patient, $service, time() + 86400, time() + 4 * 86400); + + $this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $user); + self::assertSame(200, $this->responseCode()); + + self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $user)['data']); + } + + public function testAnotherClinicCannotSeeOrDeleteTheEntry(): void + { + [$owner, $section, , , $patient] = $this->clinicWithPatient(); + [$other] = $this->clinicWithPatient(); + + $service = $this->service($section); + $entry = $this->join($owner, $patient, $service, time() + 86400, time() + 4 * 86400); + + self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $other)['data']); + + $this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $other); + self::assertSame(404, $this->responseCode()); + } +}