feat: add visit price requirement feature

- Introduced a new boolean flag `require_visit_price` in the `EntityInsurancePricing` to enforce visit price for appointments.
- Updated the appointment creation endpoints to validate `visit_price_rials` based on the new flag.
- Added `visit_price_rials` field to the `Appointment` entity to store the visit price.
- Enhanced the `PatientService` to validate visit price during session creation.
- Updated API documentation to reflect changes in appointment and insurance pricing.
- Implemented a new service `VisitPriceRequirementResolver` to determine if a visit price is required for a doctor based on their pricing settings.
- Added migrations to update the database schema for the new fields.
This commit is contained in:
hamed
2026-07-16 19:44:30 +03:30
parent 880c4c06cb
commit a0ddb4c0d1
18 changed files with 497 additions and 22 deletions
@@ -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/<uuid>/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]);
// ...
<span style={fieldLabel}>قیمت ویزیت (تومان)</span>
<input className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت" value={visitPrice} onChange={(e) => 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 را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم.
+50 -6
View File
@@ -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 (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, margin: '0 0 4px' }}>قیمت ویزیت آزاد</h2>
@@ -37,19 +54,46 @@ export default function FreeVisitPrice() {
</p>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>قیمت (تومان)</label>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>
قیمت (تومان){required && <span style={{ color: 'var(--danger)' }}> *</span>}
</label>
<input
type="number" min={0} dir="ltr" className="input" style={{ width: 200 }}
value={value} onChange={(e) => setValue(e.target.value)}
aria-invalid={!!error}
value={value} onChange={(e) => { setValue(e.target.value); setError(''); }}
/>
</div>
<button className="btn primary sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
<button className="btn primary sm" disabled={saveMut.isPending} onClick={save}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
{value !== '' && (
<span style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 8 }}>{formatRial(tomanToRial(Number(value) || 0))}</span>
)}
</div>
{error && (
<p style={{ fontSize: 12, color: 'var(--danger)', margin: '6px 0 0' }}>{error}</p>
)}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 13, cursor: 'pointer', marginTop: 16 }}>
<span style={{
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
background: required ? 'var(--primary)' : '#c4c4c4', transition: 'background .2s',
}}>
<input
type="checkbox" checked={required} role="switch" aria-label="الزامی کردن هزینه ویزیت"
onChange={(e) => { setRequired(e.target.checked); setError(''); }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }}
/>
<span style={{
position: 'absolute', top: 2, insetInlineStart: required ? 22 : 2, width: 18, height: 18,
borderRadius: 999, background: '#fff', transition: 'inset-inline-start .2s', boxShadow: '0 1px 2px rgba(0,0,0,.2)',
}} />
</span>
الزامی کردن هزینه ویزیت
</label>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '6px 0 0', lineHeight: 1.7 }}>
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
</p>
</div>
);
}
+24 -6
View File
@@ -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 }:
{/* قیمت ویزیت */}
<div style={{ marginBottom: 16 }}>
<span style={fieldLabel}>قیمت ویزیت (تومان)</span>
<input className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت" value={visitPrice} onChange={(e) => setVisitPrice(e.target.value)} />
<span style={fieldLabel}>
قیمت ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
</span>
<input
className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت"
aria-invalid={!!visitPriceError}
value={visitPrice}
onChange={(e) => { setVisitPrice(e.target.value); setVisitPriceError(''); }}
/>
{visitPriceError && (
<span style={{ fontSize: 12, color: 'var(--danger)', display: 'block', marginTop: 4 }}>{visitPriceError}</span>
)}
</div>
{/* بیمه — منطق موجود NewSessionPage؛ فقط وقتی سرویس تحت پوشش یا بیمار بیمه دارد */}
+32 -2
View File
@@ -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() {
</div>
)}
{/* هزینه ویزیت */}
<div style={sectionTitle}>هزینه ویزیت</div>
<div style={{ width: 300, maxWidth: '100%', marginBottom: 12 }}>
<label style={label}>
هزینه ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
</label>
<div className="field" style={{ marginTop: 6, height: 44 }}>
<PriceInput value={visitPriceToman} onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }} />
</div>
{requireVisit && visitPriceToman <= 0 && (
<span style={{ fontSize: 12, color: 'var(--danger)', display: 'block', marginTop: 4 }}>هزینه ویزیت الزامی است</span>
)}
</div>
<div style={{ maxWidth: 500 }}>
<label style={label}>انتخاب وضعیت</label>
<div style={{ margin: '6px 0 12px' }}>
+4 -1
View File
@@ -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` (`{ "<uuid>": <minutes> }`، فقط با `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 |
+4 -3
View File
@@ -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: <appointment.toArray()> } }`
| 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`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ می‌شود.
+7
View File
@@ -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`.
---
+2
View File
@@ -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`) |
---
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260716124039 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add require_visit_price flag to entity_insurance_pricing (free-visit row)';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260716130713 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add nullable visit_price_rials to appointments';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+13 -1
View File
@@ -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
-
@@ -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);
}
@@ -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
+6
View File
@@ -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,
@@ -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 ─────────────────────────────
@@ -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,
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Insurance\Service;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Insurance\Entity\EntityInsurancePricing;
use App\Insurance\Repository\EntityInsurancePricingRepository;
/**
* فلگ «الزامی کردن هزینه ویزیت» را برای پزشکِ یک نوبت resolve می‌کند:
* ردیف قیمت‌گذاری خود پزشک اگر موجود باشد؛ وگرنه کلینیکِ واحد پزشک
* (همان ترتیب resolve کردن tenant در PatientService).
*/
class VisitPriceRequirementResolver
{
public function __construct(
private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly ClinicRepository $clinicRepo,
) {}
public function isRequiredForDoctor(Doctor $doctor): bool
{
$row = $this->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;
}
}
+7
View File
@@ -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'])) {