# Локализация — бизнес-правила

Бизнес-правила локализации 1Формы: роли языков и fallback-цепочка при чтении, разделение Business/Data объектов, nullable FK-паттерн, автоперевод, кеш, права, экспорт/импорт и известные ограничения. Для администраторов и разработчиков при настройке и сопровождении мультиязычной конфигурации.

## 1. Язык по умолчанию и международный язык

В системе ровно один язык помечен `IsDefault=1` и ровно один — `IsInternational=1`. Гарантируется уникальными filtered-индексами на `Languages`.

Типичная конфигурация:
- Default: Русский (ID=1)
- International: English (ID=2)

| Роль | Флаг | Назначение |
|------|-------|-----------|
| Default | `IsDefault=1` | Язык, на котором хранятся исходные значения. Fallback третьего приоритета при чтении |
| International | `IsInternational=1` | Международный язык. Fallback второго приоритета при чтении |
| User language | `Users.LanguageID` | Язык пользователя. Первый приоритет при чтении |

При получении локализованного значения (`GetValue`) система проверяет языки в порядке:

1. **Язык пользователя** (`Users.LanguageID`)
2. **Международный язык** (`IsInternational=1`)
3. **Язык по умолчанию** (`IsDefault=1`)
4. **null** — используется исходное (нелокализованное) значение из целевой таблицы

Система не перебирает все доступные языки — только три приоритетных. Если значения нет ни для одного из них, возвращается null.

Тот же порядок отката действует и для SQL-читателей локализованных значений: его реализует функция `dbo.fn_AllLocalized_DataLocalizedDescriptionEx`, и на ней держатся отображаемые имена пользователя (`dbo.UserNameModes`).

Язык интерфейса пользователя определяет также язык браузерной проверки орфографии: во всех основных текстовых полях и редакторах платформы (текстовые ДП, комментарии, описание задачи, поля поиска и др.) включена браузерная проверка орфографии, и её язык берётся из языка интерфейса. Саму проверку выполняет браузер. От языка интерфейса зависит и язык панели инструментов визуального редактора — доступны русский и английский, остальные языки получают английскую панель (см. [Дополнительные параметры](https://help.1forma.ru/domains/ext-params/business.md), раздел «ДП «Большой текст»: пользовательский интерфейс»).

Описанный порядок относится к локализованным значениям сущностей. У статических подписей веб-интерфейса свой порядок отката — см. [Локализация — Администрирование](https://help.1forma.ru/domains/localization/admin.md). Шаблоны системных комментариев ленты (`dbo.EventTypeTemplates`) — третья ось, самая короткая: язык читателя, а при отсутствии шаблона — сразу язык по умолчанию, международный не подставляется. Устройство выбора шаблона и кеша — [comments/business.md](https://help.1forma.ru/domains/comments/business.md), раздел «Системные типы».

### Выбор языка интерфейса и язык профиля

Язык интерфейса пользователь выбирает в настройках профиля (поле «Язык»). Выбранное значение хранится в профиле (`Users.LanguageID`) и после входа является единственным источником языка интерфейса: обновление страницы, вход из другого браузера или с другого устройства язык не меняют, а смена языка, сделанная из мобильного приложения или администратором в карточке пользователя, видна при следующей загрузке страницы.

До входа язык можно выбрать на форме входа. Такой предвыбор браузер запоминает у себя и показывает на форме входа при следующих загрузках страницы, пока пользователь не авторизуется. При входе предвыбор однократно переносится в профиль и из браузера снимается: дальше язык берётся только из профиля, поэтому после выхода из системы форма входа показывает язык по умолчанию. Если при входе перенос не удался — например, из-за сбоя сети, — предвыбор остаётся в браузере и переносится на следующей загрузке страницы, так что выбор формы входа не теряется.

Язык профиля меняется только явным выбором самого пользователя: браузер язык профиля не переписывает, поэтому перевоплощение в другого пользователя язык его профиля не меняет.

**Скрытие при одном доступном языке.** Набор доступных языков задаёт общая настройка «Доступные языки». Если доступен один язык, переключатель языка не показывается нигде: на форме входа и регистрации, в верхней панели, в пункте «Язык» меню пользователя, в поле «Язык» профиля и в мобильных настройках. Отдельные признаки, скрывающие выбор языка в конкретном месте, продолжают действовать поверх этого правила.

**Выключенный язык заменяется резервным.** Резервный язык — язык по умолчанию, а если он тоже выключен, то первый доступный. Запомненный браузером предвыбор выключенного языка заменяется резервным и в профиль не переносится, поэтому форма входа открывается на доступном языке и не падает. Выключенный язык профиля после входа однократно переводится на резервный существующим вызовом смены языка, и страница перезагружается. При перевоплощении в другого пользователя язык его профиля не меняется — на резервный переходит только интерфейс.

### Культура пользователя: форматирование и локальные словари

На языке культуры пользователя также берутся: подписи элементов таблиц (меню столбцов, фильтры, пагинация, итоги), заголовок окна журнала подписей и подписи его колонок, элементы оболочки десктопного приложения — стартовое окно подключения к серверу, меню системного трея и диалог подтверждения закрытия приложения, — и текст неизвестной ошибки сервера в интерфейсе. Для оболочки десктопа поддерживаются все культуры платформы, при отсутствии перевода используется английский текст.

Локализация сущностей отвечает за тексты. За формат значений и служебные надписи интерфейса отвечает отдельный механизм, и его единый источник на клиенте — активная культура пользователя. От неё зависят:

- формат дат в ленте, карточке задачи и журнале подписей;
- формат дат на клиенте собирает общий пайп: он кэширует форматтеры по ключу «локаль + опции», поэтому смена языка интерфейса переформатирует даты, а разделители дней в списке чатов и лентах не создают форматтер заново на каждом проходе обнаружения изменений — опции форматирования передаются стабильным кортежем (см. Фронтенд чата, раздел «Форматирование дат в списке чатов»);
- формат чисел и денежных сумм, включая разделители и набор валют (числа считает штатный механизм браузера `Intl.NumberFormat` по культуре);
- служебные подписи таблиц — меню столбцов, фильтры, пагинация, агрегаты (словарь `ag-grid-<культура>`);
- заголовок окна журнала подписей и подписи его колонок.

Эти надписи и форматы следуют культуре пользователя, а не закреплены за русским языком: при культуре, отличной от `ru-RU`, меню таблиц, журнал подписей, даты и числа показываются на языке пользователя и в его формате.

### Отображаемый текст не участвует в логике

Условие, построенное по переведённой подписи, ломается молча при смене языка: ошибки в журнале нет, а поведение меняется. Так нельзя: сравнивать тип дополнительного параметра с русским названием типа, сравнивать подпись вкладки, ветвиться по названию пункта меню, опознавать папку комментариев по её имени, разбирать текст ошибки API на стороне интерфейса.

В логике сравнивается устойчивое значение: идентификатор пункта меню, идентификатор или индекс вкладки, значение перечисления типа параметра, признак папки комментариев, машинный код ошибки. Если сервер отдаёт только подпись — устойчивое поле добавляется в контракт, а не додумывается на клиенте. Сами строки при этом могут оставаться русскими: важно, чтобы от них не зависело поведение.


Язык по умолчанию — источник языка и для системных записей, у которых нет известного языка автора: например, запись истории правок при холодной перекомпиляции смарт-скрипта берётся по нему (см. [C# (Roslyn) — типизированные смарт-скрипты](https://help.1forma.ru/domains/smart-actions/csharp-scripting-roslyn.md), подраздел «Культура записи фоновой компиляции»).

**Язык на старте приложения.** Пока пользователь не вошёл, язык интерфейса определяется на стороне браузера. После входа язык приходит из данных сессии (`culture` в ответе `/api/auth/info`), а если это значение не заполнено — используется язык браузера. Предвыбор языка на форме входа, сделанный до входа, переносится в профиль однократно (см. `auth/business.md`).

## 2. Двухуровневая модель: Business vs Data

Локализация разделена на два типа объектов с разными характеристиками.

| Свойство | Business | Data |
|----------|----------|------|
| Сущности | Категории, статусы, ДП, подписи, кнопки, меню | ФИО пользователей, тексты задач, значения ДП, орг. единицы |
| Кто редактирует | Администратор | Пользователи (через UI/SmartAction) |
| Экспорт/импорт | Да, по GUID (ID перегенерируется) | Нет |
| Автоперевод | Да (Azure Translate) | Нет |
| Денормализация | Нет (из кеша) | Да (Task -> TasksInSubcat) |
| Fulltext search | Нет | Да (LocalizedDataObjectValues.Value) |

Каждая локализуемая сущность имеет nullable FK-колонку (`LocalizedLastNameId`, `LocalizedDescriptionId` и т.д.).

Правила nullable FK (`LocalizedXxxId`):
- Колонка = NULL означает, что сущность не имеет локализации
- При первом `Merge` создаётся запись в `LocalizedXxxObjects`, FK заполняется
- При удалении локализации (`Delete`) FK обнуляется
- FK не имеет CASCADE DELETE в сторону целевой таблицы — удаление сущности не удаляет локализацию автоматически (осиротевшие записи)

## 3. Entity Type Mapping

93 значения в `LocalizationEntityType` enum. Каждое значение маппится на:
- C# тип сущности (Entity)
- Имя FK-колонки (`PropertyName`)
- Тип объекта (Business / Data)
- Имя исходного поля (`OriginalValuePropertyName`)

Маппинг выполняет singleton `LocalizationMappingService`.

Ключевые entity types:

| ID | Enum | ObjectType | Сущность |
|---:|------|-----------|----------|
| 1 | Category | Business | Раздел |
| 2 | Subcategory | Business | Категория |
| 3 | SubcategoryEntity | Business | Поле Entity категории |
| 4 | StateRouteInSubcat | Business | Переход по маршруту |
| 5 | State | Business | Статус |
| 6 | ExtParam | Business | ДП |
| 7 | ExtParamBlock | Business | Блок ДП |
| 8 | Signature | Business | Подпись |
| 9 | SignatureResolutionType | Business | Резолюция подписи |
| 10 | Task | Data | Текст задачи |
| 11 | UserLastName | Data | Фамилия |
| 12 | UserFirstName | Data | Имя |
| 13 | UserMiddleName | Data | Отчество |
| 23 | ExtParamOption | Business | Значение комбобокса |
| 30 | OrgStructureName | Data | Название орг. единицы |
| 39 | TextExtParamValues | Data | Значения текстовых ДП |

### Локализованные ФИО пользователя: автокопия и ручной перевод

Локализованные ФИО (`UserLastName`, `UserFirstName`, `UserMiddleName`) хранятся отдельно по каждому языку. Значения делятся на два вида:

- **Автокопия** — локализованное значение совпадает с прежним значением в карточке пользователя (`Users.LastName` / `FirstName` / `MiddleName`) на момент сохранения. Такие значения система поддерживает актуальными сама.
- **Ручной перевод** — значение отличается от прежнего, то есть введено человеком. При сохранении карточки такие значения не изменяются.

**Правило.** Сохранение карточки пользователя обновляет локализованные ФИО не только для языка текущей сессии оператора, но и для каждого языка, где локализованное значение совпадает с прежним `Users.*` (автокопия). Языки с ручным переводом остаются без изменений. Признак «введено человеком» в схеме не хранится — разделение делается сравнением с прежним значением в момент сохранения. Если прежнее значение пустое, совпавшими считаются все незаполненные языки: при первом заполнении ранее пустого поля локализованные значения заводятся по всем языкам.

То же правило действует при переименовании через AD-синхронизацию, интеграцию с 1С и смарт-действие «Обновить пользователя»: эти пути передают прежние ФИО явно, из снимка до записи.

Значения, разошедшиеся с прежним `Users.*` раньше (и потому считающиеся ручным переводом), автоматически не чинятся — для них нужен отдельный перенос.

## 4. Автоперевод, логирование и кеширование

Правила автоперевода (Azure Translate):
- Применяется только к Business-объектам (конфигурация)
- Переводятся только языки, для которых нет значения (`Value` = NULL или пусто)
- Уже заполненные значения не перезаписываются
- Если значение для языка пользователя не изменилось и все языки заполнены — перевод не запускается
- Выполняется асинхронно (`AsyncRunner.Run`) — UI не блокируется
- Если Azure Translate недоступен — значение записывается только для языка по умолчанию
- Удаление сущности во время фонового перевода не даёт ошибки и не оставляет переводов без родителя: результат перевода не записывается, объект локализации заново не заводится
- Если переводы живой сущности удалили осознанно, автоперевод по изменению наименования их не возвращает: фон отличает пустой ключ «переводов ещё не было» от обнулённого «переводы удалили»
- Перевод выполняется только после сохранения изменений в базе: откат транзакции сохранения оставляет сущность без перевода

Изменения логируются для:
- `OrgStructureName` — через `Admin.LogAction` (полный diff: язык, старое/новое значение)

Для остальных типов сущностей отдельного лога изменений локализации нет.

- `LocalizationCache` — ключ: `ComplexKey(EntityId, EntityType)`, значение: `List<LocalizedValueDto>`
- Инвалидируется при каждом `Merge` (точечно, по ключу)
- Полная загрузка при старте: все Business + Data значения
- Прямая запись в БД не инвалидирует кеш — это документированное ограничение

## 5. Права доступа, экспорт/импорт и known issues

`LocalizationCheckPermissionsService` проверяет:
- При чтении: доступ к сущности (`CheckPermissions`)
- При записи: доступ к редактированию сущности (`CheckEditPermissions`)

Проверка привязана к целевой сущности (категория, пользователь и т.д.), не к записям локализации напрямую.

- `LocalizedBusinessObjects.Guid` используется для перемаппинга FK при импорте
- `Id` (PK) перегенерируется при импорте (не входит в `KeepIdentity`)
- Двухфазный процесс: сначала импорт объектов с новыми ID, затем перемаппинг FK по GUID
- Data-объекты не экспортируются (нет `Guid` в `LocalizedDataObjects`)
- **Перенос между площадками.** Номера языков в справочнике `Languages` назначает база (IDENTITY), поэтому на разных площадках один номер означает разные языки. Утилита переноса выгружает справочник языков источника в `_meta/languages.json` и на импорте подставляет номер того же языка на приёмнике: сначала по коду `LangAlias`, при несовпадении — по культуре. Если языка на приёмнике нет или его код неоднозначен, локализованное значение не импортируется и попадает в отчёт переноса; чужой язык не подставляется. Корневая сущность со ссылкой на язык при этом сохраняется: необязательная ссылка обнуляется, обязательная получает язык приёмника по умолчанию. Архивы без файла языков разбираются по номеру, как раньше, с предупреждением в журнале

**Ключ и подпись пишутся буквой своего алфавита.** Ключ ресурса, имя настройки и свойство в структуре данных — латиница; текст подписи — буквы того языка, на котором она написана. Буква другого алфавита, совпадающая начертанием, отличает ключ от правильного только для глаза: потребитель по имени его не находит, а текст не находится поиском. Если значение с верным, на вид, написанием поиском не находится, строку проверяют посимвольно: в ней может стоять буква другого алфавита. Исправляют символ в том значении, которое участвует в поиске; намеренно смешанные строки тестовых данных не трогают, если они проверяют обработку такого входа. После правки убеждаются, что запись находится по обычному написанию, а смысл отображаемого текста не изменился. Поиск по набору контентных иконок устроен так же: сопоставление идёт по буквальному составу имени и тегов.

Known issues:

1. **SmartAction CreateUser/UpdateUser** записывает ФИО только для одного языка — того, что задан параметром действия; параметров для мультиязычного ФИО нет. Распространение по языкам-автокопиям выполняется при сохранении карточки пользователя (см. «Локализованные ФИО пользователя: автокопия и ручной перевод»).
2. **Группировка в гриде** по локализованным ДП не применяет локализацию (заголовки групп на языке денормализации).
3. **Осиротевшие записи** в `LocalizedXxxObjects/Values` при удалении целевой сущности — нет CASCADE DELETE от целевой таблицы к таблицам локализации.

4. **Осиротевшие записи при перезаписи значения локализуемого текстового ДП.** В версиях с 2.263.412 по 2.268 серверное изменение значения ДП (смарт-действие, API, синхронизация, импорт) обнуляло ссылку на локализацию: на каждую перезапись заводилась новая запись, прежняя оставалась без ссылок. Исправлено в 2.268. Накопленные сироты зачищаются штатной миграцией релиза — порциями с защитным лимитом в 50 000 строк; на базе, где сирот больше, очистка выполняется отдельным DBA-скриптом. Поиск — [admin.md](https://help.1forma.ru/domains/localization/admin.md) раздел 6; вручную не удалять.
5. **Удалённые переводы возвращаются при изменении наименования в узком окне.** Фон проверяет сущность и её переводы до записи, но между проверкой и записью остаётся окно: если переводы живой сущности удалили именно в нём, автоперевод заведёт объект локализации заново и вернёт только что удалённые значения. Так же не подхватывается замена ключа локализации при изменении наименования. Ограничение принято осознанно; ветка пользовательских данных (в отличие от конфигурационных объектов) такой проверкой не защищена.

6. **Проверка непустоты значений под collation.** Под collation `Cyrillic_General_CI_AS` символы без веса (казахская буква, эмодзи) сравниваются с пустой строкой наравне, поэтому проверка непустоты сравнением со строкой — `x > ''`, `x <> ''`, `x = ''` — считает заполненное значение пустым. Проверка через `len(x) > 0` от collation не зависит и безопасна. На клиентских данных класс виден так: задача, у которой текстовый ДП заполнен одним символом вне cp1251, не находится фильтром грида «значение не пусто».

7. **Расхождение колонки значения и локализации у локализуемого текстового ДП.** Пока у текстового ДП включена настройка «Локализуемый», значение хранится дважды: в колонке значения (`ExtParamValues.ExtParamValue`) и в записи локализации. Раньше очистка HTML стояла ниже записи в колонку и работала только на ветку локализации, поэтому копии расходились: в локализации лежал очищенный текст, а в колонке — неочищенный. Пользователь видел очищенный текст (карточка локализует значение), а денормализованная копия для списков и гридов заполнялась из колонки как есть и расходилась с экраном; её читают грид, теги по значению, лукап и поиск по значению, и именно она становится видимой, когда настройку снимают. Исправлено в 2.268: очистка HTML выполняется один раз до записи, поэтому обе копии получают один и тот же очищенный текст. Правило действует для ДП типа «Большой текст с форматированием»; у типов «Большой текст без форматирования» и «Текст» сущности снимает своя логика типа. Уже сохранённые значения не перезаписываются.

## 6. Подписи интерфейса и значения данных

Переводу подлежат подписи: заголовки экранов и вкладок, названия колонок и кнопок, тексты диалогов и уведомлений. Значения данных — то, что администратор вводит и что сохраняется как содержимое сущности, — от языка интерфейса не зависят и показываются так, как записаны.

Пример: имя индивидуальной группы на карточке пользователя подставляется в редактируемое поле заготовкой вида «Индивидуальная Фамилия Имя». Это заготовка названия группы, а не подпись интерфейса, поэтому она одинакова при любом языке администратора. Названия разделов, категорий и статусов — тоже значения: в интерфейсе они показываются в переводе, который хранится отдельной локализованной записью и подставляется механизмом локализации сущностей.

**Значения, уходящие на сервер, тоже не переводятся.** Не каждый русский литерал в интерфейсе — текст для пользователя. Позиция, которая не является подписью, подсказкой, заголовком, пунктом меню или кнопкой, остаётся литералом: это значение, уходящее на сервер, ключ данных или текст журнала. В коде такие позиции помечают с указанием причины, ключ ресурса для них не заводят — иначе на сервер уйдёт подпись вместо значения. Сравнения по русскому тексту в условиях при переводе интерфейса не переписывают: сохранённый в настройке русский текст остаётся контрактом данных.

**Сообщения сервера переводятся так же.** Подписи интерфейса — не единственный текст, который локализуется. Ошибки и уведомления серверной части берутся из словарей, а не набираются русской строкой в коде, — это касается не только API, но и сервисов администрирования, интеграции, скриптов и работы с документами. Непереведённый словарь отдаёт английское значение по умолчанию; если сообщение сервера показывается не на языке интерфейса, значит для этого языка перевода нет, а не то, что текст не локализуется вовсе.

Проверять, на каком языке пришло сообщение, нужно по языку профиля: сообщение сервера уходит на языке интерфейса, а не языке браузера. Если интерфейс русский, а текст ошибки английский — перевода для этого языка пока нет; это ожидаемо и исправляется переводом на сервере, а не настройкой у пользователя. Часть сообщений сознательно остаётся технической и на язык не переводится: например, жалоба на неверный параметр приходит с кодом `error_IncorrectParameterValue` и показывает то, что прислал запрос. Служебные надписи — логи, отчёт о выгрузке, вывод утилит миграции — это не сообщения пользователю, они и на русском остаются по замыслу.


