"Six laser sessions" is the common case in an aesthetics clinic: the patient pays once and books the sessions later. Credit is a ledger, not a counter. No table has a remaining/used_count column and a schema test enforces that — the balance is always SUM(delta) over append-only rows, so every number a patient sees has a full history behind it. Corrections are new rows, never edits. - purchase / consume / refund / adjustment / expiry, each with a reason, an author and the appointment it belongs to - consume happens in confirm(), never in quote(): if the preview consumed, a page refresh would cost the patient a session - cancelling adds a refund row; the consume row stays - FIFO across a patient's packages — the oldest is closest to expiring - an empty package is not an error, it just does not apply and the patient pays - adjust/expire need a doctor or clinic role, and adjust always needs a reason - app:package:expire writes the closing row so "where did my 3 sessions go?" always has an answer Consume takes a pessimistic lock on the one package row. That is the opposite of task 07's slot buckets, and docs/api/package.md carries the table explaining why, so nobody unifies them later. Idempotency checks for an existing consume row before inserting rather than catching the unique violation: in Doctrine that exception closes the EntityManager and burns the rest of the request. The unique key stays as the last line of defence. Admin: PackagesPage, a packages tab on the patient record, and a ledger page whose running-balance column shows where the final number came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
25 KiB
جداسازی محیط (tenancy)
هر دادهٔ عملیاتی در ClinicPro به یک محیط تعلق دارد: یا مطب شخصی یک پزشک، یا یک کلینیک. این سند میگوید آن محیط چطور تعیین میشود، کجا اجبار میشود، و — مهمتر — کجا اجبار نمیشود.
هویت محیط
جفت (entity_type, entity_id) روی خودِ جدول:
| ستون | مقدار |
|---|---|
entity_type |
doctor یا clinic — VARCHAR(10) در همهٔ جدولهای tenant-دار (طولِ یکسان، وگرنه JOIN به collation mismatch میخورد) |
entity_id |
شناسهٔ همان پزشک یا کلینیک |
موجودیتها این جفت را از trait مشترک میگیرند:
use App\Shared\Tenant\TenantOwnedTrait;
$appointment->assignTenant(EntityContext::forBooking($doctor, $clinic));
// یا وقتی جفت از قبل بهصورت اسکالر حل شده:
$rule->assignTenantPair('clinic', $clinicId);
ستونها NOT NULL بدون مقدار پیشفرضاند. اگر سازندهای assignTenant() را فراموش کند، flush میشکند — عمدی است: ردیفِ بیمحیط بیصدا از دید همه پنهان میشود.
تعیین محیط جاری
App\Shared\Context\EntityContextResolver تنها نقطهٔ تصمیم است. اولویت:
clinic_uuid صریحِ درخواست > UserActiveContext ذخیرهشده > fallback نقش
دو مورد اول انتخابِ کاربراند و EntityContext::$chosen را true میکنند. سومی حدس است.
ماتریس نقشها
| نقش | محیط مؤثر |
|---|---|
| پزشک مستقل | همیشه ('doctor', id) |
| پزشک عضو یک یا چند کلینیک | طبق محیط فعال؛ بیرون از آن، مطب شخصی |
| پزشکِ مالک کلینیک | طبق محیط فعال؛ در محیط کلینیک بدون محدودیت ClinicDoctorPermission |
| مدیر/مالک کلینیک | همیشه ('clinic', id) |
| منشی | فقط از محیط فعال؛ بدون آن unknown |
user_active_context هم db_uuid دارد هم db_type، پس حل محیط یک lookup است نه دو تا.
اجبار: TenantFilter
یک SQLFilter که به هر کوئری DQL روی موجودیتهای tenant-دار شرط (entity_type, entity_id) اضافه میکند.
پیشفرض خاموش است. TenantFilterSubscriber آن را در kernel.request روشن میکند و فقط وقتی که:
- کاربر احراز شده باشد، و
ROLE_ADMINنداشته باشد (پنل ادمین ذاتاً cross-tenant است)، و- محیطش انتخابشده باشد (
$context->chosen)
چرا فقط محیط انتخابشده؟ کاربری که هنوز محیطی برنگزیده در هیچ محیطی «نیست». قفلکردنش روی حدسِ نقش، دادهٔ کلینیکیای را که قانوناً حقش است پنهان میکند — مثلاً پزشکِ عضوی که هرگز محیط عوض نکرده، نوبتهای کلینیکش را از دست میداد. برای این کاربران، دسترسی را همان checkerهای دامنه تعیین میکنند (رفتار پیش از فاز ۴).
چه تضمین میدهد و چه نمیدهد
| مسیر | فیلتر اعمال میشود؟ |
|---|---|
| DQL و QueryBuilder | ✅ |
findBy / findOneBy |
✅ |
EntityManager::find() با کلید اصلی |
✅ — در Doctrine ORM 3 (تثبیتشده در TenantFilterLeakTest) |
| بارگذاری تنبل کالکشنها | ✅ |
| entity که از قبل در identity map است | ❌ دوباره کوئری نمیشود |
getReference() |
❌ |
| SQL خام DBAL | ❌ |
| فرزندان aggregate | ❌ — همیشه از ریشه JOIN کن |
فیلتر جایگزین authorization نیست. AppointmentAccessChecker، ClinicDoctorAccessChecker، SecretaryAccessChecker و PatientRecordScopeResolver سر جایشان میمانند: آنها «چه کاری مجاز است» را جواب میدهند، فیلتر فقط «کدام ردیفها».
تغییر رفتار: ۴۰۴ بهجای ۴۰۳
با فیلتر روشن، رکوردی که مال محیط دیگری است وجود ندارد، نه اینکه «ممنوع» باشد:
- کاربر با محیط انتخابشدهٔ A که رکورد محیط B را باز کند →
404 - مقداردهیِ proxy به موجودیتی که فیلتر کنارش گذاشته →
EntityNotFoundExceptionکهExceptionSubscriberبه404باERR_NOT_FOUND_001نگاشت میکند (و در سطحinfoلاگ میشود تا FK واقعاً شکسته هم دیده شود)
طبقهبندی جدولها — App\Shared\Tenant\GlobalTables
هر entity دقیقاً در یکی از چهار وضعیت است، و TenantSchemaCoverageTest این را اجبار میکند:
| وضعیت | یعنی | نمونه |
|---|---|---|
| جفت tenant دارد | فیلتر پوششش میدهد | appointments، patient_records، service_sections |
ENTITIES |
عمداً سراسری | cities، specialties، users، blogs |
AGGREGATE_CHILDREN |
محیط را از ریشه به ارث میبرد | patient_notes → patient_records |
DEFERRED |
بدهی ثبتشده، هنوز طبقهبندی نشده | خالی — فاز ۶ آخرین موردش را تعیین تکلیف کرد |
⚠️ فرزندان aggregate تور ایمنی ندارند
فیلتر روی آنها اعمال نمیشود. کوئری مستقیم روی service_item_consumables بدون JOIN به ریشه، cross-tenant است. TenantSchemaCoverageTest فقط تضمین میکند زنجیرهٔ اعلامشده به ریشهای با جفت tenant میرسد — نه اینکه کوئریها واقعاً از ریشه شروع میشوند.
قاعدهٔ مرزبندی (فاز ۸): فرزندی که uuidش از خودِ درخواست میآید، حق ندارد فرزند بماند.
$item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']);
این خودش یک کوئری بیلنگر است. فاز ۷ نتیجه گرفته بود فرزندان ستون لازم ندارند چون «هر کوئری repository به ریشه لنگر میزند» — که دربارهٔ DQLهای repository درست بود و همین حالت را ندید. هر سه نشتی فاز ۷ دقیقاً همین شکل بودند.
پس هشت فرزندِ قابلدسترس با uuid جفت محیط خودشان را گرفتند و دیگر در AGGREGATE_CHILDREN نیستند:
service_items · patient_sessions · session_payments · patient_notes
patient_calls · patient_messages · patient_attachments · patient_medical_records
جفت در سازنده از ریشه مشتق میشود، نه از ورودی:
public function __construct(ServiceSection $section, ...) {
$this->section = $section;
$this->assignTenantPair($section->getEntityType(), $section->getEntityId());
}
دو خاصیت مهم دارد: هیچ نقطهٔ ساختی نمیتواند فراموشش کند، و چون محیطِ یک ریشه هرگز عوض نمیشود، این کپی write-once است و واگرا نمیشود.
۱۷ فرزند باقیمانده هیچکدام با uuid از درخواست لود نمیشوند (اندازهگیریشده، نه فرض) و فقط از ریشه پیمایش میشوند.
RequestReachableChildTenantTest این را بدون هیچ گارد دستی میسنجد: فقط خودِ فیلتر. با برداشتن ستون، همان نشتی مالی فاز ۷ برمیگردد و تست قرمز میشود.
⚠️ ریشهٔ سراسری، فرزندِ محیطدار — پروندهٔ doctor_addresses
doctor_addresses عمداً در ENTITIES سراسری است («آدرسهای پزشک؛ در همهٔ محیطهای او
یکسان است»)، پس فیلتر رویش اعمال نمیشود و findOneBy(['uuid' => …]) آدرس کلینیک
دیگر را هم برمیگرداند. دو جدولِ تسک شعبه روی همین ریشه نشستند و درس دادند:
| جدول | طبقهبندی | چرا |
|---|---|---|
branch_working_hours |
جفت محیط خودش | اول بهعنوان فرزند aggregate با ریشهٔ DoctorAddress ثبت شد و TenantSchemaCoverageTest ردش کرد: ریشهای که خودش سراسری است، هیچ محیطی برای ارث دادن ندارد |
rooms |
جفت محیط خودش | uuidش از درخواست میآید — همان قاعدهٔ فاز ۸ |
جفت از type آدرس مشتق میشود، که نگاشتی کامل است:
personal ⇒ (doctor, doctor_id) و clinic ⇒ (clinic, clinic_id). چون آدرس هم فقط در
محیط خودش فهرست میشود، هیچ ردیفی بیدلیل پنهان نمیشود.
خودِ آدرس محافظ دستی دارد: App\Branch\Service\BranchResolver تکنقطهٔ تبدیل
«uuid شعبه در request» به آدرسِ محیط جاری است و در غیر این صورت ۴۰۴ میدهد — همان
رفتار فیلتر، نه ۴۰۳.
درسِ عملیاتی: آنجا که AGGREGATE_CHILDREN بیفایده است، فقط طبقهبندی عوض نکن؛
جفت واقعی بده. و برای ریشهٔ سراسری یک resolver واحد بساز، نه بررسی تکراری در هر کنترلر.
قرینهٔ درست همین ماجرا در تسک ۰۲ دیده میشود: resource_skills و
resource_pool_members هم فرزند aggregate اند، ولی ریشههاشان (clinic_resources و
resource_pools) خودشان جفت محیط دارند و uuidشان از request نمیآید — پس آنجا
AGGREGATE_CHILDREN طبقهبندی درستی است. تفاوت در ریشه است، نه در فرزند.
uuid از درخواست — خطرناکترین الگو
سه نشتی واقعی در آدیت این نقطه پیدا شد و هیچکدام در repository نبودند؛ همه در کنترلر و سرویس بودند، جایی که یک uuid از بدنه یا کوئری میآید و کسی محیطش را نمیسنجد:
| مسیر | چه بود |
|---|---|
GET /api/v1/appointment-service-slots |
با uuid سرویسِ محیط دیگر، وجود/فعالبودن/مدتش لو میرفت و اسلاتها با آن محاسبه میشد |
POST /api/v1/my/appointment |
بخش/سرویس/پرسنلِ محیط دیگر به نوبت چسبانده و ذخیره میشد |
POST/PATCH مراجعه |
قیمتِ سرویسِ محیط دیگر وارد فاکتور میشد و SessionService با آن ذخیره میماند |
App\Shared\Tenant\TenantOwnershipChecker نقطهٔ واحد این بررسی است:
$this->tenantOwnership->belongsTo($context, $entity); // با EntityContext
$this->tenantOwnership->belongsToPair($type, $id, $entity); // وقتی جفت اسکالر است
$this->tenantOwnership->allBelongTo($context, $entities); // یک بیگانه = رد کل فهرست
موجودیتی که جفتش را expose نکند، استثنا میدهد — سکوت اینجا گاردِ همیشه-بسته میسازد که خودش باگ است.
فیلتر جایگزین این بررسی نیست، حتی روی جدولِ جفتدار. جداسازی سختِ TenantFilter
فقط روی محیطِ انتخابشده اعمال میشود ({@see EntityContext::$chosen}). پزشکی که
هنوز محیطی برنگزیده در هیچ محیطی «نیست»، پس فیلتر برایش خاموش است و
PATCH /api/v1/room/{uuid} میتوانست اتاق کلینیک دیگری را ویرایش کند — با
RoomCrudTest::testForeignRoomIsNotFound گرفته شد که قبل از اصلاح ۲۰۰ میداد.
هر کنترلری که uuid را از request میگیرد باید belongsToPair() را خودش صدا بزند.
TenantLookupInventoryTest تعداد این جستوجوها را per-file نگه میدارد. افزودن یک findByUuid تازه روی موجودیت محیطدار تست را قرمز میکند تا کسی ثابت کند محیطش بررسی میشود و بعد عدد را بهروز کند.
جدولهای مالی
بدهی صفر است. testThereIsNoUnclassifiedDebtLeft خالیماندنش را اجبار میکند.
استدلالِ اولیهٔ «مالکیتشان دوگانه است» درست نبود: پرداخت به محیطِ گیرنده تعلق دارد و پرداختکننده مانعی نیست، چون بیمار محیطی انتخاب نکرده و فیلتر برایش خاموش است.
| جدول | تصمیم | چرا |
|---|---|---|
payments |
جفت محیط | نوبت → محیط نوبت · اشتراک → محیطی که خریدار صاحبش است · شارژ پیامک → محیط همان کیف پول |
payment_logs · financial_breakdowns |
فرزند Payment |
با FK به پرداخت لنگر میخورند |
secretary_earnings |
فرزند FinancialBreakdown |
زنجیره تا payments میرسد |
wallet_transactions |
ENTITIES + انتسابِ per-ردیف |
کیف پولِ شخص است و موجودی سراسری میماند؛ ولی هر ردیف محیطِ ثبتش را در recorded_entity_* نگه میدارد تا دفتری که کلینیک میبیند به همان محیط محدود شود (پایین) |
settlements |
ENTITIES |
برداشت از همان کیف پولِ شخصی (SettlementController موجودی را با getWalletBalance(user) میسنجد) |
bank_accounts · pos_devices |
جفت محیط، تهیپذیر | از کاربر به محیط منتقل شدند؛ موارد مبهم تهی ماندند (پایین) |
نتیجهٔ عملی برای زنجیره: تضمین فقط تا جایی است که کوئری به payments لنگر بزند. SecretaryEarningRepository::reportFor این کار را با join('b.payment','p') میکند و فیلتر روی همان مینشیند؛ FinancialChainTenantTest همین را میسنجد.
کیف پول: موجودی سراسری، دفتر per-محیط
کیف پول ستون tenant ندارد و نباید داشته باشد: پول مالِ شخص است و اگر فیلتر روی موجودی مینشست، بیمار در هر محیط بخشی از پول خودش را میدید.
ولی دفترِ تراکنش را کلینیک هم میبیند (GET /api/v1/patient/{uuid}/wallet/transactions و recent_transactions)، و گاردِ ownsRecord فقط پرونده را میسنجد نه سطرها. پس بدون تفکیک، کلینیک A میخواند بیمار در کلینیک B چه پرداخت کرده و چه کسی ثبتش کرده.
راهحل، انتساب per-ردیف است نه مالکیت per-کیفپول:
// WalletTransaction — نامِ ستونها عمداً entity_type/entity_id نیست
#[ORM\Column(name: 'recorded_entity_type', ...)] private ?string $recordedEntityType = null;
#[ORM\Column(name: 'recorded_entity_id', ...)] private ?int $recordedEntityId = null;
⚠️ نامها باید همین بمانند. TenantFilter روی entityType/entityId کلید میزند؛ اگر این دو همان نام را میگرفتند، فیلتر خودکار روی محاسبهٔ موجودی هم مینشست و پول بیمار را نصف نشان میداد. اینجا انتساب است، نه مالکیت.
نتیجه: balance_rials سراسری، سطرهای دفتر per-محیط — پس جمع سطرها با موجودی برابر نیست و UI باید بگوید. ردیفهای بیانتساب (پیش از این تفکیک، یا بیرون از هر محیط مثل سهم نماینده) در همهجا دیده میشوند تا تاریخچهٔ موجود ناپدید نشود.
PatientWalletTenantTest هر سه را میسنجد: تفکیک سطرها، سراسریماندن موجودی، و دیدهشدن ردیف بیانتساب.
⚠️ نقطهٔ ضعف: کارتِ بیمحیط در هیچ محیطی دیده نمیشود
bank_accounts و pos_devices تنها جدولهاییاند که جفت محیطشان تهیپذیر است ({@see NullableTenantOwnedTrait}). دلیل: تا فاز ۶ روی User ثبت میشدند و برای کاربری که چند محیط دارد هیچ ستونی نمیگفت کدام کارت مال کدام محیط است. تصمیم گرفته شد حدس زده نشود؛ ردیف مبهم تهی میماند تا مالک خودش تعیین کند.
هزینهاش این است: فیلتر شرط تساوی میگذارد و NULL با هیچ مقداری برابر نیست، پس چنین ردیفی از هر کوئری DQL غایب است — حتی برای کسی که خودش ثبتش کرده.
راه خروج، تنها استثنای این دامنه است: findUnassignedByUser() و assignEntity() عمداً با DBAL خام اجرا میشوند (فیلتر رویشان اعمال نمیشود) و بهجای فیلتر، محدودیت user_id را در خودِ کوئری دارند. انتساب با PATCH /api/v1/my/payment-methods/{bank-accounts|pos}/{uuid}/environment انجام میشود و شرطهای مالکیت و بیمحیط بودن داخل خودِ UPDATEاند تا دو درخواست همزمان یک کارت را به دو محیط نچسبانند.
پنل ادمین این ردیفها را با نشانهٔ «محیط تعییننشده» و دکمهٔ انتساب نشان میدهد، وگرنه کاربر چندمحیطی فکر میکند کارتش گم شده.
SQL خام — آدیتشده
فیلتر روی Connection::executeQuery/executeStatement اعمال نمیشود. هر نقطهای که SQL خام میزند بررسی و طبقهبندی شده:
| فایل | دسته | چرا امن است |
|---|---|---|
Billing/Repository/ClaimRepository |
tenant-دار | هر سه کوئری WHERE c.entity_type = :type AND c.entity_id = :id دارند؛ ClaimsByPatientTest::testAnotherTenantsClaimsNeverAppearInTheDashboard تثبیتش میکند |
Admin/Controller/AdminApiController |
ادمین | #[IsGranted('ROLE_ADMIN')] سطح کلاس؛ عمداً cross-tenant |
Representation/Controller/RepresentationActionController |
نماینده | فقط doctors، اسکوپ representation_id |
Category/Service/CategoryImporter |
سراسری | فقط جدولهای مرجع؛ نام جدول از ثابت TABLES میآید (نه ورودی کاربر) و isValidBundle() + ROLE_ADMIN گیتش میکنند |
Doctor/Command/Purge*Command · Shared/Command/SeedDemoDataCommand |
کنسول | dry-run پیشفرض، --force لازم، prod از سطح kernel مسدود |
Shared/Controller/HealthController |
سراسری | SELECT 1 |
Shared/Logging/DbLogger |
سراسری | app_log در GlobalTables |
PaymentMethod/Repository/{BankAccount,Pos}Repository |
عمداً بیرون فیلتر | تنها راه رسیدن به ردیفِ بیمحیط؛ هر دو کوئری WHERE user_id = ? دارند و assignEntity شرط entity_type IS NULL را هم داخل UPDATE نگه میدارد. PaymentMethodTenantTest تلاش برای تصاحب کارت شخص دیگر را میسنجد |
getReference() در کل src/ یک مورد است و روی User (سراسری) — بدون اثر tenant.
بکاپ per-tenant
php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql
جدولها از metadata خوانده میشوند (همان معیار TenantFilter)، پس جدولی که فردا جفت tenant بگیرد خودکار وارد خروجی میشود.
⚠️ فرزندان aggregate ستون محیط ندارند و در خروجی نمیآیند. برای بکاپ کامل یک محیط، آنها باید از ریشه دنبال شوند.
ورودی --tenant با regex بسته اعتبارسنجی میشود چون مستقیم داخل --where و خط فرمان میرود؛ TenantDumpCommandTest هفت ورودی بدشکل (تزریق SQL و شل، نوع ناشناخته، id صفر/منفی) را میسنجد.
entity جدید میسازی؟
۱. اگر به یک محیط تعلق دارد → use TenantOwnedTrait; و در نقطهٔ ساخت assignTenant() را صدا بزن
۲. اگر ندارد → با دلیل در GlobalTables::ENTITIES ثبتش کن
۳. اگر فرزند یک aggregate است → در GlobalTables::AGGREGATE_CHILDREN با ریشهٔ صریح
۴. تست را اجرا کن: ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php
NullableTenantOwnedTrait برای entity جدید نیست. فقط برای جدولی است که از قبل وجود داشته و مالکِ بعضی ردیفهایش از داده قابل تشخیص نیست؛ entity جدید از روز اول محیط دارد، پس ستون تهیپذیر فقط تور ایمنی را سوراخ میکند.
DEFERRED هم راه فرار نیست: خالی است و باید خالی بماند.
ایندکسها: entity_type, entity_id باید ستونهای اول هر ایندکس ترکیبیِ لیست باشند، وگرنه MariaDB برای شرط فیلتر از آن استفاده نمیکند.
تستها
| فایل | چه چیزی را تضمین میکند |
|---|---|
tests/Shared/EntityContextResolverTest.php |
ماتریس ۵ نقش |
tests/Shared/TenantIsolationMatrixTest.php |
هر نقش فقط دادهٔ محیط خودش را از API میگیرد |
tests/Shared/TenantFilterLeakTest.php |
فیلتر بهتنهایی cross-tenant را میبندد؛ مسیر عمومی و ادمین باز میمانند |
tests/Shared/TenantSchemaCoverageTest.php |
هیچ entity طبقهبندینشده نمیماند |
tests/Appointment/BookingTenantTest.php |
نوبت در محیط درست ثبت میشود |
tests/Secretary/SecretaryMultiClinicScopeTest.php |
یک منشی، یک پزشک، چند کلینیک |
tests/Shared/TenantOwnershipCheckerTest.php |
خودِ checker: null، محیط حلنشده، نوعِ متفاوت با شناسهٔ یکسان |
tests/Shared/TenantLookupInventoryTest.php |
جستوجوی uuid تازهای بدون بازبینی اضافه نشده |
tests/Appointment/ServiceModeSectionDurationTest.php |
سرویسِ محیط دیگر نه اسلات میدهد نه به نوبت میچسبد |
tests/Patient/SessionServiceTenantTest.php |
سرویس/پرسنلِ محیط دیگر نه قیمت میخورد نه ذخیره میشود |
tests/Payment/PaymentTenantTest.php |
پرداخت به محیط گیرنده مینشیند؛ بیمار پرداخت خودش را میبیند، محیط دیگر نمیبیند |
tests/Settlement/FinancialChainTenantTest.php |
زنجیرهٔ مالی از راه لنگر به payments جدا میشود؛ کیف پول عمداً سراسری میماند |
tests/PaymentMethod/PaymentMethodTenantTest.php |
کارتها per-محیطاند؛ ردیف بیمحیط دیده میشود ولی تا انتساب قابل ویرایش نیست |
tests/Patient/PatientWalletTenantTest.php |
دفتر کیف پول per-محیط است ولی موجودی سراسری میماند |
tests/Shared/RequestReachableChildTenantTest.php |
فرزندانِ قابلدسترس با uuid را خودِ فیلتر میبندد، بدون گارد دستی |
tests/Auth/MultiClinicOwnerContextTest.php |
مالک چند کلینیک به هرکدام میتواند سوییچ کند |
اعتبار جلسه: چرا برخلاف کیف پول جفت محیط میگیرد
wallet_transactions عمداً در ENTITIES است: پول مالِ شخص است و در هر محیطی همان
پول است؛ هر ردیف فقط recorded_entity_* دارد تا معلوم باشد کجا ثبت شده.
session_credit_ledger متفاوت است و جفت محیط واقعی میگیرد: «شش جلسه لیزر کلینیک الف»
در کلینیک ب هیچ معنایی ندارد و قابل مصرف نیست. همین تفاوت باعث میشود پکیجهای یک بیمار
در دو کلینیک کاملاً از هم جدا بمانند.