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