Compare commits

...

3 Commits

Author SHA1 Message Date
aep
3a91d050f2 Merge pull request 'scripts/regression: полная регрессия main всех репозиториев перед выкладкой (#179)' (#180) from feat/179-regression-suite into main 2026-10-28 13:30:32 +03:00
Andrey Epifancev
44e0fd8229 scripts/regression: в снимке документов locator OCR без id задания (#179)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:36:42 +04:00
Andrey Epifancev
9bc2b26c1c scripts/regression: полная регрессия main всех репозиториев перед выкладкой (#179)
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 <noreply@anthropic.com>
2026-10-28 12:09:15 +04:00
10 changed files with 1271 additions and 3 deletions

View File

@ -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)

View File

@ -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`),

View File

@ -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`,

View 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 <тот же каталог>`.

View 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"]}
}
]
}

View 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
View 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

View 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())

View 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()

View 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())