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>
This commit is contained in:
hamed
2026-07-18 10:38:56 +03:30
co-authored by Claude Opus 4.8
parent c103c393f3
commit 00cb9aaa1a
42 changed files with 789 additions and 125 deletions
@@ -0,0 +1,93 @@
<?php
namespace App\Shared\EventSubscriber;
use App\Shared\Util\PersianText;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* ارقام فارسی/عربی را در بدنهٔ JSON درخواست‌های API به لاتین ترجمه می‌کند.
*
* پنل ادمین ورودی‌ها را در مبدأ نرمال می‌کند، ولی `nobat724_front` و
* `clinic-pro-tauri` هم همین API را صدا می‌زنند؛ این لایه تضمین می‌کند هیچ
* کلاینتی نتواند رقم فارسی وارد دیتابیس کند.
*
* فقط ترجمهٔ رقم انجام می‌شود — کاراکتر غیرعددی حذف نمی‌شود چون شبا حرف `IR`
* دارد و تلفن ثابت خط تیره.
*/
class NumericFieldNormalizerSubscriber implements EventSubscriberInterface
{
/** کلیدهایی که مقدارشان عددی است و باید نرمال شوند. */
private const NUMERIC_KEYS = [
'mobile', 'mobile_number', 'telephone', 'phone', 'notification_mobile',
'national_code', 'postal_code',
'card_number', 'account_number', 'sheba', 'shaba', 'iban',
'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials',
'insurance_price_rials', 'patient_share_rials', 'visit_price_rials',
'duration_minutes', 'duration', 'commission_percent', 'coverage',
'coverage_percent', 'franchise', 'ceiling', 'tax_percent',
'base_insurance_discount_percent', 'supplementary_discount_percent',
];
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => ['onKernelRequest', 8]];
}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/api/v1/')) {
return;
}
if (!in_array($request->getMethod(), ['POST', 'PUT', 'PATCH'], true)) {
return;
}
if (!str_contains((string) $request->headers->get('Content-Type'), 'json')) {
return;
}
$content = $request->getContent();
if ($content === '') {
return;
}
$data = json_decode($content, true);
if (!is_array($data)) {
return;
}
$normalized = $this->normalizeTree($data);
if ($normalized === $data) {
return;
}
$request->initialize(
$request->query->all(),
$request->request->all(),
$request->attributes->all(),
$request->cookies->all(),
$request->files->all(),
$request->server->all(),
json_encode($normalized, JSON_UNESCAPED_UNICODE),
);
}
private function normalizeTree(array $data): array
{
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = $this->normalizeTree($value);
continue;
}
if (is_string($value) && in_array((string) $key, self::NUMERIC_KEYS, true)) {
$data[$key] = PersianText::digits($value);
}
}
return $data;
}
}
+14
View File
@@ -36,6 +36,20 @@ final class PersianText
return trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
}
/**
* فقط ارقام فارسی/عربی را به لاتین ترجمه می‌کند و بقیهٔ کاراکترها را دست نمی‌زند.
*
* برخلاف normalize() فاصله‌ها را جمع نمی‌کند و trim نمی‌کند — برای فیلدهای عددی
* لازم است، چون شبا حرف دارد و تلفن ثابت خط تیره.
*/
public static function digits(string $text): string
{
return strtr($text, array_combine(
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
));
}
/** مقایسهٔ دو نام فارسی پس از نرمال‌سازی. */
public static function sameName(string $a, string $b): bool
{