Профилирование фронта и бэка: стенд, замеры, отчёт (#202) #203
@ -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 |
|
||||
|
||||
@ -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`.
|
||||
|
||||
0
perf/perfkit/gen/__init__.py
Normal file
0
perf/perfkit/gen/__init__.py
Normal file
86
perf/perfkit/gen/client.py
Normal file
86
perf/perfkit/gen/client.py
Normal 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
86
perf/perfkit/gen/plan.py
Normal 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
619
perf/perfkit/gen/run.py
Normal 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
203
perf/perfkit/gen/verify.py
Normal 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)))
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
34
perf/tests/test_gen_plan.py
Normal file
34
perf/tests/test_gen_plan.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user