NODEDC_1C/scripts/probe_address_general_domai...

511 lines
20 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.

#!/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 (Q1Q5)")
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())