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

620 lines
31 KiB
Python
Raw Permalink 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.

"""Генератор данных стенда через 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))