feat(package): session packages backed by a credit ledger

"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 11:11:03 +03:30
co-authored by Claude Opus 5
parent d6294242b7
commit ca9648732d
35 changed files with 3205 additions and 78 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 PackagesPage from './pages/PackagesPage';
import PatientPackageLedgerPage from './pages/PatientPackageLedgerPage';
import PoliciesPage from './pages/PoliciesPage';
import PolicyFormPage from './pages/PolicyFormPage';
import PolicySimulationPage from './pages/PolicySimulationPage';
@@ -293,6 +295,8 @@ export default function App() {
<Route path="policies" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PoliciesPage /></RoleRoute>} />
<Route path="policies/new" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'update']}><PolicyFormPage /></RoleRoute>} />
<Route path="policies/:policyUuid/simulate" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PolicySimulationPage /></RoleRoute>} />
<Route path="packages" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PackagesPage /></RoleRoute>} />
<Route path="patient-package/:patientPackageUuid/ledger" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PatientPackageLedgerPage /></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,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon,
} from '@heroicons/react/24/outline';
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
@@ -33,6 +33,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ 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: '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'] },
+120
View File
@@ -0,0 +1,120 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { CreditLedger, PackageDefinition, PatientPackage } from '../types';
/**
* پکیج و اعتبار جلسات.
*
* مانده هیچ‌جا کش نمی‌شود و بعد از هر تغییر دفتر، کوئری باطل می‌شود: عددی که کاربر
* می‌بیند باید همان جمع دفتر باشد، نه چیزی که فرانت حساب کرده.
*/
const PACKAGES_KEY = ['packages'];
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function usePackages() {
const qc = useQueryClient();
const query = useQuery({
queryKey: PACKAGES_KEY,
queryFn: () => api.get<ApiResponse<PackageDefinition[]>>('/api/v1/packages'),
});
const invalidate = () => qc.invalidateQueries({ queryKey: PACKAGES_KEY });
const create = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post<ApiResponse<PackageDefinition>>('/api/v1/packages', body),
onSuccess: () => {
toast.success('پکیج ساخته شد');
invalidate();
},
onError: (e) => fail(e, 'ساخت پکیج ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
api.patch<ApiResponse<PackageDefinition>>(`/api/v1/package/${uuid}`, body),
onSuccess: () => {
toast.success('پکیج به‌روزرسانی شد');
invalidate();
},
onError: (e) => fail(e, 'به‌روزرسانی پکیج ناموفق بود'),
});
/** حذف = غیرفعال کردن؛ پکیج فروخته‌شده حذف‌شدنی نیست. */
const deactivate = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<PackageDefinition>>(`/api/v1/package/${uuid}`),
onSuccess: () => {
toast.success('پکیج غیرفعال شد');
invalidate();
},
onError: (e) => fail(e, 'غیرفعال‌سازی ناموفق بود'),
});
return { packages: query.data?.data ?? [], loading: query.isLoading, create, update, deactivate };
}
export function usePatientPackages(patientUuid: string | undefined) {
const qc = useQueryClient();
const key = ['patient-packages', patientUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<PatientPackage[]>>(`/api/v1/patient/${patientUuid}/packages`),
enabled: !!patientUuid,
});
const sell = useMutation({
mutationFn: (body: { package_uuid: string; price_paid_rials?: number }) =>
api.post<ApiResponse<PatientPackage>>(`/api/v1/patient/${patientUuid}/package`, body),
onSuccess: () => {
toast.success('پکیج برای بیمار ثبت شد');
qc.invalidateQueries({ queryKey: key });
},
onError: (e) => fail(e, 'ثبت پکیج ناموفق بود'),
});
return { patientPackages: query.data?.data ?? [], loading: query.isLoading, sell };
}
export function useCreditLedger(patientPackageUuid: string | undefined) {
const qc = useQueryClient();
const key = ['credit-ledger', patientPackageUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<CreditLedger>>(`/api/v1/patient-package/${patientPackageUuid}/ledger`),
enabled: !!patientPackageUuid,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: key });
qc.invalidateQueries({ queryKey: ['patient-packages'] });
};
const adjust = useMutation({
mutationFn: (body: { delta: number; reason: string }) =>
api.post<ApiResponse<PatientPackage>>(`/api/v1/patient-package/${patientPackageUuid}/adjust`, body),
onSuccess: () => {
toast.success('اصلاح اعتبار ثبت شد');
invalidate();
},
onError: (e) => fail(e, 'اصلاح اعتبار ناموفق بود'),
});
const expire = useMutation({
mutationFn: (reason: string) =>
api.post<ApiResponse<PatientPackage>>(`/api/v1/patient-package/${patientPackageUuid}/expire`, { reason }),
onSuccess: () => {
toast.success('پکیج ابطال شد');
invalidate();
},
onError: (e) => fail(e, 'ابطال پکیج ناموفق بود'),
});
return { ledger: query.data?.data, loading: query.isLoading, adjust, expire };
}
+279
View File
@@ -0,0 +1,279 @@
import React, { useEffect, useMemo, useState } from 'react';
import { PlusIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import PriceInput from '../components/ui/PriceInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { formatRial } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { usePackages } from '../hooks/usePackages';
import { api, type ApiResponse } from '../lib/api';
import { useQuery } from '@tanstack/react-query';
import type { PackageDefinition, ServiceItem } from '../types';
interface Draft {
uuid?: string;
name: string;
session_count: number;
price_rials: number;
validity_days: number | '';
service_uuids: string[];
}
const EMPTY: Draft = { name: '', session_count: 6, price_rials: 0, validity_days: '', service_uuids: [] };
/**
* تعریف پکیج‌ها.
*
* ماندهٔ بیمار اینجا نیست — آن در پروندهٔ بیمار است. اینجا فقط «چه می‌فروشیم».
*/
export default function PackagesPage() {
const { packages, loading, create, update, deactivate } = usePackages();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [draft, setDraft] = useState<Draft | null>(null);
const { data: servicesData } = useQuery({
queryKey: ['service-items-for-packages'],
queryFn: () => api.get<ApiResponse<ServiceItem[]>>('/api/v1/service-items'),
staleTime: 60_000,
});
const services = servicesData?.data ?? [];
const rows = useMemo(() => {
const q = urlState.search.trim();
return packages.filter((p) => q === '' || p.name.includes(q));
}, [packages, urlState.search]);
const columns: Column<PackageDefinition>[] = [
{
key: 'name',
header: 'پکیج',
render: (p) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{p.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{p.services.map((s) => s.name).join('، ') || 'بدون سرویس'}
</span>
</div>
),
},
{
key: 'session_count',
header: 'تعداد جلسه',
render: (p) => <span style={{ fontSize: 13 }}>{p.session_count}</span>,
},
{
key: 'price_rials',
header: 'قیمت',
render: (p) => <span style={{ fontSize: 13 }}>{formatRial(p.price_rials)}</span>,
},
{
key: 'validity_days',
header: 'اعتبار',
render: (p) => (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
{p.validity_days === null ? 'بی‌پایان' : `${p.validity_days} روز`}
</span>
),
},
{
key: 'active',
header: 'وضعیت',
render: (p) => <ActiveBadge active={p.active} />,
},
];
const save = async () => {
if (!draft) return;
const body = {
name: draft.name,
session_count: draft.session_count,
price_rials: draft.price_rials,
validity_days: draft.validity_days === '' ? null : draft.validity_days,
service_uuids: draft.service_uuids,
};
if (draft.uuid) {
await update.mutateAsync({ uuid: draft.uuid, body });
} else {
await create.mutateAsync(body);
}
setDraft(null);
};
return (
<div className="fade-in">
<PageHeader
title="پکیج‌ها"
description="بستهٔ چندجلسه‌ای که بیمار یکجا می‌خرد و بعداً جلساتش را رزرو می‌کند."
backTo="/admin/settings-menu"
action={
canManage ? (
<button type="button" className="btn primary sm" onClick={() => setDraft({ ...EMPTY })}>
<PlusIcon style={{ width: 15 }} /> پکیج تازه
</button>
) : undefined
}
/>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در پکیج‌ها..."
emptyMessage="هنوز پکیجی تعریف نشده است"
actions={(p) =>
canManage ? (
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
uuid: p.uuid,
name: p.name,
session_count: p.session_count,
price_rials: p.price_rials,
validity_days: p.validity_days ?? '',
service_uuids: p.services.map((s) => s.uuid),
})
}
>
ویرایش
</button>
{p.active && (
<button
type="button"
className="btn secondary sm"
disabled={deactivate.isPending}
onClick={() => deactivate.mutate(p.uuid)}
>
غیرفعال
</button>
)}
</div>
) : null
}
/>
<Modal
open={draft !== null}
title={draft?.uuid ? 'ویرایش پکیج' : 'پکیج تازه'}
onClose={() => setDraft(null)}
footer={
<>
<button
type="button"
className="btn primary"
disabled={!draft?.name.trim() || draft.service_uuids.length === 0 || create.isPending || update.isPending}
onClick={save}
>
ذخیره
</button>
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
انصراف
</button>
</>
}
>
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field">
<label htmlFor="pkg-name">نام پکیج</label>
<input
id="pkg-name"
className="input"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="۶ جلسه لیزر فول‌بادی"
/>
</div>
<div className="field">
<label htmlFor="pkg-sessions">تعداد جلسه</label>
<input
id="pkg-sessions"
className="input"
type="number"
min={1}
value={draft.session_count}
onChange={(e) => setDraft({ ...draft, session_count: Number(e.target.value) })}
/>
</div>
<div className="field">
<label>قیمت</label>
<PriceInput
value={draft.price_rials}
onChange={(v) => setDraft({ ...draft, price_rials: v })}
suffix="ریال"
/>
</div>
<div className="field">
<label htmlFor="pkg-validity">اعتبار (روز)</label>
<input
id="pkg-validity"
className="input"
type="number"
min={1}
value={draft.validity_days}
onChange={(e) =>
setDraft({ ...draft, validity_days: e.target.value === '' ? '' : Number(e.target.value) })
}
placeholder="خالی یعنی بی‌پایان"
/>
</div>
<div className="field">
<label>سرویسهای پوششدادهشده</label>
<SearchableSelect
value={null}
onChange={(v) => {
const uuid = String(v ?? '');
if (uuid && !draft.service_uuids.includes(uuid)) {
setDraft({ ...draft, service_uuids: [...draft.service_uuids, uuid] });
}
}}
options={services
.filter((s) => !draft.service_uuids.includes(s.uuid))
.map((s) => ({ value: s.uuid, label: s.name }))}
placeholder="افزودن سرویس"
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{draft.service_uuids.map((uuid) => (
<button
key={uuid}
type="button"
className="badge"
onClick={() =>
setDraft({ ...draft, service_uuids: draft.service_uuids.filter((u) => u !== uuid) })
}
>
{services.find((s) => s.uuid === uuid)?.name ?? uuid}
</button>
))}
</div>
{draft.service_uuids.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
پکیج بدون سرویس قابل مصرف نیست.
</span>
)}
</div>
</div>
)}
</Modal>
</div>
);
}
+89 -2
View File
@@ -2,12 +2,13 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom';
import {
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon,
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, RectangleStackIcon,
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon,
} from '@heroicons/react/24/outline';
import { PlusIcon } from '@heroicons/react/24/outline';
import { usePackages, usePatientPackages } from '../hooks/usePackages';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
@@ -41,7 +42,7 @@ import {
} from '../lib/patientForm';
import { usePermissions } from '../hooks/usePermissions';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'notes' | 'callcenter' | 'attach' | 'records';
const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [
{ key: 'services', label: 'سرویس‌ها', icon: (c) => <TabServices color={c} /> },
@@ -49,6 +50,7 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }
{ key: 'appointments', label: 'نوبت‌ها', icon: (c) => <TabCalendar color={c} /> },
{ key: 'payments', label: 'پرداخت‌ها', icon: (c) => <TabCard color={c} /> },
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
{ key: 'packages', label: 'پکیج‌ها', icon: (c) => <RectangleStackIcon style={{ width: 18, color: c }} /> },
{ key: 'notes', label: 'یادداشت‌ها', icon: (c) => <DocumentTextIcon style={{ width: 18, color: c }} /> },
{ key: 'callcenter', label: 'کال سنتر', icon: (c) => <TabCall color={c} /> },
{ key: 'attach', label: 'ضمیمه', icon: (c) => <TabAttach color={c} /> },
@@ -260,6 +262,8 @@ export default function PatientDetailPage() {
<PaymentsTab q={sessionsQ} />
) : tab === 'wallet' ? (
<WalletTab uuid={uuid!} />
) : tab === 'packages' ? (
<PackagesTab uuid={uuid!} />
) : tab === 'callcenter' ? (
<CallCenterTab uuid={uuid!} />
) : tab === 'attach' ? (
@@ -1021,3 +1025,86 @@ function AppointmentsTab({ uuid, q }: {
);
}
/**
* پکیج‌های بیمار.
*
* مانده از دفتر می‌آید نه از شمارنده؛ لینک «دفتر» همان تاریخچه را نشان می‌دهد تا
* معلوم باشد عدد از کجا آمده.
*/
function PackagesTab({ uuid }: { uuid: string }) {
const { patientPackages, loading, sell } = usePatientPackages(uuid);
const { packages } = usePackages();
const [selected, setSelected] = useState('');
const active = packages.filter((p) => p.active);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 240, margin: 0 }}>
<label>فروش پکیج تازه</label>
<SearchableSelect
value={selected}
onChange={(v) => setSelected(String(v ?? ''))}
options={active.map((p) => ({
value: p.uuid,
label: `${p.name}${p.session_count} جلسه`,
}))}
placeholder="انتخاب پکیج"
/>
</div>
<button
type="button"
className="btn primary sm"
disabled={selected === '' || sell.isPending}
onClick={async () => {
await sell.mutateAsync({ package_uuid: selected });
setSelected('');
}}
>
ثبت خرید
</button>
</div>
{loading ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری</div>
) : patientPackages.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
این بیمار هنوز پکیجی نخریده است
</div>
) : (
patientPackages.map((p) => (
<div key={p.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontWeight: 600 }}>{p.package_name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
خرید {formatDate(p.purchased_at)}
{p.valid_to !== null && ` · اعتبار تا ${formatDate(p.valid_to)}`}
</span>
</div>
<span style={{ fontSize: 14 }}>
مانده: <strong>{p.balance}</strong> از {p.session_count}
</span>
{p.expired && <span className="badge red"><span className="bdot" />منقضی</span>}
{!p.expired && p.balance === 0 && (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
اعتبار پکیج تمام شده؛ نوبت بعدی نقدی محاسبه میشود.
</span>
)}
<Link
className="btn secondary sm"
style={{ marginRight: 'auto' }}
to={`/admin/patient-package/${p.uuid}/ledger`}
>
دفتر اعتبار
</Link>
</div>
))
)}
</div>
);
}
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { Routes, Route } from 'react-router-dom';
import { api } from '../lib/api';
import PatientPackageLedgerPage from './PatientPackageLedgerPage';
const get = api.get as ReturnType<typeof vi.fn>;
const ledger = {
package: {
uuid: 'pp1',
package_uuid: 'p1',
package_name: '۶ جلسه لیزر',
patient_uuid: 'pat1',
session_count: 6,
price_paid_rials: 25_000_000,
purchased_at: 1_700_000_000,
valid_to: null,
expired: false,
balance: 5,
},
rows: [
{
uuid: 'l1', kind: 'purchase', delta: 6, appointment_uuid: null, service_uuid: null,
service_name: null, reason: 'خرید پکیج', created_by: 'u1', created_at: 1_700_000_000,
running_balance: 6,
},
{
uuid: 'l2', kind: 'consume', delta: -1, appointment_uuid: 'a1', service_uuid: 's1',
service_name: 'لیزر', reason: null, created_by: null, created_at: 1_700_100_000,
running_balance: 5,
},
],
};
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/patient-package/:patientPackageUuid/ledger" element={<PatientPackageLedgerPage />} />
</Routes>,
{ route: '/admin/patient-package/pp1/ledger' },
);
}
describe('PatientPackageLedgerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
get.mockResolvedValue({ success: true, data: ledger });
});
/** ماندهٔ هر ردیف باید دیده شود — همان چیزی که یک شمارنده نمی‌تواند نشان دهد. */
it('shows every ledger row with its running balance', async () => {
renderPage();
await waitFor(() => expect(screen.getByText('خرید')).toBeInTheDocument());
expect(screen.getByText('مصرف در نوبت')).toBeInTheDocument();
expect(screen.getByText('+6')).toBeInTheDocument();
expect(screen.getByText('-1')).toBeInTheDocument();
});
it('summarises the balance against the purchased session count', async () => {
renderPage();
await waitFor(() => expect(screen.getByText(/از 6 جلسه/)).toBeInTheDocument());
// عدد مانده در همان جمله می‌آید؛ «۵ از ۶» یعنی یک جلسه مصرف شده.
expect(screen.getByText(/از 6 جلسه/).parentElement).toHaveTextContent('5');
});
});
@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import { formatDate, formatRial } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import { useCreditLedger } from '../hooks/usePackages';
import type { CreditLedgerRow } from '../types';
const KIND_LABELS: Record<CreditLedgerRow['kind'], string> = {
purchase: 'خرید',
consume: 'مصرف در نوبت',
refund: 'بازگشت با لغو',
adjustment: 'اصلاح دستی',
expiry: 'انقضا',
};
/**
* دفتر اعتبار یک پکیج خریداری‌شده.
*
* ستون «مانده» در هر ردیف نشان می‌دهد عدد نهایی از کجا آمده — همان چیزی که یک
* شمارندهٔ ذخیره‌شده هرگز نمی‌تواند نشان دهد.
*/
export default function PatientPackageLedgerPage() {
const { patientPackageUuid } = useParams<{ patientPackageUuid: string }>();
const { ledger, loading, adjust, expire } = useCreditLedger(patientPackageUuid);
const { can } = usePermissions();
const canCorrect = can('appointment_settings', 'update');
const [adjusting, setAdjusting] = useState(false);
const [delta, setDelta] = useState(1);
const [reason, setReason] = useState('');
const columns: Column<CreditLedgerRow>[] = [
{
key: 'created_at',
header: 'تاریخ',
render: (r) => <span style={{ fontSize: 13 }}>{formatDate(r.created_at)}</span>,
},
{
key: 'kind',
header: 'نوع',
render: (r) => <span style={{ fontSize: 13 }}>{KIND_LABELS[r.kind]}</span>,
},
{
key: 'delta',
header: 'تغییر',
render: (r) => (
<span style={{ fontSize: 13, fontWeight: 600, color: r.delta > 0 ? 'var(--success)' : 'var(--danger)' }}>
{r.delta > 0 ? `+${r.delta}` : r.delta}
</span>
),
},
{
key: 'running_balance',
header: 'مانده',
render: (r) => <span style={{ fontSize: 13 }}>{r.running_balance}</span>,
},
{
key: 'reason',
header: 'دلیل',
render: (r) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{r.reason ?? r.service_name ?? '—'}
</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title={ledger ? `دفتر اعتبار: ${ledger.package.package_name}` : 'دفتر اعتبار'}
description="هر تغییر اعتبار یک ردیف است؛ ردیف‌ها حذف یا ویرایش نمی‌شوند."
backTo="/admin/patients"
/>
{ledger && (
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span style={{ fontSize: 14 }}>
مانده: <strong>{ledger.package.balance}</strong> از {ledger.package.session_count} جلسه
</span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
پرداختی: {formatRial(ledger.package.price_paid_rials)}
</span>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
خرید: {formatDate(ledger.package.purchased_at)}
{ledger.package.valid_to !== null && ` · اعتبار تا ${formatDate(ledger.package.valid_to)}`}
</span>
{ledger.package.expired && <span className="badge red"><span className="bdot" />منقضی</span>}
{canCorrect && (
<div style={{ display: 'flex', gap: 8, marginRight: 'auto' }}>
<button type="button" className="btn secondary sm" onClick={() => setAdjusting(true)}>
اصلاح دستی
</button>
<button
type="button"
className="btn secondary sm"
disabled={ledger.package.balance <= 0 || expire.isPending}
onClick={() => expire.mutate('ابطال دستی توسط کلینیک')}
>
ابطال مانده
</button>
</div>
)}
</div>
)}
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={ledger?.rows ?? []}
loading={loading}
emptyMessage="این پکیج هنوز تراکنشی ندارد"
/>
</div>
<Modal
open={adjusting}
title="اصلاح دستی اعتبار"
onClose={() => setAdjusting(false)}
footer={
<>
<button
type="button"
className="btn primary"
disabled={delta === 0 || reason.trim() === '' || adjust.isPending}
onClick={async () => {
await adjust.mutateAsync({ delta, reason: reason.trim() });
setAdjusting(false);
setReason('');
}}
>
ثبت اصلاح
</button>
<button type="button" className="btn secondary" onClick={() => setAdjusting(false)}>
انصراف
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field">
<label htmlFor="adj-delta">تغییر (مثبت یا منفی)</label>
<input
id="adj-delta"
className="input"
type="number"
value={delta}
onChange={(e) => setDelta(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="adj-reason">دلیل</label>
<input
id="adj-reason"
className="input"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثلاً: جبران جلسهٔ لغوشده توسط کلینیک"
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
دلیل در دفتر ثبت میشود و بعداً قابل ویرایش نیست.
</span>
</div>
</div>
</Modal>
</div>
);
}
+47
View File
@@ -1204,3 +1204,50 @@ export interface PolicySimulationRun {
rows: SimulationRow[];
warning: string | null;
}
// ── پکیج و اعتبار جلسات (تسک ۱۱) ─────────────────────────────────────────────
export interface PackageDefinition {
uuid: string;
name: string;
session_count: number;
price_rials: number;
/** `null` یعنی بی‌پایان */
validity_days: number | null;
active: boolean;
services: { uuid: string; name: string }[];
created_at: number;
}
export interface PatientPackage {
uuid: string;
package_uuid: string;
package_name: string;
patient_uuid: string;
session_count: number;
price_paid_rials: number;
purchased_at: number;
valid_to: number | null;
expired: boolean;
/** همیشه از جمع دفتر می‌آید — هیچ ستونی در دیتابیس نیست */
balance: number;
}
export interface CreditLedgerRow {
uuid: string;
kind: 'purchase' | 'consume' | 'refund' | 'adjustment' | 'expiry';
delta: number;
appointment_uuid: string | null;
service_uuid: string | null;
service_name: string | null;
reason: string | null;
created_by: string | null;
created_at: number;
/** در UI محاسبه‌شده نیست — سرور همان جمع تجمعی را می‌دهد */
running_balance: number;
}
export interface CreditLedger {
package: PatientPackage;
rows: CreditLedgerRow[];
}
+10
View File
@@ -174,3 +174,13 @@ ddev exec php bin/phpunit tests/Appointment/HoldAndBookTest.php # ۱۲ تست
دقیقه نگه‌داشتن صندلی، هم وقت بیمار را تلف می‌کند هم صندلی را.
جزئیات: [policy.md](policy.md)
---
## اعتبار پکیج
`confirm` یک جلسه از پکیج معتبر بیمار کسر می‌کند (ردیف `consume`) و لغو نوبت آن را
برمی‌گرداند (ردیف `refund`) — ردیف مصرف حذف نمی‌شود. کلید یکتای دفتر تضمین می‌کند
اجرای دوبارهٔ `confirm` جلسهٔ دوم نخورد.
جزئیات: [package.md](package.md)
+302
View File
@@ -0,0 +1,302 @@
# Package — پکیج و دفتر اعتبار جلسات
اندپوینت‌های `src/Package/*`. «پکیج شش جلسه لیزر» حالت رایج کلینیک زیبایی است: بیمار
یکجا پول می‌دهد و بعداً جلساتش را رزرو می‌کند.
**اعتبار یک دفتر حساب است، نه یک شمارنده.** هیچ ستون `remaining` یا `used_count` در هیچ
جدولی وجود ندارد و مانده همیشه `SUM(delta)` ردیف‌های دفتر است. ردیف‌ها append-only اند؛
تصحیح یعنی ردیف تازه، نه ویرایش ردیف قبلی.
همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط
دیگر). `adjust` و `expire` علاوه بر آن نقش پزشک/کلینیک/ادمین می‌خواهند (`403` برای منشی).
---
## چرخهٔ اعتبار
| نوع ردیف | delta | کِی نوشته می‌شود |
|---|---|---|
| `purchase` | `+session_count` | فروش پکیج به بیمار |
| `consume` | `-1` | `BookingService::confirm()` — ثبت نهایی نوبت |
| `refund` | `+1` | لغو همان نوبت؛ ردیف `consume` **حذف نمی‌شود** |
| `adjustment` | `±n` | اصلاح دستی، همیشه با `reason` و `created_by` |
| `expiry` | `-balance` | `app:package:expire` یا ابطال دستی |
`UNIQUE(appointment_id, kind)` مصرف دوباره را می‌بندد: `confirm` idempotent است و
اجرای دومش جلسهٔ دوم نمی‌خورد.
**FIFO:** وقتی بیمار چند پکیج معتبر برای یک سرویس دارد، قدیمی‌ترین اول مصرف می‌شود —
چون به انقضا نزدیک‌تر است و نگه داشتنش یعنی بیمار پولش را از دست بدهد.
---
## GET `/api/v1/packages`
| Query | Type | Description |
|---|---|---|
| `active` | bool | فقط فعال‌ها یا فقط غیرفعال‌ها |
### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "5502aa44-213a-44bd-9b24-64094c675f6e",
"name": "۶ جلسه لیزر فول‌بادی",
"session_count": 6,
"price_rials": 25000000,
"validity_days": 365,
"active": true,
"services": [{ "uuid": "be2e7a93-…", "name": "لیزر فول‌بادی" }],
"created_at": 1785483226
}
]
}
```
---
## POST `/api/v1/packages`
### Request Body
```json
{
"name": "۶ جلسه لیزر فول‌بادی",
"session_count": 6,
"price_rials": 25000000,
"validity_days": 365,
"service_uuids": ["be2e7a93-976a-468b-aa7a-b8f4a4278953"]
}
```
| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | ✅ | |
| `session_count` | int | ✅ | حداقل ۱ |
| `price_rials` | int | — | ریال؛ ستون `bigint` است |
| `validity_days` | int\|null | — | اعتبار از تاریخ خرید؛ `null` = بی‌پایان |
| `service_uuids` | string[] | ✅ | حداقل یک سرویس از همین محیط |
| `active` | bool | — | پیش‌فرض `true` |
### Response `201`
همان شکل بالا، با `uuid` تازه.
### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | نام خالی، `session_count < 1`، یا `service_uuids` خالی |
| `ERR_NOT_FOUND_001` | 404 | سرویس خارج از محیط جاری |
> پکیج بدون سرویس هرگز قابل مصرف نیست؛ ساختنش فقط یک تلهٔ خاموش برای اپراتور است،
> پس `422` می‌گیرد.
---
## GET · PATCH · DELETE `/api/v1/package/{uuid}`
`PATCH` همان فیلدهای ساخت را می‌پذیرد. فرستادن `service_uuids` **جایگزینی کامل** است.
`DELETE` پکیج را **غیرفعال** می‌کند، حذف نمی‌کند: ردیف‌های دفترِ بیماران به آن ارجاع
دارند و حذفش تاریخچهٔ اعتبار را بی‌معنا می‌کند.
---
## POST `/api/v1/patient/{uuid}/package`
فروش پکیج به بیمار. یک ردیف `purchase` هم‌زمان نوشته می‌شود.
### Request Body
```json
{ "package_uuid": "5502aa44-…", "price_paid_rials": 25000000 }
```
`price_paid_rials` اختیاری است؛ نبودنش یعنی قیمت تعریفِ لحظهٔ خرید.
### Response `201`
```json
{
"success": true,
"data": {
"uuid": "dcd21f8e-ddee-4f3a-96fb-9fd6c1867a27",
"package_uuid": "5502aa44-…",
"package_name": "۶ جلسه لیزر فول‌بادی",
"patient_uuid": "5faa22e0-…",
"session_count": 6,
"price_paid_rials": 25000000,
"purchased_at": 1785483226,
"valid_to": 1817019226,
"expired": false,
"balance": 6
}
}
```
`session_count` و `price_paid_rials` **کپی**اند نه ارجاع: تغییر تعریف پکیج فردا، پکیج
فروخته‌شدهٔ دیروز را عوض نمی‌کند.
---
## GET `/api/v1/patient/{uuid}/packages`
پکیج‌های بیمار با ماندهٔ روز. پکیج منقضی `balance: 0` نشان می‌دهد ولی دفترش دست‌نخورده
می‌ماند.
---
## GET `/api/v1/patient-package/{uuid}/ledger`
### Response `200`
```json
{
"success": true,
"data": {
"package": { "uuid": "dcd21f8e-…", "balance": 7, "…": "مثل بالا" },
"rows": [
{
"uuid": "3d11dc1f-…",
"kind": "purchase",
"delta": 6,
"appointment_uuid": null,
"service_uuid": null,
"service_name": null,
"reason": "خرید پکیج «۶ جلسه لیزر فول‌بادی»",
"created_by": "b093deb7-…",
"created_at": 1785483226,
"running_balance": 6
},
{
"uuid": "ca4ab9fb-…",
"kind": "adjustment",
"delta": 1,
"reason": "جبران جلسهٔ لغوشده",
"created_by": "b093deb7-…",
"created_at": 1785483226,
"running_balance": 7
}
]
}
}
```
`running_balance` جمع تجمعی است و در پاسخ محاسبه می‌شود — همین به کاربر نشان می‌دهد
عدد نهایی از کجا آمده.
---
## POST `/api/v1/patient-package/{uuid}/adjust`
**Permission:** پزشک · کلینیک · ادمین (منشی `403`)
### Request Body
```json
{ "delta": 2, "reason": "جبران جلسهٔ لغوشده توسط کلینیک" }
```
### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | `delta` صفر یا غایب، یا `reason` خالی |
| `ERR_VALIDATION_001` | 422 | اصلاحی که مانده را منفی می‌کند |
| `ERR_FORBIDDEN_001` | 403 | نقش منشی |
```json
{
"success": false,
"data": null,
"errors": [{ "code": "ERR_VALIDATION_002", "message": "دلیل اصلاح الزامی است", "field": "reason" }]
}
```
---
## POST `/api/v1/patient-package/{uuid}/expire`
ابطال دستی: یک ردیف `expiry` با `delta = -balance`. ماندهٔ صفر → `422`.
---
## اثر پکیج روی قیمت
`POST /api/v1/pricing/quote` یک فیلد اختیاری `patient_uuid` می‌گیرد. با آن، اگر بیمار
پکیج معتبری برای همان سرویس داشته باشد **قیمت پایهٔ سرویس** پوشش داده می‌شود:
```json
{
"base_rials": 5000000,
"final_rials": 0,
"package_will_be_consumed": true,
"package_uuid": "dcd21f8e-…",
"breakdown": {
"discounts": [
{ "label": "پوشش پکیج «۶ جلسه لیزر فول‌بادی»", "rials": 5000000, "kind": "package" }
]
}
}
```
⚠️ **`quote` هیچ‌وقت مصرف نمی‌کند** — فقط اعلام می‌کند. مصرف واقعی در
`BookingService::confirm()` است. اگر پیش‌نمایش مصرف می‌کرد، هر رفرش صفحه یک جلسه از
بیمار می‌گرفت.
پکیج **قیمت پایه** را می‌پوشاند نه آیتم‌های اضافه: «شش جلسه لیزر» یعنی شش بار خودِ
لیزر، نه هر چیزی که کنارش انتخاب شود.
ماندهٔ صفر خطا نیست: پکیج اعمال نمی‌شود و بیمار مبلغ کامل را نقدی می‌پردازد.
---
## دستور انقضا
```bash
ddev exec php bin/console app:package:expire # روزانه
ddev exec php bin/console app:package:expire --dry-run
```
برای هر پکیج با `valid_to` گذشته و ماندهٔ مثبت، یک ردیف `expiry` می‌نویسد. دفتر
دست‌نخورده می‌ماند تا «۳ جلسه‌ام چه شد؟» همیشه جواب داشته باشد.
---
## قفل بدبینانه اینجا، سطل زمانی آنجا
مصرف اعتبار با `PESSIMISTIC_WRITE` روی همان یک ردیف پکیج قفل می‌شود — برخلاف رزرو
اسلات (تسک ۰۷) که با سطل‌های پنج‌دقیقه‌ای و کلید یکتا کار می‌کند. این تفاوت عمدی است:
| | رزرو اسلات (تسک ۰۷) | اعتبار جلسه (تسک ۱۱) |
|---|---|---|
| نرخ رقابت | بالا — ساعت پرتقاضا | ناچیز — یک بیمار، یک پکیج |
| ردیف‌های درگیر | ده‌ها سطل | یک ردیف |
| هزینهٔ قفل | صف‌شدن رزروها | ناچیز |
| راه‌حل | کلید یکتا روی سطل | قفل بدبینانه |
اگر روزی کسی خواست «برای یکدستی» یکی را به دیگری تبدیل کند، همین جدول جواب است.
---
## طبقه‌بندی محیط
| جدول | وضعیت |
|---|---|
| `packages` · `patient_packages` · `session_credit_ledger` | جفت محیط |
| `package_services` | `AGGREGATE_CHILDREN` — ریشه `Package` |
برخلاف `wallet_transactions` (که `ENTITIES` است چون پول مال شخص است)، اعتبار جلسه جفت
محیط واقعی می‌گیرد: اعتبار جلسهٔ لیزر در کلینیک الف در کلینیک ب معنا ندارد.
## صفحه‌های پنل
| مسیر | صفحه |
|---|---|
| `/admin/packages` | تعریف پکیج‌ها |
| `/admin/patient-package/{uuid}/ledger` | دفتر اعتبار یک پکیج خریداری‌شده |
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Package # ۱۶ تست
```
مهم‌ترینش `testNoStoredBalanceColumnExists` است: هیچ ستون مانده‌ای در schema نباید
باشد. تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی»
می‌ایستد.
+10
View File
@@ -154,3 +154,13 @@ ddev exec php bin/phpunit tests/Pricing # ۱۲ تست
```
جزئیات دسته‌ها و اثرها: [policy.md](policy.md)
---
## پکیج
`quote` یک `patient_uuid` اختیاری می‌گیرد؛ با آن، پکیج معتبرِ بیمار قیمت پایهٔ سرویس را
می‌پوشاند و `package_will_be_consumed` روشن می‌شود. **پیش‌نمایش هرگز مصرف نمی‌کند**
مصرف در ثبت نهایی است.
جزئیات: [package.md](package.md)
+10
View File
@@ -305,3 +305,13 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql
| `tests/Patient/PatientWalletTenantTest.php` | دفتر کیف پول per-محیط است ولی موجودی سراسری می‌ماند |
| `tests/Shared/RequestReachableChildTenantTest.php` | فرزندانِ قابل‌دسترس با uuid را **خودِ فیلتر** می‌بندد، بدون گارد دستی |
| `tests/Auth/MultiClinicOwnerContextTest.php` | مالک چند کلینیک به هرکدام می‌تواند سوییچ کند |
## اعتبار جلسه: چرا برخلاف کیف پول جفت محیط می‌گیرد
`wallet_transactions` عمداً در `ENTITIES` است: پول مالِ **شخص** است و در هر محیطی همان
پول است؛ هر ردیف فقط `recorded_entity_*` دارد تا معلوم باشد کجا ثبت شده.
`session_credit_ledger` متفاوت است و جفت محیط واقعی می‌گیرد: «شش جلسه لیزر کلینیک الف»
در کلینیک ب هیچ معنایی ندارد و قابل مصرف نیست. همین تفاوت باعث می‌شود پکیج‌های یک بیمار
در دو کلینیک کاملاً از هم جدا بمانند.
@@ -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,104 +11,108 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | **هیچ ستون `remaining`/`used_count`/`balance` در هیچ جدولی** | | ⭐⭐ `LedgerSchemaTest` اجبار می‌کند |
| ۰.۳ | دفتر append-only هیچ `remove`/`update` روی ردیف‌ها | ⏳ | |
| ۰.۴ | `WalletTransaction` و منطق کیف پول دست‌نخورده | | مفهوم متفاوت |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | **هیچ ستون `remaining`/`used_count`/`balance` در هیچ جدولی** | | ⭐⭐ `testNoStoredBalanceColumnExists` روی schema واقعی |
| ۰.۳ | دفتر append-only | ✅ | هیچ `remove`/`setter` روی `SessionCreditLedger`؛ تصحیح = ردیف تازه |
| ۰.۴ | `WalletTransaction` دست‌نخورده | | تفاوتش در `tenancy.md` نوشته شد |
## ۱. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `Package` · `PackageService` · `PatientPackage` · `SessionCreditLedger` | | |
| ۱.۲ | `CreditLedgerService` **تنها** نویسندهٔ دفتر | | |
| ۱.۳ | `balance()` = `SUM(delta)`، بدون هیچ مقدار ذخیره‌شده | | ⭐ |
| ۱.۴ | پنج `kind` تعریف شد | ⏳ | |
| ۱.۵ | `quote` **هرگز** مصرف نمی‌کند؛ فقط `confirm` | ⏳ | ⭐⭐ رفرش صفحه = از دست رفتن جلسه |
| ۱.۶ | `PriceQuote` پرچم `packageWillBeConsumed` دارد | ⏳ | |
| ۱.۷ | مانده صفر → `false`، **نه استثنا** | | ⭐ بیمار نقدی بپردازد |
| ۱.۸ | قفل بدبینانه `PESSIMISTIC_WRITE` روی ردیف پکیج | | با جدول مقایسه با تسک ۰۷ |
| ۱.۹ | `catch UniqueConstraintViolationException` روی `consume` → idempotent | ⏳ | |
| ۱.۱۰ | FIFO — قدیمی‌ترین پکیج منقضی‌نشده | | LIFO یعنی پول بیمار سوخته |
| ۱.۱۱ | `valid_to` هنگام **خرید** محاسبه و ذخیره می‌شود | ⏳ | |
| ۱.۱۲ | لغو → ردیف `refund`، نه حذف `consume` | ⏳ | |
| ۱.۱۳ | `TODO` با ارجاع به تسک ۱۳ برای سیاست بازگشت اعتبار | | نه پرچم نیم‌کاره |
| ۱.۱۴ | `adjust` فقط با نقش مدیر و با `reason` اجباری | | |
| ۱.۱۵ | `app:package:expire` روزانه — ردیف `expiry` با `delta = -balance` | ⏳ | |
| ۱.۱۶ | قلاب مرحلهٔ ۴ `PricingEngine` وصل شد | ⏳ | |
| ۱.۱۷ | هشت endpoint | | |
| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | |
| ۱.۱ | چهار entity | | |
| ۱.۲ | `CreditLedgerService` تنها نویسندهٔ دفتر | | فروش، مصرف، بازگشت و اصلاح همه از همین عبور می‌کنند |
| ۱.۳ | `balance()` = `SUM(delta)` | | ⭐ |
| ۱.۴ | پنج `kind` | ✅ | سازنده `kind` ناشناخته و `delta` صفر را رد می‌کند |
| ۱.۵ | `quote` هرگز مصرف نمی‌کند | ✅ | ⭐⭐ `testQuoteAnnouncesThePackageWithoutConsumingIt` دو بار quote می‌زند و مانده را می‌سنجد |
| ۱.۶ | پرچم `packageWillBeConsumed` | ✅ | + `package_uuid` |
| ۱.۷ | مانده صفر → `false` نه استثنا | | ⭐ |
| ۱.۸ | قفل بدبینانه روی ردیف پکیج | | داخل `wrapInTransaction`؛ جدول مقایسه با تسک ۰۷ در `package.md` |
| ۱.۹ | `consume` idempotent | ⚠️ | با **بررسی پیش از درج** نه `catch` روی نقض کلید: گرفتن استثنا در Doctrine خودِ EntityManager را می‌بندد و بقیهٔ همان request را می‌سوزاند. کلید یکتا آخرین خط دفاع می‌ماند |
| ۱.۱۰ | FIFO | | `testTheOldestUnexpiredPackageIsUsedFirst` |
| ۱.۱۱ | `valid_to` هنگام خرید | ✅ | از `validity_days` لحظهٔ خرید |
| ۱.۱۲ | لغو → ردیف `refund` | ✅ | `BookingService::cancel()` |
| ۱.۱۳ | ارجاع به تسک ۱۳ برای سیاست بازگشت | | در docblock `refund()` |
| ۱.۱۴ | `adjust` فقط نقش مدیر و با `reason` | | منشی `403` |
| ۱.۱۵ | `app:package:expire` | ✅ | `--dry-run` هم دارد |
| ۱.۱۶ | قلاب `PricingEngine` | ✅ | `patient_uuid` اختیاری در `quote` |
| ۱.۱۷ | هشت endpoint | | ۹ تا: `packages` GET/POST · `package/{uuid}` GET/PATCH/DELETE · `patient/{uuid}/package` · `patient/{uuid}/packages` · `patient-package/{uuid}/ledger` · `/adjust` · `/expire` |
| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid | ✅ | `testAnotherClinicCannotSeeOrTouchThePackage` |
## ۲. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | چهار جدول | | |
| ۲.۲ | `price_rials` و `price_paid_rials` از نوع **BIGINT** | | پکیج بزرگ |
| ۲.۳ | `UNIQUE(appointment_id, kind)` روی دفتر | | ⭐ جلوگیری از مصرف دوباره |
| ۲.۴ | `session_count`/`price_paid_rials` روی `patient_packages` **snapshot** اند | | قانون پنجم |
| ۲.۵ | `ON DELETE RESTRICT` روی سرویسِ پکیج فروخته‌شده | | |
| ۲.۶ | `package_services` در `AGGREGATE_CHILDREN` | | |
| ۲.۷ | دفتر **جفت tenant** دارد (نه `ENTITIES` مثل کیف پول) | | ⭐ دلیل مکتوب |
| ۲.۸ | `TenantSchemaCoverageTest` سبز | | |
| ۲.۱ | چهار جدول | | `Version20260731072023` |
| ۲.۲ | `bigint` روی هر دو ستون مبلغ | | |
| ۲.۳ | `UNIQUE(appointment_id, kind)` | | ⭐ |
| ۲.۴ | snapshot تعداد و قیمت | | قانون پنجم |
| ۲.۵ | `ON DELETE RESTRICT` روی سرویس و پکیج | | |
| ۲.۶ | `package_services` در `AGGREGATE_CHILDREN` | | |
| ۲.۷ | دفتر جفت tenant دارد | | ⭐ دلیلش در `tenancy.md` کنار کیف پول |
| ۲.۸ | `TenantSchemaCoverageTest` سبز | | |
## ۳. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `PackagesPage` — تعریف با `PriceInput` و انتخاب سرویس | | |
| ۳.۲ | کارت «پکیج‌ها» در `PatientDetailPage` با مانده و انقضا | ⏳ | |
| ۳.۳ | `PatientPackageLedgerPage` — جدول دفتر | | |
| ۳.۴ | ستون «مانده تجمعی» **محاسبه‌شده در UI**، نه ستون DB | ⏳ | ⭐ به کاربر ثابت می‌کند عدد از کجاست |
| ۳.۵ | ستون‌های دفتر: تاریخ، نوع، تغییر، مانده تجمعی، دلیل، ثبت‌کننده، نوبت | ⏳ | |
| ۳.۶ | پیام «اعتبار پکیج تمام شده؛ این نوبت نقدی محاسبه می‌شود» | | |
| ۳.۷ | `DataTable` با skeleton و empty state | | |
| ۳.۸ | سرویس‌ها با `SearchableSelect` | | |
| ۳.۹ | `backTo`/`BackButton` روی زیرصفحه‌ها | | |
| ۳.۱۰ | هیچ رنگ/شعاع hard-code | | |
| ۳.۱۱ | دارک‌مود و حالت فشرده | ⏳ | |
| ۳.۱۲ | RTL و موبایل | | |
| ۳.۱۳ | مبالغ با `formatRial` · تاریخ با `formatDate` | | |
| ۳.۱۴ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
| ۳.۱۵ | همهٔ رشته‌ها فارسی | | |
| ۳.۱۶ | دکمهٔ `adjust` فقط برای نقش مدیر نمایش داده می‌شود | | `FeatureGate`/بررسی نقش |
| ۳.۱ | `PackagesPage` با `PriceInput` و انتخاب سرویس | | + ورودی منوی تنظیمات |
| ۳.۲ | پکیج‌های بیمار در `PatientDetailPage` | ✅ | تب «پکیج‌ها» با مانده، انقضا، فروش و لینک دفتر |
| ۳.۳ | `PatientPackageLedgerPage` | | |
| ۳.۴ | ستون مانده تجمعی | ⚠️ | **سرور** محاسبه‌اش می‌کند (`running_balance`) نه UI — یک منبع، و همان عددی که تست بک‌اند تضمینش می‌کند |
| ۳.۵ | ستون‌های دفتر | ⚠️ | تاریخ، نوع، تغییر، مانده، دلیل هست؛ ستون‌های «ثبت‌کننده» و «نوبت» در پاسخ هستند ولی در جدول نمایش داده نمی‌شوند (عرض موبایل) |
| ۳.۶ | پیام «اعتبار تمام شده؛ نقدی محاسبه می‌شود» | | در تب پکیج‌های بیمار |
| ۳.۷ | `DataTable` با skeleton و empty state | | |
| ۳.۸ | سرویس‌ها با `SearchableSelect` | | هیچ `<select>` بومی |
| ۳.۹ | `backTo` روی زیرصفحه‌ها | | |
| ۳.۱۰ | هیچ رنگ/شعاع hard-code | | |
| ۳.۱۱ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌های موجود؛ بازبینی چشمی انجام نشد |
| ۳.۱۲ | RTL و موبایل | | جدول دفتر اسکرول افقی داخلی دارد |
| ۳.۱۳ | `formatRial` و `formatDate` | | |
| ۳.۱۴ | وضعیت لیست در URL | ✅ | `useUrlState` در `PackagesPage` |
| ۳.۱۵ | همهٔ رشته‌ها فارسی | | |
| ۳.۱۶ | دکمهٔ `adjust` فقط برای مدیر | | `can('appointment_settings', 'update')` |
## ۴. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `CreditLedgerTest``SUM(delta)` در همهٔ سناریوها، append-only | | ⭐ |
| ۴.۲ | `LedgerSchemaTest` هیچ ستون مانده در schema | | ⭐⭐ |
| ۴.۳ | `QuoteDoesNotConsumeTest` — ده `quote` مانده بی‌تغییر | ⏳ | ⭐⭐ |
| ۴.۴ | `ConcurrentConsumeTest` — مانده منفی نمی‌شود | ⏳ | |
| ۴.۵ | `IdempotentConsumeTest``confirm` دوبار یک ردیف | | |
| ۴.۶ | `FifoTest` | | |
| ۴.۷ | `ExpiryTest` — ردیف `expiry` و حذف از finder | ⏳ | |
| ۴.۸ | `AdjustmentAuthTest` منشی ۴۰۳، مدیر بی‌دلیل ۴۲۲ | ⏳ | |
| ۴.۹ | `PackageTenantTest` — پکیج محیط دیگر ۴۰۴ | | |
| ۴.۱۰ | `PricingIntegrationTest` — ردیف `package` منفی + invariant تسک ۰۸ حفظ شد | ⏳ | ⭐ |
| ۴.۱ | `SUM(delta)` در همهٔ سناریوها، append-only | | ⭐ ترتیب `purchase → consume → refund` و ماندهٔ تجمعی |
| ۴.۲ | هیچ ستون مانده در schema | | ⭐⭐ |
| ۴.۳ | `quote` مصرف نمی‌کند | ✅ | ⭐⭐ دو quote پشت‌سرهم، مانده بی‌تغییر |
| ۴.۴ | مانده منفی نمی‌شود | ⚠️ | با مصرف پشت‌سرهم تست شد (`testAnEmptyPackageIsSimplyNotApplied`)؛ تست هم‌زمانی واقعی با دو اتصال نوشته نشد |
| ۴.۵ | مصرف دوباره یک ردیف | | |
| ۴.۶ | FIFO | | |
| ۴.۷ | انقضا | ✅ | نمایش صفر + دستور + ردیف `expiry` |
| ۴.۸ | مجوز اصلاح | ✅ | منشی رد، مدیر بدون دلیل ۴۲۲، منفی‌کردن مانده ۴۲۲ |
| ۴.۹ | جداسازی محیط | | |
| ۴.۱۰ | یکپارچگی با قیمت | ✅ | `final_rials` صفر و ردیف `package` در `breakdown` |
| ۴.۱۱ | تست فرانت دفتر | ✅ | `PatientPackageLedgerPage.test.tsx` |
**اجرا:** `ddev exec php bin/phpunit tests/Package` → ۱۶ تست (۱ skip عمدی: تولید خروجی مستندات).
## ۵. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `docs/api/package.md` | | |
| ۵.۲ | جدول مقایسهٔ قفل بدبینانه (این تسک) با سطل زمانی (تسک ۰۷) | | ⭐ وگرنه «یکدست‌سازی» می‌شود |
| ۵.۳ | `docs/architecture/tenancy.md` — تفاوت دفتر اعتبار با کیف پول | | ⭐ اشتباه گرفتنشان = نشتی مالی |
| ۵.۱ | `docs/api/package.md` | | JSON واقعی از اجرای واقعی |
| ۵.۲ | جدول مقایسهٔ قفل بدبینانه با سطل زمانی | | ⭐ در `package.md` |
| ۵.۳ | `tenancy.md` — تفاوت دفتر اعتبار با کیف پول | | ⭐ |
| ۵.۴ | یادداشت متقابل در `pricing.md` و `appointment-booking.md` | ✅ | |
## ۶. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | |
| ۶.۲ | `bin/phpunit` کامل سبز | | |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | `PatientWalletTenantTest` موجود سبز ماند | | |
| ۶.۵ | `phpstan` بدون خطای جدید | | |
| ۶.۶ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۶.۷ | تست‌های tenant سبز | | |
| ۶.۸ | `docs/api/*` به‌روز | | |
| ۶.۹ | چک‌لیست UI کامل | | |
| ۶.۱۰ | دو کلاینت دیگر بررسی شدند | | مبلغ صفر در رزرو درست نمایش داده می‌شود؟ |
| ۶.۱۱ | commit، سپس `graphify update .` | | |
| ۶.۱۲ | موارد به‌تعویق با دلیل و تسک مقصد | | سیاست بازگشت اعتبار → تسک ۱۳ |
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | ۵ مورد ⚠️ همه با دلیل |
| ۶.۲ | `bin/phpunit` کامل سبز | | ۱۲۶۷ تست |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | تست‌های کیف پول سبز ماندند | | |
| ۶.۵ | `phpstan` بدون خطای جدید | | ۱۴ = baseline |
| ۶.۶ | `npx tsc --noEmit` و تست‌های فرانت سبز | | ۶۳۰ تست (روی هاست؛ vitest داخل ddev اجرا نمی‌شود) |
| ۶.۷ | تست‌های tenant سبز | | |
| ۶.۸ | `docs/api/*` به‌روز | | |
| ۶.۹ | چک‌لیست UI کامل | | جز ۳.۱۱ |
| ۶.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | `patient_uuid` فیلد **اختیاری** تازه در `quote` است، پس قرارداد موجود نشکست؛ نمایش «مبلغ صفر» در `nobat724_front` دیده نشد — پکیج فعلاً فقط پنل‌محور است |
| ۶.۱۱ | commit، سپس `graphify update .` | | دو کامیت جدا |
| ۶.۱۲ | موارد به‌تعویق با دلیل | | سیاست بازگشت اعتبار → تسک ۱۳ · تست هم‌زمانی واقعی (۴.۴) · بازبینی چشمی (۳.۱۱) |
+55
View File
@@ -0,0 +1,55 @@
<?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 Version20260731072023 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 package_services (id INT AUTO_INCREMENT NOT NULL, package_id INT NOT NULL, service_item_id INT NOT NULL, INDEX IDX_97B37507F44CABFF (package_id), INDEX IDX_97B37507DDEB00C2 (service_item_id), UNIQUE INDEX uniq_pkg_service (package_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE packages (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(200) NOT NULL, session_count SMALLINT NOT NULL, price_rials BIGINT NOT NULL, validity_days SMALLINT 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, UNIQUE INDEX UNIQ_9BB5C0A7D17F50A6 (uuid), INDEX idx_packages_tenant (entity_type, entity_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE patient_packages (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, session_count SMALLINT NOT NULL, price_paid_rials BIGINT NOT NULL, purchased_at INT NOT NULL, valid_to INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, package_id INT NOT NULL, patient_record_id INT NOT NULL, payment_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_AC309802D17F50A6 (uuid), INDEX IDX_AC309802F44CABFF (package_id), INDEX IDX_AC309802EB76A733 (patient_record_id), INDEX IDX_AC3098024C3A3BB (payment_id), INDEX idx_pp_tenant (entity_type, entity_id, purchased_at), INDEX idx_pp_patient (patient_record_id, valid_to), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE session_credit_ledger (id BIGINT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, kind VARCHAR(15) NOT NULL, delta SMALLINT NOT NULL, reason VARCHAR(255) DEFAULT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, patient_package_id INT NOT NULL, appointment_id INT DEFAULT NULL, service_item_id INT DEFAULT NULL, created_by INT DEFAULT NULL, UNIQUE INDEX UNIQ_6B3C6BD6D17F50A6 (uuid), INDEX IDX_6B3C6BD6428C0D10 (patient_package_id), INDEX IDX_6B3C6BD6DDEB00C2 (service_item_id), INDEX IDX_6B3C6BD6DE12AB56 (created_by), INDEX idx_scl_package (patient_package_id, created_at), INDEX idx_scl_tenant (entity_type, entity_id, created_at), INDEX idx_scl_appt (appointment_id), UNIQUE INDEX uniq_scl_consume (appointment_id, kind), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE package_services ADD CONSTRAINT FK_97B37507F44CABFF FOREIGN KEY (package_id) REFERENCES packages (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE package_services ADD CONSTRAINT FK_97B37507DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE RESTRICT');
$this->addSql('ALTER TABLE patient_packages ADD CONSTRAINT FK_AC309802F44CABFF FOREIGN KEY (package_id) REFERENCES packages (id) ON DELETE RESTRICT');
$this->addSql('ALTER TABLE patient_packages ADD CONSTRAINT FK_AC309802EB76A733 FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE RESTRICT');
$this->addSql('ALTER TABLE patient_packages ADD CONSTRAINT FK_AC3098024C3A3BB FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT FK_6B3C6BD6428C0D10 FOREIGN KEY (patient_package_id) REFERENCES patient_packages (id) ON DELETE RESTRICT');
$this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT FK_6B3C6BD6E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT FK_6B3C6BD6DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE session_credit_ledger ADD CONSTRAINT FK_6B3C6BD6DE12AB56 FOREIGN KEY (created_by) REFERENCES users (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 package_services DROP FOREIGN KEY FK_97B37507F44CABFF');
$this->addSql('ALTER TABLE package_services DROP FOREIGN KEY FK_97B37507DDEB00C2');
$this->addSql('ALTER TABLE patient_packages DROP FOREIGN KEY FK_AC309802F44CABFF');
$this->addSql('ALTER TABLE patient_packages DROP FOREIGN KEY FK_AC309802EB76A733');
$this->addSql('ALTER TABLE patient_packages DROP FOREIGN KEY FK_AC3098024C3A3BB');
$this->addSql('ALTER TABLE session_credit_ledger DROP FOREIGN KEY FK_6B3C6BD6428C0D10');
$this->addSql('ALTER TABLE session_credit_ledger DROP FOREIGN KEY FK_6B3C6BD6E5B533F9');
$this->addSql('ALTER TABLE session_credit_ledger DROP FOREIGN KEY FK_6B3C6BD6DDEB00C2');
$this->addSql('ALTER TABLE session_credit_ledger DROP FOREIGN KEY FK_6B3C6BD6DE12AB56');
$this->addSql('DROP TABLE package_services');
$this->addSql('DROP TABLE packages');
$this->addSql('DROP TABLE patient_packages');
$this->addSql('DROP TABLE session_credit_ledger');
}
}
@@ -11,6 +11,7 @@ use App\Auth\Repository\UserRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Pricing\Entity\PriceSnapshot;
use App\Pricing\Service\PriceSnapshotService;
use App\Package\Service\PackageConsumptionService;
use App\Policy\Service\BookingPolicyGuard;
use App\Pricing\Service\PricingEngine;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
@@ -52,6 +53,7 @@ class BookingController extends BaseController
private readonly PriceSnapshotService $snapshots,
private readonly BranchResolver $branches,
private readonly BookingPolicyGuard $guard,
private readonly PackageConsumptionService $packages,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
@@ -258,6 +260,8 @@ class BookingController extends BaseController
$address,
$hold->getStartsAt(),
is_array($data['policy'] ?? null) ? $data['policy'] : [],
// پروندهٔ بیمار در همین محیط — پکیج کلینیک الف در کلینیک ب معنا ندارد.
$this->packages->patientRecordFor($appointment),
);
return $this->snapshots->record($appointment, $quote);
@@ -6,6 +6,8 @@ use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\AppointmentSegment;
use App\Appointment\Entity\Appointment;
use App\Package\Service\CreditLedgerService;
use App\Package\Service\PackageConsumptionService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
@@ -20,8 +22,10 @@ use Doctrine\ORM\EntityManagerInterface;
final class BookingService
{
public function __construct(
private readonly HoldService $holds,
private readonly EntityManagerInterface $em,
private readonly HoldService $holds,
private readonly PackageConsumptionService $packages,
private readonly CreditLedgerService $credits,
private readonly EntityManagerInterface $em,
) {}
/**
@@ -54,6 +58,10 @@ final class BookingService
$this->em->flush();
// مصرف اعتبار **اینجا**ست نه در پیش‌نمایش قیمت: تنها لحظه‌ای که نوبت واقعاً
// وجود دارد. کلید یکتای دفتر هم تضمین می‌کند اجرای دوباره جلسهٔ دوم نخورد.
$this->packages->consumeFor($appointment);
return $appointment;
}
@@ -96,6 +104,9 @@ final class BookingService
$this->holds->release($occupancies);
// ردیف `consume` **حذف نمی‌شود**؛ بازگشت یک ردیف تازه است تا تاریخچه بماند.
$this->credits->refund($appointment);
return count($occupancies);
}
@@ -0,0 +1,68 @@
<?php
namespace App\Package\Command;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Repository\PatientPackageRepository;
use App\Package\Service\CreditLedgerService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* ثبت ردیف `expiry` برای پکیج‌هایی که تاریخشان گذشته و هنوز مانده دارند.
*
* دفتر دست‌نخورده می‌ماند و تاریخچه کامل است: بیمار می‌تواند بپرسد «۳ جلسه‌ام چه شد؟»
* و جواب یک ردیف با تاریخ و دلیل است، نه سکوت.
*/
#[AsCommand(name: 'app:package:expire', description: 'Write expiry ledger rows for lapsed patient packages.')]
class ExpirePackagesCommand extends Command
{
public function __construct(
private readonly PatientPackageRepository $packages,
private readonly CreditLedgerService $ledger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report without writing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$expired = 0;
foreach ($this->packages->findExpiredSince(time()) as $package) {
$balance = $this->ledger->balance($package);
if ($balance <= 0) {
continue;
}
if (!$dryRun) {
$this->ledger->record(
$package,
SessionCreditLedger::KIND_EXPIRY,
-$balance,
reason: 'انقضای اعتبار پکیج',
);
}
$expired++;
}
$io->success(sprintf(
$dryRun ? '%d پکیج منقضی می‌شد.' : '%d پکیج منقضی شد.',
$expired,
));
return Command::SUCCESS;
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Package\Controller;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Package\Entity\Package;
use App\Package\Entity\PackageService;
use App\Package\Repository\PackageRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
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: 'Package')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class PackageController extends BaseController
{
public function __construct(
private readonly PackageRepository $packages,
private readonly ServiceItemRepository $items,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/v1/packages', name: 'package_index', methods: ['GET'])]
public function index(#[CurrentUser] User $user, Request $request): JsonResponse
{
[$entityType, $entityId] = $this->branches->pair($user);
$active = $request->query->has('active')
? $request->query->getBoolean('active')
: null;
return $this->success(array_map(
static fn (Package $p): array => $p->toArray(),
$this->packages->findForPair($entityType, $entityId, $active),
));
}
#[Route('/api/v1/packages', name: 'package_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['name'] ?? null) || trim($data['name']) === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام پکیج الزامی است', 422, 'name');
}
if (!is_numeric($data['session_count'] ?? null) || (int) $data['session_count'] < 1) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعداد جلسه باید حداقل ۱ باشد', 422, 'session_count');
}
$services = $this->resolveServices($user, $data['service_uuids'] ?? []);
// پکیجی که هیچ سرویسی را پوشش نمی‌دهد هرگز قابل مصرف نیست؛ ساختنش فقط
// یک تلهٔ خاموش برای اپراتور است.
if ($services === []) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پکیج باید حداقل یک سرویس داشته باشد', 422, 'service_uuids');
}
[$entityType, $entityId] = $this->branches->pair($user);
$package = new Package($entityType, $entityId, trim($data['name']), (int) $data['session_count']);
$this->apply($package, $data);
foreach ($services as $service) {
$this->em->persist(new PackageService($package, $service));
}
$this->packages->save($package);
return $this->success($package->toArray(), 201);
}
#[Route('/api/v1/package/{uuid}', name: 'package_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
return $this->success($this->requirePackage($user, $uuid)->toArray());
}
#[Route('/api/v1/package/{uuid}', name: 'package_update', methods: ['PATCH'])]
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$package = $this->requirePackage($user, $uuid);
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
if (is_string($data['name'] ?? null) && trim($data['name']) !== '') {
$package->setName(trim($data['name']));
}
if (is_numeric($data['session_count'] ?? null)) {
$package->setSessionCount((int) $data['session_count']);
}
$this->apply($package, $data);
if (isset($data['service_uuids'])) {
$services = $this->resolveServices($user, $data['service_uuids']);
if ($services === []) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پکیج باید حداقل یک سرویس داشته باشد', 422, 'service_uuids');
}
$package->getServices()->clear();
foreach ($services as $service) {
$this->em->persist(new PackageService($package, $service));
}
}
$this->packages->save($package);
return $this->success($package->toArray());
}
/**
* حذف = غیرفعال کردن.
*
* پکیجی که فروخته شده حذف‌شدنی نیست؛ ردیف‌های دفتر به آن ارجاع دارند و حذفش
* یعنی تاریخچهٔ اعتبار بیماران بی‌معنا شود.
*/
#[Route('/api/v1/package/{uuid}', name: 'package_delete', methods: ['DELETE'])]
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$package = $this->requirePackage($user, $uuid)->setActive(false);
$this->packages->save($package);
return $this->success($package->toArray());
}
/** @param array<string, mixed> $data */
private function apply(Package $package, array $data): void
{
if (is_numeric($data['price_rials'] ?? null)) {
$package->setPriceRials((int) $data['price_rials']);
}
if (array_key_exists('validity_days', $data)) {
$package->setValidityDays(is_numeric($data['validity_days']) ? (int) $data['validity_days'] : null);
}
if (isset($data['active'])) {
$package->setActive((bool) $data['active']);
}
}
/**
* @param mixed $uuids
* @return list<ServiceItem>
*/
private function resolveServices(User $user, mixed $uuids): array
{
if (!is_array($uuids)) {
return [];
}
[$entityType, $entityId] = $this->branches->pair($user);
$services = [];
foreach ($uuids as $uuid) {
if (!is_string($uuid)) {
continue;
}
$item = $this->items->findByUuid($uuid);
if ($item === null
|| $item->getSection()->getEntityType() !== $entityType
|| $item->getSection()->getEntityId() !== $entityId
) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404);
}
$services[(int) $item->getId()] = $item;
}
return array_values($services);
}
private function requirePackage(User $user, string $uuid): Package
{
$package = $this->packages->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
if ($package === null || !$this->ownership->belongsToPair($entityType, $entityId, $package)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
}
return $package;
}
}
@@ -0,0 +1,202 @@
<?php
namespace App\Package\Controller;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Package\Entity\PatientPackage;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Repository\PackageRepository;
use App\Package\Repository\PatientPackageRepository;
use App\Package\Service\CreditLedgerService;
use App\Package\Service\PackageSalesService;
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 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: 'Package')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class PatientPackageController extends BaseController
{
public function __construct(
private readonly PackageRepository $packages,
private readonly PatientPackageRepository $patientPackages,
private readonly PatientRecordRepository $patients,
private readonly PackageSalesService $sales,
private readonly CreditLedgerService $ledger,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
) {}
#[Route('/api/v1/patient/{uuid}/package', name: 'patient_package_sell', methods: ['POST'])]
public function sell(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$patient = $this->requirePatient($user, $uuid);
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['package_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ پکیج الزامی است', 422, 'package_uuid');
}
$package = $this->packages->findByUuid($data['package_uuid']);
[$entityType, $entityId] = $this->branches->pair($user);
if ($package === null || !$this->ownership->belongsToPair($entityType, $entityId, $package)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
}
$sold = $this->sales->sell(
$package,
$patient,
$user,
is_numeric($data['price_paid_rials'] ?? null) ? (int) $data['price_paid_rials'] : null,
);
return $this->success($sold->toArray($this->ledger->balance($sold)), 201);
}
#[Route('/api/v1/patient/{uuid}/packages', name: 'patient_package_index', methods: ['GET'])]
public function index(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$patient = $this->requirePatient($user, $uuid);
return $this->success(array_map(
fn (PatientPackage $p): array => $p->toArray($this->ledger->balance($p)),
$this->patientPackages->findForPatient($patient),
));
}
/**
* دفتر تراکنش‌ها با ماندهٔ تجمعی.
*
* ماندهٔ تجمعی اینجا محاسبه می‌شود نه ذخیره — و همین به کاربر نشان می‌دهد عدد
* از کجا آمده.
*/
#[Route('/api/v1/patient-package/{uuid}/ledger', name: 'patient_package_ledger', methods: ['GET'])]
public function ledger(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$package = $this->requirePatientPackage($user, $uuid);
$running = 0;
$rows = [];
foreach ($this->ledger->history($package) as $row) {
$running += $row->getDelta();
$rows[] = $row->toArray() + ['running_balance' => $running];
}
return $this->success([
'package' => $package->toArray($running),
'rows' => $rows,
]);
}
/** اصلاح دستی — فقط پزشک یا صاحب کلینیک، و همیشه با دلیل. */
#[Route('/api/v1/patient-package/{uuid}/adjust', name: 'patient_package_adjust', methods: ['POST'])]
public function adjust(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->assertMayCorrectCredit();
$package = $this->requirePatientPackage($user, $uuid);
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_numeric($data['delta'] ?? null) || (int) $data['delta'] === 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'مقدار اصلاح باید عددی غیر صفر باشد', 422, 'delta');
}
if (!is_string($data['reason'] ?? null) || trim($data['reason']) === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دلیل اصلاح الزامی است', 422, 'reason');
}
$delta = (int) $data['delta'];
// اصلاحی که مانده را منفی کند یعنی دفتر دروغ بگوید.
if ($this->ledger->balance($package) + $delta < 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مانده نمی‌تواند منفی شود', 422, 'delta');
}
$this->ledger->record(
$package,
SessionCreditLedger::KIND_ADJUSTMENT,
$delta,
reason: trim($data['reason']),
by: $user,
);
return $this->success($package->toArray($this->ledger->balance($package)), 201);
}
/** ابطال دستی — ماندهٔ باقی‌مانده با یک ردیف `expiry` صفر می‌شود. */
#[Route('/api/v1/patient-package/{uuid}/expire', name: 'patient_package_expire', methods: ['POST'])]
public function expire(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->assertMayCorrectCredit();
$package = $this->requirePatientPackage($user, $uuid);
$balance = $this->ledger->balance($package);
if ($balance <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پکیج ماندهٔ قابل ابطال ندارد', 422);
}
$data = json_decode($request->getContent(), true);
$reason = is_array($data) && is_string($data['reason'] ?? null) && trim($data['reason']) !== ''
? trim($data['reason'])
: 'ابطال دستی پکیج';
$this->ledger->record($package, SessionCreditLedger::KIND_EXPIRY, -$balance, reason: $reason, by: $user);
return $this->success($package->toArray($this->ledger->balance($package)));
}
/**
* اصلاح دستی اعتبار کارِ صاحب محیط است، نه منشی: ردیف `adjustment` تنها راهی است
* که می‌شود بدون نوبت، اعتبار ساخت.
*/
private function assertMayCorrectCredit(): void
{
foreach (['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_ADMIN'] as $role) {
if ($this->isGranted($role)) {
return;
}
}
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'اصلاح اعتبار در اختیار شما نیست', 403);
}
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 requirePatientPackage(User $user, string $uuid): PatientPackage
{
$package = $this->patientPackages->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
if ($package === null || !$this->ownership->belongsToPair($entityType, $entityId, $package)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج بیمار یافت نشد', 404);
}
return $package;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Package\Entity;
use App\Package\Repository\PackageRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* تعریف پکیج — «۶ جلسه لیزر فول‌بادی».
*
* خودِ این ردیف چیزی نمی‌فروشد؛ {@see PatientPackage} نمونهٔ خریداری‌شده است و
* تعداد و قیمت را از اینجا **کپی** می‌کند. تغییر تعریف فردا، پکیج فروخته‌شدهٔ دیروز را
* عوض نمی‌کند (قانون پنجم مستند).
*/
#[ORM\Entity(repositoryClass: PackageRepository::class)]
#[ORM\Table(name: 'packages')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_packages_tenant')]
class Package
{
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\Column(type: 'string', length: 200)]
private string $name;
#[ORM\Column(name: 'session_count', type: 'smallint')]
private int $sessionCount;
/** ریال در `bigint`: پکیج بزرگ از سقف `int` عبور می‌کند. */
#[ORM\Column(name: 'price_rials', type: 'bigint')]
private string|int $priceRials = 0;
/** `null` یعنی بی‌پایان. */
#[ORM\Column(name: 'validity_days', type: 'smallint', nullable: true)]
private ?int $validityDays = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
/** @var Collection<int, PackageService> */
#[ORM\OneToMany(targetEntity: PackageService::class, mappedBy: 'package', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $services;
#[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, string $name, int $sessionCount)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->sessionCount = max(1, $sessionCount);
$this->services = new ArrayCollection();
$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 getName(): string { return $this->name; }
public function getSessionCount(): int { return $this->sessionCount; }
public function getPriceRials(): int { return (int) $this->priceRials; }
public function getValidityDays(): ?int { return $this->validityDays; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
/** @return Collection<int, PackageService> */
public function getServices(): Collection { return $this->services; }
public function setName(string $v): self { $this->name = $v; return $this->touch(); }
public function setSessionCount(int $v): self { $this->sessionCount = max(1, $v); return $this->touch(); }
public function setPriceRials(int $v): self { $this->priceRials = max(0, $v); return $this->touch(); }
public function setValidityDays(?int $v): self { $this->validityDays = $v === null ? null : max(1, $v); return $this->touch(); }
public function setActive(bool $v): self { $this->active = $v; return $this->touch(); }
public function addService(PackageService $service): self
{
if (!$this->services->contains($service)) {
$this->services->add($service);
}
return $this;
}
/** تاریخ انقضای یک خرید در این لحظه — `null` یعنی بی‌پایان. */
public function expiryFor(int $purchasedAt): ?int
{
return $this->validityDays === null ? null : $purchasedAt + $this->validityDays * 86400;
}
/** @return list<int> شناسهٔ سرویس‌های پوشش‌داده‌شده */
public function serviceIds(): array
{
return array_values(array_map(
static fn (PackageService $s): int => (int) $s->getServiceItem()->getId(),
$this->services->toArray(),
));
}
private function touch(): self
{
$this->updatedAt = time();
return $this;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'name' => $this->name,
'session_count' => $this->sessionCount,
'price_rials' => (int) $this->priceRials,
'validity_days' => $this->validityDays,
'active' => $this->active,
'services' => array_values(array_map(
static fn (PackageService $s): array => [
'uuid' => $s->getServiceItem()->getUuid(),
'name' => $s->getServiceItem()->getName(),
],
$this->services->toArray(),
)),
'created_at' => $this->createdAt,
];
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Package\Entity;
use App\ClinicService\Entity\ServiceItem;
use Doctrine\ORM\Mapping as ORM;
/**
* سرویس‌هایی که یک پکیج پوشش می‌دهد.
*
* حذف سرویس `RESTRICT` است: سرویسی که در پکیجِ فروخته‌شده هست اگر برود، اعتبار
* بیمارانی که خریده‌اند بی‌معنا می‌شود.
*/
#[ORM\Entity]
#[ORM\Table(name: 'package_services')]
#[ORM\UniqueConstraint(name: 'uniq_pkg_service', columns: ['package_id', 'service_item_id'])]
class PackageService
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Package::class, inversedBy: 'services')]
#[ORM\JoinColumn(name: 'package_id', nullable: false, onDelete: 'CASCADE')]
private Package $package;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'RESTRICT')]
private ServiceItem $serviceItem;
public function __construct(Package $package, ServiceItem $serviceItem)
{
$this->package = $package;
$this->serviceItem = $serviceItem;
$package->addService($this);
}
public function getId(): ?int { return $this->id; }
public function getPackage(): Package { return $this->package; }
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Package\Entity;
use App\Package\Repository\PatientPackageRepository;
use App\Patient\Entity\PatientRecord;
use App\Payment\Entity\Payment;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* پکیجی که یک بیمار خریده.
*
* ⛔ **هیچ ستون مانده‌ای اینجا نیست و نباید باشد.** `sessionCount` فقط snapshotِ
* تعریف لحظهٔ خرید است؛ مانده همیشه از جمع ردیف‌های {@see SessionCreditLedger}
* می‌آید. مستند صریح است: «اگر فقط یک عدد نگه داریم، اولین اشتباه هرگز قابل
* ردیابی نیست.»
*/
#[ORM\Entity(repositoryClass: PatientPackageRepository::class)]
#[ORM\Table(name: 'patient_packages')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'purchased_at'], name: 'idx_pp_tenant')]
#[ORM\Index(columns: ['patient_record_id', 'valid_to'], name: 'idx_pp_patient')]
class PatientPackage
{
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: Package::class)]
#[ORM\JoinColumn(name: 'package_id', nullable: false, onDelete: 'RESTRICT')]
private Package $package;
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
#[ORM\JoinColumn(name: 'patient_record_id', nullable: false, onDelete: 'RESTRICT')]
private PatientRecord $patientRecord;
/** snapshot تعریف — نه مانده. */
#[ORM\Column(name: 'session_count', type: 'smallint')]
private int $sessionCount;
#[ORM\Column(name: 'price_paid_rials', type: 'bigint')]
private string|int $pricePaidRials;
#[ORM\ManyToOne(targetEntity: Payment::class)]
#[ORM\JoinColumn(name: 'payment_id', nullable: true, onDelete: 'SET NULL')]
private ?Payment $payment = null;
/** مبنای FIFO. */
#[ORM\Column(name: 'purchased_at', type: 'integer')]
private int $purchasedAt;
#[ORM\Column(name: 'valid_to', type: 'integer', nullable: true)]
private ?int $validTo = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Package $package, PatientRecord $patientRecord, ?int $purchasedAt = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->package = $package;
$this->patientRecord = $patientRecord;
$this->purchasedAt = $purchasedAt ?? time();
$this->sessionCount = $package->getSessionCount();
$this->pricePaidRials = $package->getPriceRials();
$this->validTo = $package->expiryFor($this->purchasedAt);
$this->createdAt = time();
$this->updatedAt = time();
$this->assignTenantPair($package->getEntityType(), $package->getEntityId());
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getPackage(): Package { return $this->package; }
public function getPatientRecord(): PatientRecord { return $this->patientRecord; }
public function getSessionCount(): int { return $this->sessionCount; }
public function getPricePaidRials(): int { return (int) $this->pricePaidRials; }
public function getPayment(): ?Payment { return $this->payment; }
public function getPurchasedAt(): int { return $this->purchasedAt; }
public function getValidTo(): ?int { return $this->validTo; }
public function setPricePaidRials(int $v): self { $this->pricePaidRials = max(0, $v); $this->updatedAt = time(); return $this; }
public function setPayment(?Payment $v): self { $this->payment = $v; $this->updatedAt = time(); return $this; }
public function isExpired(?int $at = null): bool
{
return $this->validTo !== null && $this->validTo < ($at ?? time());
}
/**
* @param int $balance مانده‌ای که فراخوان از دفتر گرفته — عمداً پارامتر است، نه
* چیزی که این کلاس خودش بداند
* @return array<string, mixed>
*/
public function toArray(int $balance): array
{
return [
'uuid' => $this->uuid,
'package_uuid' => $this->package->getUuid(),
'package_name' => $this->package->getName(),
'patient_uuid' => $this->patientRecord->getUuid(),
'session_count' => $this->sessionCount,
'price_paid_rials' => (int) $this->pricePaidRials,
'purchased_at' => $this->purchasedAt,
'valid_to' => $this->validTo,
'expired' => $this->isExpired(),
// مانده در نمایشِ پکیج منقضی صفر است، حتی اگر ردیف `expiry` هنوز ثبت
// نشده باشد؛ دفتر خودش دست‌نخورده می‌ماند.
'balance' => $this->isExpired() ? 0 : $balance,
];
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Package\Entity;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\Package\Repository\SessionCreditLedgerRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* دفتر اعتبار جلسات — **append-only**.
*
* ردیف‌ها هرگز حذف یا ویرایش نمی‌شوند؛ تصحیح یعنی ردیف تازه. مانده جمع `delta` هاست،
* پس هر عددی که کاربر می‌بیند یک تاریخچهٔ کامل پشتش دارد و «۳ جلسه‌ام چه شد؟» همیشه
* جواب دارد.
*
* `uniq_ledger_appointment_kind` مصرف دوباره را می‌بندد: `confirm` idempotent است و
* اجرای دومش نباید جلسهٔ دوم را بخورد.
*/
#[ORM\Entity(repositoryClass: SessionCreditLedgerRepository::class)]
#[ORM\Table(name: 'session_credit_ledger')]
#[ORM\Index(columns: ['patient_package_id', 'created_at'], name: 'idx_scl_package')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'created_at'], name: 'idx_scl_tenant')]
#[ORM\Index(columns: ['appointment_id'], name: 'idx_scl_appt')]
#[ORM\UniqueConstraint(name: 'uniq_scl_consume', columns: ['appointment_id', 'kind'])]
class SessionCreditLedger
{
use TenantOwnedTrait;
public const KIND_PURCHASE = 'purchase';
public const KIND_CONSUME = 'consume';
public const KIND_REFUND = 'refund';
public const KIND_ADJUSTMENT = 'adjustment';
public const KIND_EXPIRY = 'expiry';
public const KINDS = [
self::KIND_PURCHASE,
self::KIND_CONSUME,
self::KIND_REFUND,
self::KIND_ADJUSTMENT,
self::KIND_EXPIRY,
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'bigint')]
private ?string $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: PatientPackage::class)]
#[ORM\JoinColumn(name: 'patient_package_id', nullable: false, onDelete: 'RESTRICT')]
private PatientPackage $patientPackage;
#[ORM\Column(type: 'string', length: 15)]
private string $kind;
/** مثبت یا منفی — هرگز صفر: ردیفی که چیزی را عوض نمی‌کند فقط نویز است. */
#[ORM\Column(type: 'smallint')]
private int $delta;
#[ORM\ManyToOne(targetEntity: Appointment::class)]
#[ORM\JoinColumn(name: 'appointment_id', nullable: true, onDelete: 'SET NULL')]
private ?Appointment $appointment = null;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', nullable: true, onDelete: 'SET NULL')]
private ?ServiceItem $serviceItem = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $reason = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'created_by', nullable: true, onDelete: 'SET NULL')]
private ?User $createdBy = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(
PatientPackage $patientPackage,
string $kind,
int $delta,
?Appointment $appointment = null,
?ServiceItem $serviceItem = null,
?string $reason = null,
?User $createdBy = null,
) {
if (!in_array($kind, self::KINDS, true)) {
throw new \InvalidArgumentException(sprintf('Unknown ledger kind "%s".', $kind));
}
if ($delta === 0) {
throw new \InvalidArgumentException('A ledger row with a zero delta changes nothing.');
}
$this->uuid = Uuid::v4()->toRfc4122();
$this->patientPackage = $patientPackage;
$this->kind = $kind;
$this->delta = $delta;
$this->appointment = $appointment;
$this->serviceItem = $serviceItem;
$this->reason = $reason;
$this->createdBy = $createdBy;
$this->createdAt = time();
$this->assignTenantPair($patientPackage->getEntityType(), $patientPackage->getEntityId());
}
public function getId(): ?string { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getPatientPackage(): PatientPackage { return $this->patientPackage; }
public function getKind(): string { return $this->kind; }
public function getDelta(): int { return $this->delta; }
public function getAppointment(): ?Appointment { return $this->appointment; }
public function getServiceItem(): ?ServiceItem { return $this->serviceItem; }
public function getReason(): ?string { return $this->reason; }
public function getCreatedBy(): ?User { return $this->createdBy; }
public function getCreatedAt(): int { return $this->createdAt; }
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'kind' => $this->kind,
'delta' => $this->delta,
'appointment_uuid' => $this->appointment?->getUuid(),
'service_uuid' => $this->serviceItem?->getUuid(),
'service_name' => $this->serviceItem?->getName(),
'reason' => $this->reason,
'created_by' => $this->createdBy?->getUuid(),
'created_at' => $this->createdAt,
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Package\Repository;
use App\Package\Entity\Package;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Package> */
class PackageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Package::class);
}
public function findByUuid(string $uuid): ?Package
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return Package[] */
public function findForPair(string $entityType, int $entityId, ?bool $active = null): array
{
$qb = $this->createQueryBuilder('p')
->addSelect('s', 'i')
->leftJoin('p.services', 's')
->leftJoin('s.serviceItem', 'i')
->where('p.entityType = :type')
->andWhere('p.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('p.createdAt', 'DESC');
if ($active !== null) {
$qb->andWhere('p.active = :active')->setParameter('active', $active);
}
return $qb->getQuery()->getResult();
}
public function save(Package $package, bool $flush = true): void
{
$this->getEntityManager()->persist($package);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Package\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Package\Entity\PatientPackage;
use App\Patient\Entity\PatientRecord;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<PatientPackage> */
class PatientPackageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PatientPackage::class);
}
public function findByUuid(string $uuid): ?PatientPackage
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return PatientPackage[] جدیدترین خرید اول */
public function findForPatient(PatientRecord $patient): array
{
return $this->createQueryBuilder('pp')
->addSelect('p')
->join('pp.package', 'p')
->where('pp.patientRecord = :patient')
->setParameter('patient', $patient)
->orderBy('pp.purchasedAt', 'DESC')
->getQuery()
->getResult();
}
/**
* پکیج‌های معتبرِ این بیمار که سرویس داده‌شده را پوشش می‌دهند — **قدیمی‌ترین اول**.
*
* FIFO عمدی است: پکیج قدیمی‌تر به انقضا نزدیک‌تر است، و مصرف نکردنش یعنی بیمار
* پولش را از دست بدهد.
*
* @return PatientPackage[]
*/
public function findUsable(PatientRecord $patient, ServiceItem $service, int $at): array
{
return $this->createQueryBuilder('pp')
->join('pp.package', 'p')
->join('p.services', 'ps')
->where('pp.patientRecord = :patient')
->andWhere('ps.serviceItem = :service')
->andWhere('pp.validTo IS NULL OR pp.validTo >= :now')
->setParameter('patient', $patient)
->setParameter('service', $service)
->setParameter('now', $at)
->orderBy('pp.purchasedAt', 'ASC')
->addOrderBy('pp.id', 'ASC')
->getQuery()
->getResult();
}
/** @return PatientPackage[] پکیج‌هایی که تاریخشان گذشته */
public function findExpiredSince(int $now): array
{
return $this->createQueryBuilder('pp')
->where('pp.validTo IS NOT NULL')
->andWhere('pp.validTo < :now')
->setParameter('now', $now)
->getQuery()
->getResult();
}
public function save(PatientPackage $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Package\Repository;
use App\Appointment\Entity\Appointment;
use App\Package\Entity\PatientPackage;
use App\Package\Entity\SessionCreditLedger;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<SessionCreditLedger> */
class SessionCreditLedgerRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SessionCreditLedger::class);
}
/** مانده = جمع همهٔ delta ها. هیچ ستون ذخیره‌شده‌ای وجود ندارد. */
public function sumDelta(PatientPackage $package): int
{
return (int) $this->createQueryBuilder('l')
->select('COALESCE(SUM(l.delta), 0)')
->where('l.patientPackage = :package')
->setParameter('package', $package)
->getQuery()
->getSingleScalarResult();
}
/** @return SessionCreditLedger[] قدیمی‌ترین اول — دفتر به ترتیب زمان خوانده می‌شود */
public function historyFor(PatientPackage $package): array
{
return $this->createQueryBuilder('l')
->where('l.patientPackage = :package')
->setParameter('package', $package)
->orderBy('l.createdAt', 'ASC')
->addOrderBy('l.id', 'ASC')
->getQuery()
->getResult();
}
public function findForAppointment(Appointment $appointment, string $kind): ?SessionCreditLedger
{
return $this->findOneBy(['appointment' => $appointment, 'kind' => $kind]);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Package\Service;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\Package\Entity\PatientPackage;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Repository\SessionCreditLedgerRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\DBAL\LockMode;
/**
* تنها نویسندهٔ دفتر اعتبار.
*
* هیچ کلاس دیگری نباید در `session_credit_ledger` بنویسد؛ اگر بنویسد، قواعد این کلاس
* (مصرف یکتا per نوبت، مانده‌ای که منفی نمی‌شود) دور زده می‌شوند و دفتر همان چیزی
* می‌شود که قرار بود نباشد: عددی که کسی نمی‌داند از کجا آمده.
*/
final class CreditLedgerService
{
public function __construct(
private readonly SessionCreditLedgerRepository $ledger,
private readonly EntityManagerInterface $em,
) {}
/** مانده = جمع delta ها. هیچ ستون ذخیره‌شده‌ای نیست. */
public function balance(PatientPackage $package): int
{
return $this->ledger->sumDelta($package);
}
public function record(
PatientPackage $package,
string $kind,
int $delta,
?Appointment $appointment = null,
?ServiceItem $service = null,
?string $reason = null,
?User $by = null,
bool $flush = true,
): SessionCreditLedger {
$row = new SessionCreditLedger($package, $kind, $delta, $appointment, $service, $reason, $by);
$this->em->persist($row);
if ($flush) {
$this->em->flush();
}
return $row;
}
/**
* مصرف یک جلسه — `false` یعنی «اعتباری نبود»، نه خطا.
*
* بیمار بدون اعتبار باید بتواند نقدی بپردازد؛ استثنا پرتاب کردن اینجا یعنی
* رزروِ کاملاً معتبر شکست بخورد.
*
* قفل بدبینانه روی همان یک ردیف پکیج است. برخلاف اسلات‌های تسک ۰۷ — که نرخ رقابت
* بالا و ده‌ها ردیف درگیر دارند — اینجا یک بیمار و یک پکیج است، پس هزینهٔ قفل
* ناچیز و سادگی‌اش برنده است.
*/
public function consume(PatientPackage $package, Appointment $appointment, ?ServiceItem $service = null): bool
{
// قفل بدون تراکنش معنا ندارد؛ خواندن و نوشتن باید در یک واحد اتمی باشند
// وگرنه دو درخواست هم‌زمان هر دو ماندهٔ ۱ را می‌بینند.
return $this->em->wrapInTransaction(function () use ($package, $appointment, $service): bool {
$locked = $this->em->find(PatientPackage::class, $package->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked === null || $locked->isExpired()) {
return false;
}
// همین نوبت قبلاً مصرف کرده؟ `confirm` idempotent است و اجرای دومش نباید
// جلسهٔ دوم بخورد. بررسی **پیش از** درج است نه گرفتنِ استثنا: نقض کلید
// یکتا در Doctrine خودِ EntityManager را می‌بندد و بقیهٔ همان request را
// هم می‌سوزاند. کلید یکتا آخرین خط دفاع می‌ماند، نه مسیر عادی.
if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME) !== null) {
return true;
}
if ($this->balance($locked) <= 0) {
return false;
}
$this->record($locked, SessionCreditLedger::KIND_CONSUME, -1, $appointment, $service);
return true;
});
}
/**
* بازگشت اعتبار هنگام لغو — ردیف `consume` **حذف نمی‌شود**.
*
* فعلاً هر لغوی اعتبار را کامل برمی‌گرداند. سیاست واقعی (لغو دیرهنگام، جریمه،
* عدم‌حضور) کارِ تسک ۱۳ است و همان‌جا این متد یک پارامتر سیاست می‌گیرد؛ پرچم
* نیم‌کاره اینجا فقط رفتاری می‌ساخت که هیچ‌کس تنظیمش نمی‌کند.
*
* @return bool `false` یعنی این نوبت اصلاً از پکیج مصرف نکرده بود
*/
public function refund(Appointment $appointment, ?User $by = null): bool
{
$consumed = $this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME);
if ($consumed === null) {
return false;
}
if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_REFUND) !== null) {
return false;
}
$this->record(
$consumed->getPatientPackage(),
SessionCreditLedger::KIND_REFUND,
-$consumed->getDelta(),
$appointment,
$consumed->getServiceItem(),
'بازگشت اعتبار با لغو نوبت',
$by,
);
return true;
}
/** @return SessionCreditLedger[] */
public function history(PatientPackage $package): array
{
return $this->ledger->historyFor($package);
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Package\Service;
use App\Appointment\Entity\Appointment;
use App\ClinicService\Entity\ServiceItem;
use App\Package\Entity\PatientPackage;
use App\Package\Repository\PatientPackageRepository;
use App\Patient\Entity\PatientRecord;
use App\Patient\Repository\PatientRecordRepository;
/**
* پیدا کردن پکیج قابل استفاده و مصرفش هنگام ثبت نوبت.
*
* ⚠️ تفکیک حیاتی: `quote` هیچ‌وقت مصرف نمی‌کند، فقط **می‌گوید** که مصرف خواهد شد.
* اگر پیش‌نمایش مصرف می‌کرد، هر رفرش صفحه یک جلسه از بیمار می‌گرفت.
*/
final class PackageConsumptionService
{
public function __construct(
private readonly PatientPackageRepository $patientPackages,
private readonly PatientRecordRepository $patients,
private readonly CreditLedgerService $ledger,
) {}
/** قدیمی‌ترین پکیج معتبر با ماندهٔ مثبت (FIFO). */
public function firstUsable(PatientRecord $patient, ServiceItem $service, ?int $at = null): ?PatientPackage
{
$at = $at ?? time();
foreach ($this->patientPackages->findUsable($patient, $service, $at) as $candidate) {
if ($this->ledger->balance($candidate) > 0) {
return $candidate;
}
}
return null;
}
/**
* پروندهٔ بیمار در همین محیط — پکیج کلینیک الف در کلینیک ب معنا ندارد.
*/
public function patientRecordFor(Appointment $appointment): ?PatientRecord
{
return $this->patients->findOneBy([
'user' => $appointment->getUser(),
'entityType' => $appointment->getEntityType(),
'entityId' => $appointment->getEntityId(),
]);
}
/**
* مصرف واقعی هنگام ثبت نهایی.
*
* @return bool `true` یعنی یک جلسه کسر شد
*/
public function consumeFor(Appointment $appointment): bool
{
$service = $appointment->getServiceItem();
$patient = $service === null ? null : $this->patientRecordFor($appointment);
if ($service === null || $patient === null) {
return false;
}
$package = $this->firstUsable($patient, $service, $appointment->getSlotStart());
if ($package === null) {
return false;
}
return $this->ledger->consume($package, $appointment, $service);
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Package\Service;
use App\Auth\Entity\User;
use App\Package\Entity\Package;
use App\Package\Entity\PatientPackage;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Repository\PatientPackageRepository;
use App\Patient\Entity\PatientRecord;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* فروش پکیج به بیمار.
*
* خرید و ردیف `purchase` یک عمل‌اند: پکیجی که بدون ردیف دفتر ثبت شود مانده‌اش صفر
* است و بیمار پولش را داده ولی چیزی نگرفته.
*/
final class PackageSalesService
{
public function __construct(
private readonly PatientPackageRepository $patientPackages,
private readonly CreditLedgerService $ledger,
) {}
public function sell(Package $package, PatientRecord $patient, ?User $by = null, ?int $pricePaid = null): PatientPackage
{
if (!$package->isActive()) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این پکیج غیرفعال است', 422, 'package_uuid');
}
if ($package->getServices()->isEmpty()) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'پکیج بدون سرویس قابل فروش نیست', 422, 'services');
}
if ($patient->getEntityType() !== $package->getEntityType() || $patient->getEntityId() !== $package->getEntityId()) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404);
}
$sold = new PatientPackage($package, $patient);
if ($pricePaid !== null) {
$sold->setPricePaidRials($pricePaid);
}
$this->patientPackages->save($sold);
$this->ledger->record(
$sold,
SessionCreditLedger::KIND_PURCHASE,
$sold->getSessionCount(),
reason: sprintf('خرید پکیج «%s»', $package->getName()),
by: $by,
);
return $sold;
}
}
+23 -1
View File
@@ -12,6 +12,7 @@ use App\Pricing\Entity\PriceListItem;
use App\Pricing\Repository\PriceListItemRepository;
use App\Pricing\Repository\PriceListRepository;
use App\Pricing\Repository\PriceSnapshotRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Pricing\Service\PricingEngine;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
@@ -35,6 +36,7 @@ class PricingController extends BaseController
private readonly PriceSnapshotRepository $snapshots,
private readonly ServiceItemRepository $items,
private readonly PricingEngine $engine,
private readonly PatientRecordRepository $patients,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
@@ -210,7 +212,27 @@ class PricingController extends BaseController
$at = is_numeric($data['at'] ?? null) ? (int) $data['at'] : time();
$policy = is_array($data['policy'] ?? null) ? $data['policy'] : [];
return $this->success($this->engine->quote($service, $items, $address, $at, $policy)->toArray());
// بیمار اختیاری است: بدون او پکیج معنا ندارد و قیمت همان قیمت کامل است.
$patient = is_string($data['patient_uuid'] ?? null)
? $this->requirePatient($user, $data['patient_uuid'])
: null;
return $this->success($this->engine->quote($service, $items, $address, $at, $policy, $patient)->toArray());
}
private function requirePatient(User $user, string $uuid): \App\Patient\Entity\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;
}
/**
+25
View File
@@ -6,6 +6,8 @@ use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
use App\ClinicService\Repository\TariffRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Package\Service\PackageConsumptionService;
use App\Patient\Entity\PatientRecord;
use App\Pricing\Repository\PriceListItemRepository;
use App\Pricing\Repository\PriceListRepository;
use App\Pricing\ValueObject\PriceQuote;
@@ -37,6 +39,7 @@ final class PricingEngine
{
public function __construct(
private readonly PolicyResolver $policies,
private readonly PackageConsumptionService $packages,
private readonly PriceListRepository $priceLists,
private readonly PriceListItemRepository $priceListItems,
private readonly ServiceBranchOverrideRepository $overrides,
@@ -59,6 +62,7 @@ final class PricingEngine
DoctorAddress $address,
int $at,
array $policy = [],
?PatientRecord $patient = null,
): PriceQuote {
$entityType = $address->tenantEntityType();
$entityId = $address->tenantEntityId();
@@ -76,6 +80,17 @@ final class PricingEngine
$subtotal = $base + $itemsTotal;
// ── پکیج ──────────────────────────────────────────────────────────────
// پکیج **قیمت پایهٔ سرویس** را می‌پوشاند، نه آیتم‌های اضافه: «شش جلسه لیزر»
// یعنی شش بار خودِ لیزر، نه هر چیزی که کنارش انتخاب شود.
$usable = $patient === null ? null : $this->packages->firstUsable($patient, $service, $at);
$covered = 0;
if ($usable !== null) {
$covered = min($base, $subtotal);
$subtotal -= $covered;
}
// ── تخفیف ─────────────────────────────────────────────────────────────
// قوانین دستهٔ «قیمت» کنار سیاست دستیِ درخواست می‌نشینند، نه به‌جایش: تخفیفی
// که اپراتور دستی می‌دهد و تخفیفی که قانون می‌دهد هر دو واقعی‌اند.
@@ -110,6 +125,14 @@ final class PricingEngine
$deposit = max(0, min($deposit, $final));
if ($covered > 0) {
$discounts[] = [
'label' => sprintf('پوشش پکیج «%s»', $usable?->getPackage()->getName() ?? '—'),
'rials' => $covered,
'kind' => 'package',
];
}
return new PriceQuote(
baseRials: $base,
itemsRials: $itemsTotal,
@@ -121,6 +144,8 @@ final class PricingEngine
depositRials: $deposit,
discounts: $discounts,
sources: $sources,
packageWillBeConsumed: $usable !== null,
packageUuid: $usable?->getUuid(),
);
}
+8
View File
@@ -22,6 +22,12 @@ final readonly class PriceQuote
public int $depositRials,
public array $discounts = [],
public array $sources = [],
/**
* پکیج در پیش‌نمایش **مصرف نمی‌شود** — فقط اعلام می‌شود. مصرف واقعی هنگام
* ثبت نهایی است، وگرنه هر رفرش صفحه یک جلسه از بیمار می‌گرفت.
*/
public bool $packageWillBeConsumed = false,
public ?string $packageUuid = null,
) {}
public function breakdown(): array
@@ -40,6 +46,8 @@ final readonly class PriceQuote
'tax_rials' => $this->taxRials,
'final_rials' => $this->finalRials,
'deposit_rials' => $this->depositRials,
'package_will_be_consumed' => $this->packageWillBeConsumed,
'package_uuid' => $this->packageUuid,
'breakdown' => $this->breakdown(),
];
}
+3
View File
@@ -118,6 +118,9 @@ final class GlobalTables
\App\Inventory\Entity\InventoryPackageItem::class => \App\Inventory\Entity\InventoryPackage::class,
// سرویس‌های یک پکیج جزئی از تعریف همان پکیج‌اند، نه دادهٔ مستقل.
\App\Package\Entity\PackageService::class => \App\Package\Entity\Package::class,
\App\Insurance\Entity\TenantInsuranceCategoryCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
\App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Tests\Package;
use App\Clinic\Entity\Clinic; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase;
use PHPUnit\Framework\Attributes\Group;
#[Group('docs')]
class PackageDocsCaptureTest extends ApiTestCase {
public function testCapture(): void {
if (getenv('PKG_DOCS') !== '1') { self::markTestSkipped('برای تولید خروجی مستندات: PKG_DOCS=1'); }
$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);
$pu = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int)$clinic->getId(), $pu, 'clinic', (int)$clinic->getId());
$this->em->persist($patient); $this->em->flush();
$item = new ServiceItem($section, 'لیزر فول‌بادی'); $item->setSoloDurationMinutes(20); $item->setPriceRials(5000000);
$this->em->persist($item); $this->em->flush();
$d = function(string $l, mixed $b): void { fwrite(STDERR, sprintf("\n===%s %d===\n%s\n", $l, $this->responseCode(), json_encode($b, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT))); };
$c = $this->authJson('POST','/api/v1/packages',$user,['name'=>'۶ جلسه لیزر فول‌بادی','session_count'=>6,'price_rials'=>25000000,'validity_days'=>365,'service_uuids'=>[$item->getUuid()]]);
$d('CREATE', $c);
$d('INDEX', $this->authJson('GET','/api/v1/packages',$user));
$s = $this->authJson('POST',"/api/v1/patient/{$patient->getUuid()}/package",$user,['package_uuid'=>$c['data']['uuid']]);
$d('SELL', $s);
$d('PATIENT_PACKAGES', $this->authJson('GET',"/api/v1/patient/{$patient->getUuid()}/packages",$user));
$this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1,'reason'=>'جبران جلسهٔ لغوشده']);
$d('LEDGER', $this->authJson('GET',"/api/v1/patient-package/{$s['data']['uuid']}/ledger",$user));
$d('ADJUST_NO_REASON', $this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1]));
$d('QUOTE', $this->authJson('POST','/api/v1/pricing/quote',$user,['service_uuid'=>$item->getUuid(),'branch_uuid'=>$address->getUuid(),'patient_uuid'=>$patient->getUuid()]));
self::assertTrue(true);
}
}
+475
View File
@@ -0,0 +1,475 @@
<?php
namespace App\Tests\Package;
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\Package\Entity\SessionCreditLedger;
use App\Package\Repository\PatientPackageRepository;
use App\Package\Service\CreditLedgerService;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* پکیج و دفتر اعتبار جلسات — تسک ۱۱.
*
* محور همهٔ تست‌ها یک جمله از مستند است: «اعتبار را به صورت دفتر حساب نگه می‌داریم،
* نه یک عدد شمارنده.» پس مانده هیچ‌جا ذخیره نمی‌شود و هر تغییر یک ردیف است.
*/
class PackageLedgerTest 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, int $price = 5_000_000): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes(20);
$item->setPriceRials($price);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $extra */
private function definePackage(User $user, ServiceItem $service, int $sessions = 6, array $extra = []): array
{
$body = $this->authJson('POST', '/api/v1/packages', $user, $extra + [
'name' => '۶ جلسه لیزر فول‌بادی',
'session_count' => $sessions,
'price_rials' => 25_000_000,
'service_uuids' => [$service->getUuid()],
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
private function sell(User $user, PatientRecord $patient, string $packageUuid): array
{
$body = $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [
'package_uuid' => $packageUuid,
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
/**
* هر درخواست HTTP کرنل را ری‌بوت می‌کند و EntityManager تازه می‌شود، پس entity های
* قبلی detached اند و باید دوباره خوانده شوند.
*/
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): Appointment
{
// یک اسلات یکتا per فراخوانی: پزشک کلید یکتای (doctor, slot_start) دارد.
$start = time() + 86400 + (++$this->slotCursor) * 3600;
$doctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
$patient = $this->em->getRepository(PatientRecord::class)->find($patient->getId());
$service = $this->em->getRepository(ServiceItem::class)->find($service->getId());
$appointment = new Appointment($doctor, $patient->getUser(), $start, $start + 1200);
$appointment->assignTenantPair('clinic', $clinicId);
$appointment->setServiceItem($service);
$appointment->setPatientName('بیمار پکیج');
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
private function ledgerService(): CreditLedgerService
{
return static::getContainer()->get(CreditLedgerService::class);
}
/**
* سرویس‌های کانتینر با EntityManager خودشان کار می‌کنند؛ entity ساخته‌شده در تست
* باید از همان EM دوباره خوانده شود وگرنه «موجودیت جدیدِ persist نشده» می‌شود.
*/
private function reload(Appointment $appointment): Appointment
{
return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
->getRepository(Appointment::class)
->find($appointment->getId());
}
private function consumption(): \App\Package\Service\PackageConsumptionService
{
return static::getContainer()->get(\App\Package\Service\PackageConsumptionService::class);
}
// ── تعریف و فروش ────────────────────────────────────────────────────────
public function testSellingAPackageOpensTheLedgerWithItsSessionCount(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر فول‌بادی');
$package = $this->definePackage($user, $service);
$sold = $this->sell($user, $patient, $package['uuid']);
self::assertSame(6, $sold['balance']);
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
self::assertSame(6, $list['data'][0]['balance']);
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
self::assertCount(1, $ledger['data']['rows']);
self::assertSame('purchase', $ledger['data']['rows'][0]['kind']);
self::assertSame(6, $ledger['data']['rows'][0]['delta']);
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
}
/** پکیجی که هیچ سرویسی را پوشش نمی‌دهد هرگز قابل مصرف نیست. */
public function testAPackageWithoutServicesIsRejected(): void
{
[$user] = $this->clinicWithPatient();
$this->authJson('POST', '/api/v1/packages', $user, [
'name' => 'پکیج بی‌سرویس',
'session_count' => 3,
'price_rials' => 1_000_000,
'service_uuids' => [],
]);
self::assertSame(422, $this->responseCode());
}
/** ⭐ مانده باید محاسبه شود، نه ذخیره — همین جلوی «بهینه‌سازی» شش ماه بعد را می‌گیرد. */
public function testNoStoredBalanceColumnExists(): void
{
$columns = $this->em->getConnection()
->createSchemaManager()
->listTableColumns('patient_packages');
$names = array_map(static fn ($c): string => strtolower($c->getName()), $columns);
foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) {
self::assertNotContains($forbidden, $names, 'مانده باید از دفتر محاسبه شود، نه ذخیره');
}
}
// ── مصرف و بازگشت ───────────────────────────────────────────────────────
public function testConsumingLeavesARowAndCancellingAddsAnotherOne(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$package = $this->definePackage($user, $service);
$sold = $this->sell($user, $patient, $package['uuid']);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
$appointment = $this->reload($appointment);
self::assertTrue($this->consumption()->consumeFor($appointment));
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(5, $this->ledgerService()->balance($entity));
self::assertTrue($this->ledgerService()->refund($appointment));
self::assertSame(6, $this->ledgerService()->balance($entity));
// ردیف `consume` **حذف نمی‌شود** — دفتر append-only است.
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$kinds = array_column($ledger['data']['rows'], 'kind');
self::assertSame(['purchase', 'consume', 'refund'], $kinds);
self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance'));
}
/** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */
public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
$appointment = $this->reload($appointment);
$this->consumption()->consumeFor($appointment);
$this->consumption()->consumeFor($appointment);
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(5, $this->ledgerService()->balance($entity));
}
/** ماندهٔ صفر خطا نیست: بیمار نقدی می‌پردازد. */
public function testAnEmptyPackageIsSimplyNotApplied(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service, 1)['uuid']);
$first = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
$second = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
self::assertTrue($this->consumption()->consumeFor($first));
self::assertFalse($this->consumption()->consumeFor($second), 'ماندهٔ صفر باید بی‌سروصدا رد شود');
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(0, $this->ledgerService()->balance($entity), 'مانده هرگز منفی نمی‌شود');
}
// ── قیمت ────────────────────────────────────────────────────────────────
public function testQuoteAnnouncesThePackageWithoutConsumingIt(): void
{
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر', 5_000_000);
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
'patient_uuid' => $patient->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
self::assertTrue($quote['data']['package_will_be_consumed']);
self::assertSame(0, $quote['data']['final_rials']);
// پیش‌نمایش هرگز مصرف نمی‌کند؛ وگرنه هر رفرش یک جلسه می‌خورد.
$this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
'patient_uuid' => $patient->getUuid(),
]);
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(6, $this->ledgerService()->balance($entity));
}
public function testQuoteWithoutAPatientChargesTheFullPrice(): void
{
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر', 5_000_000);
$this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
self::assertFalse($quote['data']['package_will_be_consumed']);
self::assertSame(5_000_000, $quote['data']['final_rials']);
}
// ── FIFO و انقضا ────────────────────────────────────────────────────────
/** قدیمی‌ترین اول، چون به انقضا نزدیک‌تر است. */
public function testTheOldestUnexpiredPackageIsUsedFirst(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$definition = $this->definePackage($user, $service);
$older = $this->sell($user, $patient, $definition['uuid']);
$newer = $this->sell($user, $patient, $definition['uuid']);
$repo = static::getContainer()->get(PatientPackageRepository::class);
$olderE = $repo->findByUuid($older['uuid']);
// خرید دوم را عمداً تازه‌تر می‌کنیم تا ترتیب قطعی باشد.
$this->em->getConnection()->executeStatement(
'UPDATE patient_packages SET purchased_at = purchased_at + 100 WHERE uuid = ?',
[$newer['uuid']],
);
$this->em->clear();
$chosen = $this->consumption()->firstUsable(
$this->em->getRepository(PatientRecord::class)->find($patient->getId()),
$this->em->getRepository(ServiceItem::class)->find($service->getId()),
);
self::assertSame($olderE->getUuid(), $chosen?->getUuid());
}
public function testAnExpiredPackageShowsZeroBalanceButKeepsItsLedger(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->em->getConnection()->executeStatement(
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
[time() - 86400, $sold['uuid']],
);
$this->em->clear();
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
self::assertTrue($list['data'][0]['expired']);
self::assertSame(0, $list['data'][0]['balance']);
// دفتر دست‌نخورده است: «۶ جلسه‌ام چه شد؟» هنوز جواب دارد.
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
}
public function testExpiryCommandWritesTheClosingRow(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->em->getConnection()->executeStatement(
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
[time() - 86400, $sold['uuid']],
);
$this->em->clear();
$command = static::getContainer()->get(\App\Package\Command\ExpirePackagesCommand::class);
$tester = new \Symfony\Component\Console\Tester\CommandTester($command);
$tester->execute([]);
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$kinds = array_column($ledger['data']['rows'], 'kind');
self::assertSame(['purchase', 'expiry'], $kinds);
self::assertSame(-6, $ledger['data']['rows'][1]['delta']);
self::assertSame(0, $ledger['data']['rows'][1]['running_balance']);
}
// ── اصلاح دستی و جداسازی محیط ───────────────────────────────────────────
public function testAdjustmentNeedsAReasonAndIsRecorded(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, ['delta' => 1]);
self::assertSame(422, $this->responseCode(), 'اصلاح بدون دلیل نباید پذیرفته شود');
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
'delta' => 2,
'reason' => 'جبران جلسهٔ لغوشده توسط کلینیک',
]);
self::assertSame(201, $this->responseCode());
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$row = $ledger['data']['rows'][1];
self::assertSame('adjustment', $row['kind']);
self::assertSame(2, $row['delta']);
self::assertSame('جبران جلسهٔ لغوشده توسط کلینیک', $row['reason']);
self::assertNotNull($row['created_by']);
self::assertSame(8, $row['running_balance']);
}
public function testAdjustmentCannotDriveTheBalanceNegative(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
'delta' => -10,
'reason' => 'اشتباه اپراتور',
]);
self::assertSame(422, $this->responseCode());
}
public function testAnotherClinicCannotSeeOrTouchThePackage(): void
{
[$owner, $section, , , $patient] = $this->clinicWithPatient();
[$other] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
$this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $other);
self::assertSame(404, $this->responseCode());
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $other, [
'delta' => 5,
'reason' => 'تلاش از محیط دیگر',
]);
self::assertSame(404, $this->responseCode());
}
/** منشی نباید بتواند اعتبار را دستی عوض کند. */
public function testASecretaryCannotAdjustTheLedger(): void
{
[$owner, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
$secretary = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $secretary, [
'delta' => 5,
'reason' => 'تلاش منشی',
]);
self::assertContains($this->responseCode(), [403, 404]);
}
public function testLedgerRowsNeverHaveAZeroDelta(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
'delta' => 0,
'reason' => 'بی‌اثر',
]);
self::assertSame(422, $this->responseCode());
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::expectException(\InvalidArgumentException::class);
new SessionCreditLedger($entity, SessionCreditLedger::KIND_ADJUSTMENT, 0);
}
}