Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 364065a239 | |||
| 000bf816cc | |||
| 339c5f09f7 | |||
| 7a49291296 | |||
| e3f6227ed1 | |||
| 7b8535eef2 | |||
| 69c1c5b374 | |||
| 8e804cc482 |
@@ -9,6 +9,26 @@ on:
|
||||
jobs:
|
||||
a11y:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
# Полноценный PostgreSQL для CI: схема Лидерры — чисто PG (RLS, партиции,
|
||||
# роли БД, raw schema.sql через load_initial_schema), на SQLite не грузится.
|
||||
# Без живой БД 14 авторизованных Pa11y-маршрутов не могут залогиниться под
|
||||
# admin@demo.local → таймаут на "wait for path /dashboard" → красный CI.
|
||||
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
|
||||
@@ -35,8 +55,27 @@ jobs:
|
||||
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 (memory feedback_environment.md #74) — как в deploy.yml.
|
||||
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-check),
|
||||
# но поздние миграции (snapshot, lead-region) делают необёрнутый
|
||||
# GRANT ... TO crm_app_user/crm_supplier_worker → роли должны существовать.
|
||||
# SET ROLE crm_migrator в этих миграциях с guard'ом has_schema_privilege →
|
||||
# под postgres-суперюзером корректно делает RESET ROLE (грантов на public нет).
|
||||
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 +83,56 @@ 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: первое вхождение ключа выигрывает → не дописываем дубли,
|
||||
# а удаляем строку и добавляем заново (детерминированный override).
|
||||
# APP_ENV=local нужен, чтобы DatabaseSeeder вызвал DemoSeeder (admin@demo.local)
|
||||
# и чтобы session-cookie не был secure-only (вход по http в CI).
|
||||
# SANCTUM_STATEFUL_DOMAINS обязан включать localhost:8000 — иначе Sanctum
|
||||
# считает запрос с Pa11y-хоста (localhost:8000) stateless → сессия не залипает.
|
||||
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_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: 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
|
||||
# параллельно, чтобы Pa11y-навигации не упирались в однопоточность.
|
||||
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 +148,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
|
||||
|
||||
@@ -122,7 +122,7 @@ class PhoneRangesImportCommand extends Command
|
||||
}
|
||||
|
||||
// 5. Сборка staging.
|
||||
$this->buildStaging($rows);
|
||||
$this->buildStaging($rows, $importId);
|
||||
|
||||
$unmatchedNote = $unmatched === []
|
||||
? ''
|
||||
@@ -371,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) {
|
||||
|
||||
@@ -123,10 +123,14 @@ final class RussianRegions
|
||||
*/
|
||||
private const REGION_ALIASES = [
|
||||
'г. Москва' => 'Москва',
|
||||
'Город Москва' => 'Москва',
|
||||
'г. Санкт-Петербург' => 'Санкт-Петербург',
|
||||
'г. Санкт - Петербург' => 'Санкт-Петербург',
|
||||
'г. Севастополь' => 'Севастополь',
|
||||
'Республика Удмуртская' => 'Удмуртская Республика',
|
||||
'Республика Саха /Якутия/' => 'Республика Саха (Якутия)',
|
||||
'Чувашская Республика - Чувашия' => 'Чувашская Республика',
|
||||
'Кемеровская область - Кузбасс обл.' => 'Кемеровская область',
|
||||
'Кемеровская область - Кузбасс область' => 'Кемеровская область',
|
||||
'Кемеровская область - Кузбасс' => 'Кемеровская область',
|
||||
];
|
||||
|
||||
@@ -152,10 +156,40 @@ final class RussianRegions
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = self::REGION_ALIASES[$segment]
|
||||
?? (string) preg_replace('/\s*обл\.$/u', ' область', $segment);
|
||||
// ХМАО приходит в множестве форм (em-dash/дефис, «Югра», « АО», капитализация) —
|
||||
// ловим по двум устойчивым маркерам до общих правил.
|
||||
if (mb_stripos($segment, 'Ханты') !== false && mb_stripos($segment, 'Мансийск') !== false) {
|
||||
return 'Ханты-Мансийский автономный округ — Югра';
|
||||
}
|
||||
|
||||
return isset(self::nameToCode()[$name]) ? $name : null;
|
||||
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. */
|
||||
|
||||
@@ -108,3 +108,17 @@ it('normalizes real Россвязь region formats to subject_code and fills re
|
||||
->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);
|
||||
});
|
||||
|
||||
@@ -59,3 +59,44 @@ it('exposes the canonical name via canonicalRegionName', function (): void {
|
||||
->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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user