364 lines
16 KiB
Python
364 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
XML_KIND_TO_SQL_KIND = {
|
|
"Catalogs": "Catalog",
|
|
"Documents": "Document",
|
|
"Reports": "Report",
|
|
"DataProcessors": "DataProcessor",
|
|
"ChartsOfCharacteristicTypes": "ChartOfCharacteristicTypes",
|
|
"ChartsOfAccounts": "ChartOfAccounts",
|
|
"ChartsOfCalculationTypes": "ChartOfCalculationTypes",
|
|
"InformationRegisters": "InformationRegister",
|
|
"AccumulationRegisters": "AccumulationRegister",
|
|
"AccountingRegisters": "AccountingRegister",
|
|
"CalculationRegisters": "CalculationRegister",
|
|
"BusinessProcesses": "BusinessProcess",
|
|
"Tasks": "Task",
|
|
}
|
|
|
|
|
|
def read_json(path: Path) -> dict[str, Any]:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def xml_route(item: dict[str, Any]) -> dict[str, Any]:
|
|
parts = Path(str(item.get("path") or item.get("relative_path") or "")).parts
|
|
for index, part in enumerate(parts):
|
|
if part in XML_KIND_TO_SQL_KIND and index + 3 < len(parts):
|
|
owner_name = parts[index + 1]
|
|
if parts[index + 2] == "Templates":
|
|
return {
|
|
"owner_kind": XML_KIND_TO_SQL_KIND[part],
|
|
"owner_name": owner_name,
|
|
"template_name": parts[index + 3],
|
|
"source": "xml_path",
|
|
}
|
|
rel_parts = Path(str(item.get("relative_path") or "")).parts
|
|
if len(rel_parts) >= 4 and rel_parts[0] == "Templates":
|
|
return {"template_name": rel_parts[1], "source": "xml_relative_path"}
|
|
return {"template_name": None, "source": "unknown"}
|
|
|
|
|
|
def sql_key(item: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
|
|
owner = item.get("owner") if isinstance(item.get("owner"), dict) else {}
|
|
template = item.get("template") if isinstance(item.get("template"), dict) else {}
|
|
return owner.get("kind"), owner.get("name"), template.get("name")
|
|
|
|
|
|
def xml_key(item: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
|
|
route = item.get("route") if isinstance(item.get("route"), dict) else xml_route(item)
|
|
return route.get("owner_kind"), route.get("owner_name"), route.get("template_name")
|
|
|
|
|
|
def dimension_pair(value: Any) -> tuple[int | None, int | None]:
|
|
if not isinstance(value, dict):
|
|
return None, None
|
|
rows = value.get("rows")
|
|
columns = value.get("columns")
|
|
try:
|
|
parsed_rows = int(rows) if rows is not None else None
|
|
except (TypeError, ValueError):
|
|
parsed_rows = None
|
|
try:
|
|
parsed_columns = int(columns) if columns is not None else None
|
|
except (TypeError, ValueError):
|
|
parsed_columns = None
|
|
return parsed_rows, parsed_columns
|
|
|
|
|
|
def dimension_delta(left: tuple[int | None, int | None], right: tuple[int | None, int | None]) -> dict[str, int | None]:
|
|
rows = left[0] - right[0] if left[0] is not None and right[0] is not None else None
|
|
columns = left[1] - right[1] if left[1] is not None and right[1] is not None else None
|
|
return {"rows": rows, "columns": columns}
|
|
|
|
|
|
def format_dimensions(value: Any) -> str:
|
|
rows, columns = dimension_pair(value)
|
|
if rows is None and columns is None:
|
|
return "-"
|
|
return f"{rows if rows is not None else '-'}x{columns if columns is not None else '-'}"
|
|
|
|
|
|
def format_delta(value: dict[str, int | None] | None) -> str:
|
|
if not isinstance(value, dict):
|
|
return "-"
|
|
rows = value.get("rows")
|
|
columns = value.get("columns")
|
|
if rows is None and columns is None:
|
|
return "-"
|
|
row_text = f"{rows:+d}" if isinstance(rows, int) else "-"
|
|
column_text = f"{columns:+d}" if isinstance(columns, int) else "-"
|
|
return f"{row_text}x{column_text}"
|
|
|
|
|
|
def sql_count(profile: dict[str, Any], key: str, sample_key: str | None = None) -> int | None:
|
|
counts = profile.get("counts") if isinstance(profile.get("counts"), dict) else {}
|
|
value = counts.get(key)
|
|
if value is not None:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if sample_key and isinstance(profile.get(sample_key), list):
|
|
return len(profile.get(sample_key) or [])
|
|
return None
|
|
|
|
|
|
def merge_record_analysis_counts(profile: dict[str, Any]) -> dict[str, int]:
|
|
blocks = 0
|
|
records = 0
|
|
for candidate in profile.get("sample_merge_record_block_candidates") or []:
|
|
if not isinstance(candidate, dict):
|
|
continue
|
|
evidence = candidate.get("evidence") if isinstance(candidate.get("evidence"), dict) else {}
|
|
analysis = evidence.get("record_analysis") if isinstance(evidence.get("record_analysis"), dict) else {}
|
|
if analysis.get("schema") != "moxel_numeric_block_records.v1":
|
|
continue
|
|
blocks += 1
|
|
try:
|
|
records += int(analysis.get("records_analyzed") or 0)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return {"blocks": blocks, "records": records}
|
|
|
|
|
|
def compare_item(sql_item: dict[str, Any] | None, xml_item: dict[str, Any]) -> dict[str, Any]:
|
|
route = xml_item.get("route") if isinstance(xml_item.get("route"), dict) else xml_route(xml_item)
|
|
xml_counts = xml_item.get("counts") if isinstance(xml_item.get("counts"), dict) else {}
|
|
xml_capacity = dimension_pair(xml_item.get("capacity_dimensions"))
|
|
xml_used = dimension_pair(xml_item.get("used_dimensions"))
|
|
result: dict[str, Any] = {
|
|
"route": route,
|
|
"xml": {
|
|
"xml_kind": xml_item.get("xml_kind"),
|
|
"capacity_dimensions": xml_item.get("capacity_dimensions"),
|
|
"used_dimensions": xml_item.get("used_dimensions"),
|
|
"counts": xml_counts,
|
|
},
|
|
"status": "ok",
|
|
"gaps": [],
|
|
}
|
|
if sql_item is None:
|
|
result["status"] = "missing_sql_profile"
|
|
result["gaps"].append({"code": "missing_sql_profile", "severity": "error", "message": "No matching SQL template profile was found."})
|
|
return result
|
|
|
|
profile = sql_item.get("profile") if isinstance(sql_item.get("profile"), dict) else {}
|
|
sql_capacity = dimension_pair(profile.get("capacity_dimensions") or profile.get("dimensions"))
|
|
sql_used = dimension_pair(profile.get("used_dimensions"))
|
|
used_delta = dimension_delta(sql_used, xml_used)
|
|
sql_counts = {
|
|
"cells": sql_count(profile, "cells", None),
|
|
"cell_coordinate_hints": sql_count(profile, "cell_coordinate_hints", "sample_cell_coordinate_hints"),
|
|
"cell_style_coordinate_hints": sql_count(profile, "cell_style_coordinate_hints", "sample_style_coordinate_hints"),
|
|
"parameters": sql_count(profile, "cell_parameters", "sample_cell_parameters"),
|
|
"text_ids": sql_count(profile, "cell_text_identifiers", "sample_text_identifiers"),
|
|
"merges": sql_count(profile, "merged_ranges", None),
|
|
"merge_record_block_candidates": sql_count(profile, "merge_record_block_candidates", "sample_merge_record_block_candidates"),
|
|
"merge_count_hints": sql_count(profile, "merge_count_hints", "sample_merge_count_hints"),
|
|
"format_indexes": sql_count(profile, "cell_style_candidates", "sample_style_texts"),
|
|
}
|
|
merge_record_analysis = merge_record_analysis_counts(profile)
|
|
result["sql"] = {
|
|
"owner": sql_item.get("owner"),
|
|
"template": sql_item.get("template"),
|
|
"capacity_dimensions": profile.get("capacity_dimensions") or profile.get("dimensions"),
|
|
"used_dimensions": profile.get("used_dimensions"),
|
|
"used_delta": used_delta,
|
|
"counts": sql_counts,
|
|
"merge_record_analysis": merge_record_analysis,
|
|
"capabilities": profile.get("capabilities") or {},
|
|
}
|
|
|
|
if sql_capacity != xml_capacity:
|
|
result["gaps"].append(
|
|
{
|
|
"code": "capacity_dimensions_mismatch",
|
|
"severity": "error",
|
|
"sql": {"rows": sql_capacity[0], "columns": sql_capacity[1]},
|
|
"xml": {"rows": xml_capacity[0], "columns": xml_capacity[1]},
|
|
}
|
|
)
|
|
if sql_used == (None, None):
|
|
result["gaps"].append({"code": "used_dimensions_missing", "severity": "warning", "xml": {"rows": xml_used[0], "columns": xml_used[1]}})
|
|
elif sql_used != xml_used:
|
|
result["gaps"].append(
|
|
{
|
|
"code": "used_dimensions_mismatch",
|
|
"severity": "warning",
|
|
"sql": {"rows": sql_used[0], "columns": sql_used[1]},
|
|
"xml": {"rows": xml_used[0], "columns": xml_used[1]},
|
|
}
|
|
)
|
|
|
|
checks = [
|
|
("cells", "cells_missing_or_limited"),
|
|
("parameters", "parameters_missing_or_limited"),
|
|
("merges", "merged_ranges_missing"),
|
|
("format_indexes", "format_indexes_missing_or_limited"),
|
|
]
|
|
for key, code in checks:
|
|
xml_value = xml_counts.get(key)
|
|
sql_value = sql_counts.get(key)
|
|
if not xml_value:
|
|
continue
|
|
if sql_value in {None, 0}:
|
|
result["gaps"].append({"code": code, "severity": "warning", "sql": sql_value, "xml": xml_value})
|
|
elif int(sql_value) < int(xml_value):
|
|
result["gaps"].append({"code": code, "severity": "info", "sql": sql_value, "xml": xml_value})
|
|
|
|
hint_count = int(sql_counts.get("cell_coordinate_hints") or 0)
|
|
xml_cells = int(xml_counts.get("cells") or 0)
|
|
if xml_cells and hint_count:
|
|
result.setdefault("progress", []).append(
|
|
{
|
|
"code": "cell_coordinate_hints_available",
|
|
"sql": hint_count,
|
|
"xml_cells": xml_cells,
|
|
"coverage_ratio": round(hint_count / xml_cells, 4),
|
|
"message": "SQL decoder returned coordinate hints. These are progress evidence, not authoritative decoded cells.",
|
|
}
|
|
)
|
|
merge_block_count = int(sql_counts.get("merge_record_block_candidates") or 0)
|
|
merge_count_hint_count = int(sql_counts.get("merge_count_hints") or 0)
|
|
xml_merges = int(xml_counts.get("merges") or 0)
|
|
if xml_merges and merge_block_count:
|
|
result.setdefault("progress", []).append(
|
|
{
|
|
"code": "merge_record_blocks_available",
|
|
"sql": merge_block_count,
|
|
"xml_merges": xml_merges,
|
|
"message": "SQL decoder found MOXCEL merge-record block candidates. These are progress evidence, not authoritative merged ranges.",
|
|
}
|
|
)
|
|
if xml_merges and merge_count_hint_count:
|
|
result.setdefault("progress", []).append(
|
|
{
|
|
"code": "merge_count_hints_available",
|
|
"sql": merge_count_hint_count,
|
|
"xml_merges": xml_merges,
|
|
"message": "SQL decoder found MOXCEL merge-count hints. These confirm merge presence/count slots, not authoritative merged ranges.",
|
|
}
|
|
)
|
|
if xml_merges and merge_record_analysis["blocks"]:
|
|
result.setdefault("progress", []).append(
|
|
{
|
|
"code": "merge_record_analysis_available",
|
|
"sql_blocks": merge_record_analysis["blocks"],
|
|
"sql_records_analyzed": merge_record_analysis["records"],
|
|
"xml_merges": xml_merges,
|
|
"message": "SQL decoder returned normalized merge-block record analysis for slot-formula discovery.",
|
|
}
|
|
)
|
|
|
|
if result["gaps"]:
|
|
result["status"] = "gap"
|
|
return result
|
|
|
|
|
|
def compare(sql_profile: dict[str, Any], xml_profile: dict[str, Any]) -> dict[str, Any]:
|
|
sql_items = [item for item in sql_profile.get("items") or [] if isinstance(item, dict)]
|
|
sql_by_key = {sql_key(item): item for item in sql_items}
|
|
comparisons = []
|
|
for xml_item in xml_profile.get("templates") or []:
|
|
if not isinstance(xml_item, dict) or xml_item.get("xml_kind") != "tabular_document":
|
|
continue
|
|
route = xml_route(xml_item)
|
|
xml_item = {**xml_item, "route": route}
|
|
key = xml_key(xml_item)
|
|
sql_item = sql_by_key.get(key)
|
|
if sql_item is None and key[2]:
|
|
matches = [item for item in sql_items if sql_key(item)[2] == key[2]]
|
|
sql_item = matches[0] if len(matches) == 1 else None
|
|
comparisons.append(compare_item(sql_item, xml_item))
|
|
gap_counts: dict[str, int] = {}
|
|
progress_counts: dict[str, int] = {}
|
|
for item in comparisons:
|
|
for gap in item.get("gaps") or []:
|
|
code = str(gap.get("code") or "unknown")
|
|
gap_counts[code] = gap_counts.get(code, 0) + 1
|
|
for progress in item.get("progress") or []:
|
|
code = str(progress.get("code") or "unknown")
|
|
progress_counts[code] = progress_counts.get(code, 0) + 1
|
|
return {
|
|
"schema": "codex_1c_template_sql_xml_profile_compare.v1",
|
|
"source": "analysis_only_xml_fixture",
|
|
"sql_profile": sql_profile.get("base_id") or sql_profile.get("schema"),
|
|
"xml_profile": xml_profile.get("root"),
|
|
"comparisons": comparisons,
|
|
"counts": {
|
|
"xml_tabular_templates": len(comparisons),
|
|
"matched": sum(1 for item in comparisons if item.get("sql")),
|
|
"missing_sql_profile": sum(1 for item in comparisons if item.get("status") == "missing_sql_profile"),
|
|
"with_gaps": sum(1 for item in comparisons if item.get("gaps")),
|
|
"gap_counts": gap_counts,
|
|
"progress_counts": progress_counts,
|
|
},
|
|
}
|
|
|
|
|
|
def render_markdown(payload: dict[str, Any]) -> str:
|
|
lines = ["# 1C Template SQL/XML Profile Compare", ""]
|
|
counts = payload.get("counts") or {}
|
|
lines.append(f"- XML tabular templates: `{counts.get('xml_tabular_templates')}`")
|
|
lines.append(f"- Matched SQL profiles: `{counts.get('matched')}`")
|
|
lines.append(f"- With gaps: `{counts.get('with_gaps')}`")
|
|
lines.append(f"- Progress signals: `{counts.get('progress_counts') or {}}`")
|
|
lines.append("")
|
|
lines.append(
|
|
"| Owner | Template | Status | SQL capacity | XML capacity | SQL used | XML used | Used delta | SQL cells | Coord hints | Merge blocks | Merge count hints | Merge records analyzed | Gaps | Progress |"
|
|
)
|
|
lines.append("| --- | --- | --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | --- |")
|
|
for item in payload.get("comparisons") or []:
|
|
route = item.get("route") or {}
|
|
sql = item.get("sql") or {}
|
|
xml = item.get("xml") or {}
|
|
sql_counts = sql.get("counts") or {}
|
|
merge_record_analysis = sql.get("merge_record_analysis") or {}
|
|
gaps = ", ".join(str(gap.get("code")) for gap in (item.get("gaps") or []))
|
|
progress = ", ".join(str(entry.get("code")) for entry in (item.get("progress") or []))
|
|
lines.append(
|
|
f"| `{route.get('owner_kind') or ''}.{route.get('owner_name') or ''}` | "
|
|
f"`{route.get('template_name') or ''}` | `{item.get('status')}` | "
|
|
f"`{format_dimensions(sql.get('capacity_dimensions'))}` | "
|
|
f"`{format_dimensions(xml.get('capacity_dimensions'))}` | "
|
|
f"`{format_dimensions(sql.get('used_dimensions'))}` | "
|
|
f"`{format_dimensions(xml.get('used_dimensions'))}` | "
|
|
f"`{format_delta(sql.get('used_delta'))}` | "
|
|
f"{sql_counts.get('cells') or 0} | {sql_counts.get('cell_coordinate_hints') or 0} | "
|
|
f"{sql_counts.get('merge_record_block_candidates') or 0} | {sql_counts.get('merge_count_hints') or 0} | "
|
|
f"{merge_record_analysis.get('records') or 0} | "
|
|
f"`{gaps}` | `{progress}` |"
|
|
)
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Compare SQL-decoded template profiles with XML analysis fixtures.")
|
|
parser.add_argument("--sql-profile", default="reports/1c-template-baselines/upo_test_tabular_template_profiles.json")
|
|
parser.add_argument("--xml-profile", required=True)
|
|
parser.add_argument("--output-json", default="reports/1c-template-baselines/sql-xml-template-profile-compare.json")
|
|
parser.add_argument("--output-markdown", default="reports/1c-template-baselines/sql-xml-template-profile-compare.md")
|
|
args = parser.parse_args()
|
|
|
|
payload = compare(read_json(Path(args.sql_profile)), read_json(Path(args.xml_profile)))
|
|
json_path = Path(args.output_json)
|
|
md_path = Path(args.output_markdown)
|
|
json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
md_path.write_text(render_markdown(payload), encoding="utf-8")
|
|
print(json.dumps({"status": "ok", "json": str(json_path), "markdown": str(md_path), "counts": payload["counts"]}, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|