diff --git a/assets/admin/components/TreatmentCaseEditModal.tsx b/assets/admin/components/TreatmentCaseEditModal.tsx new file mode 100644 index 00000000..bcfeb4e2 --- /dev/null +++ b/assets/admin/components/TreatmentCaseEditModal.tsx @@ -0,0 +1,227 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import Modal from './ui/Modal'; +import Input from './ui/Input'; +import SearchableSelect from './ui/SearchableSelect'; +import { formatNumber } from '../lib/utils'; +import type { TreatmentCaseDetail, TreatmentCaseStatus } from '../types'; + +const STATUS_OPTIONS: Array<{ value: TreatmentCaseStatus; label: string }> = [ + { value: 'active', label: 'در جریان' }, + { value: 'completed', label: 'تمام شده' }, + { value: 'abandoned', label: 'رها شده' }, +]; + +interface DoctorRow { uuid: string; name?: string | null; full_name?: string | null } + +/** + * ویرایش پروندهٔ درمان. + * + * پرونده بعد از باز شدن سند است نه فرم، پس فقط چیزهایی اینجا هستند که واقعاً وسط دوره + * عوض می‌شوند. سرور جلوی ویرایشی را که سابقه را بازنویسی کند می‌گیرد؛ فرم آن خطا را + * نشان می‌دهد، تکرارش نمی‌کند. + */ +export default function TreatmentCaseEditModal({ caseUuid, onClose }: { + caseUuid: string; + onClose: () => void; +}) { + const qc = useQueryClient(); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['treatment-case', caseUuid], + queryFn: () => api.get>(`/api/v1/treatment-case/${caseUuid}`), + }); + + // همان اندپوینتی که صفحهٔ نوبت‌ها می‌خواند: فقط پزشکانِ مجازِ همین محیط. + // پاسخش دو لایه تو در تو است (`data.data`) — الگوی شناخته‌شدهٔ همین اندپوینت. + const doctorsQ = useQuery>({ + queryKey: ['clinic-doctors-lite'], + queryFn: () => api.get('/api/v1/my/clinic-doctors'), + staleTime: 60_000, + }); + + const detail = data?.data; + + return ( + + {isLoading ? ( +
در حال بارگذاری...
+ ) : isError || !detail ? ( +
+ خواندن پرونده ناموفق بود. + +
+ ) : ( + { + qc.invalidateQueries({ queryKey: ['treatment-cases'] }); + qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] }); + onClose(); + }} + onClose={onClose} + /> + )} +
+ ); +} + +function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: { + detail: TreatmentCaseDetail; + doctors: DoctorRow[]; + doctorsLoading: boolean; + onSaved: () => void; + onClose: () => void; +}) { + const [status, setStatus] = useState(detail.status); + const [supervisor, setSupervisor] = useState(detail.supervisor?.uuid ?? null); + const [total, setTotal] = useState(String(detail.total_sessions)); + const [areas, setAreas] = useState( + detail.areas.map((a) => a.category_uuid).filter((u): u is string => u !== null), + ); + + // ناحیه‌ای که دسته‌اش حذف شده در سابقه هست ولی دیگر قابل انتخاب نیست — باید دیده + // شود، وگرنه کاربر فکر می‌کند فرم آن را انداخته است. + const orphanAreas = useMemo( + () => detail.areas.filter((a) => a.category_uuid === null).map((a) => a.name), + [detail.areas], + ); + + const minTotal = detail.completed_sessions; + + const save = useMutation({ + mutationFn: () => api.patch>(`/api/v1/treatment-case/${detail.uuid}`, { + status, + supervisor_doctor_uuid: supervisor, + area_uuids: areas, + total_sessions: Number(total) || 0, + }), + onSuccess: () => { toast.success('پرونده به‌روزرسانی شد'); onSaved(); }, + onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'ویرایش پرونده ناموفق بود'), + }); + + const toggleArea = (uuid: string) => + setAreas((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]); + + const totalValid = Number(total) >= 2 && Number(total) <= 60; + const valid = areas.length > 0 && totalValid; + + return ( +
+
+ + بیمار: + {detail.patient.name || 'بدون نام'} + + + سرویس: + {detail.service.name} + +
+ +
+ +
+ {STATUS_OPTIONS.map((o) => ( + + ))} +
+
+ +
+ + ({ value: d.uuid, label: d.name ?? d.full_name ?? '' }))} + value={supervisor} + onChange={(v) => setSupervisor(v === null ? null : String(v))} + placeholder="بدون پزشک ناظر" + isLoading={doctorsLoading} + isClearable + height={40} + /> +
+ +
+ +
+ {detail.available_areas.map((a) => { + const on = areas.includes(a.uuid); + return ( + + ); + })} +
+ {areas.length === 0 && حداقل یک ناحیه لازم است} + {orphanAreas.length > 0 && ( + + نواحیِ «{orphanAreas.join('، ')}» در سابقه هستند ولی دسته‌بندی‌شان حذف شده و قابل انتخاب نیستند. + + )} +
+ +
+ +
+ setTotal(e.target.value.replace(/\D/g, '').slice(0, 2))} + /> +
+ + {totalValid + ? `${formatNumber(minTotal)} جلسه انجام شده. جلسه‌ای که نوبت دارد یا انجام شده حذف نمی‌شود.` + : 'تعداد جلسات باید بین ۲ و ۶۰ باشد'} + +
+ +
+ + +
+
+ ); +} diff --git a/assets/admin/pages/TreatmentCasesPage.test.tsx b/assets/admin/pages/TreatmentCasesPage.test.tsx new file mode 100644 index 00000000..d993a799 --- /dev/null +++ b/assets/admin/pages/TreatmentCasesPage.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import TreatmentCasesPage from './TreatmentCasesPage'; + +const get = api.get as ReturnType; + +function caseRow(over: Record = {}) { + return { + uuid: 'case-1', + status: 'active', + total_sessions: 3, + completed_sessions: 1, + opened_at: 1_786_000_000, + closed_at: null, + service: { uuid: 'svc-1', name: 'لیزر توتال' }, + supervisor: { uuid: 'doc-1', name: 'پزشک مدیسا' }, + patient: { record_uuid: 'rec-1', name: 'محمد رسولی', mobile: '09120001111', record_number: '۱۲' }, + areas: [{ uuid: 'ca-1', name: 'دست', category_uuid: 'cat-1' }], + ...over, + }; +} + +/** فقط اندپوینت فهرست را جواب می‌دهد؛ بقیه خالی. */ +function mockList(rows: unknown[]) { + get.mockImplementation((url: string) => { + if (url.startsWith('/api/v1/treatment-cases')) return Promise.resolve({ success: true, data: rows }); + return Promise.resolve({ success: true, data: [] }); + }); +} + +beforeEach(() => { + get.mockReset(); +}); + +describe('صفحهٔ پرونده‌های درمان', () => { + it('نام بیمار سرتیتر کارت است، نه نام سرویس', async () => { + mockList([caseRow()]); + + renderWithProviders(); + + const name = await screen.findByText('محمد رسولی'); + expect(name.tagName).toBe('STRONG'); + expect(screen.getByText('لیزر توتال')).toBeInTheDocument(); + }); + + /** جستجو باید به سرور برود، نه اینکه فهرست را در مرورگر فیلتر کند. */ + it('عبارت جستجو را به‌صورت پارامتر q می‌فرستد', async () => { + mockList([caseRow()]); + + renderWithProviders(); + await screen.findByText('محمد رسولی'); + + fireEvent.change(screen.getByLabelText('جستجوی پرونده'), { target: { value: 'رسولی' } }); + + await waitFor( + () => expect(get.mock.calls.some( + (c: unknown[]) => typeof c[0] === 'string' && c[0].includes('q=%D8%B1%D8%B3%D9%88%D9%84%DB%8C'), + )).toBe(true), + { timeout: 2000 }, + ); + }); + + it('فیلتر وضعیت به‌صورت status می‌رود', async () => { + mockList([caseRow()]); + + renderWithProviders(); + await screen.findByText('محمد رسولی'); + + fireEvent.click(screen.getByRole('button', { name: 'رها شده' })); + + await waitFor(() => expect(get.mock.calls.some( + (c: unknown[]) => typeof c[0] === 'string' && c[0].includes('status=abandoned'), + )).toBe(true)); + }); + + /** نتیجهٔ خالیِ جستجو با «هنوز پرونده‌ای ساخته نشده» یکی نیست. */ + it('خالیِ جستجو پیام خودش را دارد', async () => { + mockList([]); + + renderWithProviders(); + await screen.findByText(/پرونده‌ای یافت نشد/); + + fireEvent.change(screen.getByLabelText('جستجوی پرونده'), { target: { value: 'هیچ' } }); + + expect(await screen.findByText(/برای «هیچ» پرونده‌ای پیدا نشد/, {}, { timeout: 2000 })).toBeInTheDocument(); + }); + + it('خطا را از فهرست خالی جدا می‌کند', async () => { + get.mockRejectedValue(new Error('boom')); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByText(/خواندن پرونده‌ها ناموفق بود/)).toBeInTheDocument()); + expect(screen.queryByText(/پرونده‌ای یافت نشد/)).not.toBeInTheDocument(); + }); + + it('دکمهٔ ویرایش مودال را باز می‌کند', async () => { + mockList([caseRow()]); + + renderWithProviders(); + fireEvent.click(await screen.findByRole('button', { name: /ویرایش/ })); + + expect(await screen.findByText('ویرایش پروندهٔ درمان')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/TreatmentCasesPage.tsx b/assets/admin/pages/TreatmentCasesPage.tsx index c7bce745..57ce75f6 100644 --- a/assets/admin/pages/TreatmentCasesPage.tsx +++ b/assets/admin/pages/TreatmentCasesPage.tsx @@ -1,12 +1,14 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; +import { MagnifyingGlassIcon, PencilSquareIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; -import { formatDate, formatDateTime } from '../lib/utils'; +import { formatDate, formatDateTime, formatNumber } from '../lib/utils'; import { useUrlState } from '../hooks/useUrlState'; +import TreatmentCaseEditModal from '../components/TreatmentCaseEditModal'; import type { TreatmentCaseSummary, StaffTreatmentSession, SlotSuggestionResponse } from '../types'; const TABS = [ @@ -29,7 +31,7 @@ const CASE_STATUS_LABEL: Record = { * را جواب می‌دهند — «کدام بیمار در چه مرحله‌ای است و چه کاری مانده». */ export default function TreatmentCasesPage() { - const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '' }); + const [urlState, setUrlState] = useUrlState({ tab: 'cases', status: '', q: '' }); const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'cases') as TabId; return ( @@ -50,18 +52,53 @@ export default function TreatmentCasesPage() { {tab === 'cases' - ? setUrlState({ status: s })} /> + ? setUrlState({ status: s })} + search={urlState.q} + onSearch={(q) => setUrlState({ q })} + /> : } ); } -function CasesTab({ status, onStatus }: { status: string; onStatus: (s: string) => void }) { - const { data, isLoading } = useQuery({ - queryKey: ['treatment-cases', status], - queryFn: () => api.get>( - `/api/v1/treatment-cases${status ? `?status=${status}` : ''}`, - ), +const STATUS_FILTERS = [ + ['', 'همه'], + ['active', 'در جریان'], + ['completed', 'تمام شده'], + ['abandoned', 'رها شده'], +] as const; + +function CasesTab({ status, onStatus, search, onSearch }: { + status: string; + onStatus: (s: string) => void; + search: string; + onSearch: (s: string) => void; +}) { + // فیلد جستجو محلی می‌ماند و فقط مقدار نهایی به URL می‌رود؛ وگرنه هر حرف یک ورودی + // تاریخچه می‌سازد و «بازگشت» بی‌معنی می‌شود. + const [term, setTerm] = useState(search); + useEffect(() => setTerm(search), [search]); + useEffect(() => { + const t = setTimeout(() => { if (term !== search) onSearch(term); }, 350); + return () => clearTimeout(t); + }, [term]); + + const [editing, setEditing] = useState(null); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['treatment-cases', status, search], + queryFn: () => { + const qs = new URLSearchParams(); + if (status) qs.set('status', status); + if (search) qs.set('q', search); + const suffix = qs.toString(); + + return api.get>( + `/api/v1/treatment-cases${suffix ? `?${suffix}` : ''}`, + ); + }, staleTime: 30_000, }); @@ -69,59 +106,115 @@ function CasesTab({ status, onStatus }: { status: string; onStatus: (s: string) return ( <> -
- {[['', 'همه'], ['active', 'در جریان'], ['completed', 'تمام شده'], ['abandoned', 'رها شده']].map(([v, label]) => ( - - ))} +
+
+ + setTerm(e.target.value)} + placeholder="نام بیمار، موبایل، کد ملی، شمارهٔ پرونده یا سرویس" + aria-label="جستجوی پرونده" + /> + {term !== '' && ( + + )} +
+ +
+ {STATUS_FILTERS.map(([v, label]) => ( + + ))} +
{isLoading ? ( -
در حال بارگذاری...
+
+ {[0, 1].map((i) =>
)} +
+ ) : isError ? ( + /* خطای سرور نباید «پرونده‌ای یافت نشد» خوانده شود — آن یعنی جستجو نتیجه نداشت. */ +
+ خواندن پرونده‌ها ناموفق بود. + +
) : cases.length === 0 ? ( -
- پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود. +
+ {search + ? `برای «${search}» پرونده‌ای پیدا نشد.` + : 'پرونده‌ای یافت نشد. پرونده وقتی ساخته می‌شود که نوبتِ سرویسی با «طول درمان» قطعی شود.'}
) : (
- {cases.map((c) => ( -
-
- {c.service.name} - - {CASE_STATUS_LABEL[c.status]} - - - {c.completed_sessions} از {c.total_sessions} جلسه - -
- -
- شروع: {formatDate(c.opened_at)} - {c.supervisor && پزشک ناظر: {c.supervisor.name}} - {c.areas.length > 0 && نواحی: {c.areas.map((a) => a.name).join('، ')}} -
- - -
- ))} + {cases.map((c) => setEditing(c.uuid)} />)}
)} + + {editing !== null && ( + setEditing(null)} /> + )} ); } +function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () => void }) { + const percent = c.total_sessions > 0 + ? Math.round((c.completed_sessions / c.total_sessions) * 100) + : 0; + + return ( +
+
+ {/* بیمار سرتیتر است نه سرویس: دو پروندهٔ یک سرویس فقط با نام بیمار از هم جدا می‌شوند. */} + {c.patient.name || 'بیمار بدون نام'} + + {CASE_STATUS_LABEL[c.status]} + + + {formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} جلسه + + +
+ +
+ {c.service.name} + {c.patient.mobile} + شروع: {formatDate(c.opened_at)} + {c.supervisor && پزشک ناظر: {c.supervisor.name}} + {c.areas.length > 0 && نواحی: {c.areas.map((a) => a.name).join('، ')}} +
+ + {/* `` نیتیو ظاهر مرورگر را می‌گیرد و با توکن‌های تم نمی‌خواند. */} +
+
+
+
+ ); +} + function UnbookedTab() { const { data, isLoading } = useQuery({ queryKey: ['treatment-sessions-unbooked'], diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 84e70b4f..a73b9c0c 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1322,19 +1322,33 @@ export interface StaffTreatmentSession extends TreatmentSessionSummary { resource_name?: string | null; } +export type TreatmentCaseStatus = 'active' | 'completed' | 'abandoned'; + export interface TreatmentCaseSummary { uuid: string; - status: 'active' | 'completed' | 'abandoned'; + status: TreatmentCaseStatus; total_sessions: number; completed_sessions: number; opened_at: number; closed_at: number | null; service: { uuid: string; name: string }; supervisor: { uuid: string; name: string } | null; - areas: Array<{ uuid: string; name: string }>; + /** بدون بیمار، دو پروندهٔ یک سرویس در فهرست از هم قابل تشخیص نیستند. */ + patient: { + record_uuid: string; + name: string | null; + mobile: string; + record_number: string | null; + }; + areas: Array<{ uuid: string; name: string; category_uuid: string | null }>; sessions?: TreatmentSessionSummary[]; } +/** پاسخ `GET /api/v1/treatment-case/{uuid}` — پرونده به‌علاوهٔ نواحیِ قابل انتخاب. */ +export interface TreatmentCaseDetail extends TreatmentCaseSummary { + available_areas: Array<{ uuid: string; name: string }>; +} + /** تعریف یک فیلد فرم ثبت درمان، از `ResourceType.field_schema`. */ export interface TreatmentFormField { key: string; diff --git a/docs/api/treatment.md b/docs/api/treatment.md index bfc1917a..56ff4f5b 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -209,6 +209,7 @@ single-session again. Idempotent: deleting a service that has no protocol still | Query | توضیح | |---|---| | `status` | `active` \| `completed` \| `abandoned` — نبودش یعنی همه | +| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس | ```json { @@ -220,13 +221,47 @@ single-session again. Idempotent: deleting a service that has no protocol still "closed_at": null, "service": { "uuid": "…", "name": "لیزر توتال" }, "supervisor": { "uuid": "…", "name": "دکتر ناظر" }, - "areas": [ { "uuid": "…", "name": "بیکینی" } ] + "patient": { + "record_uuid": "…", + "name": "محمد رسولی", + "mobile": "09120001111", + "record_number": "۱۲" + }, + "areas": [ { "uuid": "…", "name": "بیکینی", "category_uuid": "…" } ] } ``` +`areas[].uuid` شناسهٔ همان ردیفِ ناحیه است و `category_uuid` شناسهٔ دستهٔ کاتالوگ. +ویرایش با دومی کار می‌کند؛ `null` یعنی دسته حذف شده و ناحیه فقط در سابقه مانده. + ## GET `/api/v1/treatment-case/{uuid}` -همان شکل، به‌علاوهٔ `sessions`. پروندهٔ محیط دیگر `404` می‌گیرد. +همان شکل، به‌علاوهٔ `sessions` و `available_areas` — نواحیِ قابل انتخاب برای همین +سرویس، تا فرم ویرایش اندپوینت دومی نخواهد. پروندهٔ محیط دیگر `404` می‌گیرد. + +## PATCH `/api/v1/treatment-case/{uuid}` + +ویرایش پروندهٔ درمان. هر فیلد اختیاری است؛ فقط کلیدهای فرستاده‌شده اعمال می‌شوند. + +| فیلد | توضیح | +|---|---| +| `status` | `active` \| `completed` \| `abandoned`. برگرداندن به `active` پروندهٔ بسته را باز می‌کند و `closed_at` را پاک می‌کند | +| `supervisor_doctor_uuid` | پزشک ناظر؛ `null` یعنی بدون ناظر | +| `area_uuids` | فهرست **دستهٔ کاتالوگ**، جایگزین کامل. حداقل یکی | +| `total_sessions` | بین `TreatmentProtocol::MIN_STEPS` و `MAX_STEPS`. کم‌کردن جلسات را از انتها حذف می‌کند | + +مرزِ ثابت: **هیچ ویرایشی سابقهٔ انجام‌شده را بازنویسی نمی‌کند.** + +| کد | HTTP | فیلد | شرط | +|---|---|---|---| +| ERR_VALIDATION_001 | 422 | `status` | وضعیت نامعتبر | +| ERR_VALIDATION_001 | 422 | `area_uuids` | فهرست خالی یا نامعتبر | +| ERR_VALIDATION_001 | 422 | `total_sessions` | خارج از بازهٔ مجاز | +| ERR_NOT_FOUND_001 | 404 | `supervisor_doctor_uuid` / `area_uuids` | پزشک یا ناحیه یافت نشد | +| ERR_CONFLICT_001 | 409 | `area_uuids` | ناحیه در جلسه‌ای ثبت شده و حذف نمی‌شود | +| ERR_CONFLICT_001 | 409 | `total_sessions` | کمتر از جلساتی که نوبت دارند یا انجام شده‌اند | + +قواعدش در `TreatmentCaseEditor` است نه کنترلر. --- diff --git a/src/Treatment/Controller/TreatmentCaseController.php b/src/Treatment/Controller/TreatmentCaseController.php index b2763356..58dc879c 100644 --- a/src/Treatment/Controller/TreatmentCaseController.php +++ b/src/Treatment/Controller/TreatmentCaseController.php @@ -3,6 +3,7 @@ namespace App\Treatment\Controller; use App\Auth\Entity\User; +use App\ClinicService\Entity\CatalogCategory; use App\Doctor\Service\AddressResolver; use App\Resource\Repository\ClinicResourceRepository; use App\Shared\Constant\ErrorCodes; @@ -14,6 +15,8 @@ use App\Treatment\Entity\TreatmentSession; use App\Treatment\Repository\TreatmentCaseRepository; use App\Treatment\Repository\TreatmentSessionRepository; use App\Treatment\Service\NextSessionSlotFinder; +use App\Treatment\Service\TreatmentCaseEditor; +use App\Treatment\Service\TreatmentCaseOpener; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -32,6 +35,8 @@ class TreatmentCaseController extends BaseController private readonly NextSessionSlotFinder $slotFinder, private readonly TenantOwnershipChecker $ownership, private readonly AddressResolver $branches, + private readonly TreatmentCaseOpener $opener, + private readonly TreatmentCaseEditor $editor, ) {} #[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])] @@ -42,16 +47,50 @@ class TreatmentCaseController extends BaseController $status = $request->query->get('status'); $status = is_string($status) && $status !== '' ? $status : null; + $q = $request->query->get('q'); + $q = is_string($q) ? trim($q) : ''; + return $this->success(array_map( static fn (TreatmentCase $c): array => $c->toArray(), - $this->cases->findForTenant($entityType, $entityId, $status), + $this->cases->findForTenant($entityType, $entityId, $status, $q !== '' ? $q : null), )); } #[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])] public function show(#[CurrentUser] User $user, string $uuid): JsonResponse { - return $this->success($this->requireCase($user, $uuid)->toArray(withSessions: true)); + $case = $this->requireCase($user, $uuid); + + /** + * نواحیِ قابل انتخاب کنار خودِ پرونده می‌آید، وگرنه فرم ویرایش باید حدس بزند + * کدام دسته‌ها مجازند یا اندپوینت دومی برای همان یک سؤال ساخته شود. + */ + return $this->success($case->toArray(withSessions: true) + [ + 'available_areas' => array_map( + static fn (CatalogCategory $c): array => ['uuid' => $c->getUuid(), 'name' => $c->getName()], + $this->opener->resolveAreas($case->getServiceItem()), + ), + ]); + } + + /** + * ویرایش پروندهٔ باز — وضعیت، پزشک ناظر، نواحی و تعداد جلسات. + * + * قواعدش در {@see TreatmentCaseEditor} است نه اینجا: هیچ ویرایشی نباید سابقهٔ + * انجام‌شده را بازنویسی کند و آن تصمیم جای کنترلر نیست. + */ + #[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])] + public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $case = $this->editor->update($this->requireCase($user, $uuid), $data); + + return $this->success($case->toArray(withSessions: true)); } /** diff --git a/src/Treatment/Entity/TreatmentCase.php b/src/Treatment/Entity/TreatmentCase.php index 1dd396bb..c748c6fa 100644 --- a/src/Treatment/Entity/TreatmentCase.php +++ b/src/Treatment/Entity/TreatmentCase.php @@ -155,6 +155,54 @@ class TreatmentCase return $this; } + /** پرونده‌ای که اشتباه بسته شده دوباره باز می‌شود؛ `closedAt` باید پاک شود وگرنه «بستهٔ فعال» می‌ماند. */ + public function reopen(): self + { + $this->status = self::STATUS_ACTIVE; + $this->closedAt = null; + $this->touch(); + + return $this; + } + + public function setSupervisorDoctor(?Doctor $doctor): self + { + $this->supervisorDoctor = $doctor; + $this->touch(); + + return $this; + } + + /** + * تعداد جلساتِ همین پرونده، مستقل از پروتکل سرویس. + * + * پروتکل الگوی پیش‌فرض است نه قرارداد: بیمار ممکن است به جلسهٔ کمتر یا بیشتر + * نیاز داشته باشد بدون اینکه سرویس برای بقیه عوض شود. + */ + public function setTotalSessions(int $total): self + { + $this->totalSessions = $total; + $this->touch(); + + return $this; + } + + public function removeArea(TreatmentCaseArea $area): self + { + $this->areas->removeElement($area); + $this->touch(); + + return $this; + } + + public function removeSession(TreatmentSession $session): self + { + $this->sessions->removeElement($session); + $this->touch(); + + return $this; + } + public function toArray(bool $withSessions = false): array { $data = [ @@ -168,6 +216,16 @@ class TreatmentCase 'uuid' => $this->serviceItem->getUuid(), 'name' => $this->serviceItem->getName(), ], + /** + * بدون بیمار، دو پروندهٔ یک سرویس از هم قابل تشخیص نیستند — فهرست + * پرونده‌ها بدونش چند کارتِ یکسان است. + */ + 'patient' => [ + 'record_uuid' => $this->patientRecord->getUuid(), + 'name' => $this->patientRecord->getUser()->getRealName(), + 'mobile' => $this->patientRecord->getUser()->getMobileNumber(), + 'record_number' => $this->patientRecord->getRecordNumber(), + ], 'supervisor' => $this->supervisorDoctor === null ? null : [ 'uuid' => $this->supervisorDoctor->getUuid(), 'name' => $this->supervisorDoctor->getName(), diff --git a/src/Treatment/Entity/TreatmentCaseArea.php b/src/Treatment/Entity/TreatmentCaseArea.php index c5dcedd5..86d3299c 100644 --- a/src/Treatment/Entity/TreatmentCaseArea.php +++ b/src/Treatment/Entity/TreatmentCaseArea.php @@ -62,6 +62,11 @@ class TreatmentCaseArea return [ 'uuid' => $this->uuid, 'name' => $this->nameSnapshot, + /** + * فرم ویرایش با دستهٔ کاتالوگ کار می‌کند نه با این ردیف. `null` یعنی دسته + * حذف شده — ناحیه هنوز در سابقه هست ولی دیگر قابل انتخاب نیست. + */ + 'category_uuid' => $this->category?->getUuid(), ]; } } diff --git a/src/Treatment/Repository/TreatmentCaseRepository.php b/src/Treatment/Repository/TreatmentCaseRepository.php index 59b348b0..3f6cb2c7 100644 --- a/src/Treatment/Repository/TreatmentCaseRepository.php +++ b/src/Treatment/Repository/TreatmentCaseRepository.php @@ -39,8 +39,17 @@ class TreatmentCaseRepository extends ServiceEntityRepository } /** @return TreatmentCase[] */ - public function findForTenant(string $entityType, int $entityId, ?string $status = null): array - { + /** + * @param ?string $q جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس. + * منشی همان کلیدی را می‌زند که در فرم نوبت می‌زند، پس هر چهار + * شناسهٔ بیمار باید بگیرد نه فقط نام. + */ + public function findForTenant( + string $entityType, + int $entityId, + ?string $status = null, + ?string $q = null, + ): array { $qb = $this->createQueryBuilder('c') ->where('c.entityType = :type') ->andWhere('c.entityId = :id') @@ -52,6 +61,17 @@ class TreatmentCaseRepository extends ServiceEntityRepository $qb->andWhere('c.status = :status')->setParameter('status', $status); } + if ($q !== null && $q !== '') { + $qb->join('c.patientRecord', 'pr') + ->join('pr.user', 'u') + ->join('c.serviceItem', 'si') + ->andWhere( + 'u.realName LIKE :q OR u.mobileNumber LIKE :q OR u.nationalCode LIKE :q' + . ' OR pr.recordNumber LIKE :q OR si.name LIKE :q', + ) + ->setParameter('q', '%' . $q . '%'); + } + return $qb->getQuery()->getResult(); } } diff --git a/src/Treatment/Service/TreatmentCaseEditor.php b/src/Treatment/Service/TreatmentCaseEditor.php new file mode 100644 index 00000000..415e9ad3 --- /dev/null +++ b/src/Treatment/Service/TreatmentCaseEditor.php @@ -0,0 +1,237 @@ + $data + */ + public function update(TreatmentCase $case, array $data): TreatmentCase + { + if (array_key_exists('status', $data)) { + $this->applyStatus($case, (string) $data['status']); + } + + if (array_key_exists('supervisor_doctor_uuid', $data)) { + $this->applySupervisor($case, $data['supervisor_doctor_uuid']); + } + + if (array_key_exists('area_uuids', $data)) { + $this->applyAreas($case, $data['area_uuids']); + } + + if (array_key_exists('total_sessions', $data)) { + $this->applyTotalSessions($case, (int) $data['total_sessions']); + } + + $this->em->flush(); + + return $case; + } + + private function applyStatus(TreatmentCase $case, string $status): void + { + $allowed = [TreatmentCase::STATUS_ACTIVE, TreatmentCase::STATUS_COMPLETED, TreatmentCase::STATUS_ABANDONED]; + + if (!in_array($status, $allowed, true)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'وضعیت پرونده نامعتبر است', 422, 'status'); + } + + if ($status === $case->getStatus()) { + return; + } + + $status === TreatmentCase::STATUS_ACTIVE ? $case->reopen() : $case->close($status); + } + + private function applySupervisor(TreatmentCase $case, mixed $uuid): void + { + if ($uuid === null || $uuid === '') { + $case->setSupervisorDoctor(null); + + return; + } + + $doctor = $this->doctors->findByUuid((string) $uuid); + + if ($doctor === null) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404, 'supervisor_doctor_uuid'); + } + + $case->setSupervisorDoctor($doctor); + } + + /** + * نواحی جایگزین می‌شوند، ولی ناحیه‌ای که جلسه‌ای رویش ثبت شده حذف نمی‌شود. + * + * حذفش یعنی پاک کردن سابقهٔ درمان — `SessionAreaRecord` به همان ردیف اشاره دارد و + * پرونده باید بگوید جلسهٔ قبل روی چه ناحیه‌ای انجام شد. + * + * @param mixed $uuids فهرست uuid دسته‌های کاتالوگ + */ + private function applyAreas(TreatmentCase $case, mixed $uuids): void + { + if (!is_array($uuids)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فهرست نواحی نامعتبر است', 422, 'area_uuids'); + } + + $wanted = array_values(array_unique(array_map('strval', $uuids))); + + if ($wanted === []) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'حداقل یک ناحیه لازم است', 422, 'area_uuids'); + } + + /** @var array $categories */ + $categories = []; + + foreach ($wanted as $uuid) { + $category = $this->categories->findByUuid($uuid); + + if ($category === null) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'ناحیه یافت نشد', 404, 'area_uuids'); + } + + $categories[$uuid] = $category; + } + + $existing = []; + + foreach ($case->getAreas() as $area) { + $key = $area->getCategory()?->getUuid(); + + if ($key !== null) { + $existing[$key] = $area; + } + } + + foreach ($case->getAreas()->toArray() as $area) { + $key = $area->getCategory()?->getUuid(); + + if ($key !== null && in_array($key, $wanted, true)) { + continue; + } + + if ($this->areaHasRecords($area)) { + throw new AppException( + ErrorCodes::ERR_CONFLICT_001, + sprintf('ناحیهٔ «%s» در جلسه‌ای ثبت شده و حذف نمی‌شود', $area->getName()), + 409, + 'area_uuids', + ); + } + + $case->removeArea($area); + $this->em->remove($area); + } + + $order = 0; + + foreach ($wanted as $uuid) { + if (!isset($existing[$uuid])) { + $case->addArea(new TreatmentCaseArea($case, $categories[$uuid], $order)); + } + + ++$order; + } + } + + /** + * جلسه اضافه می‌شود یا از انتها کم — ولی هرگز جلسه‌ای که نوبت گرفته یا انجام شده. + * + * کفِ مجاز تعداد جلساتی است که دیگر دست‌نخوردنی‌اند، نه عددی ثابت. + */ + private function applyTotalSessions(TreatmentCase $case, int $total): void + { + if ($total < TreatmentProtocol::MIN_STEPS || $total > TreatmentProtocol::MAX_STEPS) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('تعداد جلسات باید بین %d و %d باشد', TreatmentProtocol::MIN_STEPS, TreatmentProtocol::MAX_STEPS), + 422, + 'total_sessions', + ); + } + + $sessions = $case->getSessions()->toArray(); + usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + + $locked = 0; + + foreach ($sessions as $session) { + if ($this->isRemovable($session)) { + continue; + } + + $locked = max($locked, $session->getSessionNumber()); + } + + if ($total < $locked) { + throw new AppException( + ErrorCodes::ERR_CONFLICT_001, + sprintf('%d جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست', $locked), + 409, + 'total_sessions', + ); + } + + for ($i = count($sessions) - 1; $i >= 0 && count($sessions) > $total; --$i) { + $session = $sessions[$i]; + + if ($session->getSessionNumber() <= $total || !$this->isRemovable($session)) { + continue; + } + + $case->removeSession($session); + $this->em->remove($session); + array_splice($sessions, $i, 1); + } + + for ($number = count($sessions) + 1; $number <= $total; ++$number) { + $case->addSession(new TreatmentSession($case, $number)); + } + + $case->setTotalSessions($total); + } + + private function areaHasRecords(TreatmentCaseArea $area): bool + { + return (int) $this->em->createQuery( + 'SELECT COUNT(r.id) FROM App\Treatment\Entity\SessionAreaRecord r WHERE r.caseArea = :area', + )->setParameter('area', $area)->getSingleScalarResult() > 0; + } + + /** جلسه‌ای که نه نوبت دارد نه شروع شده، هنوز فقط یک برنامه است. */ + private function isRemovable(TreatmentSession $session): bool + { + return $session->getAppointment() === null + && $session->getStartedAt() === null + && in_array($session->getStatus(), [TreatmentSession::STATUS_PLANNED, TreatmentSession::STATUS_CANCELLED], true); + } +} diff --git a/tests/Treatment/TreatmentCaseEditTest.php b/tests/Treatment/TreatmentCaseEditTest.php new file mode 100644 index 00000000..6363b634 --- /dev/null +++ b/tests/Treatment/TreatmentCaseEditTest.php @@ -0,0 +1,221 @@ +get(TreatmentCaseEditor::class); + } + + private function cases(): TreatmentCaseRepository + { + return static::getContainer()->get(TreatmentCaseRepository::class); + } + + /** + * @return array{TreatmentCase, Clinic, ServiceItem, array} + */ + private function scenario(string $patientName = 'سارا کاظمی'): array + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر ویرایش'); + $this->em->persist($doctor); + + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک ویرایش ' . uniqid()); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $categories = []; + foreach (['دست', 'پا', 'صورت'] as $name) { + $c = new CatalogCategory('clinic', (int) $clinic->getId(), $name); + $this->em->persist($c); + $categories[$name] = $c; + } + + $service = new ServiceItem($section, 'لیزر بدن', 4_000_000); + $this->em->persist($service); + $this->em->flush(); + + $protocol = new TreatmentProtocol($service); + $this->em->persist($protocol); + $protocol->replaceSteps([ + new TreatmentProtocolStep($protocol, 1, 0), + new TreatmentProtocolStep($protocol, 2, 15), + new TreatmentProtocolStep($protocol, 3, 30), + ]); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $patient->setRealName($patientName); + $record = new PatientRecord('clinic', (int) $clinic->getId(), $patient, 'clinic', (int) $clinic->getId()); + $this->em->persist($record); + $this->em->flush(); + + $case = new TreatmentCase('clinic', (int) $clinic->getId(), $record, $service, $protocol); + $this->em->persist($case); + $case->addArea(new TreatmentCaseArea($case, $categories['دست'], 0)); + $case->addArea(new TreatmentCaseArea($case, $categories['پا'], 1)); + foreach ([1, 2, 3] as $n) { + $case->addSession(new TreatmentSession($case, $n)); + } + $this->em->flush(); + + return [$case, $clinic, $service, $categories]; + } + + private function sessionNumbers(TreatmentCase $case): array + { + $numbers = array_map( + static fn (TreatmentSession $s): int => $s->getSessionNumber(), + $case->getSessions()->toArray(), + ); + sort($numbers); + + return $numbers; + } + + public function testStatusCanBeClosedAndReopened(): void + { + [$case] = $this->scenario(); + + $this->editor()->update($case, ['status' => TreatmentCase::STATUS_ABANDONED]); + self::assertSame(TreatmentCase::STATUS_ABANDONED, $case->getStatus()); + self::assertNotNull($case->getClosedAt()); + + // بازگرداندن باید closedAt را پاک کند، وگرنه پرونده «بستهٔ فعال» می‌ماند. + $this->editor()->update($case, ['status' => TreatmentCase::STATUS_ACTIVE]); + self::assertSame(TreatmentCase::STATUS_ACTIVE, $case->getStatus()); + self::assertNull($case->getClosedAt()); + } + + public function testSupervisorCanBeChangedAndCleared(): void + { + [$case, $clinic] = $this->scenario(); + + $other = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تازه'); + $this->em->persist($other); + $clinic->getDoctors()->add($other); + $this->em->flush(); + + $this->editor()->update($case, ['supervisor_doctor_uuid' => $other->getUuid()]); + self::assertSame($other->getId(), $case->getSupervisorDoctor()?->getId()); + + $this->editor()->update($case, ['supervisor_doctor_uuid' => null]); + self::assertNull($case->getSupervisorDoctor()); + } + + public function testAreasAreReplaced(): void + { + [$case, , , $categories] = $this->scenario(); + + $this->editor()->update($case, ['area_uuids' => [ + $categories['دست']->getUuid(), + $categories['صورت']->getUuid(), + ]]); + + $names = array_map(static fn ($a) => $a->getName(), $case->getAreas()->toArray()); + sort($names); + self::assertSame(['دست', 'صورت'], $names); + } + + /** حذف ناحیه‌ای که جلسه‌ای رویش ثبت شده یعنی پاک کردن سابقهٔ درمان. */ + public function testAnAreaWithRecordsCannotBeRemoved(): void + { + [$case, , , $categories] = $this->scenario(); + + $area = $case->getAreas()->first(); + $session = $case->getSessions()->first(); + $this->em->persist(new \App\Treatment\Entity\SessionAreaRecord($session, $area)); + $this->em->flush(); + + $this->expectException(\App\Shared\Exception\AppException::class); + $this->editor()->update($case, ['area_uuids' => [$categories['صورت']->getUuid()]]); + } + + public function testSessionsGrowAndShrink(): void + { + [$case] = $this->scenario(); + + $this->editor()->update($case, ['total_sessions' => 5]); + self::assertSame([1, 2, 3, 4, 5], $this->sessionNumbers($case)); + self::assertSame(5, $case->getTotalSessions()); + + $this->editor()->update($case, ['total_sessions' => 2]); + self::assertSame([1, 2], $this->sessionNumbers($case)); + self::assertSame(2, $case->getTotalSessions()); + } + + /** جلسه‌ای که نوبت گرفته کفِ تعداد را بالا می‌برد؛ حذفش یعنی گم شدن یک نوبت واقعی. */ + public function testSessionsCannotDropBelowBookedWork(): void + { + [$case, $clinic] = $this->scenario(); + + $sessions = $case->getSessions()->toArray(); + usort($sessions, static fn ($a, $b) => $a->getSessionNumber() <=> $b->getSessionNumber()); + + $appointment = $this->newAppointment( + $clinic->getDoctors()->first(), + $this->createUser(['ROLE_USER']), + 1_795_000_000, + 1_795_001_800, + $clinic, + ); + $this->em->persist($appointment); + $this->em->flush(); + $sessions[2]->attachAppointment($appointment); + $this->em->flush(); + + $this->expectException(\App\Shared\Exception\AppException::class); + $this->editor()->update($case, ['total_sessions' => 2]); + } + + public function testTotalSessionsIsBounded(): void + { + [$case] = $this->scenario(); + + $this->expectException(\App\Shared\Exception\AppException::class); + $this->editor()->update($case, ['total_sessions' => 1]); + } + + /** جستجو باید همان کلیدی را بگیرد که منشی در فرم نوبت می‌زند. */ + public function testSearchMatchesPatientAndService(): void + { + $name = 'نازنین ' . uniqid(); + [$case, $clinic] = $this->scenario($name); + + $type = 'clinic'; + $id = (int) $clinic->getId(); + + self::assertCount(1, $this->cases()->findForTenant($type, $id, null, $name)); + self::assertCount(1, $this->cases()->findForTenant($type, $id, null, 'لیزر بدن')); + self::assertSame([], $this->cases()->findForTenant($type, $id, null, 'چیزی که نیست')); + self::assertCount(1, $this->cases()->findForTenant($type, $id, null, null)); + + self::assertSame($case->getId(), $this->cases()->findForTenant($type, $id, null, $name)[0]->getId()); + } +}