511 lines
20 KiB
Python
511 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Batch-0 live probe for GENERAL DOMAIN Group-1 (Q1..Q5).
|
||
|
||
Runs deterministic 1C queries against MCP execute endpoint and generates
|
||
a markdown report with route-level verdicts.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime as dt
|
||
import json
|
||
import re
|
||
from collections import defaultdict
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib import request
|
||
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
def decode_mojibake(value: str) -> str:
|
||
text = str(value or "")
|
||
if not text:
|
||
return text
|
||
candidates = [text]
|
||
|
||
try:
|
||
candidates.append(text.encode("latin1").decode("utf-8"))
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
candidates.append(text.encode("cp1251").decode("utf-8"))
|
||
except Exception:
|
||
pass
|
||
|
||
def score(s: str) -> int:
|
||
cyr = len(re.findall(r"[А-Яа-яЁё]", s))
|
||
bad = len(re.findall(r"(?:Ð.|Ñ.|Р.|С.)", s))
|
||
return cyr * 2 - bad
|
||
|
||
return max(candidates, key=score)
|
||
|
||
|
||
def parse_number(cell: str) -> int | float | str:
|
||
source = str(cell).strip()
|
||
if source == "":
|
||
return ""
|
||
# Preserve leading-zero codes like "01", "002", "010" for account dictionaries.
|
||
if re.fullmatch(r"-?\d+", source) and not re.fullmatch(r"-?0\d+", source):
|
||
return int(source)
|
||
if re.fullmatch(r"-?\d+[.,]\d+", source):
|
||
return float(source.replace(",", "."))
|
||
return source
|
||
|
||
|
||
def parse_text_table(data_text: str) -> list[dict[str, Any]]:
|
||
text = decode_mojibake(data_text).replace("\r", "").strip()
|
||
if not text:
|
||
return []
|
||
|
||
header_match = re.search(r"\{([^}]*)\}:", text)
|
||
if not header_match:
|
||
return []
|
||
|
||
raw_cols = header_match.group(1)
|
||
columns = [
|
||
decode_mojibake(part.strip().strip('"'))
|
||
for part in raw_cols.split(",")
|
||
if part.strip()
|
||
]
|
||
body = text[header_match.end() :].strip()
|
||
if not body:
|
||
return []
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
for line in body.split("\n"):
|
||
raw = line.strip()
|
||
if not raw:
|
||
continue
|
||
|
||
parts: list[str] = []
|
||
token = []
|
||
in_quotes = False
|
||
i = 0
|
||
while i < len(raw):
|
||
ch = raw[i]
|
||
if ch == '"':
|
||
if in_quotes and i + 1 < len(raw) and raw[i + 1] == '"':
|
||
token.append('"')
|
||
i += 2
|
||
continue
|
||
in_quotes = not in_quotes
|
||
i += 1
|
||
continue
|
||
if ch == "," and not in_quotes:
|
||
parts.append("".join(token).strip())
|
||
token = []
|
||
i += 1
|
||
continue
|
||
token.append(ch)
|
||
i += 1
|
||
parts.append("".join(token).strip())
|
||
|
||
row: dict[str, Any] = {}
|
||
for idx, col in enumerate(columns):
|
||
cell = decode_mojibake(parts[idx] if idx < len(parts) else "")
|
||
row[col] = parse_number(cell)
|
||
rows.append(row)
|
||
return rows
|
||
|
||
|
||
@dataclass
|
||
class QueryResult:
|
||
rows: list[dict[str, Any]]
|
||
error: str | None
|
||
|
||
|
||
def execute_query(endpoint: str, channel: str, query: str, limit: int) -> QueryResult:
|
||
url = f"{endpoint.rstrip('/')}/api/execute_query?channel={channel}"
|
||
payload = {"query": query, "limit": limit}
|
||
body = json.dumps(payload).encode("utf-8")
|
||
req = request.Request(
|
||
url,
|
||
data=body,
|
||
headers={"content-type": "application/json; charset=utf-8"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with request.urlopen(req, timeout=90) as resp:
|
||
content = resp.read().decode("utf-8", errors="replace")
|
||
except Exception as exc:
|
||
return QueryResult(rows=[], error=f"http_error: {exc}")
|
||
|
||
try:
|
||
payload_obj = json.loads(content)
|
||
except Exception:
|
||
return QueryResult(rows=[], error=f"invalid_json_response: {content[:300]}")
|
||
|
||
if payload_obj.get("success") is not True:
|
||
return QueryResult(rows=[], error=decode_mojibake(str(payload_obj.get("error") or "unknown_error")))
|
||
|
||
data = payload_obj.get("data")
|
||
if isinstance(data, list):
|
||
rows = [{decode_mojibake(str(k)): v for k, v in row.items()} for row in data if isinstance(row, dict)]
|
||
return QueryResult(rows=rows, error=None)
|
||
if isinstance(data, str):
|
||
return QueryResult(rows=parse_text_table(data), error=None)
|
||
if isinstance(data, dict) and isinstance(data.get("rows"), list):
|
||
rows = [{decode_mojibake(str(k)): v for k, v in row.items()} for row in data["rows"] if isinstance(row, dict)]
|
||
return QueryResult(rows=rows, error=None)
|
||
return QueryResult(rows=[], error=None)
|
||
|
||
|
||
def pick(row: dict[str, Any], *keys: str) -> Any:
|
||
for key in keys:
|
||
if key in row:
|
||
return row[key]
|
||
return None
|
||
|
||
|
||
def as_int(value: Any) -> int:
|
||
if isinstance(value, bool):
|
||
return int(value)
|
||
if isinstance(value, int):
|
||
return value
|
||
if isinstance(value, float):
|
||
return int(value)
|
||
try:
|
||
return int(str(value).replace(",", "."))
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def normalize_ym(value: Any) -> str:
|
||
text = str(value or "").strip()
|
||
m = re.match(r"^(\d{4}-\d{2})-\d{2}", text)
|
||
if m:
|
||
return m.group(1)
|
||
return text
|
||
|
||
|
||
def normalize_account_code(value: Any) -> str:
|
||
code = str(value or "").strip().strip('"')
|
||
return code
|
||
|
||
|
||
def section_from_code(code: str) -> str | None:
|
||
m = re.match(r"^(\d{2})", code)
|
||
if not m:
|
||
return None
|
||
return m.group(1)
|
||
|
||
|
||
def top_rows(rows: list[dict[str, Any]], key: str, n: int = 10) -> list[dict[str, Any]]:
|
||
return sorted(rows, key=lambda r: as_int(r.get(key)), reverse=True)[:n]
|
||
|
||
|
||
def build_report(
|
||
out_path: Path,
|
||
endpoint: str,
|
||
channel: str,
|
||
run_ts: str,
|
||
q1_minmax: QueryResult,
|
||
q1_year_ops: QueryResult,
|
||
q2_year_docs: QueryResult,
|
||
q3_month_ops: QueryResult,
|
||
q4_doc_types: QueryResult,
|
||
q5_dt: QueryResult,
|
||
q5_kt: QueryResult,
|
||
q5_chart: QueryResult,
|
||
) -> None:
|
||
errors = [
|
||
("Q1.minmax", q1_minmax.error),
|
||
("Q1.year_ops", q1_year_ops.error),
|
||
("Q2.year_docs", q2_year_docs.error),
|
||
("Q3.month_ops", q3_month_ops.error),
|
||
("Q4.doc_types", q4_doc_types.error),
|
||
("Q5.dt_accounts", q5_dt.error),
|
||
("Q5.kt_accounts", q5_kt.error),
|
||
("Q5.chart", q5_chart.error),
|
||
]
|
||
hard_errors = [(name, err) for name, err in errors if err]
|
||
|
||
q1_row = q1_minmax.rows[0] if q1_minmax.rows else {}
|
||
min_period = pick(q1_row, "МинПериод", "MinPeriod")
|
||
max_period = pick(q1_row, "МаксПериод", "MaxPeriod")
|
||
total_ops = as_int(pick(q1_row, "КоличествоОпераций", "Количество", "Count"))
|
||
|
||
q1_year_top = []
|
||
years_present: list[int] = []
|
||
for row in q1_year_ops.rows:
|
||
year = as_int(pick(row, "Год", "Year"))
|
||
count = as_int(pick(row, "КоличествоОпераций", "Количество", "Count"))
|
||
if year > 0:
|
||
years_present.append(year)
|
||
q1_year_top.append((year, count))
|
||
q1_year_top = sorted(q1_year_top, key=lambda x: x[1], reverse=True)
|
||
|
||
q2_top = []
|
||
for row in q2_year_docs.rows:
|
||
year = as_int(pick(row, "Год", "Year"))
|
||
docs = as_int(pick(row, "КоличествоДокументов", "Количество", "Count"))
|
||
if year > 0:
|
||
q2_top.append((year, docs))
|
||
q2_top = sorted(q2_top, key=lambda x: x[1], reverse=True)
|
||
|
||
q3_top = []
|
||
for row in q3_month_ops.rows:
|
||
ym = normalize_ym(pick(row, "Месяц", "Month"))
|
||
ops = as_int(pick(row, "КоличествоОпераций", "Количество", "Count"))
|
||
if ym:
|
||
q3_top.append((ym, ops))
|
||
q3_top = sorted(q3_top, key=lambda x: x[1], reverse=True)
|
||
|
||
q4_top = []
|
||
for row in q4_doc_types.rows:
|
||
doc_type = str(pick(row, "ТипДокумента", "DocumentType") or "").strip()
|
||
docs = as_int(pick(row, "КоличествоДокументов", "Количество", "Count"))
|
||
if doc_type:
|
||
q4_top.append((doc_type, docs))
|
||
q4_top = sorted(q4_top, key=lambda x: x[1], reverse=True)
|
||
|
||
section_totals: dict[str, int] = defaultdict(int)
|
||
for source in (q5_dt.rows, q5_kt.rows):
|
||
for row in source:
|
||
code = normalize_account_code(pick(row, "КодСчета", "Код", "AccountCode"))
|
||
count = as_int(pick(row, "КоличествоПроводок", "Количество", "Count"))
|
||
section = section_from_code(code)
|
||
if section is None:
|
||
continue
|
||
section_totals[section] += count
|
||
|
||
section_names: dict[str, str] = {}
|
||
for row in q5_chart.rows:
|
||
code = normalize_account_code(pick(row, "Код", "Code"))
|
||
name = str(pick(row, "Наименование", "Name") or "").strip()
|
||
if re.fullmatch(r"\d{2}", code):
|
||
section_names[code] = name
|
||
|
||
section_rows = sorted(section_totals.items(), key=lambda x: x[1], reverse=True)
|
||
section_top10 = section_rows[:10]
|
||
section_bottom10 = list(reversed(section_rows[-10:])) if section_rows else []
|
||
|
||
q1_verdict = "PASS" if q1_year_top and min_period and max_period else "PARTIAL"
|
||
q2_verdict = "PASS" if q2_top else "PARTIAL"
|
||
q3_verdict = "PASS" if q3_top else "PARTIAL"
|
||
q4_verdict = "PASS" if q4_top else "PARTIAL"
|
||
q5_verdict = "PASS" if section_top10 else "PARTIAL"
|
||
|
||
lines: list[str] = []
|
||
lines.append("# Management Route Probe Report — General Domain Group 1 (Q1–Q5)")
|
||
lines.append("")
|
||
lines.append(f"- Дата/время запуска: `{run_ts}`")
|
||
lines.append(f"- Endpoint: `{endpoint}`")
|
||
lines.append(f"- Channel: `{channel}`")
|
||
lines.append("- Контур: `question_mode=address_query`, Batch-0 route probes")
|
||
lines.append("")
|
||
if hard_errors:
|
||
lines.append("## Ошибки probe")
|
||
for name, err in hard_errors:
|
||
lines.append(f"- `{name}`: {err}")
|
||
lines.append("")
|
||
lines.append("## Вердикт по вопросам группы 1")
|
||
lines.append(f"- Q1 (покрытие периодов): **{q1_verdict}**")
|
||
lines.append(f"- Q2 (самый активный год по документам): **{q2_verdict}**")
|
||
lines.append(f"- Q3 (самый активный месяц по операциям): **{q3_verdict}**")
|
||
lines.append(f"- Q4 (наиболее частые типы документов): **{q4_verdict}**")
|
||
lines.append(f"- Q5 (наиболее/наименее заполненные разделы учета): **{q5_verdict}**")
|
||
lines.append("")
|
||
lines.append("## Q1 — Покрытие базы и активность по годам")
|
||
lines.append(f"- Мин период: `{min_period}`")
|
||
lines.append(f"- Макс период: `{max_period}`")
|
||
lines.append(f"- Всего операций в регистре: `{total_ops}`")
|
||
if years_present:
|
||
lines.append(f"- Годы с данными: `{min(years_present)}..{max(years_present)}` (уникальных лет: `{len(set(years_present))}`)")
|
||
lines.append("- Топ годов по количеству операций:")
|
||
for year, cnt in q1_year_top[:8]:
|
||
lines.append(f" - `{year}`: `{cnt}`")
|
||
lines.append("")
|
||
lines.append("## Q2 — Самый активный год по количеству документов")
|
||
lines.append("- Метрика: `COUNT(DISTINCT Регистратор)` по годам на `РегистрБухгалтерии.Хозрасчетный`.")
|
||
lines.append("- Топ годов:")
|
||
for year, cnt in q2_top[:8]:
|
||
lines.append(f" - `{year}`: `{cnt}`")
|
||
lines.append("- Вывод: route дает корректный ranking по документной активности в контуре движений.")
|
||
lines.append("")
|
||
lines.append("## Q3 — Самый активный месяц по количеству операций")
|
||
lines.append("- Метрика: `COUNT(*)` по `НАЧАЛОПЕРИОДА(Период, МЕСЯЦ)`.")
|
||
lines.append("- Топ месяцев:")
|
||
for ym, cnt in q3_top[:12]:
|
||
lines.append(f" - `{ym}`: `{cnt}`")
|
||
lines.append("")
|
||
lines.append("## Q4 — Наиболее частые типы документов")
|
||
lines.append("- Метрика: `COUNT(DISTINCT Регистратор)` по `ПРЕДСТАВЛЕНИЕ(ТИПЗНАЧЕНИЯ(Регистратор))`.")
|
||
lines.append("- Топ типов:")
|
||
for doc_type, cnt in q4_top[:12]:
|
||
lines.append(f" - `{doc_type}`: `{cnt}`")
|
||
lines.append("")
|
||
lines.append("## Q5 — Заполненность разделов учета")
|
||
lines.append("- Метод: агрегирование по первым двум цифрам кода счета (дебет + кредит).")
|
||
lines.append("- Топ разделов:")
|
||
for section, cnt in section_top10:
|
||
name = section_names.get(section, "(наименование не найдено в плане счетов)")
|
||
lines.append(f" - `{section}` `{name}`: `{cnt}`")
|
||
lines.append("- Разделы с минимальной активностью (среди использованных):")
|
||
for section, cnt in section_bottom10:
|
||
name = section_names.get(section, "(наименование не найдено в плане счетов)")
|
||
lines.append(f" - `{section}` `{name}`: `{cnt}`")
|
||
lines.append("")
|
||
lines.append("## Что подтверждено для продуктового плана")
|
||
lines.append("- `R01 period_coverage_profile`: подтвержден (Q1/Q3).")
|
||
lines.append("- `R02 document_type_usage_profile`: подтвержден (Q2/Q4).")
|
||
lines.append("- `Q5` закрывается route-контрактом через account-section aggregation; нужна фиксация правила для \"почти не используются\" (порог/квантиль).")
|
||
lines.append("")
|
||
lines.append("## Ограничения и требования к точности")
|
||
lines.append("- Q2/Q4 измеряются по `Регистратор` в движениях; это нужно явно закрепить как `movement-based document activity`.")
|
||
lines.append("- Для Q5 нельзя опираться только на raw счета: обязателен post-processing `section = account_code[:2]`.")
|
||
lines.append("- Есть записи с редкими/системными кодами (например off-balance); требуется whitelist/normalization policy для бизнес-отчета.")
|
||
lines.append("")
|
||
lines.append("## Следующий шаг Batch-0")
|
||
lines.append("- Зафиксировать route contracts для `R01` и `R02` в runtime docs.")
|
||
lines.append("- Добавить acceptance-вопросы Q1..Q5 в domain pack с жесткой проверкой метрик и сортировки.")
|
||
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Probe Group-1 routes for general management domain.")
|
||
parser.add_argument("--endpoint", default="http://127.0.0.1:6003")
|
||
parser.add_argument("--channel", default="default")
|
||
parser.add_argument(
|
||
"--out",
|
||
default=str(
|
||
REPO_ROOT
|
||
/ "docs"
|
||
/ "ADDRESS"
|
||
/ "address_query"
|
||
/ "management_route_probe_report_g1_2026-04-02.md"
|
||
),
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
q1_minmax_query = """
|
||
ВЫБРАТЬ
|
||
МИНИМУМ(Движения.Период) КАК МинПериод,
|
||
МАКСИМУМ(Движения.Период) КАК МаксПериод,
|
||
КОЛИЧЕСТВО(*) КАК КоличествоОпераций
|
||
ИЗ
|
||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||
""".strip()
|
||
|
||
q1_year_ops_query = """
|
||
ВЫБРАТЬ
|
||
ГОД(Движения.Период) КАК Год,
|
||
КОЛИЧЕСТВО(*) КАК КоличествоОпераций
|
||
ИЗ
|
||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||
СГРУППИРОВАТЬ ПО
|
||
ГОД(Движения.Период)
|
||
УПОРЯДОЧИТЬ ПО
|
||
КоличествоОпераций УБЫВ
|
||
""".strip()
|
||
|
||
q2_year_docs_query = """
|
||
ВЫБРАТЬ
|
||
ГОД(Движения.Период) КАК Год,
|
||
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Движения.Регистратор) КАК КоличествоДокументов
|
||
ИЗ
|
||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||
СГРУППИРОВАТЬ ПО
|
||
ГОД(Движения.Период)
|
||
УПОРЯДОЧИТЬ ПО
|
||
КоличествоДокументов УБЫВ
|
||
""".strip()
|
||
|
||
q3_month_ops_query = """
|
||
ВЫБРАТЬ
|
||
НАЧАЛОПЕРИОДА(Движения.Период, МЕСЯЦ) КАК Месяц,
|
||
КОЛИЧЕСТВО(*) КАК КоличествоОпераций
|
||
ИЗ
|
||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||
СГРУППИРОВАТЬ ПО
|
||
НАЧАЛОПЕРИОДА(Движения.Период, МЕСЯЦ)
|
||
УПОРЯДОЧИТЬ ПО
|
||
КоличествоОпераций УБЫВ
|
||
""".strip()
|
||
|
||
q4_doc_types_query = """
|
||
ВЫБРАТЬ
|
||
ПРЕДСТАВЛЕНИЕ(ТИПЗНАЧЕНИЯ(Движения.Регистратор)) КАК ТипДокумента,
|
||
КОЛИЧЕСТВО(РАЗЛИЧНЫЕ Движения.Регистратор) КАК КоличествоДокументов
|
||
ИЗ
|
||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||
СГРУППИРОВАТЬ ПО
|
||
ПРЕДСТАВЛЕНИЕ(ТИПЗНАЧЕНИЯ(Движения.Регистратор))
|
||
УПОРЯДОЧИТЬ ПО
|
||
КоличествоДокументов УБЫВ
|
||
""".strip()
|
||
|
||
q5_dt_query = """
|
||
ВЫБРАТЬ
|
||
Движения.СчетДт.Код КАК КодСчета,
|
||
КОЛИЧЕСТВО(*) КАК КоличествоПроводок
|
||
ИЗ
|
||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||
СГРУППИРОВАТЬ ПО
|
||
Движения.СчетДт.Код
|
||
УПОРЯДОЧИТЬ ПО
|
||
КоличествоПроводок УБЫВ
|
||
""".strip()
|
||
|
||
q5_kt_query = """
|
||
ВЫБРАТЬ
|
||
Движения.СчетКт.Код КАК КодСчета,
|
||
КОЛИЧЕСТВО(*) КАК КоличествоПроводок
|
||
ИЗ
|
||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
||
СГРУППИРОВАТЬ ПО
|
||
Движения.СчетКт.Код
|
||
УПОРЯДОЧИТЬ ПО
|
||
КоличествоПроводок УБЫВ
|
||
""".strip()
|
||
|
||
q5_chart_query = """
|
||
ВЫБРАТЬ
|
||
Счета.Код КАК Код,
|
||
Счета.Наименование КАК Наименование
|
||
ИЗ
|
||
ПланСчетов.Хозрасчетный КАК Счета
|
||
УПОРЯДОЧИТЬ ПО
|
||
Код
|
||
""".strip()
|
||
|
||
q1_minmax = execute_query(args.endpoint, args.channel, q1_minmax_query, limit=10)
|
||
q1_year_ops = execute_query(args.endpoint, args.channel, q1_year_ops_query, limit=200)
|
||
q2_year_docs = execute_query(args.endpoint, args.channel, q2_year_docs_query, limit=200)
|
||
q3_month_ops = execute_query(args.endpoint, args.channel, q3_month_ops_query, limit=240)
|
||
q4_doc_types = execute_query(args.endpoint, args.channel, q4_doc_types_query, limit=120)
|
||
q5_dt = execute_query(args.endpoint, args.channel, q5_dt_query, limit=300)
|
||
q5_kt = execute_query(args.endpoint, args.channel, q5_kt_query, limit=300)
|
||
q5_chart = execute_query(args.endpoint, args.channel, q5_chart_query, limit=600)
|
||
|
||
run_ts = dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds")
|
||
out_path = Path(args.out)
|
||
build_report(
|
||
out_path=out_path,
|
||
endpoint=args.endpoint,
|
||
channel=args.channel,
|
||
run_ts=run_ts,
|
||
q1_minmax=q1_minmax,
|
||
q1_year_ops=q1_year_ops,
|
||
q2_year_docs=q2_year_docs,
|
||
q3_month_ops=q3_month_ops,
|
||
q4_doc_types=q4_doc_types,
|
||
q5_dt=q5_dt,
|
||
q5_kt=q5_kt,
|
||
q5_chart=q5_chart,
|
||
)
|
||
print(f"[ok] report written: {out_path}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|