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>
305 lines
18 KiB
Markdown
305 lines
18 KiB
Markdown
# نرمالسازی ارقام فارسی/عربی در همه فیلدهای عددی
|
||
|
||
## پروژه
|
||
|
||
`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 بگیر، نه یکجا.
|