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>
18 KiB
نرمالسازی ارقام فارسی/عربی در همه فیلدهای عددی
پروژه
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
// تبدیل ارقام فارسی/عربی به انگلیسی + حذف هر کاراکتر غیرعددی.
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
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
<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
export const iranMobileSchema = z
.string()
.transform((v) => toEnglishDigits(v).replace(/\D/g, ''))
.refine((v) => IRAN_MOBILE_RE.test(v), 'شماره موبایل باید ۱۱ رقم و با 09 شروع شود');
schemaهایی که ارقام فارسی را رد میکنند (چون \d فقط ASCII است)
// 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
$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را اصلاح کن. - اینها را اضافه کن:
/** فقط ارقام لاتین، با محدودیت طول اختیاری. */
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یک propnumeric?: 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سفارشی لازم است:
<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 تبدیل کند:
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.tsddev exec npx tsc --noEmitوddev exec yarn devddev 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 بگیر، نه یکجا.