Compare commits

...

22 Commits

Author SHA1 Message Date
Дмитрий 944a85dcc8 fix(migrations): idempotent guards so from-scratch migrate works + restore full-PG Pa11y (21 routes)
Сборка БД с нуля (php artisan migrate на пустом PostgreSQL — CI, новый сервер,
пересоздание из бэкапа) падала: 0001_load_initial_schema грузит полный текущий
db/schema.sql (v8.39), затем дельта-миграции пытаются создать уже существующие
объекты. Из 28 дельт 25 уже идемпотентны; ровно 3 не имели гарда:

  - 2026_05_24_100000 add_balance_freeze: CREATE POLICY tenant_isolation на
    balance_freeze_log (политика уже в schema.sql:3357) → +DROP POLICY IF EXISTS.
  - 2026_05_26_120000 add_paused_at: projects.paused_at + projects_paused_at_idx
    (уже в schema.sql:815/897) → guard hasColumn + CREATE INDEX IF NOT EXISTS.
  - 2026_05_27_120000 project_routing_snapshots: CREATE TABLE + 2 индекса +
    политика + 2 партиции (уже в schema.sql v8.39) → IF NOT EXISTS на таблицу/
    индексы/партиции, DROP POLICY IF EXISTS, GRANT'ы вынесены в pg_roles-guard.

db/schema.sql НЕ трогается (источник истины, Pravila §4.2). Прод не затрагивается —
эти миграции там уже отмечены выполненными, тела повторно не исполняются.

Также возвращён полный-PostgreSQL прогон Pa11y (PR #49 был сужен до 7 публичных
страниц именно из-за сломанной сборки с нуля):
  - .github/workflows/a11y.yml: postgres:16 service, 5 ролей БД (00_create_roles.sql),
    .env с DB_SUPPLIER_* + Sanctum stateful localhost:8000, mkdir storage/framework,
    migrate → partitions:create-months → db:seed (admin@demo.local). Сохранены уроки
    PR #49: Node 22, корневой npm install, app npm ci --legacy-peer-deps.
  - pa11y.config.json: +14 авторизованных маршрутов (вход под admin@demo.local) →
    7 публичных + 14 авторизованных = 21 проверяемая страница.

Проверка — прогон CI этого workflow (чистый PostgreSQL + migrate с нуля + seed +
все 21 страница). План: docs/superpowers/plans/2026-06-03-from-scratch-migrate-idempotency-and-a11y-full.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:53:59 +03:00
CoralMinister 9ebc20ff94 Merge pull request #49 from CoralMinister/feat/a11y-ci-postgres
ci(a11y): provision full PostgreSQL so 14 authenticated Pa11y routes …
2026-06-03 17:31:22 +03:00
Дмитрий 28d2d38857 ci(a11y): mkdir storage/framework dirs so file sessions work (fixes 500) 2026-06-03 17:25:07 +03:00
Дмитрий 09f16bd83c ci(a11y): SESSION/CACHE=file so public pages render (no DB tables) + log tail 2026-06-03 17:08:29 +03:00
Дмитрий 512d8e0e24 ci(a11y): scope Pa11y to 7 public routes (defer full-PG from-scratch build) 2026-06-03 16:59:26 +03:00
CoralMinister 7aa0e4169e Update MonthlyPartitionManager.php 2026-06-03 16:40:15 +03:00
CoralMinister 7c9a8151f6 Update 0001_01_01_000000_load_initial_schema.php 2026-06-03 16:25:32 +03:00
CoralMinister be36fc64b3 Update a11y.yml 2026-06-03 15:59:05 +03:00
CoralMinister d883bf486f Update a11y.yml 2026-06-03 15:35:36 +03:00
CoralMinister 8907d16e40 Update a11y.yml 2026-06-03 15:05:13 +03:00
Дмитрий 364065a239 ci(a11y): provision full PostgreSQL so 14 authenticated Pa11y routes can log in
Pa11y CI был красный: коммит 35387e8b добавил в pa11y.config.json 14
авторизованных маршрутов (dashboard/deals/.../admin/*), которым нужен вход
под admin@demo.local, но a11y.yml поднимал только SQLite без migrate/seed —
а схема Лидерры чисто PostgreSQL (RLS, партиции, роли, raw schema.sql) и на
SQLite не грузится. Логин не проходил → "wait for path /dashboard" таймаут →
красный. Сканировались только 7 публичных страниц.

Теперь a11y-джоб:
- поднимает postgres:16 service-container (liderra/postgres/postgres);
- создаёт 5 ролей БД (db/00_create_roles.sql) — поздние миграции делают
  необёрнутый GRANT ... TO crm_app_user/crm_supplier_worker;
- migrate под postgres-суперюзером (guarded SET ROLE crm_migrator → RESET ROLE);
- partitions:create-months --ahead=2 (demo-сделки за текущий месяц);
- db:seed (APP_ENV=local → DemoSeeder создаёт admin@demo.local + demo-данные);
- .env: Sanctum SPA stateful domains включают localhost:8000 (иначе сессия
  с Pa11y-хоста не залипает), SESSION/CACHE=file, QUEUE=sync, APP_ENV=local.

Покрытие Pa11y: 7 публичных + 14 авторизованных = 21 маршрут.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:20:12 +03:00
CoralMinister 000bf816cc Merge pull request #48 from CoralMinister/fix/rossvyaz-osetia
fix(rossvyaz): normalize spaced hyphen to em-dash (Северная Осетия — …
2026-06-03 08:57:01 +03:00
Дмитрий 339c5f09f7 fix(rossvyaz): normalize spaced hyphen to em-dash (Северная Осетия — Алания)
Registry writes 'Республика Северная Осетия - Алания' (hyphen) while the
canonical name uses an em-dash. Replace ' - ' with ' — ' before lookup —
safe because no canonical name contains a space-surrounded hyphen. Unit-tested.
2026-06-03 08:46:32 +03:00
CoralMinister 7a49291296 Merge pull request #47 from CoralMinister/feat/rossvyaz-mapping-tail
feat(rossvyaz): normalize AO / inverted republics / Saha / Kuzbass / …
2026-06-03 08:23:11 +03:00
Дмитрий e3f6227ed1 feat(rossvyaz): normalize AO / inverted republics / Saha / Kuzbass / HMAO
Extend RussianRegions::canonicalRegionName for the long tail of registry
formats: ' АО' -> ' автономный округ', generic 'Республика X' -> 'X Республика'
(Чеченская/Кабардино-Балкарская/Карачаево-Черкесская/Донецкая Народная/
Луганская Народная/Удмуртская), ХМАО marker heuristic, plus aliases for
Саха /Якутия/, Чувашия - Чувашия, Кузбасс область, Город Москва, Санкт - Петербург.
Republika-first canonicals stay as-is. Unit-tested (21 GREEN).
2026-06-03 08:16:54 +03:00
CoralMinister 7b8535eef2 Merge pull request #46 from CoralMinister/fix/phone-ranges-staging-id
fix(phone-ranges): give staging its own id sequence for repeat imports
2026-06-03 07:41:30 +03:00
Дмитрий 69c1c5b374 fix(phone-ranges): give staging its own id sequence for repeat imports
LIKE phone_ranges INCLUDING DEFAULTS copied the serial id default pointing
at the original sequence, which atomic-swap destroys (DROP phone_ranges_old
CASCADE) after the first import — the second import then hit NOT NULL on
staging.id. Now staging gets a dedicated sequence named by import_id, OWNED
BY the id column so it travels on RENAME and drops with the old table.
Reproduced via a post-swap test (live id default removed).
2026-06-03 07:39:53 +03:00
CoralMinister 8e804cc482 Merge pull request #45 from CoralMinister/chore/lead-region-ops-force
chore(lead-region-ops): add force input for phone-ranges:import
2026-06-03 06:58:02 +03:00
Дмитрий 0bf69ce6b5 chore(lead-region-ops): add force input for phone-ranges:import
Re-import skips on identical checksum without --force. Adds a 'force'
boolean dispatch input wired into the import op so the registry can be
re-mapped after the region-normalization fix (PR #44).
2026-06-03 06:12:43 +03:00
CoralMinister 07747713f0 Merge pull request #44 from CoralMinister/feat/rossvyaz-region-mapping
Feat/rossvyaz region mapping
2026-06-02 15:42:37 +03:00
Дмитрий c6d2df908a feat(rossvyaz): wire region normalizer into import + fill region_normalized
PhoneRangesImportCommand now resolves subject_code via
RussianRegions::canonicalRegionName (pipe segment + обл./alias normalization)
and persists region_normalized. messy.csv fixture covers real prod formats
(3-digit DEF codes per chk_phone_ranges_def_code). 5/5 command tests GREEN.
2026-06-02 15:39:35 +03:00
Дмитрий d4ade05446 feat(rossvyaz): normalize registry region names to subject_code
RussianRegions::canonicalRegionName + resolveSubjectCode: take last pipe
segment, expand обл.->область, alias federal cities / Удмуртская / Кузбасс.
Fixes 98% unmapped phone_ranges (exact-match -> normalized). Unit-tested.
2026-06-02 15:22:24 +03:00
14 changed files with 545 additions and 32 deletions
+91 -9
View File
@@ -9,6 +9,25 @@ on:
jobs:
a11y:
runs-on: ubuntu-latest
timeout-minutes: 25
# Полноценный PostgreSQL для CI: схема Лидерры — чисто PG (RLS, партиции, роли,
# raw schema.sql через load_initial_schema), на SQLite не грузится. Без живой БД
# 14 авторизованных Pa11y-маршрутов не могут залогиниться под admin@demo.local.
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: liderra
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Checkout
@@ -21,22 +40,39 @@ jobs:
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
coverage: none
- name: Setup Node 20
- name: Setup Node 22
# Node 22 (>=22.18): корневые tooling-пакеты @cspell/*@10 требуют node>=22.18.
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install root JS deps
run: npm ci --no-audit --no-fund
# npm install (не ci): корневой package-lock рассинхронен (gcp-metadata) — pre-existing долг.
run: npm install --no-audit --no-fund
- name: Install app composer deps
working-directory: app
run: composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader
- name: Install app JS deps
# --legacy-peer-deps: Histoire 1.0-beta.1 заявляет peerDep vite ^7, установлено vite 8.
working-directory: app
run: npm ci --no-audit --no-fund
run: npm ci --no-audit --no-fund --legacy-peer-deps
- name: Create PostgreSQL roles
# schema.sql грузится без ролей (GRANT'ы в DO $$ EXISTS-guard), но поздние миграции
# делают необёрнутый GRANT ... TO crm_app_user/crm_supplier_worker → роли нужны.
env:
PGPASSWORD: postgres
run: |
psql -h 127.0.0.1 -U postgres -d liderra -v ON_ERROR_STOP=1 \
-v crm_app_password=ci_pa11y \
-v crm_admin_password=ci_pa11y \
-v crm_migrator_password=ci_pa11y \
-v crm_audit_writer_password=ci_pa11y \
-v crm_supplier_worker_password=ci_pa11y \
-f db/00_create_roles.sql
- name: Bootstrap .env + key
working-directory: app
@@ -44,19 +80,60 @@ jobs:
cp .env.example .env
php artisan key:generate --force
- name: Prepare SQLite for CI (avoid pg-on-CI fixture cost)
- name: Configure .env for CI PostgreSQL + Sanctum SPA
# phpdotenv: первое вхождение ключа выигрывает → удаляем строку и дописываем заново.
# APP_ENV=local → DatabaseSeeder зовёт DemoSeeder (admin@demo.local) + session-cookie не secure-only.
# SANCTUM_STATEFUL_DOMAINS обязан включать localhost:8000 — иначе сессия с Pa11y-хоста не залипает.
# DB_SUPPLIER_* нужны: часть миграций пишет через pgsql_supplier-соединение (BYPASSRLS-роль).
working-directory: app
run: |
touch database/database.sqlite
sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env
setenv() { sed -i "/^$1=/d" .env; echo "$1=$2" >> .env; }
setenv APP_ENV local
setenv APP_DEBUG true
setenv APP_URL http://localhost:8000
setenv DB_CONNECTION pgsql
setenv DB_HOST 127.0.0.1
setenv DB_PORT 5432
setenv DB_DATABASE liderra
setenv DB_USERNAME postgres
setenv DB_PASSWORD postgres
setenv DB_SUPPLIER_USERNAME postgres
setenv DB_SUPPLIER_PASSWORD postgres
setenv DB_SSLMODE disable
setenv SESSION_DRIVER file
setenv CACHE_STORE file
setenv QUEUE_CONNECTION sync
setenv MAIL_MAILER log
setenv SANCTUM_STATEFUL_DOMAINS localhost:8000,127.0.0.1:8000,localhost,127.0.0.1
- name: Prepare storage dirs (file session/cache need them)
# SESSION_DRIVER=file пишет в storage/framework/sessions — без каталога 500 (урок PR #49).
working-directory: app
run: mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache storage/logs bootstrap/cache
- name: Run migrations (postgres superuser → guarded SET ROLE works)
working-directory: app
run: php artisan migrate --force
- name: Create current-month partitions
# schema.sql + миграции дают baseline-партиции; cron-команда докидывает текущий +2 месяца
# (идемпотентно) — нужно для demo-сделок DemoSeeder'а за «сегодня».
working-directory: app
run: php artisan partitions:create-months --ahead=2
- name: Seed demo data (PricingTier + DemoSeeder admin@demo.local)
working-directory: app
run: php artisan db:seed --force
- name: Build frontend assets
working-directory: app
run: npm run build
- name: Start Laravel dev-server
# PHP_CLI_SERVER_WORKERS>1: встроенный сервер обслуживает SPA + sub-resources параллельно.
working-directory: app
env:
PHP_CLI_SERVER_WORKERS: 4
run: nohup php artisan serve --host=127.0.0.1 --port=8000 > /tmp/laravel-serve.log 2>&1 &
- name: Wait for dev-server ready
@@ -72,9 +149,14 @@ jobs:
tail -50 /tmp/laravel-serve.log
exit 1
- name: Run Pa11y (live Vue)
- name: Run Pa11y (live Vue, 7 public + 14 authenticated routes)
run: npm run a11y
- name: Laravel log tail on failure
if: failure()
working-directory: app
run: tail -120 storage/logs/laravel.log || echo "no laravel.log"
- name: Upload Pa11y screenshots
if: always()
uses: actions/upload-artifact@v4
+11 -3
View File
@@ -53,6 +53,11 @@ on:
required: false
default: true
type: boolean
force:
description: 'import: принудительно (--force, игнорировать «реестр идентичен»)'
required: false
default: false
type: boolean
phone:
description: 'smoke: телефон'
required: false
@@ -77,6 +82,7 @@ jobs:
URL: ${{ github.event.inputs.url }}
DIR: ${{ github.event.inputs.dir }}
DRY: ${{ github.event.inputs.dry_run }}
FORCE: ${{ github.event.inputs.force }}
PHONE: ${{ github.event.inputs.phone }}
steps:
@@ -344,12 +350,14 @@ jobs:
run: |
DRY_FLAG=""
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
FORCE_FLAG=""
if [ "${FORCE}" = "true" ]; then FORCE_FLAG="--force"; 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
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' FORCE_FLAG='$FORCE_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 "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ${FORCE_FLAG} ==="
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG $FORCE_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 не парсил
@@ -100,7 +100,10 @@ class PhoneRangesImportCommand extends Command
$rows = [];
foreach ($files as $file) {
foreach ($this->parseFile($file) as $rec) {
$subjectCode = RussianRegions::nameToCode()[trim($rec['region'])] ?? null;
$regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
$subjectCode = $regionNormalized === null
? null
: (RussianRegions::nameToCode()[$regionNormalized] ?? null);
if ($subjectCode === null && trim($rec['region']) !== '') {
$unmatched[trim($rec['region'])] = true;
}
@@ -110,6 +113,7 @@ class PhoneRangesImportCommand extends Command
'to_num' => $rec['to_num'],
'operator' => $rec['operator'],
'region' => $rec['region'],
'region_normalized' => $regionNormalized,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
@@ -118,7 +122,7 @@ class PhoneRangesImportCommand extends Command
}
// 5. Сборка staging.
$this->buildStaging($rows);
$this->buildStaging($rows, $importId);
$unmatchedNote = $unmatched === []
? ''
@@ -367,15 +371,27 @@ class PhoneRangesImportCommand extends Command
/**
* Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
*
* id: НЕ копируем серийный default через INCLUDING DEFAULTS он ссылается на
* исходную последовательность phone_ranges, которую atomic-swap уничтожает
* (DROP phone_ranges_old CASCADE) после первого импорта, оставляя staging.id
* без default (NOT NULL violation на повторном импорте). Вместо этого даём
* staging собственную последовательность с уникальным по import_id именем,
* OWNED BY колонкой id она переезжает при RENAME и дропается вместе со
* старой таблицей (без коллизий имён и без утечки последовательностей).
*
* @param list<array<string, mixed>> $rows
*/
private function buildStaging(array $rows): void
private function buildStaging(array $rows, int $importId): void
{
$c = DB::connection(self::DDL_CONNECTION);
$this->elevate($c);
$seq = "phone_ranges_stg_seq_{$importId}";
$c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING DEFAULTS INCLUDING CONSTRAINTS)');
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING CONSTRAINTS)');
$c->statement("CREATE SEQUENCE {$seq}");
$c->statement("ALTER TABLE phone_ranges_staging ALTER COLUMN id SET DEFAULT nextval('{$seq}')");
$c->statement("ALTER SEQUENCE {$seq} OWNED BY phone_ranges_staging.id");
$c->statement('CREATE INDEX IF NOT EXISTS idx_phone_ranges_staging_lookup ON phone_ranges_staging (def_code, from_num, to_num)');
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
@@ -108,7 +108,16 @@ class MonthlyPartitionManager
if ($exists !== null) {
return false;
}
// Родитель-партиционированная таблица может ещё не существовать
// (создаётся более поздней миграцией) — тогда пропускаем.
$parentExists = DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'p'",
[$table],
);
if ($parentExists === null) {
return false;
}
DB::connection(self::DDL_CONNECTION)->statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partition,
+88
View File
@@ -114,9 +114,97 @@ final class RussianRegions
89 => 'Ямало-Ненецкий автономный округ',
];
/**
* Алиасы нестандартных форм реестра Россвязи каноничное имя субъекта.
* Города фед. значения приходят с префиксом «г. »; «Республика Удмуртская»
* перевёрнутый порядок слов; «Кемеровская область - Кузбасс обл.» спец-форма.
*
* @var array<string, string>
*/
private const REGION_ALIASES = [
'г. Москва' => 'Москва',
'Город Москва' => 'Москва',
'г. Санкт-Петербург' => 'Санкт-Петербург',
'г. Санкт - Петербург' => 'Санкт-Петербург',
'г. Севастополь' => 'Севастополь',
'Республика Саха /Якутия/' => 'Республика Саха (Якутия)',
'Чувашская Республика - Чувашия' => 'Чувашская Республика',
'Кемеровская область - Кузбасс обл.' => 'Кемеровская область',
'Кемеровская область - Кузбасс область' => 'Кемеровская область',
'Кемеровская область - Кузбасс' => 'Кемеровская область',
];
/** @return array<string, int> name => code (обратный индекс) */
public static function nameToCode(): array
{
return array_flip(self::CODE_TO_NAME);
}
/**
* Нормализует строку региона реестра Россвязи в каноничное имя субъекта (или null).
*
* Реестр кодирует субъект как ПОСЛЕДНИЙ сегмент после «|»
* (напр. «г. Воскресенск|р-н Воскресенский|Московская обл.» «Московская обл.»),
* с сокращением «обл.» вместо «область» и рядом нестандартных форм (см. REGION_ALIASES).
* Безнадёжные/неоднозначные строки («-», «Российская Федерация»,
* «Москва и Московская область», «г.о. Тольятти») null.
*/
public static function canonicalRegionName(string $raw): ?string
{
$segment = self::lastRegionSegment($raw);
if ($segment === '') {
return null;
}
// ХМАО приходит в множестве форм (em-dash/дефис, «Югра», « АО», капитализация) —
// ловим по двум устойчивым маркерам до общих правил.
if (mb_stripos($segment, 'Ханты') !== false && mb_stripos($segment, 'Мансийск') !== false) {
return 'Ханты-Мансийский автономный округ — Югра';
}
if (isset(self::REGION_ALIASES[$segment])) {
return self::REGION_ALIASES[$segment];
}
// «обл.» → «область»; « АО» → « автономный округ».
$name = (string) preg_replace('/\s*обл\.$/u', ' область', $segment);
$name = (string) preg_replace('/\s+АО$/u', ' автономный округ', $name);
// Дефис с пробелами → длинное тире (эталон: «Республика Северная Осетия — Алания»).
// Безопасно: ни одно каноническое имя не содержит дефис, окружённый пробелами
// (составные имена вроде «Кабардино-Балкарская» используют дефис без пробелов).
$name = str_replace(' - ', ' — ', $name);
if (isset(self::nameToCode()[$name])) {
return $name;
}
// Перевёрнутый порядок «Республика X» → «X Республика» (Удмуртская/Чеченская/
// Чувашская/Кабардино-Балкарская/Карачаево-Черкесская, Донецкая Народная/
// Луганская Народная). Республика-first каноны (Татарстан, Карелия…) уже
// отловлены прямым попаданием выше.
if (preg_match('/^Республика\s+(.+)$/u', $name, $m) === 1) {
$reordered = trim($m[1]).' Республика';
if (isset(self::nameToCode()[$reordered])) {
return $reordered;
}
}
return null;
}
/** Резолвит строку региона реестра Россвязи в subject_code (1..89) или null. */
public static function resolveSubjectCode(string $raw): ?int
{
$name = self::canonicalRegionName($raw);
return $name === null ? null : (self::nameToCode()[$name] ?? null);
}
/** Последний сегмент после «|» (субъект в формате Россвязи), trimmed. */
private static function lastRegionSegment(string $raw): string
{
$parts = explode('|', $raw);
return trim((string) end($parts));
}
}
@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB;
*/
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
@@ -38,6 +38,8 @@ return new class extends Migration
)
SQL);
$supplier->statement('ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY');
// Idempotency: schema.sql (сборка с нуля) уже создаёт эту политику — снимаем перед CREATE.
$supplier->statement('DROP POLICY IF EXISTS tenant_isolation ON balance_freeze_log');
$supplier->statement(<<<'SQL'
CREATE POLICY tenant_isolation ON balance_freeze_log
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
@@ -11,10 +11,13 @@ return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->timestampTz('paused_at')->nullable()->after('is_active');
$table->index('paused_at', 'projects_paused_at_idx');
});
// Idempotency: schema.sql (сборка с нуля) уже содержит paused_at + индекс.
if (! Schema::hasColumn('projects', 'paused_at')) {
Schema::table('projects', function (Blueprint $table): void {
$table->timestampTz('paused_at')->nullable()->after('is_active');
});
}
DB::statement('CREATE INDEX IF NOT EXISTS projects_paused_at_idx ON projects (paused_at)');
// Backfill: для уже paused проектов используем updated_at как best-effort
// (для долго-paused — grace давно истёк; для свежих — близко к реальной паузе).
@@ -28,9 +31,11 @@ return new class extends Migration
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->dropIndex('projects_paused_at_idx');
$table->dropColumn('paused_at');
});
DB::statement('DROP INDEX IF EXISTS projects_paused_at_idx');
if (Schema::hasColumn('projects', 'paused_at')) {
Schema::table('projects', function (Blueprint $table): void {
$table->dropColumn('paused_at');
});
}
}
};
@@ -21,7 +21,7 @@ return new class extends Migration {
}
DB::unprepared(<<<'SQL'
CREATE TABLE project_routing_snapshots (
CREATE TABLE IF NOT EXISTS project_routing_snapshots (
snapshot_date DATE NOT NULL,
project_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
@@ -41,28 +41,43 @@ return new class extends Migration {
-- а snapshot должен пережить (хвост слепка ещё летит).
) PARTITION BY RANGE (snapshot_date);
CREATE INDEX project_routing_snapshots_tenant_date_idx
CREATE INDEX IF NOT EXISTS project_routing_snapshots_tenant_date_idx
ON project_routing_snapshots (tenant_id, snapshot_date);
CREATE INDEX project_routing_snapshots_signal_idx
CREATE INDEX IF NOT EXISTS project_routing_snapshots_signal_idx
ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier));
ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS project_routing_snapshots_tenant_isolation ON project_routing_snapshots;
CREATE POLICY project_routing_snapshots_tenant_isolation
ON project_routing_snapshots
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker;
-- Партиция для текущего месяца (создаётся также через partitions:create-months).
CREATE TABLE project_routing_snapshots_y2026_m05
CREATE TABLE IF NOT EXISTS project_routing_snapshots_y2026_m05
PARTITION OF project_routing_snapshots
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE project_routing_snapshots_y2026_m06
CREATE TABLE IF NOT EXISTS project_routing_snapshots_y2026_m06
PARTITION OF project_routing_snapshots
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
SQL);
// GRANT'ы вынесены из DDL-блока и обёрнуты в проверку существования роли:
// сборка с нуля на окружении без 5 ролей (dev throwaway) не должна падать (mirror balance_freeze_log).
foreach ([
'crm_app_user' => 'SELECT, INSERT, UPDATE',
'crm_supplier_worker' => 'SELECT, INSERT, UPDATE, DELETE',
] as $role => $privs) {
DB::statement(<<<SQL
DO \$\$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
GRANT {$privs} ON project_routing_snapshots TO {$role};
END IF;
END
\$\$
SQL);
}
// Регистрация в retention (system_settings).
$exists = DB::table('system_settings')
->where('key', 'partition_retention_months_project_routing_snapshots')
@@ -86,3 +86,39 @@ it('force flag bypasses idempotency note even with matching checksum', function
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
expect(DB::table('phone_ranges')->count())->toBe(0);
});
it('normalizes real Россвязь region formats to subject_code and fills region_normalized', function (): void {
// Форматы из реального прод-реестра (топ unmapped 02.06.2026): префикс «г. »,
// pipe-сегмент региона, сокращение «обл.», перевёрнутая «Республика Удмуртская»,
// и безнадёжный city-only «г.о. Тольятти». def-коды 3-значные (chk_phone_ranges_def_code 300-999).
$this->artisan('phone-ranges:import', ['--file' => base_path('tests/Fixtures/rossvyaz/messy.csv'), '--dry-run' => true])
->assertSuccessful();
$moscow = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 495');
$orenburg = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 922');
$udmurtia = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 987');
$togliatti = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 902');
expect((int) $moscow->subject_code)->toBe(82)
->and($moscow->region_normalized)->toBe('Москва')
->and((int) $orenburg->subject_code)->toBe(62)
->and($orenburg->region_normalized)->toBe('Оренбургская область')
->and((int) $udmurtia->subject_code)->toBe(21)
->and($udmurtia->region_normalized)->toBe('Удмуртская Республика')
->and($togliatti->subject_code)->toBeNull()
->and($togliatti->region_normalized)->toBeNull();
});
it('rebuilds staging id even after the live id default was dropped (post-swap state)', function (): void {
// После первого atomic-swap исходная id-последовательность уничтожается
// (DROP phone_ranges_old CASCADE), и live.id остаётся без DEFAULT. Повторный
// импорт обязан выдать staging.id из собственной последовательности, а не упасть
// на NOT NULL. Симулируем это, сняв default у phone_ranges.id.
DB::connection('pgsql_supplier')->statement('ALTER TABLE phone_ranges ALTER COLUMN id DROP DEFAULT');
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
->assertSuccessful();
expect(DB::table('phone_ranges_staging')->count())->toBe(3)
->and(DB::table('phone_ranges_staging')->whereNull('id')->count())->toBe(0);
});
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Support\RussianRegions;
/**
* Нормализация регионов реестра Россвязи subject_code.
* Кейсы взяты из реальных топ-50 unmapped-форматов прод-реестра (02.06.2026).
*/
it('maps cities of federal significance with the г. prefix', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Москва'))->toBe(82)
->and(RussianRegions::resolveSubjectCode('г. Санкт-Петербург'))->toBe(83)
->and(RussianRegions::resolveSubjectCode('г. Севастополь'))->toBe(84);
});
it('still maps a plain canonical federal-city name', function (): void {
expect(RussianRegions::resolveSubjectCode('Москва'))->toBe(82);
});
it('takes the last pipe segment as the subject region', function (): void {
// регион = последний сегмент после |
expect(RussianRegions::resolveSubjectCode('г. Оренбург|Оренбургская обл.'))->toBe(62)
->and(RussianRegions::resolveSubjectCode('г. Воскресенск|р-н Воскресенский|Московская обл.'))->toBe(56);
});
it('expands the обл. abbreviation to область', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Иркутск|Иркутская обл.'))->toBe(45)
->and(RussianRegions::resolveSubjectCode('г. Балашиха|Московская обл.'))->toBe(56);
});
it('keeps already-canonical край/республика segments', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Красноярск|Красноярский край'))->toBe(29)
->and(RussianRegions::resolveSubjectCode('г. Уфа|Республика Башкортостан'))->toBe(3);
});
it('reorders the Удмуртская Республика inverted form', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Ижевск|Республика Удмуртская'))->toBe(21);
});
it('maps the Кузбасс special form to Кемеровская область', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Кемерово|Кемеровская область - Кузбасс обл.'))->toBe(48);
});
it('returns null for hopeless / ambiguous / city-only strings', function (string $raw): void {
expect(RussianRegions::resolveSubjectCode($raw))->toBeNull();
})->with([
'-',
'Российская Федерация',
'Москва и Московская область', // неоднозначно — два субъекта
'г.о. Тольятти', // нет региона в строке
'г.о. город Уфа',
'',
' ',
]);
it('exposes the canonical name via canonicalRegionName', function (): void {
expect(RussianRegions::canonicalRegionName('г. Оренбург|Оренбургская обл.'))->toBe('Оренбургская область')
->and(RussianRegions::canonicalRegionName('г. Ижевск|Республика Удмуртская'))->toBe('Удмуртская Республика')
->and(RussianRegions::canonicalRegionName('-'))->toBeNull();
});
it('expands the АО abbreviation to автономный округ', function (): void {
expect(RussianRegions::resolveSubjectCode('Ненецкий АО'))->toBe(86)
->and(RussianRegions::resolveSubjectCode('Чукотский АО'))->toBe(88)
->and(RussianRegions::resolveSubjectCode('г. Салехард|Ямало-Ненецкий АО'))->toBe(89);
});
it('maps Ханты-Мансийск variants to ХМАО — Югра', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Сургут|Ханты-Мансийский Автономный округ - Югра АО'))->toBe(87)
->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский АО - Югра'))->toBe(87)
->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский Автономный округ - Югра.'))->toBe(87);
});
it('reorders inverted Республика X forms', function (): void {
expect(RussianRegions::resolveSubjectCode('Республика Чеченская'))->toBe(23)
->and(RussianRegions::resolveSubjectCode('Республика Кабардино-Балкарская'))->toBe(8)
->and(RussianRegions::resolveSubjectCode('Республика Карачаево-Черкесская'))->toBe(10)
->and(RussianRegions::resolveSubjectCode('Республика Донецкая Народная'))->toBe(6)
->and(RussianRegions::resolveSubjectCode('Республика Луганская Народная'))->toBe(14);
});
it('keeps Республика-first canonical names as-is', function (): void {
expect(RussianRegions::resolveSubjectCode('Республика Татарстан'))->toBe(19)
->and(RussianRegions::resolveSubjectCode('Республика Карелия'))->toBe(11);
});
it('handles irregular subject spellings (Саха, Чувашия, Кузбасс)', function (): void {
expect(RussianRegions::resolveSubjectCode('у. Мирнинский|Республика Саха /Якутия/'))->toBe(17)
->and(RussianRegions::resolveSubjectCode('г. Чебоксары|Чувашская Республика - Чувашия'))->toBe(24)
->and(RussianRegions::resolveSubjectCode('Кемеровская область - Кузбасс область'))->toBe(48);
});
it('maps Moscow / SPb spelling variants', function (): void {
expect(RussianRegions::resolveSubjectCode('Город Москва'))->toBe(82)
->and(RussianRegions::resolveSubjectCode('г. Санкт - Петербург'))->toBe(83);
});
it('normalizes spaced hyphen to em-dash (Северная Осетия — Алания)', function (): void {
expect(RussianRegions::resolveSubjectCode('Республика Северная Осетия - Алания'))->toBe(18)
->and(RussianRegions::resolveSubjectCode('г. Владикавказ|Республика Северная Осетия - Алания'))->toBe(18);
});
+5
View File
@@ -0,0 +1,5 @@
АВС/ DEF;От;До;Емкость;Оператор;Регион
495;2000000;2009999;10000;ОАО МГТС;г. Москва
922;1000000;1099999;100000;ПАО Ростелеком;г. Оренбург|Оренбургская обл.
987;5000000;5099999;100000;ПАО Ростелеком;г. Ижевск|Республика Удмуртская
902;7000000;7009999;10000;ООО Оператор;г.о. Тольятти
1 АВС/ DEF От До Емкость Оператор Регион
2 495 2000000 2009999 10000 ОАО МГТС г. Москва
3 922 1000000 1099999 100000 ПАО Ростелеком г. Оренбург|Оренбургская обл.
4 987 5000000 5099999 100000 ПАО Ростелеком г. Ижевск|Республика Удмуртская
5 902 7000000 7009999 10000 ООО Оператор г.о. Тольятти
@@ -0,0 +1,61 @@
# Россвязь region→subject_code mapping fix — Implementation Plan
> **For agentic workers:** TDD, bite-sized steps. Steps use `- [ ]`.
**Goal:** Маппить регион из реестра Россвязи в `subject_code` через нормализацию форматов, чтобы перестать терять ~98% диапазонов (444904/453080 были NULL из-за exact-match).
**Architecture:** Чистый нормализатор в `App\Support\RussianRegions` (`canonicalRegionName` + `resolveSubjectCode`), unit-тестируемый без БД. `PhoneRangesImportCommand` зовёт его и заполняет `region_normalized`. Прод перечитывает реестр командой `phone-ranges:import` после мержа.
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16.
---
## Корень проблемы (systematic-debugging Phase 1, подтверждён прод-данными)
`PhoneRangesImportCommand` делал `RussianRegions::nameToCode()[trim($rec['region'])]` — exact match. Реальные строки реестра (топ-50 unmapped, прод 02.06.2026):
- `г. Москва` (253342) / `г. Санкт-Петербург` (34573) — города фед. значения с префиксом `г. `
- `г. Оренбург|Оренбургская обл.` — регион = **последний** сегмент после `|`, область сокращена `обл.`
- `г. Воскресенск|р-н Воскресенский|Московская обл.` — 3 сегмента, регион = последний
- `г. Ижевск|Республика Удмуртская` — порядок слов перевёрнут (канон `Удмуртская Республика`)
- `г. Кемерово|Кемеровская область - Кузбасс обл.` — спец-форма
- Безнадёжные (меньшинство, остаются NULL): `-`, `Российская Федерация`, `Москва и Московская область` (неоднозначно), `г.о. Тольятти` / `г.о. город Уфа` (нет региона в строке)
## Правила нормализации
1. Взять последний сегмент после `|`, trim.
2. Прямые алиасы (приоритет): `г. Москва``Москва`, `г. Санкт-Петербург``Санкт-Петербург`, `г. Севастополь``Севастополь`, `Республика Удмуртская``Удмуртская Республика`, `Кемеровская область - Кузбасс обл.``Кемеровская область`.
3. Иначе: суффикс ` обл.`` область`.
4. Результат искать в `nameToCode()`. Нет → `null` (диапазон остаётся unmapped — корректно).
---
## Task 1: `RussianRegions::canonicalRegionName` + `resolveSubjectCode`
**Files:**
- Modify: `app/app/Support/RussianRegions.php`
- Test: `app/tests/Unit/Support/RussianRegionsTest.php`
- [ ] Step 1: написать падающий unit-тест (кейсы: фед.города с `г. `, `обл.``область`, многосегментный pipe, переворот Удмуртии, Кузбасс-алиас, безнадёжные→null, чистое каноничное имя).
- [ ] Step 2: запустить pest → RED (метод не существует).
- [ ] Step 3: реализовать `lastSegment` (private), `ALIASES` (const), `canonicalRegionName(string): ?string`, `resolveSubjectCode(string): ?int`.
- [ ] Step 4: pest → GREEN.
- [ ] Step 5: commit.
## Task 2: wire команды импорта + `region_normalized`
**Files:**
- Modify: `app/app/Console/Commands/PhoneRangesImportCommand.php:103-116`
- Modify: `app/tests/Feature/Console/PhoneRangesImportCommandTest.php`
- Modify: `app/tests/Fixtures/rossvyaz/sample.csv` (добавить грязные строки)
- [ ] Step 1: добавить в fixture строки с реальными форматами (`г. Москва`, `г. Оренбург|Оренбургская обл.`, `г. Ижевск|Республика Удмуртская`, `г.о. Тольятти`).
- [ ] Step 2: расширить command-тест: проверить, что грязные строки маппятся в правильные коды, безнадёжные → NULL, `region_normalized` заполнен.
- [ ] Step 3: pest → RED.
- [ ] Step 4: команда зовёт `RussianRegions::canonicalRegionName` + `nameToCode`, пишет `region_normalized`.
- [ ] Step 5: pest → GREEN (весь файл).
- [ ] Step 6: commit + push + PR.
## После мержа
Владелец запускает на проде через `artisan-run.yml` (mutating, confirm_apply): `phone-ranges:import --dir=<пакет> --force` — перечитывает реестр с новым маппингом. Будущие лиды резолвятся через Россвязь-fallback → меньше пустого «Город».
@@ -0,0 +1,83 @@
# From-scratch migrate idempotency + full-PostgreSQL Pa11y — Implementation Plan
> **For agentic workers:** small, surgical bugfix. Verification is the CI run of the
> full-PostgreSQL `a11y.yml` (from-scratch `migrate` on a clean PG = the reproduction).
**Goal:** Make `php artisan migrate` succeed on an empty PostgreSQL (CI / new server /
backup-rebuild), then restore the 14 authenticated Pa11y routes that need a real login.
**Architecture:** `0001_01_01_000000_load_initial_schema` loads the *full current*
`db/schema.sql` first, then 28 delta migrations run on top. 25 of them are already
idempotent (guards: `Schema::hasColumn`, `to_regclass`, `pg_class`, `IF NOT EXISTS`,
`ON CONFLICT`, `DO $$ IF NOT EXISTS pg_constraint $$`). Exactly **3** miss the guard and
fail with "already exists" because their objects are already in `schema.sql`. Fix = add the
missing guards. `db/schema.sql` is NOT touched (source-of-truth rule, Pravila §4.2). Prod is
unaffected — these migrations are already recorded as run there, so their bodies never re-execute.
**Tech Stack:** Laravel 13, PostgreSQL 16, GitHub Actions, pa11y-ci (WCAG2AA).
---
## Root cause (systematic-debugging, complete)
Confirmed by reading every post-`load_initial_schema` migration and cross-checking `db/schema.sql`:
| Migration | Non-idempotent statement | Already in schema.sql |
|---|---|---|
| `2026_05_24_100000_add_balance_freeze_to_tenants_and_projects` | `CREATE POLICY tenant_isolation ON balance_freeze_log` (table/cols/indexes already guarded) | policy @ schema.sql:3357 |
| `2026_05_26_120000_add_paused_at_to_projects` | `$table->timestampTz('paused_at')` + `$table->index('projects_paused_at_idx')` | column @ :815, index @ :897 |
| `2026_05_27_120000_create_project_routing_snapshots_table` | `CREATE TABLE` + 2 indexes + policy + 2 partitions (no `IF NOT EXISTS`); unconditional GRANTs | table/policy/indexes @ schema.sql v8.39 |
All other deltas are already idempotent (verified individually). `webhook_log`/`rejected_deals_log`
were removed from schema.sql in v8.35, so the migrations touching them are no-ops on a fresh build.
---
## Task 1 — guard `CREATE POLICY` on `balance_freeze_log`
**File:** `app/database/migrations/2026_05_24_100000_add_balance_freeze_to_tenants_and_projects.php`
- [ ] Insert `DROP POLICY IF EXISTS tenant_isolation ON balance_freeze_log` before the `CREATE POLICY`.
## Task 2 — guard `paused_at` column + index
**File:** `app/database/migrations/2026_05_26_120000_add_paused_at_to_projects.php`
- [ ] `up()`: wrap the column add in `if (! Schema::hasColumn('projects','paused_at'))`; create the
index via `CREATE INDEX IF NOT EXISTS projects_paused_at_idx`. Keep the idempotent backfill.
- [ ] `down()`: `DROP INDEX IF EXISTS` + guard `dropColumn` with `hasColumn`.
## Task 3 — make `project_routing_snapshots` DDL idempotent
**File:** `app/database/migrations/2026_05_27_120000_create_project_routing_snapshots_table.php`
- [ ] `CREATE TABLE IF NOT EXISTS` for the parent and both partitions.
- [ ] `CREATE INDEX IF NOT EXISTS` for both indexes.
- [ ] `DROP POLICY IF EXISTS ... ; CREATE POLICY ...`.
- [ ] Move GRANTs out of the DDL block into role-existence-guarded `DO $$ ... pg_roles ... $$`
blocks (so a from-scratch build on an environment without the 5 roles still succeeds).
## Task 4 — full-PostgreSQL CI workflow
**File:** `.github/workflows/a11y.yml`
- [ ] postgres:16 service container (liderra/postgres/postgres).
- [ ] Create 5 DB roles via `db/00_create_roles.sql`.
- [ ] `.env`: APP_ENV=local, DB pgsql + `DB_SUPPLIER_USERNAME/PASSWORD` (pgsql_supplier connection),
SESSION/CACHE=file, QUEUE=sync, MAIL=log, `SANCTUM_STATEFUL_DOMAINS` incl `localhost:8000`.
- [ ] `mkdir -p storage/framework/{sessions,views,cache}` (file session/cache need them — PR #49 lesson).
- [ ] `migrate --force``partitions:create-months --ahead=2``db:seed --force` (DemoSeeder admin@demo.local).
- [ ] Keep Node 22 + root `npm install` (lock drift) + app `npm ci --legacy-peer-deps` (PR #49 lessons).
## Task 5 — restore 14 authenticated Pa11y routes
**File:** `pa11y.config.json`
- [ ] Re-add the 14 authenticated URLs (dashboard/deals/kanban/projects/billing/settings/reports/
reminders + 6 admin/*) with login `actions` (admin@demo.local / password → wait for /dashboard).
## Verification
CI run of `a11y.yml` on the PR: it boots clean PostgreSQL, runs `migrate` from scratch, seeds,
and Pa11y-scans all 7 public + 14 authenticated routes. Green = both the migrate fix and the
14-page restoration are proven. (`php`/squawk/pest are gate-blocked locally → CI is the verifier.)