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:
@@ -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'] },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 نباید
|
||||
باشد. تست عجیبی به نظر میرسد ولی همان چیزی است که شش ماه بعد جلوی «بهینهسازی»
|
||||
میایستد.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 .` | ✅ | دو کامیت جدا |
|
||||
| ۶.۱۲ | موارد بهتعویق با دلیل | ✅ | سیاست بازگشت اعتبار → تسک ۱۳ · تست همزمانی واقعی (۴.۴) · بازبینی چشمی (۳.۱۱) |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user