Andrey Epifancev fb80d8c636 perf: генератор данных через API
Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:08:30 +04:00

204 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Проверка результата генерации: `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)))