diff --git a/.claude/prompt/require-visit-price-setting.md b/.claude/prompt/require-visit-price-setting.md new file mode 100644 index 00000000..d734b6ed --- /dev/null +++ b/.claude/prompt/require-visit-price-setting.md @@ -0,0 +1,204 @@ +# گزینه «الزامی کردن هزینه ویزیت» در تنظیمات نوبت‌دهی + +## پروژه + +`clinicpro` (backend + پنل ادمین React) + +## زمینه + +در صفحه `/admin/appointment-settings` کامپوننت `FreeVisitPrice` مبلغ «قیمت ویزیت آزاد» را از `GET /api/v1/insurance-pricing` می‌خواند و با `PUT` همان endpoint ذخیره می‌کند (ذخیره در `EntityInsurancePricing` با `insurance_id = NULL`). این مبلغ در گام «ایجاد سرویس» ثبت مراجعه (`CreateStep.tsx`) به‌عنوان مقدار پیش‌فرض «قیمت ویزیت» استفاده می‌شود، اما هیچ‌کدام از فرم‌ها آن را الزامی نمی‌کنند و صفحه ثبت نوبت (`AppointmentCreatePage.tsx`) اصلاً فیلد هزینه ویزیت ندارد. کلینیک‌هایی که می‌خواهند هیچ مراجعه/نوبتی بدون هزینه ویزیت ثبت نشود، ابزاری برای اجبار آن ندارند. + +## مشکل / هدف + +یک تنظیم boolean با عنوان **«الزامی کردن هزینه ویزیت»** (به‌همراه متن راهنما) به صفحه appointment-settings اضافه شود که وقتی فعال است: + +1. «قیمت ویزیت آزاد» الزامی شود (بدون مقدار > 0 ذخیره تنظیمات ممکن نباشد) — هم در UI هم در backend. +2. در گام «ایجاد سرویس» ثبت مراجعه (`/admin/patients//session/new`)، فیلد «قیمت ویزیت» الزامی شود؛ بدون مقدار > 0 ثبت نشود — UI + backend. +3. در صفحه ثبت نوبت (`/admin/appointments/new`) فیلد جدید «هزینه ویزیت» اضافه شود (**تصمیم تأییدشده توسط کاربر**): با فلگ فعال الزامی، با فلگ غیرفعال اختیاری؛ مقدار پیش‌فرض از «قیمت ویزیت آزاد». + +وقتی غیرفعال است، همه این فیلدها اختیاری بمانند (رفتار فعلی). وضعیت الزامی/اختیاری باید در UI واضح باشد (ستاره `*` روی label + پیام خطای فارسی زیر فیلد). + +> «صدور فاکتور سرویس» در این پنل همان گام ۱ ویزارد ثبت مراجعه است (`CreateStep`) که `POST /api/v1/patient/{uuid}/session` را صدا می‌زند؛ گام‌های پرداخت/جزییات (`PaymentStep`/`DetailsStep`) قیمت ویزیت را فقط از session ساخته‌شده می‌خوانند و فیلد ورودی ندارند. پس الزام فاکتور با اعتبارسنجی همین گام + گارد backend پوشش داده می‌شود. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Insurance/Entity/EntityInsurancePricing.php` | محل ذخیره «قیمت ویزیت آزاد» (ردیف `insurance_id = NULL`) — ستون جدید فلگ اینجا اضافه می‌شود | +| `src/Insurance/Controller/InsuranceController.php` | `GET`/`PUT /api/v1/insurance-pricing` — expose و اعتبارسنجی فلگ | +| `src/Patient/Service/PatientService.php` | `createSession()` — اعتبارسنجی الزامی بودن `visit_price_rials` | +| `src/Appointment/Entity/Appointment.php` | ستون جدید `visit_price_rials` (nullable) | +| کنترلر ایجاد نوبت (`POST /api/v1/admin/appointment` و `/api/v1/my/appointment`) | پذیرش و اعتبارسنجی `visit_price_rials` — با `ddev exec php bin/console debug:router \| grep appointment` پیدا کن | +| `assets/admin/components/FreeVisitPrice.tsx` | UI تنظیم قیمت ویزیت آزاد — toggle + helper text + الزامی شدن قیمت | +| `assets/admin/components/session/CreateStep.tsx` | گام «ایجاد سرویس» — الزامی شدن «قیمت ویزیت» | +| `assets/admin/pages/AppointmentCreatePage.tsx` | صفحه ثبت نوبت — فیلد جدید «هزینه ویزیت» | +| `docs/api/insurance.md`، `docs/api/patient.md`، `docs/api/appointment.md` | به‌روزرسانی مستندات (Standing Rule) | + +## وضعیت فعلی + +`EntityInsurancePricing` فقط مبلغ دارد، فلگ ندارد: + +```php +#[ORM\Column(name: 'patient_share_rials', type: 'integer')] +private int $patientShareRials = 0; + +public function isFreeVisit(): bool { return $this->insuranceId === null; } +``` + +`InsuranceController::saveInsurancePricing` بدون هیچ اعتبارسنجی upsert می‌کند: + +```php +if (array_key_exists('free_visit_price_rials', $data)) { + $this->upsertPricing($entityType, $entityId, null, (int) $data['free_visit_price_rials']); +} +``` + +`PatientService::createSession` مقدار صفر را می‌پذیرد: + +```php +$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0)); +``` + +`CreateStep.tsx` قیمت ویزیت را اختیاری می‌گیرد (پیش‌فرض از free visit، ولی صفر هم ثبت می‌شود): + +```tsx +const [visitPrice, setVisitPrice] = useState('0'); +// ... +const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0; +useEffect(() => { + if (freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(freeVisit)); +}, [freeVisit]); +// ... +قیمت ویزیت (تومان) + setVisitPrice(e.target.value)} /> +``` + +`AppointmentCreatePage.tsx` payload فقط بیعانه دارد، هزینه ویزیت ندارد: + +```tsx +...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}), +``` + +و `Appointment` entity ستون قیمت ویزیت ندارد (فقط `deposit_amount_rials`). + +## وظایف + +### ۱. Backend — فلگ `require_visit_price` روی EntityInsurancePricing + +ستون boolean جدید روی همان ردیف free-visit (بدون endpoint جدید — توسعه endpoint موجود، طبق قاعده ۲): + +```php +#[ORM\Column(name: 'require_visit_price', type: 'boolean', options: ['default' => false])] +private bool $requireVisitPrice = false; + +public function isRequireVisitPrice(): bool { return $this->requireVisitPrice; } +public function setRequireVisitPrice(bool $v): self { $this->requireVisitPrice = $v; $this->updatedAt = time(); return $this; } +``` + +- `toArray()` هم `require_visit_price` را اضافه کن. +- Migration: `ddev exec php bin/console doctrine:migrations:diff` سپس `migrate`. +- در `EntityInsurancePricingRepository` (یا متد کمکی در سرویس مشترک) یک lookup ساده: فلگ فعال است اگر ردیف free-visit موجود و `requireVisitPrice === true`. + +### ۲. Backend — `GET`/`PUT /api/v1/insurance-pricing` + +در `getInsurancePricing`: کنار `free_visit_price_rials`، کلید `require_visit_price` (از ردیف free-visit، پیش‌فرض `false`) برگردان. + +در `saveInsurancePricing`: + +```php +$requireVisitPrice = null; +if (array_key_exists('require_visit_price', $data)) { + $requireVisitPrice = (bool) $data['require_visit_price']; +} +$price = array_key_exists('free_visit_price_rials', $data) ? (int) $data['free_visit_price_rials'] : /* مقدار فعلی ردیف free-visit یا 0 */; + +// اگر فلگ (جدید یا ذخیره‌شده قبلی) فعال است، قیمت باید > 0 باشد +$effectiveFlag = $requireVisitPrice ?? /* فلگ ذخیره‌شده فعلی */; +if ($effectiveFlag && $price <= 0) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است', 422, 'free_visit_price_rials'); +} +``` + +سپس upsert موجود + ست کردن فلگ روی ردیف free-visit. (اگر فقط فلگ ارسال شود و ردیف free-visit وجود نداشته باشد، ردیف با قیمت 0 ساخته نشود مگر فلگ false باشد — سناریوی مرزی تست شود.) + +### ۳. Backend — الزامی شدن `visit_price_rials` در ثبت session + +در `PatientService::createSession` (یا کنترلر آن، هر جا اعتبارسنجی‌های مشابه انجام می‌شود)، قبل از ساخت session: + +```php +$requireVisit = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null)?->isRequireVisitPrice() ?? false; +if ($requireVisit && (int) ($data['visit_price_rials'] ?? 0) <= 0) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هزینه ویزیت الزامی است', 422); +} +``` + +(الگوی خطا را با بقیه اعتبارسنجی‌های همین مسیر هماهنگ کن — اگر کنترلر `$this->error()` برمی‌گرداند همان الگو.) + +### ۴. Backend — فیلد `visit_price_rials` روی Appointment + +- ستون nullable روی `Appointment`: + +```php +#[ORM\Column(name: 'visit_price_rials', type: 'integer', nullable: true)] +private ?int $visitPriceRials = null; +``` + +- getter/setter + افزودن به `toArray()` (کنار `deposit_amount_rials`). +- Migration جدید. +- در endpointهای ایجاد نوبت (`POST /api/v1/admin/appointment` و `POST /api/v1/my/appointment` — کنترلر مربوطه را با `debug:router` پیدا کن): `visit_price_rials` را از payload بپذیر و ست کن. اعتبارسنجی: فلگ را برای entity پزشکِ نوبت (doctor) resolve کن؛ اگر فعال بود و مقدار ارسالی `<= 0` بود → خطای 422 با پیام فارسی «هزینه ویزیت الزامی است». +- توجه: `resolveEntity` در `InsuranceController` بر اساس کاربر جاری است؛ برای ایجاد نوبت توسط admin/منشی، فلگ باید بر اساس **پزشک نوبت** (و در نبود قیمت‌گذاری پزشک، کلینیک مرتبط — همان ترتیبی که `BillingCalculator`/pricing فعلی استفاده می‌کند) خوانده شود، نه کاربر لاگین‌شده. + +### ۵. Frontend — `FreeVisitPrice.tsx` + +- Toggle «الزامی کردن هزینه ویزیت» (همان الگوی سوییچ `AppointmentCreatePage` خطوط ۴۳۷–۴۵۰) زیر فیلد قیمت. +- Helper text (متن راهنما) زیر toggle با استایل `fontSize:12, color:'var(--text-3)'` — متن پیشنهادی: + «با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی می‌شود و بدون آن امکان ذخیره وجود ندارد.» +- interface را گسترش بده: `interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }` و state محلی برای toggle. +- `saveMut` هر دو کلید را بفرستد: `{ free_visit_price_rials, require_visit_price }`. +- اعتبارسنجی client-side: اگر toggle فعال و قیمت خالی/صفر → دکمه ذخیره خطا بدهد (پیام خطای فارسی زیر فیلد + `toast.error`)، درخواست ارسال نشود. +- وقتی toggle فعال است، label قیمت با `*` قرمز: «قیمت (تومان) *». + +### ۶. Frontend — `CreateStep.tsx` + +- query `insurance-pricing` از قبل هست؛ فلگ را بخوان: + +```tsx +const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false; +``` + +- label: `قیمت ویزیت (تومان){requireVisit && ' *'}` (ستاره قرمز). +- در `submit()` قبل از mutate: + +```tsx +if (requireVisit && visit <= 0) { + toast.error('هزینه ویزیت الزامی است'); + return; +} +``` + +- پیام خطای inline زیر فیلد وقتی الزامی و خالی/صفر (state خطا که با تغییر مقدار پاک شود). + +### ۷. Frontend — `AppointmentCreatePage.tsx` + +- query جدید: `useQuery({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing') })` → `freeVisit` و `requireVisit`. +- بخش جدید «هزینه ویزیت» (بعد از «بیعانه» یا کنار آن): `PriceInput` با state `visitPriceRials`، مقدار اولیه از `freeVisit` (با `useEffect` مشابه CreateStep، فقط وقتی کاربر دستی تغییر نداده). +- label با `*` وقتی `requireVisit` فعال است؛ helper کوتاه «هزینه ویزیت این نوبت (تومان)». +- payload: `...(visitPriceRials > 0 || requireVisit ? { visit_price_rials: visitPriceRials } : {})` — و شرط `valid` را گسترش بده: `&& (!requireVisit || visitPriceRials > 0)` تا دکمه «ثبت اطلاعات» بدون مقدار غیرفعال بماند. +- پیام inline قرمز زیر فیلد وقتی الزامی و صفر. + +### ۸. تست‌ها و مستندات + +- **PHPUnit:** ذخیره تنظیمات با فلگ فعال و قیمت 0 → 422؛ با قیمت معتبر → 200؛ ثبت session با فلگ فعال بدون `visit_price_rials` → 422، با مقدار → 201؛ فلگ غیرفعال → رفتار قبلی (صفر مجاز)؛ ایجاد نوبت با/بدون فلگ. +- **Vitest:** `FreeVisitPrice` — toggle فعال + قیمت خالی → خطا و عدم ارسال؛ `CreateStep` — الزامی بودن قیمت ویزیت با فلگ (mock query)؛ سناریوی فلگ غیرفعال بدون تغییر رفتار. +- `docs/api/insurance.md` (کلید جدید `require_visit_price` در GET/PUT insurance-pricing + خطای 422)، `docs/api/patient.md` (اعتبارسنجی جدید session)، `docs/api/appointment.md` (فیلد جدید `visit_price_rials`) — همه در همین سشن. + +## نکات مهم + +- **واحدها:** backend ریال (`_rials`)، UI تومان — تبدیل با `rialToToman`/`tomanToRial` (الگوی `FreeVisitPrice`). `PriceInput` مقدار ریالی نگه می‌دارد (الگوی `depositRials`) — سازگاری واحد را در AppointmentCreatePage دوبار چک کن. +- **Envelope:** پاسخ insurance-pricing با `$this->success([...])` تک‌سطح است و frontend فعلی با `(data as any)?.data` می‌خواند — همین الگو را حفظ کن، double-nesting نساز. +- گارد اصلی backend است؛ اعتبارسنجی UI فقط تجربه کاربری. هر سه endpoint (insurance-pricing PUT، session POST، appointment POST) باید مستقل از UI مقدار را رد کنند. +- edge case: فلگ فعال ولی ردیف free-visit حذف/بدون قیمت → ثبت مراجعه باید 422 بدهد نه crash؛ `?->` و پیش‌فرض `false` رعایت شود. +- edge case: کاربر با نقش admin (بدون پروفایل پزشک) در appointments/new — query insurance-pricing ممکن است 403 بدهد (`resolveEntity` کاربر جاری)؛ در این حالت فلگ را `false` فرض کن و فیلد اختیاری بماند، یا فلگ را بر اساس پزشک انتخاب‌شده از endpoint مناسب بخوان — هنگام پیاده‌سازی بررسی و مستند کن. +- دو migration جدا (EntityInsurancePricing، Appointment) یا یکی — خروجی `migrations:diff` را قبل از migrate بازبینی کن. +- رشته‌های UI فارسی؛ کد/کامیت انگلیسی. +- سوییچ toggle را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم. diff --git a/assets/admin/components/FreeVisitPrice.tsx b/assets/admin/components/FreeVisitPrice.tsx index dafdf824..05619fde 100644 --- a/assets/admin/components/FreeVisitPrice.tsx +++ b/assets/admin/components/FreeVisitPrice.tsx @@ -4,11 +4,13 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; -interface Pricing { free_visit_price_rials: number } +interface Pricing { free_visit_price_rials: number; require_visit_price: boolean } export default function FreeVisitPrice() { const qc = useQueryClient(); const [value, setValue] = useState(''); + const [required, setRequired] = useState(false); + const [error, setError] = useState(''); const { data } = useQuery<{ data: Pricing }>({ queryKey: ['insurance-pricing'], @@ -17,11 +19,17 @@ export default function FreeVisitPrice() { const pricing = (data as any)?.data as Pricing | undefined; useEffect(() => { - if (pricing) setValue(String(rialToToman(pricing.free_visit_price_rials ?? 0))); + if (pricing) { + setValue(String(rialToToman(pricing.free_visit_price_rials ?? 0))); + setRequired(!!pricing.require_visit_price); + } }, [pricing]); const saveMut = useMutation({ - mutationFn: () => api.put('/api/v1/insurance-pricing', { free_visit_price_rials: tomanToRial(Number(value) || 0) }), + mutationFn: () => api.put('/api/v1/insurance-pricing', { + free_visit_price_rials: tomanToRial(Number(value) || 0), + require_visit_price: required, + }), onSuccess: () => { toast.success('قیمت ویزیت ذخیره شد'); qc.invalidateQueries({ queryKey: ['insurance-pricing'] }); @@ -29,6 +37,15 @@ export default function FreeVisitPrice() { onError: (e: Error) => toast.error(e.message), }); + const save = () => { + if (required && (Number(value) || 0) <= 0) { + setError('با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است'); + return; + } + setError(''); + saveMut.mutate(); + }; + return (

قیمت ویزیت آزاد

@@ -37,19 +54,46 @@ export default function FreeVisitPrice() {

- + setValue(e.target.value)} + aria-invalid={!!error} + value={value} onChange={(e) => { setValue(e.target.value); setError(''); }} />
- {value !== '' && ( {formatRial(tomanToRial(Number(value) || 0))} )}
+ {error && ( +

{error}

+ )} + + +

+ با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی می‌شود و بدون آن امکان ذخیره وجود ندارد. +

); } diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx index a2a80728..0943c365 100644 --- a/assets/admin/components/session/CreateStep.tsx +++ b/assets/admin/components/session/CreateStep.tsx @@ -5,7 +5,7 @@ import { PlusIcon, MinusIcon } from '@heroicons/react/24/outline'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import type { PatientProfile, ServiceSection, ServiceItem } from '../../types'; -import { formatRial } from '../../lib/utils'; +import { formatRial, rialToToman, tomanToRial } from '../../lib/utils'; import SearchableSelect from '../ui/SearchableSelect'; import PersianDateInput from '../ui/PersianDateInput'; import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons'; @@ -76,6 +76,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel }: const [selectedConsumables, setSelectedConsumables] = useState<{ uuid: string; name: string; price: number; qty: number }[]>([]); const [packageUuid, setPackageUuid] = useState(''); const [visitPrice, setVisitPrice] = useState('0'); + const [visitPriceError, setVisitPriceError] = useState(''); const [baseId, setBaseId] = useState(''); const [suppId, setSuppId] = useState(''); const [basePercent, setBasePercent] = useState('0'); @@ -103,13 +104,14 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel }: const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({ queryKey: ['tenant-insurances'], queryFn: () => api.get('/api/v1/billing/tenant-insurances'), }); - const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number } }>({ + const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), }); const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0; + const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false; useEffect(() => { - if (freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(freeVisit)); + if (freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(rialToToman(freeVisit))); }, [freeVisit]); // eslint-disable-line react-hooks/exhaustive-deps const contracts = (contractsData as any)?.data?.data as Contract[] | undefined ?? []; @@ -194,7 +196,8 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel }: }; // ── قیمت‌ها (آینه‌ی سرور) ──────────────────────────────────────────────── - const visit = Number(visitPrice) || 0; + // فیلد «قیمت ویزیت» تومان است؛ محاسبات و API ریالی‌اند. + const visit = tomanToRial(Number(visitPrice) || 0); const base = Number(basePercent) || 0; const supp = Number(suppPercent) || 0; const servicesTotal = useMemo(() => selectedServices.reduce((s, x) => s + x.price * x.qty, 0), [selectedServices]); @@ -222,6 +225,11 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel }: }); const submit = () => { + if (requireVisit && visit <= 0) { + setVisitPriceError('هزینه ویزیت الزامی است'); + toast.error('هزینه ویزیت الزامی است'); + return; + } createMut.mutate({ visit_price_rials: visit, base_insurance_discount_percent: showInsurance ? base : 0, @@ -366,8 +374,18 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel }: {/* قیمت ویزیت */}
- قیمت ویزیت (تومان) - setVisitPrice(e.target.value)} /> + + قیمت ویزیت (تومان){requireVisit && *} + + { setVisitPrice(e.target.value); setVisitPriceError(''); }} + /> + {visitPriceError && ( + {visitPriceError} + )}
{/* بیمه — منطق موجود NewSessionPage؛ فقط وقتی سرویس تحت پوشش یا بیمار بیمه دارد */} diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index 2bf9c518..ca2002f1 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -13,7 +13,7 @@ import SearchableSelect from '../components/ui/SearchableSelect'; import { WalletChargeLink } from '../components/AppointmentActions'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; -import { tehranWallClockToUnix } from '../lib/utils'; +import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils'; /** * افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای @@ -104,6 +104,21 @@ export default function AppointmentCreatePage() { const [status, setStatus] = useState('pending'); const [note, setNote] = useState(''); + // ── هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت» (کاربر بدون + // پروفایل doctor/clinic از این endpoint 403 می‌گیرد → فلگ false و فیلد اختیاری می‌ماند) + const pricingQ = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({ + queryKey: ['insurance-pricing'], + queryFn: () => api.get('/api/v1/insurance-pricing'), + }); + const freeVisit = (pricingQ.data as any)?.data?.free_visit_price_rials ?? 0; + const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false; + // فیلد UI تومان است؛ API ریالی (visit_price_rials). + const [visitPriceToman, setVisitPriceToman] = useState(0); + const [visitPriceTouched, setVisitPriceTouched] = useState(false); + useEffect(() => { + if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit)); + }, [freeVisit, visitPriceTouched]); + const effectiveName = picked?.user_name || name.trim(); const effectiveMobile = picked?.user_mobile || mobile.trim(); const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, ''); @@ -111,7 +126,7 @@ export default function AppointmentCreatePage() { ? (servicePick.serviceUuids.length > 0 && !!servicePick.slot) : (!!start && !!end); const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 - && effectiveNationalCode.length === 10 && timingValid; + && effectiveNationalCode.length === 10 && timingValid && (!requireVisit || visitPriceToman > 0); const create = useMutation({ mutationFn: async () => { @@ -131,6 +146,7 @@ export default function AppointmentCreatePage() { }), ...(staffUuid ? { staff_uuid: staffUuid } : {}), ...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}), + ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), ...(note.trim() ? { note: note.trim() } : {}), }; const res: any = await api.post(createEndpoint, payload); @@ -458,6 +474,20 @@ export default function AppointmentCreatePage() { )} + {/* هزینه ویزیت */} +
هزینه ویزیت
+
+ +
+ { setVisitPriceToman(v); setVisitPriceTouched(true); }} /> +
+ {requireVisit && visitPriceToman <= 0 && ( + هزینه ویزیت الزامی است + )} +
+
diff --git a/docs/api/admin.md b/docs/api/admin.md index 285e9b6d..20ad16c6 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -602,6 +602,7 @@ Create a new appointment for a patient. If no user exists with the given mobile, "service_item_uuids": ["service-uuid-1", "service-uuid-2"], "duration_from_services": false, "service_durations": { "service-uuid-1": 75 }, + "visit_price_rials": 3000000, "note": "optional note" } ``` @@ -609,6 +610,8 @@ Create a new appointment for a patient. If no user exists with the given mobile, > `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد و روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** و سپس با موبایل resolve می‌شود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص می‌تواند چند موبایل داشته باشد). اگر بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود. > > `service_item_uuids[]` (اختیاری): یک یا چند سرویس که به نوبت پیوست می‌شوند؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` پاسخ برمی‌گردند. UUID ناموجود ⇒ `422`. با `duration_from_services: true` مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود (سرویسِ غیرbookable/بدون مدت ⇒ `422`)؛ بدون آن ساعت پایانِ دستی حفظ می‌ماند. `service_durations` (`{ "": }`، فقط با `duration_from_services=true`): override مدتِ هر سرویس برای همان نوبت؛ پیش‌فرضِ سرویس در تنظیمات تغییر نمی‌کند. +> +> `visit_price_rials` (اختیاری/شرطی): هزینه ویزیت (ریال) که روی نوبت ذخیره می‌شود. اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار `> 0` الزامی است. ### Response `201` ```json @@ -626,7 +629,7 @@ Create a new appointment for a patient. If no user exists with the given mobile, ### Error Responses | Code | HTTP | Description | |------|------|-------------| -| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name), or missing/invalid `patient_national_code` (`field: patient_national_code`) | +| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name), or missing/invalid `patient_national_code` (`field: patient_national_code`), or required `visit_price_rials <= 0` when `require_visit_price` is on (`field: visit_price_rials`) | | `ERR_PROFILE_MOBILE_TAKEN` | 422 | این شماره موبایل با کد ملی دیگری ثبت شده است (`field: patient_mobile`) | | `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found | | `SLOT_TAKEN` | 409 | Slot already booked | diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 37082ec7..5489a617 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -474,6 +474,7 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book | `patient_mobile` | string | ✅ | راه تماس بیمار | | `patient_name` | string | ✅ | نام بیمار — فقط برای بیمارِ **کاملاً جدید** استفاده می‌شود؛ اگر کد ملی به پروفایلِ موجود بخورد، نامِ همان پروفایل روی نوبت ذخیره و نمایش داده می‌شود و این ورودی نادیده گرفته می‌شود | | `patient_national_code` | string | ✅ | کد ملی بیمار — باید ۱۰ رقم معتبر باشد (`isValidIranNationalCode`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند | +| `visit_price_rials` | int | شرطی | هزینه ویزیت (ریال). اختیاری؛ ولی اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار `> 0` الزامی است. روی نوبت ذخیره و در `toArray` با کلید `visit_price_rials` برمی‌گردد | > **هویت بیمار بر پایه‌ی کد ملی:** کد ملی روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** پیدا می‌شود، سپس با موبایل. پس یک شخص می‌تواند چند موبایل داشته باشد ولی پرونده‌اش (`PatientRecord`) یکتا می‌ماند. اگر موبایلی که پروفایلش کد ملی دیگری دارد دوباره با کد ملی متفاوت ارسال شود، خطای 422 برمی‌گردد. اگر هیچ بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود. موبایلِ واردشده در هر نوبت به‌صورت snapshot روی خودِ نوبت (`patient_mobile`) هم ذخیره می‌شود. > @@ -496,7 +497,7 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book | Code | HTTP | Description | |------|------|-------------| | `FORBIDDEN` | 403 | Role not allowed, or caller not scoped to this doctor | -| `VALIDATION` | 422 | Missing required fields, or missing/invalid `patient_national_code` (`field: patient_national_code`) | +| `VALIDATION` | 422 | Missing required fields, or missing/invalid `patient_national_code` (`field: patient_national_code`), or required `visit_price_rials <= 0` when `require_visit_price` is on (`field: visit_price_rials`) | | `ERR_PROFILE_MOBILE_TAKEN` | 422 | این شماره موبایل با کد ملی دیگری ثبت شده است (`field: patient_mobile`) | | `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found | | `SLOT_TAKEN` | 409 | Slot already booked | @@ -597,7 +598,7 @@ Role-aware paginated list of appointments. Returns only what the authenticated u ## Clinic workflow extensions (نوبت‌ها — Figma) -New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویس‌های نوبت — چند سرویس، هر عضو `{uuid, name}`), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `is_reserve` (نوبت رزرو — day-level, never occupies a slot). +New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویس‌های نوبت — چند سرویس، هر عضو `{uuid, name}`), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `visit_price_rials` (هزینه ویزیت، nullable), `is_reserve` (نوبت رزرو — day-level, never occupies a slot). New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions: `pending → confirmed|following_up|cancelled_*|expired` · `confirmed → completed|following_up|salon|cancelled_*|no_show` · `following_up → confirmed|salon|completed|cancelled_*|no_show` · `salon → completed|following_up|cancelled_*|no_show` @@ -631,7 +632,7 @@ Response `200`: `{ success, data: { data: } }` | 409 | slot taken or version conflict | ### POST `/api/v1/my/appointment` (extended) -Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `is_reserve`, `service_item_uuids[]`, `duration_from_services`. +Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `visit_price_rials`, `is_reserve`, `service_item_uuids[]`, `duration_from_services`. `is_reserve: true` → day-level reserve entry: `slot_end` may equal `slot_start`, the past-slot rule is skipped, and the entry never occupies a slot (several reserves may share a day). Response `201` now also returns `is_reserve`. `service_item_uuids[]` (غیرِ رزرو): یک یا چند سرویس که به نوبت **پیوست** می‌شوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` برمی‌گردند. UUID ناموجود ⇒ `422`. `duration_from_services: true` (حالت نوبت‌دهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ می‌شود. diff --git a/docs/api/insurance.md b/docs/api/insurance.md index 14f6d6bf..95cf26c8 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -292,6 +292,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR "entity_type": "doctor", "entity_id": 7, "free_visit_price_rials": 5000000, + "require_visit_price": false, "insurances": [ { "insurance_id": 3, @@ -311,6 +312,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR ``` - `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد). +- `require_visit_price` — فلگ «الزامی کردن هزینه ویزیت». وقتی `true` باشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (`> 0`) رد می‌شوند. ### خطاها - `403` `ERR_FORBIDDEN_001` — پروفایل (doctor/clinic) برای کاربر یافت نشد. @@ -327,6 +329,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR ```json { "free_visit_price_rials": 5000000, + "require_visit_price": true, "insurances": [ { "insurance_id": 3, "patient_share_rials": 1500000 }, { "insurance_id": 9, "patient_share_rials": null } @@ -337,14 +340,18 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR | فیلد | نوع | توضیح | |------|-----|-------| | `free_visit_price_rials` | int | مبلغ ویزیت آزاد (ریال). اختیاری؛ اگر نباشد تغییر نمی‌کند. | +| `require_visit_price` | bool | فلگ «الزامی کردن هزینه ویزیت». اختیاری؛ اگر نباشد مقدار ذخیره‌شده حفظ می‌شود. | | `insurances[].insurance_id` | int | شناسه‌ی بیمه (الزامی برای هر ردیف). | | `insurances[].patient_share_rials` | int \| null | سهم بیمار با این بیمه. `null` → ردیف حذف می‌شود. | +اعتبارسنجی: اگر فلگ مؤثر (ارسالی یا ذخیره‌شده) `true` باشد و قیمت مؤثر (ارسالی یا ذخیره‌شده) `<= 0`، درخواست رد می‌شود. + ### Response `200` همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره). ### خطاها - `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد. +- `422` `ERR_VALIDATION_001` (field: `free_visit_price_rials`) — فلگ الزامی فعال است ولی قیمت ویزیت آزاد `<= 0`. --- diff --git a/docs/api/patient.md b/docs/api/patient.md index 1ab6b878..1da259ee 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -431,6 +431,7 @@ Creates a new visit session for a patient record. **Field notes:** +- `visit_price_rials`: هزینه ویزیت (ریال). به‌طور پیش‌فرض اختیاری (پیش‌فرض `0`)؛ اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای tenant فعال باشد، مقدار `> 0` **الزامی** است. - `payment_method`: `cash` | `card` | `insurance` | `online` | `pending` - `session_at` (اختیاری): زمان پذیرش (unix)؛ اگر نیاید `null` می‌ماند و زمان ثبت (`created_at`) مبنا است. - `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته می‌شود، وگرنه بی‌صدا نادیده گرفته می‌شود. روی قیمت اثری ندارد (فقط مرجع). @@ -459,6 +460,7 @@ Creates a new visit session for a patient record. |------|------|-------------| | `ERR_PATIENT_NOT_FOUND` | 404 | Record not found or not owned | | `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature | +| `ERR_VALIDATION_001` | 422 | فلگ `require_visit_price` فعال است ولی `visit_price_rials <= 0` (field: `visit_price_rials`) | --- diff --git a/migrations/Version20260716124039.php b/migrations/Version20260716124039.php new file mode 100644 index 00000000..3bf592d1 --- /dev/null +++ b/migrations/Version20260716124039.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE entity_insurance_pricing ADD require_visit_price TINYINT DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE entity_insurance_pricing DROP require_visit_price'); + } +} diff --git a/migrations/Version20260716130713.php b/migrations/Version20260716130713.php new file mode 100644 index 00000000..f008b2a8 --- /dev/null +++ b/migrations/Version20260716130713.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE appointments ADD visit_price_rials INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE appointments DROP visit_price_rials'); + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e6e4bfed..97563a5d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,17 @@ parameters: ignoreErrors: + - + message: '#^Dead catch \- Doctrine\\DBAL\\Exception\\UniqueConstraintViolationException is never thrown in the try block\.$#' + identifier: catch.neverThrown + count: 1 + path: src/Appointment/Controller/AppointmentController.php + + - + message: '#^Property App\\Patient\\Entity\\PatientRecord\:\:\$tags on left side of \?\?\= is not nullable nor uninitialized\.$#' + identifier: nullCoalesce.initializedProperty + count: 1 + path: src/Patient/Entity/PatientRecord.php + - message: '#^Strict comparison using \!\=\= between mixed and null will always evaluate to true\.$#' identifier: notIdentical.alwaysTrue @@ -99,7 +111,7 @@ parameters: - message: '#^Strict comparison using \!\=\= between mixed and null will always evaluate to true\.$#' identifier: notIdentical.alwaysTrue - count: 4 + count: 6 path: src/Insurance/Controller/InsuranceController.php - diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index f005dfe4..b1c4206f 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -42,6 +42,7 @@ class AdminApiController extends BaseController private readonly \App\Payment\Service\PaymentManager $paymentManager, private readonly \App\Payment\Repository\PaymentRepository $paymentRepo, private readonly \App\Patient\Service\PatientResolver $patientResolver, + private readonly \App\Insurance\Service\VisitPriceRequirementResolver $visitPriceResolver, ) {} // ── Users ───────────────────────────────────────────────────────────────── @@ -911,6 +912,11 @@ class AdminApiController extends BaseController $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]); if (!$doctor) return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404); + $visitPriceRials = isset($data['visit_price_rials']) ? (int) $data['visit_price_rials'] : null; + if ($this->visitPriceResolver->isRequiredForDoctor($doctor) && ($visitPriceRials ?? 0) <= 0) { + return $this->error(ErrorCodes::VALIDATION, 'هزینه ویزیت الزامی است', 422, 'visit_price_rials'); + } + // Identity is keyed on the national code (unique) so the case-file stays // single per person even when booked under a different mobile. $patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName); @@ -920,6 +926,7 @@ class AdminApiController extends BaseController $appointment->setPatientName($patientName); $appointment->setPatientMobile($mobile); if (!empty($data['note'])) $appointment->setNote($data['note']); + if ($visitPriceRials !== null) $appointment->setVisitPriceRials($visitPriceRials); foreach ($serviceItems as $si) { $appointment->addServiceItem($si); } diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 2944c493..8d6f2ede 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -12,6 +12,7 @@ use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Entity\Doctor; use App\Doctor\Repository\DoctorRepository; +use App\Insurance\Service\VisitPriceRequirementResolver; use App\Patient\Service\PatientResolver; use App\Secretary\Entity\DoctorSecretary; use App\Secretary\Repository\DoctorSecretaryRepository; @@ -42,6 +43,7 @@ class MyAppointmentsController extends BaseController private readonly PatientResolver $patientResolver, private readonly \App\Auth\Repository\UserRepository $userRepo, private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo, + private readonly VisitPriceRequirementResolver $visitPriceResolver, ) {} #[Route('/api/v1/my/appointment', methods: ['POST'])] @@ -127,6 +129,11 @@ class MyAppointmentsController extends BaseController return $this->error(ErrorCodes::FORBIDDEN, 'برای این پزشک مجاز به ثبت نوبت نیستید', 403); } + $visitPriceRials = isset($data['visit_price_rials']) ? (int) $data['visit_price_rials'] : null; + if ($this->visitPriceResolver->isRequiredForDoctor($doctor) && ($visitPriceRials ?? 0) <= 0) { + return $this->error(ErrorCodes::VALIDATION, 'هزینه ویزیت الزامی است', 422, 'visit_price_rials'); + } + // Identity is keyed on the national code (unique) so the case-file stays // single per person even when booked under a different mobile. $patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName); @@ -163,6 +170,9 @@ class MyAppointmentsController extends BaseController if (isset($data['deposit_amount_rials'])) { $appointment->setDepositAmountRials((int) $data['deposit_amount_rials']); } + if ($visitPriceRials !== null) { + $appointment->setVisitPriceRials($visitPriceRials); + } // The resolver returns the patient keyed on national code, so its profile // name is the real identity. Snapshot that (not the free-typed modal name) // so the appointment never diverges from an existing profile; fall back to diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index ed6c0428..d7c9468d 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -159,6 +159,9 @@ class Appointment #[ORM\Column(name: 'deposit_amount_rials', type: 'integer', nullable: true)] private ?int $depositAmountRials = null; + #[ORM\Column(name: 'visit_price_rials', type: 'integer', nullable: true)] + private ?int $visitPriceRials = null; + /** * Reserve-list entry (نوبت رزرو): booked for a day, not a time slot. * slotStart/slotEnd hold that day's midnight so date queries keep working. @@ -245,6 +248,7 @@ class Appointment public function getStaff(): ?\App\Staff\Entity\ClinicStaff { return $this->staff; } public function isDepositRequired(): bool { return $this->depositRequired; } public function getDepositAmountRials(): ?int { return $this->depositAmountRials; } + public function getVisitPriceRials(): ?int { return $this->visitPriceRials; } public function isReserve(): bool { return $this->isReserve; } public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; } @@ -252,6 +256,7 @@ class Appointment public function setStaff(?\App\Staff\Entity\ClinicStaff $v): self { $this->staff = $v; return $this; } public function setDepositRequired(bool $v): self { $this->depositRequired = $v; return $this; } public function setDepositAmountRials(?int $v): self { $this->depositAmountRials = $v; return $this; } + public function setVisitPriceRials(?int $v): self { $this->visitPriceRials = $v; return $this; } /** * Move the appointment to a new slot (جا به جایی نوبت) and/or flip its @@ -347,6 +352,7 @@ class Appointment 'staff' => $this->staff ? ['uuid' => $this->staff->getUuid(), 'full_name' => $this->staff->getFullName()] : null, 'deposit_required' => $this->depositRequired, 'deposit_amount_rials' => $this->depositAmountRials, + 'visit_price_rials' => $this->visitPriceRials, 'is_reserve' => $this->isReserve, 'version' => $this->version, 'created_at' => $this->createdAt, diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php index 0e7b039d..66da8b20 100644 --- a/src/Insurance/Controller/InsuranceController.php +++ b/src/Insurance/Controller/InsuranceController.php @@ -229,10 +229,12 @@ class InsuranceController extends BaseController $rows = $this->pricingRepo->findByEntity($entityType, $entityId); $freeVisitPriceRials = 0; + $requireVisitPrice = false; $perInsurance = []; foreach ($rows as $row) { if ($row->isFreeVisit()) { $freeVisitPriceRials = $row->getPatientShareRials(); + $requireVisitPrice = $row->isRequireVisitPrice(); } else { $perInsurance[$row->getInsuranceId()] = $row->getPatientShareRials(); } @@ -251,6 +253,7 @@ class InsuranceController extends BaseController 'entity_type' => $entityType, 'entity_id' => $entityId, 'free_visit_price_rials' => $freeVisitPriceRials, + 'require_visit_price' => $requireVisitPrice, 'insurances' => $insurances, ]); } @@ -266,8 +269,24 @@ class InsuranceController extends BaseController $data = json_decode($request->getContent(), true) ?? []; - if (array_key_exists('free_visit_price_rials', $data)) { - $this->upsertPricing($entityType, $entityId, null, (int) $data['free_visit_price_rials']); + $freeVisitRow = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null); + + $requireVisitPrice = array_key_exists('require_visit_price', $data) + ? (bool) $data['require_visit_price'] + : ($freeVisitRow?->isRequireVisitPrice() ?? false); + + $freeVisitPrice = array_key_exists('free_visit_price_rials', $data) + ? (int) $data['free_visit_price_rials'] + : ($freeVisitRow?->getPatientShareRials() ?? 0); + + if ($requireVisitPrice && $freeVisitPrice <= 0) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است', 422, 'free_visit_price_rials'); + } + + $touchesFreeVisit = array_key_exists('free_visit_price_rials', $data) || array_key_exists('require_visit_price', $data); + if ($touchesFreeVisit && ($freeVisitRow !== null || $freeVisitPrice > 0)) { + $this->upsertPricing($entityType, $entityId, null, $freeVisitPrice) + ->setRequireVisitPrice($requireVisitPrice); } foreach (($data['insurances'] ?? []) as $row) { @@ -290,7 +309,7 @@ class InsuranceController extends BaseController return $this->getInsurancePricing($user); } - private function upsertPricing(string $entityType, int $entityId, ?int $insuranceId, int $shareRials): void + private function upsertPricing(string $entityType, int $entityId, ?int $insuranceId, int $shareRials): EntityInsurancePricing { $row = $this->pricingRepo->findOneForInsurance($entityType, $entityId, $insuranceId); if ($row === null) { @@ -299,6 +318,8 @@ class InsuranceController extends BaseController $row->setPatientShareRials($shareRials); } $this->pricingRepo->save($row, false); + + return $row; } // ── TenantInsurance — قراردادهای بیمه‌ی tenant ───────────────────────────── diff --git a/src/Insurance/Entity/EntityInsurancePricing.php b/src/Insurance/Entity/EntityInsurancePricing.php index 9dfc28a4..d3ac36b6 100644 --- a/src/Insurance/Entity/EntityInsurancePricing.php +++ b/src/Insurance/Entity/EntityInsurancePricing.php @@ -31,6 +31,10 @@ class EntityInsurancePricing #[ORM\Column(name: 'patient_share_rials', type: 'integer')] private int $patientShareRials = 0; + /** Only meaningful on the free-visit row (insurance_id = NULL). */ + #[ORM\Column(name: 'require_visit_price', type: 'boolean', options: ['default' => false])] + private bool $requireVisitPrice = false; + #[ORM\Column(name: 'updated_at', type: 'integer')] private int $updatedAt; @@ -50,8 +54,10 @@ class EntityInsurancePricing public function getPatientShareRials(): int { return $this->patientShareRials; } public function isFreeVisit(): bool { return $this->insuranceId === null; } + public function isRequireVisitPrice(): bool { return $this->requireVisitPrice; } public function setPatientShareRials(int $v): self { $this->patientShareRials = $v; $this->updatedAt = time(); return $this; } + public function setRequireVisitPrice(bool $v): self { $this->requireVisitPrice = $v; $this->updatedAt = time(); return $this; } public function toArray(): array { @@ -61,6 +67,7 @@ class EntityInsurancePricing 'entity_id' => $this->entityId, 'insurance_id' => $this->insuranceId, 'patient_share_rials' => $this->patientShareRials, + 'require_visit_price' => $this->requireVisitPrice, ]; } } diff --git a/src/Insurance/Service/VisitPriceRequirementResolver.php b/src/Insurance/Service/VisitPriceRequirementResolver.php new file mode 100644 index 00000000..359e7876 --- /dev/null +++ b/src/Insurance/Service/VisitPriceRequirementResolver.php @@ -0,0 +1,38 @@ +pricingRepo->findOneForInsurance(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null); + if ($row !== null) { + return $row->isRequireVisitPrice(); + } + + $clinics = $this->clinicRepo->findByDoctor($doctor); + if (count($clinics) === 1) { + return $this->pricingRepo + ->findOneForInsurance(EntityInsurancePricing::TYPE_CLINIC, $clinics[0]->getId(), null) + ?->isRequireVisitPrice() ?? false; + } + + return false; + } +} diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index 1513d9bb..22656a1b 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -8,6 +8,7 @@ use App\Billing\Service\BillingCalculator; use App\Billing\ValueObject\Money; use App\ClinicService\Repository\ServiceItemRepository; use App\Clinic\Repository\ClinicRepository; +use App\Insurance\Repository\EntityInsurancePricingRepository; use App\Insurance\Service\TenantInsuranceService; use App\Inventory\Repository\InventoryItemRepository; use App\Inventory\Repository\InventoryPackageRepository; @@ -48,6 +49,7 @@ class PatientService private readonly TenantInsuranceService $tenantInsuranceService, private readonly BillingCalculator $billingCalculator, private readonly WalletService $walletService, + private readonly EntityInsurancePricingRepository $pricingRepo, ) {} /** @@ -138,6 +140,11 @@ class PatientService string $entityType, int $entityId ): PatientSession { + $freeVisitRow = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null); + if (($freeVisitRow?->isRequireVisitPrice() ?? false) && (int) ($data['visit_price_rials'] ?? 0) <= 0) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هزینه ویزیت الزامی است', 422, 'visit_price_rials'); + } + $session = new PatientSession($record); if (!empty($data['appointment_uuid'])) {