Профилирование фронта и бэка: стенд, замеры, отчёт (#202) #203

Merged
aep merged 28 commits from feat/202-perf-profiling into test 2026-10-28 16:11:09 +03:00
10 changed files with 1074 additions and 5 deletions
Showing only changes of commit fb80d8c636 - Show all commits

View File

@ -112,3 +112,12 @@
| 2026-10-25 | В манифесте сегмент «нет direction» в URL листа — литерал `null` | `TableList.jsx` строит путь через `${table?.direction}`; при null из API получается `null` (при undefined было бы `undefined`) | Переходы на листы без direction дадут 404/пустую страницу |
| 2026-10-25 | Агрегатор считает median чётного n как среднее двух центральных, p95 — nearest-rank; нечисловые и bool-метрики игнорируются | Бриф задаёт nearest-rank только для p95; median по стандартному определению | Небольшое расхождение чисел с ручным расчётом |
| 2026-10-25 | `results.append` валидирует наличие всех обязательных ключей записи | Ранний отказ на кривом формате от Node-стороны | Лишний ValueError при расширении формата |
| 2026-10-25 | План масштаба 1: 3 ССП, 6 РФ, 30 ВСП/РФ, 500 доп. строк на лист (FORM_4 — 100), 4 проекта/РФ, fill_ratio 0.6; масштаб 2 удваивает все поля, включая org_units_ssp | Самый большой лист rf (FORM_2/AHR) >= 1000 строк; генерация масштаба 1 в пределах получаса; обоснование в `perf/perfkit/gen/plan.py` | Другой объём данных; масштаб 2 займёт ~4-5x масштаба 1 |
| 2026-10-25 | Строк на листах FORM_4 в 5 раз меньше, чем на листах FORM_1/2 (`FORM4_LINES_DIVISOR`) | add_line и правка ячеек FORM_4 стоят O(число строк листа) в БД (0.2 с -> 1.6 с на 300 строках, батч 200 ячеек 9-11 с); 500 строк на лист сделали бы генерацию многочасовой | Листы FORM_4 в замерах меньше, чем у заказчика; ищется там же отдельно |
| 2026-10-25 | Формы: FORM_1 по ССП, FORM_2 по ССП и РФ, FORM_4 по РФ | Свод 2 «РФ» читает FORM_2 РФ (миграция 0041), FORM_4 — форма РФ, FORM_1 — ГО | Своды заполнены иначе, чем в проде |
| 2026-10-25 | Всё заполнение (строки, ячейки) идёт под admin, этапы EXECUTOR_RF/DFIP с окном now-1d..now+30d создаются после заполнения (фазы PERF_RF/PERF_DFIP); колонки этапа — те, что реально записываются на листе | У admin нет ограничений по этапам, быстрее и надёжнее; в конце самопроверка правки под rf, rf_coauthor, dfip | Окно этапа открыто только на колонки заполнения (plan, actual, reserve, ...), не на все |
| 2026-10-25 | Логины пользователей `perf_rf`, `perf_rf_coauthor`, `perf_dfip` (ключи манифеста rf, rf_coauthor, dfip) | username в API не короче 3 символов, `rf` отвергается | Сценарии должны брать логин из манифеста, а не из ключа |
| 2026-10-25 | Программы и проекты FORM_4 создаются через WebSocket (`program_added`, `project_added`) | HTTP-ручки нет; WS — тот же путь, что у фронта, и те же SQL-функции add_form4_* | Зависимость генератора от протокола WS |
| 2026-10-25 | В `Sheet` манифеста добавлено поле `owner` ("rf"/"ssp", по умолчанию "rf"); в манифест попадают листы только ССП-01 и РФ-01 | Манифест без признака владельца не позволяет отличить листы, недоступные `rf`; полный список форм раздул бы сценарии | Сценарии T5 должны фильтровать по owner |
| 2026-10-25 | Лист свода кодируется в `Page.url` после `#` (`/svod#FORM_1`, `/svod/2#RF`) | Вкладка свода — состояние страницы, не маршрут; хеш не влияет на роутинг, имена `svod1_*`, `svod2_*` уникальны | Сценарий обязан выбирать вкладку по хешу/имени |
| 2026-10-25 | В `perf/stand.sh` добавлена `reset`; `start_bg` отвязывает подоболочку от stdout | Требование задачи; без отвязки `stand.sh api \| tail` висел | Правка поведения стенда Task 1 |

View File

@ -9,7 +9,8 @@ perf/stand.sh all # up, migrate, bootstrap, web-build, api, web, smoke
perf/stand.sh down # остановить бэк, фронт и Postgres
```
Подкоманды: `up | migrate | bootstrap | api | web-build | web | smoke | down | all`.
Подкоманды: `up | migrate | bootstrap | api | web-build | web | smoke | reset | down | all`.
`reset` — чистая БД: останавливает бэк, сносит volume Postgres, поднимает заново (`up`, `migrate`, `bootstrap`, `api`); фронт не трогает.
- Postgres 16 (`pg_stat_statements`) на `localhost:55432`, `perf/perf/perf`.
- Бэк (`DEBUG=true`) на `http://localhost:8000`, логи и PID в `perf/.run/`.
@ -20,3 +21,16 @@ perf/stand.sh down # остановить бэк, фронт и Postgres
- Переменные: `PERF_API`, `PERF_WEB`, `PERF_DB_DSN`.
- Зависимости Python ставятся в `perf/.venv` (`api/requirements-dev.txt` + `perf/requirements.txt`).
- npm берёт пакеты с `https://registry.npmjs.org` (`npm_config_registry`), `web/.npmrc` не нужен.
## Данные
```
perf/stand.sh reset # пустая БД (генератор на непустой БД откажется работать)
cd perf && .venv/bin/python -m perfkit.gen.run --scale 1 # ~20-40 мин, пишет results/manifest-s1.json
cd perf && .venv/bin/python -m perfkit.gen.verify --scale 1 # должен напечатать VERIFY OK
```
Генератор работает через HTTP-API под `admin` (WebSocket — только для программ и проектов FORM_4,
у них нет HTTP-ручки). Пользователи: `perf_rf`, `perf_rf_coauthor` (одна РФ, роль 3) и `perf_dfip`
(роль 2, все оргединицы), пароль `perf`; в манифесте они под ключами `rf`, `rf_coauthor`, `dfip`.
Числа плана и их обоснование — в `perf/perfkit/gen/plan.py`.

View File

View File

@ -0,0 +1,86 @@
"""Асинхронный клиент API для генератора: общий семафор, повторный вход при 401, повтор при сбоях сети."""
from __future__ import annotations
import asyncio
import os
from typing import Any
import httpx
API = os.environ.get("PERF_API", "http://localhost:8000")
PREFIX = "/api/v1"
class ApiError(Exception):
def __init__(self, status: int, text: str, method: str, path: str):
super().__init__(f"{method} {path} -> {status}: {text[:400]}")
self.status = status
self.text = text
self.method = method
self.path = path
class Api:
def __init__(self, username: str, password: str, sem: asyncio.Semaphore, base: str = API):
self.username = username
self.password = password
self.sem = sem
self.base = base
self.client = httpx.AsyncClient(
base_url=base + PREFIX,
timeout=httpx.Timeout(600.0, connect=30.0),
limits=httpx.Limits(max_connections=64, max_keepalive_connections=64),
)
self.token: str | None = None
self._login_lock = asyncio.Lock()
self.requests = 0
async def aclose(self) -> None:
await self.client.aclose()
async def login(self, stale: str | None = None) -> str:
async with self._login_lock:
if self.token is not None and self.token != stale:
return self.token
r = await self.client.post("/auth/login", json={"username": self.username, "password": self.password})
if r.status_code != 200:
raise ApiError(r.status_code, r.text, "POST", "/auth/login")
self.token = r.json()["access_token"]
return self.token
async def req(self, method: str, path: str, *, ok_empty: bool = False, **kw: Any) -> Any:
if self.token is None:
await self.login()
last: Exception | None = None
for attempt in range(4):
try:
async with self.sem:
self.requests += 1
token = self.token
r = await self.client.request(method, path, headers={"Authorization": f"Bearer {token}"}, **kw)
if r.status_code == 401:
await self.login(stale=token)
continue
if r.status_code in (502, 503, 504):
await asyncio.sleep(1 + attempt)
last = ApiError(r.status_code, r.text, method, path)
continue
if r.status_code >= 400:
raise ApiError(r.status_code, r.text, method, path)
return r.json() if r.content else None
except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
last = e
await asyncio.sleep(1 + attempt)
raise last if last else RuntimeError(f"{method} {path}: исчерпаны попытки")
async def get(self, path: str, **kw: Any) -> Any:
return await self.req("GET", path, **kw)
async def post(self, path: str, **kw: Any) -> Any:
return await self.req("POST", path, **kw)
async def patch(self, path: str, **kw: Any) -> Any:
return await self.req("PATCH", path, **kw)
async def put(self, path: str, **kw: Any) -> Any:
return await self.req("PUT", path, **kw)

86
perf/perfkit/gen/plan.py Normal file
View File

@ -0,0 +1,86 @@
"""План генерации данных стенда: сколько чего создать на заданном масштабе.
Обоснование чисел масштаба 1
----------------------------
* Размер листа складывается из шаблонных строк (дерево статей из сида, создаётся самой БД)
и добавленных строк (`extra_lines_per_sheet`, POST .../line). Шаблон у FORM_2/AHR — 566 строк,
у FORM_1/AHR — 522, у FORM_4/STRUCTURE — 775 (+ программы, проекты и все строки AHR/CAP/OPER).
`extra_lines_per_sheet = 500` даёт у `rf` лист FORM_2/AHR ~1090 строк (требование: самый
большой лист rf >= 1000). Строки раскладываются по листьям дерева статей по кругу, так что
покрыто всё дерево. На листах FORM_4 строк в 5 раз меньше (FORM4_LINES_DIVISOR, причина ниже);
лист FORM_4/STRUCTURE содержит 775 строк шаблона и от числа добавленных строк не зависит.
* Формы: FORM_1 — по ССП, FORM_2 — по ССП и РФ (свод 2 «РФ» читает именно FORM_2 РФ),
FORM_4 — по РФ. 3 ССП и 6 РФ: в сводах 1 и 2 несколько организаций с правдоподобным
разбросом, а объём остаётся таким, чтобы полная генерация шла десятки минут, а не часы
(каждый add_line/PATCH — отдельный запрос, добавление строки ~0.05-0.15 с и растёт с размером листа).
* `vsp_per_rf = 30`: ВСП на РФ; на каждый ВСП по одной строке на сателлитных листах FORM_2
(AHR_RENT/UTILITY/SECURITY) и источник `vsp_id` для строк FORM_4.
* `projects_per_rf = 4`: проекты FORM_3 (по 2 отчёта LIMIT/CURRENT_EXPENSES за 2026, у половины
ещё 2027), по 616 строк в отчёте каждого — отчёт проекта тоже >1000 строк с шаблоном.
* `fill_ratio = 0.6`: доля строк, в которые записаны значения; остальные пустые, как в жизни.
* Масштаб 2 удваивает org_units_rf, vsp_per_rf, extra_lines_per_sheet, projects_per_rf (и
org_units_ssp, чтобы своды росли по обеим осям); объём строк растёт ~4x, а время генерации
~4-5x, а не 2x: число организаций и число строк на лист удваиваются одновременно, плюс add_line
на вдвое больших листах медленнее. Замер масштаба 1 и оценка масштаба 2 — в отчёте задачи.
"""
from __future__ import annotations
from dataclasses import dataclass
YEAR = 2026
PROGRAMS_PER_SHEET = 2 # FORM_4: программ на каждый лист AHR/CAP/OPER формы
PROJECTS_PER_PROGRAM = 3 # FORM_4: проектов на программу
PASSWORD = "perf"
# FORM_4: добавление строки и правка ячеек стоят O(число строк листа) на стороне БД (замер: add_line
# 0.2 с на пустом листе -> 1.6 с на 300 строках; батч из 200 ячеек 9-11 с), тогда как у FORM_1/2
# 0.05-0.15 с при 500. Поэтому на листах FORM_4 строк в DIVISOR раз меньше; размер самого большого
# листа rf (>= 1000) даёт FORM_2/AHR, а не FORM_4. Находка по приложению, см. отчёт.
FORM4_LINES_DIVISOR = 5
@dataclass(frozen=True)
class GenPlan:
org_units_ssp: int
org_units_rf: int
vsp_per_rf: int
extra_lines_per_sheet: int
projects_per_rf: int
fill_ratio: float
def lines_per_sheet(self, form_type: str) -> int:
"""Сколько строк добавляется на каждый лист (AHR/CAP/OPER) формы данного типа."""
n = self.extra_lines_per_sheet
return n // FORM4_LINES_DIVISOR if form_type == "FORM_4" else n
@property
def forms_form1(self) -> int:
return self.org_units_ssp
@property
def forms_form2(self) -> int:
return self.org_units_ssp + self.org_units_rf
@property
def forms_form4(self) -> int:
return self.org_units_rf
@property
def programs(self) -> int:
return self.org_units_rf * 3 * PROGRAMS_PER_SHEET
@property
def form4_projects(self) -> int:
return self.programs * PROJECTS_PER_PROGRAM
def plan_for_scale(scale: int) -> GenPlan:
if not isinstance(scale, int) or scale < 1:
raise ValueError(f"scale должен быть целым >= 1, получено {scale!r}")
return GenPlan(
org_units_ssp=3 * scale,
org_units_rf=6 * scale,
vsp_per_rf=30 * scale,
extra_lines_per_sheet=500 * scale,
projects_per_rf=4 * scale,
fill_ratio=0.6,
)

619
perf/perfkit/gen/run.py Normal file
View File

@ -0,0 +1,619 @@
"""Генератор данных стенда через HTTP-API (и WebSocket там, где у API нет HTTP-ручки).
cd perf && .venv/bin/python -m perfkit.gen.run --scale 1
Порядок: оргединицы -> пользователи -> ВСП -> формы -> программы/проекты FORM_4 -> строки ->
значения ячеек -> этапы -> проекты FORM_3 (годы, отчёты, значения, этапы) -> манифест.
Всё делается под admin (у admin на ячейки и строки нет ограничений по этапам), этапы для
EXECUTOR_RF и DFIP с открытым окном создаются после заполнения; в конце проверяется, что
`rf`, `rf_coauthor` и `dfip` действительно могут читать и править.
"""
from __future__ import annotations
import argparse
import asyncio
import dataclasses
import json
import os
import random
import re
import sys
import time
from collections import Counter
from datetime import datetime, timedelta, timezone
from itertools import cycle
import asyncpg
import websockets
from .. import RESULTS_DIR
from ..manifest import Manifest, Page, ProjectReport, Sheet, User, dump, manifest_path
from .client import API, Api, ApiError
from .plan import FORM4_LINES_DIVISOR, PASSWORD, PROGRAMS_PER_SHEET, PROJECTS_PER_PROGRAM, YEAR, GenPlan, plan_for_scale
DSN = os.environ.get("PERF_DB_DSN", "postgresql://perf:perf@localhost:55432/perf")
CONCURRENCY = int(os.environ.get("PERF_GEN_CONCURRENCY", "8"))
CELLS_BATCH = 200
EMAIL_DOMAIN = "perf-stand.com" # .local отклоняется email-validator (см. design.md)
# листы, в которые добавляются строки: (лист, direction)
LINE_SHEETS = {
"FORM_1": [("AHR", "Support"), ("AHR", "Development"), ("CAP", "Support"), ("CAP", "Development"), ("OPER", None)],
"FORM_2": [("AHR", None), ("CAP", None), ("OPER", None)],
"FORM_4": [("AHR", None), ("CAP", None), ("OPER", None)],
}
SATELLITES = ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"] # FORM_2: по строке на ВСП
# колонки-кандидаты для заполнения; реально записываемые определяются пробой (computed_field отсекаются)
NUM_CANDIDATES = (
[f"plan.q{q}" for q in range(1, 5)]
+ [f"q{q}.actual_m{m}" for q in (1, 2, 3) for m in (1, 2, 3)]
+ [f"reserve.q{q}" for q in range(1, 5)]
+ [f"sequestration.q{q}" for q in range(1, 5)]
+ [f"seq_ssp.q{q}" for q in range(1, 5)]
+ [f"seq_dfip.q{q}" for q in range(1, 5)]
)
TEXT_CANDIDATES = ["plan.comment"]
REPORT_NUM_CANDIDATES = (
[f"q{q}.base_plan" for q in range(1, 5)]
+ [f"q{q}.m{m}" for q in (1, 2, 3) for m in (1, 2, 3)]
+ [f"q{q}.adj_by_items" for q in range(1, 5)]
+ [f"q{q}.adj_increase" for q in range(1, 5)]
)
PROJECT_TYPES = ["Открытие ВСП", "Закрытие ВСП", "Переезд ВСП", "Реновация РФ", "Открытие УРМ"]
VSP_FORMATS = ["Флагманский", "Типовой", "Розничный", "МСБ", "Лёгкий", "Мини", "Другое"]
PLACEMENTS = ["Собственность", "Аренда", "Субаренда"]
REPORT_TYPES = ["LIMIT", "CURRENT_EXPENSES"]
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
@dataclasses.dataclass
class FormInfo:
id: int
type: str
org_id: int
org_kind: str # "ssp" | "rf"
org_index: int
vsp_ids: list[int]
cols: dict = dataclasses.field(default_factory=dict) # (sheet, direction) -> list[str]
@property
def has_satellites(self) -> bool:
return self.type == "FORM_2" and self.org_kind == "rf" and bool(self.vsp_ids)
def sheet_params(direction: str | None) -> dict:
return {"direction": direction} if direction else {}
def now_window() -> tuple[str, str]:
"""Окно этапа now-1d .. now+30d. API кладёт значение в TIMESTAMP WITHOUT TIME ZONE (CAST) и
отвергает aware-даты, поэтому отдаём наивное UTC-время; сдвиг на utc_offset оргединицы (+03)
много меньше запаса в сутки."""
now = datetime.now(timezone.utc).replace(tzinfo=None, microsecond=0)
return (now - timedelta(days=1)).isoformat(), (now + timedelta(days=30)).isoformat()
def rand_value(col: str, rng: random.Random):
if col.endswith(".comment") or col.endswith("justification"):
return f"Комментарий {rng.randint(1, 99999)}"
if col.startswith("plan."):
return round(rng.uniform(5e4, 5e6), 2)
if re.search(r"\.(actual_)?m\d$", col):
return round(rng.uniform(0, 3e5), 2)
return round(rng.uniform(0, 2e5), 2)
# --------------------------------------------------------------------------- шаги
async def ensure_empty() -> None:
conn = await asyncpg.connect(DSN)
try:
n_org = await conn.fetchval("SELECT count(*) FROM v3.org_unit")
n_users = await conn.fetchval("SELECT count(*) FROM v3.app_user WHERE username <> 'admin'")
finally:
await conn.close()
if n_org or n_users:
sys.exit(f"БД не пуста (оргединиц: {n_org}, пользователей кроме admin: {n_users}): сначала perf/stand.sh reset")
async def create_orgs(api: Api, plan: GenPlan) -> tuple[list[int], list[int]]:
ssp, rf = [], []
for i in range(1, plan.org_units_ssp + 1):
r = await api.post("/org-unit/", json={"title": f"ССП-{i:02d} Головной офис", "is_ssp": True})
ssp.append(r["result"]["id"])
for i in range(1, plan.org_units_rf + 1):
r = await api.post("/org-unit/", json={"title": f"РФ-{i:02d} Региональный филиал", "is_ssp": False})
rf.append(r["result"]["id"])
return ssp, rf
async def create_users(api: Api, rf_org: int, all_orgs: list[int]) -> dict[str, User]:
spec = [
("rf", 3, "Исполнитель РФ"),
("rf_coauthor", 3, "Соавтор РФ"),
("dfip", 2, "Исполнитель ДФиП"),
] # ключ манифеста; логин = "perf_" + ключ (в API username не короче 3 символов, «rf» не проходит)
users: dict[str, User] = {"admin": User("admin", "admin", 1)}
for key, role, full_name in spec:
username = f"perf_{key}"
r = await api.post(
"/users/",
json={
"email": f"{username.replace('_', '-')}@{EMAIL_DOMAIN}",
"username": username,
"full_name": full_name,
"role_id": role,
"password": PASSWORD,
},
)
uid = r["result"]["id"]
orgs = [rf_org] if role == 3 else all_orgs
await api.put("/users/dfip-many-ssp/", params={"user_id": uid}, json={"ssp_ids": orgs})
users[key] = User(username, PASSWORD, role)
return users
async def create_vsp(api: Api, plan: GenPlan, rf_orgs: list[int]) -> dict[int, list[int]]:
out: dict[int, list[int]] = {o: [] for o in rf_orgs}
async def one(r_idx: int, org: int, i: int) -> None:
opened = datetime(2012 + (i % 12), 1 + (i % 12), 1 + (i % 27)).date()
body = {
"registration_number": f"RF{r_idx:02d}-{i:04d}",
"address": f"г. Город-{r_idx:02d}, ул. Улица-{i:03d}, д. {1 + i % 90}",
"ssp_id": org,
"vsp_type": "ВСП",
"approved_format": VSP_FORMATS[i % len(VSP_FORMATS)],
"placement_type": PLACEMENTS[i % len(PLACEMENTS)],
"open_date": opened.isoformat(),
"staff_count": 3 + i % 17,
"area": round(40 + (i * 7.3) % 260, 1),
"rent_contract_num": f"Д-{r_idx:02d}/{i:04d}",
"rent_end_date": (opened + timedelta(days=365 * 15)).isoformat(),
}
r = await api.post("/dict/info", json=body)
out[org].append(r["result"]["id"])
await asyncio.gather(*(one(ri, org, i) for ri, org in enumerate(rf_orgs, 1) for i in range(1, plan.vsp_per_rf + 1)))
for v in out.values():
v.sort()
return out
async def create_forms(api: Api, ssp: list[int], rf: list[int], vsp: dict[int, list[int]]) -> list[FormInfo]:
kinds = {o: ("ssp", i) for i, o in enumerate(ssp, 1)} | {o: ("rf", i) for i, o in enumerate(rf, 1)}
spec = {"FORM_1": ssp, "FORM_2": ssp + rf, "FORM_4": rf}
forms: list[FormInfo] = []
for ftype, orgs in spec.items():
r = await api.post("/form/", json={"year": YEAR, "form_type_code": ftype, "org_unit_ids": orgs})
for f in r["result"]:
kind, idx = kinds[f["org_unit_id"]]
forms.append(FormInfo(f["id"], ftype, f["org_unit_id"], kind, idx, vsp.get(f["org_unit_id"], [])))
forms.sort(key=lambda f: f.id)
return forms
async def leaf_items(api: Api, ftype: str, sheet: str) -> list[int]:
r = await api.get("/expense-item/", params={"form_type": ftype, "sheet": sheet})
items = r["result"]
parents = {i["parent_id"] for i in items if i.get("parent_id") is not None}
return sorted(i["id"] for i in items if i["id"] not in parents and (i.get("depth") or 0) >= 2)
async def ws_events(api: Api, form_id: int, sheet: str, events: list[tuple[str, dict]]) -> list[dict]:
"""Отправляет события по очереди по одному соединению, ждёт ответ на каждое."""
url = API.replace("http", "ws", 1) + f"/api/v1/ws/form/{form_id}/sheet/{sheet}"
results = []
async with websockets.connect(url, open_timeout=30, max_size=None) as ws:
await ws.send(json.dumps({"event": "user_login", "data": {"token": api.token}}))
for ev, data in events:
await ws.send(json.dumps({"event": ev, "data": data}))
while True:
msg = json.loads(await asyncio.wait_for(ws.recv(), 120))
if msg.get("event") == ev and ("result" in msg or "error" in msg):
results.append(msg)
break
return results
async def make_structure(api: Api, form: FormInfo, sheet: str) -> list[int]:
"""FORM_4: программы и проекты на листе. Возвращает id проектов (для привязки строк)."""
res = await ws_events(api, form.id, sheet, [("program_added", {"name": f"Программа{sheet}{p}"}) for p in range(1, PROGRAMS_PER_SHEET + 1)])
prog_ids = []
for m in res:
if "error" in m:
raise RuntimeError(f"program_added form={form.id} {sheet}: {m['error']}")
prog_ids.append(m["result"][0])
events = [
("project_added", {"name": f"Проект{sheet}{pi}{j}", "program_id": pid})
for pi, pid in enumerate(prog_ids, 1)
for j in range(1, PROJECTS_PER_PROGRAM + 1)
]
res = await ws_events(api, form.id, sheet, events)
out = []
for m in res:
if "error" in m:
raise RuntimeError(f"project_added form={form.id} {sheet}: {m['error']}")
out.append(m["result"][0])
return out
def new_line_id(resp: dict) -> int | None:
ids = [x["data"]["line_id"] for x in resp.get("result", []) if x.get("data", {}).get("line_id")]
return ids[-1] if ids else None
async def add_lines(api: Api, form: FormInfo, sheet: str, direction: str | None, count: int, leaves: list[int],
rng: random.Random, stats: Counter, project_ids: list[int] | None) -> list[int]:
"""Добавляет `count` строк, обходя листья дерева статей по кругу (дерево покрыто целиком)."""
pool = leaves[:]
rng.shuffle(pool)
ids: list[int] = []
bad: set[int] = set()
vsp_cycle = cycle(form.vsp_ids) if (form.type == "FORM_4" and form.vsp_ids) else None
proj_cycle = cycle(project_ids) if project_ids else None
i = 0
while len(ids) < count:
usable = [x for x in pool if x not in bad]
if not usable:
stats["pool_exhausted"] += 1
break
eid = usable[i % len(usable)]
i += 1
body: dict = {"expense_item_id": eid}
if direction:
body["direction"] = direction
if vsp_cycle:
body["vsp_id"] = next(vsp_cycle)
if proj_cycle:
body["project_id"] = next(proj_cycle)
try:
resp = await api.post(f"/form/{form.id}/sheet/{sheet}/line", json=body)
except ApiError as e:
if e.status in (400, 404, 409, 422):
bad.add(eid)
stats[f"line_rejected_{e.status}"] += 1
stats.setdefault("line_reject_sample", e.text[:160]) # type: ignore[arg-type]
continue
raise
lid = new_line_id(resp)
if lid is None:
stats["line_id_missing"] += 1
else:
ids.append(lid)
stats["bad_leaves"] += len(bad)
return ids
async def add_satellite_lines(api: Api, form: FormInfo, sheet: str, rng: random.Random) -> list[int]:
ids = []
for n, vsp in enumerate(form.vsp_ids, 1):
body = {
"vsp_id": vsp,
"contract_number": f"ДГ-{form.org_index:02d}/{sheet[-4:]}/{n:04d}",
"contract_end_date": datetime(YEAR + 1 + n % 3, 1 + n % 12, 1 + n % 27, tzinfo=timezone.utc).isoformat(),
}
resp = await api.post(f"/form/{form.id}/sheet/{sheet}/line", json=body)
res = resp.get("result", [])
sid = next((x["data"].get("id") or x["data"].get("line_id") for x in res if x.get("row_type") == "INPUT"), None)
if sid:
ids.append(sid)
return ids
async def probe_cols(api: Api, base: str, params: dict, line_id: int, candidates: list[str], value_fn) -> list[str]:
"""Определяет записываемые колонки. Сначала одним батчем: ошибка называет отвергнутую колонку
(computed_field: X), её убираем и повторяем. Если ошибка колонку не называет, добираем по одной.
Батч нужен потому, что на FORM_4 каждая одиночная правка стоит секунды (перерисовка листа)."""
cols = list(candidates)
for _ in range(len(candidates) + 1):
if not cols:
return []
changes = [{"line_id": line_id, "column": c, "value": value_fn(c)} for c in cols]
try:
await api.patch(base + "/cells", params=params, json={"changes": changes})
return cols
except ApiError as e:
if e.status not in (400, 403, 404, 409, 422):
raise
named = [c for c in cols if re.search(re.escape(c) + r"(?![\w.])", e.text)]
if not named:
break
cols = [c for c in cols if c not in named]
ok = []
for col in cols:
try:
await api.patch(base + "/cell", params=params, json={"line_id": line_id, "column": col, "value": value_fn(col)})
ok.append(col)
except ApiError as e:
if e.status not in (400, 403, 404, 409, 422):
raise
return ok
async def fill_cells(api: Api, path: str, params: dict, line_ids: list[int], cols: list[str], plan: GenPlan,
rng: random.Random, stats: Counter) -> int:
num_cols = [c for c in cols if c not in TEXT_CANDIDATES]
txt_cols = [c for c in cols if c in TEXT_CANDIDATES]
chosen = [i for i in line_ids if rng.random() < plan.fill_ratio]
changes = []
for lid in chosen:
for c in num_cols:
# плановые кварталы заполняются всегда, остальное с вероятностью 0.5
if c.startswith("plan.q") or rng.random() < 0.5:
changes.append({"line_id": lid, "column": c, "value": rand_value(c, rng)})
for c in txt_cols:
if rng.random() < 0.3:
changes.append({"line_id": lid, "column": c, "value": rand_value(c, rng)})
for k in range(0, len(changes), CELLS_BATCH):
batch = changes[k:k + CELLS_BATCH]
try:
await api.patch(path + "/cells", params=params, json={"changes": batch})
except ApiError as e:
if e.status != 400:
raise
stats["cells_batch_fallback"] += 1 # computed_field и т.п.: пробуем по одной ячейке
for ch in batch:
try:
await api.patch(path + "/cell", params=params, json=ch)
except ApiError as e2:
if e2.status not in (400, 403, 409, 422):
raise
stats["cell_rejected"] += 1
stats["cells_written"] += len(changes)
return len(changes)
async def process_form(api: Api, plan: GenPlan, form: FormInfo, leaves_cache: dict, stats: Counter, scale: int) -> None:
t0 = time.time()
rng = random.Random(f"{scale}-{form.id}")
sheets = LINE_SHEETS[form.type]
for sheet, direction in sheets:
key = (form.type, sheet)
if key not in leaves_cache:
leaves_cache[key] = asyncio.ensure_future(leaf_items(api, form.type, sheet))
leaves = await leaves_cache[key]
projects = None
if form.type == "FORM_4":
projects = await make_structure(api, form, sheet)
stats["form4_projects"] += len(projects)
ids = await add_lines(api, form, sheet, direction, plan.lines_per_sheet(form.type), leaves, rng, stats, projects)
stats["lines_added"] += len(ids)
if ids:
path = f"/form/{form.id}/sheet/{sheet}"
params = sheet_params(direction)
# записываемость колонок зависит от типа формы и листа, не от конкретной формы:
# пробуем один раз на (тип, лист, direction) — проба стоит ~35 запросов с перерисовкой листа
ckey = ("cols", form.type, sheet, direction)
if ckey not in leaves_cache:
leaves_cache[ckey] = asyncio.ensure_future(probe_cols(
api, path, params, ids[0], NUM_CANDIDATES + TEXT_CANDIDATES,
lambda c: rand_value(c, rng)))
form.cols[(sheet, direction)] = await leaves_cache[ckey]
await fill_cells(api, path, params, ids, form.cols[(sheet, direction)], plan, rng, stats)
if form.has_satellites:
for sheet in SATELLITES:
ids = await add_satellite_lines(api, form, sheet, rng)
stats["satellite_lines"] += len(ids)
log(f"форма {form.id} {form.type} {form.org_kind}-{form.org_index:02d}: готово за {time.time() - t0:.0f} c")
async def post_phase(api: Api, path: str, body: dict, stats: Counter, label: str) -> bool:
"""Создаёт этап. Колонки, чей раздел (секция) не входит в form_type, API отвергает целиком
(«Колонка X некорректна») — выкидываем их и повторяем."""
for _ in range(2):
try:
await api.post(path, json=body)
return True
except ApiError as e:
bad = set(re.findall(r"Колонка (\S+) некорректна", e.text))
keep = [c for c in body["column_keys"] if c not in bad]
if bad and keep and len(keep) < len(body["column_keys"]):
body = {**body, "column_keys": keep}
continue
stats[f"{label}_errors"] += 1
stats.setdefault(f"{label}_error_sample", e.text[:200]) # type: ignore[arg-type]
return False
return False
async def create_phases_for_form(api: Api, form: FormInfo, stats: Counter) -> None:
opens, closes = now_window()
sheets = (await api.get(f"/form/{form.id}/sheets"))["result"]["all_sheets"]
default_cols = [f"plan.q{q}" for q in range(1, 5)]
for s in sheets:
sheet, direction = s["sheet_name"], s["direction"]
cols = form.cols.get((sheet, direction)) or default_cols
for code, role in (("PERF_RF", "EXECUTOR_RF"), ("PERF_DFIP", "DFIP")):
body = {"sheet": sheet, "phase_code": code, "role": role, "column_keys": cols,
"opens_at": opens, "closes_at": closes, "direction": direction}
if await post_phase(api, f"/stages/form/{form.id}", body, stats, "phase"):
stats["phases_created"] += 1
async def create_projects(api: Api, plan: GenPlan, rf_orgs: list[int], stats: Counter, scale: int) -> list[dict]:
"""FORM_3: проекты РФ с годами и отчётами. Возвращает записи {project_id, org_index, year, report_type, rows}."""
out: list[dict] = []
cols_cache: dict[str, list[str]] = {}
async def create_one(r_idx: int, org: int, j: int) -> tuple[int, int, list[int]]:
body = {
"name": f"Пр{r_idx:02d}П{j:02d}", "year": YEAR, "branch_id": org, "level": "project",
"project_type": PROJECT_TYPES[j % len(PROJECT_TYPES)], "vsp_format": VSP_FORMATS[j % len(VSP_FORMATS)],
"placement_type": PLACEMENTS[j % len(PLACEMENTS)], "object_address": f"г. Город-{r_idx:02d}, ул. Проектная {j}",
"staff_count": 4 + j, "total_area": 80.0 + 12 * j,
}
r = await api.post("/projects", json=body) # technical_number выдаётся с гонкой при параллельных запросах
pid = r["project_id"]
years = [YEAR]
if j % 2 == 0:
await api.post(f"/projects/{pid}/year")
years.append(YEAR + 1)
stats["projects"] += 1
return pid, r_idx, years
async def fill_one(pid: int, r_idx: int, years: list[int], j: int) -> None:
rng = random.Random(f"{scale}-p-{r_idx}-{j}")
for year in years:
for rt in REPORT_TYPES:
path = f"/projects/{pid}/report/{year}/{rt}"
rows = (await api.get(path))["result"]
ids = [x["data"]["line_id"] for x in rows if x["row_type"] == "INPUT" and x["data"].get("line_id")]
if ids:
if rt not in cols_cache:
cols_cache[rt] = await probe_cols(api, path, {}, ids[0], REPORT_NUM_CANDIDATES,
lambda c: rand_value(c, rng))
cols = cols_cache[rt] or REPORT_NUM_CANDIDATES[:4]
await fill_cells(api, path, {}, ids, cols, plan, rng, stats)
opens, closes = now_window()
for code, role in (("PERF_RF", "EXECUTOR_RF"), ("PERF_DFIP", "DFIP")):
body = {"year": year, "report_type": rt, "phase_code": code, "role": role,
"column_keys": cols_cache.get(rt) or REPORT_NUM_CANDIDATES[:4], "opens_at": opens, "closes_at": closes}
if await post_phase(api, f"/stages/project/{pid}", body, stats, "project_phase"):
stats["project_phases_created"] += 1
out.append({"project_id": pid, "org_index": r_idx, "year": year, "report_type": rt, "rows": len(rows)})
created = []
for ri, org in enumerate(rf_orgs, 1):
for j in range(1, plan.projects_per_rf + 1):
pid, r_idx, years = await create_one(ri, org, j)
created.append((pid, r_idx, years, j))
# первая проба колонок отчёта делается до параллельного запуска, чтобы не пробовать дважды
first = created[0]
await fill_one(first[0], first[1], first[2], first[3])
await asyncio.gather(*(fill_one(*c) for c in created[1:]))
return out
async def self_check(plan: GenPlan, users: dict[str, User], forms: list[FormInfo], stats: Counter) -> None:
"""Проверка прав: rf/rf_coauthor/dfip читают листы своих форм и правят ячейку в открытом окне."""
sem = asyncio.Semaphore(4)
rf_form4 = next(f for f in forms if f.type == "FORM_4" and f.org_kind == "rf" and f.org_index == 1)
rf_form2 = next(f for f in forms if f.type == "FORM_2" and f.org_kind == "rf" and f.org_index == 1)
for uname in ("rf", "rf_coauthor", "dfip"):
u = users[uname]
api = Api(u.username, u.password, sem)
try:
for form in (rf_form4, rf_form2):
rows = (await api.get(f"/form/{form.id}/sheet/AHR"))["result"]
lines = [x["data"]["line_id"] for x in rows if x["row_type"] == "INPUT" and x["data"].get("line_id")
and (form.type == "FORM_4" or not x["data"].get("header", {}).get("vsp_id"))]
if not lines:
raise RuntimeError(f"self_check: у {uname} нет строк на {form.type}/AHR")
await api.patch(f"/form/{form.id}/sheet/AHR/cell",
json={"line_id": lines[0], "column": "plan.q1", "value": 1000.0})
finally:
await api.aclose()
log("самопроверка прав rf/rf_coauthor/dfip: OK")
stats["self_check"] = 1
async def count_rows(api: Api, path: str, params: dict | None = None) -> int:
return len((await api.get(path, params=params or {}))["result"])
async def build_manifest(api: Api, scale: int, users: dict[str, User], forms: list[FormInfo], projects: list[dict]) -> Manifest:
m = Manifest(scale=scale, users=users)
primary = [f for f in forms if f.org_index == 1] # ССП-01 (FORM_1, FORM_2) и РФ-01 (FORM_2, FORM_4)
for f in primary:
sheets = (await api.get(f"/form/{f.id}/sheets"))["result"]["all_sheets"]
for s in sheets:
sheet, direction = s["sheet_name"], s["direction"]
rows = await count_rows(api, f"/form/{f.id}/sheet/{sheet}", sheet_params(direction))
m.sheets.append(Sheet(f.id, f.type, sheet, direction, YEAR,
Sheet.build_url(f.id, f.type, sheet, direction, YEAR), rows, owner=f.org_kind))
for p in projects:
if p["org_index"] != 1:
continue
path = f"/projects/{p['project_id']}/report/{p['year']}/{p['report_type']}"
rows = p["rows"]
url = f"/table/form/{p['project_id']}/form-type/PROJECT/{p['report_type']}/null/{p['year']}"
m.project_reports.append(ProjectReport(p["project_id"], p["year"], p["report_type"], url, rows))
# своды: у страниц одинаковый путь, лист задаётся состоянием вкладки; после '#' — имя вкладки
for sheet in ("MAIN", "FORM_1", "FORM_2", "FORM_3", "FORM_4"):
m.svods.append(Page(f"svod1_{sheet}", f"/svod#{sheet}"))
for sheet in ("GO", "RF"):
m.svods.append(Page(f"svod2_{sheet}", f"/svod/2#{sheet}"))
m.svods.append(Page("svods_navigator", "/svods-navigator"))
m.svods.append(Page("projects_summaries", "/projects/summaries"))
# страницы, с которых открываются модальные окна (этапы, год, экспорт, создание форм/проектов, админка, справочники)
m.modal_pages += [Page("tasks", "/tasks"), Page("projects", "/projects")]
for f in primary:
m.modal_pages.append(Page(f"task_{f.org_kind}_{f.type.lower()}", f"/task/{f.id}"))
first_project = next((p["project_id"] for p in projects if p["org_index"] == 1), None)
if first_project:
m.modal_pages.append(Page("project_card", f"/project/{first_project}"))
m.modal_pages += [
Page("admin_users", "/admin_panel/users"), Page("admin_ssps", "/admin_panel/ssps"),
Page("admin_default_stage", "/admin_panel/default-stage"), Page("admin_audit_log", "/admin_panel/audit-log"),
Page("dict_vsp", "/dicts/dict-vsp"), Page("dicts_navigator", "/dicts-navigator"),
]
return m
async def main(scale: int, overrides: dict) -> None:
plan = plan_for_scale(scale)
if overrides:
plan = dataclasses.replace(plan, **overrides)
log(f"ВНИМАНИЕ: план переопределён вручную (только отладка): {plan}")
log(f"масштаб {scale}: {plan}")
await ensure_empty()
t_start = time.time()
sem = asyncio.Semaphore(CONCURRENCY)
admin = Api("admin", "admin", sem)
stats: Counter = Counter()
try:
await admin.login()
ssp, rf = await create_orgs(admin, plan)
log(f"оргединицы: ССП {len(ssp)}, РФ {len(rf)}")
users = await create_users(admin, rf[0], ssp + rf)
log("пользователи rf, rf_coauthor, dfip созданы")
vsp = await create_vsp(admin, plan, rf)
log(f"ВСП: {sum(len(v) for v in vsp.values())}")
forms = await create_forms(admin, ssp, rf, vsp)
log(f"формы: {Counter(f.type for f in forms)}")
leaves_cache: dict = {}
await asyncio.gather(*(process_form(admin, plan, f, leaves_cache, stats, scale) for f in forms))
log(f"строки и значения: {dict(stats)}")
await asyncio.gather(*(create_phases_for_form(admin, f, stats) for f in forms))
log(f"этапы форм: создано {stats['phases_created']}, ошибок {stats['phase_errors']}")
projects = await create_projects(admin, plan, rf, stats, scale)
log(f"проекты FORM_3: {stats['projects']}, отчётов {len(projects)}")
await self_check(plan, users, forms, stats)
manifest = await build_manifest(admin, scale, users, forms, projects)
path = manifest_path(scale, RESULTS_DIR)
dump(manifest, path)
biggest = max((s for s in manifest.sheets if s.owner == "rf"), key=lambda s: s.rows)
log(f"манифест: {path}; листов {len(manifest.sheets)}, отчётов проектов {len(manifest.project_reports)}; "
f"самый большой лист rf: form {biggest.form_id} {biggest.form_type}/{biggest.sheet} = {biggest.rows} строк")
top = sorted(manifest.sheets, key=lambda s: -s.rows)[:8]
log("топ листов: " + "; ".join(f"{s.form_type}/{s.sheet}/{s.direction}[{s.owner}]={s.rows}" for s in top))
elapsed = time.time() - t_start
log(f"ГЕНЕРАЦИЯ ЗАВЕРШЕНА за {elapsed / 60:.1f} мин; запросов {admin.requests}; статистика {dict(stats)}")
(RESULTS_DIR / f"gen-stats-s{scale}.json").write_text(
json.dumps({"scale": scale, "plan": dataclasses.asdict(plan), "elapsed_s": round(elapsed, 1),
"requests": admin.requests, "stats": dict(stats)}, ensure_ascii=False, indent=2), encoding="utf-8")
finally:
await admin.aclose()
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--scale", type=int, required=True)
ap.add_argument("--extra-lines", type=int, help="ТОЛЬКО ОТЛАДКА: переопределить extra_lines_per_sheet")
ap.add_argument("--orgs-ssp", type=int, help="ТОЛЬКО ОТЛАДКА")
ap.add_argument("--orgs-rf", type=int, help="ТОЛЬКО ОТЛАДКА")
ap.add_argument("--vsp", type=int, help="ТОЛЬКО ОТЛАДКА")
ap.add_argument("--projects", type=int, help="ТОЛЬКО ОТЛАДКА")
a = ap.parse_args()
ov = {k: v for k, v in {
"extra_lines_per_sheet": a.extra_lines, "org_units_ssp": a.orgs_ssp, "org_units_rf": a.orgs_rf,
"vsp_per_rf": a.vsp, "projects_per_rf": a.projects}.items() if v is not None}
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
asyncio.run(main(a.scale, ov))

203
perf/perfkit/gen/verify.py Normal file
View File

@ -0,0 +1,203 @@
"""Проверка результата генерации: `python -m perfkit.gen.verify --scale N` -> `VERIFY OK`.
Проверки по БД (asyncpg, PERF_DB_DSN), по API (вход под `rf`, свод под admin) и по фронту (PERF_WEB):
1. число форм по типам, ВСП, программ/проектов FORM_4, проектов FORM_3 и их годов/отчётов равно плану;
2. число строк (budget_line) каждой формы не меньше плана и не больше плана плюс запас на строки,
которые БД создаёт сама (FORM_2: строки лимитов, родительские строки сателлитов);
сателлитные строки (rent/utility/security) — ровно vsp_per_rf на лист у каждой РФ;
3. у `rf` самый большой лист >= 1000 строк (читается через API под `rf`, совпадает с манифестом);
4. на каждом листе из манифеста есть этап EXECUTOR_RF с opens_at < now() < closes_at
(и такой же для DFIP); то же для каждого отчёта проекта;
5. своды 1 и 2 отдают непустые данные с ненулевыми значениями;
6. каждый URL манифеста отвечает 200 через PERF_WEB.
"""
from __future__ import annotations
import argparse
import asyncio
import dataclasses
import os
import sys
import asyncpg
import httpx
from .. import RESULTS_DIR
from ..manifest import load, manifest_path
from .client import Api
from .plan import YEAR, plan_for_scale
DSN = os.environ.get("PERF_DB_DSN", "postgresql://perf:perf@localhost:55432/perf")
WEB = os.environ.get("PERF_WEB", "http://localhost:8080")
SLACK_AUTO_LINES = 40 # строки, которые БД создаёт сама (FORM_2: 19 лимитных + 3 родителя сателлитов)
problems: list[str] = []
def check(cond: bool, msg: str) -> None:
print(("ok " if cond else "FAIL ") + msg)
if not cond:
problems.append(msg)
def has_nonzero(x) -> bool:
if isinstance(x, dict):
return any(has_nonzero(v) for v in x.values())
if isinstance(x, (int, float)) and not isinstance(x, bool):
return x != 0
return False
async def db_checks(conn, plan, man) -> None:
forms = {r["form_type_code"]: r["n"] for r in await conn.fetch(
"SELECT form_type_code, count(*) n FROM v3.budget_form WHERE year=$1 AND NOT to_delete GROUP BY 1", YEAR)}
check(forms.get("FORM_1", 0) == plan.forms_form1, f"форм FORM_1 {forms.get('FORM_1', 0)} == {plan.forms_form1}")
check(forms.get("FORM_2", 0) == plan.forms_form2, f"форм FORM_2 {forms.get('FORM_2', 0)} == {plan.forms_form2}")
check(forms.get("FORM_4", 0) == plan.forms_form4, f"форм FORM_4 {forms.get('FORM_4', 0)} == {plan.forms_form4}")
orgs = {r["is_ssp"]: r["n"] for r in await conn.fetch("SELECT is_ssp, count(*) n FROM v3.org_unit GROUP BY 1")}
check(orgs.get(True, 0) == plan.org_units_ssp and orgs.get(False, 0) == plan.org_units_rf,
f"оргединиц ССП/РФ {orgs.get(True, 0)}/{orgs.get(False, 0)} == {plan.org_units_ssp}/{plan.org_units_rf}")
vsp = await conn.fetchval("SELECT count(*) FROM v3.vsp")
check(vsp == plan.org_units_rf * plan.vsp_per_rf, f"ВСП {vsp} == {plan.org_units_rf * plan.vsp_per_rf}")
# строки по формам
per_form = {r["id"]: (r["form_type_code"], r["is_ssp"], r["n"]) for r in await conn.fetch(
"""SELECT bf.id, bf.form_type_code, ou.is_ssp, count(bl.id) n
FROM v3.budget_form bf JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
LEFT JOIN v3.budget_line bl ON bl.budget_form_id = bf.id
WHERE bf.year=$1 GROUP BY 1,2,3""", YEAR)}
sheets_per_type = {"FORM_1": 5, "FORM_2": 3, "FORM_4": 3}
bad = []
total_lines = 0
for fid, (ft, is_ssp, n) in per_form.items():
expected = sheets_per_type[ft] * plan.lines_per_sheet(ft)
total_lines += n
# у РФ в FORM_2 каждая сателлитная строка синхронизируется в строку AHR (+ vsp_per_rf на лист)
slack = SLACK_AUTO_LINES + (3 * plan.vsp_per_rf if (ft == "FORM_2" and not is_ssp) else 0)
if not (expected <= n <= expected + slack):
bad.append((fid, ft, n, expected))
check(not bad, f"строк в формах: {len(per_form)} форм, всего {total_lines}; вне диапазона плана: {bad[:5]}")
sat = 0
for t in ("rent_detail", "utility_detail", "security_detail"):
sat += await conn.fetchval(f"SELECT count(*) FROM v3.{t}")
check(sat == 3 * plan.org_units_rf * plan.vsp_per_rf, f"сателлитных строк {sat} == {3 * plan.org_units_rf * plan.vsp_per_rf}")
prog = await conn.fetchval("SELECT count(*) FROM v3.form4_project WHERE level='program'")
proj = await conn.fetchval("SELECT count(*) FROM v3.form4_project WHERE level='project'")
check(prog == plan.programs, f"программ FORM_4 {prog} == {plan.programs}")
check(proj == plan.form4_projects, f"проектов FORM_4 {proj} == {plan.form4_projects}")
n_proj = await conn.fetchval("SELECT count(*) FROM v3.project WHERE level='project'")
exp_proj = plan.org_units_rf * plan.projects_per_rf
check(n_proj == exp_proj, f"проектов FORM_3 {n_proj} == {exp_proj}")
exp_years = exp_proj + plan.org_units_rf * (plan.projects_per_rf // 2)
years = await conn.fetchval("SELECT count(*) FROM v3.rf_project_year")
reports = await conn.fetchval("SELECT count(*) FROM v3.rf_project_report")
check(years == exp_years, f"годов проектов {years} == {exp_years}")
check(reports == 2 * exp_years, f"отчётов проектов {reports} == {2 * exp_years}")
filled = await conn.fetchval("SELECT count(*) FROM v3.plan WHERE plan_q1 IS NOT NULL")
check(filled > 0, f"заполненных плановых строк {filled} > 0")
# окна этапов
bad_sheets = [s for s in man.sheets if not await conn.fetchval(
"""SELECT EXISTS (SELECT 1 FROM v3.form_phase WHERE budget_form_id=$1 AND sheet=$2
AND direction IS NOT DISTINCT FROM $3 AND role='EXECUTOR_RF'
AND opens_at < now() AND now() < closes_at)""",
s.form_id, s.sheet, s.direction)]
check(not bad_sheets, f"этап EXECUTOR_RF с открытым окном на каждом из {len(man.sheets)} листов манифеста")
bad_reports = []
for r in man.project_reports:
ok = await conn.fetchval(
"""SELECT EXISTS (SELECT 1 FROM v3.form3_phase fp
JOIN v3.rf_project_report rr ON rr.id = fp.rf_project_report_id
JOIN v3.rf_project_year ry ON ry.id = rr.rf_project_year_id
WHERE ry.project_id=$1 AND ry.year=$2 AND rr.report_type=$3 AND fp.role='EXECUTOR_RF'
AND fp.opens_at < now() AND now() < fp.closes_at)""",
r.project_id, r.year, r.report_type)
if not ok:
bad_reports.append(r)
check(not bad_reports, f"этап EXECUTOR_RF с открытым окном на каждом из {len(man.project_reports)} отчётов проектов")
async def api_checks(man, min_biggest: int) -> None:
sem = asyncio.Semaphore(4)
rf = man.users["rf"]
api = Api(rf.username, rf.password, sem)
admin = Api("admin", "admin", sem)
try:
sizes = {}
for s in (x for x in man.sheets if x.owner == "rf"):
params = {"direction": s.direction} if s.direction else {}
rows = (await api.get(f"/form/{s.form_id}/sheet/{s.sheet}", params=params))["result"]
sizes[(s.form_id, s.sheet, s.direction)] = len(rows)
if len(rows) != s.rows:
check(False, f"rf: строк листа {s.form_id}/{s.sheet} {len(rows)} == манифест {s.rows}")
biggest = max(sizes.items(), key=lambda kv: kv[1])
check(biggest[1] >= min_biggest, f"самый большой лист rf {biggest[0]} = {biggest[1]} строк >= {min_biggest}")
check(all(sizes[(s.form_id, s.sheet, s.direction)] == s.rows for s in man.sheets if s.owner == "rf"),
"строки rf-листов в манифесте совпадают с API")
year = YEAR
for sheet in ("MAIN", "FORM_1", "FORM_2", "FORM_3", "FORM_4"):
rows = (await admin.get(f"/svod/1/{sheet}/{year}"))["result"]
check(bool(rows) and any(has_nonzero(r["data"]) for r in rows), f"свод 1 {sheet}: {len(rows)} строк, есть ненулевые значения")
for sheet in ("GO", "RF"):
rows = (await admin.get(f"/svod/2/{sheet}/{year}"))["result"]
check(bool(rows) and any(has_nonzero(r["data"]) for r in rows), f"свод 2 {sheet}: {len(rows)} строк, есть ненулевые значения")
r = await admin.get("/projects/plan-summary", params={"year": year})
check(bool(r.get("result")), "сводная проектов plan-summary непустая")
finally:
await api.aclose()
await admin.aclose()
def url_checks(man) -> None:
urls = [s.url for s in man.sheets] + [r.url for r in man.project_reports] + [p.url for p in man.svods] + [p.url for p in man.modal_pages]
names = [p.name for p in man.svods] + [p.name for p in man.modal_pages]
check(len(set(names)) == len(names) and not any((" " in n or "/" in n) for n in names),
"имена страниц svods/modal_pages уникальны и без пробелов и слэшей")
bad = []
with httpx.Client(timeout=30, follow_redirects=True) as c:
for u in urls:
code = c.get(WEB + u).status_code
if code != 200:
bad.append((u, code))
check(not bad, f"URL манифеста отвечают 200 через {WEB}: {len(urls)} шт.; ошибки: {bad[:5]}")
async def main(scale: int, overrides: dict, min_biggest: int) -> int:
plan = plan_for_scale(scale)
if overrides:
plan = dataclasses.replace(plan, **overrides)
man = load(manifest_path(scale, RESULTS_DIR))
check(man.scale == scale, f"манифест масштаба {man.scale}")
conn = await asyncpg.connect(DSN)
try:
await db_checks(conn, plan, man)
finally:
await conn.close()
await api_checks(man, min_biggest)
url_checks(man)
if problems:
print(f"VERIFY FAIL: {len(problems)} проблем")
return 1
print("VERIFY OK")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--scale", type=int, required=True)
ap.add_argument("--min-biggest", type=int, default=1000, help="порог самого большого листа rf (по умолчанию 1000)")
ap.add_argument("--extra-lines", type=int, help="ТОЛЬКО ОТЛАДКА (как в run)")
ap.add_argument("--orgs-ssp", type=int)
ap.add_argument("--orgs-rf", type=int)
ap.add_argument("--vsp", type=int)
ap.add_argument("--projects", type=int)
a = ap.parse_args()
ov = {k: v for k, v in {
"extra_lines_per_sheet": a.extra_lines, "org_units_ssp": a.orgs_ssp, "org_units_rf": a.orgs_rf,
"vsp_per_rf": a.vsp, "projects_per_rf": a.projects}.items() if v is not None}
sys.exit(asyncio.run(main(a.scale, ov, a.min_biggest)))

View File

@ -9,6 +9,8 @@
Task 3 заполняет `svods` и `modal_pages` по маршрутам Routes.jsx
(/svod, /svod/2, /svods-navigator, /projects/summaries и т.д.) — это просто списки Page(name, url).
Лист свода выбирается вкладкой внутри страницы, не URL: у Page свода url = путь + '#' + имя листа
(`/svod#FORM_1`, `/svod/2#RF`); хеш на маршрутизацию не влияет, сценарий по нему выбирает вкладку.
"""
from __future__ import annotations
@ -31,6 +33,9 @@ class Sheet:
year: int
url: str
rows: int
# чьей оргединице принадлежит форма: "rf" — РФ пользователя `rf` (ему доступна), "ssp" — ССП
# (доступна admin/dfip, но не `rf`). Поле добавлено Task 3, по умолчанию "rf".
owner: str = "rf"
@staticmethod
def build_url(form_id, form_type, sheet, direction, year) -> str:

View File

@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Стенд профилирования: up | migrate | bootstrap | api | web-build | web | smoke | down | all
# Стенд профилирования: up | migrate | bootstrap | api | web-build | web | smoke | reset | down | all
set -euo pipefail
SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
cd "$(dirname "$0")/.."
ROOT="$(pwd)"
PERF="$ROOT/perf"
@ -27,7 +28,8 @@ start_bg() { # name, workdir, cmd...
if [ -f "$RUN/$name.pid" ] && kill -0 "$(cat "$RUN/$name.pid")" 2>/dev/null; then
echo "$name уже запущен (pid $(cat "$RUN/$name.pid"))"; return
fi
(cd "$dir" && nohup "$@" >"$RUN/$name.log" 2>&1 & echo $! >"$RUN/$name.pid")
# внешний >/dev/null: фоновая подоболочка не должна держать stdout вызывающего (иначе `| tail` виснет)
(cd "$dir" && nohup "$@" >"$RUN/$name.log" 2>&1 </dev/null & echo $! >"$RUN/$name.pid") >/dev/null 2>&1
}
wait_http() { # url, tries
@ -73,6 +75,17 @@ case "$cmd" in
venv
"$VENV/bin/python" "$PERF/stand/smoke.py"
;;
reset)
# Чистая БД: останавливаем api (держит соединения), сносим volume Postgres, поднимаем заново
# и снова запускаем api. Фронт (web) не трогаем: БД он не использует.
if [ -f "$RUN/api.pid" ]; then
pkill -P "$(cat "$RUN/api.pid")" 2>/dev/null || true
kill "$(cat "$RUN/api.pid")" 2>/dev/null || true
rm -f "$RUN/api.pid"
fi
docker compose -f "$PERF/docker-compose.yml" down -v
"$SELF" up; "$SELF" migrate; "$SELF" bootstrap; "$SELF" api
;;
down)
for n in web api; do
if [ -f "$RUN/$n.pid" ]; then
@ -84,9 +97,9 @@ case "$cmd" in
docker compose -f "$PERF/docker-compose.yml" down
;;
all)
"$0" up; "$0" migrate; "$0" bootstrap; "$0" web-build; "$0" api; "$0" web; "$0" smoke
"$SELF" up; "$SELF" migrate; "$SELF" bootstrap; "$SELF" web-build; "$SELF" api; "$SELF" web; "$SELF" smoke
;;
*)
echo "usage: $0 up|migrate|bootstrap|api|web-build|web|smoke|down|all" >&2; exit 2
echo "usage: $0 up|migrate|bootstrap|api|web-build|web|smoke|reset|down|all" >&2; exit 2
;;
esac

View File

@ -0,0 +1,34 @@
import dataclasses
import pytest
from perfkit.gen.plan import GenPlan, plan_for_scale
def test_plan_scale2_doubles_volume():
p1, p2 = plan_for_scale(1), plan_for_scale(2)
for name in ("org_units_rf", "vsp_per_rf", "extra_lines_per_sheet", "projects_per_rf"):
assert getattr(p2, name) == 2 * getattr(p1, name), name
assert p2.fill_ratio == p1.fill_ratio
def test_plan_rejects_zero_scale():
with pytest.raises(ValueError):
plan_for_scale(0)
def test_plan_scale1_fields_and_biggest_sheet():
p = plan_for_scale(1)
assert {f.name for f in dataclasses.fields(GenPlan)} >= {
"org_units_ssp", "org_units_rf", "vsp_per_rf", "extra_lines_per_sheet",
"projects_per_rf", "fill_ratio",
}
# самый большой лист rf (FORM_2/AHR: 566 строк шаблона + extra) не меньше 1000 строк
assert 566 + p.extra_lines_per_sheet >= 1000
assert p.org_units_rf >= 1 and p.org_units_ssp >= 1
assert 0 < p.fill_ratio <= 1
def test_plan_rejects_negative_scale():
with pytest.raises(ValueError):
plan_for_scale(-1)