Compare commits
3 Commits
7e9a56a7a2
...
3a91d050f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a91d050f2 | |||
|
|
44e0fd8229 | ||
|
|
9bc2b26c1c |
6
Makefile
6
Makefile
@ -8,7 +8,7 @@ CHANGE ?=
|
||||
# Текущая версия модели данных — по ней рисуются диаграммы (make diagrams)
|
||||
DBML ?= model/data-model-v5.23.dbml
|
||||
|
||||
.PHONY: venv validate-contracts validate-openspec test-snapshot test-sync-statements sync-statements statements-check check diagrams
|
||||
.PHONY: venv validate-contracts validate-openspec test-snapshot test-sync-statements sync-statements statements-check check diagrams regress
|
||||
|
||||
# Зависимости скриптов проверки контракта — jsonschema и PyYAML (перечни кодов форм contracts/statement-code-orders.yaml)
|
||||
venv:
|
||||
@ -47,3 +47,7 @@ check: validate-contracts validate-openspec test-snapshot test-sync-statements
|
||||
# Диаграммы model/diagrams/part01…09.{svg,png} из $(DBML); нужен npx (Node.js), зависимостей Python нет
|
||||
diagrams:
|
||||
python3 scripts/gen_model_diagrams.py $(DBML)
|
||||
|
||||
# Полная регрессия main всех репозиториев workspace перед выкладкой (scripts/regression/README.md); REGRESS_ARGS="--only stand,ui"
|
||||
regress:
|
||||
scripts/regression/regress.sh $(REGRESS_ARGS)
|
||||
|
||||
@ -119,6 +119,13 @@ docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka run --rm
|
||||
`migration-cycle`, `check-infra`, `docker-build`, `web-check`; `scan-gateway` — `help`, `docker-build`, `emulator`;
|
||||
`tabular-parser` — `docker-build`.
|
||||
|
||||
### Перед выкладкой: полная регрессия
|
||||
|
||||
`make regress` в docs (`scripts/regression/regress.sh`) прогоняет всё перечисленное выше по `main` всех репозиториев
|
||||
сразу, не останавливаясь на первой ошибке, плюс то, чего нет в `check-full`: фронт в чистой копии с `npm ci`, e2e ядра с
|
||||
настоящим парсером, стенд с нуля в отдельном compose-проекте со сценарием по API и контрольными цифрами, UI-смоук и
|
||||
прогон парсера по банку первички со сравнением с прошлым прогоном. Описание этапов и итога — `scripts/regression/README.md`.
|
||||
|
||||
## Эталонный фрагмент `dev/Makefile.common`
|
||||
|
||||
Рецепты общих целей вынесены в [`Makefile.common`](Makefile.common) — это эталон (источник — `Makefile` `scan-gateway`),
|
||||
|
||||
@ -252,8 +252,9 @@ git diff <коммит sync> HEAD -- . ':!SNAPSHOT.md' > /tmp/contour-arm-core.p
|
||||
|
||||
## 8. Чек-лист одной синхронизации
|
||||
|
||||
1. Снаружи: нужные PR слиты в `main` трёх сервисов; `make check-full` зелёный в каждом; контракт в `docs/contracts` и копии
|
||||
`api/contracts` идентичны.
|
||||
1. Снаружи: нужные PR слиты в `main` трёх сервисов; полная регрессия `make -C docs regress` зелёная (она включает
|
||||
`check-full` каждого сервиса, e2e, стенд с нуля и UI-смоук — `scripts/regression/README.md`), а расхождения её снимков
|
||||
с прошлой выкладкой объяснены задачами выкладки; контракт в `docs/contracts` и копии `api/contracts` идентичны.
|
||||
2. Правки из контура (раздел 7) возвращены наружу и слиты.
|
||||
3. `prepare-contour-snapshot.sh` по каждому сервису; скрипт завершился словами «снимок готов».
|
||||
4. В контуре: клон, ветка, `rsync --delete`, просмотр `git status`, коммит `sync: <repo>@<hash> (<дата>)`, `make venv`,
|
||||
|
||||
69
scripts/regression/README.md
Normal file
69
scripts/regression/README.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Полная регрессия перед выкладкой
|
||||
|
||||
Один прогон проверяет `main` всех репозиториев workspace так, как его увидит контур: проверки качества каждого
|
||||
репозитория, интеграционные и сквозные тесты, образы, стенд с нуля со сценарием по API и контрольными цифрами, UI-смоук и
|
||||
прогон парсера по банку первички. В отличие от `make check-full`, прогон не останавливается на первой ошибке: перед
|
||||
выкладкой нужна вся картина. Задача — arm-ka-docs#179.
|
||||
|
||||
```bash
|
||||
make regress # из корня docs, workspace — родитель docs
|
||||
scripts/regression/regress.sh --only stand,ui # только часть этапов (список: --list)
|
||||
scripts/regression/regress.sh --skip bank --keep-stand
|
||||
ARM_KA_WORKSPACE=~/projects/awp-ca scripts/regression/regress.sh # из worktree docs: проверяются основные checkout'ы
|
||||
```
|
||||
|
||||
Результат — `admin/regression/<дата_время>/` в workspace (`--out` — другой каталог; повторный запуск этапов в тот же
|
||||
каталог заменяет только их строки): `summary.md` — итог, `logs/<этап>.<шаг>.log`, `stand/` (отчёт сценария, снимки свода,
|
||||
шахматки и форм, экспорт xlsx), `ui/` (скриншоты экранов), `bank/` (результаты парсера по doc-bank). Каталог содержит
|
||||
производные реальных данных — в git не класть. Код возврата: `0` — всё зелёное, `1` — есть красные шаги, `2` —
|
||||
preflight не пройден.
|
||||
|
||||
## Этапы
|
||||
|
||||
| Этап | Что проверяет |
|
||||
|---|---|
|
||||
| `preflight` | все репозитории (`docs`, `fixtures`, `arm-statements`, `arm-core`, `tabular-parser`, `scan-gateway`) на `main`, равны `origin/main`, без правок; Docker, `uv`, `npm`, `openspec`; нет чужих потребителей общей NATS (контейнеры сервисов `arm-ka`, ядро на `:8000`) — иначе интеграционные тесты и e2e падают по таймауту. Не пройден — прогон останавливается (`--allow-dirty` — прогнать как есть, с пометкой в итоге) |
|
||||
| `venv` | `make venv` во всех репозиториях (окружения по текущим зависимостям) |
|
||||
| `static` | `make check` docs (контракты, OpenSpec, снимок контура, `sync-statements`), обзор свежести копий `arm_statements`, `lint` и `test` arm-statements и трёх сервисов, `dbml-check` ядра |
|
||||
| `web` | фронт `arm-core/web` в **чистой копии** во временном каталоге: `npm ci` (как пайплайн; при расхождении lock с `package.json` — красный, копия доустанавливается `npm install`), biome, `tsc`, ESLint + Prettier, vitest, сборка. `main` не меняется |
|
||||
| `integration` | инфраструктура общего compose-проекта `arm-ka` (`make up`), `check-infra`, `test-integration` трёх сервисов; у ядра — с циклом миграций `upgrade → downgrade base → upgrade`. Тесты создают свои БД, рабочую `armka` не трогают |
|
||||
| `fixtures` | реальные данные парсера: `test-fixtures` (golden, покрытие словарей) и `golden-check` tabular-parser |
|
||||
| `images` | сборка образов `arm-core:dev`, `tabular-parser:dev`, `scan-gateway:dev` из `main` (профиль `services`) |
|
||||
| `e2e` | сквозной тест ядра с настоящим tabular-parser (контейнер в `arm-ka`, после теста возвращается в прежнее состояние); пропуск теста — красный |
|
||||
| `stand` | **изолированный стенд с нуля**: compose-проект `arm-ka-regress` на своих портах (ядро `:18000`, парсер `:18001`, шлюз `:18002`, PostgreSQL `:15432`, NATS `:14222`, MinIO `:19000`), миграции и `seed --dev`, эмулятор ИС Сканирование; сценарий `stand_scenario.py` по `expected.json` |
|
||||
| `ui` | dev-сервер фронта против стенда, Playwright (`ui_smoke.py`) по основным экранам: исключения страницы, ответы `/api` ≥ 400, пустые экраны, скриншоты |
|
||||
| `bank` | CLI tabular-parser по всем табличным файлам `doc-bank` (если он есть) и сравнение с прошлым прогоном (`parser_bank.py`) |
|
||||
|
||||
Стенд снимается (`down -v`) после этапа `ui`; `--keep-stand` оставляет его для ручной проверки. Общий стенд `arm-ka`
|
||||
(данные, тома, порты) этапы `stand` и `ui` не трогают; `integration` и `e2e` пользуются его инфраструктурой.
|
||||
|
||||
## Сценарий стенда и контрольные цифры
|
||||
|
||||
`expected.json` — данные, а не код: ГК/ГСК dev-seed, пользователи, по сценарию — файлы фикстур для загрузки, `summary`
|
||||
свода, пробелы, контрольные строки с суммами, пары шахматки, ожидаемые формы ББ/ОФР. Источники цифр — `dev/qa-guide.md`
|
||||
§7.1–7.3 (РБ 2024 Q3, посчитаны вручную по файлам), прогоны 08.10 и 14.10.2026 (РБ 2025 Q1) и
|
||||
`test_intercompany_matrix_golden` ядра. Новая цифра — только с причиной в описании PR.
|
||||
|
||||
На каждый сценарий: создание консолидации (и повтор — 409), блокировка, загрузка файлов в версию, ожидание
|
||||
распознавания (PDF — через эмулятор ИС), подтверждение ОСВ-документов, `summary`, пробелы, контрольные строки, свод
|
||||
постранично = `rows_total`, экспорт xlsx (строки листов «Сводная ОСВ» и «Пробелы», `Content-Disposition`), чек-лист,
|
||||
шахматка по всем предустановленным правилам, результирующая таблица, итоги элиминации, формы ББ/ОФР; в конце — права
|
||||
(эксперт читает, но не блокирует — 403) и 404.
|
||||
|
||||
Помимо ручных цифр, сценарий сохраняет **снимки** (`stand/snapshots/`: весь свод построчно, шахматка, формы, документы)
|
||||
и сравнивает их со снимками последнего прошлого прогона в `admin/regression/`. Расхождения не красные — их нужно
|
||||
просмотреть: изменение свода или шахматки между выкладками должно объясняться задачами выкладки.
|
||||
|
||||
## Банк первички
|
||||
|
||||
`parser_bank.py` разбирает все файлы `doc-bank/index/index.csv` с маршрутом «парсер» и пишет отпечаток каждого документа
|
||||
(тип, статус, счёт, период, число строк, сумма денежных колонок, коды предупреждений). База сравнения — последний
|
||||
прошлый прогон (`--bank-baseline DIR` — явно), на первом прогоне — колонка `parse_status` индекса (прогон 20.10.2026).
|
||||
Итог — переходы «хуже» (кандидаты в регрессию, таблица в `summary.md`), «лучше» и «тот же статус, другой разбор»
|
||||
(`bank/transitions.json`). Этап не красный по переходам: регрессия это или исправление — решает человек.
|
||||
|
||||
## Если шаг красный
|
||||
|
||||
Сначала лог шага (`logs/<этап>.<шаг>.log`, путь — в `summary.md`). Частые причины не в коде: занятая NATS (preflight
|
||||
`shared-consumers`), не поднятая инфраструктура, сеть для `npm ci` / `uv`. Шаг можно перезапустить отдельно:
|
||||
`--only <этап> --out <тот же каталог>`.
|
||||
71
scripts/regression/expected.json
Normal file
71
scripts/regression/expected.json
Normal file
@ -0,0 +1,71 @@
|
||||
{
|
||||
"_comment": "Контрольные цифры сценария стенда (stand_scenario.py). Источники: dev/qa-guide.md §7.1–7.3 (РБ 2024 Q3, суммы посчитаны вручную по фикстурам), прогоны 08.10 и 14.10.2026 (2025 Q1), тест test_intercompany_matrix_golden (шахматка; встречная пара 62/60: у РБ сальдо Дт 62.01 по Счастливочной 2346459.08 в файле, у Счастливочной Кт 60.01 по РБ 2346459.05 — расхождение 3 коп. в самих книгах). Меняется осознанно: новая цифра — с причиной в описании PR. Суммы — рубли, null/отсутствие = 0.",
|
||||
"user": "ka_go@armka.local",
|
||||
"readonly_user": "expert@armka.local",
|
||||
"company_group": "Русский Бисквит",
|
||||
"related_group": "ГСК Русский Бисквит",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rb-2024-q3",
|
||||
"year": 2024,
|
||||
"quarter": "Q3",
|
||||
"uploads": [
|
||||
{"inn": "3528052718", "files": ["osv/rb/2024-Q1/*", "osv/rb/2024-Q2/*", "osv/rb/2024-Q3/*"]},
|
||||
{"inn": "3528104476", "files": ["osv/schastl/2024-Q1/*", "osv/schastl/2024-Q2/*", "osv/schastl/2024-Q3/*"]}
|
||||
],
|
||||
"summary": {"rows_total": 1385, "unmapped_total": 1236, "documents_total": 22},
|
||||
"gaps": [
|
||||
["3528052718", "01", ["Q1", "Q2"]],
|
||||
["3528052718", "58.03", ["Q1", "Q2"]],
|
||||
["3528052718", "66", ["Q1", "Q2"]],
|
||||
["3528052718", "67", ["Q1", "Q2"]],
|
||||
["3528052718", "76", ["Q1", "Q2"]],
|
||||
["3528104476", "01", ["Q1", "Q2"]],
|
||||
["3528104476", "02", ["Q1", "Q2"]]
|
||||
],
|
||||
"rows": [
|
||||
{"name": "Счастливочная 60.01 ← РБ (ИНН)", "inn": "3528104476", "account_code": "60.01", "q": "бисквит",
|
||||
"expect": {"opening_credit": "4454151.93", "turnover_debit": "128058143.65", "turnover_credit": "125950450.77",
|
||||
"closing_credit": "2346459.05", "opening_debit": "0", "closing_debit": "0",
|
||||
"counterparty_type": "GSK", "match_kind": "INN", "entry_ids": 3}},
|
||||
{"name": "Счастливочная 60.02 ← РБ (нет в Q2)", "inn": "3528104476", "account_code": "60.02", "q": "бисквит",
|
||||
"expect": {"opening_debit": "0", "opening_credit": "0", "turnover_debit": "9745019.62",
|
||||
"turnover_credit": "9745019.62", "closing_debit": "0", "closing_credit": "0",
|
||||
"counterparty_type": "GSK", "entry_ids": 2}},
|
||||
{"name": "РБ 60.01 ← «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (алиас dev-seed)", "inn": "3528052718", "account_code": "60.01", "q": "Юг-Финанс",
|
||||
"expect": {"opening_credit": "38806758.00", "turnover_debit": "307986284.00", "turnover_credit": "375490642.46",
|
||||
"closing_credit": "106311116.46", "opening_debit": "0", "closing_debit": "0",
|
||||
"counterparty_type": "GSK"}},
|
||||
{"name": "Счастливочная 62.01 ← РБ (две строки в файле)", "inn": "3528104476", "account_code": "62.01", "q": "бисквит",
|
||||
"expect": {"opening_debit": "40211938.00", "turnover_debit": "398018012.46", "turnover_credit": "330517504.00",
|
||||
"closing_debit": "107712446.46", "opening_credit": "0", "closing_credit": "0",
|
||||
"counterparty_type": "GSK", "entry_ids": 6}},
|
||||
{"name": "Счастливочная 62.02 ← РБ", "inn": "3528104476", "account_code": "62.02", "q": "бисквит",
|
||||
"expect": {"opening_credit": "24100000.00", "turnover_debit": "9900000.00", "turnover_credit": "0",
|
||||
"closing_credit": "14200000.00", "opening_debit": "0", "closing_debit": "0",
|
||||
"counterparty_type": "GSK"}}
|
||||
],
|
||||
"matrix_manual": [
|
||||
{"debit": "62", "credit": "60", "cells": [
|
||||
["3528104476", "3528052718", "107712446.46", "106311116.46"],
|
||||
["3528052718", "3528104476", "2346459.08", "2346459.05"]
|
||||
]}
|
||||
],
|
||||
"statements": {}
|
||||
},
|
||||
{
|
||||
"id": "rb-2025-q1",
|
||||
"year": 2025,
|
||||
"quarter": "Q1",
|
||||
"uploads": [
|
||||
{"inn": "3528052718", "files": ["osv/rb/2025-Q1/*", "statements/rb/Бухгалтерская отчетность 1 кв.25.pdf"]},
|
||||
{"inn": "3528104476", "files": ["osv/schastl/2025-Q1/*", "statements/schastl/БО 1 кв 2025.PDF"]}
|
||||
],
|
||||
"summary": {"rows_total": 1421, "unmapped_total": 1291, "documents_total": 16},
|
||||
"gaps": [],
|
||||
"rows": [],
|
||||
"matrix_manual": [],
|
||||
"statements": {"3528052718": ["BALANCE", "INCOME"], "3528104476": ["BALANCE", "INCOME"]}
|
||||
}
|
||||
]
|
||||
}
|
||||
173
scripts/regression/parser_bank.py
Normal file
173
scripts/regression/parser_bank.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""Прогон tabular-parser (CLI) по банку первички doc-bank и сравнение с прошлым прогоном (regress.sh, этап bank).
|
||||
|
||||
Берёт из doc-bank/index/index.csv файлы с route = «парсер» (xls/xlsx/xlsb), разбирает каждый CLI
|
||||
`python -m src.parser.cli <файл>` в api/ сервиса (6 процессов, таймаут на файл) и пишет:
|
||||
results.jsonl — строка на файл: код возврата, время, статус файла, отпечатки документов (тип, статус, счёт, период,
|
||||
число строк, сумма денежных колонок, коды предупреждений);
|
||||
summary.md — счётчики статусов и переходы против базы: прошлый прогон (--baseline DIR с results.jsonl) или,
|
||||
если его нет, колонка parse_status индекса (прогон 20.10.2026, main 6c81199).
|
||||
Переходы «лучше → хуже» (done → needs_check/ошибка, needs_check → ошибка) — кандидаты в регрессию, их надо
|
||||
просмотреть; изменения отпечатков при том же статусе перечислены отдельно. Код возврата 0 всегда, кроме сбоя самого
|
||||
скрипта: решение «регрессия или исправление» за человеком.
|
||||
|
||||
python3 -I parser_bank.py --parser-api ../tabular-parser/api --bank ../doc-bank --out DIR [--baseline DIR] [--jobs 6]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
|
||||
MONEY = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
|
||||
RANK = {"done": 0, "needs_check": 1, "failed": 2, "error": 2, "timeout": 3}
|
||||
INDEX_STATUS = {"done": "done", "needs_check": "needs_check", "ошибка": "error", "ложный done": "done"}
|
||||
|
||||
|
||||
def money_sum(rows: list[dict]) -> str:
|
||||
total = Decimal(0)
|
||||
for r in rows:
|
||||
for k in MONEY:
|
||||
v = r.get(k)
|
||||
if v not in (None, ""):
|
||||
try:
|
||||
total += Decimal(str(v))
|
||||
except InvalidOperation:
|
||||
pass
|
||||
return str(total)
|
||||
|
||||
|
||||
def fingerprint(doc: dict) -> dict:
|
||||
h = doc.get("header") or {}
|
||||
rows = doc.get("rows") or []
|
||||
lines = doc.get("lines") or []
|
||||
fp = {
|
||||
"type": doc.get("document_type"),
|
||||
"status": doc.get("status"),
|
||||
"locator": doc.get("locator"),
|
||||
"account": h.get("account_code_raw"),
|
||||
"period": [h.get("period_year"), h.get("period_quarter"), h.get("is_cumulative")],
|
||||
"rows": len(rows) or len(lines),
|
||||
"sum": money_sum(rows),
|
||||
"warnings": sorted({w.get("code") for w in doc.get("warnings") or []}),
|
||||
}
|
||||
if lines:
|
||||
fp["lines_hash"] = hashlib.sha1(json.dumps(lines, sort_keys=True, default=str).encode()).hexdigest()[:12]
|
||||
return fp
|
||||
|
||||
|
||||
def parse_one(api: Path, file: Path, timeout: int) -> dict:
|
||||
t0 = time.time()
|
||||
env = dict(os.environ, ENV_FILE=".env.test", APP_ENV="test")
|
||||
try:
|
||||
p = subprocess.run(
|
||||
[str(api / ".venv/bin/python"), "-m", "src.parser.cli", str(file)],
|
||||
cwd=api, env=env, capture_output=True, timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"rc": "timeout", "sec": timeout, "status": "timeout", "docs": []}
|
||||
res = {"rc": p.returncode, "sec": round(time.time() - t0, 2)}
|
||||
try:
|
||||
out = json.loads(p.stdout)
|
||||
except ValueError:
|
||||
res |= {"status": "error", "error": (p.stderr.decode(errors="replace").strip().splitlines() or ["?"])[-1][:300], "docs": []}
|
||||
return res
|
||||
res["status"] = out.get("status")
|
||||
if out.get("error"):
|
||||
res["error"] = str(out["error"])[:300]
|
||||
res["docs"] = [fingerprint(d) for d in out.get("documents") or []]
|
||||
return res
|
||||
|
||||
|
||||
def norm(status: str | None) -> str:
|
||||
return {"failed": "error"}.get(status or "", status or "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--parser-api", required=True, type=Path)
|
||||
ap.add_argument("--bank", required=True, type=Path)
|
||||
ap.add_argument("--out", required=True, type=Path)
|
||||
ap.add_argument("--baseline", type=Path)
|
||||
ap.add_argument("--jobs", type=int, default=6)
|
||||
ap.add_argument("--timeout", type=int, default=300)
|
||||
a = ap.parse_args()
|
||||
a.out.mkdir(parents=True, exist_ok=True)
|
||||
index = [r for r in csv.DictReader((a.bank / "index/index.csv").open()) if r["route"] == "парсер"]
|
||||
files = sorted(index, key=lambda r: -int(r["size"] or 0)) # большие первыми — короче хвост
|
||||
t0 = time.time()
|
||||
results: dict[str, dict] = {}
|
||||
|
||||
def work(r: dict) -> tuple[str, dict]:
|
||||
return r["file"], parse_one(a.parser_api.resolve(), (a.bank / r["file"]).resolve(), a.timeout)
|
||||
|
||||
with ThreadPoolExecutor(a.jobs) as ex:
|
||||
for i, (name, res) in enumerate(ex.map(work, files), 1):
|
||||
results[name] = res
|
||||
if i % 200 == 0:
|
||||
print(f"{i}/{len(files)} за {int(time.time() - t0)} с", flush=True)
|
||||
with (a.out / "results.jsonl").open("w") as f:
|
||||
for r in index:
|
||||
f.write(json.dumps({"file": r["file"], "name": r["name"]} | results[r["file"]], ensure_ascii=False) + "\n")
|
||||
|
||||
base: dict[str, dict] = {}
|
||||
base_name = ""
|
||||
if a.baseline and (a.baseline / "results.jsonl").exists():
|
||||
base_name = f"прошлый прогон `{a.baseline}`"
|
||||
for ln in (a.baseline / "results.jsonl").read_text().splitlines():
|
||||
x = json.loads(ln)
|
||||
base[x["file"]] = x
|
||||
else:
|
||||
base_name = "индекс doc-bank (parse_status, прогон 20.10.2026)"
|
||||
for r in index:
|
||||
if r["parse_status"] in INDEX_STATUS:
|
||||
base[r["file"]] = {"status": INDEX_STATUS[r["parse_status"]], "docs": None}
|
||||
|
||||
names = {r["file"]: r["name"] for r in index}
|
||||
cur = Counter(norm(x["status"]) for x in results.values())
|
||||
worse, better, changed = [], [], []
|
||||
for file, res in results.items():
|
||||
b = base.get(file)
|
||||
if not b:
|
||||
continue
|
||||
s0, s1 = norm(b["status"]), norm(res["status"])
|
||||
if RANK.get(s1, 9) > RANK.get(s0, 9):
|
||||
worse.append((file, s0, s1, res.get("error", "")))
|
||||
elif RANK.get(s1, 9) < RANK.get(s0, 9):
|
||||
better.append((file, s0, s1))
|
||||
elif b.get("docs") is not None and b["docs"] != res["docs"]:
|
||||
changed.append((file, s1))
|
||||
md = [
|
||||
"## Парсер по банку первички (doc-bank)",
|
||||
"",
|
||||
f"Файлов: {len(results)} за {int(time.time() - t0)} с; статусы: " + ", ".join(f"{k} {v}" for k, v in cur.most_common()),
|
||||
f"База сравнения: {base_name}.",
|
||||
"",
|
||||
f"- хуже, чем в базе: **{len(worse)}** (кандидаты в регрессию — просмотреть)",
|
||||
f"- лучше: {len(better)}",
|
||||
f"- тот же статус, другой разбор (отпечаток документов): {len(changed)}",
|
||||
]
|
||||
if worse:
|
||||
md += ["", "| Файл | Было | Стало | Ошибка |", "|---|---|---|---|"]
|
||||
md += [f"| {names[f]} | {s0} | {s1} | {e.replace('|', '/')[:120]} |" for f, s0, s1, e in sorted(worse, key=lambda x: names[x[0]])[:100]]
|
||||
if len(worse) > 100:
|
||||
md.append(f"| … ещё {len(worse) - 100} | | | |")
|
||||
tr = Counter(f"{s0} → {s1}" for _, s0, s1 in better)
|
||||
if tr:
|
||||
md += ["", "Переходы к лучшему: " + ", ".join(f"{k}: {v}" for k, v in tr.most_common())]
|
||||
(a.out / "summary.md").write_text("\n".join(md) + "\n")
|
||||
(a.out / "transitions.json").write_text(
|
||||
json.dumps({"worse": worse, "better": better, "changed": changed}, ensure_ascii=False, indent=1)
|
||||
)
|
||||
print("\n".join(md))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
309
scripts/regression/regress.sh
Executable file
309
scripts/regression/regress.sh
Executable file
@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env bash
|
||||
# Полная регрессия main всех репозиториев workspace перед выкладкой (arm-ka-docs#179). Описание — README.md рядом.
|
||||
#
|
||||
# scripts/regression/regress.sh [--out DIR] [--only st1,st2] [--skip st1,st2] [--keep-stand] [--allow-dirty]
|
||||
# [--bank-baseline DIR] [--list]
|
||||
#
|
||||
# Этапы идут по порядку; упавший шаг не останавливает следующие (нужна вся картина), кроме preflight: без чистых
|
||||
# main = origin/main прогон не имеет смысла (--allow-dirty — прогнать как есть, с пометкой в итоге).
|
||||
# Итог — $OUT/summary.md (SHA репозиториев, статус и время шагов, счётчики pytest, пропуски), логи — $OUT/logs/.
|
||||
# Код возврата: 0 — все шаги зелёные, 1 — есть красные, 2 — preflight не пройден или неверные аргументы.
|
||||
#
|
||||
# Workspace (каталог с arm-core/, tabular-parser/, … на main) — ARM_KA_WORKSPACE, по умолчанию родитель docs. Скрипты
|
||||
# можно запускать из worktree docs: тогда задайте ARM_KA_WORKSPACE, проверяются всё равно основные checkout'ы.
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
WS="${ARM_KA_WORKSPACE:-$(cd "$HERE/../../.." && pwd)}"
|
||||
COMPOSE_FILE="$WS/docs/dev/docker-compose.yml"
|
||||
# Изолированный стенд: свой compose-проект и порты, общий стенд arm-ka (данные, тома) не трогается
|
||||
REGRESS_PROJECT="${REGRESS_PROJECT:-arm-ka-regress}"
|
||||
# Порты — только для команд этого проекта (dcr): глобальный export пересоздал бы контейнеры arm-ka на чужих портах
|
||||
ARM_CORE_PORT=18000
|
||||
REGRESS_PORTS="POSTGRES_PORT=15432 NATS_PORT=14222 NATS_MONITOR_PORT=18222 MINIO_API_PORT=19000 MINIO_CONSOLE_PORT=19001
|
||||
ARM_CORE_PORT=$ARM_CORE_PORT TABULAR_PARSER_PORT=18001 SCAN_GATEWAY_PORT=18002"
|
||||
ALL_STAGES="preflight venv static web integration fixtures images e2e stand ui bank"
|
||||
# Репозитории под проверкой; pylib_ai_arm_statements (AURORA-1677) появится здесь после выпуска 0.1.2
|
||||
REPOS="docs fixtures arm-statements arm-core tabular-parser scan-gateway"
|
||||
|
||||
OUT="" ONLY="" SKIP="" KEEP_STAND=0 ALLOW_DIRTY=0 BANK_BASELINE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--out) OUT="$2"; shift 2 ;;
|
||||
--only) ONLY="$2"; shift 2 ;;
|
||||
--skip) SKIP="$2"; shift 2 ;;
|
||||
--keep-stand) KEEP_STAND=1; shift ;;
|
||||
--allow-dirty) ALLOW_DIRTY=1; shift ;;
|
||||
--bank-baseline) BANK_BASELINE="$2"; shift 2 ;;
|
||||
--list) echo "$ALL_STAGES"; exit 0 ;;
|
||||
-h | --help) sed -n '2,13p' "$0"; exit 0 ;;
|
||||
*) echo "неизвестный аргумент: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
for s in ${ONLY//,/ } ${SKIP//,/ }; do
|
||||
case " $ALL_STAGES " in *" $s "*) ;; *) echo "неизвестный этап: $s (есть: $ALL_STAGES)" >&2; exit 2 ;; esac
|
||||
done
|
||||
|
||||
OUT="${OUT:-$WS/admin/regression/$(date +%Y-%m-%d_%H%M)}"
|
||||
mkdir -p "$OUT/logs"
|
||||
RESULTS="$OUT/results.tsv"
|
||||
export ARM_KA_DOCS="$WS/docs" ARM_KA_FIXTURES="$WS/fixtures"
|
||||
# ENV_FILE в окружении перебивает pytest.ini сервисов — не даём просочиться из оболочки
|
||||
unset ENV_FILE
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
|
||||
# step <этап> <шаг> <команда…>: вывод — в logs/<этап>.<шаг>.log, строка итога — в results.tsv
|
||||
step() {
|
||||
local stage=$1 name=$2 t0 rc lf
|
||||
shift 2
|
||||
lf="$OUT/logs/$stage.$name.log"
|
||||
t0=$(date +%s)
|
||||
log "$stage/$name …"
|
||||
("$@") >"$lf" 2>&1
|
||||
rc=$?
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "$stage" "$name" "$rc" "$(($(date +%s) - t0))" "$lf" >>"$RESULTS"
|
||||
if [ $rc -eq 0 ]; then log "$stage/$name ok"; else log "$stage/$name КРАСНЫЙ (rc=$rc, $lf)"; fi
|
||||
return $rc
|
||||
}
|
||||
|
||||
want() {
|
||||
local s=$1
|
||||
[ -n "$ONLY" ] && { case ",$ONLY," in *",$s,"*) ;; *) return 1 ;; esac; }
|
||||
[ -n "$SKIP" ] && { case ",$SKIP," in *",$s,"*) return 1 ;; esac; }
|
||||
return 0
|
||||
}
|
||||
|
||||
dc() { docker compose -f "$COMPOSE_FILE" "$@"; }
|
||||
# shellcheck disable=SC2086 — REGRESS_PORTS намеренно раскладывается на слова
|
||||
dcr() { env $REGRESS_PORTS docker compose -f "$COMPOSE_FILE" -p "$REGRESS_PROJECT" --profile services "$@"; }
|
||||
mk() { make --no-print-directory -C "$WS/$1" "${@:2}"; }
|
||||
|
||||
# --- preflight -------------------------------------------------------------------------------------------------------
|
||||
|
||||
repo_state() {
|
||||
local r=$1 dir="$WS/$1" br head origin dirty
|
||||
[ -d "$dir/.git" ] || [ -f "$dir/.git" ] || { echo "$r: нет репозитория в $dir"; return 1; }
|
||||
git -C "$dir" fetch -q origin || { echo "$r: fetch не удался"; return 1; }
|
||||
br=$(git -C "$dir" rev-parse --abbrev-ref HEAD)
|
||||
head=$(git -C "$dir" rev-parse HEAD)
|
||||
origin=$(git -C "$dir" rev-parse origin/main)
|
||||
dirty=$(git -C "$dir" status --porcelain --untracked-files=no | wc -l | tr -d ' ')
|
||||
printf '%s\t%s\t%s\t%s\n' "$r" "$br" "$head" "$(git -C "$dir" log -1 --format='%cs %s' HEAD | cut -c1-100)" >>"$OUT/shas.tsv"
|
||||
echo "$r: ветка $br, HEAD ${head:0:7}, origin/main ${origin:0:7}, изменённых файлов $dirty"
|
||||
[ "$br" = main ] && [ "$head" = "$origin" ] && [ "$dirty" = 0 ]
|
||||
}
|
||||
|
||||
st_preflight() {
|
||||
: >"$OUT/shas.tsv"
|
||||
local bad=0 r
|
||||
for r in $REPOS; do step preflight "repo-$r" repo_state "$r" || bad=1; done
|
||||
step preflight docker docker info --format '{{.ServerVersion}}' || bad=1
|
||||
step preflight tools sh -c 'command -v uv && command -v npm && command -v openspec' || bad=1
|
||||
# Чужие потребители общей NATS (arm-ka): контейнеры сервисов и локальное ядро на :8000 перехватывают сообщения
|
||||
# queue group, интеграционные тесты и e2e тогда падают по таймауту (scan-gateway — 08.10, arm-core e2e — docstring)
|
||||
step preflight shared-consumers sh -c '
|
||||
running=$(docker ps --filter label=com.docker.compose.project=arm-ka --format "{{.Names}}" | grep -E "arm-core-1|tabular-parser|scan-gateway|scan-emulator" || true)
|
||||
local8000=$(lsof -nP -iTCP:8000 -sTCP:LISTEN 2>/dev/null | tail -n +2 || true)
|
||||
[ -z "$running" ] && [ -z "$local8000" ] && { echo "сервисов на общей NATS нет"; exit 0; }
|
||||
echo "на общем стенде arm-ka работают потребители NATS — остановите их до integration/e2e:"; echo "$running"; echo "$local8000"; exit 1' || bad=1
|
||||
if [ $bad -ne 0 ] && [ $ALLOW_DIRTY -eq 0 ]; then
|
||||
log "preflight не пройден — прогон остановлен (--allow-dirty — прогнать как есть)"
|
||||
return 2
|
||||
fi
|
||||
[ $bad -ne 0 ] && echo "allow-dirty" >"$OUT/allow-dirty"
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- окружения -------------------------------------------------------------------------------------------------------
|
||||
|
||||
st_venv() {
|
||||
local r
|
||||
for r in docs arm-statements tabular-parser scan-gateway arm-core; do step venv "$r" mk "$r" venv; done
|
||||
}
|
||||
|
||||
# --- lint + unit (без инфраструктуры) --------------------------------------------------------------------------------
|
||||
|
||||
st_static() {
|
||||
step static docs-check mk docs check
|
||||
step static docs-statements-check mk docs statements-check
|
||||
step static arm-statements-lint mk arm-statements lint
|
||||
step static arm-statements-test mk arm-statements test
|
||||
local r
|
||||
for r in tabular-parser scan-gateway arm-core; do
|
||||
step static "$r-lint" mk "$r" lint
|
||||
step static "$r-test" mk "$r" test
|
||||
done
|
||||
step static arm-core-dbml-check mk arm-core dbml-check
|
||||
}
|
||||
|
||||
# --- фронт arm-core/web ----------------------------------------------------------------------------------------------
|
||||
# В чистой копии web/ (без node_modules и dist) во временном каталоге, main не меняется (npm install в main переписывает
|
||||
# package-lock.json, а node_modules main бывают собраны под другую ОС). Шаг npm-ci — воспроизводимость lock: красный,
|
||||
# если lock расходится с package.json. Остальные шаги идут на установке без lock (npm-install): lock собран на linux, и
|
||||
# по нему npm не ставит нативные пакеты другой ОС (biome, rspack, canvas — npm/cli#4828); так же ставит и контур, в
|
||||
# снимке lock нет (dev/contour-transfer-guide.md §3.2).
|
||||
|
||||
WEB_TMP="" STAND_UP=0
|
||||
PLAYWRIGHT="${PLAYWRIGHT:-playwright==1.55.0}"
|
||||
web_copy() {
|
||||
WEB_TMP=$(mktemp -d "${TMPDIR:-/tmp}/regress-web.XXXXXX")
|
||||
rsync -a --exclude node_modules --exclude dist "$WS/arm-core/web/" "$WEB_TMP/"
|
||||
echo "$WEB_TMP"
|
||||
}
|
||||
|
||||
web_install() { rm -rf "$1/node_modules" "$1/package-lock.json" && npm --prefix "$1" install --no-audit --no-fund; }
|
||||
|
||||
st_web() {
|
||||
web_copy >/dev/null
|
||||
local w="$WEB_TMP"
|
||||
echo "$w" >"$OUT/web-tmp"
|
||||
step web npm-ci npm --prefix "$w" ci --no-audit --no-fund --dry-run
|
||||
step web npm-install web_install "$w"
|
||||
step web biome sh -c "cd '$w' && npx biome check src"
|
||||
step web tsc sh -c "cd '$w' && npx tsc --noEmit"
|
||||
step web eslint-prettier npm --prefix "$w" run lint
|
||||
step web vitest npm --prefix "$w" test
|
||||
step web build npm --prefix "$w" run build
|
||||
}
|
||||
|
||||
# --- интеграционные (общая инфраструктура arm-ka: тесты создают свои БД, рабочую armka не трогают) ----------------------
|
||||
|
||||
st_integration() {
|
||||
step integration infra-up mk scan-gateway up
|
||||
step integration arm-core-check-infra mk arm-core check-infra
|
||||
step integration tabular-parser mk tabular-parser test-integration
|
||||
step integration scan-gateway mk scan-gateway test-integration
|
||||
# включает цикл миграций upgrade → downgrade base → upgrade и сверку с create_all (tests/test_migrations.py)
|
||||
step integration arm-core mk arm-core test-integration
|
||||
}
|
||||
|
||||
# --- реальные данные -------------------------------------------------------------------------------------------------
|
||||
|
||||
st_fixtures() {
|
||||
step fixtures tabular-parser-fixtures mk tabular-parser test-fixtures
|
||||
step fixtures tabular-parser-golden mk tabular-parser golden-check
|
||||
}
|
||||
|
||||
# --- образы ----------------------------------------------------------------------------------------------------------
|
||||
# Сборка профиля services собирает arm-core:dev, tabular-parser:dev, scan-gateway:dev из main (Dockerfile в корне репо);
|
||||
# эти же образы потом поднимают e2e и стенд. Эквивалент make docker-build трёх сервисов.
|
||||
|
||||
st_images() {
|
||||
step images build dcr build
|
||||
}
|
||||
|
||||
# --- e2e ядра с настоящим tabular-parser (общая NATS/MinIO из .env.test) ---------------------------------------------
|
||||
|
||||
e2e_run() {
|
||||
local was rc
|
||||
was=$(docker ps -q --filter name=arm-ka-tabular-parser-1)
|
||||
dc -p arm-ka --profile services up -d --no-build --wait tabular-parser || return 1
|
||||
mk arm-core e2e
|
||||
rc=$?
|
||||
[ -z "$was" ] && dc -p arm-ka --profile services stop tabular-parser
|
||||
return $rc
|
||||
}
|
||||
|
||||
st_e2e() {
|
||||
step e2e arm-core-osv-ingest e2e_run
|
||||
# пропуск («профиль services не поднят») даёт код 0 — его считаем красным
|
||||
if grep -q "SKIPPED" "$OUT/logs/e2e.arm-core-osv-ingest.log"; then
|
||||
printf 'e2e\tnot-skipped\t1\t0\t%s\n' "$OUT/logs/e2e.arm-core-osv-ingest.log" >>"$RESULTS"
|
||||
log "e2e/not-skipped КРАСНЫЙ: e2e пропущен"
|
||||
fi
|
||||
}
|
||||
|
||||
# prev_run <подкаталог/файл>: каталог <подкаталог> последнего прошлого прогона рядом с $OUT, где файл есть (база сравнения)
|
||||
prev_run() {
|
||||
local f
|
||||
f=$(find "$(dirname "$OUT")" -mindepth 3 -maxdepth 3 -path "*/$1" ! -path "$OUT/*" 2>/dev/null | sort | tail -1)
|
||||
[ -n "$f" ] && dirname "$f"
|
||||
}
|
||||
|
||||
# --- изолированный стенд с нуля: миграции, seed --dev, сценарий по API -----------------------------------------------
|
||||
|
||||
stand_down() { dcr down -v --remove-orphans; }
|
||||
|
||||
st_stand() {
|
||||
local stand_base
|
||||
stand_base=$(prev_run stand/report.json)
|
||||
step stand down-before stand_down
|
||||
step stand up dcr up -d --no-build --wait \
|
||||
postgres nats minio arm-core tabular-parser scan-gateway scan-emulator
|
||||
step stand scenario python3 -I "$HERE/stand_scenario.py" --api "http://127.0.0.1:$ARM_CORE_PORT" \
|
||||
--fixtures "$WS/fixtures" --expected "$HERE/expected.json" --out "$OUT/stand" \
|
||||
${stand_base:+--baseline "$stand_base"}
|
||||
STAND_UP=1
|
||||
}
|
||||
|
||||
# Стенд снимается после ui (экраны смотрят на его данные); --keep-stand — оставить для ручной проверки
|
||||
stand_teardown() {
|
||||
[ "$STAND_UP" = 1 ] || return 0
|
||||
dcr logs --no-color >"$OUT/logs/stand.compose.log" 2>&1
|
||||
if [ $KEEP_STAND -eq 1 ]; then
|
||||
log "стенд оставлен: проект $REGRESS_PROJECT, ядро http://127.0.0.1:$ARM_CORE_PORT (снять: docker compose -p $REGRESS_PROJECT --profile services down -v)"
|
||||
else
|
||||
step stand down-after stand_down
|
||||
fi
|
||||
}
|
||||
|
||||
# --- UI-смоук: dev-сервер фронта (копия web/ из этапа web) против стенда, Playwright по основным экранам --------------
|
||||
|
||||
WEB_PORT=13000
|
||||
st_ui() {
|
||||
[ "$STAND_UP" = 1 ] && [ -f "$OUT/stand/report.json" ] || {
|
||||
printf 'ui\tstand-required\t1\t0\t-\n' >>"$RESULTS"
|
||||
log "ui: стенд не поднят в этом запуске (этап stand) — КРАСНЫЙ"
|
||||
return 1
|
||||
}
|
||||
if [ -z "$WEB_TMP" ]; then
|
||||
web_copy >/dev/null
|
||||
step ui npm-install web_install "$WEB_TMP"
|
||||
fi
|
||||
step ui browser uv run --with "$PLAYWRIGHT" playwright install chromium
|
||||
(cd "$WEB_TMP" && REACT_APP_ROOT_PATH=/ ARM_CORE_URL="http://127.0.0.1:$ARM_CORE_PORT" \
|
||||
exec npx rsbuild dev --port $WEB_PORT) >"$OUT/logs/ui.web-server.log" 2>&1 &
|
||||
step ui web-server sh -c "for i in \$(seq 1 60); do curl -sf -o /dev/null http://127.0.0.1:$WEB_PORT/ && exit 0; sleep 2; done;
|
||||
echo 'dev-сервер фронта не поднялся за 120 с'; tail -20 '$OUT/logs/ui.web-server.log'; exit 1" || return 1
|
||||
step ui smoke uv run --with "$PLAYWRIGHT" python "$HERE/ui_smoke.py" --web "http://127.0.0.1:$WEB_PORT" \
|
||||
--api "http://127.0.0.1:$ARM_CORE_PORT" --stand-report "$OUT/stand/report.json" --out "$OUT/ui"
|
||||
lsof -ti "tcp:$WEB_PORT" -sTCP:LISTEN | xargs kill 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- парсер по банку первички (doc-bank) со сравнением с прошлым прогоном --------------------------------------------
|
||||
|
||||
st_bank() {
|
||||
[ -d "$WS/doc-bank/files" ] || { log "bank: нет $WS/doc-bank — этап пропущен"; return 0; }
|
||||
local base="${BANK_BASELINE:-$(prev_run bank/results.jsonl)}"
|
||||
step bank parse "$WS/tabular-parser/api/.venv/bin/python" -I "$HERE/parser_bank.py" \
|
||||
--parser-api "$WS/tabular-parser/api" --bank "$WS/doc-bank" --out "$OUT/bank" ${base:+--baseline "$base"}
|
||||
}
|
||||
|
||||
# --- итог ------------------------------------------------------------------------------------------------------------
|
||||
|
||||
summary() { python3 -I "$HERE/summary.py" "$OUT" "$ALL_STAGES"; }
|
||||
|
||||
touch "$RESULTS"
|
||||
# Повторный запуск этапов в тот же --out: строки этих этапов из прошлого запуска убираем, остальные остаются в итоге
|
||||
for s in $ALL_STAGES; do
|
||||
want "$s" || continue
|
||||
awk -F'\t' -v s="$s" '$1 != s' "$RESULTS" >"$RESULTS.tmp" && mv "$RESULTS.tmp" "$RESULTS"
|
||||
done
|
||||
|
||||
log "регрессия: workspace $WS, результат $OUT"
|
||||
rc=0
|
||||
for s in $ALL_STAGES; do
|
||||
want "$s" || continue
|
||||
"st_$s"
|
||||
src=$?
|
||||
if [ "$s" = preflight ] && [ $src -eq 2 ]; then rc=2; break; fi
|
||||
done
|
||||
stand_teardown
|
||||
summary
|
||||
[ -n "$WEB_TMP" ] && rm -rf "$WEB_TMP"
|
||||
# awk завершается кодом 0, если есть красный шаг
|
||||
if [ $rc -eq 0 ] && awk -F'\t' '$3 != 0 { bad = 1 } END { exit !bad }' "$RESULTS"; then rc=1; fi
|
||||
log "итог: $OUT/summary.md (код $rc)"
|
||||
exit $rc
|
||||
452
scripts/regression/stand_scenario.py
Normal file
452
scripts/regression/stand_scenario.py
Normal file
@ -0,0 +1,452 @@
|
||||
"""Сценарий регрессии на стенде с нуля (regress.sh, этап stand): только stdlib, запуск `python3 -I`.
|
||||
|
||||
Для каждого сценария expected.json: консолидация ГК/ГСК за период → блокировка → загрузка файлов фикстур в версию →
|
||||
ожидание распознавания (tabular-parser, scan-gateway + эмулятор ИС) → подтверждение ОСВ-документов → проверки свода
|
||||
(summary, пробелы, контрольные строки), экспорта xlsx, шахматки, форм ББ/ОФР; плюс негатив (повтор создания — 409,
|
||||
блокировка без права EDIT — 403). Итог — report.json (проверки) и snapshots/<сценарий>.*.json (весь свод, шахматка,
|
||||
формы); с --baseline снимки сравниваются с прошлым прогоном (расхождения — в report.json, раздел diffs).
|
||||
|
||||
python3 -I stand_scenario.py --api http://127.0.0.1:18000 --fixtures ../fixtures --expected expected.json --out DIR
|
||||
[--baseline DIR_ПРОШЛОГО_stand] [--timeout 600]
|
||||
|
||||
Код возврата: 0 — все проверки зелёные, 1 — есть красные (расхождения со снимком прошлого прогона красными не считаются,
|
||||
их надо просмотреть).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
MONEY = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
|
||||
PENDING_FILE = {"UPLOADED", "PROCESSING"}
|
||||
# locator документов из OCR содержит id задания (file:<uuid>.json;table:0) — в снимке заменяется
|
||||
UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
|
||||
TO_CONFIRM = {"RECOGNIZED", "NEEDS_CHECK"}
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, base: str) -> None:
|
||||
self.base = base.rstrip("/") + "/api/v1"
|
||||
self.token = ""
|
||||
|
||||
def call(self, method: str, path: str, body=None, *, raw=False, ctype=None, params=None):
|
||||
url = self.base + path + ("?" + urllib.parse.urlencode(params, doseq=True) if params else "")
|
||||
data, headers = None, {}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
if body is not None:
|
||||
if ctype:
|
||||
data, headers["Content-Type"] = body, ctype
|
||||
else:
|
||||
data, headers["Content-Type"] = json.dumps(body).encode(), "application/json"
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
payload = r.read()
|
||||
return r.status, (payload if raw else (json.loads(payload) if payload else None)), dict(r.headers)
|
||||
except urllib.error.HTTPError as e:
|
||||
payload = e.read()
|
||||
try:
|
||||
return e.code, json.loads(payload), dict(e.headers)
|
||||
except ValueError:
|
||||
return e.code, payload.decode(errors="replace"), dict(e.headers)
|
||||
|
||||
def ok(self, method: str, path: str, body=None, **kw):
|
||||
status, data, _ = self.call(method, path, body, **kw)
|
||||
if status >= 300:
|
||||
raise RuntimeError(f"{method} {path}: {status} {str(data)[:300]}")
|
||||
return data
|
||||
|
||||
def login(self, email: str) -> str:
|
||||
self.token = ""
|
||||
self.token = self.ok("POST", "/dev/token", {"email": email})["access_token"]
|
||||
return self.token
|
||||
|
||||
def upload(self, path: str, file: Path):
|
||||
boundary = uuid.uuid4().hex
|
||||
name = file.name.replace('"', "%22")
|
||||
body = (
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="files"; filename="{name}"\r\n'
|
||||
"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode() + file.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
||||
return self.ok("POST", path, body, ctype=f"multipart/form-data; boundary={boundary}")
|
||||
|
||||
def pages(self, path: str, params=None, limit=200):
|
||||
offset, items = 0, []
|
||||
while True:
|
||||
page = self.ok("GET", path, params={**(params or {}), "limit": limit, "offset": offset})
|
||||
items += page["items"]
|
||||
offset += limit
|
||||
if offset >= page["total"]:
|
||||
return items
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self) -> None:
|
||||
self.checks: list[dict] = []
|
||||
self.diffs: list[dict] = []
|
||||
|
||||
def check(self, name: str, ok: bool, detail: str = "") -> bool:
|
||||
self.checks.append({"name": name, "ok": bool(ok), "detail": detail})
|
||||
print(("ok " if ok else "FAIL ") + name + (f": {detail}" if detail else ""), flush=True)
|
||||
return ok
|
||||
|
||||
|
||||
def dec(v) -> Decimal:
|
||||
return Decimal(str(v)) if v not in (None, "") else Decimal(0)
|
||||
|
||||
|
||||
def scrub(obj, inn_by_le: dict):
|
||||
"""Снимок без идентификаторов стенда: id ЮЛ → ИНН, прочие id и списки id убираются (меняются на каждом стенде)."""
|
||||
if isinstance(obj, list):
|
||||
return [scrub(x, inn_by_le) for x in obj]
|
||||
if not isinstance(obj, dict):
|
||||
return obj
|
||||
out = {}
|
||||
for k, v in obj.items():
|
||||
if isinstance(v, str) and v in inn_by_le:
|
||||
out[k] = inn_by_le[v]
|
||||
elif k == "id" or k.endswith(("_id", "_ids")):
|
||||
continue
|
||||
else:
|
||||
out[k] = scrub(v, inn_by_le)
|
||||
return out
|
||||
|
||||
|
||||
def wait_health(base: str, timeout: float) -> dict:
|
||||
deadline, last = time.time() + timeout, None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(base.rstrip("/") + "/healthcheck", timeout=5) as r:
|
||||
last = json.loads(r.read())
|
||||
if last.get("nats") == "ok":
|
||||
return last
|
||||
except (OSError, ValueError) as e:
|
||||
last = {"error": str(e)}
|
||||
time.sleep(2)
|
||||
raise RuntimeError(f"ядро не готово за {timeout} с: {last}")
|
||||
|
||||
|
||||
def xlsx_rows(content: bytes) -> dict[str, int]:
|
||||
"""Число строк <row> на листе по имени листа (без openpyxl: только stdlib)."""
|
||||
z = zipfile.ZipFile(io.BytesIO(content))
|
||||
wb = z.read("xl/workbook.xml").decode()
|
||||
rels = z.read("xl/_rels/workbook.xml.rels").decode()
|
||||
targets = dict(re.findall(r'<Relationship[^>]*Id="([^"]+)"[^>]*Target="([^"]+)"', rels))
|
||||
targets |= {k: v for v, k in re.findall(r'<Relationship[^>]*Target="([^"]+)"[^>]*Id="([^"]+)"', rels)}
|
||||
out = {}
|
||||
for name, rid in re.findall(r'<sheet[^>]*name="([^"]+)"[^>]*r:id="([^"]+)"', wb):
|
||||
target = targets[rid].lstrip("/")
|
||||
target = target if target.startswith("xl/") else "xl/" + target
|
||||
out[name] = len(re.findall(r"<row[ >]", z.read(target).decode()))
|
||||
return out
|
||||
|
||||
|
||||
def run_scenario(api: Api, sc: dict, ctx: dict, fixtures: Path, rep: Report, out: Path, timeout: float) -> None:
|
||||
sid = sc["id"]
|
||||
by_inn = ctx["by_inn"]
|
||||
status, card, _ = api.call(
|
||||
"POST",
|
||||
"/consolidations",
|
||||
{"company_group_id": ctx["group_id"], "related_group_id": ctx["related_id"], "year": sc["year"], "quarter": sc["quarter"]},
|
||||
)
|
||||
if not rep.check(f"{sid}: создание консолидации", status == 201, f"{status}"):
|
||||
return
|
||||
vid, cid = card["id"], card["consolidation_id"]
|
||||
rep.check(f"{sid}: карточка версии", card["status"] == "INITIATED" and card["version_no"] == 1, f"status={card['status']}")
|
||||
st2, body2, _ = api.call(
|
||||
"POST",
|
||||
"/consolidations",
|
||||
{"company_group_id": ctx["group_id"], "related_group_id": ctx["related_id"], "year": sc["year"], "quarter": sc["quarter"]},
|
||||
)
|
||||
rep.check(f"{sid}: повтор создания — 409", st2 == 409, f"{st2} {str(body2)[:120]}")
|
||||
api.ok("POST", f"/consolidations/versions/{vid}/lock")
|
||||
|
||||
# загрузка
|
||||
sent = 0
|
||||
for up in sc["uploads"]:
|
||||
le = by_inn[up["inn"]]
|
||||
files = sorted(p for pat in up["files"] for p in fixtures.glob(pat) if p.is_file())
|
||||
for f in files:
|
||||
res = api.upload(f"/consolidations/{cid}/legal-entities/{le}/files", f)
|
||||
if res["rejected"]:
|
||||
rep.check(f"{sid}: загрузка {f.name}", False, str(res["rejected"])[:200])
|
||||
sent += len(res["accepted"])
|
||||
expected_files = sum(len([p for pat in up["files"] for p in fixtures.glob(pat) if p.is_file()]) for up in sc["uploads"])
|
||||
rep.check(f"{sid}: файлы приняты", sent == expected_files, f"{sent} из {expected_files}")
|
||||
|
||||
# распознавание
|
||||
t0, files = time.time(), []
|
||||
while time.time() - t0 < timeout:
|
||||
files = [f for up in sc["uploads"] for f in api.pages(f"/consolidations/{cid}/legal-entities/{by_inn[up['inn']]}/files")]
|
||||
if files and not any(f["status"] in PENDING_FILE for f in files):
|
||||
break
|
||||
time.sleep(3)
|
||||
by_status: dict[str, int] = {}
|
||||
for f in files:
|
||||
by_status[f["status"]] = by_status.get(f["status"], 0) + 1
|
||||
rep.check(
|
||||
f"{sid}: распознавание завершено за {int(time.time() - t0)} с",
|
||||
by_status.get("PROCESSED", 0) == expected_files,
|
||||
json.dumps(by_status, ensure_ascii=False),
|
||||
)
|
||||
for f in files:
|
||||
if f["status"] != "PROCESSED":
|
||||
job = f.get("job") or {}
|
||||
rep.check(f"{sid}: файл {f['original_name']}", False, f"{f['status']} {job.get('error_code')} {job.get('error_message')}")
|
||||
|
||||
# подтверждение ОСВ-документов (чистые уже CONFIRMED — автоподтверждение OQ-157)
|
||||
confirmed = 0
|
||||
for f in files:
|
||||
for d in f["documents"]:
|
||||
if d["document_type"] == "OSV" and d["status"] in TO_CONFIRM:
|
||||
st, body, _ = api.call("POST", f"/documents/{d['id']}/confirm")
|
||||
ok = st in (200, 201) or (st == 409 and "ALREADY_CONFIRMED" in str(body))
|
||||
rep.check(f"{sid}: подтверждение {f['original_name']} / {d['locator']}", ok, f"{st}") if not ok else None
|
||||
confirmed += 1
|
||||
docs = [(f["original_name"], d) for f in files for d in f["documents"]]
|
||||
(out / "snapshots" / f"{sid}.documents.json").write_text(
|
||||
json.dumps(
|
||||
sorted(
|
||||
[
|
||||
{k: d.get(k) for k in ("document_type", "osv_account_code", "period_year", "period_quarter", "is_cumulative", "period_check", "counterparty_check", "locator", "issue_counts")}
|
||||
| {"file": n, "status_after_recognition": d["status"], "locator": UUID_RE.sub("<uuid>", str(d["locator"]))}
|
||||
for n, d in docs
|
||||
],
|
||||
key=lambda x: (x["file"], str(x["locator"])),
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=1,
|
||||
)
|
||||
)
|
||||
print(f"{sid}: подтверждено вручную {confirmed}", flush=True)
|
||||
for kind in ("OSV", "BALANCE", "INCOME"):
|
||||
doc = next((d for _, d in docs if d["document_type"] == kind), None)
|
||||
if doc:
|
||||
ctx["documents"][f"{sid}-{kind.lower()}"] = doc["id"]
|
||||
|
||||
# свод
|
||||
summary = api.ok("GET", f"/consolidations/versions/{vid}/osv/summary")
|
||||
for k, v in sc["summary"].items():
|
||||
rep.check(f"{sid}: summary.{k}", summary[k] == v, f"{summary[k]} (ожидалось {v})")
|
||||
inn_by_le = {v: k for k, v in by_inn.items()}
|
||||
gaps = sorted([inn_by_le.get(g["legal_entity_id"], g["legal_entity_name"]), g["account_code"], sorted(g["missing_quarters"])] for g in summary["gaps"])
|
||||
rep.check(f"{sid}: пробелы", gaps == sorted(sc["gaps"]), f"{len(gaps)}: {gaps}" if gaps != sorted(sc["gaps"]) else f"{len(gaps)}")
|
||||
card = api.ok("GET", f"/consolidations/versions/{vid}")
|
||||
rep.check(f"{sid}: статус версии после загрузки", card["status"] == "DATA_CORRECTION", card["status"])
|
||||
|
||||
for row in sc["rows"]:
|
||||
items = [
|
||||
r
|
||||
for r in api.pages(f"/consolidations/versions/{vid}/osv", {"legal_entity_id": by_inn[row["inn"]], "q": row["q"]})
|
||||
if r["account_code"] == row["account_code"]
|
||||
]
|
||||
if not rep.check(f"{sid}: строка «{row['name']}» найдена", len(items) == 1, f"строк: {len(items)}"):
|
||||
continue
|
||||
r, bad = items[0], []
|
||||
for k, v in row["expect"].items():
|
||||
if k in MONEY:
|
||||
if dec(r[k]) != dec(v):
|
||||
bad.append(f"{k}={r[k]} (ожидалось {v})")
|
||||
elif k == "entry_ids":
|
||||
if len(r["entry_ids"]) != v:
|
||||
bad.append(f"entry_ids={len(r['entry_ids'])} (ожидалось {v})")
|
||||
elif r.get(k) != v:
|
||||
bad.append(f"{k}={r.get(k)} (ожидалось {v})")
|
||||
rep.check(f"{sid}: строка «{row['name']}» суммы", not bad, "; ".join(bad))
|
||||
|
||||
rows = api.pages(f"/consolidations/versions/{vid}/osv", limit=2000)
|
||||
rep.check(f"{sid}: свод постранично = rows_total", len(rows) == summary["rows_total"], f"{len(rows)}")
|
||||
snap_rows = sorted(
|
||||
(
|
||||
{
|
||||
"le": inn_by_le.get(r["legal_entity_id"], r["legal_entity_name"]),
|
||||
"account": r["account_code"],
|
||||
"row": r["row_number"],
|
||||
"cp": r["counterparty_name"],
|
||||
"cp_inn": r["counterparty_inn"],
|
||||
"type": r["counterparty_type"],
|
||||
"match": r["match_kind"],
|
||||
}
|
||||
| {k: str(dec(r[k])) for k in MONEY}
|
||||
for r in rows
|
||||
),
|
||||
key=lambda x: (x["le"], str(x["account"]), str(x["cp"]), x["row"] or 0),
|
||||
)
|
||||
(out / "snapshots" / f"{sid}.osv.json").write_text(json.dumps(snap_rows, ensure_ascii=False, indent=0))
|
||||
types: dict[str, int] = {}
|
||||
for r in rows:
|
||||
types[r["counterparty_type"]] = types.get(r["counterparty_type"], 0) + 1
|
||||
print(f"{sid}: типы контрагентов {types}", flush=True)
|
||||
|
||||
# экспорт
|
||||
st, content, headers = api.call("GET", f"/consolidations/versions/{vid}/osv/export", raw=True)
|
||||
if rep.check(f"{sid}: экспорт свода 200", st == 200, f"{st}"):
|
||||
sheets = xlsx_rows(content)
|
||||
(out / f"{sid}.svod.xlsx").write_bytes(content)
|
||||
main_rows = sheets.get("Сводная ОСВ", -1) - 1
|
||||
gaps_rows = sheets.get("Пробелы", -1) - 1
|
||||
rep.check(f"{sid}: экспорт — строк = rows_total", main_rows == summary["rows_total"], f"{sheets}")
|
||||
rep.check(f"{sid}: экспорт — пробелов = summary.gaps", gaps_rows == len(summary["gaps"]), f"{gaps_rows}")
|
||||
cd = headers.get("Content-Disposition") or headers.get("content-disposition") or ""
|
||||
rep.check(f"{sid}: экспорт — Content-Disposition", "attachment" in cd and ".xlsx" in cd, cd[:120])
|
||||
|
||||
# чек-лист
|
||||
st, checklist, _ = api.call("GET", f"/consolidations/versions/{vid}/checklist")
|
||||
rep.check(f"{sid}: чек-лист 200", st == 200, f"{st}")
|
||||
|
||||
# шахматка: все предустановленные правила + результирующая таблица
|
||||
rules = api.ok("GET", f"/consolidations/versions/{vid}/elimination-rules")["rules"]
|
||||
matrix = {}
|
||||
for rule in rules:
|
||||
m = api.ok("GET", f"/consolidations/versions/{vid}/intercompany-matrix/preset/{rule['id']}")
|
||||
names = {c["id"]: inn_by_le.get(c["id"], c["name"]) for c in m["companies"]}
|
||||
matrix[f"{rule['rule_no']} {rule['statement_type']} {rule['side_1']} {rule['measure']}"] = sorted(
|
||||
[names.get(c["company_a_id"]), names.get(c["company_b_id"]), str(dec(c["amount_a"])), str(dec(c["amount_b"])), str(dec(c["variance_amount"])), c["one_sided"]]
|
||||
for c in m["cells"]
|
||||
)
|
||||
st, result_table, _ = api.call("GET", f"/consolidations/versions/{vid}/intercompany-matrix/result-table")
|
||||
rep.check(f"{sid}: результирующая таблица 200", st == 200, f"{st}")
|
||||
st, totals, _ = api.call("GET", f"/consolidations/versions/{vid}/elimination-totals")
|
||||
rep.check(f"{sid}: итоги элиминации 200", st == 200, f"{st}")
|
||||
rep.check(f"{sid}: правил элиминации", len(rules) > 0, f"{len(rules)}")
|
||||
# ручная шахматка «Дт <счёт> / Кт <счёт>» — как в test_intercompany_matrix_golden ядра; предустановленные правила
|
||||
# собирают стороны шире одного счёта (например, правило 6 — ещё 76.06 и 76.АВ), их суммы — только в снимке
|
||||
accounts = {acc["code"]: acc["id"] for acc in ctx["accounts"]}
|
||||
for m in sc["matrix_manual"]:
|
||||
body = {"debit_account_ids": [accounts[m["debit"]]], "credit_account_ids": [accounts[m["credit"]]]}
|
||||
st, res, _ = api.call("POST", f"/consolidations/versions/{vid}/intercompany-matrix/manual", body)
|
||||
if not rep.check(f"{sid}: шахматка Дт {m['debit']} / Кт {m['credit']} — 200", st == 200, f"{st} {str(res)[:200]}"):
|
||||
continue
|
||||
got = {
|
||||
(inn_by_le.get(c["company_a_id"]), inn_by_le.get(c["company_b_id"])): [str(dec(c["amount_a"])), str(dec(c["amount_b"]))]
|
||||
for c in res["cells"]
|
||||
}
|
||||
matrix[f"manual Дт {m['debit']} / Кт {m['credit']}"] = sorted([*k, *v] for k, v in got.items())
|
||||
for a_inn, b_inn, amount_a, amount_b in m["cells"]:
|
||||
cell = got.get((a_inn, b_inn))
|
||||
rep.check(
|
||||
f"{sid}: шахматка Дт {m['debit']} / Кт {m['credit']}, {a_inn} → {b_inn}",
|
||||
cell is not None and dec(cell[0]) == dec(amount_a) and dec(cell[1]) == dec(amount_b),
|
||||
f"{cell} (ожидалось {[amount_a, amount_b]})",
|
||||
)
|
||||
|
||||
(out / "snapshots" / f"{sid}.matrix.json").write_text(
|
||||
json.dumps(
|
||||
{"rules": len(rules), "matrix": matrix, "result_table": scrub(result_table, inn_by_le), "totals": scrub(totals, inn_by_le)},
|
||||
ensure_ascii=False, indent=1, default=str,
|
||||
)
|
||||
)
|
||||
|
||||
# формы ББ/ОФР из PDF (эмулятор ИС): типы документов, строки, проверки
|
||||
stmts = {}
|
||||
for inn, want in sc["statements"].items():
|
||||
got = sorted(d["document_type"] for n, d in docs if d["document_type"] in ("BALANCE", "INCOME") and by_inn[inn] in {f["legal_entity_id"] for f in files if f["original_name"] == n})
|
||||
rep.check(f"{sid}: формы {inn}", got == sorted(want), f"{got}")
|
||||
for n, d in docs:
|
||||
if d["document_type"] in ("BALANCE", "INCOME"):
|
||||
s = api.ok("GET", f"/documents/{d['id']}/statement")
|
||||
stmts[f"{n} / {d['document_type']}"] = {
|
||||
"status": s["document"]["status"],
|
||||
"form_variant": s["document"]["form_variant"],
|
||||
"lines": len(s["lines"]),
|
||||
"codes": sorted(str(ln["line_code"]) for ln in s["lines"] if ln["line_code"]),
|
||||
"failed": sorted(c.get("code", str(c)) if isinstance(c, dict) else str(c) for c in s["checks"]["failed"]),
|
||||
"issues": sorted(i["code"] for i in s["issues"]),
|
||||
}
|
||||
rep.check(f"{sid}: форма {n} / {d['document_type']} — строки есть", len(s["lines"]) > 0, f"строк {len(s['lines'])}, статус {s['document']['status']}")
|
||||
(out / "snapshots" / f"{sid}.statements.json").write_text(json.dumps(stmts, ensure_ascii=False, indent=1))
|
||||
ctx["versions"][sid] = vid
|
||||
|
||||
|
||||
def negative(api: Api, exp: dict, ctx: dict, rep: Report) -> None:
|
||||
vid = next(iter(ctx["versions"].values()), None)
|
||||
if not vid:
|
||||
return
|
||||
api.login(exp["readonly_user"])
|
||||
st, _, _ = api.call("GET", f"/consolidations/versions/{vid}/osv/summary")
|
||||
rep.check("права: эксперт читает свод", st == 200, f"{st}")
|
||||
st, body, _ = api.call("POST", f"/consolidations/versions/{vid}/lock")
|
||||
rep.check("права: эксперт не берёт блокировку — 403", st == 403, f"{st} {str(body)[:120]}")
|
||||
api.login(exp["user"])
|
||||
st, _, _ = api.call("GET", "/consolidations/versions/" + str(uuid.uuid4()))
|
||||
rep.check("несуществующая версия — 404", st == 404, f"{st}")
|
||||
|
||||
|
||||
def compare(out: Path, baseline: Path, rep: Report) -> None:
|
||||
for cur in sorted((out / "snapshots").glob("*.json")):
|
||||
old = baseline / "snapshots" / cur.name
|
||||
if not old.exists():
|
||||
rep.diffs.append({"file": cur.name, "detail": "нет в прошлом прогоне"})
|
||||
continue
|
||||
a, b = json.loads(old.read_text()), json.loads(cur.read_text())
|
||||
if a == b:
|
||||
continue
|
||||
if isinstance(a, list) and isinstance(b, list):
|
||||
sa, sb = {json.dumps(x, sort_keys=True, ensure_ascii=False) for x in a}, {json.dumps(x, sort_keys=True, ensure_ascii=False) for x in b}
|
||||
detail = f"было {len(a)}, стало {len(b)}; ушло {len(sa - sb)}, появилось {len(sb - sa)}"
|
||||
examples = sorted(sb - sa)[:5]
|
||||
else:
|
||||
ka = set(a) if isinstance(a, dict) else set()
|
||||
changed = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k)) if isinstance(a, dict) else []
|
||||
detail = f"изменились ключи: {changed[:10]}" + (f" (+{len(changed) - 10})" if len(changed) > 10 else "")
|
||||
examples = [] if ka else [str(b)[:200]]
|
||||
rep.diffs.append({"file": cur.name, "detail": detail, "examples": examples})
|
||||
print(f"сравнение с {baseline}: расхождений {len(rep.diffs)}", flush=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--api", required=True)
|
||||
ap.add_argument("--fixtures", required=True, type=Path)
|
||||
ap.add_argument("--expected", required=True, type=Path)
|
||||
ap.add_argument("--out", required=True, type=Path)
|
||||
ap.add_argument("--baseline", type=Path)
|
||||
ap.add_argument("--timeout", type=float, default=600)
|
||||
a = ap.parse_args()
|
||||
exp = json.loads(a.expected.read_text())
|
||||
(a.out / "snapshots").mkdir(parents=True, exist_ok=True)
|
||||
rep = Report()
|
||||
api = Api(a.api)
|
||||
ctx: dict = {}
|
||||
try:
|
||||
health = wait_health(a.api, 180)
|
||||
rep.check("ядро готово", True, json.dumps(health, ensure_ascii=False)[:200])
|
||||
api.login(exp["user"])
|
||||
groups = api.ok("GET", "/directories/company-groups", params={"q": exp["company_group"]})["items"]
|
||||
group = next(g for g in groups if g["name"] == exp["company_group"])
|
||||
related = next(r for r in api.ok("GET", f"/directories/company-groups/{group['id']}/related-groups") if r["name"] == exp["related_group"])
|
||||
les = api.pages("/directories/legal-entities", {"company_group_id": group["id"]}, limit=200)
|
||||
ctx = {"group_id": group["id"], "related_id": related["id"], "by_inn": {le["inn"]: le["id"] for le in les}, "versions": {}, "documents": {}}
|
||||
ctx["accounts"] = api.ok("GET", "/directories/accounts")
|
||||
rep.check("dev-seed: ГК, ГСК, ЮЛ", len(les) > 0 and all(i in ctx["by_inn"] for sc in exp["scenarios"] for i in (u["inn"] for u in sc["uploads"])), f"ЮЛ {len(les)}")
|
||||
for sc in exp["scenarios"]:
|
||||
try:
|
||||
run_scenario(api, sc, ctx, a.fixtures, rep, a.out, a.timeout)
|
||||
except Exception as e: # noqa: BLE001 — сценарий падает целиком, следующий идёт
|
||||
rep.check(f"{sc['id']}: сценарий без исключений", False, f"{type(e).__name__}: {e}")
|
||||
negative(api, exp, ctx, rep)
|
||||
except Exception as e: # noqa: BLE001
|
||||
rep.check("сценарий без исключений", False, f"{type(e).__name__}: {e}")
|
||||
if a.baseline and (a.baseline / "snapshots").is_dir():
|
||||
compare(a.out, a.baseline, rep)
|
||||
ids = {k: ctx[k] for k in ("group_id", "versions", "documents")} if ctx else {}
|
||||
report = {"checks": rep.checks, "diffs": rep.diffs, "baseline": str(a.baseline or ""), "ids": ids}
|
||||
(a.out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=1))
|
||||
red = [c for c in rep.checks if not c["ok"]]
|
||||
print(f"проверок {len(rep.checks)}, красных {len(red)}, расхождений со снимком {len(rep.diffs)}")
|
||||
return 1 if red else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
87
scripts/regression/summary.py
Normal file
87
scripts/regression/summary.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Итог регрессии: summary.md из results.tsv, shas.tsv и логов шагов (вызывает regress.sh).
|
||||
|
||||
Аргументы: каталог прогона, список этапов через пробел (порядок в таблице). Для логов pytest — последняя строка-итог
|
||||
(«N passed, M skipped …») и число пропусков из-за отсутствия docs/fixtures: зелёный шаг с такими пропусками не доказывает
|
||||
сверку контракта и прохождение на реальных данных (dev/checks.md, «Внешние ресурсы»).
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PYTEST_TAIL = re.compile(r"=+ (.*\b(passed|failed|error|skipped|deselected)\b.*) in [\d.]+s")
|
||||
# причины пропуска «нет соседнего репозитория» в сервисах (dev/checks.md)
|
||||
MISSING_RES = re.compile(r"SKIPPED.*(ARM_KA_FIXTURES|ARM_KA_DOCS|fixtures недоступ|docs недоступ|нет \.\./(docs|fixtures))")
|
||||
|
||||
|
||||
def pytest_info(log: Path) -> str:
|
||||
try:
|
||||
text = log.read_text(errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
tails = [m.group(1) for m in PYTEST_TAIL.finditer(text)]
|
||||
missing = len(MISSING_RES.findall(text))
|
||||
parts = []
|
||||
if tails:
|
||||
parts.append(tails[-1] if len(tails) == 1 else " | ".join(tails))
|
||||
if missing:
|
||||
parts.append(f"⚠ пропусков без docs/fixtures: {missing}")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def first_error(log: Path) -> str:
|
||||
"""Короткая подсказка для красного шага: первая строка FAILED/ERROR/КРАСНЫЙ/Error из лога."""
|
||||
try:
|
||||
lines = log.read_text(errors="replace").splitlines()
|
||||
except OSError:
|
||||
return ""
|
||||
for pat in (r"^FAILED |^ERROR ", r"FAIL|КРАСН|✗", r"[Ee]rror|ошибк"):
|
||||
for ln in lines:
|
||||
if re.search(pat, ln):
|
||||
return ln.strip()[:160]
|
||||
return lines[-1].strip()[:160] if lines else ""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
out, stages = Path(sys.argv[1]), sys.argv[2].split()
|
||||
rows = [ln.split("\t") for ln in (out / "results.tsv").read_text().splitlines() if ln.strip()]
|
||||
shas = [ln.split("\t") for ln in (out / "shas.tsv").read_text().splitlines()] if (out / "shas.tsv").exists() else []
|
||||
red = [r for r in rows if r[2] != "0"]
|
||||
md = [f"# Регрессия {out.name}", ""]
|
||||
md.append(f"**Итог: {'ЗЕЛЁНАЯ' if not red else f'КРАСНАЯ — {len(red)} из {len(rows)} шагов'}**")
|
||||
if (out / "allow-dirty").exists():
|
||||
md.append("")
|
||||
md.append("⚠ Прогон с `--allow-dirty`: preflight не пройден (не main, не origin/main или есть правки) — см. этап preflight.")
|
||||
md += ["", "## Репозитории", "", "| Репозиторий | Ветка | SHA | Последний коммит |", "|---|---|---|---|"]
|
||||
md += [f"| {r} | {b} | `{h[:10]}` | {s} |" for r, b, h, s in shas]
|
||||
md += ["", "## Шаги", "", "| Этап | Шаг | Статус | Время, с | Детали |", "|---|---|---|---|---|"]
|
||||
order = {s: i for i, s in enumerate(stages)}
|
||||
for stage, name, rc, sec, log in sorted(rows, key=lambda r: order.get(r[0], 99)):
|
||||
logp = Path(log)
|
||||
detail = pytest_info(logp)
|
||||
if rc != "0":
|
||||
detail = "; ".join(x for x in (detail, first_error(logp)) if x)
|
||||
status = "ok" if rc == "0" else f"**КРАСНЫЙ** ({rc})"
|
||||
md.append(f"| {stage} | {name} | {status} | {sec} | {detail.replace('|', '/')} |")
|
||||
stand = out / "stand" / "report.json"
|
||||
if stand.exists():
|
||||
rep = json.loads(stand.read_text())
|
||||
md += ["", "## Стенд: сценарий по API", ""]
|
||||
red = [c for c in rep["checks"] if not c["ok"]]
|
||||
md.append(f"Проверок {len(rep['checks'])}, красных {len(red)}.")
|
||||
md.append("")
|
||||
md += [f"- {'✓' if c['ok'] else '✗'} {c['name']}" + (f": {c['detail']}" if c["detail"] else "") for c in rep["checks"]]
|
||||
if rep.get("baseline"):
|
||||
md += ["", f"Сравнение снимков со стендом прошлого прогона `{rep['baseline']}`: расхождений {len(rep['diffs'])}."]
|
||||
md += [f"- `{d['file']}`: {d['detail']}" for d in rep["diffs"]]
|
||||
bank = out / "bank" / "summary.md"
|
||||
if bank.exists():
|
||||
md += ["", bank.read_text().strip()]
|
||||
md += ["", f"Логи: `{out / 'logs'}`"]
|
||||
(out / "summary.md").write_text("\n".join(md) + "\n")
|
||||
print("\n".join(md))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
95
scripts/regression/ui_smoke.py
Normal file
95
scripts/regression/ui_smoke.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""UI-смоук фронта arm-core/web против стенда регрессии (regress.sh, этап ui; после сценария stand).
|
||||
|
||||
Обходит основные экраны под ka_go (токен — в sessionStorage, как после входа), на каждом ждёт затишья сети и
|
||||
собирает: исключения страницы, ошибки консоли, ответы /api со статусом >= 400. Красный экран — исключение страницы,
|
||||
ответ /api >= 500 или 4xx (кроме перечисленных в ALLOWED_4XX), пустая страница. Скриншоты — <out>/<экран>.png,
|
||||
итог — <out>/report.json. Идентификаторы версий и документов берёт из report.json сценария стенда.
|
||||
|
||||
uv run --with playwright==1.55.0 python ui_smoke.py --web http://127.0.0.1:13000 --api http://127.0.0.1:18000 \\
|
||||
--stand-report DIR/stand/report.json --out DIR/ui
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# 4xx, которые фронт получает штатно (пусто — любой 4xx красный; дополнять с причиной)
|
||||
ALLOWED_4XX: set[tuple[str, int]] = set()
|
||||
|
||||
|
||||
def token(api: str, email: str) -> str:
|
||||
req = urllib.request.Request(
|
||||
api.rstrip("/") + "/api/v1/dev/token",
|
||||
data=json.dumps({"email": email}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())["access_token"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--web", required=True)
|
||||
ap.add_argument("--api", required=True)
|
||||
ap.add_argument("--stand-report", required=True, type=Path)
|
||||
ap.add_argument("--out", required=True, type=Path)
|
||||
ap.add_argument("--user", default="ka_go@armka.local")
|
||||
a = ap.parse_args()
|
||||
a.out.mkdir(parents=True, exist_ok=True)
|
||||
ids = json.loads(a.stand_report.read_text()).get("ids", {})
|
||||
g = ids.get("group_id")
|
||||
pages = [("groups", "/groups"), ("catalogue-groups", "/catalogue/groups"), ("catalogue-le", "/catalogue/jur_l")]
|
||||
if g:
|
||||
pages.append(("group", f"/groups/{g}"))
|
||||
for sid, vid in (ids.get("versions") or {}).items():
|
||||
pages += [(f"{sid}-osv", f"/groups/{g}/versions/{vid}/osv"), (f"{sid}-chess", f"/groups/{g}/versions/{vid}/chess")]
|
||||
for name, doc in (ids.get("documents") or {}).items():
|
||||
pages.append((f"doc-{name}", f"/documents/{doc}"))
|
||||
|
||||
tok = token(a.api, a.user)
|
||||
results = []
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
ctx = browser.new_context(viewport={"width": 1600, "height": 1000}, locale="ru-RU")
|
||||
ctx.add_init_script(f"try {{ sessionStorage.setItem('armka.token', {json.dumps(tok)}); }} catch (e) {{}}")
|
||||
for name, path in pages:
|
||||
page = ctx.new_page()
|
||||
errors, console, bad_api = [], [], []
|
||||
page.on("pageerror", lambda e, errors=errors: errors.append(str(e)[:300]))
|
||||
page.on("console", lambda m, console=console: console.append(m.text[:300]) if m.type == "error" else None)
|
||||
|
||||
def on_response(r, bad_api=bad_api):
|
||||
if "/api/" in r.url and r.status >= 400:
|
||||
bad_api.append(f"{r.status} {r.request.method} {r.url.split('/api/', 1)[1][:150]}")
|
||||
|
||||
page.on("response", on_response)
|
||||
try:
|
||||
page.goto(a.web.rstrip("/") + path, wait_until="networkidle", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
text = page.inner_text("body")
|
||||
except Exception as e: # noqa: BLE001 — экран красный, обход продолжается
|
||||
errors.append(f"{type(e).__name__}: {e}"[:300])
|
||||
text = ""
|
||||
page.screenshot(path=str(a.out / f"{name}.png"), full_page=False)
|
||||
red_api = [
|
||||
x for x in bad_api
|
||||
if int(x.split()[0]) >= 500 or (x.split()[1], int(x.split()[0])) not in ALLOWED_4XX
|
||||
]
|
||||
ok = not errors and not red_api and len(text.strip()) > 20
|
||||
results.append({"name": name, "path": path, "ok": ok, "page_errors": errors, "api_errors": bad_api, "console_errors": console[:10]})
|
||||
print(("ok " if ok else "FAIL ") + f"{name} {path}" + ("" if ok else f": {errors or red_api or 'пустая страница'}"), flush=True)
|
||||
page.close()
|
||||
browser.close()
|
||||
(a.out / "report.json").write_text(json.dumps(results, ensure_ascii=False, indent=1))
|
||||
red = [r for r in results if not r["ok"]]
|
||||
print(f"экранов {len(results)}, красных {len(red)}")
|
||||
return 1 if red else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
x
Reference in New Issue
Block a user