name: Lead region — prod ops # Самодостаточный launch-инструмент фичи lead-region-resolution. # Один воркфлоу, переключатель op. НЕ трогает deploy.yml / artisan-run.yml. # # op: # pre-migrate — пред-применить миграцию 2026_05_31_100000 через postgres # superuser (crm_app_user не член crm_migrator → обычный migrate # падает) + пометить применённой, чтобы deploy её пропустил. # set-env — записать DADATA-ключи (из secrets) + LEAD_REGION_RESOLVER_ENABLED # (input flag) в боевой .env, перекэшировать config, рестарт очереди. # fetch-rossvyaz — скачать файл/архив реестра (input url) на прод в /var/www/liderra/rossvyaz. # import — phone-ranges:import (input dry_run) под www-data (DDL-свап идёт # через pgsql_supplier = crm_supplier_worker, член crm_migrator). # smoke — phone-region:smoke --phone= под www-data (нужны ключи). # # Secrets: LIDERRA_SSH_KEY, DADATA_API_KEY, DADATA_SECRET. on: workflow_dispatch: inputs: op: description: 'Операция' required: true type: choice options: - pre-migrate - set-env - fetch-rossvyaz - fetch-via-runner - deliver-from-repo - import - smoke flag: description: 'set-env: LEAD_REGION_RESOLVER_ENABLED' required: false default: 'false' type: choice options: - 'false' - 'true' url: description: 'fetch-rossvyaz: прямая ссылка на CSV/ZIP реестра Россвязи' required: false type: string dir: description: 'import: каталог с CSV на проде' required: false default: '/var/www/liderra/rossvyaz' type: string dry_run: description: 'import: только staging без swap' required: false default: true type: boolean phone: description: 'smoke: телефон' required: false default: '79161234567' type: string jobs: op: name: ${{ github.event.inputs.op }} runs-on: ubuntu-latest timeout-minutes: 15 concurrency: group: liderra-prod-deploy cancel-in-progress: false env: LIDERRA_HOST: 111.88.246.137 LIDERRA_USER: ubuntu APP_DIR: /var/www/liderra/app OP: ${{ github.event.inputs.op }} FLAG: ${{ github.event.inputs.flag }} URL: ${{ github.event.inputs.url }} DIR: ${{ github.event.inputs.dir }} DRY: ${{ github.event.inputs.dry_run }} PHONE: ${{ github.event.inputs.phone }} steps: - name: Setup SSH key run: | mkdir -p ~/.ssh echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy chmod 600 ~/.ssh/liderra_deploy ssh-keyscan -H "${LIDERRA_HOST}" >> ~/.ssh/known_hosts 2>/dev/null - name: Checkout repo (for deliver-from-repo) if: ${{ github.event.inputs.op == 'deliver-from-repo' }} uses: actions/checkout@v4 - name: op=pre-migrate (superuser DDL + mark applied) if: ${{ github.event.inputs.op == 'pre-migrate' }} run: | SQL_B64=$(cat <<'SQLEOF' | base64 -w0 BEGIN; -- 1. phone_ranges_imports (FK target — создаём первым) CREATE TABLE phone_ranges_imports ( id BIGSERIAL PRIMARY KEY, imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), source_url TEXT NOT NULL, rows_inserted INTEGER NOT NULL DEFAULT 0, rows_updated INTEGER NOT NULL DEFAULT 0, checksum_sha256 TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'in_progress' CHECK (status IN ('in_progress','completed','failed','rolled_back')), error TEXT, completed_at TIMESTAMPTZ ); COMMENT ON TABLE phone_ranges_imports IS 'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).'; -- 2. phone_ranges (реестр диапазонов; SaaS-level, без RLS — публичные данные) CREATE TABLE phone_ranges ( id BIGSERIAL PRIMARY KEY, def_code SMALLINT NOT NULL, from_num BIGINT NOT NULL, to_num BIGINT NOT NULL, operator TEXT NOT NULL, region TEXT NOT NULL, region_normalized TEXT, subject_code SMALLINT, imported_at TIMESTAMPTZ NOT NULL, import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id), CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999), CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89), CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num) ); CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num); COMMENT ON TABLE phone_ranges IS 'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver.'; GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker; -- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at) CREATE TABLE lead_region_resolution_log ( id BIGSERIAL, supplier_lead_id BIGINT NOT NULL, received_at TIMESTAMPTZ NOT NULL, phone_masked TEXT NOT NULL, subject_code_resolved SMALLINT, subject_code_from_tag SMALLINT, region_source TEXT NOT NULL CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')), dadata_qc SMALLINT, dadata_provider TEXT, dadata_type TEXT, dadata_response_masked JSONB, rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE, actual_subject_code SMALLINT CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89), substituted_subject_code SMALLINT CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89), routing_step SMALLINT CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3), phone_operator TEXT, cache_hit BOOLEAN NOT NULL DEFAULT FALSE, duration_ms INTEGER, resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (id, received_at) ) PARTITION BY RANGE (received_at); CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id); CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at); COMMENT ON TABLE lead_region_resolution_log IS 'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно.'; GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker; GRANT SELECT ON lead_region_resolution_log TO crm_app_user; CREATE TABLE lead_region_resolution_log_y2026_m05 PARTITION OF lead_region_resolution_log FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); CREATE TABLE lead_region_resolution_log_y2026_m06 PARTITION OF lead_region_resolution_log FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); -- 4. supplier_leads: +4 колонки ALTER TABLE supplier_leads ADD COLUMN resolved_subject_code SMALLINT CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89), ADD COLUMN region_source TEXT CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')), ADD COLUMN dadata_qc SMALLINT, ADD COLUMN phone_operator TEXT; -- 5. deals: +2 колонки ALTER TABLE deals ADD COLUMN phone_operator TEXT, ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE; -- ownership как у миграции (она шла бы под crm_migrator) ALTER TABLE phone_ranges_imports OWNER TO crm_migrator; ALTER TABLE phone_ranges OWNER TO crm_migrator; ALTER TABLE lead_region_resolution_log OWNER TO crm_migrator; ALTER TABLE lead_region_resolution_log_y2026_m05 OWNER TO crm_migrator; ALTER TABLE lead_region_resolution_log_y2026_m06 OWNER TO crm_migrator; -- retention (system_settings, 12 мес) INSERT INTO system_settings (key, value, type, description, updated_at) SELECT 'partition_retention_months_lead_region_resolution_log', '12', 'int', 'Retention в месяцах для lead_region_resolution_log (~365 дней)', NOW() WHERE NOT EXISTS ( SELECT 1 FROM system_settings WHERE key = 'partition_retention_months_lead_region_resolution_log'); COMMIT; SQLEOF ) ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" "SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/op.log set -euo pipefail MIG_NAME='2026_05_31_100000_create_phone_ranges_and_resolution_log' ALREADY=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM migrations WHERE migration='${MIG_NAME}' LIMIT 1") if [ "${ALREADY}" = "1" ]; then echo "Migration ${MIG_NAME} уже применена — пропускаю." exit 0 fi TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM information_schema.tables WHERE table_name='phone_ranges' LIMIT 1") if [ "${TABLE_EXISTS}" != "1" ]; then echo "Применяю lead-region DDL через postgres superuser..." echo "$SQL_B64" | base64 -d | sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1 else echo "Таблица phone_ranges уже существует — только помечаю миграцию." fi NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations") sudo -u postgres psql -d liderra -c \ "INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}')" echo "Помечено ${MIG_NAME} применённой (batch ${NEXT_BATCH})." echo "=== Проверка таблиц ===" sudo -u postgres psql -d liderra -c "\dt phone_ranges|phone_ranges_imports|lead_region_resolution_log" || true REMOTE - name: op=set-env (keys from secrets + flag → prod .env) if: ${{ github.event.inputs.op == 'set-env' }} env: DK: ${{ secrets.DADATA_API_KEY }} DS: ${{ secrets.DADATA_SECRET }} run: | DK_B64=$(printf '%s' "$DK" | base64 -w0) DS_B64=$(printf '%s' "$DS" | base64 -w0) ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \ "DK_B64='$DK_B64' DS_B64='$DS_B64' FLAG='$FLAG' APP_DIR='$APP_DIR' bash -s" <<'REMOTE' | tee /tmp/op.log set -euo pipefail ENV="${APP_DIR}/.env" DK=$(echo "$DK_B64" | base64 -d) DS=$(echo "$DS_B64" | base64 -d) upsert() { local key="$1" val="$2" sudo sed -i "/^${key}=/d" "$ENV" echo "${key}=${val}" | sudo tee -a "$ENV" >/dev/null } upsert DADATA_API_KEY "$DK" upsert DADATA_SECRET "$DS" upsert LEAD_REGION_RESOLVER_ENABLED "$FLAG" cd "$APP_DIR" sudo -u www-data php artisan config:clear sudo -u www-data php artisan config:cache sudo systemctl restart liderra-queue echo "set-env готово: flag=${FLAG}, ключи записаны." echo "=== Проверка (значения скрыты) ===" sudo grep -E '^(DADATA_API_KEY|DADATA_SECRET|LEAD_REGION_RESOLVER_ENABLED)=' "$ENV" | sed -E 's/=(.).*/=\1***/' echo "=== queue status ===" systemctl is-active liderra-queue || true REMOTE - name: op=fetch-rossvyaz (download registry on prod) if: ${{ github.event.inputs.op == 'fetch-rossvyaz' }} run: | # Пустой url → качаем все 4 официальных файла Минцифры за один прогон. # Непустой url → качаем только его (ручной режим). ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \ "URL='$URL' bash -s" <<'REMOTE' | tee /tmp/op.log set -euo pipefail DEST=/var/www/liderra/rossvyaz sudo mkdir -p "$DEST" cd "$DEST" if [ -n "$URL" ]; then URLS="$URL" else URLS="https://opendata.digital.gov.ru/downloads/DEF-9xx.csv https://opendata.digital.gov.ru/downloads/ABC-3xx.csv https://opendata.digital.gov.ru/downloads/ABC-4xx.csv https://opendata.digital.gov.ru/downloads/ABC-8xx.csv" fi for U in $URLS; do FNAME=$(basename "${U%%\?*}") [ -n "$FNAME" ] || FNAME="rossvyaz-download" echo "Скачиваю $U -> $FNAME" sudo curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,application/octet-stream,*/*' -H 'Accept-Language: ru-RU,ru;q=0.9' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FNAME" "$U" case "$FNAME" in *.zip|*.ZIP) echo "Распаковываю zip..."; sudo unzip -o "$FNAME" ;; esac done sudo chown -R www-data:www-data "$DEST" echo "=== Содержимое $DEST ===" ls -lh "$DEST" FIRST_CSV=$(ls "$DEST"/DEF-9xx.csv "$DEST"/*.csv "$DEST"/*.CSV 2>/dev/null | head -1 || true) if [ -n "$FIRST_CSV" ]; then echo "=== Первые строки $FIRST_CSV (cp1251→utf8) ===" sudo head -3 "$FIRST_CSV" | iconv -f cp1251 -t utf-8 2>/dev/null || sudo head -3 "$FIRST_CSV" fi REMOTE - name: op=fetch-via-runner (download on runner, ship to prod) if: ${{ github.event.inputs.op == 'fetch-via-runner' }} run: | mkdir -p /tmp/rv && cd /tmp/rv && rm -f /tmp/rv/*.csv for U in https://opendata.digital.gov.ru/downloads/DEF-9xx.csv https://opendata.digital.gov.ru/downloads/ABC-3xx.csv https://opendata.digital.gov.ru/downloads/ABC-4xx.csv https://opendata.digital.gov.ru/downloads/ABC-8xx.csv; do FN=$(basename "${U%%\?*}") echo "runner: скачиваю $U -> $FN" curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,*/*' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FN" "$U" done echo "=== скачано на runner ===" ls -lh /tmp/rv | tee /tmp/op.log ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*.csv' scp -i ~/.ssh/liderra_deploy /tmp/rv/*.csv "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/" ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'sudo mkdir -p /var/www/liderra/rossvyaz && sudo mv /tmp/rvup/*.csv /var/www/liderra/rossvyaz/ && sudo chown -R www-data:www-data /var/www/liderra/rossvyaz && echo "=== на проде /var/www/liderra/rossvyaz ===" && ls -lh /var/www/liderra/rossvyaz' | tee -a /tmp/op.log - name: op=deliver-from-repo (scp repo CSV/ZIP to prod, unzip there) if: ${{ github.event.inputs.op == 'deliver-from-repo' }} run: | # Ищем файлы реестра где угодно (корень или папка), .csv или .zip mapfile -t FILES < <(find . -maxdepth 2 -type f \( \( -iname 'DEF-9xx*' -o -iname 'ABC-3xx*' -o -iname 'ABC-4xx*' -o -iname 'ABC-8xx*' \) -iname '*.csv' -o -iname '*.zip' \) ! -path './.git/*') if [ ${#FILES[@]} -eq 0 ]; then echo "::error::Не нашёл файлов реестра (DEF-9xx/ABC-*.csv|zip) ни в корне, ни в rossvyaz-data/. Проверь, что они закоммичены в репозиторий."; exit 1 fi echo "=== файлы в репозитории (rossvyaz-data/) ===" ls -lh "${FILES[@]}" | tee /tmp/op.log ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*' scp -i ~/.ssh/liderra_deploy "${FILES[@]}" "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/" ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" ' cd /tmp/rvup for z in *.zip *.ZIP; do if [ -e "$z" ]; then echo "распаковываю $z"; unzip -o "$z"; rm -f "$z"; fi; done sudo mkdir -p /var/www/liderra/rossvyaz find . -iname "*.csv" -exec sudo mv {} /var/www/liderra/rossvyaz/ \; sudo chown -R www-data:www-data /var/www/liderra/rossvyaz echo "=== на проде /var/www/liderra/rossvyaz ===" ls -lh /var/www/liderra/rossvyaz ' | tee -a /tmp/op.log - name: op=import (phone-ranges:import) if: ${{ github.event.inputs.op == 'import' }} run: | DRY_FLAG="" if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \ "APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log set -e cd "$APP_DIR" echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ===" sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG 2>&1 echo "=== Счётчики ===" sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true # staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил # подзапрос к phone_ranges_staging, когда таблица уже свапнута (иначе # ERROR relation "phone_ranges_staging" does not exist даже в ветке CASE). STAGING_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT to_regclass('phone_ranges_staging') IS NOT NULL") if [ "$STAGING_EXISTS" = "t" ]; then sudo -u postgres psql -d liderra -c "SELECT count(*) AS staging_rows FROM phone_ranges_staging" 2>&1 || true else echo "staging: отсутствует (после свапа — норма)" fi echo "=== Последний импорт ===" sudo -u postgres psql -d liderra -c \ "SELECT id, status, rows_inserted, rows_updated, imported_at FROM phone_ranges_imports ORDER BY id DESC LIMIT 3" 2>&1 || true REMOTE - name: op=smoke (phone-region:smoke) if: ${{ github.event.inputs.op == 'smoke' }} run: | ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \ "APP_DIR='$APP_DIR' PHONE='$PHONE' bash -s" <<'REMOTE' | tee /tmp/op.log set -e cd "$APP_DIR" echo "=== phone-region:smoke --phone=${PHONE} ===" sudo -u www-data php artisan phone-region:smoke --phone="$PHONE" 2>&1 REMOTE - name: Print summary if: always() run: | { echo "## lead-region-ops: \`${OP}\`" echo echo '```' cat /tmp/op.log 2>/dev/null || echo "(нет вывода)" echo '```' } >> "$GITHUB_STEP_SUMMARY" - name: Cleanup SSH key if: always() run: rm -f ~/.ssh/liderra_deploy