Files
clinicpro/.claude/prompt/normalize-persian-digits.md
T
hamedandClaude Opus 4.8 00cb9aaa1a feat(admin): normalize Persian/Arabic digits in every numeric field
Users typing on a Persian keyboard produced two distinct failures. Fields with
type="number" silently returned an empty string — the browser rejects Persian
digits, so the value was lost and saved as empty or zero. Text fields passed the
Persian characters straight through to the database, where a mobile stored as
۰۹۱۲… never matches 09… again. The secretary form hit the second case with no
validation at all.

Frontend:
- Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms
  with numericField()/latinDigitsField() wrappers for React Hook Form fields.
- Converts every type="number" input to type="text" inputMode="numeric" with
  digit normalization; none remain. Fields that legitimately carry non-digits
  (sheba, landline) only get the digits translated, keeping IR and separators.
- Points the patient national-code and mobile schemas at the shared normalizing
  schemas, which accept Persian input instead of rejecting it.
- Drops two duplicate local digit converters in favour of the shared helper.

Backend:
- Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted
  numeric keys of JSON request bodies under /api/v1/ before controllers run, so
  nobat724_front and clinic-pro-tauri are covered too. Translation only — no
  characters are stripped, non-string values and other keys are untouched.

Three component tests asserted on role="spinbutton" and numeric input values;
both are properties of type="number", so they were updated to match the new
text inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:38:56 +03:30

305 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# نرمال‌سازی ارقام فارسی/عربی در همه فیلدهای عددی
## پروژه
`clinicpro` (پنل ادمین React + یک لایه دفاعی در backend). لایه backend همه کلاینت‌ها را پوشش می‌دهد — `nobat724_front` و `clinic-pro-tauri` هم از همان `/api/v1/...` استفاده می‌کنند، پس نیازی به پرامپت جدا برای آن‌ها نیست.
## زمینه
کاربر فارسی‌زبان با کیبورد فارسی، عدد را با ارقام فارسی (`۰-۹`) یا عربی (`٠-٩`) تایپ می‌کند. ابزار نرمال‌سازی از قبل در پروژه هست (`toEnglishDigits` در `assets/admin/lib/utils.ts`) و چند کامپوننت (`MobileInput`، `DigitInput`، `PriceInput`، `Input` با prop `numeric`) از آن استفاده می‌کنند — ولی **اکثر فیلدهای عددی پنل از هیچ‌کدام استفاده نمی‌کنند**.
دو نوع خرابی متفاوت رخ می‌دهد و باید هر دو در ذهن باشد:
- **`type="number"`** → مرورگر مقدار را نامعتبر می‌داند و `e.target.value` رشتهٔ **خالی** برمی‌گرداند. یعنی کاربر عدد را می‌بیند ولی فیلد خالی/صفر ذخیره می‌شود — **باگ از دست رفتن داده**، نه مقدار غلط.
- **`type="text"` / `type="tel"`** → ارقام فارسی دست‌نخورده تا دیتابیس می‌روند. مثلاً شماره موبایل `۰۹۱۲...` ذخیره می‌شود و بعداً هیچ‌وقت با `09...` مچ نمی‌شود.
نقطهٔ شروع گزارش کاربر: فرم «افزودن منشی» — هم موبایل و هم کد ملی از نوع دوم‌اند و مستقیم به API می‌روند.
## مشکل / هدف
۱. فرم منشی (موبایل + کد ملی) ارقام فارسی را بدون تبدیل ارسال می‌کند.
۲. حدود ۵۰ فیلد عددی دیگر در پنل همین مشکل را دارند.
۳. هیچ محافظ سمت backend وجود ندارد (فقط یک endpoint نرمال‌سازی می‌کند).
۴. چند پیاده‌سازی تکراری از همان تابع تبدیل در فایل‌های مختلف پخش شده است.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/lib/utils.ts:105-135` | `toEnglishDigits`، `sanitizeMobileInput`، `iranMobileSchema` |
| `assets/admin/components/ui/Input.tsx:19-25` | prop `numeric` — پیاده‌سازی درست، **صفر مصرف‌کننده** |
| `assets/admin/components/ui/MobileInput.tsx` | فیلد موبایل |
| `assets/admin/components/ui/DigitInput.tsx` | فیلد فقط‌رقم با `maxDigits` |
| `assets/admin/components/ui/PriceInput.tsx` | فیلد مبلغ با جداکننده |
| `assets/admin/pages/MySecretariesPage.tsx:226-266, 437, 441` | فرم منشی + `DefaultTextField` خام |
| `assets/admin/pages/RepresentationProfilePage.tsx:21-26` | `toLatinDigits` تکراری — باید حذف شود |
| `assets/admin/components/inventory/AddItemModal.tsx:29` | wrapper محلی `digits()` |
| `src/Shared/Util/PersianText.php:31-34` | نرمال‌ساز backend — فقط در یک controller استفاده شده |
| `src/Doctor/Controller/DoctorClaimController.php:95-99` | تنها مصرف‌کنندهٔ فعلی `PersianText` روی ارقام |
## وضعیت فعلی
### ابزار موجود — `assets/admin/lib/utils.ts:105-116`
```ts
// تبدیل ارقام فارسی/عربی به انگلیسی + حذف هر کاراکتر غیرعددی.
export function toEnglishDigits(input: string): string {
if (!input) return '';
return input
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660));
}
export function sanitizeMobileInput(input: string): string {
return toEnglishDigits(input).replace(/\D/g, '').slice(0, 11);
}
```
> کامنت بالای `toEnglishDigits` غلط است — این تابع کاراکتر غیرعددی را حذف **نمی‌کند**، فقط ارقام را ترجمه می‌کند. کامنت را اصلاح کن.
### الگوی درستِ موجود — `assets/admin/components/ui/Input.tsx:19-25`
```tsx
const handleChange = numeric
? (e: React.ChangeEvent<HTMLInputElement>) => {
const latin = toEnglishDigits(e.target.value);
if (latin !== e.target.value) e.target.value = latin;
onChange?.(e);
}
: onChange;
```
### فرم منشی — `assets/admin/pages/MySecretariesPage.tsx:437, 441`
```tsx
<DefaultTextField placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
...
<DefaultTextField placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
```
`DefaultTextField` (`:226-266`) یک `<input>` خام بدون `type`/`inputMode`/`dir` است و مقدار را عیناً پاس می‌دهد. مقدار در `:773-775` و `:803` بدون هیچ پردازشی ارسال می‌شود. این فرم اصلاً Zod schema ندارد.
### الگوی درست schema — `assets/admin/lib/utils.ts:125-135`
```ts
export const iranMobileSchema = z
.string()
.transform((v) => toEnglishDigits(v).replace(/\D/g, ''))
.refine((v) => IRAN_MOBILE_RE.test(v), 'شماره موبایل باید ۱۱ رقم و با 09 شروع شود');
```
### schemaهایی که ارقام فارسی را رد می‌کنند (چون `\d` فقط ASCII است)
```ts
// components/PatientRecordInfoForm.tsx:20
national_code: z.string().trim().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد').or(z.literal('')),
// pages/PatientRecordFormPage.tsx:21-22
national_code: z.string().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد'),
mobile: z.string().regex(/^09\d{9}$/, 'شماره تماس نامعتبر است'),
```
### backend — `src/Shared/Util/PersianText.php:31-34`
```php
$text = strtr($text, array_combine(
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
));
```
فقط در `DoctorClaimController` روی ارقام استفاده شده. بقیهٔ endpointها (منشی، بیمار، پرسنل، کلینیک، سرویس، اشتراک، حساب بانکی) ارقام فارسی را بدون تغییر در دیتابیس می‌نویسند.
## وظایف
### ۱. تکمیل ابزارهای مشترک در `lib/utils.ts`
- کامنت غلط `toEnglishDigits` را اصلاح کن.
- این‌ها را اضافه کن:
```ts
/** فقط ارقام لاتین، با محدودیت طول اختیاری. */
export function digitsOnly(input: string, maxLen?: number): string {
const d = toEnglishDigits(input).replace(/\D/g, '');
return maxLen ? d.slice(0, maxLen) : d;
}
/** برای z.coerce.number() که روی ارقام فارسی NaN می‌دهد. */
export const persianSafeNumber = (schema: z.ZodNumber) =>
z.preprocess((v) => (typeof v === 'string' ? toEnglishDigits(v) : v), schema);
export const IRAN_NATIONAL_CODE_RE = /^\d{10}$/;
export const iranNationalCodeSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد');
export const iranNationalCodeOptionalSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => v === '' || IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی نامعتبر است');
```
تست‌ها را در `assets/admin/lib/utils.test.ts` اضافه کن (کنار تست‌های موجود `toEnglishDigits` در خطوط ۱۱۸-۱۳۶): ورودی فارسی، عربی، مخلوط، خالی، و رشتهٔ دارای کاراکتر غیرعددی.
### ۲. حذف پیاده‌سازی‌های تکراری
- `assets/admin/pages/RepresentationProfilePage.tsx:21-26` → تابع محلی `toLatinDigits` را حذف و با `toEnglishDigits` جایگزین کن (مصرف در `:158` و `:211`).
- `assets/admin/components/inventory/AddItemModal.tsx:29``digits()` محلی را با `digitsOnly` مشترک جایگزین کن.
### ۳. فرم منشی — نقطهٔ شروع گزارش کاربر
در `assets/admin/pages/MySecretariesPage.tsx`:
- موبایل (`:437`) → `<MobileInput>` (یا `DigitInput` با `maxDigits={11}`).
- کد ملی (`:441`) → `<DigitInput maxDigits={10}>`.
- **یا** ساده‌تر و کم‌ریسک‌تر: به `DefaultTextField` یک prop `numeric?: boolean` و `maxDigits?: number` اضافه کن که داخلش `digitsOnly` صدا بزند، سپس روی این دو فیلد `numeric` بگذار. اگر این راه را رفتی، `type="tel"`، `inputMode="numeric"` و `dir="ltr"` را هم ست کن.
- در `:773-775` و `:803` هم قبل از ارسال `digitsOnly` بزن (دفاع لایه‌ای — کاربر می‌تواند paste کند).
- این فرم schema ندارد؛ حداقل `iranMobileSchema` و `iranNationalCodeOptionalSchema` را روی همین دو فیلد اعمال کن تا خطای فارسی معنادار نشان داده شود.
### ۴. مهاجرت همهٔ فیلدهای عددی
فهرست کامل زیر لیست کار است. برای هر مورد:
- فیلد پول/مبلغ → `<PriceInput>`
- فیلد شمارهٔ ملی/موبایل/کارت/شبا/کد پستی → `<DigitInput maxDigits={n}>`
- بقیه (درصد، مدت، تعداد، وزن، سطح) → `<Input numeric>` یا `type="text" inputMode="numeric"` + `digitsOnly` در `onChange`
- **هیچ فیلد `type="number"` جدیدی نساز** و موجودها را به `type="text" inputMode="numeric"` تبدیل کن، وگرنه مشکل «مقدار خالی» باقی می‌ماند.
- اگر فیلد با React Hook Form `register` شده، `setValueAs` یا `onChange` سفارشی لازم است:
```tsx
<input
type="text"
inputMode="numeric"
dir="ltr"
{...register('price_rials', { setValueAs: (v) => digitsOnly(String(v ?? '')) })}
/>
```
#### منشی
| فایل:خط | فیلد |
|---|---|
| `MySecretariesPage.tsx:437` | `telephone` |
| `MySecretariesPage.tsx:441` | `national_code` |
#### پرسنل
| فایل:خط | فیلد |
|---|---|
| `pages/StaffPage.tsx:265` | `phone` |
| `pages/StaffPage.tsx:269` | `national_code` |
#### بیماران
| فایل:خط | فیلد |
|---|---|
| `pages/PatientRecordFormPage.tsx:129` | `national_code` |
| `pages/PatientRecordFormPage.tsx:132` | `mobile` |
| `components/PatientRecordInfoForm.tsx:117` | `national_code` — فقط prop `numeric` را به `<Input>` اضافه کن |
| `components/PatientRecordInfoForm.tsx:184` | `postal_code` — همان |
| `pages/MyPatientsPage.tsx:1441, 1452, 1464` | `visit_price_rials`، دو فیلد درصد تخفیف |
#### کلینیک/پزشک (تلفن ثابت — موبایل‌ها از قبل درست‌اند)
| فایل:خط | فیلد |
|---|---|
| `pages/ClinicsPage.tsx:278` | `telephone` |
| `pages/ClinicDetailPage.tsx:368` | `telephone` |
| `pages/ClinicFormPage.tsx:65` | `telephone` |
| `pages/DoctorDetailPage.tsx:652` | `telephone` |
#### نوبت
| فایل:خط | فیلد |
|---|---|
| `components/NewAppointmentDrawer.tsx:318` | `duration` |
| `pages/AppointmentCreatePage.tsx:437` | `duration` |
| `components/AppointmentFiltersModal.tsx:90` | `nationalCode` (فیلتر جستجو — بدون تبدیل هیچ‌وقت مچ نمی‌شود) |
#### زمان‌بندی
| فایل:خط | فیلد |
|---|---|
| `components/schedule/ScheduleSection.tsx:458` | `rest_interval` |
| `components/schedule/ScheduleSection.tsx:465` | `time_to_rest` |
| `components/schedule/ScheduleSection.tsx:705` | `buffer_minutes` |
| `components/schedule/ScheduleSection.tsx:751` | `booking_window_value` |
#### مبلغ / درصد
| فایل:خط | فیلد |
|---|---|
| `components/FreeVisitPrice.tsx:65` | قیمت ویزیت |
| `components/session/CreateStep.tsx:414, 441, 445` | قیمت ویزیت، دو درصد بیمه |
| `components/InsuranceModal.tsx:164, 168, 172` | `coverage`، `franchise`، `ceiling` |
| `components/ServiceInsuranceModal.tsx:130` | درصد پوشش |
| `components/DiscountTab.tsx:242, 247, 297` | `value` (درصد)، `priority`، `min_visit_count` |
| `pages/ClinicServicesPage.tsx:529` | `duration_minutes` — placeholder فعلی `"مثلاً: ۵۰"` با ارقام فارسی است و کاربر را به اشتباه می‌اندازد؛ اصلاحش کن |
| `pages/SmsWalletPage.tsx:531` | `amount_rials` |
| `pages/RepresentationSettlementPage.tsx:130` | `amount` |
#### تنظیمات / ادمین
| فایل:خط | فیلد |
|---|---|
| `pages/SettingsPage.tsx:319, 325, 356, 373, 399, 405, 495` | ساعت لغو، ساعت یادآوری، درصد کمیسیون، درصد مالیات، سه فیلد مبلغ |
| `pages/LogsPage.tsx:257` | روزهای نگهداری لاگ |
| `pages/CategoriesPage.tsx:352, 574, 702, 827` | `weight` (چهار جا) |
| `pages/AdminSubscriptionPage.tsx:274, 278, 323, 327, 333` | `level`، `max_secretaries`، `duration_months`، `price_rials`، `sort_order` |
#### نمایندگان
| فایل:خط | فیلد |
|---|---|
| `pages/RepresentationsPage.tsx:251` | `commission_percent` |
| `pages/RepresentationDetailPage.tsx:490` | `commission_percent` |
#### بانکی — هیچ‌کدام تبدیل ندارند
| فایل:خط | فیلد |
|---|---|
| `components/paymentMethods/BankAccountFormModal.tsx:91` | `cardNumber``DigitInput maxDigits={16}` |
| `components/paymentMethods/BankAccountFormModal.tsx:~95` | `accountNumber` |
| `components/paymentMethods/BankAccountFormModal.tsx:99` | `shabaNumber` → شبا حرف `IR` دارد؛ `digitsOnly` خام آن را خراب می‌کند. فقط `toEnglishDigits` بزن و حروف را نگه‌دار |
#### فقط یکدست‌سازی (از قبل درست کار می‌کنند)
`pages/LoginPage.tsx:242, 280, 330` و `components/ui/NotificationMobileCard.tsx:128` از `sanitizeMobileInput` استفاده می‌کنند — به `<MobileInput>` مهاجرت بده، اولویت پایین.
### ۵. اصلاح schemaهای Zod
- `components/PatientRecordInfoForm.tsx:20` و `pages/PatientRecordFormPage.tsx:21-22` → با `iranNationalCodeSchema` / `iranMobileSchema` جایگزین کن.
- همهٔ `z.coerce.number()`ها را با `persianSafeNumber(z.number()...)` بپوشان: `AdminSubscriptionPage.tsx:35, 36, 45, 46, 48`؛ `ClinicServicesPage.tsx:28, 31, 32`؛ `RepresentationsPage.tsx:28`؛ `SmsWalletPage.tsx:25`؛ `MyPatientsPage.tsx:70-74`.
### ۶. لایه دفاعی backend
یک نرمال‌سازی سطح-request بساز تا هیچ کلاینتی (پنل ادمین، `nobat724_front`، `clinic-pro-tauri`) نتواند ارقام فارسی وارد دیتابیس کند.
پیشنهاد: `src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php` روی `kernel.request` که برای درخواست‌های `/api/v1/**` با بدنهٔ JSON، مقدار کلیدهای شناخته‌شده را با `PersianText::normalize` تبدیل کند:
```php
private const NUMERIC_KEYS = [
'mobile', 'mobile_number', 'telephone', 'phone', 'notification_mobile',
'national_code', 'postal_code', 'card_number', 'account_number', 'sheba', 'iban',
'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials',
'duration_minutes', 'commission_percent', 'coverage', 'franchise', 'ceiling',
];
```
نکات:
- بازگشتی روی آرایه‌های تودرتو اعمال شود (مثلاً `insurances[].patient_share_rials`).
- مقدار فقط ترجمهٔ رقم شود؛ **حذف کاراکتر غیرعددی نکن** (شبا حرف دارد، تلفن ثابت خط تیره).
- فقط روی `string` اعمال شود، `int`/`bool`/`null` دست‌نخورده بماند.
- اگر تشخیص دادی subscriber بیش از حد گسترده است و ریسک دارد، جایگزین کم‌ریسک‌تر: `PersianText::normalize` را در همان چند controller حساس (منشی، بیمار، پرسنل، حساب بانکی) دستی صدا بزن و در گزارش بگو کدام مسیر را رفتی و چرا.
تست backend در `tests/Shared/` بنویس: POST با موبایل فارسی → مقدار ذخیره‌شده لاتین است.
### ۷. تست و مستندات
- `ddev exec yarn test` برای تست‌های `lib/utils.test.ts`
- `ddev exec npx tsc --noEmit` و `ddev exec yarn dev`
- `ddev exec php bin/phpunit tests/Shared`
- اگر subscriber ساختی، رفتار جدید را در `docs/api/README.md` (یا فایل مناسب `docs/api/`) به‌عنوان یک قاعدهٔ سراسری مستند کن: «ارقام فارسی/عربی در فیلدهای عددی سمت سرور نرمال می‌شوند».
## نکات مهم
- **`type="number"` دشمن این کار است.** با ارقام فارسی مقدار خالی برمی‌گرداند و هیچ `onChange` هندلری نجاتش نمی‌دهد. تبدیل به `type="text" inputMode="numeric"` بخش اجباری هر مورد است، نه اختیاری.
- شبا (`IR` + ۲۴ رقم) و تلفن ثابت (`021-1234...`) کاراکتر غیرعددی معتبر دارند — روی این‌ها فقط `toEnglishDigits` بزن نه `digitsOnly`.
- `PriceInput` از قبل خروجی `number` می‌دهد؛ جایگزینی مستقیم `type="number"` با آن ممکن است تایپ فرم را عوض کند — امضای `onChange` را چک کن.
- `<Input numeric>` از قبل ساخته شده و تست نشده چون هیچ مصرف‌کننده‌ای ندارد؛ بعد از اولین استفاده حتماً دستی تست کن.
- فیلدهایی که با RHF `register` شده‌اند با دست‌کاری مستقیم `e.target.value` درست کار نمی‌کنند مگر `setValueAs` یا `Controller` استفاده شود.
- RTL: فیلدهای عددی باید `dir="ltr"` داشته باشند تا عدد وارونه نمایش داده نشود.
- از کلاس‌های CSS موجود استفاده کن (`input`، `field`، `cp-input`)؛ کتابخانه جدید اضافه نکن.
- این تغییر بزرگ و پرتکرار است — **قابلیت‌به‌قابلیت پیش برو** و بعد از هر گروه `tsc` و build بگیر، نه یک‌جا.