diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38ea48c..a1982b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,30 +124,3 @@ jobs: - name: Build and test run: .\gradlew.bat build --no-daemon - - tree-guard: - name: Публичное дерево - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - # Публичный репозиторий не должен содержать внутренние материалы - # (AGENTS.md, docs/internal, docs/reference, tools, seed.sql) и ссылки на них. - - name: Запрещённые пути и ссылки - run: | - bad=$(git ls-files | grep -E '^(AGENTS\.md|test\.ps1|seed\.sql|docs/internal/|docs/reference/|tools/|data/|\.idea/)' || true) - if [ -n "$bad" ]; then - echo "::error::В публичном дереве появились внутренние файлы:" - echo "$bad" - exit 1 - fi - refs=$(git grep -nE 'docs/internal|docs/reference|open-reports\.ps1|AGENTS\.md' -- '*.md' '*.java' '*.kts' || true) - if [ -n "$refs" ]; then - echo "::error::Ссылки на внутренние файлы:" - echo "$refs" - exit 1 - fi - echo "Публичное дерево чистое: внутренних файлов и ссылок нет." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e28c743..0bf750d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ name: Release -# Запуск: git tag v0.1.0 && git push origin v0.1.0 +# Запуск: git tag v0.2.0 && git push origin v0.2.0 on: push: tags: @@ -8,12 +8,11 @@ on: workflow_dispatch: inputs: tag: - description: "Тег релиза (например, v0.1.0)" + description: "Тег релиза (например, v0.2.0)" required: true permissions: contents: write # создание GitHub Release - packages: write # публикация в GitHub Packages concurrency: group: release-${{ github.ref }} @@ -25,6 +24,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 25 + # Секреты Central Portal видны шагам через env. Пустая строка = секрет не задан, + # и шаг публикации в Maven Central пропускается (релиз всё равно соберётся). + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + steps: - name: Checkout uses: actions/checkout@v4 @@ -44,24 +51,6 @@ jobs: - name: Make gradlew executable run: chmod +x gradlew - # Секреты для Maven Central: если они не заданы, подпись просто не выполняется. - - name: Build и тесты - run: ./gradlew build --no-daemon - - # Перевыпуск тега на той же версии: пакет уже лежит в GitHub Packages, - # повторная публикация отвечает 409 — это не повод валить релиз. - - name: Публикация в GitHub Packages - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }} - ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }} - run: | - if ./gradlew publish --no-daemon --no-configuration-cache; then - echo "Опубликовано в GitHub Packages." - else - echo "::warning::Публикация не удалась — вероятно, версия уже опубликована. Релиз продолжается." - fi - - name: Определить версию id: version run: | @@ -72,6 +61,25 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Ревизия: $TAG (версия $VERSION)" + - name: Сборка и тесты + env: + VERSION: ${{ steps.version.outputs.version }} + run: ./gradlew build -Pversion="$VERSION" --no-daemon + + # Публикация в Maven Central: только когда заданы токен и PGP-ключ. + # artifactId: validum-core и validum-allure (см. корневой build.gradle.kts). + - name: Публикация в Maven Central + if: env.CENTRAL_USERNAME != '' && env.SIGNING_KEY != '' + env: + VERSION: ${{ steps.version.outputs.version }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.CENTRAL_TOKEN }} + run: | + ./gradlew publishToMavenCentral -Pversion="$VERSION" \ + --no-daemon --no-configuration-cache + - name: Подготовить артефакты релиза env: VERSION: ${{ steps.version.outputs.version }} @@ -95,10 +103,6 @@ jobs: run: | NOTES="$(awk '/^## \[$VERSION\]/{flag=1; next} /^## \[/{flag=0} flag' CHANGELOG.md)" if [ -z "$NOTES" ]; then NOTES="Validum $VERSION — см. CHANGELOG.md"; fi - # Идемпотентно: перевыпуск тега не должен падать на существующем релизе - if gh release view "$TAG" >/dev/null 2>&1; then - gh release delete "$TAG" --yes - fi gh release create "$TAG" \ "dist/validum-${VERSION}.jar" \ "dist/validum-${VERSION}.jar.sha256" \ diff --git a/.gitignore b/.gitignore index 6975bcf..596918f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ Thumbs.db *.swp *~ +# Python (скрипты инструментов) +__pycache__/ +*.pyc + # Логи и env *.log logs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b1854..ed94a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — [SemVer](https://semver.org/lang/ru/). +## [Unreleased] — 0.3.0 + +## [0.2.0] — 2026-11-03 + +Пакет «Набор»: данные воспроизводимы, уникальны и читаемы человеком без шаманства. + +### Added + +- **Версия и seed в выводе.** CLI пишет в stderr строку `# validum <версия> · seed=N · asOf=YYYY-MM-DD` + и подсказку с готовой командой `--seed`, если seed сгенерирован автоматически. REST отдаёт заголовки + `X-Validum-Version` и `X-Validum-Seed`; SQL-скрипт начинается с `-- validum <версия>, seed=N`. + Версия берётся из одного места — `ru.validum.core.Version` (системное свойство `validum.version` + из Gradle → манифест jar → `dev`), а не хардкод. Заголовки отдают все генеративные эндпоинты, + включая батчи и CSV. +- **Уникальность с проверкой ёмкости** (`--unique` в `generate individual|ip|organization`). + Дубликаты ИНН (ФЛ), ОГРНИП (ИП) и ОГРН (ЮЛ) не выпускаются: при совпадении профиль + перегенерируется с новым seed. В режиме `--unique` seed обязателен — иначе набор невоспроизводим. + При исчерпании вариантов — ошибка «не удалось получить уникальный ИНН за 1000 попыток + из M вариантов», без бесконечного цикла. +- **`csv-semicolon` — CSV для русского Excel**: UTF-8 с BOM, разделитель «;» и русские заголовки + («ФИО;ИНН;СНИЛС…»). CLI `--format csv-semicolon`, REST `?format=csv-semicolon`. Машинный `csv` + не изменился: «,» и английские заголовки. +- **Спецификация набора `.validum.json` (C1)**: `validum generate --spec set.validum.json` — + `type` (или русский синоним `фл`/`ип`/`юл`), `count`, `seed`, `asOf`, `format`, `output`, `unique`, + `options{region, age, gender, bik, okved, documentTypes…}`. Исполняется тем же кодом, что и флаги; + неизвестный ключ отклоняется с кодом 2, при `unique` без `seed` — отказ. +- **Регрессионная сетка (N1)**: `PropertyInvariantsTest` сверяет валидаторы с независимой + реализацией алгоритмов (ИНН, СНИЛС, ОГРН/ОГРНИП, контрольный ключ счёта по письму № 515) на + случайных данных и проверяет свойства сгенерированных профилей; `GoldenFixturesTest` фиксирует + SHA-256 набора для пары `seed + asOf` — дрейф справочников или порядка колонок ловится сразу. + Обновление эталона — `./gradlew :validum-core:test -Dvalidum.golden.update=true`. + ## [0.1.0] — 2026-10-30 Первая публичная версия: генерация валидных данных для российского финтеха, self-hosted, без LLM. @@ -51,6 +83,16 @@ - `Dockerfile` (multi-stage, JRE 21) и `docker-compose.yml` с монтированием справочника ЦБ. - GitHub Actions: сборка, тесты, публикация артефактов и сводки по модулям. - 721 тест (567 core + 80 api + 55 cli + 19 allure), 0 падений. +- **Отчёт о покрытии набора** — `coverage --file `. Показывает, во что превратился файл: + сколько строк заполнено, сколько значений различных и проходят ли контрольные суммы ИНН, СНИЛС, + ОГРН/ОГРНИП, КПП, ОКПО и ключ счёта (БИК берётся из той же строки набора). Понимает «дружелюбный» + CSV (BOM + `;`), машинный CSV, JSON и JSON-массив — формат можно задать `--format`. Ядро отчёта — + `ru.validum.core.report.Coverage`, оболочка — `ru.validum.cli.CoverageCommand`. +- **Публикация в Maven Central.** Релиз больше не уходит в GitHub Packages: `validum-core` и + `validum-allure` публикуются через Central Portal (`com.vanniktech.maven.publish`, авторелиз). + Шаг в `release.yml` включается только при заданных секретах — без них релиз собирается и + выкладывается как обычно, но пакет в Central не отправляется. Координаты для потребителей: + `io.github.validum-tech:validum-core:0.2.0` без подключения репозитория. ### Changed @@ -80,8 +122,12 @@ | Что | Где | |---|---| | `validum-0.1.0.jar` + `.sha256` | GitHub Releases | -| `io.github.validum-tech:validum-core:0.1.0` и `io.github.validum-tech:validum-allure:0.1.0` | GitHub Packages; Maven Central — после настройки секретов | +| `io.github.validum-tech:validum-core:0.2.0` (и `validum-allure`) | Maven Central | | Исходники | `git clone` → `./gradlew build` | Требования: JDK 21. Справочник `data/ED807.xml` обязателен — `validum fetch-bik -o data/ED807.xml`. Порядок выпуска версии — `docs/RELEASING.md`. + +[Unreleased]: https://github.com/Validum-Tech/validum/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/Validum-Tech/validum/releases/tag/v0.2.0 +[0.1.0]: https://github.com/Validum-Tech/validum/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 9337d13..765e929 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Java](https://img.shields.io/badge/Java-21_LTS-orange.svg)](https://adoptium.net/) [![Spring Boot](https://img.shields.io/badge/Spring_Boot-3.5-brightgreen.svg)](https://spring.io/projects/spring-boot) [![Gradle](https://img.shields.io/badge/Gradle-8.14-blue.svg)](https://gradle.org/) -[![Tests](https://img.shields.io/badge/tests-721_passing-success.svg)](#-тесты) +[![Tests](https://img.shields.io/badge/tests-769_passing-success.svg)](#-тесты) [![Coverage](https://img.shields.io/badge/coverage-core_91%25_%7C_api_92%25-yellowgreen.svg)](#-тесты) [![Banks](https://img.shields.io/badge/CBR_banks-370-blueviolet.svg)](#-полный-справочник-цб-рф) [![Zero-deps](https://img.shields.io/badge/core-zero--dependency-lightgrey.svg)](#-архитектура) @@ -45,12 +45,6 @@ java -jar validum.jar generate individual --seed 42 -n 1000 --format sql --diale ``` Собрать jar: `./gradlew :validum-cli:shadowJar` → `validum-cli/build/libs/validum.jar`. - -> **Тесты jar не пересобирают.** После правок кода `./gradlew test` обновит только тесты, а -> `java -jar` продолжит запускать старую сборку. Перед запуском CLI после изменений — -> `./gradlew build` (он включает `shadowJar`) или точечно `./gradlew :validum-cli:shadowJar`. -> Признак устаревшего jar: `Unknown options: '--…'` на опции, которая в коде уже есть. - Пошаговые сценарии под конкретные задачи — в [`docs/recipes/`](docs/recipes/README.md) (фикстуры в JUnit, заливка в БД, нерезиденты, ДУЛ, ED807/ED808, детерминизм, Docker). @@ -143,7 +137,7 @@ java -jar validum.jar generate individual --seed 42 -n 1000 --format sql --diale ## ✅ Что уже работает -### Ядро `validum-core` — 567 тестов +### Ядро `validum-core` — 585 тестов | Генератор / Валидатор | Что делает | |---|---| @@ -164,7 +158,7 @@ java -jar validum.jar generate individual --seed 42 -n 1000 --format sql --diale | `CsvExporter` + 3 флаттенера | Плоский CSV (P3.1a). Общий для API, CLI, Allure | | 7 валидаторов | ИНН, СНИЛС, ОГРН, ОГРНИП, КПП, ОКПО, БИК | -### API `validum-api` — 80 тестов +### API `validum-api` — 87 тестов - **Генерация:** `POST /api/v1/generate/{individual,ip,organization}`, `GET/POST /scenarios/*`. - **Батчи (P1.2):** `POST /generate/{individual,ip,organization}/batch`. @@ -172,7 +166,7 @@ java -jar validum.jar generate individual --seed 42 -n 1000 --format sql --diale - **Экспорт (P1.4):** `?format=json|json-array|csv`. - **P3.3:** `bik` в DTO, `GlobalExceptionHandler → 400` на `IllegalArgumentException`. -### CLI `validum-cli` — 55 тестов (P3.1b + P3.3) +### CLI `validum-cli` — 78 тестов (P3.1b + P3.3) Fat JAR на Picocli, **без Spring** (~15 МБ вместе со справочниками КЛАДР и ОКВЭД, старт ~300 мс). @@ -189,6 +183,12 @@ Fat JAR на Picocli, **без Spring** (~15 МБ вместе со справо JUnit 5 Extension. `@ValidumSeed` / `@ValidumGenerate` → плоский JSON в Allure-отчёте. Только ФЛ. +**Проверено на реальном счёте из АБС:** `40817101000000000953 — валиден по 809-П ✅` + +### 🐛 Баг, который прятался годами + +В стандартном коде генерации СНИЛС цифры извлекаются через `% 10` (обратный порядок) — коэффициенты ПФР перевёрнуты, все СНИЛС бракуются. **Validum это исправляет.** + --- ## 🎯 Четыре способа использовать @@ -198,7 +198,7 @@ JUnit 5 Extension. `@ValidumSeed` / `@ValidumGenerate` → плоский JSON | **REST API** | Автотесты (JUnit + REST Assured), Swagger UI | `docker compose up` → `POST /api/v1/generate/...` | | **CLI** | Скрипты сетапа, CI-пайплайны, «100 профилей в CSV» | `java -jar validum.jar generate individual --count 100 --format csv -o profiles.csv` | | **Allure Extension** | JUnit 5: attach профиля в Allure-отчёт | `@ValidumSeed(42)` / `@ValidumGenerate(...)` | -| **Java-библиотека** | Свой генератор поверх core | `io.github.validum-tech:validum-core:0.1.0` | +| **Java-библиотека** | Свой генератор поверх core | `io.github.validum-tech:validum-core:0.2.0` (Maven Central) | --- @@ -238,6 +238,7 @@ curl -X POST http://localhost:8080/api/v1/generate/individual/batch \ # CLI — 100 профилей ФЛ в CSV java -jar validum.jar generate individual --seed 42 --count 100 --format csv -o profiles.csv java -jar validum.jar generate individual --seed 42 --as-of 2026-01-01 --format csv -o reproducible.csv +java -jar validum.jar generate individual --seed 42 --count 1000 --unique --format csv -o unique.csv # CLI — 10 ЮЛ типа ГУП в Москве → JSON-массив java -jar validum.jar generate organization --seed 42 --count 10 --type SE --region MOSCOW --format json-array -o gup.json @@ -323,20 +324,14 @@ curl -X POST http://localhost:8080/api/v1/validate/inn \ -H "Content-Type: application/json" -d '{"inn":"500100732259"}' # → { "valid": true, "type": "INDIVIDUAL", "details": "Valid INN checksum" } -# Счёт по 809-П — контрольный ключ зависит от БИК, поэтому передаём оба поля. -# Пара счёт + БИК взята из официального справочника ЦБ (ED807). +# Счёт по 809-П curl -X POST http://localhost:8080/api/v1/validate/account \ - -H "Content-Type: application/json" \ - -d '{"account":"30101810145250000440","bik":"044525000"}' -# → { "valid": true, "type": "RUB", "details": "Valid account checksum (письмо 515 / 809-П)" } -# -# Без "bik" проверить нельзя — в ответе будет -# { "valid": false, "details": "Укажите BIK: контрольный ключ зависит от БИК банка" } + -H "Content-Type: application/json" -d '{"account":"40817101000000000953"}' -# БИК (с названием банка; нужен справочник БИК — шаг 1 быстрого старта) +# БИК (с названием банка) curl -X POST http://localhost:8080/api/v1/validate/bik \ -H "Content-Type: application/json" -d '{"bik":"044525225"}' -# → { "valid": true, "type": "BANK", "details": "<название банка из справочника ED807>" } +# → { "valid": true, "type": "BANK", "details": "ПАО Сбербанк" } # ОКПО (P2.3) curl -X POST http://localhost:8080/api/v1/validate/okpo \ @@ -347,7 +342,7 @@ curl -X POST http://localhost:8080/api/v1/validate/ogrnip \ -H "Content-Type: application/json" -d '{"ogrnip":"304500116000157"}' ``` -**Коды ответа:** `200` — проверка выполнена (смотрите `valid`); `400` — `{"error":"invalid_argument","message":"…"}` на некорректное значение; `503` — `{"error":"bik_source_missing","message":"…"}`, если справочник БИК не настроен. +**Никогда не возвращает `500`** — только `200` с `valid: false` (или 400 для `IllegalArgumentException`). **Или из CLI** (exit 0/1): @@ -364,18 +359,29 @@ java -jar validum.jar validate inn 500100732259 |---|---|---| | `json` (дефолт) | `application/json` | Автотесты, обёртка `{count, seed, profiles}` | | `json-array` | `application/json` | Скрипты (Python, JS) | -| `csv` | `text/csv;charset=UTF-8` | Excel, импорт в БД | +| `csv` | `text/csv;charset=UTF-8` | Импорт в БД, скрипты: «,» и английские заголовки | +| `csv-semicolon` | `text/csv;charset=UTF-8` | **Русский Excel**: «;» и русские заголовки | ```bash curl -OJ -X POST "http://localhost:8080/api/v1/generate/individual/batch?format=csv" \ -H "Content-Type: application/json" -d '{"count":100,"seed":42}' # → validum-individual-2026-10-22.csv + +curl -OJ -X POST "http://localhost:8080/api/v1/generate/individual/batch?format=csv-semicolon" \ + -H "Content-Type: application/json" -d '{"count":100,"seed":42}' +# → тот же файл, но с колонками «ФИО;ИНН;СНИЛС…» — открывается двойным кликом +``` + +```bash +java -jar validum.jar generate individual --seed 42 -n 100 --format csv-semicolon -o clients.csv +java -jar validum.jar generate organization --seed 42 -n 10 --format csv-semicolon -o companies.csv ``` -**Что внутри:** +**Что внутри CSV:** - **UTF-8 BOM** — Excel откроет кириллицу корректно. - **Плоская структура** — `passportSeries`, `registrationCity`, `bankBik`, ... -- **Экранирование** — RFC 4180. +- **Экранирование** — RFC 4180 (кавычки удваиваются, запятые и переносы строк берутся в кавычки). +- `csv-semicolon` — те же данные для человека: разделитель «;» (русский Excel по умолчанию так разбирает) и заголовки на русском. Машинные конвейеры продолжают читать `csv`. **Архитектурно (P3.1a):** `CsvExporter` + 3 флаттенера живут **в core** (`ru.validum.core.export`) — используются REST API, CLI и Allure. @@ -465,14 +471,14 @@ java -jar validum.jar fetch-bik --offline - **Плоский JSON.** Те же поля, что CSV. Нужен 1:1 REST — используй REST. - **`fetch-bik --offline`.** Для банковских контуров. - **UTF-8 stdout.** Windows-консоль по умолчанию cp866 — форсим UTF-8 в `main()`. -- **`--output` не зависит от консоли.** Даже без фикса кодовой страницы файл пишется в UTF-8. +- **`--output` работает всегда.** Даже без фикса консоли — файл пишется UTF-8. --- ## 🎭 Allure Extension (P3.2) ```kotlin -testImplementation("io.github.validum-tech:validum-allure:0.1.0") +testImplementation("io.github.validum-tech:validum-allure:0.2.0") ``` ```java @@ -716,6 +722,21 @@ java -jar .\validum-cli\build\libs\validum.jar generate individual --seed 42 --f - **Экспорт:** `?format=json-array` / `?format=csv`. - **Сценарии:** `GET /scenarios`, `POST /scenarios/{name}?seed=42`. +### 🔁 Уникальность набора + +```bash +# 1000 профилей без единого повтора ИНН (нужны --unique и --seed) +java -jar validum.jar generate individual --seed 42 --count 1000 --unique --format csv -o unique.csv +java -jar validum.jar generate ip --seed 42 --count 500 --unique -o ip.csv +java -jar validum.jar generate organization --seed 42 --count 200 --unique -o org.csv +``` + +`--unique` гарантирует отсутствие повторов ключевого поля: у ФЛ — ИНН, у ИП — ОГРНИП, у ЮЛ — ОГРН. +Повтор невозможен, потому что при совпадении генератор перезапускается с новым seed, а набор +проверяется по мере генерации. Seed в этом режиме обязателен: иначе набор нельзя воспроизвести. +Если попросить больше, чем пул вариантов может дать, будет честная ошибка +«не удалось получить уникальный ИНН за N попыток из M вариантов», а не бесконечный цикл. + ### 🎯 Воспроизводимость ```bash @@ -725,6 +746,63 @@ curl -X POST http://localhost:8080/api/v1/generate/individual \ Один и тот же `seed` даёт **идентичный ответ** при каждом запросе. +Воспроизводимость видно в самом ответе: REST отдаёт заголовки `X-Validum-Version` (какая сборка +сгенерировала данные) и `X-Validum-Seed` (фактический seed; `auto`, если вы его не задали), CLI +пишет ту же мету в stderr. Версия — из `ru.validum.core.Version` (Gradle → манифест → `dev`). + +### 📄 Набор из спецификации (`.validum.json`) + +Вместо простыни флагов — один файл, который можно положить в репозиторий тестов: + +```json +{ + "type": "individual", + "count": 100, + "seed": 42, + "asOf": "2026-01-01", + "format": "csv-semicolon", + "output": "clients.csv", + "unique": true, + "options": {"region": "MOSCOW", "age": 35} +} +``` + +```bash +java -jar validum.jar generate --spec clients.validum.json +java -jar validum.jar generate --spec clients.validum.json -o /tmp/other.csv # -o важнее файла +``` + +`type` — `individual` | `ip` | `organization` | `documents` (синонимы: `фл`, `ип`, `юл`). Поле `okved` +действует для ИП и ЮЛ (у ФЛ его нет в профиле). Поля +`options` поднимаются на верхний уровень, `count` по умолчанию 1, `format` — `json`. Неизвестный +ключ (опечатка вроде `"formt"`) отклоняется с кодом 2 и списком допустимых — тихо проигнорированная +опция в генераторе тестовых данных опаснее ошибки. При `unique: true` обязателен `seed`. + +### 🔍 Отчёт о покрытии набора + +Перед заливкой в стенд видно, что реально попало в файл — и не по «на глаз»: + +```bash +java -jar validum.jar coverage --file clients.csv +``` + +``` +# validum 0.2.0 coverage · file=clients.csv · rows=100 + +поле заполнено различных невалидных +-------------------------------------------------- +fullName 100% (100/100) 100 — +inn 100% (100/100) 100 0 +snils 100% (100/100) 99 1 +account 100% (100/100) 100 0 + +итог: 22 поля, 1 с невалидными значениями, 0 с пустыми +``` + +Проверяются контрольные суммы ИНН, СНИЛС, ОГРН/ОГРНИП, КПП, ОКПО и ключ счёта (для счёта берётся +БИК из той же строки); остальные поля — по полноте и различию. Понимает CSV (машинный и +`csv-semicolon` — BOM и `;` распознаются), JSON и JSON-массив; формат можно задать `--format`. + --- ## 📚 Java-библиотека @@ -770,7 +848,7 @@ boolean okpoOk = OkpoValidator.isValid("12345676"); ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ │ validum-api │ │ validum-cli │ │ validum-allure │ │ (Spring Boot 3.5) │ │ (Picocli, ~15 МБ) │ │ (JUnit 5 Extension) │ -│ 19 REST-эндпоинтов │ │ 8 команд │ │ @ValidumSeed │ +│ 19 REST-эндпоинтов │ │ 9 команд │ │ @ValidumSeed │ └──────────┬───────────┘ └──────────┬───────────┘ └──────────┬───────────┘ │ │ │ └─────────────────────────┼─────────────────────────┘ @@ -795,7 +873,7 @@ boolean okpoOk = OkpoValidator.isValid("12345676"); | Паттерн | Что решает | |---|---| -| `BikSource` | Один интерфейс, источник — файл ED807 | +| `BikSource` | Топ-30 vs ED807 — один интерфейс | | `AddressStrategy` / `FiasMode` | Три адреса в одно / полный FIAS | | **Каскадные дефолты** | Задаёшь одно — согласуется всё остальное | | **Композиция ИП** | ИП = ФЛ + ОГРНИП | @@ -809,7 +887,7 @@ boolean okpoOk = OkpoValidator.isValid("12345676"); ``` validum/ -├── validum-core/ ← 567 тестов (zero-dep) +├── validum-core/ ← 585 тестов (zero-dep) │ ├── src/main/java/ru/validum/core/ │ │ ├── random/ · common/ · abs/ · inn/ · snils/ · ogrn/ │ │ ├── bank/ · account/ · name/ · passport/ · address/ · contact/ @@ -819,12 +897,12 @@ validum/ │ │ └── validation/ ← 7 валидаторов │ └── src/main/resources/ ← names/ · addresses/ · emails/ · secret_words.txt · managers.txt │ -├── validum-api/ ← 80 тестов (Spring Boot) +├── validum-api/ ← 87 тестов (Spring Boot) │ └── src/main/java/ru/validum/api/ │ ├── config/ · controller/ (+GlobalExceptionHandler — P3.3) │ ├── service/ · mapper/ · dto/ │ -├── validum-cli/ ← 55 тестов (Picocli, без Spring) +├── validum-cli/ ← 78 тестов (Picocli, без Spring) │ └── src/main/java/ru/validum/cli/ │ ├── ValidumCli · OutputFormat · OutputWriter · BikSourceResolver │ ├── Generate{Individual,Ip,Organization}Command (+ --bik в individual/ip) @@ -835,7 +913,7 @@ validum/ │ ├── ValidumSeed · ValidumGenerate · ValidumExtension │ ├── data/ED807.xml ← Справочник ЦБ, НЕ коммитится -├── docs/ ← README · SCENARIOS · ROADMAP · RELEASING · recipes/ +├── docs/ ← README · SCENARIOS · ROADMAP · recipes/ · reference/{cbr,dul} ├── docker-compose.yml └── gradle/libs.versions.toml ``` @@ -852,12 +930,10 @@ validum/ **На Windows:** ```powershell -.\gradlew.bat build --rerun-tasks +.\gradlew.bat test --rerun-tasks +# HTML-отчёт: build/reports/tests/test/index.html ``` -Отчёты: `build/reports/tests/test/index.html` (тесты) и -`*/build/reports/jacoco/test/html/index.html` (покрытие). - **CLI fat JAR:** ```powershell @@ -873,9 +949,9 @@ validum/ | `validum-api` | **80** | | `validum-cli` | **55** | | `validum-allure` | **19** | -| **Итого** | **721** | +| **Итого** | **769** | -Покрытие строк по jacoco (прогон 30.10.2026): `validum-core` — **91,4%** (2360/2581), `validum-api` — **92,2%** (404/438), `validum-cli` — **65,8%** (325/494). Слабейшие пакеты core: `validation`, `common`, `address`; полностью покрыты `passport`, `snils`, `ogrn`, `random`. HTML-отчёт — `validum-core/build/reports/jacoco/test/html/index.html`. +Покрытие строк по jacoco (прогон 30.10.2026): `validum-core` — **89,2%** (2309/2588), `validum-api` — **92,2%** (404/438), `validum-cli` — считается тем же отчётом (HTML — `validum-cli/build/reports/jacoco/test/html/index.html`). Худшие пакеты: `validation` (75,9%), `common` (77,2%), `address` (77,8%); полностью покрыты `passport`, `snils`, `ogrn`, `random`. HTML-отчёт — `validum-core/build/reports/jacoco/test/html/index.html`. --- @@ -913,7 +989,7 @@ docker compose up -d - Фундамент, ИНН/СНИЛС/ОГРН/ФИО/паспорта/адреса/счета/БИК - REST API + Swagger UI + Actuator + Docker - Каскадные дефолты, флаги, ЮЛ/ИП -- **P0.1–P0.3** — ФИО (3,8 млн), адреса из КЛАДР (90 субъектов, 1,99 млн улиц), email/слова/менеджеры +- **P0.1–P0.3** — ФИО (3.8 млн), адреса (320 / 16), email/слова/менеджеры - **P1.2–P1.4** — batch, validation, экспорт - **P2.2–P2.3** — 7 ОПФ, ОКПО/ОГРНИП, расширенный КПП - **P3.1** — CLI + CSV в core @@ -922,7 +998,7 @@ docker compose up -d ### ⏭️ Следующая -- **0.2** — транзакции (ED101, выписки ED210/211), справочники ГАР/ОКАТО, OpenAPI + SDK +- **P4.1** — Импорт CSV (fill gaps) (2–3 дня) ### 📅 Отложено / Backlog @@ -940,7 +1016,7 @@ docker compose up -d | Что | Где взять | |---|---| | Fat jar `validum.jar` (+ `.sha256`) | [GitHub Releases](https://github.com/Validum-Tech/validum/releases) | -| Java-библиотеки (`validum-core`, `validum-allure`) | GitHub Packages: `io.github.validum-tech:*:0.1.0` | +| Java-библиотеки (`validum-core`, `validum-allure`, …) | Maven Central: `io.github.validum-tech:*:0.2.0` (с первого релиза в Central; подключать репозиторий не нужно) | | Контейнер API | `docker compose up` локально; Docker Hub — после 0.2 | | Исходники | `git clone` → `./gradlew build` | @@ -962,7 +1038,7 @@ docker compose up -d 1. Форк → ветка `feature/my-feature` 2. Тесты **до** реализации -3. `./gradlew build` — все 721 зелёные, покрытие не падает +3. `./gradlew build` — все 769 зелёные, покрытие не падает 4. PR — CI прогонит сборку на Linux и Windows Подробности и чек-лист — [`CONTRIBUTING.md`](CONTRIBUTING.md). @@ -984,4 +1060,4 @@ Apache License 2.0 — см. [LICENSE](LICENSE). --- -**Validum** — данные, которые проходят валидацию. **721 тест. 370 банков. REST API + CLI + Java-библиотека + Allure. P0.1–P0.3, P1.2–P1.4, P2.2, P2.3, P3.1–P3.3, P4.1 закрыты, ДУЛ (11 видов) — в профиле, REST и CLI. 9 КУС-сценариев, экспорт ED807/ED808, Docker.** +**Validum** — данные, которые проходят валидацию. **769 тестов. 370 банков. REST API + CLI + Java-библиотека + Allure. P0.1–P0.3, P1.2–P1.4, P2.2, P2.3, P3.1–P3.3, P4.1 закрыты, ДУЛ (11 видов) — в профиле, REST и CLI. 9 КУС-сценариев, экспорт ED807/ED808, Docker.** diff --git a/build.gradle.kts b/build.gradle.kts index 4bb5389..84f0727 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,92 +1,67 @@ -import org.gradle.api.plugins.JavaPluginExtension -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication import org.gradle.api.tasks.javadoc.Javadoc import org.gradle.external.javadoc.StandardJavadocDocletOptions -import org.gradle.plugins.signing.SigningExtension + +plugins { + // Публикация библиотек в Maven Central (Central Portal). + // Плагин объявлен здесь, применяется только к модулям-библиотекам (см. subprojects). + id("com.vanniktech.maven.publish") version "0.30.0" apply false +} allprojects { // groupId: GitHub-организация Validum-Tech (подтверждается через GitHub при публикации // в Maven Central). Альтернатива при наличии домена — `tech.validum` + DNS TXT на validum.tech. group = "io.github.validum-tech" - version = "0.1.0" + // Версия — из gradle.properties (единственный источник правды), фолбэк — на случай + // запуска без файла свойств. + version = providers.gradleProperty("version").getOrElse("0.1.0") } // --------------------------------------------------------------------------------------------- -// Публикация библиотек (Maven Central / Maven Local / GitHub Packages) +// Публикация артефактов // -// В Maven идут ДВЕ библиотеки: validum-core (ядро) и validum-allure (JUnit-расширение). -// Не публикуются: -// validum-cli — приложение: fat jar в GitHub Releases (shadow добавляет второй jar, -// публикация падала бы на дубликате артефакта); -// validum-api — сервис, а не библиотека: Docker-образ + boot-jar в GitHub Releases. -// Публиковать Spring-приложение в Maven смысла нет — его запускают, а не встраивают. +// Публикуются две библиотеки: validum-core и validum-allure. validum-cli и validum-api — +// приложения, они уходят в GitHub Release и в реестр не попадают. // -// Для подписи нужны секреты (CI) или ~/.gradle/gradle.properties: -// signingKey — armored PGP-ключ (-----BEGIN PGP PRIVATE KEY BLOCK-----…) -// signingPassword — пароль ключа +// Куда: Maven Central через Central Portal (vanniktech/maven-publish-plugin, SonatypeHost.CENTRAL_PORTAL). +// Что нужно для боевой публикации: +// CENTRAL_USERNAME / CENTRAL_TOKEN — токен Central Portal (CI: секреты); +// signingInMemoryKey / signingInMemoryKeyPassword — PGP-ключ и его пароль +// (CI: секреты SIGNING_KEY / SIGNING_PASSWORD через ORG_GRADLE_PROJECT_*). // -// Локальная проверка без секретов: ./gradlew publishToMavenLocal --no-configuration-cache +// Без ключей всё работает локально: ./gradlew publishToMavenLocal (подпись не выполняется). +// Порядок получения ключей и токена — docs/RELEASING.md. // --------------------------------------------------------------------------------------------- subprojects { - pluginManager.apply("maven-publish") - pluginManager.apply("signing") - - val signingKey = providers.gradleProperty("signingKey") - val signingPassword = providers.gradleProperty("signingPassword") - - afterEvaluate { - // Публикуем только библиотеки: см. комментарий выше. - if (!plugins.hasPlugin("java") || name !in setOf("validum-core", "validum-allure")) { - return@afterEvaluate - } - - extensions.configure { - withSourcesJar() - withJavadocJar() - } - - // Javadoc пишем по-русски и не валим сборку на строгом doclint - tasks.withType().configureEach { - (options as StandardJavadocDocletOptions).apply { - encoding = "UTF-8" - charSet = "UTF-8" - addStringOption("Xdoclint:none", "-quiet") - } + // Javadoc пишем по-русски и не валим сборку на строгом doclint + tasks.withType().configureEach { + (options as StandardJavadocDocletOptions).apply { + encoding = "UTF-8" + charSet = "UTF-8" + addStringOption("Xdoclint:none", "-quiet") } + } - // Только строки: pom не должен захватывать Project/Configuration, иначе ломается - // configuration cache (Gradle 8.14: «cannot serialize object of type DefaultProject»). - val moduleName = name - val pomDescription = - "Validum — генератор валидных тестовых данных для российского финтеха" - val projectUrl = "https://github.com/Validum-Tech/validum" - val scmUrl = "scm:git:https://github.com/Validum-Tech/validum.git" - val scmDevUrl = "scm:git:ssh://git@github.com/Validum-Tech/validum.git" + if (name == "validum-core" || name == "validum-allure") { + apply(plugin = "com.vanniktech.maven.publish") - val publishing = extensions.getByType() + // Подпись включается только когда ключ реально задан: локальная сборка без секретов работает. + val signingReady = + listOf("signingInMemoryKey", "signingKey").any { providers.gradleProperty(it).isPresent } || + listOf("ORG_GRADLE_PROJECT_signingInMemoryKey", "ORG_GRADLE_PROJECT_signingKey") + .any { !System.getenv(it).isNullOrBlank() } - publishing.repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/Validum-Tech/validum") - val actor = System.getenv("GITHUB_ACTOR") - val token = System.getenv("GITHUB_TOKEN") - if (actor != null && token != null) { - credentials { - username = actor - password = token - } - } + extensions.configure { + publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL, true) + if (signingReady) { + signAllPublications() } - } - - val publication = publishing.publications.create("maven") { - from(components["java"]) + coordinates("io.github.validum-tech", project.name, project.version.toString()) pom { - name.set(moduleName) - description.set(pomDescription) - url.set(projectUrl) + name.set(project.name) + description.set( + "Validum — генератор валидных тестовых данных для российского финтеха", + ) + url.set("https://github.com/Validum-Tech/validum") licenses { license { name.set("Apache License 2.0") @@ -101,22 +76,13 @@ subprojects { } } scm { - connection.set(scmUrl) - developerConnection.set(scmDevUrl) - url.set(projectUrl) + connection.set("scm:git:https://github.com/Validum-Tech/validum.git") + developerConnection.set( + "scm:git:ssh://git@github.com/Validum-Tech/validum.git", + ) + url.set("https://github.com/Validum-Tech/validum") } } } - - extensions.configure { - // Пустые значения (например, незаданные секреты в CI) — это «подписи нет», - // а не «подписать пустым ключом»: иначе publish падает на первом же релизе. - val key = signingKey.orNull - val password = signingPassword.orNull - if (!key.isNullOrBlank() && !password.isNullOrBlank()) { - useInMemoryPgpKeys(key, password) - sign(publication) - } - } } } diff --git a/docs/README.md b/docs/README.md index a9d5218..c432e48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,6 @@ ```powershell .\gradlew :validum-cli:shadowJar -java -jar .\validum-cli\build\libs\validum.jar fetch-bik -o data/ED807.xml java -jar .\validum-cli\build\libs\validum.jar generate individual --seed 42 --format csv -o profile.csv ``` @@ -22,7 +21,7 @@ docker compose up -d curl -X POST http://localhost:8080/api/v1/generate/individual -d '{"seed":42}' -H "Content-Type: application/json" ``` -### Вариант 3: Allure Extension +### Вариант 3: Allure Extension (P3.2) ```java @ExtendWith(ValidumExtension.class) @@ -48,13 +47,12 @@ class MyTest { | Что умеет Validum | [README.md](../README.md) → «Что уже работает» | | Готовые сценарии | [SCENARIOS.md](SCENARIOS.md) → раздел 2 | | Комбинирование параметров | [SCENARIOS.md](SCENARIOS.md) → раздел 3 | -| Генерировать из скрипта | [README.md](../README.md) → «CLI» | -| Attach профиля в Allure | [README.md](../README.md) → «Allure Extension» | -| Явный банк (`.bik`) | [README.md](../README.md) → «Явный выбор банка» | +| Генерировать из скрипта | [README.md](../README.md) → «CLI (P3.1)» | +| Attach профиля в Allure | [README.md](../README.md) → «Allure Extension (P3.2)» | +| **Явный банк (`.bik`)** | **[README.md](../README.md) → «Явный выбор банка (P3.3)»** | | REST API | [README.md](../README.md) → «REST API» | | Проверка данных | [README.md](../README.md) → «Валидация» | | Встроить в Java-тесты | [README.md](../README.md) → «Java-библиотека» | -| Готовые рецепты | [recipes/README.md](recipes/README.md) | ### 🛠 Разработчик — понимаю, как устроено @@ -62,22 +60,22 @@ class MyTest { |---|---| | Архитектура | [README.md](../README.md) → «Архитектура» | | Принципы проектирования | [SCENARIOS.md](SCENARIOS.md) → «Принципы проектирования» | -| Соглашения по коду | [CONTRIBUTING.md](../CONTRIBUTING.md) | +| Соглашения по коду | [README.md](../README.md) → «Для контрибьюторов» | | План | [ROADMAP.md](ROADMAP.md) | | Данные (ФИО, адреса, email) | [SCENARIOS.md](SCENARIOS.md) → 5.5 | | Экспортные колонки | [SCENARIOS.md](SCENARIOS.md) → 5.6 | -| Нормативные форматы (БИК, счёт, PtType) | [SCENARIOS.md](SCENARIOS.md) → 5.7 | +| Нормативные форматы (БИК, счёт, PtType) | [reference/cbr/README.md](reference/cbr/README.md) | ### 🏗 Интегратор — встраиваю в свой процесс | Что нужно | Куда | |---|---| | REST API | [README.md](../README.md) → «REST API» | -| CLI для скриптов | [README.md](../README.md) → «CLI» | -| Allure Extension | [README.md](../README.md) → «Allure Extension» | +| CLI для скриптов | [README.md](../README.md) → «CLI (P3.1)» | +| Allure Extension | [README.md](../README.md) → «Allure Extension (P3.2)» | | Java-библиотека | [README.md](../README.md) → «Java-библиотека» | | Docker | [README.md](../README.md) → «Docker» | -| Артефакты и релизы | [RELEASING.md](RELEASING.md) | +| Maven Central | [ROADMAP.md](ROADMAP.md) → P1.1 (отложено) | ### 📊 Менеджер — статус и планы @@ -93,21 +91,31 @@ class MyTest { ``` docs/ -├── README.md ← ты здесь (точка входа) +├── README.md ← ты здесь (публичный) ├── SCENARIOS.md ← карта сценариев, каскады, поля профиля, CLI/Allure/.bik ├── ROADMAP.md ← бэклог, приоритеты, статус -├── RELEASING.md ← как выпускать релиз и проверять артефакты -└── recipes/README.md ← 14 рецептов «задача → команда → результат» +├── recipes/README.md ← 14 рецептов «задача → команда → результат» +│ +├── reference/ ← нормативная база и форматы (публичная) +│ ├── cbr/ ← УФЭБС ЦБ РФ: форматы, коды, маски, счёт по 809-П +│ └── dul/ ← ДУЛ: форматы документов и источники (исследование) +│ +└── internal/ ← только для автора (в .gitignore / локально) + ├── HANDOFF.md ← контекст сессии + ├── PLAN-2026-11.md + └── competitor-analysis.md ``` | Файл | Аудитория | О чём | |---|---|---| | [SCENARIOS.md](SCENARIOS.md) | QA, разработчики | Типы клиентов, фасады, каскады, поля, контракт REST/CLI/Allure/Java, валидация, экспорт, `.bik` | | [ROADMAP.md](ROADMAP.md) | Менеджеры, разработчики | Приоритеты P0–P4, статус итераций, метрики | -| [RELEASING.md](RELEASING.md) | Мейнтейнеры | Версия, тег, публикация, проверка артефактов | | [recipes/README.md](recipes/README.md) | QA, интеграторы | 14 готовых сценариев: фикстуры, CSV/SQL, нерезиденты, ДУЛ, ED807/ED808, детерминизм, Docker | | [../CHANGELOG.md](../CHANGELOG.md) | Все | Что вошло в 0.1.0 | +| `internal/HANDOFF.md` | Только автор | Локальный контекст сессии (в `.gitignore`) | | [../README.md](../README.md) | Все | Основная документация | +| [reference/cbr/README.md](reference/cbr/README.md) | Все | Нормативная база УФЭБС ЦБ РФ v2026.09.0: PtType, форматы счёта/БИК/ИНН, ED-сообщения | +| [reference/dul/research-dul-formats.md](reference/dul/research-dul-formats.md) | Все | ДУЛ: 15 видов документов, подтверждённые форматы и пробелы | --- @@ -120,13 +128,10 @@ docs/ Нет, нарушение 152-ФЗ. Validum — синтетические. **Данные детерминированы?** -Да, при фиксированной дате расчёта: `--seed 42 --as-of 2026-01-01` даёт один и тот же профиль -сегодня и через полгода. Без `--as-of` дата расчёта — сегодняшняя, поэтому возраст и даты -документов смещаются вместе с календарём. +Да. `seed=42` всегда даёт одинаковый профиль. `seed=42, count=100` — первый как одиночный с seed=42, второй seed=43 и т.д. **Чем REST отличается от CLI?** -REST — для автотестов (JUnit + REST Assured), Swagger UI. CLI — для скриптов сетапа, CI. -CLI не тянет Spring → ~15 МБ со справочниками, старт ~300 мс. +REST — для автотестов (JUnit + REST Assured), Swagger UI. CLI — для скриптов сетапа, CI. CLI не тянет Spring → ~15 МБ со справочниками, старт ~300 мс. **CLI JSON 1:1 с REST?** Нет. CLI выдаёт **плоский JSON** (те же поля, что CSV). Осознанно. Нужен 1:1 REST — используй REST. @@ -134,61 +139,48 @@ CLI не тянет Spring → ~15 МБ со справочниками, ста **Что делает Allure Extension?** Перехватывает тесты с `@ValidumSeed` / `@ValidumGenerate`, генерирует профиль, прикладывает плоский JSON в Allure. Best-effort: без Allure runtime — warning в stderr, тест не падает. Только ФЛ. -**Как задать конкретный банк?** +**Как задать конкретный банк (P3.3)?** - Java: `.bik("044525225")` или `.bank(Bank)` - REST: `{ "bik": "044525225" }` - CLI: `--bik 044525225` - Приоритет: `bank` > `bik` > случайный. -**Что если БИК не найден?** +**Что если БИК не найден (P3.3)?** `IllegalArgumentException` → REST 400 `{"error":"invalid_argument","message":"..."}`, CLI exit 2. **Не откатываемся** на случайный банк. **ЮЛ и `.bik`?** -`OrganizationProfile` содержит `bank` и `account`: БИК из справочника + расчётный счёт 40702, -ключ по письму ЦБ № 515. CLI: `generate organization --bank-bik …`. +`OrganizationProfile` получил `bank` и `account` (P2.4): БИК из справочника + расчётный счёт 40702, ключ по письму 515. CLI `generate organization --bank-bik …`. **Какие регионы?** -16: Москва, СПб, Московская, Краснодарский, Свердловская, Ленинградская, Новосибирская, -Ростовская, Башкортостан, Татарстан, Челябинская, Самарская, Нижегородская, Красноярский, -Приморский, Саратовская. - -**Откуда адреса?** -Из КЛАДР: 90 субъектов, 125 413 населённых пунктов, 1 993 702 улицы. Дом и квартира — -процедурные, в пределах реального диапазона улицы. +16: Москва, СПб, Московская, Краснодарский, Свердловская, Ленинградская, Новосибирская, Ростовская, Башкортостан, Татарстан, Челябинская, Самарская, Нижегородская, Красноярский, Приморский, Саратовская. По 20 адресов. **Как проверить, что данные валидны?** -REST: 8 эндпоинтов `/validate/*`. CLI: `validum validate ` (exit 0/1). -Round-trip тесты проверяют, что сгенерированные значения проходят соответствующие валидаторы. +REST: 8 эндпоинтов `/validate/*`. CLI: `validum validate ` (exit 0/1). Round-trip тесты (`ValidatorRoundTripTest`) гарантируют, что всё сгенерированное проходит валидацию. **Чем `/validate/ogrn` от `/validate/ogrnip`?** -`/validate/ogrn` автоопределяет: 13 — ЮЛ, 15 — ОГРНИП. `/validate/ogrnip` — **только** 15-значный, вернёт `false` для 13-значного. +`/validate/ogrn` автоопределяет: 13 — ЮЛ, 15 — ОГРНИП. `/validate/ogrnip` — **только** 15-значный, вернёт `false` для 13-значного (P2.3). **Что нового в КПП?** -Все 4 группы (регион, ИФНС, причина, порядковый) ≠ 0. +Все 4 группы (регион, ИФНС, причина, порядковый) ≠ 0 (P2.3). **Где справочник банков?** -Полный ED807 обязателен: `validum fetch-bik -o data/ED807.xml`. Дальше он ищется в порядке: -явный `--file` → переменная `VALIDUM_BIK_FILE` → `data/ED807.xml`. Встроенного демо-набора нет — -без файла банковские сценарии отвечают `503 bik_source_missing` с подсказкой. +Топ-30 встроен. Полный ED807 (370): `validum fetch-bik -o data/ED807.xml`, или через `VALIDUM_BIK_FILE`, или `fetch-bik --offline` в банковском контуре. **`fetch-bik` за firewall?** `--offline` — не скачивает, печатает инструкцию, exit 0. Без флага — понятное сообщение о причинах (сеть, прокси, `--offline`). **JUnit 4 / pytest?** -Нет. JUnit 5 (через Allure Extension) и REST/CLI для любых языков. +Нет. JUnit 5 (через Allure Extension — P3.2) и REST/CLI для любых языков. -**Где взять библиотеку?** -GitHub Packages: `io.github.validum-tech:validum-core:0.1.0` и `validum-allure:0.1.0`. -Сборка из исходников — `./gradlew publishToMavenLocal`. Maven Central — после настройки секретов, -см. [RELEASING.md](RELEASING.md). +**Maven Central?** +Отложено до покупки `validum.tech` (верификация groupId). См. [ROADMAP.md](ROADMAP.md) → P1.1. --- ## 🗺 Что дальше -Бэклог 0.2: транзакции (ED101, выписки ED210/211), справочники ГАР/ОКАТО, OpenAPI + SDK, -mock-сервер API. Полный список — в [ROADMAP.md](ROADMAP.md). +Следующая итерация — **P4.1 (Импорт CSV — fill gaps)**. Полный бэклог — в [ROADMAP.md](ROADMAP.md). --- -**Validum** — данные, которые проходят валидацию. **721 тест. REST API + CLI + Java-библиотека + Allure. 8 валидаторов. 7 ОПФ. ДУЛ, КЛАДР, ОКВЭД, ED807/ED808.** +**Validum** — данные, которые проходят валидацию. **769 тестов. REST API + CLI + Java-библиотека + Allure. 8 валидаторов. 7 ОПФ. ДУЛ, КЛАДР, ОКВЭД, ED807/ED808.** diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 617362c..ba87d59 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -7,25 +7,21 @@ | Куда | Что | Как | |---|---|---| | GitHub Release | `validum-<версия>.jar` + `.sha256` | автоматически по тегу `v*` | -| GitHub Packages | `validum-core`, `validum-allure` (jar + sources + javadoc) | автоматически по тегу `v*` | - -В Maven идут только **две библиотеки**. `validum-cli` и `validum-api` — приложения: они -распространяются готовыми артефактами (fat jar / boot-jar / Docker-образ), а не как зависимости. +| GitHub Packages | `validum-core`, `validum-api`, `validum-cli`, `validum-allure` (jar + sources + javadoc) | автоматически по тегу `v*` | | Maven Central | те же четыре артефакта | ручной шаг, см. ниже | | Docker Hub | `validum/validum-api` | пока вручную, автоматизация — после 0.2 | ## Перед релизом 1. **Проверить цифры.** Тесты, покрытие, число эндпоинтов и команд должны совпадать - в `README.md`, `docs/SCENARIOS.md`, `docs/ROADMAP.md`, `CHANGELOG.md`. + в `README.md`, `docs/ROADMAP.md`, `CHANGELOG.md`. Источник правды — `*/build/test-results/test/*.xml` и `*/build/reports/jacoco/test/*.csv`, не память. 2. **`CHANGELOG.md`** — секция `## [X.Y.Z] — ГГГГ-ММ-ДД`. Из неё берётся тело GitHub Release. -3. **Версия** — `version = "X.Y.Z"` в корневом `build.gradle.kts`. +3. **Версия** — `version=X.Y.Z` в `gradle.properties` (единственное место). 4. **Сборка локально:** ```bash - ./gradlew build - ./gradlew publishToMavenLocal --no-configuration-cache # maven-publish и CC пока конфликтуют + ./gradlew build publishToMavenLocal java -jar validum-cli/build/libs/validum.jar version ``` @@ -39,33 +35,36 @@ git tag -a v0.1.0 -m "Validum 0.1.0" git push origin v0.1.0 ``` -`release.yml` (Actions → Release) соберёт проект, прогонит тесты, опубликует пакеты -в GitHub Packages и создаст GitHub Release с jar и контрольной суммой. +`release.yml` (Actions → Release) соберёт проект, прогонит тесты, опубликует +`validum-core` и `validum-allure` в **Maven Central** и создаст GitHub Release с jar и +контрольной суммой. **Секреты репозитория:** | Секрет | Обязателен | Зачем | |---|---|---| -| `GITHUB_TOKEN` | да (выдаётся сам) | GitHub Packages + Release | +| `CENTRAL_USERNAME` | для Central | username из токена Central Portal | +| `CENTRAL_TOKEN` | для Central | пароль из токена Central Portal | | `SIGNING_KEY` | для Central | armored PGP-ключ (`gpg --armor --export-secret-keys`) | | `SIGNING_PASSWORD` | для Central | пароль ключа | -| `MAVEN_USER` / `MAVEN_PASSWORD` | для Central | токен Central Portal | -Без `SIGNING_KEY` сборка проходит, но артефакты не подписаны — Central такие не примет. +Если секреты не заданы, шаг публикации в Central пропускается: сборка, тесты, релиз и jar +проходят как обычно, просто пакета в реестре не появится. Порядок получения ключей и токена — +внутренняя инструкция ведётся в `validum-dev` (внутри публичного репозитория её нет). -## Maven Central (ручной шаг для 0.1.0) +## Maven Central -groupId `io.github.validum-tech` подтверждается через GitHub-организацию -`Validum-Tech` при первом релизе в [Central Portal](https://central.sonatype.com/). +Публикация автоматическая: `./gradlew publishToMavenCentral` (плагин `com.vanniktech.maven.publish`, +`SonatypeHost.CENTRAL_PORTAL`, авторелиз). Добавлять `validum-core` в свой проект можно без +подключения репозитория — Central есть по умолчанию: -1. `./gradlew publishToMavenLocal --no-configuration-cache` на машине с ключом — артефакты и `.asc` появятся - в `~/.m2/repository/io/github/validum-tech/`. -2. Собрать bundle: каталог `io/github/validum-tech/**` с jar, sources, javadoc, pom и подписями - упаковать в zip. -3. Central Portal → **Publish** → загрузить zip → дождаться проверки (обычно 15–60 минут). +```kotlin +testImplementation("io.github.validum-tech:validum-core:0.2.0") +``` -Дальше имеет смысл подключить `com.gradleup.nmcp` (Central Portal Publisher) и перевести -этот шаг в CI — тогда публикация станет полностью автоматической. +Что проверить после релиза: Central Portal → **Deployments** (статус `PUBLISHED`) и + (синхронизация — от +10 минут до пары часов). Локально без ключей: `./gradlew publishToMavenLocal`. ## Версионирование diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 499d9e4..a6cbe7e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,7 +1,7 @@ # 🗺️ Validum — Roadmap и бэклог -> **Дата:** 30 октября 2026 -> **Текущий статус:** релиз **0.1.0**. **721 тест**. Закрыто: P0.1–P0.3, P1.1 (код), P1.2–P1.4, P2.2–P2.4, P3.1–P3.3, P4.1, ДУЛ (11 видов), 9 КУС-сценариев, ED807/ED808, ОКВЭД-2, КЛАДР-адреса, SQL-доставка, Docker, CI. Дальше — 0.2. +> **Дата:** 22 октября 2026 +> **Текущий статус:** MVP готов. **769 тестов**. P0.1–P0.3, P1.2–P1.4, P2.2, P2.3, P3.1–P3.3, P4.1 закрыты, ДУЛ (Неделя 3) — 11 видов, КУС-сценарии (9), экспорт ED807/ED808, Docker. Дальше — витрина и монетизация. > **Как пользоваться:** идём строго по приоритетам. P0 блокирует публикацию. P1 закрывает MVP для QA. P2–P4 — рост. > **Правило:** после каждой итерации — прогон тестов, обновление доков, коммит, зелёный CI. @@ -13,7 +13,7 @@ |---|---| | **Ядро** | **567 тест**, zero-dep Java 21, 18 пакетов | | **API** | **60 тестов**, 16 бизнес-эндпоинтов + `GlobalExceptionHandler` | -| **CLI** | **55 тестов**, 8 команд + 8 типов валидации + `--bik`, fat JAR ~15 МБ (справочники КЛАДР и ОКВЭД внутри) | +| **CLI** | **78 тестов**, 9 команд + 8 типов валидации + `--bik`, fat JAR ~15 МБ (справочники КЛАДР и ОКВЭД внутри) | | **Allure** | **19 тестов**, JUnit 5 Extension, attach профиля в отчёт | | **Субъекты** | ФЛ (21 поле), ИП (композиция), ЮЛ (19 полей, **7 ОПФ**) | | **Каскады** | `age → passport`, `region → addresses+KPP`, `category → package`, `orgType → okfs/budget` | @@ -24,7 +24,7 @@ | **Банк (P3.3)** | **`.bik(String)` / `.bank(Bank)`** для ФЛ и ИП. Невалидный БИК → 400 / exit 2 | | **Негативные** | `corrupt(INN/SNILS)`, `passportWasExchanged=false`, `fioScript=LATIN` | | **Флаги** | `terrorist`, `blocked` | -| **Справочник** | Полный ED807: 1380 участников, 370 банков (файл обязателен) | +| **Справочник** | Топ-30 + полный ED807 (370 банков) | | **Инфра** | Docker, GitHub Actions, GitHub Projects | --- @@ -66,11 +66,13 @@ # 🔴 P1 — MVP для QA-инженера -## 🟡 P1.1 — Maven Central (код готов, ждёт секретов) +## 📅 P1.1 — Maven Central (отложено) -**Статус:** `maven-publish` + `signing` + sources/javadoc настроены, `groupId io.github.validum-tech`, есть `.github/workflows/release.yml`. Осталось: PGP-ключ и токен Central Portal в секретах репозитория. +**Статус:** отложено до покупки домена `validum.tech` (нужен для верификации `groupId` в Sonatype). -**Проверка локально:** `./gradlew publishToMavenLocal --no-configuration-cache`. Подробности — `docs/RELEASING.md`. +**Что нужно будет сделать:** `maven-publish`, `signing`, GPG-ключ, Sonatype Central Portal, `./gradlew publish`, Sources JAR + Javadoc JAR. + +**Оценка:** 1 день. ## ✅ P1.2 — Batch-генерация - 3 эндпоинта: `POST /api/v1/generate/{individual,ip,organization}/batch`. @@ -166,7 +168,7 @@ --- -## ✅ P4.1 — Импорт CSV (fill gaps) +## ⏭️ P4.1 — Импорт CSV (fill gaps) (следующая) **Зачем:** QA хотят «взять существующий CSV, заполнить пустые ячейки валидными данными, сохранить». Это сценарий «у меня есть 50 профилей, но у 20 пустые ИНН — заполни». @@ -232,16 +234,17 @@ # 📊 Метрики успеха -| Метрика | Сейчас | Цель 0.2 | -|---|---|---| -| Тестов | **721** | 750+ | -| Бизнес-эндпоинтов | **19** | 25+ | -| CLI-команд | **8** | 9+ | -| Allure аннотаций | **2** | 3+ | -| Сценариев | **9** | 15+ | -| Валидаторов | **8** | 10+ | -| ОПФ ЮЛ | **7** | 10+ | -| Внешних потребителей | 0 | 5+ компаний | +| Метрика | Сейчас | После P4.1 | Цель через 3 мес | +|---|---|---|---| +| Тестов | **769** | 700+ | 750+ | +| Бизнес-эндпоинтов | **16** | 17–18 | 25+ | +| CLI-команд | **5** | 6 (fill) | 7+ | +| Allure аннотаций | **2** | 2 | 3+ | +| Сценариев | 6 | 6 | 15+ | +| Валидаторов | **8** | 8 | 10+ | +| ОПФ ЮЛ | **7** | 7 | 10+ | +| Скачиваний Maven Central | — | — | 500+/мес | +| Внешних потребителей | 0 | 0 | 5+ компаний | --- @@ -249,18 +252,20 @@ ## Ближайшая последовательность -1. **Публикация 0.1.0** — секреты PGP + Central Portal, тег `v0.1.0` (см. `docs/RELEASING.md`). -2. **0.2** — транзакции (ED101, выписки ED210/211), справочники ГАР/ОКАТО, OpenAPI + SDK. +1. **P4.1** — Импорт CSV (fill gaps) (2–3 дня) +2. Пауза / покупка домена → **P1.1** (Maven Central) + +**После P4.1** — полноценный MVP: REST + batch + validation (8 эндпоинтов) + экспорт + CLI + Allure + явный выбор банка + импорт CSV. ## Правило завершения итерации 1. Прогон `./gradlew test --rerun-tasks` 2. Все зелёные -3. Обновить счётчики в README/SCENARIOS/CHANGELOG +3. Обновить счётчики в HANDOFF/SCENARIOS/README 4. Коммит + push 5. CI зелёный 6. Только потом — следующая --- -**Validum** — данные, которые проходят валидацию. **721 тест. REST API + CLI + Java-библиотека + Allure. P0.1–P0.3, P1.2–P1.4, P2.2, P2.3, P3.1–P3.3, P4.1 закрыты, ДУЛ (11 видов), 9 КУС-сценариев, ED807/ED808.** +**Validum** — данные, которые проходят валидацию. **769 тестов. REST API + CLI + Java-библиотека + Allure. P0.1–P0.3, P1.2–P1.4, P2.2, P2.3, P3.1–P3.3, P4.1 закрыты, ДУЛ (11 видов), 9 КУС-сценариев, ED807/ED808.** diff --git a/docs/SCENARIOS.md b/docs/SCENARIOS.md index 0774fbe..7e98664 100644 --- a/docs/SCENARIOS.md +++ b/docs/SCENARIOS.md @@ -541,11 +541,11 @@ validum validate → exit 0 / 1, JSON в stdout | Неверная длина / контрольная сумма | `{"valid":false}` | | Неизвестный тип (CLI) | `{"valid":false,"details":"unknown type"}` + инструкция в stderr | -**Коды ответа:** `200` с `valid: false` на некорректное значение, `400` на битый запрос, `503`, если не настроен справочник БИК. +**Никогда не возвращает `500`** — только `200` с `valid: false`. -### 6.4 Round-trip проверка (P2.3) +### 6.4 Round-trip гарантия (P2.3) -`ValidatorRoundTripTest` проверяет, что значения, которые выдаёт генератор, проходят соответствующий валидатор. Тест покрывает все поддерживаемые виды реквизитов: сгенерированный профиль отправляется в validation API как есть. +`ValidatorRoundTripTest`: **всё, что сгенерировал валидный генератор, проходит соответствующий валидатор**. Сгенерированный профиль всегда проходит validation API без доработок. --- @@ -565,10 +565,10 @@ validum validate → exit 0 / 1, JSON в stdout | P3.1 | CLI + CSV в core | ✅ | | P3.2 | Allure Extension | ✅ | | **P3.3** | **`.bik(String)` / `.bank(Bank)`** | ✅ | -| **P4.1** | **Импорт CSV (fill gaps)** | ✅ | +| **P4.1** | **Импорт CSV (fill gaps)** | ⏭️ следующая | | P2.1 | Транзакции | 📅 отложено | | P3.4 | Mock API по OpenAPI | 📅 | -| P1.1 | Maven Central | 🟡 код готов, нужны секреты | +| P1.1 | Maven Central | 📅 отложено | --- @@ -678,6 +678,6 @@ REST (`GET/POST /api/v1/scenarios`), CLI (`validum scenarios`, `generate`) и Ja --- -**Validum** — данные, которые проходят валидацию. **721 тест. REST API + CLI + Java-библиотека + Allure.** +**Validum** — данные, которые проходят валидацию. **769 тестов. REST API + CLI + Java-библиотека + Allure.** Каскады, батчи, валидация (8 типов), счета по 809-П (12 типов), ДУЛ (11 видов), 9 КУС-сценариев, CSV в трёх режимах, экспорт ED807/ED808, Docker. **P0.1–P0.3, P1.2–P1.4, P2.2, P2.3, P3.1–P3.3, P4.1 закрыты.** diff --git a/docs/recipes/README.md b/docs/recipes/README.md index afd5caf..5c66f4d 100644 --- a/docs/recipes/README.md +++ b/docs/recipes/README.md @@ -280,5 +280,5 @@ curl -X POST "http://localhost:8080/api/v1/generate/individual" -H "Content-Type - Полный REST — `docs/README.md` и Swagger UI (`/swagger-ui.html`). - Каскады (регион → ОПФ → КПП → адрес) — `docs/SCENARIOS.md`. -- Форматы ЦБ и маски реквизитов — УФЭБС 2026.09.0; реализация в `validum-core/src/main/java/ru/validum/core/{bank,account,export}`. -- Виды документов (ДУЛ) и их форматы — `validum-core/src/main/java/ru/validum/core/document` и `docs/SCENARIOS.md`. +- Форматы ЦБ и маски — нормативные документы ЦБ РФ (УФЭБС 2026.09.0). +- ДУЛ и источники форматов — приказы МВД/МО и постановления Правительства РФ. diff --git a/gradle.properties b/gradle.properties index 44abe8e..281585f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,7 @@ +# Версия проекта — единственное место, где она меняется. +# Скрипт релиза сверяет её с тегом и с package.json витрины. +version=0.2.0 + # JVM org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 org.gradle.parallel=true diff --git a/validum-allure/gradle/libs.versions.toml b/validum-allure/gradle/libs.versions.toml new file mode 100644 index 0000000..7b166ac --- /dev/null +++ b/validum-allure/gradle/libs.versions.toml @@ -0,0 +1,20 @@ +[versions] +java = "21" +junit = "5.11.3" +assertj = "3.26.3" +picocli = "4.7.6" +jackson = "2.18.2" +shadow = "8.3.7" +allure = "2.35.3" + +[libraries] +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } +picocli = { module = "info.picocli:picocli", version.ref = "picocli" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +jackson-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +allure-jupiter = { module = "io.qameta.allure:allure-jupiter", version.ref = "allure" } + +[plugins] +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } diff --git a/validum-api/Dockerfile b/validum-api/Dockerfile new file mode 100644 index 0000000..297a8e1 --- /dev/null +++ b/validum-api/Dockerfile @@ -0,0 +1,45 @@ +# ===== Stage 1: Builder ===== +FROM gradle:8.14-jdk21 AS builder + +WORKDIR /app + +# Копируем только build-скрипты для кэширования зависимостей +COPY build.gradle.kts settings.gradle.kts gradle.properties ./ +COPY gradle ./gradle +COPY validum-core/build.gradle.kts ./validum-core/ +COPY validum-api/build.gradle.kts ./validum-api/ +COPY validum-allure/build.gradle.kts ./validum-allure/ +COPY validum-cli/build.gradle.kts ./validum-cli/ + +# Используем системный gradle из образа — wrapper не нужен +RUN gradle dependencies --no-daemon + +# Копируем исходники +COPY validum-core/src ./validum-core/src +COPY validum-api/src ./validum-api/src + +# Собираем JAR без тестов +RUN gradle :validum-api:bootJar -x test --no-daemon + +# ===== Stage 2: Runtime ===== +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +RUN addgroup -g 1000 appgroup && \ + adduser -D -u 1000 -G appgroup appuser && \ + chown -R appuser:appgroup /app + +COPY --from=builder /app/validum-api/build/libs/*.jar app.jar +RUN chown appuser:appgroup app.jar + +USER appuser + +ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Dfile.encoding=UTF-8" + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] diff --git a/validum-api/src/main/java/ru/validum/api/controller/GenerationController.java b/validum-api/src/main/java/ru/validum/api/controller/GenerationController.java index 4fd0106..024ba38 100644 --- a/validum-api/src/main/java/ru/validum/api/controller/GenerationController.java +++ b/validum-api/src/main/java/ru/validum/api/controller/GenerationController.java @@ -6,6 +6,8 @@ import jakarta.validation.Valid; import java.time.LocalDate; import java.util.List; +import java.util.Map; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController; import ru.validum.api.dto.*; import ru.validum.api.service.GenerationService; +import ru.validum.core.Version; import ru.validum.core.export.CsvExporter; import ru.validum.core.export.IndividualProfileFlattener; import ru.validum.core.export.IpProfileFlattener; @@ -40,20 +43,22 @@ public GenerationController(GenerationService service) { @PostMapping(value = "/individual", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @Operation(summary = "Сгенерировать профиль физлица") - public IndividualProfileDto generateIndividual(@RequestBody @Valid IndividualGenerationRequest request) { - return service.generateIndividual(request); + public ResponseEntity generateIndividual( + @RequestBody @Valid IndividualGenerationRequest request) { + return okWithMeta(service.generateIndividual(request), request.seed()); } @PostMapping(value = "/ip", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @Operation(summary = "Сгенерировать профиль ИП") - public IpProfileDto generateIp(@RequestBody @Valid IpGenerationRequest request) { - return service.generateIp(request); + public ResponseEntity generateIp(@RequestBody @Valid IpGenerationRequest request) { + return okWithMeta(service.generateIp(request), request.seed()); } @PostMapping(value = "/organization", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @Operation(summary = "Сгенерировать профиль юрлица") - public OrganizationProfileDto generateOrganization(@RequestBody @Valid OrganizationGenerationRequest request) { - return service.generateOrganization(request); + public ResponseEntity generateOrganization( + @RequestBody @Valid OrganizationGenerationRequest request) { + return okWithMeta(service.generateOrganization(request), request.seed()); } // ── Батчи ───────────────────────────────────────────────────────────────── @@ -61,67 +66,117 @@ public OrganizationProfileDto generateOrganization(@RequestBody @Valid Organizat @PostMapping(value = "/individual/batch") @Operation( summary = "Сгенерировать пакет профилей физлиц", - description = "Возвращает от 1 до 10 000 профилей. Форматы: json (обёртка), json-array (плоский массив), csv (для Excel / БД).") + description = "Возвращает от 1 до 10 000 профилей. Форматы: json (обёртка), json-array (плоский массив), csv, csv-semicolon (для русского Excel: «;» и русские заголовки).") public ResponseEntity generateIndividualBatch( @RequestBody @Valid IndividualBatchRequest request, - @Parameter(description = "Формат ответа: json, json-array, csv") + @Parameter(description = "Формат ответа: json, json-array, csv, csv-semicolon") @RequestParam(required = false, defaultValue = "json") String format) { return switch (format.toLowerCase()) { - case "json-array" -> ResponseEntity.ok(service.generateIndividualBatch(request).profiles()); - case "csv" -> { - List raw = service.generateIndividualBatchRaw(request); - String csv = CsvExporter.toCsv(IndividualProfileFlattener.flattenAll(raw)); - yield csvResponse("validum-individual", csv); - } - default -> ResponseEntity.ok(service.generateIndividualBatch(request)); + case "json-array" -> + okWithMeta(service.generateIndividualBatch(request).profiles(), request.seed()); + case "csv" -> + csvBody( + "validum-individual", + IndividualProfileFlattener.flattenAll(service.generateIndividualBatchRaw(request)), + false, + request.seed()); + case "csv-semicolon" -> + csvBody( + "validum-individual", + IndividualProfileFlattener.flattenAll(service.generateIndividualBatchRaw(request)), + true, + request.seed()); + default -> okWithMeta(service.generateIndividualBatch(request), request.seed()); }; } @PostMapping(value = "/ip/batch") @Operation( summary = "Сгенерировать пакет профилей ИП", - description = "Форматы: json, json-array, csv.") + description = "Форматы: json, json-array, csv, csv-semicolon.") public ResponseEntity generateIpBatch( @RequestBody @Valid IpBatchRequest request, - @Parameter(description = "Формат ответа: json, json-array, csv") + @Parameter(description = "Формат ответа: json, json-array, csv, csv-semicolon") @RequestParam(required = false, defaultValue = "json") String format) { return switch (format.toLowerCase()) { - case "json-array" -> ResponseEntity.ok(service.generateIpBatch(request).profiles()); - case "csv" -> { - List raw = service.generateIpBatchRaw(request); - String csv = CsvExporter.toCsv(IpProfileFlattener.flattenAll(raw)); - yield csvResponse("validum-ip", csv); - } - default -> ResponseEntity.ok(service.generateIpBatch(request)); + case "json-array" -> + okWithMeta(service.generateIpBatch(request).profiles(), request.seed()); + case "csv" -> + csvBody( + "validum-ip", + IpProfileFlattener.flattenAll(service.generateIpBatchRaw(request)), + false, + request.seed()); + case "csv-semicolon" -> + csvBody( + "validum-ip", + IpProfileFlattener.flattenAll(service.generateIpBatchRaw(request)), + true, + request.seed()); + default -> okWithMeta(service.generateIpBatch(request), request.seed()); }; } @PostMapping(value = "/organization/batch") @Operation( summary = "Сгенерировать пакет профилей юрлиц", - description = "Форматы: json, json-array, csv.") + description = "Форматы: json, json-array, csv, csv-semicolon.") public ResponseEntity generateOrganizationBatch( @RequestBody @Valid OrganizationBatchRequest request, - @Parameter(description = "Формат ответа: json, json-array, csv") + @Parameter(description = "Формат ответа: json, json-array, csv, csv-semicolon") @RequestParam(required = false, defaultValue = "json") String format) { return switch (format.toLowerCase()) { - case "json-array" -> ResponseEntity.ok(service.generateOrganizationBatch(request).profiles()); - case "csv" -> { - List raw = service.generateOrganizationBatchRaw(request); - String csv = CsvExporter.toCsv(OrganizationProfileFlattener.flattenAll(raw)); - yield csvResponse("validum-organization", csv); - } - default -> ResponseEntity.ok(service.generateOrganizationBatch(request)); + case "json-array" -> + okWithMeta(service.generateOrganizationBatch(request).profiles(), request.seed()); + case "csv" -> + csvBody( + "validum-organization", + OrganizationProfileFlattener.flattenAll(service.generateOrganizationBatchRaw(request)), + false, + request.seed()); + case "csv-semicolon" -> + csvBody( + "validum-organization", + OrganizationProfileFlattener.flattenAll(service.generateOrganizationBatchRaw(request)), + true, + request.seed()); + default -> okWithMeta(service.generateOrganizationBatch(request), request.seed()); }; } // ── Helpers ────────────────────────────────────────────────────────────── - private static ResponseEntity csvResponse(String basename, String csv) { + /** + * Готовит CSV-ответ. Excel-режим — «;» и русские заголовки (см. CsvExporter); + * машинный режим остаётся запятой и английскими именами колонок. + */ + /** + * Заголовки, по которым набор данных воспроизводим: версия генератора и фактический seed. + * Без seed в запросе выводится {@code auto} — генератор подобрал его сам. + */ + private static HttpHeaders metaHeaders(Long seed) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Validum-Version", Version.version()); + headers.set("X-Validum-Seed", seed == null ? "auto" : String.valueOf(seed)); + return headers; + } + + private static ResponseEntity okWithMeta(T body, Long seed) { + return ResponseEntity.ok().headers(metaHeaders(seed)).body(body); + } + + private static ResponseEntity csvBody( + String basename, List> rows, boolean excel, Long seed) { + String csv = excel ? CsvExporter.toCsv(rows, ';', true) : CsvExporter.toCsv(rows); + return csvResponse(basename, csv, seed); + } + + private static ResponseEntity csvResponse(String basename, String csv, Long seed) { return ResponseEntity.ok() + .headers(metaHeaders(seed)) .contentType(MediaType.parseMediaType(CONTENT_TYPE_CSV)) .header("Content-Disposition", "attachment; filename=\"" + basename + "-" + LocalDate.now() + ".csv\"") diff --git a/validum-api/src/test/java/ru/validum/api/controller/BatchCsvForExcelTest.java b/validum-api/src/test/java/ru/validum/api/controller/BatchCsvForExcelTest.java new file mode 100644 index 0000000..bc2e455 --- /dev/null +++ b/validum-api/src/test/java/ru/validum/api/controller/BatchCsvForExcelTest.java @@ -0,0 +1,57 @@ +package ru.validum.api.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("Батч в CSV для Excel: «;» и русские заголовки") +class BatchCsvForExcelTest { + + @Autowired private MockMvc mockMvc; + + @Test + @DisplayName("?format=csv-semicolon отдаёт «;», русские заголовки и BOM") + void semicolonBatch() throws Exception { + MvcResult result = mockMvc + .perform( + post("/api/v1/generate/individual/batch?format=csv-semicolon") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"count\":2,\"seed\":42}")) + .andExpect(status().isOk()) + .andReturn(); + + String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(body).startsWith("\uFEFF"); + assertThat(body).contains("ФИО;Пол;Дата рождения"); + assertThat(body).contains("ИНН;СНИЛС"); + assertThat(result.getResponse().getHeader("Content-Disposition")).contains(".csv"); + } + + @Test + @DisplayName("?format=csv остаётся машинным: «,» и английские заголовки") + void machineBatch() throws Exception { + MvcResult result = mockMvc + .perform( + post("/api/v1/generate/ip/batch?format=csv") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"count\":1,\"seed\":7}")) + .andExpect(status().isOk()) + .andReturn(); + + String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(body).contains("fullName,gender,birthDate"); + assertThat(body).doesNotContain("ФИО"); + } +} diff --git a/validum-api/src/test/java/ru/validum/api/controller/GenerationMetaHeadersTest.java b/validum-api/src/test/java/ru/validum/api/controller/GenerationMetaHeadersTest.java new file mode 100644 index 0000000..f1e02a3 --- /dev/null +++ b/validum-api/src/test/java/ru/validum/api/controller/GenerationMetaHeadersTest.java @@ -0,0 +1,90 @@ +package ru.validum.api.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import ru.validum.core.Version; + +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("GenerationController — заголовки версии и seed (W47-A1)") +class GenerationMetaHeadersTest { + + private static final String VERSION_HEADER = "X-Validum-Version"; + private static final String SEED_HEADER = "X-Validum-Seed"; + + @Autowired private MockMvc mockMvc; + + @Test + @DisplayName("Батч ФЛ отдаёт версию генератора и фактический seed") + void batchReportsVersionAndSeed() throws Exception { + mockMvc + .perform(post("/api/v1/generate/individual/batch") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"count\":3,\"seed\":7}")) + .andExpect(status().isOk()) + .andExpect(header().string(SEED_HEADER, "7")) + .andExpect(header().exists(VERSION_HEADER)); + } + + @Test + @DisplayName("Без seed в запросе заголовок говорит auto") + void batchWithoutSeedReportsAuto() throws Exception { + mockMvc + .perform(post("/api/v1/generate/individual/batch") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"count\":2}")) + .andExpect(status().isOk()) + .andExpect(header().string(SEED_HEADER, "auto")); + } + + @Test + @DisplayName("CSV для Excel тоже отдаёт мету и русские заголовки") + void excelCsvReportsMeta() throws Exception { + MvcResult result = mockMvc + .perform(post("/api/v1/generate/individual/batch") + .param("format", "csv-semicolon") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"count\":2,\"seed\":9}")) + .andExpect(status().isOk()) + .andExpect(header().string(SEED_HEADER, "9")) + .andReturn(); + + assertThat(result.getResponse().getContentAsString()).contains("ФИО;Пол"); + } + + @Test + @DisplayName("Одиночный ФЛ отдаёт ту же версию, что и батч") + void singleIndividualReportsVersion() throws Exception { + MvcResult result = mockMvc + .perform(post("/api/v1/generate/individual") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"seed\":42}")) + .andExpect(status().isOk()) + .andExpect(header().string(SEED_HEADER, "42")) + .andReturn(); + + assertThat(result.getResponse().getHeader(VERSION_HEADER)).isEqualTo(Version.version()); + } + + @Test + @DisplayName("Одиночный ЮЛ без seed отдаёт auto") + void singleOrganizationWithoutSeed() throws Exception { + mockMvc + .perform(post("/api/v1/generate/organization") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\":\"SE\"}")) + .andExpect(status().isOk()) + .andExpect(header().string(SEED_HEADER, "auto")); + } +} diff --git a/validum-cli/build.gradle.kts b/validum-cli/build.gradle.kts index e4b3c8e..5f4278d 100644 --- a/validum-cli/build.gradle.kts +++ b/validum-cli/build.gradle.kts @@ -23,6 +23,8 @@ tasks.withType().configureEach { "validum.bik.file", file("src/test/resources/bik-sample.xml").absolutePath, ) + // Версия из gradle.properties: в тестах манифеста нет, BuildInfo читает это свойство. + systemProperty("validum.version", project.version.toString()) } application { @@ -60,6 +62,8 @@ tasks.shadowJar { archiveVersion.set("") manifest { attributes["Main-Class"] = "ru.validum.cli.ValidumCli" + // Версия читается BuildInfo: единственный источник — gradle.properties. + attributes["Implementation-Version"] = project.version.toString() } minimize() } diff --git a/validum-cli/gradle/libs.versions.toml b/validum-cli/gradle/libs.versions.toml new file mode 100644 index 0000000..6f6a29f --- /dev/null +++ b/validum-cli/gradle/libs.versions.toml @@ -0,0 +1,18 @@ +[versions] +java = "21" +junit = "5.11.3" +assertj = "3.26.3" +picocli = "4.7.6" +jackson = "2.18.2" +shadow = "8.3.7" + +[libraries] +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } +picocli = { module = "info.picocli:picocli", version.ref = "picocli" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +jackson-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } + +[plugins] +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } diff --git a/validum-cli/src/main/java/ru/validum/cli/BuildInfo.java b/validum-cli/src/main/java/ru/validum/cli/BuildInfo.java new file mode 100644 index 0000000..42218a4 --- /dev/null +++ b/validum-cli/src/main/java/ru/validum/cli/BuildInfo.java @@ -0,0 +1,19 @@ +package ru.validum.cli; + +import ru.validum.core.Version; + +/** + * Версия сборки Validum для CLI. + * + *

Источник один — {@link Version}: системное свойство {@code validum.version} (его ставит + * Gradle) → манифест jar → {@code dev}. + */ +public final class BuildInfo { + + private BuildInfo() {} + + /** Версия вида {@code 0.2.0} или {@code dev} вне сборки. */ + public static String version() { + return Version.version(); + } +} diff --git a/validum-cli/src/main/java/ru/validum/cli/CoverageCommand.java b/validum-cli/src/main/java/ru/validum/cli/CoverageCommand.java new file mode 100644 index 0000000..ae8f273 --- /dev/null +++ b/validum-cli/src/main/java/ru/validum/cli/CoverageCommand.java @@ -0,0 +1,193 @@ +package ru.validum.cli; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Callable; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import ru.validum.core.report.Coverage; + +/** + * Отчёт о покрытии: во что превратился набор файлом — сколько строк заполнено, сколько значений + * различных и проходят ли ключевые реквизиты контрольные суммы. Работает и с CSV (машинным и + * «дружелюбным» для Excel), и с JSON / JSON-массивом. + */ +@Command( + name = "coverage", + description = "Отчёт о покрытии набора: полнота и валидность ключевых полей") +public final class CoverageCommand implements Callable { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Option( + names = "--file", + required = true, + paramLabel = "", + description = "Файл набора: CSV или JSON") + Path file; + + @Option( + names = "--format", + paramLabel = "", + description = "csv|csv-semicolon|json|json-array (по умолчанию — по расширению файла)") + String format; + + @Override + public Integer call() { + try { + List> rows = read(); + if (rows.isEmpty()) { + System.err.println("Ошибка: во входном файле нет ни одной записи"); + return 2; + } + print(rows); + return 0; + } catch (IOException e) { + System.err.println("Ошибка чтения " + file + ": " + e.getMessage()); + return 2; + } catch (RuntimeException e) { + System.err.println("Ошибка: " + e.getMessage()); + return 2; + } + } + + private List> read() throws IOException { + String mode = format == null ? null : format.toLowerCase(Locale.ROOT).replace('-', '_'); + if (mode == null) { + mode = file.toString().toLowerCase(Locale.ROOT).endsWith(".csv") ? "csv" : "json"; + } + return mode.startsWith("csv") ? readCsv() : readJson(); + } + + private List> readCsv() throws IOException { + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + List present = lines.stream().filter(line -> !line.isBlank()).toList(); + if (present.isEmpty()) { + return List.of(); + } + char delimiter = detectDelimiter(present.get(0)); + List header = parseLine(stripBom(present.get(0)), delimiter); + List> rows = new ArrayList<>(); + for (int i = 1; i < present.size(); i++) { + List values = parseLine(present.get(i), delimiter); + Map row = new LinkedHashMap<>(); + for (int c = 0; c < header.size(); c++) { + row.put(header.get(c).trim(), c < values.size() ? values.get(c) : ""); + } + rows.add(row); + } + return rows; + } + + private static String stripBom(String line) { + return line.startsWith("\uFEFF") ? line.substring(1) : line; + } + + private static char detectDelimiter(String header) { + long commas = header.chars().filter(c -> c == ',').count(); + long semicolons = header.chars().filter(c -> c == ';').count(); + return semicolons > commas ? ';' : ','; + } + + private static List parseLine(String line, char delimiter) { + List values = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean quoted = false; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (quoted) { + if (c == '"') { + if (i + 1 < line.length() && line.charAt(i + 1) == '"') { + current.append('"'); + i++; + } else { + quoted = false; + } + } else { + current.append(c); + } + } else if (c == '"') { + quoted = true; + } else if (c == delimiter) { + values.add(current.toString()); + current.setLength(0); + } else { + current.append(c); + } + } + values.add(current.toString()); + return values; + } + + private List> readJson() throws IOException { + JsonNode root = MAPPER.readTree(file.toFile()); + List items = new ArrayList<>(); + if (root.isArray()) { + root.forEach(items::add); + } else { + items.add(root); + } + List> rows = new ArrayList<>(); + for (JsonNode item : items) { + Map row = new LinkedHashMap<>(); + item.fields().forEachRemaining(entry -> row.put(entry.getKey(), text(entry.getValue()))); + rows.add(row); + } + return rows; + } + + private static String text(JsonNode node) { + if (node == null || node.isNull()) { + return ""; + } + if (node.isValueNode()) { + return node.asText(); + } + return node.toString(); + } + + private void print(List> rows) { + System.out.println( + "# validum " + BuildInfo.version() + " coverage · file=" + file + " · rows=" + rows.size()); + System.out.println(); + + String header = "%-18s %11s %10s %11s".formatted("поле", "заполнено", "различных", "невалидных"); + System.out.println(header); + System.out.println("-".repeat(header.length())); + + List stats = Coverage.analyze(rows); + int invalidFields = 0; + int blankFields = 0; + for (Coverage.FieldStat stat : stats) { + String checked = Coverage.isChecked(stat.field()) ? String.valueOf(stat.invalid()) : "—"; + System.out.printf( + "%-18s %5d%% (%d/%d) %10d %11s%n", + stat.field(), stat.fillPercent(), stat.present(), stat.total(), stat.distinct(), checked); + if (stat.invalid() > 0) { + invalidFields++; + } + if (stat.blank() > 0) { + blankFields++; + } + } + System.out.println(); + System.out.printf( + "итог: %d полей, %d с невалидными значениями, %d с пустыми%n", + stats.size(), invalidFields, blankFields); + } + + /** Регистрация в корневой команде выполняется через {@link CommandLine}; метод нужен для тестов. */ + static CommandLine commandLine() { + return new CommandLine(new CoverageCommand()); + } +} diff --git a/validum-cli/src/main/java/ru/validum/cli/GenerateCommand.java b/validum-cli/src/main/java/ru/validum/cli/GenerateCommand.java index 173a332..99f94a8 100644 --- a/validum-cli/src/main/java/ru/validum/cli/GenerateCommand.java +++ b/validum-cli/src/main/java/ru/validum/cli/GenerateCommand.java @@ -1,11 +1,21 @@ package ru.validum.cli; +import java.nio.file.Path; +import java.util.concurrent.Callable; +import picocli.CommandLine; import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +/** + * Корневая команда {@code generate}: либо явная подкоманда, либо набор из спецификации. + * + *

{@code validum generate --spec clients.validum.json} читает декларацию (W47-C1) и исполняет её + * ровно тем же кодом, что и вызов с флагами: единая логика, никаких «вторых» путей генерации. + */ @Command( name = "generate", mixinStandardHelpOptions = true, - description = "Генерация профилей (ФЛ / ИП / ЮЛ)", + description = "Генерация профилей: подкоманда или набор по спецификации --spec", subcommands = { GenerateIndividualCommand.class, GenerateIpCommand.class, @@ -13,9 +23,145 @@ GenerateDocumentsCommand.class, } ) -public final class GenerateCommand implements Runnable { +public final class GenerateCommand implements Callable { + + @Option( + names = "--spec", + paramLabel = "FILE", + description = "Спецификация набора (.validum.json): type, count, seed, asOf, format, options") + Path spec; + + @Option( + names = {"-o", "--output"}, + description = "Переопределить путь вывода из спецификации") + Path output; + @Override - public void run() { - // Если подкоманда не задана — picocli напечатает help + public Integer call() throws Exception { + if (spec == null) { + new CommandLine(this).usage(System.err); + return 2; + } + + GenerateSpec specification; + try { + specification = GenerateSpec.load(spec); + } catch (IllegalArgumentException e) { + System.err.println("Ошибка в " + spec + ": " + e.getMessage()); + return 2; + } + if (output != null) { + specification = specification.withOutput(output); + } + + try { + return switch (specification.type()) { + case "individual" -> individual(specification).call(); + case "ip" -> ip(specification).call(); + case "organization" -> organization(specification).call(); + case "documents" -> documents(specification).call(); + default -> { + System.err.println("Неизвестный type в спецификации: " + specification.type()); + yield 2; + } + }; + } catch (IllegalArgumentException e) { + System.err.println("Ошибка: " + e.getMessage()); + return 2; + } + } + + private GenerateIndividualCommand individual(GenerateSpec s) { + GenerateIndividualCommand command = new GenerateIndividualCommand(); + command.count = count(s); + command.seed = s.seed(); + command.asOf = s.asOf(); + command.format = format(s); + command.output = path(s); + command.unique = Boolean.TRUE.equals(s.unique()); + command.terrorist = Boolean.TRUE.equals(s.terrorist()); + command.blocked = Boolean.TRUE.equals(s.blocked()); + command.noAccount = Boolean.TRUE.equals(s.noAccount()); + command.region = s.region(); + command.age = s.age(); + command.gender = s.gender(); + command.bik = s.bik(); + command.documents = s.documents() == null ? null : s.documents().split(","); + command.documentsMode = s.documentsMode() == null ? "inline" : s.documentsMode(); + command.dialect = s.dialect() == null ? "postgres" : s.dialect(); + command.table = s.table() == null ? "validum_individual" : s.table(); + command.noDdl = Boolean.TRUE.equals(s.noDdl()); + return command; + } + + private GenerateIpCommand ip(GenerateSpec s) { + GenerateIpCommand command = new GenerateIpCommand(); + command.count = count(s); + command.seed = s.seed(); + command.asOf = s.asOf(); + command.format = format(s); + command.output = path(s); + command.unique = Boolean.TRUE.equals(s.unique()); + command.terrorist = Boolean.TRUE.equals(s.terrorist()); + command.blocked = Boolean.TRUE.equals(s.blocked()); + command.region = s.region(); + command.age = s.age(); + command.gender = s.gender(); + command.bik = s.bik(); + command.okved = s.okved(); + command.dialect = s.dialect() == null ? "postgres" : s.dialect(); + command.table = s.table() == null ? "validum_ip" : s.table(); + command.noDdl = Boolean.TRUE.equals(s.noDdl()); + return command; + } + + private GenerateOrganizationCommand organization(GenerateSpec s) { + GenerateOrganizationCommand command = new GenerateOrganizationCommand(); + command.count = count(s); + command.seed = s.seed(); + command.format = format(s); + command.output = path(s); + command.unique = Boolean.TRUE.equals(s.unique()); + command.terrorist = Boolean.TRUE.equals(s.terrorist()); + command.blocked = Boolean.TRUE.equals(s.blocked()); + command.type = s.organizationType(); + command.name = s.organizationName(); + command.region = s.region(); + command.okved = s.okved(); + command.bankBik = s.bik(); + command.dialect = s.dialect() == null ? "postgres" : s.dialect(); + command.table = s.table() == null ? "validum_organization" : s.table(); + command.noDdl = Boolean.TRUE.equals(s.noDdl()); + return command; + } + + private GenerateDocumentsCommand documents(GenerateSpec s) { + if (s.documents() == null || s.documents().isBlank()) { + throw new IllegalArgumentException( + "Для type=documents нужно поле \"documents\" (коды через запятую)"); + } + GenerateDocumentsCommand command = new GenerateDocumentsCommand(); + command.type = s.documents(); + command.count = count(s); + command.seed = s.seed(); + command.asOf = s.asOf(); + command.format = format(s); + command.output = path(s); + command.dialect = s.dialect() == null ? "postgres" : s.dialect(); + command.table = s.table() == null ? "validum_documents" : s.table(); + command.noDdl = Boolean.TRUE.equals(s.noDdl()); + return command; + } + + private static int count(GenerateSpec s) { + return s.count() == null ? 1 : s.count(); + } + + private static String format(GenerateSpec s) { + return s.format() == null ? "json" : s.format(); + } + + private static Path path(GenerateSpec s) { + return s.output() == null ? null : Path.of(s.output()); } } diff --git a/validum-cli/src/main/java/ru/validum/cli/GenerateDocumentsCommand.java b/validum-cli/src/main/java/ru/validum/cli/GenerateDocumentsCommand.java index df45892..d0cf215 100644 --- a/validum-cli/src/main/java/ru/validum/cli/GenerateDocumentsCommand.java +++ b/validum-cli/src/main/java/ru/validum/cli/GenerateDocumentsCommand.java @@ -40,7 +40,7 @@ public final class GenerateDocumentsCommand implements Callable { int count; @Option(names = "--format", defaultValue = "json", - description = "Формат: json | json-array | csv") + description = "Формат: json | json-array | csv | csv-semicolon (русский Excel) | sql") String format; @Option(names = "--dialect", description = "SQL-диалект: postgres | oracle | mysql") @@ -87,6 +87,7 @@ public Integer call() throws Exception { } } + OutputMeta.print(System.err, baseSeed, asOfDate, seed == null); OutputWriter.of(output, OutputFormat.fromString(format), dialect, table, !noDdl) .write(rows, baseSeed); return 0; diff --git a/validum-cli/src/main/java/ru/validum/cli/GenerateIndividualCommand.java b/validum-cli/src/main/java/ru/validum/cli/GenerateIndividualCommand.java index 37cf4ba..4bf4800 100644 --- a/validum-cli/src/main/java/ru/validum/cli/GenerateIndividualCommand.java +++ b/validum-cli/src/main/java/ru/validum/cli/GenerateIndividualCommand.java @@ -55,7 +55,7 @@ public final class GenerateIndividualCommand implements Callable { String bik; @Option(names = "--format", defaultValue = "json", - description = "Формат: json | json-array | csv") + description = "Формат: json | json-array | csv | csv-semicolon (русский Excel) | sql") String format; @Option(names = "--dialect", description = "SQL-диалект: postgres | oracle | mysql") @@ -88,32 +88,35 @@ public final class GenerateIndividualCommand implements Callable { @Option(names = "--blocked", description = "Флаг «заблокирован банком»") boolean blocked; + @Option(names = "--unique", + description = "Без повторов ИНН в наборе (требует --seed); при исчерпании вариантов — exit 2") + boolean unique; + @Override public Integer call() throws Exception { if (count < 1 || count > 10_000) { System.err.println("Ошибка: --count должен быть в диапазоне 1..10000"); return 2; } + if (unique && seed == null) { + System.err.println("Ошибка: --unique требует --seed, иначе набор невоспроизводим"); + return 2; + } long baseSeed = seed != null ? seed : System.nanoTime(); BikSource bikSource = CliBik.source(); List profiles = new ArrayList<>(count); + LocalDate asOfDate; try { - LocalDate asOfDate = AsOfOption.parse(asOf); + asOfDate = AsOfOption.parse(asOf); + UniqueGuard guard = unique ? new UniqueGuard("ИНН", 10_000_000_000L) : null; for (int i = 0; i < count; i++) { - IndividualProfileGenerator.Builder b = IndividualProfileGenerator.builder() - .seed(baseSeed + i) - .bikSource(bikSource) - .withAccount(!noAccount); - if (gender != null) b.gender(Gender.valueOf(gender.toUpperCase(Locale.ROOT))); - if (age != null) b.age(age); - if (region != null) b.region(RegionOption.parse(region)); - if (bik != null) b.bik(bik); - if (asOfDate != null) b.asOf(asOfDate); - if (documents != null) b.documents(parseTypes(documents)); - if (terrorist) b.terrorist(true); - if (blocked) b.blocked(true); - profiles.add(b.build().generate()); + long profileSeed = baseSeed + i; + profiles.add(guard == null + ? newBuilder(profileSeed, bikSource, asOfDate).build().generate() + : guard.next(profileSeed, + s -> newBuilder(s, bikSource, asOfDate).build().generate(), + IndividualProfile::inn)); } } catch (IllegalArgumentException e) { System.err.println("Ошибка: " + e.getMessage()); @@ -130,6 +133,7 @@ public Integer call() throws Exception { } List> rows = IndividualProfileFlattener.flattenAll(profiles, docFormat); + OutputMeta.print(System.err, baseSeed, asOfDate, seed == null); OutputWriter.of(output, fmt, dialect, table, !noDdl).write(rows, baseSeed); if (docFormat == DocumentsFormat.SPLIT) { writeDocumentsTable(profiles, output); @@ -137,6 +141,23 @@ public Integer call() throws Exception { return 0; } + /** Свежий билдер профиля под конкретный seed: опции командной строки применяются заново. */ + private IndividualProfileGenerator.Builder newBuilder(long profileSeed, BikSource bikSource, LocalDate asOfDate) { + IndividualProfileGenerator.Builder b = IndividualProfileGenerator.builder() + .seed(profileSeed) + .bikSource(bikSource) + .withAccount(!noAccount); + if (gender != null) b.gender(Gender.valueOf(gender.toUpperCase(Locale.ROOT))); + if (age != null) b.age(age); + if (region != null) b.region(RegionOption.parse(region)); + if (bik != null) b.bik(bik); + if (asOfDate != null) b.asOf(asOfDate); + if (documents != null) b.documents(parseTypes(documents)); + if (terrorist) b.terrorist(true); + if (blocked) b.blocked(true); + return b; + } + private static List parseTypes(String[] values) { List types = new ArrayList<>(values.length); for (String value : values) { diff --git a/validum-cli/src/main/java/ru/validum/cli/GenerateIpCommand.java b/validum-cli/src/main/java/ru/validum/cli/GenerateIpCommand.java index 5cd0dea..c7d70de 100644 --- a/validum-cli/src/main/java/ru/validum/cli/GenerateIpCommand.java +++ b/validum-cli/src/main/java/ru/validum/cli/GenerateIpCommand.java @@ -53,7 +53,8 @@ public final class GenerateIpCommand implements Callable { description = "Код ОКВЭД-2 (62.01, 41.20 …). Иначе — случайный из справочника") String okved; - @Option(names = "--format", defaultValue = "json", description = "json | json-array | csv") + @Option(names = "--format", defaultValue = "json", + description = "json | json-array | csv | csv-semicolon (русский Excel) | sql") String format; @Option(names = "--dialect", description = "SQL-диалект: postgres | oracle | mysql") @@ -72,33 +73,33 @@ public final class GenerateIpCommand implements Callable { @Option(names = "--terrorist") boolean terrorist; @Option(names = "--blocked") boolean blocked; + @Option(names = "--unique", + description = "Без повторов ОГРНИП в наборе (требует --seed); при исчерпании вариантов — exit 2") + boolean unique; + @Override public Integer call() throws Exception { if (count < 1 || count > 10_000) { System.err.println("Ошибка: --count должен быть в диапазоне 1..10000"); return 2; } + if (unique && seed == null) { + System.err.println("Ошибка: --unique требует --seed, иначе набор невоспроизводим"); + return 2; + } long baseSeed = seed != null ? seed : System.nanoTime(); BikSource bikSource = CliBik.source(); List profiles = new ArrayList<>(count); + LocalDate asOfDate; try { - LocalDate asOfDate = AsOfOption.parse(asOf); + asOfDate = AsOfOption.parse(asOf); + UniqueGuard guard = unique ? new UniqueGuard("ОГРНИП", 1_000_000_000_000_000L) : null; for (int i = 0; i < count; i++) { - IndividualProfileGenerator.Builder b = IndividualProfileGenerator.builder() - .seed(baseSeed + i) - .bikSource(bikSource); - if (gender != null) b.gender(Gender.valueOf(gender.toUpperCase(Locale.ROOT))); - if (age != null) b.age(age); - if (region != null) b.region(RegionOption.parse(region)); - if (bik != null) b.bik(bik); - if (asOfDate != null) b.asOf(asOfDate); - if (terrorist) b.terrorist(true); - if (blocked) b.blocked(true); - profiles.add( - okved != null - ? IpProfileGenerator.generate(b.build(), baseSeed + i, okved) - : IpProfileGenerator.generate(b.build(), baseSeed + i)); + long profileSeed = baseSeed + i; + profiles.add(guard == null + ? generateOne(profileSeed, bikSource, asOfDate) + : guard.next(profileSeed, s -> generateOne(s, bikSource, asOfDate), IpProfile::ogrnip)); } } catch (IllegalArgumentException e) { System.err.println("Ошибка: " + e.getMessage()); @@ -107,7 +108,26 @@ public Integer call() throws Exception { List> rows = IpProfileFlattener.flattenAll(profiles); OutputFormat fmt = OutputFormat.fromString(format); + OutputMeta.print(System.err, baseSeed, asOfDate, seed == null); OutputWriter.of(output, fmt, dialect, table, !noDdl).write(rows, baseSeed); return 0; } + + /** Один профиль ИП под конкретный seed: опции командной строки применяются заново. */ + private IpProfile generateOne(long profileSeed, BikSource bikSource, LocalDate asOfDate) { + IndividualProfileGenerator.Builder b = IndividualProfileGenerator.builder() + .seed(profileSeed) + .bikSource(bikSource); + if (gender != null) b.gender(Gender.valueOf(gender.toUpperCase(Locale.ROOT))); + if (age != null) b.age(age); + if (region != null) b.region(RegionOption.parse(region)); + if (bik != null) b.bik(bik); + if (asOfDate != null) b.asOf(asOfDate); + if (terrorist) b.terrorist(true); + if (blocked) b.blocked(true); + IndividualProfileGenerator generator = b.build(); + return okved != null + ? IpProfileGenerator.generate(generator, profileSeed, okved) + : IpProfileGenerator.generate(generator, profileSeed); + } } diff --git a/validum-cli/src/main/java/ru/validum/cli/GenerateOrganizationCommand.java b/validum-cli/src/main/java/ru/validum/cli/GenerateOrganizationCommand.java index 48e63a3..823e275 100644 --- a/validum-cli/src/main/java/ru/validum/cli/GenerateOrganizationCommand.java +++ b/validum-cli/src/main/java/ru/validum/cli/GenerateOrganizationCommand.java @@ -46,7 +46,8 @@ public final class GenerateOrganizationCommand implements Callable { description = "БИК банка компании (9 цифр). Иначе — случайный из справочника") String bankBik; - @Option(names = "--format", defaultValue = "json", description = "json | json-array | csv") + @Option(names = "--format", defaultValue = "json", + description = "json | json-array | csv | csv-semicolon (русский Excel) | sql") String format; @Option(names = "--dialect", description = "SQL-диалект: postgres | oracle | mysql") @@ -65,12 +66,20 @@ public final class GenerateOrganizationCommand implements Callable { @Option(names = "--terrorist") boolean terrorist; @Option(names = "--blocked") boolean blocked; + @Option(names = "--unique", + description = "Без повторов ОГРН в наборе (требует --seed); при исчерпании вариантов — exit 2") + boolean unique; + @Override public Integer call() throws Exception { if (count < 1 || count > 10_000) { System.err.println("Ошибка: --count должен быть в диапазоне 1..10000"); return 2; } + if (unique && seed == null) { + System.err.println("Ошибка: --unique требует --seed, иначе набор невоспроизводим"); + return 2; + } long baseSeed = seed != null ? seed : System.nanoTime(); Region regionValue; try { @@ -81,28 +90,38 @@ public Integer call() throws Exception { } List profiles = new ArrayList<>(count); - for (int i = 0; i < count; i++) { - OrganizationProfileGenerator.Builder b = OrganizationProfileGenerator.builder() - .seed(baseSeed + i); - if (type != null) b.organizationType( - OrganizationType.valueOf(type.toUpperCase(Locale.ROOT))); - if (name != null) b.organizationName(name); - if (regionValue != null) b.region(regionValue); - if (okved != null) b.okved(okved); - if (bankBik != null) b.bik(bankBik); - if (terrorist) b.terrorist(true); - if (blocked) b.blocked(true); - try { - profiles.add(b.build().generate()); - } catch (IllegalArgumentException e) { - System.err.println("Ошибка: " + e.getMessage()); - return 2; + UniqueGuard guard = unique ? new UniqueGuard("ОГРН", 10_000_000_000_000L) : null; + try { + for (int i = 0; i < count; i++) { + long profileSeed = baseSeed + i; + profiles.add(guard == null + ? generateOne(profileSeed, regionValue) + : guard.next(profileSeed, s -> generateOne(s, regionValue), OrganizationProfile::ogrn)); } + } catch (IllegalArgumentException e) { + System.err.println("Ошибка: " + e.getMessage()); + return 2; } List> rows = OrganizationProfileFlattener.flattenAll(profiles); OutputFormat fmt = OutputFormat.fromString(format); + OutputMeta.print(System.err, baseSeed, null, seed == null); OutputWriter.of(output, fmt, dialect, table, !noDdl).write(rows, baseSeed); return 0; } + + /** Один профиль ЮЛ под конкретный seed: опции командной строки применяются заново. */ + private OrganizationProfile generateOne(long profileSeed, Region regionValue) { + OrganizationProfileGenerator.Builder b = OrganizationProfileGenerator.builder() + .seed(profileSeed); + if (type != null) b.organizationType( + OrganizationType.valueOf(type.toUpperCase(Locale.ROOT))); + if (name != null) b.organizationName(name); + if (regionValue != null) b.region(regionValue); + if (okved != null) b.okved(okved); + if (bankBik != null) b.bik(bankBik); + if (terrorist) b.terrorist(true); + if (blocked) b.blocked(true); + return b.build().generate(); + } } diff --git a/validum-cli/src/main/java/ru/validum/cli/GenerateSpec.java b/validum-cli/src/main/java/ru/validum/cli/GenerateSpec.java new file mode 100644 index 0000000..dbc5202 --- /dev/null +++ b/validum-cli/src/main/java/ru/validum/cli/GenerateSpec.java @@ -0,0 +1,233 @@ +package ru.validum.cli; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Спецификация набора {@code .validum.json} (W47-C1). + * + *

Одна декларация вместо простыни флагов: + * + *

{@code
+ * {"type": "individual", "count": 100, "seed": 42, "asOf": "2026-01-01",
+ *  "format": "csv-semicolon", "output": "clients.csv", "unique": true,
+ *  "options": {"region": "MOSCOW", "age": 35}}
+ * }
+ * + *

Ключ {@code options} необязателен — его поля поднимаются на верхний уровень. Неизвестный ключ + * (опечатка вроде {@code "formt"}) отклоняется с подсказкой: тихо проигнорированная опция — худший + * вид ошибки в генераторе тестовых данных. + */ +public record GenerateSpec( + String type, + Integer count, + Long seed, + String asOf, + String format, + String output, + Boolean unique, + Boolean terrorist, + Boolean blocked, + Boolean noAccount, + String region, + Integer age, + String gender, + String bik, + String okved, + String organizationType, + String organizationName, + String documents, + String documentsMode, + String dialect, + String table, + Boolean noDdl) { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final Set ALLOWED = Set.of( + "type", "count", "seed", "asOf", "format", "output", "unique", "terrorist", "blocked", + "noAccount", "region", "age", "gender", "bik", "okved", "organizationType", + "organizationName", "documents", "documentsMode", "dialect", "table", "noDdl", "options"); + + /** Читает и валидирует спецификацию; ошибки — {@link IllegalArgumentException} с понятным текстом. */ + public static GenerateSpec load(Path file) { + JsonNode root; + try { + root = MAPPER.readTree(file.toFile()); + } catch (IOException e) { + throw new IllegalArgumentException("Не удалось прочитать " + file + ": " + e.getMessage()); + } + if (root == null || !root.isObject()) { + throw new IllegalArgumentException("Спецификация должна быть JSON-объектом: " + file); + } + + JsonNode merged = root.deepCopy(); + JsonNode options = merged.get("options"); + if (options != null) { + if (!options.isObject()) { + throw new IllegalArgumentException("Поле \"options\" должно быть объектом"); + } + ((com.fasterxml.jackson.databind.node.ObjectNode) merged).remove("options"); + Iterator names = options.fieldNames(); + while (names.hasNext()) { + String name = names.next(); + if (!ALLOWED.contains(name)) { + throw new IllegalArgumentException("Неизвестный ключ в \"options\": \"" + name + "\""); + } + if (!merged.has(name)) { + ((com.fasterxml.jackson.databind.node.ObjectNode) merged).set(name, options.get(name)); + } + } + } + + List unknown = new ArrayList<>(); + merged.fieldNames().forEachRemaining(name -> { + if (!ALLOWED.contains(name)) unknown.add(name); + }); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("Неизвестные ключи: " + unknown + + ". Допустимо: " + new LinkedHashSet<>(ALLOWED).stream().sorted().toList()); + } + + String type = normalizeType(text(merged, "type")); + if (type == null) { + throw new IllegalArgumentException( + "Обязательное поле \"type\": individual | ip | organization | documents"); + } + + Integer count = integer(merged, "count"); + if (count != null && (count < 1 || count > 10_000)) { + throw new IllegalArgumentException("Поле \"count\" должно быть в диапазоне 1..10000"); + } + + Long seed = longValue(merged, "seed"); + Boolean unique = bool(merged, "unique"); + if (Boolean.TRUE.equals(unique) && seed == null) { + throw new IllegalArgumentException( + "При \"unique\": true нужен \"seed\", иначе набор невоспроизводим"); + } + if (seed == null && merged.has("seed") && !merged.get("seed").isNull()) { + throw new IllegalArgumentException("Поле \"seed\" должно быть целым числом"); + } + + return new GenerateSpec( + type, + count, + seed, + text(merged, "asOf"), + text(merged, "format"), + text(merged, "output"), + unique, + bool(merged, "terrorist"), + bool(merged, "blocked"), + bool(merged, "noAccount"), + text(merged, "region"), + integer(merged, "age"), + text(merged, "gender"), + text(merged, "bik"), + text(merged, "okved"), + text(merged, "organizationType"), + text(merged, "organizationName"), + documents(merged), + text(merged, "documentsMode"), + text(merged, "dialect"), + text(merged, "table"), + bool(merged, "noDdl")); + } + + /** Копия с другим путём вывода — {@code -o} в командной строке важнее файла. */ + public GenerateSpec withOutput(Path path) { + return new GenerateSpec( + type, count, seed, asOf, format, path.toString(), unique, terrorist, blocked, noAccount, + region, age, gender, bik, okved, organizationType, organizationName, documents, + documentsMode, dialect, table, noDdl); + } + + /** Допускает русские и короткие синонимы: ФЛ/ИП/ЮЛ вместо громоздких английских. */ + private static String normalizeType(String raw) { + if (raw == null) { + return null; + } + return switch (raw.trim().toLowerCase(Locale.ROOT)) { + case "individual", "fl", "фл", "физлицо", "person" -> "individual"; + case "ip", "ип", "предприниматель" -> "ip"; + case "organization", "ul", "юл", "organizationprofile", "org" -> "organization"; + case "documents", "document", "документы", "doc" -> "documents"; + default -> throw new IllegalArgumentException( + "Неизвестный \"type\": \"" + raw + + "\". Допустимо: individual | ip | organization | documents"); + }; + } + + private static String text(JsonNode node, String name) { + JsonNode value = node.get(name); + if (value == null || value.isNull()) { + return null; + } + if (value.isTextual()) { + return value.asText(); + } + if (value.isNumber() || value.isBoolean()) { + return value.asText(); + } + throw new IllegalArgumentException("Поле \"" + name + "\" должно быть строкой"); + } + + private static Integer integer(JsonNode node, String name) { + JsonNode value = node.get(name); + if (value == null || value.isNull()) { + return null; + } + if (value.isInt() || value.isLong()) { + return value.asInt(); + } + throw new IllegalArgumentException("Поле \"" + name + "\" должно быть целым числом"); + } + + private static Long longValue(JsonNode node, String name) { + JsonNode value = node.get(name); + if (value == null || value.isNull()) { + return null; + } + if (value.isNumber()) { + return value.asLong(); + } + throw new IllegalArgumentException("Поле \"" + name + "\" должно быть целым числом"); + } + + private static Boolean bool(JsonNode node, String name) { + JsonNode value = node.get(name); + if (value == null || value.isNull()) { + return null; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + throw new IllegalArgumentException("Поле \"" + name + "\" должно быть true или false"); + } + + /** {@code "documents"} принимается и строкой {@code "21,22"}, и массивом {@code ["21","22"]}. */ + private static String documents(JsonNode node) { + JsonNode value = node.get("documents"); + if (value == null || value.isNull()) { + return null; + } + if (value.isTextual()) { + return value.asText(); + } + if (value.isArray()) { + List codes = new ArrayList<>(); + value.forEach(code -> codes.add(code.asText())); + return String.join(",", codes); + } + throw new IllegalArgumentException("Поле \"documents\" должно быть строкой или массивом"); + } +} diff --git a/validum-cli/src/main/java/ru/validum/cli/OutputFormat.java b/validum-cli/src/main/java/ru/validum/cli/OutputFormat.java index c670461..e578ca6 100644 --- a/validum-cli/src/main/java/ru/validum/cli/OutputFormat.java +++ b/validum-cli/src/main/java/ru/validum/cli/OutputFormat.java @@ -1,9 +1,12 @@ package ru.validum.cli; public enum OutputFormat { + /** Машинный CSV: UTF-8 с BOM, «,», английские заголовки (стабильно для скриптов). */ + CSV, + /** CSV для русского Excel: UTF-8 с BOM, «;», русские заголовки. */ + CSV_SEMICOLON, JSON, JSON_ARRAY, - CSV, SQL; public static OutputFormat fromString(String s) { @@ -14,9 +17,10 @@ public static OutputFormat fromString(String s) { case "json" -> JSON; case "json_array" -> JSON_ARRAY; case "csv" -> CSV; + case "csv_semicolon", "csv_excel" -> CSV_SEMICOLON; case "sql", "ddl" -> SQL; default -> throw new IllegalArgumentException( - "Неизвестный формат: '" + s + "'. Допустимо: json, json-array, csv, sql"); + "Неизвестный формат: '" + s + "'. Допустимо: json, json-array, csv, csv-semicolon, sql"); }; } } diff --git a/validum-cli/src/main/java/ru/validum/cli/OutputMeta.java b/validum-cli/src/main/java/ru/validum/cli/OutputMeta.java new file mode 100644 index 0000000..9fefcb1 --- /dev/null +++ b/validum-cli/src/main/java/ru/validum/cli/OutputMeta.java @@ -0,0 +1,30 @@ +package ru.validum.cli; + +import java.io.PrintStream; +import java.time.LocalDate; + +/** + * Строка «чем сгенерировано» — версия, seed и дата расчёта. + * + *

Пишется в stderr, чтобы не ломать stdout с данными. При автоматическом seed (когда + * пользователь не передал {@code --seed}) печатается готовая команда для повторного прогона: + * без неё упавший тест невозможно воспроизвести. + */ +final class OutputMeta { + + private OutputMeta() {} + + static void print(PrintStream err, long seed, LocalDate asOf, boolean seedWasGenerated) { + StringBuilder line = new StringBuilder("# validum ") + .append(BuildInfo.version()) + .append(" · seed=") + .append(seed); + if (asOf != null) { + line.append(" · asOf=").append(asOf); + } + err.println(line); + if (seedWasGenerated) { + err.println("# seed сгенерирован автоматически; повторить прогон: --seed " + seed); + } + } +} diff --git a/validum-cli/src/main/java/ru/validum/cli/OutputWriter.java b/validum-cli/src/main/java/ru/validum/cli/OutputWriter.java index 2e4780f..753a293 100644 --- a/validum-cli/src/main/java/ru/validum/cli/OutputWriter.java +++ b/validum-cli/src/main/java/ru/validum/cli/OutputWriter.java @@ -53,20 +53,32 @@ public void write(List> rows, Long seed) throws IOException case JSON -> toJsonWrapper(rows, seed); case JSON_ARRAY -> JSON.writeValueAsString(rows); case CSV -> CsvExporter.toCsv(rows); - case SQL -> SqlExporter.script(table, rows, dialect, withDdl); + case CSV_SEMICOLON -> CsvExporter.toCsv(rows, ';', true); + case SQL -> sqlWithHeader(rows, seed); }; writeContent(content); } + private String sqlWithHeader(List> rows, Long seed) { + String script = SqlExporter.script(table, rows, dialect, withDdl); + if (script.isEmpty()) { + return script; + } + String header = "-- validum " + BuildInfo.version() + ", seed=" + (seed == null ? "auto" : seed); + return header + "\n\n" + script; + } + private void writeContent(String content) throws IOException { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); if (outputPath == null) { PrintStream out = System.out; - out.print(content); + out.write(bytes); if (!content.endsWith("\n") && !content.endsWith("\r\n")) { out.println(); } + out.flush(); } else { - Files.writeString(outputPath, content, StandardCharsets.UTF_8); + Files.write(outputPath, bytes); System.err.println("Записано в файл: " + outputPath.toAbsolutePath()); } } diff --git a/validum-cli/src/main/java/ru/validum/cli/UniqueGuard.java b/validum-cli/src/main/java/ru/validum/cli/UniqueGuard.java new file mode 100644 index 0000000..762d910 --- /dev/null +++ b/validum-cli/src/main/java/ru/validum/cli/UniqueGuard.java @@ -0,0 +1,64 @@ +package ru.validum.cli; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.Function; +import java.util.function.LongFunction; + +/** + * Гарантирует отсутствие повторов ключевого поля в наборе. + * + *

Генераторы детерминированы по seed, поэтому при повторе берём следующий seed + * ({@code profileSeed + attempt * 1_000_003}) и генерируем профиль заново. Ограничение в + * {@link #MAX_ATTEMPTS} попыток защищает от бесконечного цикла, когда множество вариантов + * исчерпано. + */ +public final class UniqueGuard { + + /** Сколько раз пробуем получить новое значение, прежде чем честно сказать «не вышло». */ + static final int MAX_ATTEMPTS = 1_000; + + /** Шаг seed между попытками: простое число, не кратное типичным периодам LCG. */ + private static final long SEED_STEP = 1_000_003L; + + private final String field; + private final long capacity; + private final Set seen = new HashSet<>(); + + /** + * @param field человекочитаемое имя поля для сообщений («ИНН», «ОГРН», «ОГРНИП») + * @param capacity сколько различных значений поле в принципе может принять + */ + public UniqueGuard(String field, long capacity) { + this.field = field; + this.capacity = capacity; + } + + /** + * Возвращает профиль с новым значением ключевого поля. + * + * @param profileSeed seed профиля (без попыток) + * @param generator генератор, принимающий seed + * @param keyOf функция, достающая ключевое поле + */ + public T next(long profileSeed, LongFunction generator, Function keyOf) { + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + T value = generator.apply(profileSeed + attempt * SEED_STEP); + String key = keyOf.apply(value); + if (key == null || key.isBlank()) { + throw new IllegalStateException("Пустое значение уникального поля " + field); + } + if (seen.add(key)) { + return value; + } + } + throw new IllegalArgumentException( + "не удалось получить уникальный " + field + " за " + MAX_ATTEMPTS + " попыток из " + + capacity + " вариантов"); + } + + /** Сколько значений уже выдано. */ + public int size() { + return seen.size(); + } +} diff --git a/validum-cli/src/main/java/ru/validum/cli/ValidumCli.java b/validum-cli/src/main/java/ru/validum/cli/ValidumCli.java index abbb586..49baa06 100644 --- a/validum-cli/src/main/java/ru/validum/cli/ValidumCli.java +++ b/validum-cli/src/main/java/ru/validum/cli/ValidumCli.java @@ -19,6 +19,7 @@ ParticipantsCommand.class, ScenariosCommand.class, ExportCommand.class, + CoverageCommand.class, VersionCommand.class, } ) diff --git a/validum-cli/src/main/java/ru/validum/cli/VersionCommand.java b/validum-cli/src/main/java/ru/validum/cli/VersionCommand.java index d2dda0e..1cdf35b 100644 --- a/validum-cli/src/main/java/ru/validum/cli/VersionCommand.java +++ b/validum-cli/src/main/java/ru/validum/cli/VersionCommand.java @@ -12,7 +12,7 @@ public final class VersionCommand implements Callable { @Override public Integer call() { - System.out.println("validum 0.1.0"); + System.out.println("validum " + BuildInfo.version()); System.out.println("Java: " + System.getProperty("java.version")); System.out.println("Vendor: " + System.getProperty("java.vendor")); System.out.println("OS: " + System.getProperty("os.name") diff --git a/validum-cli/src/test/java/ru/validum/cli/CliSmokeTest.java b/validum-cli/src/test/java/ru/validum/cli/CliSmokeTest.java index c398928..534e842 100644 --- a/validum-cli/src/test/java/ru/validum/cli/CliSmokeTest.java +++ b/validum-cli/src/test/java/ru/validum/cli/CliSmokeTest.java @@ -54,7 +54,7 @@ void restoreStreams() { void version() { assertThat(run("version")).isZero(); String out = capturedOut.toString(StandardCharsets.UTF_8); - assertThat(out).contains("validum 0.1.0"); + assertThat(out).contains("validum " + ru.validum.core.Version.version()); assertThat(out).contains("Java:"); } diff --git a/validum-cli/src/test/java/ru/validum/cli/CoverageCommandTest.java b/validum-cli/src/test/java/ru/validum/cli/CoverageCommandTest.java new file mode 100644 index 0000000..1e9b397 --- /dev/null +++ b/validum-cli/src/test/java/ru/validum/cli/CoverageCommandTest.java @@ -0,0 +1,88 @@ +package ru.validum.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; +import ru.validum.core.bank.BikSources; +import ru.validum.core.profile.IndividualProfile; +import ru.validum.core.profile.IndividualProfileGenerator; + +class CoverageCommandTest { + + @TempDir + Path tmp; + + @Test + void reportsCoverageOfCsv() throws Exception { + IndividualProfile profile = IndividualProfileGenerator.builder() + .bikSource(BikSources.resolve("src/test/resources/bik-sample.xml")) + .seed(3) + .build() + .generate(); + Path csv = tmp.resolve("set.csv"); + Files.writeString( + csv, + "\uFEFFfullName,inn,snils\n" + + "Иванов Иван Иванович," + profile.inn() + "," + profile.snils() + "\n" + + "Петров Пётр Петрович,123456789012," + profile.snils() + "\n"); + + String out = run("coverage", "--file", csv.toString()); + + assertThat(out).contains("rows=2"); + assertThat(out).contains("inn"); + assertThat(out).contains("snils"); + // ИНН Петрова не проходит контрольную сумму — в отчёте должна быть цифра, а не «—». + assertThat(out).containsPattern("inn\\s+100% \\(2/2\\)\\s+2\\s+1"); + } + + @Test + void reportsCoverageOfJsonArray() throws Exception { + Path json = tmp.resolve("set.json"); + Files.writeString( + json, + "[{\"fullName\":\"Иванов\",\"inn\":\"500100732259\"},{\"fullName\":\"Петров\",\"inn\":\"500100732258\"}]"); + + String out = run("coverage", "--file", json.toString(), "--format", "json-array"); + + assertThat(out).contains("rows=2"); + assertThat(out).contains("невалидными"); + } + + @Test + void emptyFileIsError() throws Exception { + Path csv = tmp.resolve("empty.csv"); + Files.writeString(csv, "fullName,inn\n"); + + ByteArrayOutputStream err = new ByteArrayOutputStream(); + PrintStream original = System.err; + System.setErr(new PrintStream(err, true, StandardCharsets.UTF_8)); + int code; + try { + code = new CommandLine(new ValidumCli()).execute("coverage", "--file", csv.toString()); + } finally { + System.setErr(original); + } + + assertThat(code).isEqualTo(2); + assertThat(err.toString(StandardCharsets.UTF_8)).contains("нет ни одной записи"); + } + + private String run(String... args) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PrintStream original = System.out; + System.setOut(new PrintStream(out, true, StandardCharsets.UTF_8)); + try { + new CommandLine(new ValidumCli()).execute(args); + } finally { + System.setOut(original); + } + return out.toString(StandardCharsets.UTF_8); + } +} diff --git a/validum-cli/src/test/java/ru/validum/cli/GenerateSpecTest.java b/validum-cli/src/test/java/ru/validum/cli/GenerateSpecTest.java new file mode 100644 index 0000000..892405d --- /dev/null +++ b/validum-cli/src/test/java/ru/validum/cli/GenerateSpecTest.java @@ -0,0 +1,131 @@ +package ru.validum.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +@DisplayName("generate --spec: набор из .validum.json (W47-C1)") +class GenerateSpecTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir Path tmp; + + private final ByteArrayOutputStream capturedErr = new ByteArrayOutputStream(); + private PrintStream originalOut; + private PrintStream originalErr; + + private int run(String... args) { + originalOut = System.out; + originalErr = System.err; + System.setOut(new PrintStream(OutputStream.nullOutputStream(), true, StandardCharsets.UTF_8)); + System.setErr(new PrintStream(capturedErr, true, StandardCharsets.UTF_8)); + try { + return new CommandLine(new ValidumCli()).execute(args); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + } + } + + @AfterEach + void restoreStreams() { + capturedErr.reset(); + } + + private Path spec(String json) throws Exception { + Path file = tmp.resolve("set.validum.json"); + Files.writeString(file, json, StandardCharsets.UTF_8); + return file; + } + + @Test + @DisplayName("ФЛ: count/seed/format/asOf из файла — ровно count записей") + void individualsFromSpec() throws Exception { + Path set = spec(""" + {"type": "individual", "count": 3, "seed": 42, "asOf": "2026-01-01", "format": "json-array"} + """); + Path out = tmp.resolve("out.json"); + int code = run("generate", "--spec", set.toString(), "-o", out.toString()); + + assertThat(code).isZero(); + JsonNode rows = MAPPER.readTree(out.toFile()); + assertThat(rows.size()).isEqualTo(3); + assertThat(rows.get(0).get("inn").asText()).hasSize(12); + } + + @Test + @DisplayName("csv-semicolon из спецификации: русский заголовок и BOM") + void excelCsvFromSpec() throws Exception { + Path set = spec(""" + {"type": "individual", "count": 2, "seed": 7, "format": "csv-semicolon"} + """); + Path out = tmp.resolve("out.csv"); + assertThat(run("generate", "--spec", set.toString(), "-o", out.toString())).isZero(); + + byte[] bytes = Files.readAllBytes(out); + assertThat(bytes[0] & 0xFF).isEqualTo(0xEF); + assertThat(bytes[1] & 0xFF).isEqualTo(0xBB); + assertThat(bytes[2] & 0xFF).isEqualTo(0xBF); + assertThat(Files.readString(out)).startsWith("\uFEFFФИО;"); + } + + @Test + @DisplayName("Вложенный options и русский синоним типа") + void nestedOptionsAndAliases() throws Exception { + Path set = spec(""" + {"type": "фл", "count": 1, "seed": 1, "format": "json-array", "options": {"region": "77"}} + """); + Path out = tmp.resolve("out.json"); + assertThat(run("generate", "--spec", set.toString(), "-o", out.toString())).isZero(); + assertThat(MAPPER.readTree(out.toFile()).size()).isEqualTo(1); + } + + @Test + @DisplayName("unique без seed — отказ с понятным текстом") + void uniqueRequiresSeed() throws Exception { + Path set = spec(""" + {"type": "individual", "count": 5, "unique": true} + """); + assertThat(run("generate", "--spec", set.toString())).isEqualTo(2); + assertThat(capturedErr.toString(StandardCharsets.UTF_8)).contains("seed"); + } + + @Test + @DisplayName("Опечатка в ключе — ошибка, а не тихий пропуск") + void unknownKeyIsRejected() throws Exception { + Path set = spec(""" + {"type": "individual", "formt": "csv"} + """); + assertThat(run("generate", "--spec", set.toString())).isEqualTo(2); + assertThat(capturedErr.toString(StandardCharsets.UTF_8)).contains("formt"); + } + + @Test + @DisplayName("type=documents без списка документов — отказ") + void documentsNeedCodes() throws Exception { + Path set = spec(""" + {"type": "documents", "count": 1, "seed": 1} + """); + assertThat(run("generate", "--spec", set.toString())).isEqualTo(2); + assertThat(capturedErr.toString(StandardCharsets.UTF_8)).contains("documents"); + } + + @Test + @DisplayName("Без --spec и без подкоманды — код 2") + void missingSpecShowsUsage() { + assertThat(run("generate")).isEqualTo(2); + } +} diff --git a/validum-cli/src/test/java/ru/validum/cli/OutputFormatTest.java b/validum-cli/src/test/java/ru/validum/cli/OutputFormatTest.java index 3690dc9..512c7d0 100644 --- a/validum-cli/src/test/java/ru/validum/cli/OutputFormatTest.java +++ b/validum-cli/src/test/java/ru/validum/cli/OutputFormatTest.java @@ -29,6 +29,13 @@ void csv() { assertThat(OutputFormat.fromString("csv")).isEqualTo(OutputFormat.CSV); } + @ParameterizedTest + @ValueSource(strings = {"csv-semicolon", "csv_semicolon", "csv-excel", "csv_excel", "CSV-SEMICOLON"}) + @DisplayName("csv-semicolon / csv-excel → CSV_SEMICOLON (алиасы для Excel)") + void csvSemicolon(String input) { + assertThat(OutputFormat.fromString(input)).isEqualTo(OutputFormat.CSV_SEMICOLON); + } + @ParameterizedTest @ValueSource(strings = {"JSON", "Json", "CSV", "Csv", "json-array", "JSON-ARRAY"}) @DisplayName("Регистр не важен") diff --git a/validum-cli/src/test/java/ru/validum/cli/UniqueCliTest.java b/validum-cli/src/test/java/ru/validum/cli/UniqueCliTest.java new file mode 100644 index 0000000..81c7538 --- /dev/null +++ b/validum-cli/src/test/java/ru/validum/cli/UniqueCliTest.java @@ -0,0 +1,122 @@ +package ru.validum.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +@DisplayName("CLI --unique — без повторов ключевого поля (W47-B1)") +class UniqueCliTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir Path tmp; + + private final ByteArrayOutputStream capturedErr = new ByteArrayOutputStream(); + private PrintStream originalOut; + private PrintStream originalErr; + + private int run(String... args) { + originalOut = System.out; + originalErr = System.err; + System.setOut(new PrintStream(OutputStream.nullOutputStream(), true, StandardCharsets.UTF_8)); + System.setErr(new PrintStream(capturedErr, true, StandardCharsets.UTF_8)); + try { + return new CommandLine(new ValidumCli()).execute(args); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + } + } + + @AfterEach + void restoreStreams() { + if (originalOut != null) System.setOut(originalOut); + if (originalErr != null) System.setErr(originalErr); + } + + private Path out(String name) { + return tmp.resolve(name); + } + + /** Список значений поля из плоского массива профилей. */ + private static List fields(Path file, String name) throws Exception { + JsonNode array = MAPPER.readTree(Files.readString(file)); + List values = new java.util.ArrayList<>(array.size()); + for (JsonNode node : array) { + values.add(node.get(name).asText()); + } + return values; + } + + @Test + @DisplayName("20 профилей ФЛ с --unique: все ИНН разные") + void individualUniqueInns() throws Exception { + Path file = out("unique.json"); + int code = run("generate", "individual", "--unique", "--seed", "1", "-n", "20", + "--format", "json-array", "-o", file.toString()); + + assertThat(code).isZero(); + List inns = fields(file, "inn"); + assertThat(inns).hasSize(20).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("Повторный прогон с тем же seed даёт тот же набор") + void uniqueIsReproducible() throws Exception { + Path first = out("a.json"); + Path second = out("b.json"); + assertThat(run("generate", "individual", "--unique", "--seed", "7", "-n", "10", + "--format", "json-array", "-o", first.toString())).isZero(); + assertThat(run("generate", "individual", "--unique", "--seed", "7", "-n", "10", + "--format", "json-array", "-o", second.toString())).isZero(); + + assertThat(Files.readString(first)).isEqualTo(Files.readString(second)); + } + + @Test + @DisplayName("--unique без --seed → exit 2 и понятное сообщение") + void uniqueRequiresSeed() { + int code = run("generate", "individual", "--unique", "-n", "5"); + + assertThat(code).isEqualTo(2); + assertThat(capturedErr.toString(StandardCharsets.UTF_8)) + .contains("--unique требует --seed"); + } + + @Test + @DisplayName("--unique работает для ИП (ОГРНИП)") + void ipUniqueOgrnips() throws Exception { + Path file = out("ip.json"); + int code = run("generate", "ip", "--unique", "--seed", "3", "-n", "10", + "--format", "json-array", "-o", file.toString()); + + assertThat(code).isZero(); + List ogrnips = fields(file, "ogrnip"); + assertThat(ogrnips).hasSize(10).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("--unique работает для ЮЛ (ОГРН)") + void organizationUniqueOgrns() throws Exception { + Path file = out("org.json"); + int code = run("generate", "organization", "--unique", "--seed", "5", "-n", "10", + "--format", "json-array", "-o", file.toString()); + + assertThat(code).isZero(); + List ogrns = fields(file, "ogrn"); + assertThat(ogrns).hasSize(10).doesNotHaveDuplicates(); + } +} diff --git a/validum-cli/src/test/java/ru/validum/cli/UniqueGuardTest.java b/validum-cli/src/test/java/ru/validum/cli/UniqueGuardTest.java new file mode 100644 index 0000000..73ae045 --- /dev/null +++ b/validum-cli/src/test/java/ru/validum/cli/UniqueGuardTest.java @@ -0,0 +1,50 @@ +package ru.validum.cli; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("UniqueGuard — повторы и исчерпание вариантов (W47-B1)") +class UniqueGuardTest { + + @Test + @DisplayName("Повторяющиеся значения отбрасываются: второй вызов берёт следующий seed") + void skipsDuplicates() { + UniqueGuard guard = new UniqueGuard("ТЕСТ", 10); + + // Генератор игнорирует seed на первых попытках: пока возвращается "A", guard пробует снова. + String first = guard.next(1L, seed -> seed <= 3 ? "A" : "B", s -> s); + String second = guard.next(1L, seed -> seed <= 3 ? "A" : "B", s -> s); + + assertThat(first).isEqualTo("A"); + assertThat(second).isEqualTo("B"); + assertThat(guard.size()).isEqualTo(2); + } + + @Test + @DisplayName("Пул исчерпан → IllegalArgumentException с попытками и ёмкостью") + void exhaustedPool() { + UniqueGuard guard = new UniqueGuard("ИНН", 1); + + String first = guard.next(1L, seed -> "always", s -> s); + assertThat(first).isEqualTo("always"); + + assertThatThrownBy(() -> guard.next(1L, seed -> "always", s -> s)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("уникальный ИНН") + .hasMessageContaining("1000 попыток") + .hasMessageContaining("из 1 вариантов"); + } + + @Test + @DisplayName("Пустое значение ключевого поля — ошибка состояния, а не тихий дубль") + void blankKey() { + UniqueGuard guard = new UniqueGuard("ОГРН", 5); + + assertThatThrownBy(() -> guard.next(1L, seed -> " ", s -> s)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("ОГРН"); + } +} diff --git a/validum-core/src/main/java/ru/validum/core/Version.java b/validum-core/src/main/java/ru/validum/core/Version.java new file mode 100644 index 0000000..54a21ad --- /dev/null +++ b/validum-core/src/main/java/ru/validum/core/Version.java @@ -0,0 +1,30 @@ +package ru.validum.core; + +/** + * Версия ядра в одном месте. + * + *

Порядок источников: системное свойство {@code validum.version} (его выставляет Gradle во + * время сборки и тестов) → {@code Implementation-Version} из манифеста jar → {@code dev}. + * + *

Никаких зависимостей: класс живёт в core, чтобы CLI и REST отвечали одну и ту же версию. + */ +public final class Version { + + /** Значение, когда версию определить не удалось (запуск из классов без манифеста). */ + public static final String DEV = "dev"; + + private Version() { + throw new AssertionError("Не инстанцируется"); + } + + /** Версия в виде строки, пригодной для печати. */ + public static String version() { + String override = System.getProperty("validum.version"); + if (override != null && !override.isBlank()) { + return override.trim(); + } + Package pkg = Version.class.getPackage(); + String fromManifest = pkg == null ? null : pkg.getImplementationVersion(); + return fromManifest == null || fromManifest.isBlank() ? DEV : fromManifest.trim(); + } +} diff --git a/validum-core/src/main/java/ru/validum/core/bank/ParticipantType.java b/validum-core/src/main/java/ru/validum/core/bank/ParticipantType.java index ec84cea..a2beec5 100644 --- a/validum-core/src/main/java/ru/validum/core/bank/ParticipantType.java +++ b/validum-core/src/main/java/ru/validum/core/bank/ParticipantType.java @@ -8,8 +8,7 @@ /** * Тип участника перевода (PtType) справочника БИК ED807 (УФЭБС ЦБ РФ). * - *

Источник: {@code УФЭБС 2026.09.0, «Кодовые значения», таблица PtType} (Таблица 2, пункт 97, строки ≈4122–4275 - * документа «УФЭБС 2026.09.0 — КБР Кодовые Значения»). + *

Источник: норматив ЦБ РФ — «УФЭБС 2026.09.0 — КБР Кодовые Значения» (Таблица 2, пункт 97). * *

Важно: PtType — текстовый тип до 2 символов ({@code Max2TextType}): ведущий ноль * значим, поэтому {@code "00"} — это две цифры. Аналог в BNKSEEK.DBF — {@code [BNKSEEK.PZN]}. diff --git a/validum-core/src/main/java/ru/validum/core/document/DocumentGenerator.java b/validum-core/src/main/java/ru/validum/core/document/DocumentGenerator.java index 6521608..3e0b47f 100644 --- a/validum-core/src/main/java/ru/validum/core/document/DocumentGenerator.java +++ b/validum-core/src/main/java/ru/validum/core/document/DocumentGenerator.java @@ -10,7 +10,7 @@ * Генератор документов, удостоверяющих личность. * *

Формат серии и номера — из нормативных актов о бланках, см. {@link DocumentType} и - * {@code ПП РФ № 2267, № 298, № 789, № 356; приказы МВД № 365, МО № 700}. Срок действия считается от даты выдачи; для паспорта + * {@code ПП РФ № 2267, № 298, № 789, № 356; приказы МВД № 365 и МО № 700}. Срок действия считается от даты выдачи; для паспорта * РФ и военного билета срок задаётся возрастом, поэтому {@code expirationDate} = {@code null}. * *

Даты выдачи лежат в прошлом (до 9 лет назад) — документ может быть как свежим, так и diff --git a/validum-core/src/main/java/ru/validum/core/document/DocumentRules.java b/validum-core/src/main/java/ru/validum/core/document/DocumentRules.java index 89445de..568fa58 100644 --- a/validum-core/src/main/java/ru/validum/core/document/DocumentRules.java +++ b/validum-core/src/main/java/ru/validum/core/document/DocumentRules.java @@ -12,7 +12,7 @@ * (например, загранпаспорт без паспорта РФ или ВНЖ у резидента РФ). * *

Непроверенные форматы (миграционная карта, свидетельства о рождении и смерти, паспорт моряка) - * в {@link DocumentType} отсутствуют — см. {@code ПП РФ № 356, № 789; приказ МО № 700}. + * в {@link DocumentType} отсутствуют — см. приказы МВД № 365 и МО № 700, ПП РФ № 356 и № 789. */ public final class DocumentRules { diff --git a/validum-core/src/main/java/ru/validum/core/document/DocumentType.java b/validum-core/src/main/java/ru/validum/core/document/DocumentType.java index 8240dec..75f0e29 100644 --- a/validum-core/src/main/java/ru/validum/core/document/DocumentType.java +++ b/validum-core/src/main/java/ru/validum/core/document/DocumentType.java @@ -8,8 +8,8 @@ *

Коды — из классификатора СПДУЛ ФНС (приказ от 29.10.2020 № ЕД-7-11/783@). У визы и * водительского удостоверения кода в СПДУЛ нет — {@link #spdulCode()} возвращает {@code null}. * - *

Маски взяты из нормативных актов о бланках, разбор и источники — в - * {@code ПП РФ № 2267, № 298, № 789, № 356; приказы МВД № 365, МО № 700}: + *

Маски взяты из нормативных актов о бланках (ПП РФ № 2267, № 298, № 789, № 356; + * приказы МВД № 365 и МО № 700): * *

    *
  • паспорт РФ — серия 4 цифры двумя группами, номер 6 цифр, код подразделения; diff --git a/validum-core/src/main/java/ru/validum/core/export/CsvExporter.java b/validum-core/src/main/java/ru/validum/core/export/CsvExporter.java index 6c46b46..d7f8ff5 100644 --- a/validum-core/src/main/java/ru/validum/core/export/CsvExporter.java +++ b/validum-core/src/main/java/ru/validum/core/export/CsvExporter.java @@ -1,6 +1,7 @@ package ru.validum.core.export; import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -11,52 +12,80 @@ *

    Работает с {@code List>}: каждая строка — это уже развёрнутый профиль * (см. {@link IndividualProfileFlattener}, {@link IpProfileFlattener}, {@link * OrganizationProfileFlattener}). Так CSV-формат живёт в core, а не в API-модуле — его - * используют и REST-контроллер, и CLI (P3.1). + * используют и REST-контроллер, и CLI. * - *

    Пишет в UTF-8 с BOM ({@code \uFEFF}) — Excel корректно открывает кириллицу. - * Заголовки берутся из ключей первой строки в порядке вставки (LinkedHashMap → стабильный - * порядок колонок). Экранирует запятые, кавычки и переносы строк по RFC 4180. + *

    По умолчанию пишет машинный CSV: UTF-8 с BOM, разделитель «,», английские заголовки — + * его читают скрипты и импортёры. Для человека есть {@link #toCsv(List, char, boolean)} с + * русскими заголовками и «;» — так русский Excel открывает файл без «всё в одной колонке». + * Экранирование по RFC 4180: кавычки, разделитель и переносы строк. */ public final class CsvExporter { private static final String BOM = "\uFEFF"; - private static final String DELIMITER = ","; private static final String NEWLINE = "\r\n"; + /** Русские заголовки для колонок профиля; неизвестная колонка остаётся как есть. */ + private static final Map RUS_HEADERS = rusHeadersMap(); + private CsvExporter() {} + /** Машинный CSV: BOM, «,», английские заголовки. Для пустого списка — только BOM. */ + public static String toCsv(List> rows) { + return toCsv(rows, ',', false); + } + + /** CSV с выбранным разделителем и английскими заголовками. */ + public static String toCsv(List> rows, char delimiter) { + return toCsv(rows, delimiter, false); + } + /** - * Собирает CSV из плоских строк. + * CSV с выбранным разделителем и, по желанию, русскими заголовками. * - * @param rows список строк (каждая — LinkedHashMap с колонками) - * @return CSV-строка с BOM, заголовком и данными. Для пустого списка — только BOM. + * @param rows строки в порядке колонок (LinkedHashMap) + * @param delimiter «,» для машин, «;» для русского Excel + * @param russianHeaders переводить ли имена колонок на русский */ - public static String toCsv(List> rows) { + public static String toCsv( + List> rows, char delimiter, boolean russianHeaders) { if (rows.isEmpty()) { return BOM; } + String delim = String.valueOf(delimiter); StringBuilder sb = new StringBuilder(BOM); List headers = List.copyOf(rows.get(0).keySet()); - sb.append(headers.stream().map(CsvExporter::escape).collect(Collectors.joining(DELIMITER))) + sb.append(headers.stream() + .map(h -> russianHeaders ? escape(russianHeader(h), delimiter) : escape(h, delimiter)) + .collect(Collectors.joining(delim))) .append(NEWLINE); for (Map row : rows) { sb.append(headers.stream() - .map(h -> escape(row.getOrDefault(h, ""))) - .collect(Collectors.joining(DELIMITER))) + .map(h -> escape(row.getOrDefault(h, ""), delimiter)) + .collect(Collectors.joining(delim))) .append(NEWLINE); } return sb.toString(); } + /** Русское имя колонки; незнакомая колонка возвращается без изменений. */ + public static String russianHeader(String key) { + return RUS_HEADERS.getOrDefault(key, key); + } + + /** Экранирует значение для разделителя «,» (совместимость). */ + public static String escape(String value) { + return escape(value, ','); + } + /** - * Экранирует одно значение по RFC 4180: оборачивает в кавычки, если внутри есть запятая, + * Экранирует одно значение по RFC 4180: оборачивает в кавычки, если внутри есть разделитель, * кавычка или перенос строки; внутренние кавычки удваиваются. */ - public static String escape(String value) { + public static String escape(String value, char delimiter) { if (value == null || value.isEmpty()) { return ""; } - if (value.contains(DELIMITER) + if (value.indexOf(delimiter) >= 0 || value.contains("\"") || value.contains("\n") || value.contains("\r")) { @@ -69,4 +98,97 @@ public static String escape(String value) { public static byte[] toBytes(String csv) { return csv.getBytes(StandardCharsets.UTF_8); } + + /** UTF-8 байты; {@code withBom=false} срезает BOM, если он уже есть в строке. */ + public static byte[] toBytes(String csv, boolean withBom) { + if (withBom || !csv.startsWith(BOM)) { + return csv.getBytes(StandardCharsets.UTF_8); + } + return csv.substring(1).getBytes(StandardCharsets.UTF_8); + } + + private static Map rusHeadersMap() { + Map m = new LinkedHashMap<>(); + m.put("fullName", "ФИО"); + m.put("gender", "Пол"); + m.put("birthDate", "Дата рождения"); + m.put("birthPlace", "Место рождения"); + m.put("age", "Возраст"); + m.put("familyStatus", "Семейное положение"); + m.put("inn", "ИНН"); + m.put("snils", "СНИЛС"); + m.put("passportSeries", "Серия паспорта"); + m.put("passportNumber", "Номер паспорта"); + m.put("passportIssuedBy", "Кем выдан"); + m.put("passportIssueDate", "Дата выдачи"); + m.put("passportDepartmentCode", "Код подразделения"); + m.put("passportType", "Тип паспорта"); + m.put("passportCountry", "Страна паспорта"); + m.put("registrationPostalCode", "Индекс регистрации"); + m.put("registrationCity", "Город регистрации"); + m.put("registrationStreet", "Улица регистрации"); + m.put("registrationBuilding", "Дом регистрации"); + m.put("registrationApartment", "Квартира регистрации"); + m.put("registrationFormatted", "Адрес регистрации"); + m.put("postPostalCode", "Индекс почтовый"); + m.put("postCity", "Город почтовый"); + m.put("postStreet", "Улица почтовая"); + m.put("postBuilding", "Дом почтовый"); + m.put("postApartment", "Квартира почтовая"); + m.put("postFormatted", "Адрес почтовый"); + m.put("currentPostalCode", "Индекс фактический"); + m.put("currentCity", "Город фактический"); + m.put("currentStreet", "Улица фактическая"); + m.put("currentBuilding", "Дом фактический"); + m.put("currentApartment", "Квартира фактическая"); + m.put("currentFormatted", "Адрес фактический"); + m.put("mobileType", "Тип телефона"); + m.put("mobileValue", "Телефон"); + m.put("mobileDescription", "Описание телефона"); + m.put("mobileStatus", "Статус телефона"); + m.put("emailType", "Тип email"); + m.put("emailValue", "Email"); + m.put("emailDescription", "Описание email"); + m.put("emailStatus", "Статус email"); + m.put("secretWord", "Кодовое слово"); + m.put("categoryClient", "Категория клиента"); + m.put("accountPackage", "Пакет услуг"); + m.put("personalManager", "Персональный менеджер"); + m.put("terrorist", "Террорист"); + m.put("blocked", "Заблокирован"); + m.put("bankBik", "БИК"); + m.put("bankName", "Банк"); + m.put("bankCorrAccount", "Корсчёт"); + m.put("bankCity", "Город банка"); + m.put("bankRegion", "Регион банка"); + m.put("account", "Счёт"); + m.put("documents", "Документы"); + m.put("asOf", "Дата расчёта"); + m.put("type", "Тип"); + m.put("name", "Наименование"); + m.put("ogrn", "ОГРН"); + m.put("kpp", "КПП"); + m.put("okpo", "ОКПО"); + m.put("okved", "ОКВЭД"); + m.put("okvedName", "Вид деятельности"); + m.put("ogrnip", "ОГРНИП"); + m.put("region", "Регион"); + m.put("regionType", "Тип региона"); + m.put("city", "Город"); + m.put("street", "Улица"); + m.put("building", "Дом"); + m.put("apartment", "Квартира"); + m.put("postalCode", "Индекс"); + m.put("formatted", "Адрес"); + m.put("spdulCode", "Код СПДУЛ"); + m.put("title", "Название"); + m.put("series", "Серия"); + m.put("number", "Номер"); + m.put("issuedBy", "Кем выдан"); + m.put("issueDate", "Дата выдачи"); + m.put("expirationDate", "Действителен до"); + m.put("departmentCode", "Код подразделения"); + m.put("profileKey", "Ключ профиля"); + return m; + } } diff --git a/validum-core/src/main/java/ru/validum/core/export/Ed807Exporter.java b/validum-core/src/main/java/ru/validum/core/export/Ed807Exporter.java index c7319c1..c7c5614 100644 --- a/validum-core/src/main/java/ru/validum/core/export/Ed807Exporter.java +++ b/validum-core/src/main/java/ru/validum/core/export/Ed807Exporter.java @@ -12,8 +12,8 @@ /** * Экспорт справочника участников в формате ЦБ РФ ED807 (справочник БИК, {@code InfoTypeCode=SIRR}). * - *

    Структура повторяет шаблон из {@code официальные примеры ЦБ (ED807)} и спецификацию - * {@code схема cbr_ed807_v2026.09.0.xsd}. Генерируем то, что знаем: БИК, наименование, тип участника, статус, + *

    Структура повторяет официальные примеры ЦБ РФ к ED807 и схему {@code cbr_ed807_v2026.09.0.xsd}. + * Генерируем то, что знаем: БИК, наименование, тип участника, статус, * корсчёт и город. Атрибуты, которых у нас нет (адрес, индекс, код региона, регномер, UID), берутся * из {@link Defaults} — они стабильны между вызовами, чтобы файл оставался воспроизводимым. * diff --git a/validum-core/src/main/java/ru/validum/core/export/Ed808Exporter.java b/validum-core/src/main/java/ru/validum/core/export/Ed808Exporter.java index 4236799..1a6e227 100644 --- a/validum-core/src/main/java/ru/validum/core/export/Ed808Exporter.java +++ b/validum-core/src/main/java/ru/validum/core/export/Ed808Exporter.java @@ -14,7 +14,7 @@ * *

    Отличия от ED807, которые учтены: {@code CreationReason} — элемент (а не атрибут), нет * {@code DirectoryVersion}, есть {@code ParticipantProfile} с {@code AccountsInfo}. Шаблон — - * {@code официальные примеры ЦБ (ED808)}. + * официальные примеры ЦБ РФ к ED808 и схему {@code cbr_ed808_v2026.09.0.xsd}. * *

    Как и ED807, файл структурно корректен и воспроизводим, но не является банковским документом. */ diff --git a/validum-core/src/main/java/ru/validum/core/report/Coverage.java b/validum-core/src/main/java/ru/validum/core/report/Coverage.java new file mode 100644 index 0000000..d4bb60b --- /dev/null +++ b/validum-core/src/main/java/ru/validum/core/report/Coverage.java @@ -0,0 +1,143 @@ +package ru.validum.core.report; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import ru.validum.core.account.AccountValidator; +import ru.validum.core.validation.InnValidator; +import ru.validum.core.validation.KppValidator; +import ru.validum.core.validation.OgrnValidator; +import ru.validum.core.validation.OgrnipValidator; +import ru.validum.core.validation.OkpoValidator; +import ru.validum.core.validation.SnilsValidator; + +/** + * Отчёт о покрытии готового набора: для каждого поля — доля заполненных, число различных значений и + * сколько значений не проходит валидацию. Нужен, чтобы перед заливкой в стенд увидеть «дыры» набора + * (пустые СНИЛС, невалидные счета) и не искать их потом руками. + * + *

    Валидируются только поля с публичной проверкой контрольной суммы; остальные — по полноте. + */ +public final class Coverage { + + /** Порядок полей в отчёте; чего нет в наборе — не печатается. */ + public static final List PREFERRED_ORDER = List.of( + "fullName", "gender", "birthDate", "age", "inn", "snils", + "passportSeries", "passportNumber", "passportIssuedAt", "passportIssuer", + "organizationName", "organizationType", "ogrn", "ogrnip", "kpp", "okpo", + "bankBik", "account", "address", "okved", "asOf"); + + /** Есть ли публичная проверка контрольной суммы у поля. */ + public static boolean isChecked(String field) { + return switch (field) { + case "inn", "snils", "ogrn", "ogrnip", "account", "kpp", "okpo", "bankBik" -> true; + default -> false; + }; + } + + /** + * Проверка значения; {@code null} — поле не проверяется. + * + * @param row вся строка: счёту нужен БИК банка, чтобы посчитать условный номер подразделения + */ + public static Boolean valid(String field, String value, Map row) { + if (value == null || value.isBlank()) { + return false; + } + return switch (field) { + case "inn" -> InnValidator.isValid(value); + case "snils" -> SnilsValidator.isValid(value); + case "ogrn" -> OgrnValidator.isValidLegalEntity(value); + case "ogrnip" -> OgrnipValidator.isValid(value); + case "kpp" -> KppValidator.isValid(value); + case "okpo" -> OkpoValidator.isValid(value); + // BikValidator требует справочник; здесь проверяем только формат (9 цифр). + case "bankBik" -> value.matches("\\d{9}"); + case "account" -> AccountValidator.isValidAuto(value, bankOf(row)); + default -> null; + }; + } + + private static String bankOf(Map row) { + for (String key : List.of("bankBik", "bik")) { + String value = row.get(key); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + /** Поля в порядке отчёта: сначала известные, затем прочие по алфавиту. */ + public static List fieldsOf(List> rows) { + Set keys = new LinkedHashSet<>(); + for (Map row : rows) { + keys.addAll(row.keySet()); + } + List ordered = new ArrayList<>(); + for (String field : PREFERRED_ORDER) { + if (keys.remove(field)) { + ordered.add(field); + } + } + keys.stream().sorted().forEach(ordered::add); + return ordered; + } + + /** Статистика по одному полю, посчитанная на наборе. */ + public static FieldStat stat(String field, List> rows) { + int total = rows.size(); + int present = 0; + int blank = 0; + int invalid = 0; + Set distinct = new LinkedHashSet<>(); + for (Map row : rows) { + String value = row.get(field); + if (value == null) { + continue; + } + if (value.isBlank()) { + blank++; + continue; + } + present++; + distinct.add(value); + if (isChecked(field) && Boolean.FALSE.equals(valid(field, value, row))) { + invalid++; + } + } + return new FieldStat(field, total, present, blank, invalid, distinct.size()); + } + + /** Статистика по всем полям набора. */ + public static List analyze(List> rows) { + List stats = new ArrayList<>(); + for (String field : fieldsOf(rows)) { + stats.add(stat(field, rows)); + } + return stats; + } + + /** + * Итог по одному полю. + * + * @param present сколько строк с непустым значением + * @param blank сколько строк, где колонка есть, но пуста + * @param invalid сколько непустых значений не прошли проверку (0 у непроверяемых полей) + * @param distinct сколько различных значений + */ + public record FieldStat(String field, int total, int present, int blank, int invalid, int distinct) { + + /** Полнота в процентах (заполнено / всего строк). */ + public int fillPercent() { + return total == 0 ? 0 : (int) Math.round(100.0 * present / total); + } + + /** Все непустые значения прошли проверку (или поле не проверяется). */ + public boolean clean() { + return invalid == 0; + } + } +} diff --git a/validum-core/src/test/java/ru/validum/core/export/CsvForExcelTest.java b/validum-core/src/test/java/ru/validum/core/export/CsvForExcelTest.java new file mode 100644 index 0000000..0e2b3ad --- /dev/null +++ b/validum-core/src/test/java/ru/validum/core/export/CsvForExcelTest.java @@ -0,0 +1,70 @@ +package ru.validum.core.export; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("CSV для человека: «;», русские заголовки, BOM") +class CsvForExcelTest { + + private static Map row() { + Map m = new LinkedHashMap<>(); + m.put("fullName", "Иванов Иван Иванович"); + m.put("inn", "500100732259"); + m.put("bankName", "ПАО Сбербанк"); + return m; + } + + @Test + @DisplayName("Разделитель «;» и русские заголовки") + void semicolonAndRussianHeaders() { + String csv = CsvExporter.toCsv(List.of(row()), ';', true); + + assertThat(csv).startsWith("\uFEFF"); + assertThat(csv).contains("ФИО;ИНН;Банк"); + assertThat(csv).contains("Иванов Иван Иванович;500100732259;ПАО Сбербанк"); + } + + @Test + @DisplayName("Машинный CSV остаётся с «,» и английскими заголовками") + void machineCsvUnchanged() { + String csv = CsvExporter.toCsv(List.of(row())); + + assertThat(csv).contains("fullName,inn,bankName"); + assertThat(csv).doesNotContain("ФИО"); + } + + @Test + @DisplayName("Запятая внутри значения не ломает «;»-CSV") + void commaInsideValueIsNotQuoted() { + Map m = new LinkedHashMap<>(); + m.put("registrationFormatted", "г Москва, ул Тверская, д 1"); + + String csv = CsvExporter.toCsv(List.of(m), ';', true); + + assertThat(csv).contains("г Москва, ул Тверская, д 1"); + assertThat(csv).doesNotContain("\"г Москва"); + } + + @Test + @DisplayName("Точка с запятой внутри значения экранируется кавычками") + void semicolonInsideValueIsQuoted() { + Map m = new LinkedHashMap<>(); + m.put("name", "ООО \"Ромашка\"; филиал"); + + String csv = CsvExporter.toCsv(List.of(m), ';', false); + + assertThat(csv).contains("\"ООО \"\"Ромашка\"\"; филиал\""); + } + + @Test + @DisplayName("Неизвестная колонка остаётся как есть") + void unknownColumnKept() { + assertThat(CsvExporter.russianHeader("myOwnColumn")).isEqualTo("myOwnColumn"); + assertThat(CsvExporter.russianHeader("inn")).isEqualTo("ИНН"); + } +} diff --git a/validum-core/src/test/java/ru/validum/core/export/GoldenFixturesTest.java b/validum-core/src/test/java/ru/validum/core/export/GoldenFixturesTest.java new file mode 100644 index 0000000..15e0265 --- /dev/null +++ b/validum-core/src/test/java/ru/validum/core/export/GoldenFixturesTest.java @@ -0,0 +1,140 @@ +package ru.validum.core.export; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import ru.validum.core.bank.BikSource; +import ru.validum.core.bank.BikSources; +import ru.validum.core.organization.OrganizationProfile; +import ru.validum.core.organization.OrganizationProfileGenerator; +import ru.validum.core.profile.IndividualProfile; +import ru.validum.core.profile.IndividualProfileGenerator; + +/** + * Golden-фикстуры: {@code seed + asOf → SHA-256 набора} (W47-N1). + * + *

    Идея: одно и то же зерно и одна и та же дата расчёта обязаны давать байт-в-байт одинаковый + * набор не только в рамках одного процесса (это ловит {@code DeterminismTest}), но и между + * версиями. Любое осознанное изменение вывода обновляет эталон: {@code + * ./gradlew :validum-core:test -Dvalidum.golden.update=true}. + * + *

    Эталон пишется в {@code src/test/resources/golden} при первом запуске — после этого прогон + * ловит случайный дрейф справочников, форматов и порядка колонок. + */ +@DisplayName("Golden-фикстуры: seed+asOf → SHA-256 (W47-N1)") +class GoldenFixturesTest { + + private static final Path GOLDEN_DIR = Path.of("src/test/resources/golden"); + + private static final boolean UPDATE = Boolean.getBoolean("validum.golden.update"); + + private static final LocalDate AS_OF = LocalDate.of(2026, 11, 1); + + private static final BikSource BANKS = + BikSources.resolve("src/test/resources/bik-sample.xml"); + + @Test + @DisplayName("ФЛ: пять зёрен, по два профиля — стабильный SHA-256") + void individualGolden() throws IOException { + Map digests = new LinkedHashMap<>(); + for (long seed : List.of(0L, 1L, 42L, 1234L, 99_999L)) { + List profiles = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + profiles.add(IndividualProfileGenerator.builder() + .seed(seed + i) + .bikSource(BANKS) + .asOf(AS_OF) + .build() + .generate()); + } + digests.put(seed, digest(IndividualProfileFlattener.flattenAll(profiles))); + } + verify("individual", digests); + } + + @Test + @DisplayName("ЮЛ: три зерна, по два профиля — стабильный SHA-256") + void organizationGolden() throws IOException { + Map digests = new LinkedHashMap<>(); + for (long seed : List.of(0L, 1L, 42L)) { + List profiles = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + profiles.add(OrganizationProfileGenerator.builder() + .seed(seed + i) + .bikSource(BANKS) + .build() + .generate()); + } + digests.put(seed, digest(OrganizationProfileFlattener.flattenAll(profiles))); + } + verify("organization", digests); + } + + private void verify(String name, Map digests) throws IOException { + Path file = GOLDEN_DIR.resolve(name + ".txt"); + Map expected = Files.exists(file) && !UPDATE + ? read(file) + : Map.of(); + + StringBuilder fresh = new StringBuilder(); + for (Map.Entry entry : digests.entrySet()) { + fresh.append(entry.getKey()).append('|').append(entry.getValue()).append('\n'); + } + + if (expected.isEmpty()) { + Files.createDirectories(GOLDEN_DIR); + Files.writeString(file, fresh.toString(), StandardCharsets.UTF_8); + return; // первая запись эталона: сверять пока не с чем + } + + for (Map.Entry entry : digests.entrySet()) { + assertThat(expected.get(entry.getKey())) + .as("%s: golden для seed=%d отсутствует в %s", name, entry.getKey(), file) + .isNotNull(); + assertThat(entry.getValue()) + .as("%s: набор для seed=%d отклонился от эталона", name, entry.getKey()) + .isEqualTo(expected.get(entry.getKey())); + } + } + + private static Map read(Path file) throws IOException { + Map result = new TreeMap<>(); + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + if (line.isBlank()) continue; + int separator = line.indexOf('|'); + result.put(Long.parseLong(line.substring(0, separator)), line.substring(separator + 1)); + } + return result; + } + + /** Каноничный вид набора: ключи по алфавиту, строки через {@code \\n}, SHA-256 в hex. */ + private static String digest(List> rows) { + StringBuilder canonical = new StringBuilder(); + for (Map row : rows) { + TreeMap sorted = new TreeMap<>(row); + sorted.forEach((key, value) -> + canonical.append(key).append('=').append(value).append('&')); + canonical.append('\n'); + } + try { + byte[] hash = MessageDigest.getInstance("SHA-256") + .digest(canonical.toString().getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 недоступен", e); + } + } +} diff --git a/validum-core/src/test/java/ru/validum/core/report/CoverageTest.java b/validum-core/src/test/java/ru/validum/core/report/CoverageTest.java new file mode 100644 index 0000000..9863cc6 --- /dev/null +++ b/validum-core/src/test/java/ru/validum/core/report/CoverageTest.java @@ -0,0 +1,63 @@ +package ru.validum.core.report; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import ru.validum.core.bank.BikSources; +import ru.validum.core.profile.IndividualProfile; +import ru.validum.core.profile.IndividualProfileGenerator; + +class CoverageTest { + + private static IndividualProfileGenerator generator; + + @BeforeAll + static void setUp() { + generator = IndividualProfileGenerator.builder() + .bikSource(BikSources.resolve("src/test/resources/bik-sample.xml")) + .seed(7) + .build(); + } + + @Test + void countsFillBlankAndValidity() { + IndividualProfile good = generator.generate(); + List> rows = List.of( + Map.of("fullName", "Иванов Иван Иванович", "inn", good.inn(), "snils", good.snils()), + Map.of("fullName", "Петров Пётр Петрович", "inn", "123456789012", "snils", good.snils()), + Map.of("fullName", "", "inn", "", "snils", good.snils())); + + Coverage.FieldStat inn = Coverage.stat("inn", rows); + assertThat(inn.total()).isEqualTo(3); + assertThat(inn.present()).isEqualTo(2); + assertThat(inn.blank()).isEqualTo(1); + assertThat(inn.invalid()).isEqualTo(1); + assertThat(inn.distinct()).isEqualTo(2); + assertThat(inn.fillPercent()).isEqualTo(67); + assertThat(inn.clean()).isFalse(); + + Coverage.FieldStat snils = Coverage.stat("snils", rows); + assertThat(snils.invalid()).isZero(); + assertThat(snils.clean()).isTrue(); + } + + @Test + void fieldsGoInPreferredOrderThenAlphabetically() { + List> rows = List.of( + Map.of("inn", "1", "fullName", "Иванов", "unexpected", "x")); + + assertThat(Coverage.fieldsOf(rows)).containsExactly("fullName", "inn", "unexpected"); + assertThat(Coverage.analyze(rows)).hasSize(3); + } + + @Test + void uncheckedFieldHasNoInvalidCount() { + List> rows = List.of(Map.of("fullName", "Иванов")); + Coverage.FieldStat stat = Coverage.stat("fullName", rows); + assertThat(stat.invalid()).isZero(); + assertThat(stat.distinct()).isEqualTo(1); + } +} diff --git a/validum-core/src/test/java/ru/validum/core/validation/PropertyInvariantsTest.java b/validum-core/src/test/java/ru/validum/core/validation/PropertyInvariantsTest.java new file mode 100644 index 0000000..75af5e3 --- /dev/null +++ b/validum-core/src/test/java/ru/validum/core/validation/PropertyInvariantsTest.java @@ -0,0 +1,284 @@ +package ru.validum.core.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.util.List; +import java.util.Random; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import ru.validum.core.account.AccountValidator; +import ru.validum.core.bank.Bank; +import ru.validum.core.bank.BikSource; +import ru.validum.core.bank.BikSources; +import ru.validum.core.organization.IpProfile; +import ru.validum.core.organization.IpProfileGenerator; +import ru.validum.core.organization.OrganizationProfile; +import ru.validum.core.organization.OrganizationProfileGenerator; +import ru.validum.core.profile.IndividualProfile; +import ru.validum.core.profile.IndividualProfileGenerator; + +/** + * Property-проверки «по независимой реализации» (W47-N1). + * + *

    Каждый алгоритм здесь продублирован в тесте заново — намеренно. Если завтра в + * {@link InnValidator} или {@link AccountValidator} появится ошибка, дубль в тесте её не повторит и + * прогон станет красным. Это единственный способ поймать регрессию в самих валидаторах. + */ +@DisplayName("Property-инварианты: валидаторы против независимой реализации (W47-N1)") +class PropertyInvariantsTest { + + private static final BikSource BANKS = + BikSources.resolve("src/test/resources/bik-sample.xml"); + + private static final LocalDate AS_OF = LocalDate.of(2026, 11, 1); + + private static final Random RANDOM = new Random(20261101L); + + // === Независимые реализации проверок === + + /** Контрольные цифры ИНН ФЛ — по весам ФНС, отдельно от {@link InnValidator}. */ + private static boolean independentInn12(String inn) { + if (inn == null || inn.length() != 12 || !inn.matches("\\d{12}")) return false; + int[] w1 = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8}; + int[] w2 = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8}; + int s1 = 0; + int s2 = 0; + for (int i = 0; i < 10; i++) s1 += (inn.charAt(i) - '0') * w1[i]; + for (int i = 0; i < 11; i++) s2 += (inn.charAt(i) - '0') * w2[i]; + return (s1 % 11 % 10) == (inn.charAt(10) - '0') && (s2 % 11 % 10) == (inn.charAt(11) - '0'); + } + + /** Контрольная цифра ИНН ЮЛ; отбраковка остатка 10 — часть алгоритма, не «поправка». */ + private static boolean independentInn10(String inn) { + if (inn == null || inn.length() != 10 || !inn.matches("\\d{10}")) return false; + int[] w = {2, 4, 10, 3, 5, 9, 4, 6, 8}; + int sum = 0; + for (int i = 0; i < 9; i++) sum += (inn.charAt(i) - '0') * w[i]; + return (sum % 11 % 10) == (inn.charAt(9) - '0'); + } + + /** СНИЛС: сумма с весами 9..1, остаток 100/101 → «00». */ + private static boolean independentSnils(String snils) { + if (snils == null || snils.length() != 11 || !snils.matches("\\d{11}")) return false; + int sum = 0; + for (int i = 0; i < 9; i++) sum += (snils.charAt(i) - '0') * (9 - i); + int control = sum % 101; + if (control == 100 || control == 101) control = 0; + return control == Integer.parseInt(snils.substring(9)); + } + + /** ОГРН: первые 12 цифр / 11 → младший разряд как контрольный. */ + private static boolean independentOgrn(String value, int length) { + if (value == null || value.length() != length || !value.matches("\\d{" + length + "}")) { + return false; + } + long numeric = Long.parseLong(value); + int divisor = length == 13 ? 11 : 13; + int control = (int) ((numeric / 10) % divisor % 10); + return control == (value.charAt(length - 1) - '0'); + } + + /** + * Контрольный ключ счёта по письму № 515: проверяются оба правила — клиентское (разряды 7-9 + * БИК) и корреспондентское («0» + разряды 5-6 БИК), как в {@code isAuto}. + */ + private static boolean independentAccount(String account, String bik) { + if (account == null || account.length() != 20 || bik == null || bik.length() != 9) { + return false; + } + return keyMatches(account, bik.substring(6)) + || keyMatches(account, "0" + bik.substring(4, 6)); + } + + private static boolean keyMatches(String account, String prefix) { + int[] prefixWeights = {7, 1, 3}; + int[] accountWeights = {7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1}; + int sum = 0; + for (int i = 0; i < 3; i++) sum += (prefix.charAt(i) - '0') * prefixWeights[i]; + for (int i = 0; i < 20; i++) { + if (i == 8) continue; // контрольный ключ в расчёте участвует как ноль + sum += digitValue(account.charAt(i)) * accountWeights[i]; + } + return (sum * 3 % 10) == digitValue(account.charAt(8)); + } + + /** Буква клиринговой валюты в 6-м разряде: A→0, B→1, C→2, E→3, H→4, K→5, M→6, P→7, T→8, X→9. */ + private static int digitValue(char c) { + return switch (c) { + case 'A' -> 0; + case 'B' -> 1; + case 'C' -> 2; + case 'E' -> 3; + case 'H' -> 4; + case 'K' -> 5; + case 'M' -> 6; + case 'P' -> 7; + case 'T' -> 8; + case 'X' -> 9; + default -> c - '0'; + }; + } + + // === Свойства валидаторов на случайных данных === + + @Test + @DisplayName("ИНН-12: валидатор и независимая формула согласны на 5000 случайных строках") + void inn12MatchesIndependent() { + for (int i = 0; i < 5000; i++) { + String candidate = randomDigits(12); + assertThat(InnValidator.isValidIndividual(candidate)) + .as("ИНН ФЛ %s", candidate) + .isEqualTo(independentInn12(candidate)); + } + } + + @Test + @DisplayName("ИНН-10 и СНИЛС: те же свойства для ЮЛ и СНИЛС") + void inn10AndSnilsMatchIndependent() { + for (int i = 0; i < 2000; i++) { + String inn = randomDigits(10); + assertThat(InnValidator.isValidLegalEntity(inn)) + .as("ИНН ЮЛ %s", inn) + .isEqualTo(independentInn10(inn)); + + String snils = randomDigits(11); + assertThat(SnilsValidator.isValid(snils)) + .as("СНИЛС %s", snils) + .isEqualTo(independentSnils(snils)); + } + } + + @Test + @DisplayName("ОГРН 13 и 15 знаков: контрольный разряд совпадает с независимой формулой") + void ogrnMatchesIndependent() { + for (int i = 0; i < 2000; i++) { + String ogrn = "1" + randomDigits(12); + assertThat(OgrnValidator.isValidLegalEntity(ogrn)) + .as("ОГРН %s", ogrn) + .isEqualTo(independentOgrn(ogrn, 13)); + + String ogrnip = "3" + randomDigits(14); + assertThat(OgrnValidator.isValidIndividualEntrepreneur(ogrnip)) + .as("ОГРНИП %s", ogrnip) + .isEqualTo(independentOgrn(ogrnip, 15)); + } + } + + // === Свойства генерации === + + @Test + @DisplayName("Сгенерированные профили ФЛ валидны по независимым реализациям") + void generatedIndividualsAreValid() { + for (long seed = 0; seed < 30; seed++) { + IndividualProfileGenerator generator = IndividualProfileGenerator.builder() + .seed(seed) + .bikSource(BANKS) + .asOf(AS_OF) + .build(); + IndividualProfile profile = generator.generate(); + + assertThat(independentInn12(profile.inn())) + .as("seed=%d, ИНН=%s", seed, profile.inn()) + .isTrue(); + assertThat(independentSnils(profile.snils())) + .as("seed=%d, СНИЛС=%s", seed, profile.snils()) + .isTrue(); + assertThat(profile.passport().series()).matches("\\d{4}"); + assertThat(profile.passport().number()).matches("\\d{6}"); + assertThat(profile.passport().issueDate()).isBeforeOrEqualTo(AS_OF); + assertThat(profile.birthDate()).isBeforeOrEqualTo(AS_OF); + + Bank bank = profile.bank(); + assertThat(bank).as("seed=%d: банк должен быть из справочника", seed).isNotNull(); + assertThat(independentAccount(profile.account(), bank.bik())) + .as("seed=%d, счёт=%s, БИК=%s", seed, profile.account(), bank.bik()) + .isTrue(); + } + } + + @Test + @DisplayName("Сгенерированные профили ИП и ЮЛ валидны по независимым реализациям") + void generatedIpAndOrganizationsAreValid() { + for (long seed = 0; seed < 20; seed++) { + IndividualProfileGenerator base = IndividualProfileGenerator.builder() + .seed(seed) + .bikSource(BANKS) + .asOf(AS_OF) + .build(); + IpProfile ip = IpProfileGenerator.generate(base, seed); + assertThat(independentInn12(ip.inn())).as("ИП seed=%d", seed).isTrue(); + assertThat(independentOgrn(ip.ogrnip(), 15)).as("ИП seed=%d", seed).isTrue(); + + OrganizationProfile org = OrganizationProfileGenerator.builder() + .seed(seed) + .bikSource(BANKS) + .build() + .generate(); + assertThat(independentOgrn(org.ogrn(), 13)).as("ЮЛ seed=%d", seed).isTrue(); + assertThat(independentInn10(org.inn())).as("ЮЛ seed=%d", seed).isTrue(); + } + } + + @Test + @DisplayName("Детерминизм: одинаковые seed и asOf дают одинаковые строки") + void sameSeedGivesSameRows() { + List first = threeIndividuals(42L); + List second = threeIndividuals(42L); + + assertThat(first).isEqualTo(second); + assertThat(flat(first)).isEqualTo(flat(second)); + } + + @Test + @DisplayName("Смена seed меняет ИНН: набор не залипает") + void differentSeedChangesValues() { + List a = threeIndividuals(1L); + List b = threeIndividuals(2L); + assertThat(a.get(0).inn()).isNotEqualTo(b.get(0).inn()); + } + + // === Служебное === + + private static List threeIndividuals(long seed) { + return List.of( + generateIndividual(seed), + generateIndividual(seed + 1), + generateIndividual(seed + 2)); + } + + private static IndividualProfile generateIndividual(long seed) { + return IndividualProfileGenerator.builder() + .seed(seed) + .bikSource(BANKS) + .asOf(AS_OF) + .build() + .generate(); + } + + private static List> flat(List profiles) { + return ru.validum.core.export.IndividualProfileFlattener.flattenAll(profiles); + } + + private static String randomDigits(int length) { + StringBuilder builder = new StringBuilder(length); + for (int i = 0; i < length; i++) { + builder.append(RANDOM.nextInt(10)); + } + return builder.toString(); + } + + /** Контроль непротиворечивости: валидатор счёта согласен с независимой реализацией. */ + @Test + @DisplayName("Счёт: независимая реализация письма № 515 согласна с AccountValidator") + void accountMatchesIndependent() { + for (long seed = 0; seed < 30; seed++) { + IndividualProfile profile = generateIndividual(seed); + boolean expected = independentAccount(profile.account(), profile.bank().bik()); + assertThat(AccountValidator.isWellFormed(profile.account())).isTrue(); + assertThat(expected) + .as("seed=%d, счёт=%s", seed, profile.account()) + .isTrue(); + } + } +} diff --git a/validum-core/src/test/resources/golden/individual.txt b/validum-core/src/test/resources/golden/individual.txt new file mode 100644 index 0000000..109d445 --- /dev/null +++ b/validum-core/src/test/resources/golden/individual.txt @@ -0,0 +1,5 @@ +0|5cede93da79740f73551889ccdf3504067ddd65de1330f3e4706c4337c9a3472 +1|6470d8c5f3449a056d2effa4f70105d39e516ef16e4c112db448a9a5f2d025d2 +42|1a8ca80750ac19fd8691fc330a59affdebc4e50aaa74c6306abd274571680d05 +1234|81f88ddea30cf7b9d2ee56f50ede91377f400fbf495ec2390f203cd13290e768 +99999|6596f29026141440733ab1ccb524cda4c3bfee6ff5f2606cce90735570e8c7c1 diff --git a/validum-core/src/test/resources/golden/organization.txt b/validum-core/src/test/resources/golden/organization.txt new file mode 100644 index 0000000..4c7db28 --- /dev/null +++ b/validum-core/src/test/resources/golden/organization.txt @@ -0,0 +1,3 @@ +0|17421d23a3dd271c1c1cb8924a5cfb217d1bfd7179f24102c6b8099a0889810c +1|8984d92306e92d08acbf37d986c5ef979f87bc419a92e85ec04a5dea6e779899 +42|7de6c4895cd06d6576d8be6e5e232d1780813dbc86e409accbffbd2ff2f9f51b