Compare commits
2
Commits
68b8b05630
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2459625c41 | ||
|
|
ee2682e222 |
@@ -547,6 +547,12 @@ Get all appointments for the authenticated user.
|
||||
|
||||
**Permission:** `AUTH` — عمداً بدون مجوزِ رجیستری.
|
||||
|
||||
> **فیلتر محیط اینجا اعمال نمیشود.** رکورد در محیطِ پزشکِ مقصد ثبت میشود، ولی
|
||||
> مالکش از راه `user_id` تعیین میشود. کاربری که خودش صاحب محیط دیگری است — پزشک،
|
||||
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمیدید و ۴۰۴ میگرفت.
|
||||
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام میشود و
|
||||
> مجوز دستنخورده باقی میماند.
|
||||
|
||||
> این اندپوینت `a.user = خودِ کاربر` را میدهد، یعنی نوبتهای خودِ فرد **بهعنوان
|
||||
> بیمار**، نه دادهٔ محیط. مصرفکنندهاش داشبورد بیمار در `nobat724_front` است.
|
||||
> آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص
|
||||
|
||||
@@ -131,6 +131,12 @@ List the **authenticated user's own** payments (derived from the token — there
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY`
|
||||
|
||||
> **فیلتر محیط اینجا اعمال نمیشود.** رکورد در محیطِ پزشکِ مقصد ثبت میشود، ولی
|
||||
> مالکش از راه `user_id` تعیین میشود. کاربری که خودش صاحب محیط دیگری است — پزشک،
|
||||
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمیدید و ۴۰۴ میگرفت.
|
||||
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام میشود و
|
||||
> مجوز دستنخورده باقی میماند.
|
||||
|
||||
### Query Parameters
|
||||
| Param | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
@@ -405,6 +411,12 @@ Get payment status and details.
|
||||
|
||||
**Permission:** `AUTH` — must be the payment owner or `ROLE_ADMIN`
|
||||
|
||||
> **فیلتر محیط اینجا اعمال نمیشود.** رکورد در محیطِ پزشکِ مقصد ثبت میشود، ولی
|
||||
> مالکش از راه `user_id` تعیین میشود. کاربری که خودش صاحب محیط دیگری است — پزشک،
|
||||
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمیدید و ۴۰۴ میگرفت.
|
||||
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام میشود و
|
||||
> مجوز دستنخورده باقی میماند.
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
|
||||
@@ -76,6 +76,30 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیرهشده
|
||||
| SQL خام DBAL | ❌ |
|
||||
| فرزندان aggregate | ❌ — همیشه از ریشه JOIN کن |
|
||||
|
||||
### استثنای مجاز: دادهٔ «مالِ خودِ کاربر»
|
||||
|
||||
چند رکورد در محیطِ یک مطب ثبت میشوند ولی مالکشان بیمار است، نه آن مطب: پرداختِ نوبت و
|
||||
خودِ نوبتِ بیمار. مجوزشان با `user_id` بررسی میشود، نه با محیط.
|
||||
|
||||
اینجا فیلتر چیزی به امنیت اضافه نمیکند و فقط ضرر میزند: کاربری که خودش صاحب محیط
|
||||
دیگری است — پزشکی که از مطب دیگری نوبت میگیرد، منشیای که جایی بیمار است — رکورد
|
||||
خودش را نمیدید و صفحهٔ نتیجهٔ پرداخت ۴۰۴ میشد.
|
||||
|
||||
تنها راه مجاز دورزدن، `App\Shared\Tenant\TenantFilterScope::withoutFilter()` است.
|
||||
عمداً یک کلاس جداست تا جاهای دورزدن قابل شمردن بمانند، و فیلتر را در `finally`
|
||||
برمیگرداند تا بقیهٔ همان درخواست دوباره محدود شود.
|
||||
|
||||
مصرفکنندگان فعلی — هر سه کاربرمحور، نه محیطمحور:
|
||||
|
||||
| اندپوینت | چرا |
|
||||
|---|---|
|
||||
| `GET /api/v1/payment/{uuid}` | پرداختکننده باید پرداخت خودش را ببیند |
|
||||
| `GET /api/v1/my/payments` | همان، به شکل فهرست |
|
||||
| `GET /api/v1/appointments/user` | نوبتهای خودِ فرد بهعنوان بیمار |
|
||||
|
||||
`PayerSeesOwnPaymentTest` هر سه را میبندد و همزمان تأیید میکند که پرداختِ کاربر
|
||||
دیگر همچنان ۴۰۳ میگیرد.
|
||||
|
||||
**فیلتر جایگزین authorization نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` سر جایشان میمانند: آنها «چه کاری مجاز است» را جواب میدهند، فیلتر فقط «کدام ردیفها».
|
||||
|
||||
### تغییر رفتار: ۴۰۴ بهجای ۴۰۳
|
||||
|
||||
@@ -55,6 +55,7 @@ class AppointmentController extends BaseController
|
||||
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
|
||||
private readonly \App\Resource\Service\PublicResourceBookingService $publicResources,
|
||||
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
|
||||
private readonly \App\Shared\Tenant\TenantFilterScope $tenantScope,
|
||||
private readonly \Psr\Log\LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -884,10 +885,19 @@ class AppointmentController extends BaseController
|
||||
#[Route('/api/v1/appointments/user', methods: ['GET'])]
|
||||
public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$status = $request->query->get('status');
|
||||
$appointments = $this->appointmentRepo->findByUser($user, $status);
|
||||
$status = $request->query->get('status');
|
||||
|
||||
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
|
||||
// نوبتِ بیمار در محیطِ پزشکِ مقصد ثبت میشود. کاربری که خودش صاحب محیط دیگری
|
||||
// است — پزشک، منشی، مالک کلینیک — با فیلترِ محیطِ خودش نوبتهای خودش را
|
||||
// نمیدید. صاحب اینجا با `user_id` تعیین میشود، نه با محیط.
|
||||
// `toArray()` هم داخل محدوده است: proxyهای پزشک و کلینیک آنجا باز میشوند و
|
||||
// با فیلترِ برگشته، Doctrine `EntityNotFoundException` میدهد.
|
||||
$rows = $this->tenantScope->withoutFilter(fn () => array_map(
|
||||
fn(Appointment $a) => $a->toArray(),
|
||||
$this->appointmentRepo->findByUser($user, $status),
|
||||
));
|
||||
|
||||
return $this->success(['data' => $rows]);
|
||||
}
|
||||
|
||||
private function canView(Appointment $a, User $user): bool
|
||||
|
||||
@@ -44,6 +44,7 @@ class PaymentController extends BaseController
|
||||
private readonly \App\Subscription\Repository\SubscriptionPeriodRepository $subscriptionPeriodRepo,
|
||||
private readonly \App\Subscription\Service\SubscriptionTaxCalculator $subscriptionTax,
|
||||
private readonly \App\Payment\Service\PaymentTaxCalculator $paymentTax,
|
||||
private readonly \App\Shared\Tenant\TenantFilterScope $tenantScope,
|
||||
private readonly string $appBaseUrl,
|
||||
private readonly string $allowedFrontendHosts = '',
|
||||
) {}
|
||||
@@ -530,11 +531,16 @@ class PaymentController extends BaseController
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
||||
$status = $request->query->get('status');
|
||||
|
||||
$items = array_map(
|
||||
fn(Payment $p) => $p->toArray(),
|
||||
$this->paymentRepo->findByUser($user, $status, $page, $limit)
|
||||
);
|
||||
$total = $this->paymentRepo->countByUser($user, $status);
|
||||
// پرداختِ بیمار به محیطِ پزشکِ مقصد تعلق دارد؛ اگر پرداختکننده خودش پزشک یا
|
||||
// منشی باشد، فیلترِ محیطِ او همان رکورد را پنهان میکند. مجوز اینجا با
|
||||
// `user_id` بررسی میشود، پس فیلتر محیط چیزی به امنیت اضافه نمیکند.
|
||||
[$items, $total] = $this->tenantScope->withoutFilter(fn () => [
|
||||
array_map(
|
||||
fn(Payment $p) => $p->toArray(),
|
||||
$this->paymentRepo->findByUser($user, $status, $page, $limit),
|
||||
),
|
||||
$this->paymentRepo->countByUser($user, $status),
|
||||
]);
|
||||
|
||||
return $this->paginated($items, $total, $page, $limit);
|
||||
}
|
||||
@@ -543,16 +549,31 @@ class PaymentController extends BaseController
|
||||
#[Route('/api/v1/payment/{uuid}', methods: ['GET'])]
|
||||
public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$payment = $this->paymentRepo->findByUuid($uuid);
|
||||
if ($payment === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404);
|
||||
// مثل myPayments: صاحبِ پرداخت با `user_id` تعیین میشود، نه با محیط.
|
||||
//
|
||||
// سریالسازی هم داخل همین محدوده است، نه فقط کوئری: `toArray()` proxyهای
|
||||
// نوبت و پزشک را باز میکند و اگر فیلتر تا آن لحظه برگشته باشد، Doctrine
|
||||
// `EntityNotFoundException` میدهد که همان ۴۰۴ را برمیگرداند.
|
||||
$result = $this->tenantScope->withoutFilter(function () use ($uuid, $user) {
|
||||
$payment = $this->paymentRepo->findByUuid($uuid);
|
||||
if ($payment === null) {
|
||||
return ['error' => [ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404]];
|
||||
}
|
||||
|
||||
if ($payment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return ['error' => [ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403]];
|
||||
}
|
||||
|
||||
return ['data' => $payment->toArray()];
|
||||
});
|
||||
|
||||
if (isset($result['error'])) {
|
||||
[$code, $message, $status] = $result['error'];
|
||||
|
||||
return $this->error($code, $message, $status);
|
||||
}
|
||||
|
||||
if ($payment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success($payment->toArray());
|
||||
return $this->success($result['data']);
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,7 +38,7 @@ final class LandingRegistry
|
||||
$pages = [
|
||||
new LandingPage(
|
||||
slug: self::BEAUTY,
|
||||
metaTitle: 'نرمافزار مدیریت کلینیک زیبایی | دورههای درمان و انبار مصرفی — کلینیک پرو',
|
||||
metaTitle: 'نرمافزار مدیریت کلینیک زیبایی | کلینیک پرو',
|
||||
metaDescription: 'نرمافزار مدیریت کلینیک زیبایی با مدیریت دورههای چندجلسهای لیزر و تزریق، تقویم دستگاهها، انبار مواد مصرفی و پروندهٔ تصویری بیمار. رایگان شروع کنید.',
|
||||
keywords: 'نرم افزار مدیریت کلینیک زیبایی, نرم افزار کلینیک زیبایی, نرم افزار لیزر موهای زائد, مدیریت دوره درمان زیبایی, نوبت دهی کلینیک زیبایی, انبار مواد مصرفی کلینیک',
|
||||
h1: 'نرمافزار مدیریت کلینیک زیبایی',
|
||||
@@ -61,7 +61,7 @@ final class LandingRegistry
|
||||
),
|
||||
new LandingPage(
|
||||
slug: self::DENTAL,
|
||||
metaTitle: 'نرمافزار مدیریت کلینیک دندانپزشکی | پرونده، بیمه و صندوق — کلینیک پرو',
|
||||
metaTitle: 'نرمافزار مدیریت کلینیک دندانپزشکی | کلینیک پرو',
|
||||
metaDescription: 'نرمافزار مدیریت کلینیک دندانپزشکی: پروندهٔ درمان هر دندان، سهم بیمهٔ پایه و تکمیلی، تعرفهٔ خدمات، نوبتدهی چندواحدی و حساب بیمار. رایگان امتحان کنید.',
|
||||
keywords: 'نرم افزار مدیریت کلینیک دندانپزشکی, نرم افزار دندانپزشکی, نرم افزار مطب دندانپزشکی, بیمه تکمیلی دندانپزشکی, نوبت دهی دندانپزشکی, پرونده بیمار دندانپزشکی',
|
||||
h1: 'نرمافزار مدیریت کلینیک دندانپزشکی',
|
||||
@@ -84,7 +84,7 @@ final class LandingRegistry
|
||||
),
|
||||
new LandingPage(
|
||||
slug: self::PHYSIOTHERAPY,
|
||||
metaTitle: 'نرمافزار مدیریت کلینیک فیزیوتراپی | جلسات درمانی و تختها — کلینیک پرو',
|
||||
metaTitle: 'نرمافزار مدیریت کلینیک فیزیوتراپی | کلینیک پرو',
|
||||
metaDescription: 'نرمافزار مدیریت کلینیک فیزیوتراپی: پکیج جلسات درمانی، تقویم تخت و دستگاه، سپردن جلسه به پرسنل، پیگیری حضور بیمار و حساب بیمه. شروع رایگان.',
|
||||
keywords: 'نرم افزار مدیریت کلینیک فیزیوتراپی, نرم افزار فیزیوتراپی, مدیریت جلسات فیزیوتراپی, نوبت دهی فیزیوتراپی, نرم افزار مرکز توانبخشی',
|
||||
h1: 'نرمافزار مدیریت کلینیک فیزیوتراپی',
|
||||
@@ -107,7 +107,7 @@ final class LandingRegistry
|
||||
),
|
||||
new LandingPage(
|
||||
slug: self::POLYCLINIC,
|
||||
metaTitle: 'سیستم جامع درمانگاهی | مدیریت چند پزشک، بخش و صندوق — کلینیک پرو',
|
||||
metaTitle: 'سیستم جامع درمانگاهی | کلینیک پرو',
|
||||
metaDescription: 'سیستم جامع درمانگاهی برای مدیریت چند پزشک و چند بخش: نوبتدهی مشترک، منابع و اتاقها، پروندهٔ واحد بیمار، صندوق و بیمه، منشی با سطح دسترسی. رایگان شروع کنید.',
|
||||
keywords: 'سیستم جامع درمانگاهی, نرم افزار درمانگاه, نرم افزار پلی کلینیک, مدیریت درمانگاه, نرم افزار مدیریت مراکز درمانی, نرم افزار چند پزشک',
|
||||
h1: 'سیستم جامع درمانگاهی',
|
||||
@@ -130,7 +130,7 @@ final class LandingRegistry
|
||||
),
|
||||
new LandingPage(
|
||||
slug: self::FREE,
|
||||
metaTitle: 'نرمافزار رایگان مدیریت کلینیک و مطب | پلن رایگان کلینیک پرو',
|
||||
metaTitle: 'نرمافزار رایگان مدیریت کلینیک | کلینیک پرو',
|
||||
metaDescription: 'نرمافزار رایگان مدیریت کلینیک: نوبتدهی آنلاین، پروندهٔ بیمار، سرویسها و صندوق در پلن رایگان کلینیک پرو — بدون هزینهٔ راهاندازی و بدون نصب.',
|
||||
keywords: 'نرم افزار رایگان مدیریت کلینیک, نرم افزار مجانی مدیریت کلینیک, نرم افزار رایگان مطب, نوبت دهی رایگان, نرم افزار پزشکی رایگان',
|
||||
h1: 'نرمافزار رایگان مدیریت کلینیک',
|
||||
@@ -153,7 +153,7 @@ final class LandingRegistry
|
||||
),
|
||||
new LandingPage(
|
||||
slug: self::OFFICE,
|
||||
metaTitle: 'نرمافزار مدیریت مطب | نوبتدهی، پرونده و حسابداری — کلینیک پرو',
|
||||
metaTitle: 'نرمافزار مدیریت مطب | کلینیک پرو',
|
||||
metaDescription: 'نرمافزار مدیریت مطب برای پزشک و منشی: نوبتدهی آنلاین، پروندهٔ الکترونیک بیمار، صندوق و گزارش مالی، یادآوری پیامکی. ساده، تحت وب، شروع رایگان.',
|
||||
keywords: 'نرم افزار مدیریت مطب, نرم افزار مطب, سیستم مدیریت مطب, نوبت دهی مطب, پرونده الکترونیک بیمار, حسابداری مطب',
|
||||
h1: 'نرمافزار مدیریت مطب',
|
||||
@@ -176,7 +176,7 @@ final class LandingRegistry
|
||||
),
|
||||
new LandingPage(
|
||||
slug: self::CRM,
|
||||
metaTitle: 'CRM کلینیک و مطب | پیگیری بیمار، یادآوری و بازگشت مراجع — کلینیک پرو',
|
||||
metaTitle: 'CRM کلینیکها | کلینیک پرو',
|
||||
metaDescription: 'CRM کلینیک: پروندهٔ کامل ارتباط با بیمار، برچسبگذاری، یادآوری پیامکی نوبت، پیگیری دورههای ناتمام و گزارش بازگشت مراجعان. بخشی از کلینیک پرو.',
|
||||
keywords: 'crm کلینیک, crm پزشکی, نرم افزار ارتباط با بیمار, پیگیری بیمار, یادآوری نوبت, بازاریابی کلینیک, باشگاه مشتریان کلینیک',
|
||||
h1: 'CRM کلینیکها',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Tenant;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* روشن و خاموش کردن فیلترِ محیط — تنها جایی که اجازه دارد این کار را بکند.
|
||||
*
|
||||
* دو مسئولیت به هم گره خوردهاند و برای همین یک کلاساند: `enable()` در Doctrine یک
|
||||
* نمونهٔ تازه از فیلتر میسازد و پارامترهای قبلی را دور میریزد، پس هر کسی که فیلتر
|
||||
* را موقتاً خاموش کند باید بتواند دقیقاً همان جفت محیط را برگرداند. نگهداشتن آن جفت
|
||||
* اینجاست، وگرنه اولین کوئریِ بعد از روشنشدن دوباره با
|
||||
* «Parameter 'tenant_entity_type' does not exist» میترکد.
|
||||
*/
|
||||
final class TenantFilterScope
|
||||
{
|
||||
/** @var array{0: string, 1: int}|null جفتِ محیطِ فعال، اگر فیلتر روشن باشد. */
|
||||
private ?array $active = null;
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $em) {}
|
||||
|
||||
/** محدودکردن همهٔ کوئریهای این درخواست به یک محیط. */
|
||||
public function apply(string $entityType, int $entityId): void
|
||||
{
|
||||
$this->active = [$entityType, $entityId];
|
||||
|
||||
$this->em->getFilters()
|
||||
->enable(TenantFilter::NAME)
|
||||
->setParameter(TenantFilter::PARAM_TYPE, $entityType, 'string')
|
||||
->setParameter(TenantFilter::PARAM_ID, $entityId, 'integer');
|
||||
}
|
||||
|
||||
/**
|
||||
* اجرای یک کوئری بیرون از فیلتر.
|
||||
*
|
||||
* برای مسیرهای «مالِ خودِ کاربر» است که مجوزشان با `user_id` بررسی میشود: پرداختِ
|
||||
* بیمار و نوبتِ او در محیطِ پزشکِ مقصد ثبت میشوند، پس کاربری که خودش صاحب محیط
|
||||
* دیگری است رکورد خودش را نمیدید.
|
||||
*
|
||||
* @template T
|
||||
* @param callable():T $query
|
||||
* @return T
|
||||
*/
|
||||
public function withoutFilter(callable $query): mixed
|
||||
{
|
||||
$filters = $this->em->getFilters();
|
||||
$restore = $filters->isEnabled(TenantFilter::NAME) ? $this->active : null;
|
||||
|
||||
if ($restore !== null) {
|
||||
$filters->disable(TenantFilter::NAME);
|
||||
}
|
||||
|
||||
try {
|
||||
return $query();
|
||||
} finally {
|
||||
// حتی وقتی کوئری استثنا میدهد، بقیهٔ همان درخواست باید دوباره محدود شود.
|
||||
if ($restore !== null) {
|
||||
$this->apply($restore[0], $restore[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ final class TenantFilterSubscriber implements EventSubscriberInterface
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly TenantFilterScope $scope,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -54,9 +55,8 @@ final class TenantFilterSubscriber implements EventSubscriberInterface
|
||||
|
||||
[$type, $id] = $context->toEntityPair();
|
||||
|
||||
$this->em->getFilters()
|
||||
->enable(TenantFilter::NAME)
|
||||
->setParameter(TenantFilter::PARAM_TYPE, $type, 'string')
|
||||
->setParameter(TenantFilter::PARAM_ID, $id, 'integer');
|
||||
// روشنکردن از راه scope انجام میشود تا همانجا جفتِ محیط هم ثبت شود؛
|
||||
// مسیرهایی که موقتاً فیلتر را خاموش میکنند بدون آن نمیتوانند برش گردانند.
|
||||
$this->scope->apply($type, $id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +105,14 @@
|
||||
</section>
|
||||
|
||||
{% if page.faq is not empty %}
|
||||
{# specs-head عنوان را وسط و با فاصلهٔ درست مینشاند؛ h2 لخت داخل گرید
|
||||
میافتد و به کارت اول میچسبد. #}
|
||||
<section class="specs" id="faq">
|
||||
<div class="wrap">
|
||||
<h2 class="reveal">پرسشهای متداول</h2>
|
||||
<div class="specs-head reveal">
|
||||
<span class="eyebrow">پرسشهای متداول</span>
|
||||
<h2>{{ page.h1 }} — آنچه بیشتر پرسیده میشود</h2>
|
||||
</div>
|
||||
<div class="fcols-grid">
|
||||
{% for item in page.faq %}
|
||||
<div class="fcol reveal">
|
||||
@@ -121,15 +126,19 @@
|
||||
{% endif %}
|
||||
|
||||
{% if related is not empty %}
|
||||
<section class="band" id="related">
|
||||
<section class="specs" id="related">
|
||||
<div class="wrap">
|
||||
<h2 class="reveal">راهکارهای دیگر کلینیک پرو</h2>
|
||||
<div class="fcols-grid">
|
||||
<div class="specs-head reveal">
|
||||
<span class="eyebrow">راهکارهای دیگر</span>
|
||||
<h2>کلینیک پرو برای صنف شما</h2>
|
||||
<p>هر مجموعه نیاز خودش را دارد؛ نسخهٔ متناسب با کار خود را ببینید.</p>
|
||||
</div>
|
||||
<div class="specs-grid">
|
||||
{% for item in related %}
|
||||
<div class="fcol reveal">
|
||||
<h3><a href="/{{ item.slug }}">{{ item.h1 }}</a></h3>
|
||||
<p>{{ item.metaDescription }}</p>
|
||||
</div>
|
||||
<a class="spec-card reveal" href="/{{ item.slug }}" style="text-decoration:none;color:inherit;display:block">
|
||||
<h3>{{ item.h1 }}</h3>
|
||||
<p style="font-size:13.5px;color:var(--muted);line-height:1.9;margin-top:12px">{{ item.metaDescription }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Payment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* پرداختکننده باید پرداخت خودش را ببیند، حتی وقتی خودش صاحب یک محیط دیگر است.
|
||||
*
|
||||
* پرداختِ بیمار به محیطِ پزشکِ مقصد تعلق دارد. `TenantFilterSubscriber` برای کاربری
|
||||
* که محیط حلشده دارد فیلتر را روشن میکند، و چون `Payment` محیطدار است، پزشکی که
|
||||
* از مطب دیگری نوبت گرفته بود صفحهٔ نتیجهٔ پرداخت خودش را ۴۰۴ میگرفت.
|
||||
*/
|
||||
class PayerSeesOwnPaymentTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* پزشکی که محیط کاریِ فعالش هم ذخیره شده است.
|
||||
*
|
||||
* بدون این ردیف، `EntityContextResolver` محیط را «انتخابشده» نمیداند و
|
||||
* `TenantFilterSubscriber` فیلتر را روشن نمیکند — یعنی تست باگ را نمیبیند،
|
||||
* درحالیکه کاربر واقعی که وارد پنل شده آن را میگیرد.
|
||||
*/
|
||||
private function makeDoctor(string $name): Doctor
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, $name);
|
||||
$doctor->setMobileNumber($user->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$this->em->persist(new UserActiveContext($user, $doctor->getUuid(), EntityContext::TYPE_DOCTOR));
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/** پرداختی که «پزشکِ پرداختکننده» برای نوبتِ «پزشکِ مقصد» انجام داده است. */
|
||||
private function makeCrossTenantPayment(Doctor $target, User $payer): Payment
|
||||
{
|
||||
$start = strtotime('+20 days') + random_int(0, 500_000) * 7;
|
||||
$appointment = $this->newAppointment($target, $payer, $start, $start + 900);
|
||||
$appointment->setVisitPriceRials(1_500_000);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$payment = new Payment($payer, 1_500_000, 'mellat', Payment::TYPE_APPOINTMENT, '');
|
||||
$payment->setAppointment($appointment);
|
||||
$payment->assignTenant(EntityContext::forBooking($target, null));
|
||||
$this->em->persist($payment);
|
||||
$this->em->flush();
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
public function testDoctorSeesThePaymentHeMadeAtAnotherPractice(): void
|
||||
{
|
||||
$target = $this->makeDoctor('پزشک مقصد');
|
||||
$payer = $this->makeDoctor('پزشک پرداختکننده');
|
||||
|
||||
$payment = $this->makeCrossTenantPayment($target, $payer->getUser());
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/payment/' . $payment->getUuid(), $payer->getUser());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($payment->getUuid(), $body['data']['uuid']);
|
||||
}
|
||||
|
||||
public function testTheSamePaymentAppearsInHisPaymentsList(): void
|
||||
{
|
||||
$target = $this->makeDoctor('پزشک مقصد');
|
||||
$payer = $this->makeDoctor('پزشک پرداختکننده');
|
||||
|
||||
$payment = $this->makeCrossTenantPayment($target, $payer->getUser());
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/my/payments?limit=100', $payer->getUser());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertContains(
|
||||
$payment->getUuid(),
|
||||
array_column($body['data'], 'uuid'),
|
||||
'پرداختِ خودِ کاربر باید در فهرست پرداختهایش باشد',
|
||||
);
|
||||
}
|
||||
|
||||
/** بیمار ساده — بدون محیط — از قبل هم کار میکرد و نباید بشکند. */
|
||||
public function testPlainPatientStillSeesHisPayment(): void
|
||||
{
|
||||
$target = $this->makeDoctor('پزشک مقصد');
|
||||
$patient = $this->createUser();
|
||||
|
||||
$payment = $this->makeCrossTenantPayment($target, $patient);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/payment/' . $payment->getUuid(), $patient);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($payment->getUuid(), $body['data']['uuid']);
|
||||
}
|
||||
|
||||
/** دورزدنِ فیلتر نباید به معنی بازشدن پرداختِ دیگران باشد. */
|
||||
public function testAnotherUserIsStillForbidden(): void
|
||||
{
|
||||
$target = $this->makeDoctor('پزشک مقصد');
|
||||
$payer = $this->createUser();
|
||||
$stranger = $this->createUser();
|
||||
|
||||
$payment = $this->makeCrossTenantPayment($target, $payer);
|
||||
|
||||
$this->authJson('GET', '/api/v1/payment/' . $payment->getUuid(), $stranger);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* همان الگو روی نوبت: نوبتی که کاربر بهعنوان بیمار گرفته، در محیطِ پزشکِ مقصد
|
||||
* ثبت میشود و برای کاربرِ صاحبِ محیطِ دیگر نامرئی میشد.
|
||||
*/
|
||||
public function testDoctorSeesTheAppointmentHeBookedAtAnotherPractice(): void
|
||||
{
|
||||
$target = $this->makeDoctor('پزشک مقصد');
|
||||
$payer = $this->makeDoctor('پزشک بیمار');
|
||||
|
||||
$payment = $this->makeCrossTenantPayment($target, $payer->getUser());
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/appointments/user', $payer->getUser());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertContains(
|
||||
$payment->getAppointment()->getUuid(),
|
||||
array_column($body['data']['data'] ?? $body['data'], 'uuid'),
|
||||
'نوبتِ خودِ کاربر باید در فهرست نوبتهایش باشد',
|
||||
);
|
||||
}
|
||||
|
||||
public function testUnknownPaymentIsStillNotFound(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
|
||||
$this->authJson('GET', '/api/v1/payment/00000000-0000-4000-8000-000000000000', $user);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user