From 9bc2b26c1cead676ee29694a777828a537a3b663 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 12:09:15 +0400 Subject: [PATCH 1/2] =?UTF-8?q?scripts/regression:=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5=D1=81=D1=81?= =?UTF-8?q?=D0=B8=D1=8F=20main=20=D0=B2=D1=81=D0=B5=D1=85=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B5=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=B2=D1=8B=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=BE=D0=B9=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit regress.sh — один вход: preflight (main = origin/main, чистые, нет чужих потребителей NATS), venv, lint+unit всех репозиториев и docs make check, фронт в чистой копии (npm ci --dry-run как проверка lock, установка без lock), интеграционные с циклом миграций, реальные данные парсера, образы, e2e ядра, изолированный стенд с нуля (compose-проект arm-ka-regress на своих портах) со сценарием по API, UI-смоук Playwright, парсер по doc-bank. Не останавливается на первой ошибке; итог — summary.md с SHA и статусом шагов. Контрольные цифры — expected.json (qa-guide §7.1–7.3, прогоны 08.10/14.10, golden шахматки); снимки свода, шахматки и форм сравниваются с прошлым прогоном. Ссылки из dev/checks.md и чек-листа синхронизации контура. Co-Authored-By: Claude Opus 5.5 --- Makefile | 6 +- dev/checks.md | 7 + dev/contour-transfer-guide.md | 5 +- scripts/regression/README.md | 69 ++++ scripts/regression/expected.json | 71 +++++ scripts/regression/parser_bank.py | 173 ++++++++++ scripts/regression/regress.sh | 309 ++++++++++++++++++ scripts/regression/stand_scenario.py | 450 +++++++++++++++++++++++++++ scripts/regression/summary.py | 87 ++++++ scripts/regression/ui_smoke.py | 95 ++++++ 10 files changed, 1269 insertions(+), 3 deletions(-) create mode 100644 scripts/regression/README.md create mode 100644 scripts/regression/expected.json create mode 100644 scripts/regression/parser_bank.py create mode 100755 scripts/regression/regress.sh create mode 100644 scripts/regression/stand_scenario.py create mode 100644 scripts/regression/summary.py create mode 100644 scripts/regression/ui_smoke.py diff --git a/Makefile b/Makefile index d872437..0cd688d 100644 --- a/Makefile +++ b/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) diff --git a/dev/checks.md b/dev/checks.md index 80906a1..f3c3a6d 100644 --- a/dev/checks.md +++ b/dev/checks.md @@ -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`), diff --git a/dev/contour-transfer-guide.md b/dev/contour-transfer-guide.md index dd9e413..9bc6438 100644 --- a/dev/contour-transfer-guide.md +++ b/dev/contour-transfer-guide.md @@ -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: @ (<дата>)`, `make venv`, diff --git a/scripts/regression/README.md b/scripts/regression/README.md new file mode 100644 index 0000000..54e0f63 --- /dev/null +++ b/scripts/regression/README.md @@ -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 <тот же каталог>`. diff --git a/scripts/regression/expected.json b/scripts/regression/expected.json new file mode 100644 index 0000000..9cab04b --- /dev/null +++ b/scripts/regression/expected.json @@ -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"]} + } + ] +} diff --git a/scripts/regression/parser_bank.py b/scripts/regression/parser_bank.py new file mode 100644 index 0000000..1caed1d --- /dev/null +++ b/scripts/regression/parser_bank.py @@ -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()) diff --git a/scripts/regression/regress.sh b/scripts/regression/regress.sh new file mode 100755 index 0000000..f59135f --- /dev/null +++ b/scripts/regression/regress.sh @@ -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 diff --git a/scripts/regression/stand_scenario.py b/scripts/regression/stand_scenario.py new file mode 100644 index 0000000..cd94f3e --- /dev/null +++ b/scripts/regression/stand_scenario.py @@ -0,0 +1,450 @@ +"""Сценарий регрессии на стенде с нуля (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"} +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]: + """Число строк на листе по имени листа (без 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']*Id="([^"]+)"[^>]*Target="([^"]+)"', rels)) + targets |= {k: v for v, k in re.findall(r']*Target="([^"]+)"[^>]*Id="([^"]+)"', rels)} + out = {} + for name, rid in re.findall(r']*name="([^"]+)"[^>]*r:id="([^"]+)"', wb): + target = targets[rid].lstrip("/") + target = target if target.startswith("xl/") else "xl/" + target + out[name] = len(re.findall(r"]", 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"]} + 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()) diff --git a/scripts/regression/summary.py b/scripts/regression/summary.py new file mode 100644 index 0000000..ee4e255 --- /dev/null +++ b/scripts/regression/summary.py @@ -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() diff --git a/scripts/regression/ui_smoke.py b/scripts/regression/ui_smoke.py new file mode 100644 index 0000000..a53539b --- /dev/null +++ b/scripts/regression/ui_smoke.py @@ -0,0 +1,95 @@ +"""UI-смоук фронта arm-core/web против стенда регрессии (regress.sh, этап ui; после сценария stand). + +Обходит основные экраны под ka_go (токен — в sessionStorage, как после входа), на каждом ждёт затишья сети и +собирает: исключения страницы, ошибки консоли, ответы /api со статусом >= 400. Красный экран — исключение страницы, +ответ /api >= 500 или 4xx (кроме перечисленных в ALLOWED_4XX), пустая страница. Скриншоты — /<экран>.png, +итог — /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()) From 44e0fd82294cca30579d8f65feff4156ced022af Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 12:36:42 +0400 Subject: [PATCH 2/2] =?UTF-8?q?scripts/regression:=20=D0=B2=20=D1=81=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=BA=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20locator=20OCR=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20id=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/regression/stand_scenario.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/regression/stand_scenario.py b/scripts/regression/stand_scenario.py index cd94f3e..ddda2ff 100644 --- a/scripts/regression/stand_scenario.py +++ b/scripts/regression/stand_scenario.py @@ -29,6 +29,8 @@ 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:.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"} @@ -218,7 +220,7 @@ def run_scenario(api: Api, sc: dict, ctx: dict, fixtures: Path, rep: Report, out 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"]} + | {"file": n, "status_after_recognition": d["status"], "locator": UUID_RE.sub("", str(d["locator"]))} for n, d in docs ], key=lambda x: (x["file"], str(x["locator"])),