feat(payment): unify payment flow with new pure redirect entry and update related endpoints
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# یکسانسازی کامل پرداخت به یک Flow واحد + entry ریدایرکت خالص (Backend + Admin)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend + Admin SPA). **cross-repo** — پرامپت همتا: `nobat724_front/.claude/prompt/payment-single-flow-frontend.md` (سایت عمومی entry پرداخت را مصرف میکند؛ Backend اول اجرا شود).
|
||||
|
||||
## زمینه
|
||||
|
||||
بعد از بازطراحیهای قبلی، بخش زیادی از «flow واحد» موجود است و **نباید دوباره ساخته شود**:
|
||||
|
||||
- `PaymentManager` (`src/Payment/Service/PaymentManager.php`): تنها جایی که با درگاه صحبت میکند و verify (transaction+قفل+idempotent+post-action+log) را انجام میدهد.
|
||||
- `GatewayFactory`: Factory/Strategy انتخاب درگاه.
|
||||
- `GET /api/v1/payment/pay/{orderId}` (عمومی): ارتباط با بانک + انتقال 302/فرم POST.
|
||||
- `callback/{gateway}` → `PaymentManager::processCallback` → 302 به `frontend_address` (دامنهٔ مبدأ با `status`).
|
||||
- appointment و subscription: POST فقط `Payment` میسازد و `pay_url` میدهد؛ سپس همان pay-endpoint.
|
||||
|
||||
اما دو انحراف از «تنها یک روش پرداخت» باقی مانده که این پرامپت آنها را رفع میکند:
|
||||
|
||||
1. **`SmsWalletController::charge` یک Flow پرداخت جداگانه است** — خودش درگاه را resolve میکند (`mellat/sep/mock` با `match`)، خودش `new Payment` + `$gateway->initiate(...)` را صدا میزند و `redirect_url` بانک را برمیگرداند. این نقض «یک flow واحد» است و باید حذف و به flow واحد منتقل شود.
|
||||
2. **entry پرداخت هنوز نیازمند یک XHR است** (POST برای ساخت `Payment` سپس ریدایرکت به `pay_url`). طبق نیاز جدید باید یک entry ریدایرکتِ خالص هم وجود داشته باشد: مرورگر مستقیماً به `GET /api/v1/payment/order/{appointmentUuid}` برود و Backend همهٔ کار (اعتبارسنجی + ساخت Payment + ارتباط با بانک + ریدایرکت به شاپرک) را انجام دهد.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
- تنها **یک** مسیر ساخت/شروع پرداخت در کل backend وجود داشته باشد: ساخت `Payment` (هر `type`) → `PaymentManager::startGatewayHandoff` → بانک → `callback` → `PaymentManager::processCallback` → ریدایرکت به دامنهٔ مبدأ.
|
||||
- هیچ کنترلری غیر از `PaymentManager` نباید `->initiate(` یا resolve مستقیم درگاه داشته باشد.
|
||||
- افزودن entry ریدایرکتِ خالص `GET /api/v1/payment/order/{appointmentUuid}` (بدون XHR) برای مسیر نوبت.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Sms/Controller/SmsWalletController.php` | **حذف flow جدا**: متد `charge` باید فقط Payment بسازد و `pay_url` بدهد |
|
||||
| `src/Payment/Controller/PaymentController.php` | افزودن `GET /payment/order/{appointmentUuid}`؛ نگهداشتن pay/callback |
|
||||
| `src/Payment/Service/PaymentManager.php` | موجود — منبع واحد ارتباط با درگاه (بدون تغییر بزرگ) |
|
||||
| `src/Payment/Gateway/GatewayFactory.php` | موجود — resolve/activeGateways |
|
||||
| `src/Payment/Repository/PaymentRepository.php` | موجود `findPendingByAppointment` برای جلوگیری از pending تکراری |
|
||||
| `config/packages/security.yaml` | `^/api/v1/payment/order/` عمومی شود |
|
||||
| `assets/admin/pages/SmsWalletPage.tsx`, `SubscriptionPage.tsx` | مصرف `pay_url` (قبلاً بهروز شده؛ فقط تأیید) |
|
||||
| `docs/api/payment.md`, `docs/api/sms.md` | بهروزرسانی |
|
||||
|
||||
## وضعیت فعلی (flow جدا در SmsWallet — باید حذف شود)
|
||||
|
||||
```php
|
||||
// src/Sms/Controller/SmsWalletController.php (charge)
|
||||
if ($this->configRepo->get('payment_test_mode') === '1') {
|
||||
$gateway = $this->mock;
|
||||
} else {
|
||||
$gateway = match ($gatewayName) { 'mellat' => $this->mellat, 'sep' => $this->sep, default => null };
|
||||
}
|
||||
if ($gateway === null) { return $this->error(...); }
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress);
|
||||
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
|
||||
$this->paymentRepo->save($payment);
|
||||
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
||||
$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl); // ❌ ارتباط مستقیم با درگاه
|
||||
// ... setGatewayToken ... return ['redirect_url' => $result->redirectUrl]; // ❌ redirect_url بانک
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. حذف Flow جداگانهٔ SmsWallet و انتقال به flow واحد
|
||||
|
||||
`SmsWalletController::charge` را طوری بازنویسی کن که **هیچ ارتباطی با درگاه نداشته باشد**؛ فقط `Payment` بسازد و `pay_url` بدهد (دقیقاً مثل appointment/subscription):
|
||||
|
||||
```php
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress);
|
||||
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->success([
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
|
||||
'order_id' => $payment->getOrderId(),
|
||||
]);
|
||||
```
|
||||
|
||||
- فقط اعتبارسنجی درگاه با `GatewayFactory::resolve($gatewayName) === null` (بدون init). `GatewayFactory` را به کنترلر تزریق کن.
|
||||
- Dependencyهای درگاه از `SmsWalletController` حذف شوند: `MellatGateway`, `SepGateway`, `MockGateway` و منطق `match`/`configRepo` مربوط به درگاه. (init توسط `PaymentManager` در `pay` endpoint انجام میشود؛ post-action `TYPE_SMS_WALLET` از قبل در `PaymentManager::handleSmsWalletCharge` هست.)
|
||||
- Callback این نوع از قبل به `/api/v1/payment/callback/{gateway}` → `PaymentManager` میرود (چون `pay` endpoint با `PaymentManager::callbackUrl` بر اساس type میسازد؛ برای غیر-subscription پیشوند `/payment/callback/` است). تأیید کن.
|
||||
|
||||
### ۲. entry ریدایرکتِ خالص `GET /api/v1/payment/order/{appointmentUuid}`
|
||||
|
||||
در `PaymentController` یک route جدید (عمومی، بدون JWT) اضافه کن که کل مراحل ۴ تا ۶ نیاز را انجام دهد و **نیازی به XHR نداشته باشد**:
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/payment/order/{appointmentUuid}', methods: ['GET'])]
|
||||
public function startOrderPayment(string $appointmentUuid, Request $request): Response
|
||||
{
|
||||
$gatewayName = trim((string) $request->query->get('gateway', ''));
|
||||
$return = trim((string) $request->query->get('return', ''));
|
||||
|
||||
$appointment = $this->appointmentRepo->findByUuid($appointmentUuid);
|
||||
if ($appointment === null) {
|
||||
return $this->redirectToReturn($return, 'notfound');
|
||||
}
|
||||
// اعتبارسنجی سفارش: قابلپرداخت بودن (pending/confirmed) و منقضی نبودن
|
||||
if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) {
|
||||
return $this->redirectToReturn($return, 'invalid');
|
||||
}
|
||||
if (!empty($return) && !$this->isAllowedFrontend($return)) {
|
||||
return $this->redirectToReturn('', 'invalid'); // آدرس بازگشت مجاز نیست
|
||||
}
|
||||
if ($this->gateways->resolve($gatewayName) === null) {
|
||||
return $this->redirectToReturn($return, 'gateway');
|
||||
}
|
||||
|
||||
// جلوگیری از pending تکراری: اگر پرداخت pending برای این نوبت هست، همان را ادامه بده.
|
||||
$payment = $this->paymentRepo->findPendingByAppointment($appointment)
|
||||
?? $this->createAppointmentPayment($appointment, $gatewayName, $return);
|
||||
|
||||
// ارتباط با بانک + انتقال به شاپرک (همان مسیر واحد).
|
||||
$result = $this->paymentManager->startGatewayHandoff($payment);
|
||||
if ($result === false) {
|
||||
return $this->redirectToFrontend($payment, false);
|
||||
}
|
||||
return $result->redirectMethod === 'POST'
|
||||
? $this->autoSubmitForm(strtok($result->redirectUrl, '?'), $result->redirectParams)
|
||||
: new RedirectResponse($result->redirectUrl);
|
||||
}
|
||||
```
|
||||
|
||||
نکتهها:
|
||||
- `createAppointmentPayment()` همان منطق ساخت `Payment` در `initiateAppointment` را کپسوله کند (fee از `appointment_fee_rials`، `frontend_address = $return`).
|
||||
- اگر یک pending موجود بود ولی درگاه/گیتوی متفاوت انتخاب شده، تصمیم بگیر: یا درگاهِ pending را بهروزرسانی کن یا همان را ادامه بده (سادهترین: همان pending را ادامه بده).
|
||||
- `redirectToReturn(string $return, string $status)`: اگر `$return` مجاز بود `302` به `"$return?status=$status"`، وگرنه یک JSON خطای کوتاه.
|
||||
- **مالکیت/امنیت (مهم):** این endpoint عمومی است و بهصورت full-page redirect از دامنهٔ دیگری فراخوانی میشود؛ JWT در دسترس نیست. بنابراین `appointmentUuid` نقش capability را دارد (غیرقابلحدس/UUID). پرداخت فقط به نفع صاحب نوبت است، پس شروع پرداخت توسط دارندهٔ لینک ریسک مالی ندارد. اگر مالکیت سختگیرانه لازم است، یک پارامتر امضاشدهٔ `sig` (HMAC از uuid + secret) اضافه کن و در این endpoint verify کن؛ در غیر اینصورت همین کافی است. تصمیم را در docs ذکر کن.
|
||||
|
||||
### ۳. عمومیکردن route جدید در security
|
||||
|
||||
در `config/packages/security.yaml`:
|
||||
- الگوی firewall `payment_callback` را گسترش بده تا `^/api/v1/payment/(callback|pay|order)/` را پوشش دهد.
|
||||
- یک `access_control` برای `^/api/v1/payment/order/` با `PUBLIC_ACCESS`.
|
||||
|
||||
### ۴. تضمین «تنها یک flow»
|
||||
|
||||
- بعد از تغییرات، مطمئن شو تنها فایلی که `->initiate(` یا resolve مستقیم درگاه دارد `PaymentManager` است:
|
||||
```bash
|
||||
grep -rn "->initiate(\|new Payment(" src --include=*.php
|
||||
```
|
||||
انتظار: `new Payment(` فقط در نقاط ساختِ Payment (کنترلرها/SmsWallet برای ساخت، بدون init)؛ `->initiate(` فقط در `PaymentManager` و کلاسهای Gateway.
|
||||
- `payment_url`/`redirect_url` بانکی نباید از هیچ endpointی به کلاینت برگردد؛ فقط `pay_url` (یا ریدایرکت مستقیم در `order`).
|
||||
|
||||
### ۵. مستندسازی
|
||||
|
||||
- `docs/api/payment.md`: افزودن `GET /api/v1/payment/order/{appointmentUuid}` (پارامترهای `gateway`, `return`, رفتار، امنیت، نمودار بهروزشده).
|
||||
- `docs/api/sms.md`: بهروزرسانی `POST /api/v1/sms/wallet/charge` — حالا `pay_url` برمیگرداند و init در pay-endpoint واحد است.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **جریان بیرونی نباید بشکند:** `pay`/`callback`/`frontend_address?status=` ثابت بمانند. `order` یک entry جدید است، نه جایگزین pay.
|
||||
- **همهٔ typeها یک مسیر:** appointment (هم XHR→pay_url و هم entry جدید order)، subscription و sms_wallet (XHR→pay_url) همگی به `pay`+`callback`+`PaymentManager` میرسند. تفاوت فقط در ساختِ اولیهٔ Payment است.
|
||||
- **علت باقیماندن یک XHR برای subscription/sms:** اینها order uuidِ ازپیشموجود ندارند و از پنل ادمین (JWT در localStorage) شروع میشوند؛ ساخت Payment نیازمند auth است. entry ریدایرکتِ خالص فقط برای نوبت (که uuid عمومی دارد) ممکن است. این موضوع در docs شفاف شود.
|
||||
- controllerها از `BaseController`؛ تاریخها Unix timestamp.
|
||||
- بعد از تغییر: `ddev exec php -l`, `ddev exec php bin/console cache:clear`, `ddev exec php vendor/bin/phpstan analyse src/Payment src/Sms`, و تست دستی `order` در `payment_test_mode=1`:
|
||||
```bash
|
||||
curl -sk -o /dev/null -w "%{http_code} %{redirect_url}\n" \
|
||||
"https://clinic-pro.ddev.site/api/v1/payment/order/<APPT_UUID>?gateway=mellat&return=http://yazd-nobat.localhost:3000/payment/result"
|
||||
```
|
||||
- بعد از تغییر API → `docs/api/payment.md` و `docs/api/sms.md` در همین session.
|
||||
@@ -0,0 +1,139 @@
|
||||
# قیمت هر پیامک در تنظیمات + رفع تنظیمات ارسال پیامک کیفپول
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend + Admin SPA).
|
||||
|
||||
## زمینه
|
||||
|
||||
دو مشکل در بخش پیامک پنل ادمین:
|
||||
|
||||
1. **`/admin/settings` → بخش «پیامک»**: امکان تعیین «هزینه هر پیامک» وجود ندارد (قبلاً بود). قیمت هر پیامک اکنون در بکاند **هاردکد** است: `SmsWalletController::SMS_PRICE_RIALS = 500`. باید به یک تنظیمِ قابلویرایش در همین صفحه تبدیل شود.
|
||||
2. **`/admin/sms-wallet` → «تنظیمات ارسال پیامک»**: درست کار نمیکند — بعد از ذخیره، وضعیت واقعی سرور (بهویژه وضعیت تأیید متن پیامک بعد از ویزیت) در UI منعکس نمیشود، چون state محلی بعد از ذخیره/رفچ ریست نمیشود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
- افزودن کلید تنظیم `sms_price_rials` (قیمت هر پیامک) که در `/admin/settings` بخش پیامک قابل ویرایش باشد و بکاند بهجای مقدار هاردکد از آن استفاده کند.
|
||||
- رفع باگِ state کهنه در فرم تنظیمات ارسالِ `/admin/sms-wallet`.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Config/Controller/SiteConfigController.php` | `ALLOWED_KEYS` تنظیمات سایت (باید `sms_price_rials` اضافه شود) |
|
||||
| `src/Sms/Controller/SmsWalletController.php` | استفاده از قیمت پیامک (هاردکد) + `resolveEntity` + endpoint تنظیمات |
|
||||
| `assets/admin/pages/SettingsPage.tsx` | فرم تنظیمات؛ بخش `sms` |
|
||||
| `assets/admin/pages/SmsWalletPage.tsx` | فرم «تنظیمات ارسال پیامک» (state محلی) |
|
||||
| `docs/api/sms.md` | مستندات (بهروزرسانی) |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
قیمت هاردکد و balance:
|
||||
|
||||
```php
|
||||
// src/Sms/Controller/SmsWalletController.php
|
||||
private const SMS_PRICE_RIALS = 500;
|
||||
// ...
|
||||
$smsPriceRials = self::SMS_PRICE_RIALS;
|
||||
$estimatedSms = (int) floor($balanceRials / $smsPriceRials);
|
||||
return $this->success([
|
||||
'balance_rials' => $balanceRials,
|
||||
'sms_price_rials' => $smsPriceRials,
|
||||
'estimated_sms_count' => $estimatedSms,
|
||||
]);
|
||||
```
|
||||
|
||||
کلیدهای مجاز تنظیمات (بدون `sms_price_rials`):
|
||||
|
||||
```php
|
||||
// src/Config/Controller/SiteConfigController.php
|
||||
private const ALLOWED_KEYS = [
|
||||
// ...
|
||||
'sms_panel_fee_rials',
|
||||
'appointment_fee_rials',
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
بخش پیامکِ `SettingsPage.tsx` فقط وضعیت کلید API را نشان میدهد (فیلد قیمت ندارد):
|
||||
|
||||
```tsx
|
||||
{current.id === 'sms' && (
|
||||
// فقط sms_api_key_configured نمایش داده میشود
|
||||
)}
|
||||
```
|
||||
|
||||
فرم تنظیماتِ `SmsWalletPage.tsx` — state محلی بعد از ذخیره ریست نمیشود:
|
||||
|
||||
```tsx
|
||||
const [localSettings, setLocalSettings] = useState<SmsSettings | null>(null);
|
||||
const currentSettings = localSettings ?? settings;
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['sms-settings'] }); toast.success('تنظیمات ذخیره شد'); },
|
||||
// ❌ localSettings ریست نمیشود → currentSettings همان نسخهٔ ویرایششده میماند،
|
||||
// دادهی تازهٔ سرور (مثل post_visit_text_status = 'pending') نمایش داده نمیشود
|
||||
});
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. افزودن کلید `sms_price_rials` به تنظیمات سایت (Backend)
|
||||
|
||||
در `SiteConfigController::ALLOWED_KEYS` کلید `'sms_price_rials'` را اضافه کن (کنار `sms_panel_fee_rials`). با این کار GET/PATCH `/api/v1/admin/settings` این کلید را برمیگرداند/ذخیره میکند.
|
||||
|
||||
### ۲. استفادهٔ بکاند از قیمتِ قابلتنظیم بهجای هاردکد
|
||||
|
||||
در `SmsWalletController`:
|
||||
- `SiteConfigRepository` را به constructor تزریق کن (اگر نیست).
|
||||
- یک helper خصوصی بساز: `private function smsPriceRials(): int { return max(1, (int) ($this->configRepo->get('sms_price_rials') ?: self::SMS_PRICE_RIALS)); }` و `SMS_PRICE_RIALS = 500` را بهعنوان **fallback** نگهدار.
|
||||
- در `balance()` بهجای `self::SMS_PRICE_RIALS` از `$this->smsPriceRials()` استفاده کن.
|
||||
- **جستجو کن** آیا جای دیگری قیمت هر پیامک برای کسر از کیفپول هنگام ارسال استفاده میشود (مثلاً `SmsWalletService` یا مسیر ارسال پیامک). اگر بله، همانجا هم از `sms_price_rials` (config) استفاده شود تا کسر و «تعداد تخمینی» همخوان باشند. اگر جایی مقدار ثابت دیگری هست، آن را هم به config متصل کن.
|
||||
|
||||
### ۳. فیلد «هزینه هر پیامک» در `SettingsPage.tsx` (Admin)
|
||||
|
||||
- به `FormValues` schema و `defaultValues` کلید `sms_price_rials` را اضافه کن (مثل `sms_panel_fee_rials`؛ نوع string، پیشفرض مثلاً `'500'`).
|
||||
- در بخش `current.id === 'sms'` یک `Field` با `input type="number"` برای `sms_price_rials` اضافه کن (الگوی دقیقاً مشابه فیلد `sms_panel_fee_rials` در بخش financial):
|
||||
```tsx
|
||||
<Field label="هزینه هر پیامک" hint="مبلغ کسرشده از کیف پول به ازای هر پیامک ارسالی (ریال).">
|
||||
<input {...register('sms_price_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="500" />
|
||||
</Field>
|
||||
```
|
||||
- مطمئن شو مقدار اولیه از `data` (پاسخ GET `/api/v1/admin/settings`) خوانده و در PATCH ارسال میشود (چون کل `FormValues` ارسال میشود، با افزودن به schema/defaults خودکار انجام میشود).
|
||||
|
||||
### ۴. رفع state کهنهٔ فرم تنظیمات در `SmsWalletPage.tsx`
|
||||
|
||||
بعد از ذخیرهٔ موفق، `localSettings` را ریست کن تا `currentSettings` به دادهٔ تازهٔ سرور برگردد (وضعیت تأیید متن، مقادیر نرمالشده):
|
||||
|
||||
```tsx
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body),
|
||||
onSuccess: () => {
|
||||
setLocalSettings(null); // ← افزوده شود
|
||||
qc.invalidateQueries({ queryKey: ['sms-settings'] });
|
||||
toast.success('تنظیمات ذخیره شد');
|
||||
},
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
```
|
||||
|
||||
- همچنین اگر لازم است که با هر بار تازهشدن `settingsData` هم state محلی صفر شود (برای جلوگیری از ماندگاری ویرایشهای ذخیرهنشده پس از رفچ)، یک `useEffect(() => { setLocalSettings(null); }, [settingsData])` اضافه کن.
|
||||
- در بدنهٔ PATCH فقط فیلدهای موردنیاز فرستاده شوند (`reminder_enabled`, `reminder_hours_before`, `post_visit_enabled`, `post_visit_text`)؛ backend بقیه را نادیده میگیرد ولی برای تمیزی میتوان همینها را صریح فرستاد.
|
||||
|
||||
### ۵. دسترسی صفحهٔ کیفپول برای کاربرانِ بدون entity (بررسی و تصمیم)
|
||||
|
||||
`SmsWalletController::resolveEntity` فقط برای `ROLE_DOCTOR` و `ROLE_CLINIC` entity برمیگرداند؛ برای بقیه (از جمله ادمینِ بدون پروفایل، secretary، clinic_doctor) `['unknown', null]` → همهٔ endpointها `403 پروفایل یافت نشد` میدهند و صفحه «کار نمیکند».
|
||||
|
||||
- اگر صفحهٔ `/admin/sms-wallet` باید برای نقشهای دیگر (مثل `clinic_doctor`) هم کار کند، `resolveEntity` را گسترش بده تا آن نقشها را هم به doctor/clinic نگاشت کند.
|
||||
- اگر کیفپول فقط برای doctor/clinic معنا دارد، در `SmsWalletPage.tsx` هنگام خطای 403 یک empty-state مناسب («کیف پول پیامک فقط برای پزشک/کلینیک فعال است») نمایش بده تا صفحه سفید/خراب نشود.
|
||||
- تصمیم را بر اساس نقشهای واقعی پروژه بگیر و در گزارش ذکر کن.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- کلید تنظیم `sms_price_rials` باید همان واحد ریال باشد؛ balance→count و کسرِ هنگام ارسال باید از **یک** مقدار بخوانند تا ناسازگاری نشود.
|
||||
- `SMS_PRICE_RIALS = 500` بهعنوان fallback بماند (نصبهای بدون مقدار config نشکنند).
|
||||
- الگوهای موجود پنل: `Field` + `register` (React Hook Form)، پاسخها `data?.data`، تاریخها Unix.
|
||||
- بعد از تغییر Backend/endpoint: `ddev exec php -l ...`، `ddev exec php bin/console cache:clear`، `ddev exec php vendor/bin/phpstan analyse src/Sms src/Config`؛ و بعد از تغییر TS: `ddev exec npx tsc --noEmit --project tsconfig.json`.
|
||||
- طبق قانون پروژه، `docs/api/sms.md` (و در صورت لزوم `docs/api/admin.md` برای کلید تنظیم جدید) در همین session بهروز شود: افزودن `sms_price_rials` به لیست تنظیمات و توضیح استفادهٔ آن.
|
||||
- Entity تغییر نمیکند → migration لازم نیست (فقط یک ردیف در جدول تنظیمات سایت که با set ساخته میشود).
|
||||
Reference in New Issue
Block a user