From 952e09bd6ac212f456f47b262257c74d4b8d7ef5 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 13:48:57 +0330 Subject: [PATCH] feat(treatment): search and edit for treatment cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list had no way to tell two cases apart. TreatmentCase::toArray() carried no patient, so four cases of the same service rendered as four identical cards — same service, same supervisor, same date, same areas. Search would have been meaningless without fixing that first, so the payload now carries the patient (name, mobile, record number) and the card leads with the name. Search: `?q=` on the list endpoint, matching patient name, mobile, national code, record number and service name — the same keys a secretary already types into the booking form. It lives in the URL via useUrlState, debounced, so back and refresh keep the view. Edit: PATCH /api/v1/treatment-case/{uuid} covering status, supervising doctor, areas and session count, driven from a modal on the list. Rules live in TreatmentCaseEditor, not the controller, around one boundary: no edit may overwrite work already done. An area with session records cannot be removed, and the session count cannot drop below the sessions that are booked or finished — both 409, both tested. Reopening a closed case clears closed_at. `areas[]` now also exposes `category_uuid`; the edit form selects catalog categories, while `uuid` identifies the snapshot row. Page fixes from the redesign checklist: the status filter was a hand-rolled primary/secondary button pair, now `.seg` with `.on`; the raw `` bar took the browser's own appearance and ignored the theme tokens, now a token- styled bar with an explicit progressbar role; session counts go through formatNumber; a failed request rendered as "no cases found", which reads as an empty clinic rather than a broken one, and an empty search now says so in its own words. Adds the test files neither the page nor the case editor had. Co-Authored-By: Claude Opus 5 --- .../components/TreatmentCaseEditModal.tsx | 227 +++++++++++++++++ .../admin/pages/TreatmentCasesPage.test.tsx | 113 +++++++++ assets/admin/pages/TreatmentCasesPage.tsx | 193 ++++++++++---- assets/admin/types/index.ts | 18 +- docs/api/treatment.md | 39 ++- .../Controller/TreatmentCaseController.php | 43 +++- src/Treatment/Entity/TreatmentCase.php | 58 +++++ src/Treatment/Entity/TreatmentCaseArea.php | 5 + .../Repository/TreatmentCaseRepository.php | 24 +- src/Treatment/Service/TreatmentCaseEditor.php | 237 ++++++++++++++++++ tests/Treatment/TreatmentCaseEditTest.php | 221 ++++++++++++++++ 11 files changed, 1120 insertions(+), 58 deletions(-) create mode 100644 assets/admin/components/TreatmentCaseEditModal.tsx create mode 100644 assets/admin/pages/TreatmentCasesPage.test.tsx create mode 100644 src/Treatment/Service/TreatmentCaseEditor.php create mode 100644 tests/Treatment/TreatmentCaseEditTest.php 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()); + } +}