feat(cancellation): cancellation policy, no-show tracking and a waitlist

Cancelling worked but had no policy behind it: no window, no penalty, nothing
happened to the deposit, and the no_show status had no effect at all.

Two rules that are expensive to get wrong, and both are load-bearing:
- The clinic cancelling its own appointment is never charged. That check is the
  first line of the calculation, not somewhere in the middle, so a later
  refactor cannot reorder it into charging patients for the clinic's decision.
- A penalty never exceeds what was actually paid. Anything above that is a
  debt, and debt belongs to billing, not to cancellation. An unpaid appointment
  is charged nothing and the response says why.

The default is no penalty at all — a penalising default would have made every
patient with a near appointment liable the moment this deployed.

No-shows are rows, not a counter on the patient: a counter loses which
appointment and when, which makes the 12-month window impossible. Crossing the
threshold adds an existing TenantTag; it never blocks the patient, because
blocking is an eligibility policy (task 09) written on top of that same tag.

Waitlist notifies up to ten matching people and the first to book wins. An
exclusive queue reads fairer but means a freed slot sits locked for half an
hour while someone ignores their phone — so the SMS says so explicitly instead.

Insufficient wallet balance does not fail the cancellation: the slot is freed
either way. A slot should not be held hostage to money.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 12:06:48 +03:30
co-authored by Claude Opus 5
parent d831ce2c1c
commit fba1555f22
26 changed files with 3122 additions and 77 deletions
+4
View File
@@ -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() {
<Route path="patient-package/:patientPackageUuid/ledger" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PatientPackageLedgerPage /></RoleRoute>} />
<Route path="course-protocols" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CourseProtocolsPage /></RoleRoute>} />
<Route path="treatment-course/:courseUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><TreatmentCoursePage /></RoleRoute>} />
<Route path="cancellation-policy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><CancellationPolicyPage /></RoleRoute>} />
<Route path="waitlist" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><WaitlistPage /></RoleRoute>} />
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
@@ -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'] },
+101
View File
@@ -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<ApiResponse<PolicyResponse>>('/api/v1/cancellation-policy'),
});
const save = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.put<ApiResponse<CancellationPolicy>>('/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<ApiResponse<CancellationPreview>>(
`/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<ApiResponse<CancellationResult>>(`/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<ApiResponse<WaitlistEntry[]>>(`/api/v1/waitlist${status ? `?status=${status}` : ''}`),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/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 };
}
@@ -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<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
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(<CancellationPolicyPage />, { 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(<CancellationPolicyPage />, { 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();
});
});
@@ -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 (
<div className="fade-in">
<PageHeader
title="سیاست لغو"
description="تا چند ساعت قبل لغو رایگان است، جریمه چقدر است، و بعد از چند بار عدم حضور بیمار پرریسک علامت می‌خورد."
backTo="/admin/settings-menu"
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640 }}>
{loading && <span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری</span>}
<div className="field" style={{ maxWidth: 220 }}>
<label htmlFor="cp-window">پنجرهٔ لغو رایگان (ساعت)</label>
<input
id="cp-window"
className="input"
type="number"
min={0}
value={freeWindow}
onChange={(e) => setFreeWindow(Number(e.target.value))}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
لغو زودتر از این، همیشه رایگان است.
</span>
</div>
<div className="field" style={{ maxWidth: 260 }}>
<label>نوع جریمه پس از پنجرهٔ رایگان</label>
<SearchableSelect
value={mode}
onChange={(v) => setMode((v as 'none' | 'percent' | 'fixed') ?? 'none')}
options={MODES}
/>
</div>
{mode === 'percent' && (
<div className="field" style={{ maxWidth: 200 }}>
<label htmlFor="cp-percent">درصد جریمه</label>
<input
id="cp-percent"
className="input"
type="number"
min={0}
max={100}
value={value}
onChange={(e) => setValue(Number(e.target.value))}
/>
{percentInvalid && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>درصد باید بین ۰ تا ۱۰۰ باشد.</span>
)}
</div>
)}
{mode === 'fixed' && (
<div className="field" style={{ maxWidth: 260 }}>
<label>مبلغ جریمه</label>
<PriceInput value={value} onChange={setValue} suffix="ریال" />
</div>
)}
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
جریمه هرگز از مبلغ پرداختی بیمار بیشتر نمیشود؛ نوبت نقدی جریمهای ندارد.
</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={depositRefundable}
onChange={(e) => setDepositRefundable(e.target.checked)}
/>
بیعانه پس از پنجرهٔ رایگان هم برمیگردد
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={creditRefundable}
onChange={(e) => setCreditRefundable(e.target.checked)}
/>
اعتبار پکیج پس از لغو برمیگردد
</label>
<div className="field" style={{ maxWidth: 220 }}>
<label htmlFor="cp-threshold">آستانهٔ عدم حضور</label>
<input
id="cp-threshold"
className="input"
type="number"
min={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
بعد از این تعداد در یک سال، بیمار برچسب پرریسک میگیرد ولی مسدود نمیشود.
</span>
</div>
{canManage && (
<div>
<button
type="button"
className="btn primary"
disabled={percentInvalid || save.isPending}
onClick={() =>
save.mutate({
free_window_hours: freeWindow,
penalty_mode: mode,
penalty_value: value,
deposit_refundable: depositRefundable,
credit_refundable: creditRefundable,
no_show_threshold: threshold,
})
}
>
ذخیرهٔ سیاست
</button>
</div>
)}
</div>
</div>
);
}
+131
View File
@@ -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<WaitlistEntry['status'], { label: string; className: string }> = {
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<WaitlistEntry>[] = [
{
key: 'service_name',
header: 'خدمت',
render: (e) => <span style={{ fontWeight: 600 }}>{e.service_name}</span>,
},
{
key: 'window',
header: 'بازهٔ دلخواه',
render: (e) => (
<span style={{ fontSize: 13 }}>
{formatDate(e.desired_from)} تا {formatDate(e.desired_to)}
</span>
),
},
{
key: 'preferred_day_parts',
header: 'زمان ترجیحی',
render: (e) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{e.preferred_day_parts.length === 0 ? 'بی‌تفاوت' : e.preferred_day_parts.join('، ')}
</span>
),
},
{
key: 'notify_count',
header: 'تعداد اطلاع',
render: (e) => (
<span style={{ fontSize: 13 }}>
{e.notify_count}
{e.notified_at !== null && (
<span style={{ fontSize: 11, color: 'var(--text-3)' }}> · {formatDate(e.notified_at)}</span>
)}
</span>
),
},
{
key: 'status',
header: 'وضعیت',
render: (e) => (
<span className={STATUS[e.status].className}>
<span className="bdot" />
{STATUS[e.status].label}
</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title="لیست انتظار"
description="وقتی ظرفیتی آزاد می‌شود همهٔ منتظرانِ آن بازه خبر می‌گیرند و اولین رزروکننده آن را می‌برد."
backTo="/admin/settings-menu"
/>
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در خدمات..."
emptyMessage="کسی در لیست انتظار نیست"
headerExtra={
<div style={{ minWidth: 180, marginRight: 'auto' }}>
<SearchableSelect
value={urlState.status}
onChange={(v) => setUrlState({ status: String(v ?? '') })}
options={[
{ value: '', label: 'همهٔ وضعیت‌ها' },
...Object.entries(STATUS).map(([value, meta]) => ({ value, label: meta.label })),
]}
placeholder="وضعیت"
/>
</div>
}
actions={(e) =>
canManage ? (
<button
type="button"
className="btn secondary sm"
disabled={remove.isPending}
onClick={() => remove.mutate(e.uuid)}
aria-label="حذف از لیست انتظار"
>
<TrashIcon style={{ width: 15 }} />
</button>
) : null
}
/>
</div>
</div>
);
}
+50
View File
@@ -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<CancellationPreview, 'paid_rials'> {
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;
}
+176
View File
@@ -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 # ۱۴ تست
```
+97
View File
@@ -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 # ۹ تست
```
@@ -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 تست (۸.۲) |
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260731081142 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
@@ -0,0 +1,219 @@
<?php
namespace App\Cancellation\Controller;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Cancellation\Entity\CancellationPolicy;
use App\Cancellation\Repository\CancellationPolicyRepository;
use App\Cancellation\Service\CancellationService;
use App\Cancellation\Service\NoShowService;
use App\Cancellation\Service\PenaltyCalculator;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Cancellation')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class CancellationController extends BaseController
{
public function __construct(
private readonly CancellationPolicyRepository $policies,
private readonly AppointmentRepository $appointments,
private readonly ServiceItemRepository $items,
private readonly PatientRecordRepository $patients,
private readonly PenaltyCalculator $calculator,
private readonly CancellationService $cancellation,
private readonly NoShowService $noShow,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
) {}
#[Route('/api/v1/cancellation-policy', name: 'cancellation_policy_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->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;
}
}
@@ -0,0 +1,157 @@
<?php
namespace App\Cancellation\Entity;
use App\Cancellation\Repository\CancellationPolicyRepository;
use App\ClinicService\Entity\ServiceItem;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* سیاست لغو یک محیط، یا override یک سرویس.
*
* `serviceItem === null` یعنی پیش‌فرض محیط. سرویسی که سیاست خودش را دارد، همان برنده
* است — بدون ترکیب و بدون وراثت جزئی، چون «۲۴ ساعت از محیط ولی ۵۰٪ از سرویس» چیزی
* است که هیچ اپراتوری نمی‌تواند در ذهنش شبیه‌سازی کند.
*
* پیش‌فرض عمداً **بدون جریمه** است: اگر پیش‌فرض جریمه‌دار بود، لحظهٔ deploy همهٔ
* بیمارانِ با نوبت نزدیک مشمول جریمه می‌شدند و کلینیک خبر نداشت.
*/
#[ORM\Entity(repositoryClass: CancellationPolicyRepository::class)]
#[ORM\Table(name: 'cancellation_policies')]
#[ORM\UniqueConstraint(name: 'uniq_cancel_policy_scope', columns: ['entity_type', 'entity_id', 'service_item_id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_cancel_policies_tenant')]
class CancellationPolicy
{
use TenantOwnedTrait;
public const MODE_NONE = 'none';
public const MODE_PERCENT = 'percent';
public const MODE_FIXED = 'fixed';
public const MODES = [self::MODE_NONE, self::MODE_PERCENT, self::MODE_FIXED];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', nullable: true, onDelete: 'CASCADE')]
private ?ServiceItem $serviceItem = null;
#[ORM\Column(name: 'free_window_hours', type: 'smallint', options: ['default' => 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<string, mixed> */
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,
];
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Cancellation\Entity;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Cancellation\Repository\NoShowRecordRepository;
use App\Patient\Entity\PatientRecord;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* یک بار عدم حضور — جدول جدا، نه شمارنده روی بیمار.
*
* همان استدلال دفتر اعتبار تسک ۱۱: شمارنده «چه زمانی و کدام نوبت» را از دست می‌دهد و
* پنجرهٔ ۱۲ ماهه را غیرقابل محاسبه می‌کند. بیماری که سه سال پیش سه بار نیامده، امروز
* پرریسک نیست.
*/
#[ORM\Entity(repositoryClass: NoShowRecordRepository::class)]
#[ORM\Table(name: 'no_show_records')]
#[ORM\UniqueConstraint(name: 'uniq_no_show_appointment', columns: ['appointment_id'])]
#[ORM\Index(columns: ['patient_record_id', 'recorded_at'], name: 'idx_no_show_patient')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'recorded_at'], name: 'idx_no_show_tenant')]
class NoShowRecord
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
#[ORM\JoinColumn(name: 'patient_record_id', nullable: false, onDelete: 'CASCADE')]
private PatientRecord $patientRecord;
#[ORM\ManyToOne(targetEntity: Appointment::class)]
#[ORM\JoinColumn(name: 'appointment_id', nullable: false, onDelete: 'CASCADE')]
private Appointment $appointment;
#[ORM\Column(name: 'recorded_at', type: 'integer')]
private int $recordedAt;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'recorded_by', nullable: true, onDelete: 'SET NULL')]
private ?User $recordedBy = null;
public function __construct(PatientRecord $patientRecord, Appointment $appointment, ?User $recordedBy = null, ?int $at = null)
{
$this->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<string, mixed> */
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'appointment_uuid' => $this->appointment->getUuid(),
'slot_start' => $this->appointment->getSlotStart(),
'recorded_at' => $this->recordedAt,
];
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Cancellation\Repository;
use App\Cancellation\Entity\CancellationPolicy;
use App\ClinicService\Entity\ServiceItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<CancellationPolicy> */
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();
}
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Cancellation\Repository;
use App\Appointment\Entity\Appointment;
use App\Cancellation\Entity\NoShowRecord;
use App\Patient\Entity\PatientRecord;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<NoShowRecord> */
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();
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Cancellation\Service;
use App\Appointment\Booking\Service\BookingService;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Cancellation\ValueObject\PenaltyResult;
use App\Package\Service\CreditLedgerService;
use App\Settlement\Service\WalletService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Waitlist\Service\WaitlistNotifier;
use Doctrine\ORM\EntityManagerInterface;
/**
* لغو نوبت با اعمال سیاست.
*
* ترتیب کارها عمدی است: اول اعتبارسنجی، بعد آزادسازی ظرفیت، بعد پول، و آخر اطلاع به
* لیست انتظار. اگر اطلاع‌رسانی اول بود، ممکن بود ده نفر برای ظرفیتی خبر شوند که هنوز
* آزاد نشده.
*/
final class CancellationService
{
public function __construct(
private readonly PenaltyCalculator $calculator,
private readonly BookingService $booking,
private readonly WalletService $wallet,
private readonly CreditLedgerService $credits,
private readonly WaitlistNotifier $waitlist,
private readonly EntityManagerInterface $em,
) {}
/**
* @return array<string, mixed>
* @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(),
'سیاست لغو: اعتبار این جلسه برنمی‌گردد',
);
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Cancellation\Service;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Cancellation\Entity\NoShowRecord;
use App\Cancellation\Repository\CancellationPolicyRepository;
use App\Cancellation\Repository\NoShowRecordRepository;
use App\Patient\Entity\PatientRecord;
use App\Tag\Entity\TenantTag;
use Doctrine\ORM\EntityManagerInterface;
/**
* ثبت عدم حضور و برچسب‌گذاری بیمار پرریسک.
*
* برچسب **مسدود نمی‌کند**. مسدودسازی یک قانون `eligibility` (تسک ۰۹) روی همین برچسب
* است؛ اینجا فقط واقعیت ثبت می‌شود. تفکیکش عمدی است: کلینیکی که می‌خواهد بیمار پرریسک
* را ببیند ولی بیعانه بگیرد، نباید مجبور شود برچسب را خاموش کند.
*/
final class NoShowService
{
public function __construct(
private readonly NoShowRecordRepository $records,
private readonly CancellationPolicyRepository $policies,
private readonly EntityManagerInterface $em,
) {}
/**
* @return array{recorded: bool, count: int, threshold: int, tagged: bool}
*/
public function record(Appointment $appointment, PatientRecord $patient, ?User $actor = null, ?int $now = null): array
{
$now = $now ?? time();
// تغییر وضعیت به `no_show` ممکن است دو بار اتفاق بیفتد؛ رکورد دوم ثبت نشود.
$existing = $this->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);
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\Cancellation\Service;
use App\Appointment\Entity\Appointment;
use App\Cancellation\Entity\CancellationPolicy;
use App\Cancellation\Repository\CancellationPolicyRepository;
use App\Cancellation\ValueObject\PenaltyResult;
use App\Payment\Entity\Payment;
use Doctrine\ORM\EntityManagerInterface;
/**
* محاسبهٔ جریمهٔ لغو — خالص، بدون هیچ نوشتنی.
*
* همین کلاس هم پیش‌نمایش را می‌دهد و هم عددی که واقعاً کسر می‌شود؛ دو مسیر جدا یعنی
* بالاخره روزی دو عدد متفاوت.
*/
final class PenaltyCalculator
{
public function __construct(
private readonly CancellationPolicyRepository $policies,
private readonly EntityManagerInterface $em,
) {}
public function calculate(Appointment $appointment, string $cancelledBy, ?int $now = null): PenaltyResult
{
// ⚠️ این شرط باید **اولین** خط باشد. اگر بعد از محاسبهٔ پنجرهٔ زمانی بیاید، یک
// refactor می‌تواند ترتیب را عوض کند و کلینیک از بیمار برای لغو خودش جریمه بگیرد.
if ($cancelledBy === Appointment::STATUS_CANCELLED_BY_DOCTOR) {
return PenaltyResult::free(true, ['لغو توسط کلینیک هرگز جریمه ندارد']);
}
$now = $now ?? time();
$policy = $this->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();
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Cancellation\ValueObject;
/**
* نتیجهٔ محاسبهٔ لغو — پیش از اینکه چیزی اتفاق بیفتد.
*
* همان شکل در پیش‌نمایش و در لغو واقعی برمی‌گردد: بیمار نباید عددی ببیند که با آنچه
* واقعاً کسر می‌شود فرق دارد.
*/
final readonly class PenaltyResult
{
/** @param list<string> $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<string, mixed> */
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,
];
}
}
@@ -0,0 +1,181 @@
<?php
namespace App\Waitlist\Controller;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Patient\Entity\PatientRecord;
use App\Patient\Repository\PatientRecordRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
use App\Waitlist\Entity\WaitlistEntry;
use App\Waitlist\Repository\WaitlistEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Waitlist')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class WaitlistController extends BaseController
{
public function __construct(
private readonly WaitlistEntryRepository $entries,
private readonly ServiceItemRepository $items,
private readonly PatientRecordRepository $patients,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/v1/waitlist', name: 'waitlist_index', methods: ['GET'])]
public function index(#[CurrentUser] User $user, Request $request): JsonResponse
{
[$entityType, $entityId] = $this->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;
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
namespace App\Waitlist\Entity;
use App\Appointment\Entity\Appointment;
use App\ClinicService\Entity\ServiceItem;
use App\Patient\Entity\PatientRecord;
use App\Shared\Tenant\TenantOwnedTrait;
use App\Waitlist\Repository\WaitlistEntryRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* «اگر وقتی در این بازه آزاد شد، خبرم کن.»
*
* توسعهٔ همان ایدهٔ `Appointment.is_reserve` موجود، ولی با بازهٔ صریح و وضعیت — تا
* بشود گفت چه کسی، برای چه، در چه بازه‌ای منتظر است.
*/
#[ORM\Entity(repositoryClass: WaitlistEntryRepository::class)]
#[ORM\Table(name: 'waitlist_entries')]
#[ORM\Index(columns: ['service_item_id', 'branch_id', 'status', 'desired_from', 'desired_to'], name: 'idx_waitlist_match')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status', 'created_at'], name: 'idx_waitlist_tenant')]
#[ORM\Index(columns: ['patient_record_id', 'status'], name: 'idx_waitlist_patient')]
class WaitlistEntry
{
use TenantOwnedTrait;
public const STATUS_WAITING = 'waiting';
public const STATUS_NOTIFIED = 'notified';
public const STATUS_CONVERTED = 'converted';
public const STATUS_EXPIRED = 'expired';
/** سقف اطلاع‌رسانی — بدون آن، یک بازهٔ پرلغو به منبع اسپم تبدیل می‌شود. */
public const MAX_NOTIFICATIONS = 3;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
#[ORM\JoinColumn(name: 'patient_record_id', nullable: false, onDelete: 'CASCADE')]
private PatientRecord $patientRecord;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $serviceItem;
#[ORM\Column(name: 'branch_id', type: 'integer', nullable: true)]
private ?int $branchId = null;
#[ORM\Column(name: 'desired_from', type: 'integer')]
private int $desiredFrom;
#[ORM\Column(name: 'desired_to', type: 'integer')]
private int $desiredTo;
/** @var list<string>|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<string> $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<string, mixed> */
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,
];
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Waitlist\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Patient\Entity\PatientRecord;
use App\Waitlist\Entity\WaitlistEntry;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<WaitlistEntry> */
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();
}
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Waitlist\Service;
use App\Appointment\Entity\Appointment;
use App\Representation\Service\JalaliDateService;
use App\Sms\Service\SmsService;
use App\Waitlist\Entity\WaitlistEntry;
use App\Waitlist\Repository\WaitlistEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* اطلاع‌رسانی به لیست انتظار وقتی ظرفیتی آزاد می‌شود.
*
* ## چرا broadcast و نه صف انحصاری
*
* ظرفیت آزادشده به حداکثر ده نفر خبر داده می‌شود و **اولین رزروکننده می‌برد**. صف
* انحصاری («فقط نفر اول ۳۰ دقیقه فرصت دارد») روی کاغذ عادلانه‌تر است، ولی در عمل
* یعنی وقتی که کسی جوابش را نمی‌دهد نیم ساعت قفل بماند و بعد به نفر دوم برسد — و
* ظرفیت آزادشدهٔ دو ساعت مانده به نوبت، نیم ساعت وقت تلف‌کردنی ندارد.
*
* در عوض، متن پیامک **اجباراً** این را می‌گوید تا کسی احساس نکند وعده‌ای شکسته شده.
*/
final class WaitlistNotifier
{
public const MAX_RECIPIENTS = 10;
public function __construct(
private readonly WaitlistEntryRepository $entries,
private readonly SmsService $sms,
private readonly JalaliDateService $jalali,
private readonly EntityManagerInterface $em,
) {}
/**
* @return int تعداد کسانی که خبر شدند
*/
public function notifyForFreedSlot(Appointment $appointment, ?int $now = null): int
{
$service = $appointment->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),
);
}
}
+391
View File
@@ -0,0 +1,391 @@
<?php
namespace App\Tests\Cancellation;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Payment\Entity\Payment;
use App\Settlement\Service\WalletService;
use App\Tag\Entity\TenantTag;
use App\Tests\ApiTestCase;
/**
* سیاست لغو، جریمه و عدم حضور — تسک ۱۳.
*
* دو قاعده که شکستنشان گران است: لغو توسط کلینیک هرگز جریمه ندارد، و جریمه هرگز از
* مبلغ پرداختی بیشتر نمی‌شود.
*/
class CancellationTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
private function clinicWithPatient(): array
{
$user = $this->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<string, mixed> $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());
}
}
+300
View File
@@ -0,0 +1,300 @@
<?php
namespace App\Tests\Waitlist;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
use App\Waitlist\Entity\WaitlistEntry;
use App\Waitlist\Repository\WaitlistEntryRepository;
use App\Waitlist\Service\WaitlistNotifier;
/**
* لیست انتظار — تسک ۱۳.
*
* تصمیم معماری: ظرفیت آزادشده **به همه** خبر داده می‌شود و اولین رزروکننده می‌برد.
* صف انحصاری یعنی وقتی که کسی جوابش را نمی‌دهد نیم ساعت قفل بماند، و ظرفیتِ دو ساعت
* مانده به نوبت آن نیم ساعت را ندارد.
*/
class WaitlistTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
private function clinicWithPatient(): array
{
$user = $this->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<string, mixed> $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());
}
}