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