G-SERVICE Docs
Архитектура ISP (OSS/BSS)

Маппинг Legacy CRM → Новая архитектура

Полная карта соответствия полей Legacy CRM API (book_orders) → Bounded Contexts, ACL-адаптеры, ID-маппинг и стратегия конвертации данных.

Маппинг Legacy CRM → Новая архитектура

Этот документ описывает конкретный план декомпозиции Legacy CRM (монолит с GOD-объектом book_orders) в новую микросервисную архитектуру. Каждое поле Legacy API замаплено на конкретный Bounded Context и сущность.

Источник: Анализ old-crm-api.yaml (OpenAPI 3.1, CRM-API v1.0.2).

Типичная ситуация на рынке: Большинство российских ISP работают с самописными CRM на PHP/Delphi или устаревшими версиями UTM/LANBilling, где вся информация о клиенте (ФИО, паспорт, адрес, тариф, MAC-адрес, порт OLT, баланс, история платежей) хранится в одной таблице-«монстре» с 50–100 полями. При миграции критически важно не пытаться мигрировать данные «одним скриптом», а использовать Anti-Corruption Layer (ACL) для постепенной конвертации и ID Mapping Service для поддержания связности между старыми int32 ID и новыми UUID.

Обзор Legacy API

Эндпоинты

МетодПутьОписаниеМаппинг в новую систему
POST/auth/loginKeycloak auth (JWT)Сохраняем Keycloak, меняем на ConnectRPC interceptor
POST/auth/logoutLogoutKeycloak — без изменений
POST/auth/refreshRefresh tokensKeycloak — без изменений
POST/v1/getmyordersМои заказыOMSListOrders (ConnectRPC)
POST/v1/getordersЗаказы по категорииOMSListOrders с фильтрами
GET/v1/getorder/{id}Детали заказаAPI Facade → агрегация из OMS + Customer + Billing + Inventory
GET/v1/getpricesСписок тарифовProduct ServiceListProducts
POST/types/datatypesТипы заказовOMSListOrderTypes (справочник)
POST/types/fieldtypesПоля заказовУбираем (динамические поля → Protobuf-контракты)
POST/types/citytypesГорода + координатыCustomer CoreListServiceAreas
POST/types/statusesСтатусы заказовOMSListOrderStatuses (enum в Protobuf)

Ключевые проблемы Legacy

Loading diagram...

Декомпозиция GOD-объекта book_orders

book_orders → Customer Core Service

Legacy полеТип (Legacy)Новая сущностьНовое полеТрансформация
fiostringCustomerfull_nameas-is (валидация + trim)
clientstringCustomertype"физ"individual, "юр"legal_entity
passportstringCustomerpassport_series, passport_numbersplit по пробелу: "1234 567890"series=1234, number=567890
cbirthstringCustomerbirth_dateparse date string → google.protobuf.Timestamp
cplacestringCustomerbirth_placeas-is
innstringCustomerinnas-is (валидация: 10 или 12 цифр)
ogrnstringCustomerogrnas-is (валидация: 13 цифр)
orgstringCustomerorganization_nameas-is (только для legal_entity)
short_namestringCustomerorganization_short_nameas-is
telstringContactvalue (type=phone)нормализация: +7XXXXXXXXXX
ph_numberstringContactvalue (type=phone, is_primary)вторичный телефон → отдельный Contact
fin_emailstringContactvalue (type=email)lowercase + trim
townstringAddresscityмаппинг через CityTypes справочник
streetstringAddressstreetas-is
homestringAddressbuildingas-is
flatstringAddressapartmentas-is
flat_rangestringAddressapartment_rangeas-is (для многоквартирных подключений)
coordinatestringAddresslatitude, longitudeparse: "55.123,37.456" → split
actual_addrstringAddressfull_address (is_service_address=true)as-is
yur_addrstringAddressfull_address (type=legal)as-is (только для legal_entity)
fiz_addrstringAddressfull_address (type=physical)as-is
post_addrstringAddressfull_address (type=postal)as-is
smsstringContactnotification_channel"1" → SMS-enabled

book_orders → Product & Subscription Service

Legacy полеТип (Legacy)Новая сущностьНовое полеТрансформация
tpstringSubscriptionProductOfferingcodeмаппинг через price.tpProductOffering.code
iptvstringServiceInstancetype=iptv, parameters.base=true"1" → создать ServiceInstance
ip_tv_plusstringServiceInstanceparameters.package=plus"1" → доп. пакет
ip_tv_footballstringServiceInstanceparameters.package=football"1" → доп. пакет
ip_tv_viasatstringServiceInstanceparameters.package=viasat"1" → доп. пакет
ip_tv_18plusstringServiceInstanceparameters.package=adult"1" → доп. пакет
ip_tv_matchstringServiceInstanceparameters.package=match"1" → доп. пакет
ip_tv_shantstringServiceInstanceparameters.package=shant"1" → доп. пакет
ip_tv_premiumstringServiceInstanceparameters.package=premium"1" → доп. пакет
100mbstringServiceInstanceparameters.speed_upgrade=100"1" → доп. опция скорости
s_unlstringServiceInstanceparameters.static_ip=true"1" → опция "статический IP"
n_unlstringServiceInstanceparameters.unlimited_nights=true"1" → опция
w_ipstringServiceInstanceparameters.white_ip=true"1" → опция "белый IP"
r_ctlstringServiceInstanceparameters.remote_control=true"1" → опция
rent_tvstringServiceInstanceequipment_rental, device_type=tv"1" → аренда TV
rent_portstringServiceInstanceequipment_rental, device_type=port"1" → аренда порта
rent_stbstringServiceInstanceequipment_rental, device_type=stb"1" → аренда STB
rent_stb150stringServiceInstanceequipment_rental, device_type=stb, model=150"1" → конкретная модель
rent_stb180stringServiceInstanceequipment_rental, device_type=stb, model=180"1" → конкретная модель
rent_eltexnv721stringServiceInstanceequipment_rental, device_type=ont, model=nv721"1" → конкретная модель
rent_eltexnv7xxwbstringServiceInstanceequipment_rental, device_type=ont, model=nv7xxwb"1" → конкретная модель
rent_routerstringServiceInstanceequipment_rental, device_type=router"1" → аренда роутера
rent_router100..300stringServiceInstanceequipment_rental, device_type=router, model=*"1" → конкретная модель
usl_liststringSubscriptionlinked service listparse comma-separated → массив ServiceInstance
stb_typestringServiceInstanceparameters.stb_modelas-is

book_orders → Billing & Finance Service

Legacy полеТип (Legacy)Новая сущностьНовое полеТрансформация
paydoublePaymentamount (type=one_time)разовый платёж за подключение
pay_dscstringPaymentdescriptionas-is
connect_costdoubleTransactionamount (type=charge, desc=connection)списание за подключение
monpaydoublePricePlanbase_priceежемесячный платёж (верификация с Product Catalog)
monpaydscstringPricePlandescriptionas-is
lsstringAccountlegacy_account_numberлицевой счёт Legacy → сохраняем как reference
ls_paydoubleAccountbalance (snapshot)текущий баланс на момент миграции
ls_pay_nbstringAccountbalance_noteпримечание к балансу
ls_aisstringAccountexternal_ids.aisID в Legacy-биллинге (АИС)
ais_paystringPaymentexternal_ids.aisпривязка к Legacy-платежу
to1cstringInvoiceexternal_ids.1cпривязка к 1С
payerstringAccountpayer_referenceкто платит (если отличается от клиента)
pay_uslstringTransactionплатные услугиparse → отдельные charge transactions
pay_usl_descrstringTransactiondescriptionописание платных услуг
rsstringBankDetailssettlement_accountрасчётный счёт (юрлица)
ksstringBankDetailscorrespondent_accountкорр. счёт
bikstringBankDetailsbikБИК банка
bankstringBankDetailsbank_nameназвание банка

book_orders → Network Inventory

Legacy полеТип (Legacy)Новая сущностьНовое полеТрансформация
nodestringDevicereference (type=switch)маппинг имени → Device.hostname
gpon_nodestringDevicereference (type=olt)маппинг имени → Device.hostname
gpon_splitterstringDevicereference (type=splitter)as-is
gpon_box_numstringраспределительная коробкаlocation_refas-is
gpon_parentstringDeviceparent_device_idupstream устройство
devicestringCPE (Device)reference (type=cpe)серийник/мак → Device
device_newint32CPE (Device)is_new1 → новое оборудование
ipstringLogicalResourcevalue (type=ipv4)as-is (валидация формата)
macstringLogicalResourcevalue (type=mac)normalize: XX:XX:XX:XX:XX:XX
vlangstringLogicalResourcevalue (type=vlan)as-is (вероятно typo: vlan)
nagiosstringdeprecatedмониторинг → Prometheus/Grafana

book_orders → Access Control (AAA)

Legacy полеТип (Legacy)Новая сущностьНовое полеТрансформация
loginstringRadiusProfileusernameas-is
passwdstringRadiusProfileradcheck.passwordrehash (если plaintext → bcrypt)

⚠️ Безопасность: Если Legacy хранит пароли в plaintext или MD5 — при миграции обязательно перехешировать. FreeRADIUS поддерживает NT-Password, Cleartext-Password, SSHA. Рекомендуется перейти на SSHA или MAC-based auth (IPoE).

book_orders → OMS (Order Management)

Legacy полеТип (Legacy)Новая сущностьНовое полеТрансформация
idint32Orderlegacy_id + новый UUIDID Mapping Table
order_typestringOrdertype (enum)маппинг строк → Protobuf enum
order_viewstringOrdercategoryмаппинг строк → enum
order_namestringOrdertitleas-is
statusint32Orderstatus (enum)Status Mapping Table (см. ниже)
priorityint32Orderpriority (enum)1=low, 2=medium, 3=high, 4=critical
dayint32Orderscheduled_dayepoch day → google.protobuf.Timestamp
date_timestringOrderscheduled_atparse → Timestamp
doint32Orderassigned1 = назначен исполнитель
do_datedatetimeOrderassigned_atas-is
end_datedatetimeOrdercompleted_atas-is
do_timedatetimeOrderwork_started_atначало работ
end_timedatetimeOrderwork_ended_atзавершение работ
deadlinedatetimeOrderdeadlineas-is
authorstringOrdercreated_byмаппинг username → User ID
author_ipstringOrdermetadata.created_from_ipaudit field
add_timestringOrdercreated_atparse → Timestamp
editorstringOrderupdated_byмаппинг username → User ID
editor_ipstringOrdermetadata.updated_from_ipaudit field
edit_timedatetimeOrderupdated_atas-is
descriptionstringOrderdescriptionas-is
notesstringOrderinternal_notesas-is
workstringOrderwork_descriptionописание выполненных работ
d_worksstringOrderwork_itemsparse → массив
whostringOrderassigneeмаппинг → User ID
whystringOrderreasonas-is
ispstringOrderassignees[]Исполнитель(и)! Comma-separated → массив User ID
ctrlstringOrdercontrol_noteas-is
num_instringOrderdocument_numbers.incomingвходящий номер документа
num_outstringOrderdocument_numbers.outgoingисходящий номер
num_gostringOrderdocument_numbers.dispatchномер рассылки
num_datestringOrderdocument_numbers.dateдата документа
officeint32Orderoffice_idID офиса
linkstringOrderexternal_linkссылка на внешний ресурс
link_hashstringOrderexternal_link_hashхеш ссылки
uploadsstringOrderattachmentsparse → массив URL
markstringOrdermarkпометка
privatestringOrderis_private"1" → true
salesstringOrdersales_channelканал продаж
isalestringOrdersale_idID продажи
erentstringOrderequipment_rental_noteпримечание об аренде
managerstringOrderaccount_managerменеджер клиента
phone_grpstringOrderphone_groupтелефонная группа
observ_olstringOrderServiceInstanceparameters.surveillance_online=true⚠️ Это услуга, а не поле заказа — переносим в Product & Subscription
observ_arstringOrderServiceInstanceparameters.surveillance_archive=true⚠️ Это услуга видеонаблюдения (архив) — переносим в Product & Subscription
fake_individualstringOrdermetadata.fake_individualфлаг
not_signedstringOrdermetadata.not_signedфлаг "не подписан"

⚠️ Сущности из UI Legacy, отсутствующие в API (book_orders)

Анализ скриншота Legacy CRM (mybook-sso.g-service.ru) выявил сущности и функции, которые не представлены в old-crm-api.yaml, но существуют в реальном UI и должны быть учтены при миграции:

Legacy UI элементОписаниеКуда мигрируемПриоритет
Потомки (child orders)Иерархия заказов: родитель → потомки. UI: "Потомки: отсутствуют"Order.parent_order_id (UUID, nullable) в OMS🔴 Высокий
КомментарииЛента комментариев к заказу. UI: "Последние комментарии", "добавить"Новая сущность OrderComment в OMS (id, order_id, author, text, created_at)🔴 Высокий
Номер регистрацииОтдельный от ID заказа номер (UI: "Номер регистрации: 7751")Order.registration_number (string, auto-increment per year)🟡 Средний
История ремонтовСсылка на прошлые ремонты по адресу/абонентуQuery: ListOrders(customer_id, type=REPAIR) — не отдельная сущность, а агрегация🟡 Средний
Доска объявленийПравый сайдбар: внутренние объявления для сотрудниковОтдельный микросервис или модуль в BFF (вне скоупа BSS/OSS)🟢 Низкий
Call-центрИнтеграция с телефониейВнешняя интеграция через Webhook/API (вне скоупа ядра)🟢 Низкий
Отправить SMSРучная отправка SMS из карточки заказаNotification ServiceSendNotification (ConnectRPC)🟡 Средний
Выписать счётГенерация счёта из карточки заказаBilling & FinanceGenerateInvoice (ConnectRPC)🟡 Средний
ПечатьПечать карточки заказаBFF: генерация PDF-представления Order🟢 Низкий

Маппинг Legacy order_type → Новые типы заказов в OMS

На скриншоте видно ~20 категорий заказов в левом сайдбаре. В текущей архитектуре OMS определены только 7 типов. Полный маппинг:

Legacy order_typeОписание (RU)Новый OMS типПримечание
ПодключениеНовое подключениеNEW_CONNECTION✅ Есть
БроньБронирование порта/ресурсовRESERVATION⚠️ Добавить в OMS
РемонтРемонт у абонентаREPAIR✅ Есть
Gpon ремонтРемонт GPON-оборудованияREPAIR (subtype=gpon)Подтип REPAIR
НастройкиНастройка оборудованияCONFIGURATION⚠️ Добавить в OMS
ДомофонУстановка/ремонт домофонаINTERCOM⚠️ Добавить или общий SERVICE_REQUEST
ОптикаРаботы с оптикойFIBER_WORK⚠️ Добавить или общий INFRASTRUCTURE
ПисьмаВходящая/исходящая корреспонденцияCORRESPONDENCE⚠️ Добавить — документооборот
ОстальноеПрочие заявкиOTHER⚠️ Добавить — catch-all
УслугиУправление доп. услугамиSERVICE_CHANGE⚠️ Добавить в OMS
ДокументыДокументооборотDOCUMENT⚠️ Добавить — юридические документы
РазвитиеРазвитие сетиNETWORK_EXPANSION⚠️ Добавить — внутренний тип
ДолжникиРабота с должникамиDUNNING_TASK⚠️ Добавить — или обрабатывать через Billing dunning pipeline
ИнженерыИнженерные задачиENGINEERING_TASK⚠️ Добавить — FSM интеграция
3-я линияЭскалация на 3-ю линию поддержкиESCALATION_L3⚠️ Добавить
МониторингЗадачи мониторингаMONITORING_TASK⚠️ Добавить — или отдельная система
СкладСкладские операцииWAREHOUSE⚠️ Добавить — FSM/склад
ОбзвонМассовый обзвон абонентовOUTBOUND_CALL⚠️ Добавить — Call-центр интеграция
ЧатыЧат с абонентамиВнешняя система (Telegram/WhatsApp интеграция)
КарточкаКарточка абонентаЭто не тип заказа, а view → GetCustomer в Customer Core

⚠️ Рекомендация: Не переносить все 20 типов как отдельные enum-значения. Лучше ввести двухуровневую классификацию: order_type (основной тип, ~10 значений) + order_category (подтип, справочник). Это позволит добавлять новые категории без изменения Protobuf-контракта.

price → Product & Subscription Service

Legacy полеТип (Legacy)Новая сущностьНовое полеТрансформация
idint32ProductOfferinglegacy_idID Mapping Table
tpstringProductOfferingname / codeas-is
tvalueint32ProductCharacteristicvalue (name=download_speed_mbps)число → характеристика скорости
costdoublePricePlanbase_priceas-is
townstringProductOfferingavailability_zoneмаппинг города → зона
clientstringProductOfferingcustomer_segment"физ"/"юр" → enum
typestringProductOfferingcategoryмаппинг типа → enum
tp_ratestringProductCharacteristicdescriptionописание тарифа

ID Mapping Service

Ключевой компонент миграции — таблица соответствия старых int32 ID и новых UUID.

Схема БД

CREATE TABLE id_mapping (
    id          BIGSERIAL PRIMARY KEY,
    entity_type VARCHAR(50) NOT NULL,  -- 'order', 'customer', 'price', etc.
    legacy_id   INTEGER NOT NULL,
    new_id      UUID NOT NULL DEFAULT gen_random_uuid(),
    migrated_at TIMESTAMPTZ,
    verified    BOOLEAN DEFAULT FALSE,
    UNIQUE (entity_type, legacy_id),
    UNIQUE (entity_type, new_id)
);

CREATE INDEX idx_id_mapping_legacy ON id_mapping (entity_type, legacy_id);
CREATE INDEX idx_id_mapping_new    ON id_mapping (entity_type, new_id);

-- Примеры:
-- ('order',    12345, '550e8400-e29b-41d4-a716-446655440000', NOW(), true)
-- ('customer', 67890, 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', NOW(), false)
-- ('price',    42,    'b2c3d4e5-f6a7-8901-bcde-f12345678901', NOW(), true)

ConnectRPC API

service IDMappingService {
  // Получить новый UUID по legacy ID
  rpc ResolveNewID(ResolveNewIDRequest) returns (ResolveNewIDResponse);
  // Получить legacy ID по новому UUID (для обратной совместимости)
  rpc ResolveLegacyID(ResolveLegacyIDRequest) returns (ResolveLegacyIDResponse);
  // Массовый маппинг (для batch-миграции)
  rpc BulkResolve(BulkResolveRequest) returns (BulkResolveResponse);
}

message ResolveNewIDRequest {
  string entity_type = 1;
  int32 legacy_id = 2;
}

message ResolveNewIDResponse {
  string new_id = 1; // UUID as string
  bool migrated = 2;
  bool verified = 3;
}

Использование в ACL

// internal/customer/acl/legacy_adapter.go
func (a *LegacyAdapter) GetCustomerByLegacyOrderID(ctx context.Context, legacyOrderID int32) (*domain.Customer, error) {
    // 1. Resolve legacy order → new order UUID
    orderUUID, err := a.idMapping.ResolveNewID(ctx, "order", legacyOrderID)
    if err != nil {
        return nil, fmt.Errorf("resolve order ID %d: %w", legacyOrderID, err)
    }

    // 2. Get order → extract customer_id
    order, err := a.orderRepo.GetByID(ctx, orderUUID)
    if err != nil {
        return nil, fmt.Errorf("get order %s: %w", orderUUID, err)
    }

    // 3. Get customer from Customer Core
    return a.customerClient.GetCustomer(ctx, order.CustomerID)
}

Legacy DB Adapter (ACL)

Адаптер между Legacy БД и новыми сервисами. Реализует Anti-Corruption Layer — защищает домен от Legacy-моделей.

Архитектура

Loading diagram...

Пример: конвертация book_orders → Customer

// internal/migration/acl/order_to_customer.go
package acl

type LegacyBookOrder struct {
    ID         int32   `db:"id"`
    FIO        string  `db:"fio"`
    Client     string  `db:"client"`
    Passport   string  `db:"passport"`
    CBirth     string  `db:"cbirth"`
    INN        string  `db:"inn"`
    OGRN       string  `db:"ogrn"`
    Org        string  `db:"org"`
    Tel        string  `db:"tel"`
    FinEmail   string  `db:"fin_email"`
    Town       string  `db:"town"`
    Street     string  `db:"street"`
    Home       string  `db:"home"`
    Flat       string  `db:"flat"`
    Coordinate string  `db:"coordinate"`
    ActualAddr string  `db:"actual_addr"`
    // ... остальные поля
}

func (acl *OrderACL) ToCustomer(order LegacyBookOrder) (*customerpb.CreateCustomerRequest, error) {
    customerType := customerpb.CustomerType_INDIVIDUAL
    if order.Client == "юр" || order.Client == "legal" {
        customerType = customerpb.CustomerType_LEGAL_ENTITY
    }

    // Parse passport: "1234 567890" → series + number
    series, number, err := parsePassport(order.Passport)
    if err != nil {
        return nil, fmt.Errorf("parse passport %q: %w", order.Passport, err)
    }

    // Parse coordinates: "55.123,37.456" → lat, lon
    lat, lon, _ := parseCoordinate(order.Coordinate) // best-effort

    // Normalize phone
    phone, _ := normalizePhone(order.Tel)

    req := &customerpb.CreateCustomerRequest{
        FullName:        strings.TrimSpace(order.FIO),
        Type:            customerType,
        PassportSeries:  series,
        PassportNumber:  number,
        Inn:             order.INN,
        Ogrn:            order.OGRN,
        OrganizationName: order.Org,
        Contacts: []*customerpb.Contact{},
        Addresses: []*customerpb.Address{},
    }

    if phone != "" {
        req.Contacts = append(req.Contacts, &customerpb.Contact{
            Type: customerpb.ContactType_PHONE, Value: phone, IsPrimary: true,
        })
    }
    if order.FinEmail != "" {
        req.Contacts = append(req.Contacts, &customerpb.Contact{
            Type: customerpb.ContactType_EMAIL, Value: strings.ToLower(strings.TrimSpace(order.FinEmail)),
        })
    }

    req.Addresses = append(req.Addresses, &customerpb.Address{
        City: order.Town, Street: order.Street, Building: order.Home,
        Apartment: order.Flat, FullAddress: order.ActualAddr,
        Latitude: lat, Longitude: lon, IsServiceAddress: true,
    })

    return req, nil
}

func parsePassport(raw string) (series, number string, err error) {
    parts := strings.Fields(strings.TrimSpace(raw))
    if len(parts) < 2 {
        return "", "", fmt.Errorf("expected 'series number', got %q", raw)
    }
    return parts[0], parts[1], nil
}

func normalizePhone(raw string) (string, error) {
    digits := strings.Map(func(r rune) rune {
        if r >= '0' && r <= '9' { return r }
        return -1
    }, raw)
    if len(digits) == 11 && digits[0] == '8' {
        digits = "7" + digits[1:]
    }
    if len(digits) != 11 {
        return "", fmt.Errorf("invalid phone: %q", raw)
    }
    return "+" + digits, nil
}

Legacy API Proxy (Strangler Fig)

На период миграции старый CRM-фронтенд продолжает работать. Legacy API Proxy проксирует старые эндпоинты на новые сервисы.

Архитектура

Loading diagram...

Пример: Proxy для getorder

// internal/proxy/handlers/get_order.go
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    legacyID, _ := strconv.Atoi(chi.URLParam(r, "orderId"))

    // Feature flag: использовать новую систему?
    if h.features.IsEnabled("new_orders", r.Context()) {
        // Resolve legacy ID → new UUID
        newID, err := h.idMapping.ResolveNewID(r.Context(), "order", int32(legacyID))
        if err != nil {
            // Fallback: заказ ещё не мигрирован → читаем из Legacy
            h.proxyToLegacy(w, r)
            return
        }

        // Агрегация из новых сервисов
        order, _ := h.omsClient.GetOrder(r.Context(), newID)
        customer, _ := h.customerClient.GetCustomer(r.Context(), order.CustomerID)
        subscription, _ := h.productClient.GetSubscription(r.Context(), order.SubscriptionID)

        // Конвертировать обратно в Legacy-формат для старого UI
        legacyResponse := h.toLegacyOrderResponse(order, customer, subscription)
        json.NewEncoder(w).Encode(legacyResponse)
        return
    }

    h.proxyToLegacy(w, r)
}

// toLegacyOrderResponse — обратная конвертация для совместимости
func (h *OrderHandler) toLegacyOrderResponse(
    order *omspb.Order,
    customer *customerpb.Customer,
    subscription *productpb.Subscription,
) *LegacyGetOrderResponse {
    return &LegacyGetOrderResponse{
        Error: 0,
        Response: LegacyBookOrder{
            ID:        int32(order.LegacyId),
            FIO:       customer.FullName,
            Client:    mapCustomerType(customer.Type),
            Passport:  customer.PassportSeries + " " + customer.PassportNumber,
            Town:      extractCity(customer.Addresses),
            Street:    extractStreet(customer.Addresses),
            // ... остальные поля
            TP:        subscription.ProductOffering.Code,
            Status:    mapStatusToLegacy(order.Status),
            Priority:  int32(order.Priority),
        },
    }
}

Status Mapping

Legacy использует int32 для статусов заказов. Нужен маппинг в новые Protobuf enum.

Маппинг таблица

Конкретные значения берутся из /types/statuses эндпоинта Legacy API. Ниже — типичный маппинг:

Legacy Status (int)Legacy NameNew Protobuf EnumОписание
0НовыйORDER_STATUS_NEWСоздан, ожидает назначения
1В работеORDER_STATUS_IN_PROGRESSНазначен исполнитель
2ВыполненORDER_STATUS_COMPLETEDРаботы завершены
3ОтменёнORDER_STATUS_CANCELLEDОтменён клиентом/оператором
4На паузеORDER_STATUS_ON_HOLDОжидание (клиента, оборудования)
5ЗакрытORDER_STATUS_CLOSEDФинализирован, архив
enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;
  ORDER_STATUS_NEW = 1;
  ORDER_STATUS_IN_PROGRESS = 2;
  ORDER_STATUS_COMPLETED = 3;
  ORDER_STATUS_CANCELLED = 4;
  ORDER_STATUS_ON_HOLD = 5;
  ORDER_STATUS_CLOSED = 6;
}

Data Migration Pipeline

ETL-пайплайн для batch-миграции данных из Legacy DB в новые сервисы.

Архитектура

Loading diagram...

Порядок миграции сущностей

Порядок важен из-за зависимостей между контекстами:

Loading diagram...
  1. ProductOffering (из price таблицы) — нет зависимостей
  2. Customer (из book_orders, дедупликация по fio + passport) — нет зависимостей
  3. Subscription (связь Customer ↔ ProductOffering) — зависит от 1, 2
  4. Account + Transactions (из ls, ls_pay, pay) — зависит от 2
  5. Network Resources (из node, gpon_*, ip, mac) — зависит от 3
  6. RADIUS Profiles (из login, passwd) — зависит от 5
  7. Orders (из book_orders workflow-полей) — зависит от 2, 3

Дедупликация клиентов

Legacy не имеет отдельной таблицы клиентов — данные клиента размазаны по заказам. При миграции:

// internal/migration/dedup/customer_dedup.go
// Стратегия: группировка по (normalized_fio + passport) → один Customer.
type CustomerKey struct {
    NormalizedFIO string
    Passport      string
}

func DeduplicateCustomers(orders []LegacyBookOrder) map[CustomerKey][]LegacyBookOrder {
    groups := make(map[CustomerKey][]LegacyBookOrder)
    for _, o := range orders {
        key := CustomerKey{
            NormalizedFIO: normalizeString(o.FIO),
            Passport:      normalizeString(o.Passport),
        }
        groups[key] = append(groups[key], o)
    }
    return groups
    // Для каждой группы: создать одного Customer, привязать все заказы к нему.
}

Edge case: Юрлица могут иметь одинаковый ИНН с разными заказами. Для юрлиц ключ дедупликации: (inn, ogrn). Для физлиц: (fio, passport).


Shadow Mode (Верификация)

На этапе миграции — параллельное чтение из Legacy и New с логированием расхождений.

Loading diagram...

Что сравниваем

ПолеПриоритетДопустимая разница
Баланс (ls_pay)Critical± 0.01 RUB
ФИОHighcase-insensitive + trim
Тариф (tp)Highпо code после маппинга
СтатусHighпосле маппинга enum
IP/MAC/VLANMediumnormalize формат
ДатыMedium± 1 секунда
Описание/notesLowignore whitespace

Keycloak: что сохраняем

Legacy уже использует Keycloak для авторизации (x-access-token header). План:

  1. Keycloak остаётся как Identity Provider.
  2. Заменить x-access-token header → стандартный Authorization: Bearer <token>.
  3. Добавить ConnectRPC interceptor для валидации JWT.
  4. Добавить RBAC roles в Keycloak: admin, operator, installer, billing_manager.
  5. Новые сервисы проверяют JWT через Keycloak public key (без обращения к Keycloak на каждый запрос).
// internal/auth/interceptor.go
// ConnectRPC interceptor для JWT-валидации
func NewAuthInterceptor(keycloakPublicKey *rsa.PublicKey) connect.UnaryInterceptorFunc {
    return func(next connect.UnaryFunc) connect.UnaryFunc {
        return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
            token := req.Header().Get("Authorization")
            claims, err := validateJWT(token, keycloakPublicKey)
            if err != nil {
                return nil, connect.NewError(connect.CodeUnauthenticated, err)
            }
            ctx = context.WithValue(ctx, userClaimsKey, claims)
            return next(ctx, req)
        }
    }
}

Ссылки по теме

On this page