Files
clinicpro/.claude/prompt/normalize-persian-digits.md
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

18 KiB
Raw Permalink Blame History

نرمال‌سازی ارقام فارسی/عربی در همه فیلدهای عددی

پروژه

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:29digits() محلی را با 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 سفارشی لازم است:
<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 cardNumberDigitInput 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.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 بگیر، نه یک‌جا.