feat: update user roles and passwords in QA driver, enhance documentation with error codes, and improve trial activation error handling
This commit is contained in:
@@ -37,8 +37,8 @@ const ROLES = {
|
|||||||
admin: ['09120671756', 'QaTest@1234'],
|
admin: ['09120671756', 'QaTest@1234'],
|
||||||
clinic: ['09127000000', 'QaTest@1234'],
|
clinic: ['09127000000', 'QaTest@1234'],
|
||||||
secretary: ['09123456778', 'QaTest@1234'],
|
secretary: ['09123456778', 'QaTest@1234'],
|
||||||
doctor: ['09390039833', '09390039833'],
|
doctor: ['09390039833', 'QaTest@1234'],
|
||||||
representation: ['09124000001', '09124000001'],
|
representation: ['09124000001', 'QaTest@1234'],
|
||||||
|
|
||||||
// Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234.
|
// Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234.
|
||||||
doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic
|
doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic
|
||||||
|
|||||||
+84
-92
@@ -2,108 +2,100 @@
|
|||||||
|
|
||||||
**پنل ادمین:** https://clinic-pro.ddev.site/admin
|
**پنل ادمین:** https://clinic-pro.ddev.site/admin
|
||||||
|
|
||||||
> رمز عبور همه کاربران seedشده: `Test@1234`
|
> رمز عبور همهٔ پرسوناها: `QaTest@1234`
|
||||||
|
|
||||||
|
این فایل وضعیت واقعی دیتابیس لوکال پس از بازسازی کامل (drop → migrate → seed) را
|
||||||
|
توصیف میکند. صحتش با `node .claude/skills/qa-clinicpro/driver.mjs roles` قابل
|
||||||
|
تأیید است — اگر ردیفی `✗` گرفت، این فایل کهنه شده است.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ادمین
|
## پرسوناها
|
||||||
|
|
||||||
| فیلد | مقدار |
|
واحد کار «پرسونا» است نه `ROLE_*`؛ پزشک مستقل و پزشک عضو کلینیک هر دو `ROLE_DOCTOR`
|
||||||
| ------ | --------------- |
|
دارند ولی دادهٔ متفاوتی میبینند.
|
||||||
| موبایل | `09100000001` |
|
|
||||||
| پسورد | `Test@1234` |
|
| پرسونا | موبایل | نقشها | تمایز |
|
||||||
| نقش | `ROLE_ADMIN` |
|
|---|---|---|---|
|
||||||
| نام | مدیر سیستم |
|
| `admin` | `09120671756` | `ROLE_ADMIN` | — |
|
||||||
|
| `clinic` | `09127000000` | `ROLE_CLINIC` | مالک «کلینیک تست QA» |
|
||||||
|
| `secretary` | `09123456778` | `ROLE_SECRETARY` | منشیِ `doctor_solo` |
|
||||||
|
| `doctor` | `09390039833` | `ROLE_DOCTOR` | پزشک ساده، بدون کلینیک |
|
||||||
|
| `representation` | `09124000001` | `ROLE_REPRESENTATION` | نمایندهٔ شهری |
|
||||||
|
| `doctor_solo` | `09129000001` | `ROLE_DOCTOR` | مطب شخصی، بدون کلینیک |
|
||||||
|
| `doctor_member` | `09129000002` | `ROLE_DOCTOR` | عضو «کلینیک تست QA» → موقع ورود «انتخاب محیط کاری» میبیند |
|
||||||
|
| `clinic_doctor` | `09129000003` | `ROLE_CLINIC` + `ROLE_DOCTOR` | چندنقشی، مالک «کلینیک تست چندنقشی» |
|
||||||
|
| `secretary_clinic` | `09129000004` | `ROLE_SECRETARY` | منشیِ `doctor_member` در کلینیک |
|
||||||
|
| `unclaimed_doctor` | `09129000005` | `ROLE_UNCLAIMED_DOCTOR` | — |
|
||||||
|
| `patient` | `09129000006` | `ROLE_USER` | کاربر عادی سایت |
|
||||||
|
| `importer` | `09129000007` | `ROLE_IMPORTER` | — |
|
||||||
|
|
||||||
|
`patient`، `unclaimed_doctor` و `importer` به پنل مدیریت دسترسی ندارند و در صفحهٔ
|
||||||
|
ورود پیام «حساب شما دسترسی به پنل مدیریت را ندارد» میگیرند. این باگ نیست:
|
||||||
|
`PasswordAuthenticator` هر کاربری را که `User::isStaff()` نباشد رد میکند.
|
||||||
|
|
||||||
|
## شناسهها
|
||||||
|
|
||||||
|
| موجودیت | UUID |
|
||||||
|
|---|---|
|
||||||
|
| پزشک `doctor_solo` | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` |
|
||||||
|
| پزشک `doctor_member` | `439c9935-77bc-4f72-b73d-2432712bb6f5` |
|
||||||
|
| پزشک `clinic_doctor` | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` |
|
||||||
|
| پزشک `doctor` | `c3311b98-86b7-4d8e-8538-1390c36c2a90` |
|
||||||
|
| پروفایل تصاحبنشده | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` |
|
||||||
|
| کلینیک تست QA | `bcb00726-2343-4d63-90c6-d0175cc74591` |
|
||||||
|
| کلینیک تست چندنقشی | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` |
|
||||||
|
|
||||||
|
هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و
|
||||||
|
برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقهای)
|
||||||
|
دارند، پس صفحات نوبتدهیشان خالی نیستند.
|
||||||
|
|
||||||
|
## دادهٔ انبوه
|
||||||
|
|
||||||
|
`app:seed-demo-data` حدود ۸٬۴۰۰ کاربر، ۱۸۰ پزشک، ۲۰۰ کلینیک، ۲۵ نماینده و ۱۵٬۰۰۰
|
||||||
|
نوبت میسازد — برای تست «دادهٔ زیاد» نیازی به seed اضافه نیست.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## کلینیک نمونه — تبریز
|
## بازسازی از صفر
|
||||||
|
|
||||||
| فیلد | مقدار |
|
ترتیب اجباری است — وابستگیها چرخهایاند:
|
||||||
| ----------- | -------------------------------------- |
|
|
||||||
| موبایل | `09100100000` |
|
|
||||||
| پسورد | `Test@1234` |
|
|
||||||
| نقش | `ROLE_CLINIC` |
|
|
||||||
| نام | کلینیک تخصصی امید تبریز |
|
|
||||||
| UUID کلینیک | `9ac73318-d313-4772-bf1e-418d47f8f4bc` |
|
|
||||||
| شهر | تبریز (id: 101) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## دکتر نمونه کامل — تبریز
|
|
||||||
|
|
||||||
| فیلد | مقدار |
|
|
||||||
| ----------- | -------------------------------------- |
|
|
||||||
| موبایل | `09100100001` |
|
|
||||||
| پسورد | `Test@1234` |
|
|
||||||
| نقش | `ROLE_DOCTOR` |
|
|
||||||
| نام | دکتر آرمان رضایی |
|
|
||||||
| تخصص | قلب و عروق + داخلی |
|
|
||||||
| UUID دکتر | `2a3a7ab9-8d34-4118-862f-b458bcd6d77f` |
|
|
||||||
| کلینیک | کلینیک تخصصی امید تبریز (عضو) |
|
|
||||||
| آدرس مطب | تبریز، خیابان آزادی |
|
|
||||||
| برنامه کاری | شنبه–چهارشنبه ۹–۱۳ و ۱۴–۱۸، پنجشنبه ۹–۱۳ |
|
|
||||||
| بیمه | تأمین اجتماعی، خدمات درمانی، نیروهای مسلح، ایران |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## منشی دکتر نمونه
|
|
||||||
|
|
||||||
| فیلد | مقدار |
|
|
||||||
| ------ | ------------------- |
|
|
||||||
| موبایل | `09100100002` |
|
|
||||||
| پسورد | `Test@1234` |
|
|
||||||
| نقش | `ROLE_SECRETARY` |
|
|
||||||
| نام | خانم نرگس صادقی |
|
|
||||||
| مرتبط | دکتر آرمان رضایی |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## دکتران bulk (۱۵۰۰ دکتر در ۱۵ شهر)
|
|
||||||
|
|
||||||
هر شهر ۱۰۰ دکتر با توزیع واقعی تخصص:
|
|
||||||
|
|
||||||
| شهر | شروع موبایل | تعداد |
|
|
||||||
| ---------- | ------------- | ----- |
|
|
||||||
| تبریز | `09100100003` | ۱۰۰ |
|
|
||||||
| ارومیه | `09100100103` | ۱۰۰ |
|
|
||||||
| اردبیل | `09100100203` | ۱۰۰ |
|
|
||||||
| اصفهان | `09100100303` | ۱۰۰ |
|
|
||||||
| کرج | `09100100403` | ۱۰۰ |
|
|
||||||
| تهران | `09100100503` | ۱۰۰ |
|
|
||||||
| مشهد | `09100100603` | ۱۰۰ |
|
|
||||||
| اهواز | `09100100703` | ۱۰۰ |
|
|
||||||
| شیراز | `09100100803` | ۱۰۰ |
|
|
||||||
| کرمان | `09100100903` | ۱۰۰ |
|
|
||||||
| کرمانشاه | `09100101003` | ۱۰۰ |
|
|
||||||
| رشت | `09100101103` | ۱۰۰ |
|
|
||||||
| ساری | `09100101203` | ۱۰۰ |
|
|
||||||
| همدان | `09100101303` | ۱۰۰ |
|
|
||||||
| یزد | `09100101403` | ۱۰۰ |
|
|
||||||
|
|
||||||
توزیع تخصص در هر شهر:
|
|
||||||
- ۱۵ پزشک عمومی
|
|
||||||
- ۱۰ داخلی + قلب
|
|
||||||
- ۸ جراحی عمومی
|
|
||||||
- ۸ زنان و زایمان
|
|
||||||
- ۸ اطفال
|
|
||||||
- ۷ ارتوپدی
|
|
||||||
- ۶ گوارش، نورولوژی، پوست، چشمپزشکی، دندانپزشکی (هر کدام ۶)
|
|
||||||
- ۵ ENT، روانپزشکی (هر کدام ۵)
|
|
||||||
- ۴ اورولوژی
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ساخت مجدد
|
|
||||||
|
|
||||||
اگر دیتابیس ریست شد، ادمین را مستقیم در دیتابیس بساز:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ddev mysql -e "INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (UUID(), '09100000001', '\$2y\$13\$8.5nFvKRxQGAXfJMXPB6sO.eR.c1RNxj0nJalcQfhzFTiEXnfaJbG', 'مدیر سیستم', '[\"ROLE_USER\",\"ROLE_ADMIN\"]', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());"
|
ddev exec php bin/console doctrine:schema:drop --full-database --force
|
||||||
|
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
ddev exec php bin/console app:create-admin 09120671756 'QaTest@1234'
|
||||||
|
# نمایندهها باید قبل از شهرها باشند: data/seed/cities.json به representation_id
|
||||||
|
# های ۱ تا ۳ ارجاع میدهد و app:seed-categories اعتبارسنجیشان میکند.
|
||||||
|
# POST /api/v1/representation ×۳ (با توکن ادمین)
|
||||||
|
ddev exec php bin/console app:seed-categories --no-interaction
|
||||||
|
ddev exec php bin/console app:seed-sms-message-templates --no-interaction
|
||||||
|
ddev exec php bin/console app:seed-demo-data --purge --no-interaction
|
||||||
```
|
```
|
||||||
|
|
||||||
برای seed دیتای واقعی دکتران و کلینیک:
|
`app:seed-demo-data` خودش بازهٔ `09124000%` را مالک است و نمایندههای مرحلهٔ قبل را
|
||||||
|
purge و بازسازی میکند؛ بعد از آن `cities.representation_id` به شناسههای قدیمی اشاره
|
||||||
|
میکند و باید به شناسههای جدید نگاشت شود.
|
||||||
|
|
||||||
```bash
|
سپس پرسوناها از راه اندپوینتهای خود اپ ساخته میشوند:
|
||||||
ddev exec php seed_realistic_data.php
|
`POST /api/v1/admin/doctors` · `POST /api/v1/admin/clinic` ·
|
||||||
```
|
`POST /api/v1/admin/clinic/{uuid}/invite-doctor` + `POST /api/v1/doctor/invitation/{uuid}/respond` ·
|
||||||
|
`POST /api/v1/secretary` · `POST /api/v1/admin/doctors/import` ·
|
||||||
|
`send-code → verify-code → register` برای `patient`.
|
||||||
|
|
||||||
|
`ROLE_IMPORTER` و `ROLE_UNCLAIMED_DOCTOR` **هیچ مسیر اپلیکیشنی ندارند** — نگاشت نقش
|
||||||
|
در `AdminApiController::updateUserRole` فقط `admin/doctor/secretary/clinic/patient`
|
||||||
|
را میشناسد، پس این دو با SQL مستقیم ست میشوند.
|
||||||
|
|
||||||
|
## نکتهها
|
||||||
|
|
||||||
|
- **کپچا:** نصب تازه `ALTCHA_ENABLED=true` دارد و override پنل خالی است، پس لاگین و
|
||||||
|
ثبتنام اسکریپتی رد میشود. از پنل ادمین یا
|
||||||
|
`PATCH /api/v1/admin/settings {"altcha_enabled":"0"}` غیرفعالش کن.
|
||||||
|
- **کد OTP در dev همیشه `12345` است** (`OtpService::sendCode`).
|
||||||
|
- **`send-code` سقف ۵ درخواست در ساعت بهازای هر IP دارد.** برای تستهای انبوه توکن را
|
||||||
|
مستقیم بساز:
|
||||||
|
`ddev exec 'php bin/console lexik:jwt:generate-token <mobile> --user-class="App\\Auth\\Entity\\User"'`
|
||||||
|
- **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'`
|
||||||
|
و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors`
|
||||||
|
یا `/api/v1/admin/clinic` ساخته میشوند رمز نمیگیرند.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { STATUS_META } from './AppointmentStatusDropdown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors Appointment::STATUS_* on the backend. The dashboard used to keep its
|
||||||
|
* own copy of this map, which drifted and rendered `confirmed` / `expired` as
|
||||||
|
* raw English; every consumer now derives from STATUS_META, so this list is the
|
||||||
|
* one place that has to stay in sync with the entity.
|
||||||
|
*/
|
||||||
|
const BACKEND_STATUSES = [
|
||||||
|
'pending',
|
||||||
|
'confirmed',
|
||||||
|
'completed',
|
||||||
|
'cancelled_by_doctor',
|
||||||
|
'cancelled_by_user',
|
||||||
|
'expired',
|
||||||
|
'no_show',
|
||||||
|
'following_up',
|
||||||
|
'salon',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe('STATUS_META', () => {
|
||||||
|
it.each(BACKEND_STATUSES)('has a Persian label and colour for %s', (status) => {
|
||||||
|
expect(STATUS_META[status]).toBeDefined();
|
||||||
|
expect(STATUS_META[status].label.trim()).not.toBe('');
|
||||||
|
expect(STATUS_META[status].color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries no label that is still English', () => {
|
||||||
|
const latin = Object.entries(STATUS_META)
|
||||||
|
.filter(([, v]) => /[A-Za-z]/.test(v.label))
|
||||||
|
.map(([k]) => k);
|
||||||
|
expect(latin).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines no status the backend does not know about', () => {
|
||||||
|
const unknown = Object.keys(STATUS_META).filter(
|
||||||
|
(k) => !BACKEND_STATUSES.includes(k as (typeof BACKEND_STATUSES)[number]),
|
||||||
|
);
|
||||||
|
expect(unknown).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ const jalaali = require('jalaali-js') as {
|
|||||||
import { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTable';
|
import { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTable';
|
||||||
import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsPanel';
|
import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsPanel';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import { STATUS_META } from '../components/ui/AppointmentStatusDropdown';
|
||||||
|
|
||||||
// ── Chart period (Jalali) ─────────────────────────────────────────────────
|
// ── Chart period (Jalali) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -47,18 +48,15 @@ function useJalaliChartPeriod() {
|
|||||||
|
|
||||||
// ── Shared Status Maps ────────────────────────────────────────────────────
|
// ── Shared Status Maps ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const APPT_LABEL: Record<string, string> = {
|
// Derived from the canonical STATUS_META rather than kept as a second copy —
|
||||||
waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب',
|
// the local map had drifted off the backend's Appointment::STATUS_* set, so
|
||||||
waiting: 'صف انتظار', in_progress: 'در حال ویزیت', visited: 'ویزیت شده',
|
// `confirmed` and `expired` rendered as raw English on the dashboard.
|
||||||
completed: 'تکمیل شده', cancelled_by_doctor: 'لغو پزشک', cancelled_by_user: 'لغو بیمار',
|
const APPT_LABEL: Record<string, string> = Object.fromEntries(
|
||||||
auto_cancel_unpaid: 'لغو خودکار', no_show: 'غیبت',
|
Object.entries(STATUS_META).map(([k, v]) => [k, v.label]),
|
||||||
};
|
);
|
||||||
const APPT_COLOR: Record<string, string> = {
|
const APPT_COLOR: Record<string, string> = Object.fromEntries(
|
||||||
waiting_for_payment: '#f59e0b', reserved: '#3b82f6', checked_in: '#6366f1',
|
Object.entries(STATUS_META).map(([k, v]) => [k, v.color]),
|
||||||
waiting: '#f97316', in_progress: '#8b5cf6', visited: '#10b981',
|
);
|
||||||
completed: '#22c55e', cancelled_by_doctor: '#ef4444', cancelled_by_user: '#f43f5e',
|
|
||||||
auto_cancel_unpaid: '#94a3b8', no_show: '#64748b',
|
|
||||||
};
|
|
||||||
const APPT_CLS: Record<string, string> = {
|
const APPT_CLS: Record<string, string> = {
|
||||||
waiting_for_payment: 'amber', reserved: 'blue', checked_in: 'violet',
|
waiting_for_payment: 'amber', reserved: 'blue', checked_in: 'violet',
|
||||||
waiting: 'amber', in_progress: 'violet', visited: 'green', completed: 'green',
|
waiting: 'amber', in_progress: 'violet', visited: 'green', completed: 'green',
|
||||||
@@ -384,7 +382,8 @@ function AdminDashboard() {
|
|||||||
{ label: 'پزشکان فعال', value: fn(stats?.active_doctors), hint: stats ? `از ${fn(stats.total_doctors)} پزشک` : '', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
{ label: 'پزشکان فعال', value: fn(stats?.active_doctors), hint: stats ? `از ${fn(stats.total_doctors)} پزشک` : '', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||||
{ label: 'کلینیکها', value: fn(stats?.total_clinics), hint: '', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
{ label: 'کلینیکها', value: fn(stats?.total_clinics), hint: '', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||||
{ label: 'نوبتهای امروز', value: fn(stats?.today_appointments), hint: stats ? `ماه جاری: ${fn(stats.this_month_appointments)}` : '', icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
{ label: 'نوبتهای امروز', value: fn(stats?.today_appointments), hint: stats ? `ماه جاری: ${fn(stats.this_month_appointments)}` : '', icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||||
{ label: 'درآمد این ماه', value: fr(stats?.this_month_revenue), hint: 'تومان', icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' },
|
// formatRial already suffixes « تومان» — a 'تومان' hint here renders it twice.
|
||||||
|
{ label: 'درآمد این ماه', value: fr(stats?.this_month_revenue), hint: '', icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' },
|
||||||
{ label: 'در انتظار بررسی', value: fn(stats ? stats.pending_comments + stats.pending_settlements : undefined), hint: stats ? `${fn(stats.pending_comments)} نظر · ${fn(stats.pending_settlements)} تسویه` : '', icon: BellAlertIcon, color: 'var(--danger)', bg: 'var(--danger-bg)' },
|
{ label: 'در انتظار بررسی', value: fn(stats ? stats.pending_comments + stats.pending_settlements : undefined), hint: stats ? `${fn(stats.pending_comments)} نظر · ${fn(stats.pending_settlements)} تسویه` : '', icon: BellAlertIcon, color: 'var(--danger)', bg: 'var(--danger-bg)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,11 @@
|
|||||||
|
|
||||||
همهی سرویسهای owner در همهی بخشها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایهی `ServiceItem::toArray`)، مرتب بر نام.
|
همهی سرویسهای owner در همهی بخشها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایهی `ServiceItem::toArray`)، مرتب بر نام.
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
| Code | HTTP | توضیح |
|
||||||
|
|------|------|-------|
|
||||||
|
| ERR_FORBIDDEN_001 | 403 | کاربر نه پروفایل پزشک دارد نه کلینیک، پس محیط کاری قابلتعیین نیست (ادمین، منشی، نماینده، کاربر عادی) |
|
||||||
|
|
||||||
## GET /api/v1/service-items/{sectionUuid}
|
## GET /api/v1/service-items/{sectionUuid}
|
||||||
|
|
||||||
لیست سرویسهای یک بخش.
|
لیست سرویسهای یک بخش.
|
||||||
|
|||||||
@@ -95,8 +95,9 @@
|
|||||||
| Code | HTTP | توضیح |
|
| Code | HTTP | توضیح |
|
||||||
|------|------|-------|
|
|------|------|-------|
|
||||||
| ERR_TRIAL_ALREADY_USED | 422 | قبلاً از تریال استفاده شده |
|
| ERR_TRIAL_ALREADY_USED | 422 | قبلاً از تریال استفاده شده |
|
||||||
| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است (SiteConfig: trial_enabled=0) |
|
| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است — یا `SiteConfig: trial_enabled=0`، یا پلن `basic` هیچ دورهٔ تریالِ `active` ندارد |
|
||||||
| ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد |
|
| ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد |
|
||||||
|
| ERR_NOT_FOUND_001 | 500 | پلن `basic` وجود ندارد یا غیرفعال است (نصب ناقص) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,14 @@ class ClinicServiceController extends BaseController
|
|||||||
{
|
{
|
||||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||||
|
|
||||||
|
// A user with neither a doctor profile nor a clinic (admin, secretary,
|
||||||
|
// representation, plain patient) resolves to EntityContext::unknown(),
|
||||||
|
// whose id is null — findByEntity() declares int and fataled with a 500.
|
||||||
|
// Same condition, same answer as assertServicesGate(): forbidden.
|
||||||
|
if ($entityId === null) {
|
||||||
|
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success($this->serializeItems(
|
return $this->success($this->serializeItems(
|
||||||
$this->itemRepo->findByEntity($entityType, $entityId)
|
$this->itemRepo->findByEntity($entityType, $entityId)
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -71,9 +71,12 @@ class SubscriptionService
|
|||||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 500);
|
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deactivating the trial period is a normal admin configuration, not a
|
||||||
|
// broken install — surface it as "trial disabled", the same 422 the
|
||||||
|
// trial_enabled flag produces, rather than a 500.
|
||||||
$trialPeriod = $this->periodRepo->findTrialPeriodForPlan($basicPlan);
|
$trialPeriod = $this->periodRepo->findTrialPeriodForPlan($basicPlan);
|
||||||
if ($trialPeriod === null) {
|
if ($trialPeriod === null) {
|
||||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 500);
|
throw new AppException(ErrorCodes::ERR_TRIAL_DISABLED, null, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$expiresAt = $this->calculateExpiresAt(null, $trialPeriod->getDurationMonths());
|
$expiresAt = $this->calculateExpiresAt(null, $trialPeriod->getDurationMonths());
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\ClinicService;
|
||||||
|
|
||||||
|
use App\ClinicService\Entity\ServiceItem;
|
||||||
|
use App\ClinicService\Entity\ServiceSection;
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/service-items resolves the caller's working context. A user with
|
||||||
|
* neither a doctor profile nor a clinic (admin, secretary, representation, plain
|
||||||
|
* patient) resolves to EntityContext::unknown(), whose id is null — which used
|
||||||
|
* to reach ServiceItemRepository::findByEntity(int $entityId) and fatal with a
|
||||||
|
* 500. It must be a 403 instead.
|
||||||
|
*/
|
||||||
|
class ServiceItemsUnresolvedContextTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
public function testUserWithoutDoctorOrClinicGetsForbiddenNotServerError(): void
|
||||||
|
{
|
||||||
|
foreach ([['ROLE_ADMIN'], ['ROLE_SECRETARY'], ['ROLE_REPRESENTATION'], ['ROLE_USER']] as $roles) {
|
||||||
|
$user = $this->createUser($roles);
|
||||||
|
|
||||||
|
$this->authJson('GET', '/api/v1/service-items', $user);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
403,
|
||||||
|
$this->responseCode(),
|
||||||
|
sprintf('roles %s should be forbidden, not a server error', implode(',', $roles)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoctorStillGetsTheirOwnItems(): void
|
||||||
|
{
|
||||||
|
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||||
|
$doctor = new Doctor($owner, 'دکتر تست محیط');
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$section = new ServiceSection('doctor', $doctor->getId(), 'تزریقات');
|
||||||
|
$item = new ServiceItem($section, 'سرم ۵۰۰cc');
|
||||||
|
$this->em->persist($section);
|
||||||
|
$this->em->persist($item);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$body = $this->authJson('GET', '/api/v1/service-items', $owner);
|
||||||
|
|
||||||
|
$this->assertSame(200, $this->responseCode());
|
||||||
|
$this->assertTrue($body['success']);
|
||||||
|
$this->assertContains('سرم ۵۰۰cc', array_column($body['data'], 'name'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Subscription;
|
||||||
|
|
||||||
|
use App\Config\Repository\SiteConfigRepository;
|
||||||
|
use App\Shared\Constant\ErrorCodes;
|
||||||
|
use App\Shared\Exception\AppException;
|
||||||
|
use App\Subscription\Entity\SubscriptionPlan;
|
||||||
|
use App\Subscription\Repository\ClinicSubscriptionRepository;
|
||||||
|
use App\Subscription\Repository\SubscriptionPeriodRepository;
|
||||||
|
use App\Subscription\Repository\SubscriptionPlanRepository;
|
||||||
|
use App\Subscription\Service\SubscriptionService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `basic` plan whose trial period has been deactivated is a normal admin
|
||||||
|
* configuration. It used to surface as ERR_NOT_FOUND_001/500; it must now be
|
||||||
|
* the same ERR_TRIAL_DISABLED/422 the trial_enabled flag produces.
|
||||||
|
*/
|
||||||
|
class ActivateTrialTest extends TestCase
|
||||||
|
{
|
||||||
|
private function service(
|
||||||
|
bool $usedTrial,
|
||||||
|
?string $trialEnabled,
|
||||||
|
?SubscriptionPlan $basicPlan,
|
||||||
|
mixed $trialPeriod,
|
||||||
|
): SubscriptionService {
|
||||||
|
$subscriptionRepo = $this->createMock(ClinicSubscriptionRepository::class);
|
||||||
|
$subscriptionRepo->method('hasUsedTrial')->willReturn($usedTrial);
|
||||||
|
|
||||||
|
$planRepo = $this->createMock(SubscriptionPlanRepository::class);
|
||||||
|
$planRepo->method('findByName')->willReturn($basicPlan);
|
||||||
|
|
||||||
|
$periodRepo = $this->createMock(SubscriptionPeriodRepository::class);
|
||||||
|
$periodRepo->method('findTrialPeriodForPlan')->willReturn($trialPeriod);
|
||||||
|
|
||||||
|
$configRepo = $this->createMock(SiteConfigRepository::class);
|
||||||
|
$configRepo->method('get')->willReturn($trialEnabled);
|
||||||
|
|
||||||
|
return new SubscriptionService($subscriptionRepo, $planRepo, $periodRepo, $configRepo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoActiveTrialPeriodIsReportedAsTrialDisabled(): void
|
||||||
|
{
|
||||||
|
$service = $this->service(
|
||||||
|
usedTrial: false,
|
||||||
|
trialEnabled: '1',
|
||||||
|
basicPlan: $this->createMock(SubscriptionPlan::class),
|
||||||
|
trialPeriod: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->activateTrial('doctor', 1);
|
||||||
|
$this->fail('expected AppException');
|
||||||
|
} catch (AppException $e) {
|
||||||
|
$this->assertSame(ErrorCodes::ERR_TRIAL_DISABLED, $e->getErrorCode());
|
||||||
|
$this->assertSame(422, $e->getHttpStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingBasicPlanStaysAServerError(): void
|
||||||
|
{
|
||||||
|
$service = $this->service(
|
||||||
|
usedTrial: false,
|
||||||
|
trialEnabled: '1',
|
||||||
|
basicPlan: null,
|
||||||
|
trialPeriod: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->activateTrial('doctor', 1);
|
||||||
|
$this->fail('expected AppException');
|
||||||
|
} catch (AppException $e) {
|
||||||
|
$this->assertSame(ErrorCodes::ERR_NOT_FOUND_001, $e->getErrorCode());
|
||||||
|
$this->assertSame(500, $e->getHttpStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAlreadyUsedTrialTakesPrecedence(): void
|
||||||
|
{
|
||||||
|
$service = $this->service(
|
||||||
|
usedTrial: true,
|
||||||
|
trialEnabled: '1',
|
||||||
|
basicPlan: null,
|
||||||
|
trialPeriod: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->activateTrial('doctor', 1);
|
||||||
|
$this->fail('expected AppException');
|
||||||
|
} catch (AppException $e) {
|
||||||
|
$this->assertSame(ErrorCodes::ERR_TRIAL_ALREADY_USED, $e->getErrorCode());
|
||||||
|
$this->assertSame(422, $e->getHttpStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDisabledFlagTakesPrecedenceOverMissingPlan(): void
|
||||||
|
{
|
||||||
|
$service = $this->service(
|
||||||
|
usedTrial: false,
|
||||||
|
trialEnabled: '0',
|
||||||
|
basicPlan: null,
|
||||||
|
trialPeriod: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->activateTrial('doctor', 1);
|
||||||
|
$this->fail('expected AppException');
|
||||||
|
} catch (AppException $e) {
|
||||||
|
$this->assertSame(ErrorCodes::ERR_TRIAL_DISABLED, $e->getErrorCode());
|
||||||
|
$this->assertSame(422, $e->getHttpStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user