#!/usr/bin/env python3 from __future__ import annotations import argparse import json import sys import urllib.request from pathlib import Path from typing import Any DEFAULT_BASE_URL = "http://docker.cin.su:8011" DISCOVERY_KINDS = ("Catalog", "Document", "DataProcessor", "Report") SOURCE_TABLES = {"Config", "ConfigCAS"} TARGET_TABLES = {"ConfigSave", "ConfigCASSave"} SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"} def rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]: body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8") request = urllib.request.Request( base_url.rstrip("/") + "/rpc", data=body, method="POST", headers={"Content-Type": "application/json; charset=utf-8"}, ) with urllib.request.urlopen(request, timeout=timeout) as response: data = json.loads(response.read().decode("utf-8")) if not isinstance(data, dict): raise RuntimeError(f"{method} returned non-object response: {data!r}") return data def sql_literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" def selector_from_args(args: argparse.Namespace) -> dict[str, Any]: selector: dict[str, Any] = {"base_id": args.base_id, "include_storage": True, "table": SOURCE_BY_TARGET[args.target_table]} for key in ("ref", "kind", "name", "guid"): value = getattr(args, key) if value: selector[key] = value return selector def discover_object(base_url: str, base_id: str, source_table: str, timeout: float) -> dict[str, Any]: fallback: dict[str, Any] | None = None best: tuple[int, dict[str, Any]] | None = None for kind in DISCOVERY_KINDS: listed = rpc( base_url, "metadata.objects.list", {"base_id": base_id, "kind": kind, "table": source_table, "limit": 20, "include_storage": True}, timeout, ) for item in listed.get("objects") or []: if not isinstance(item, dict) or not item.get("guid") or not item.get("name"): continue if fallback is None: fallback = item selector = object_selector(base_id, item, default_table=source_table) try: forms = rpc(base_url, "metadata.object.forms", selector, timeout) modules = rpc(base_url, "metadata.object.modules", selector, timeout) except Exception: continue form_count = ((forms.get("counts") or {}).get("forms") or 0) if isinstance(forms.get("counts"), dict) else 0 module_count = ((modules.get("counts") or {}).get("modules") or 0) if isinstance(modules.get("counts"), dict) else 0 score = (10 if form_count > 0 else 0) + (10 if module_count > 0 else 0) + min(int(form_count), 5) + min(int(module_count), 5) if best is None or score > best[0]: best = (score, item) if form_count > 0 and module_count > 0: return item if best is not None and best[0] > 0: return best[1] if fallback is not None: return fallback raise RuntimeError(f"No {source_table} metadata object candidate found for saved-state copy planning.") def resolve_object(base_url: str, selector: dict[str, Any], source_table: str, timeout: float) -> dict[str, Any]: if not any(selector.get(key) for key in ("ref", "kind", "name", "guid")): return discover_object(base_url, str(selector["base_id"]), source_table, timeout) resolved = rpc(base_url, "metadata.object.get", selector | {"table": source_table}, timeout) resolved_object = resolved.get("object") if isinstance(resolved.get("object"), dict) else {} if resolved.get("status") == "ok" and resolved_object.get("guid"): return resolved_object if selector.get("kind") or selector.get("name") or selector.get("guid"): listed = rpc(base_url, "metadata.objects.list", selector | {"limit": 5, "table": source_table}, timeout) objects = [item for item in listed.get("objects") or [] if isinstance(item, dict)] if objects: return objects[0] definition_query = str(selector.get("ref") or selector.get("guid") or selector.get("name") or "").strip() found = rpc( base_url, "metadata.definition.find", selector | {"query": definition_query, "areas": ["metadata"], "include_storage": True}, timeout, ) found_object = found.get("object") if isinstance(found.get("object"), dict) else {} if found_object.get("guid"): return found_object matches = [item for item in found.get("matches") or found.get("items") or [] if isinstance(item, dict)] for item in matches: if item.get("guid") or (item.get("object") or {}).get("guid"): return item.get("object") if isinstance(item.get("object"), dict) else item raise RuntimeError(f"Object selector did not resolve: {selector}") def object_selector(base_id: str, obj: dict[str, Any], *, default_table: str | None = None) -> dict[str, Any]: selector = {"base_id": base_id, "include_storage": True} storage = obj.get("storage") if isinstance(obj.get("storage"), dict) else {} table = storage.get("table") or default_table if table in SOURCE_TABLES: selector["table"] = table for key in ("kind", "name", "guid"): if obj.get(key): selector[key] = obj[key] return selector def add_source_row(rows: list[dict[str, Any]], *, role: str, table: str | None, file_name: str | None, extra: dict[str, Any] | None = None) -> None: if table not in SOURCE_TABLES or not file_name: return row = {"role": role, "table": table, "file_name": file_name} if extra: row.update(extra) if not any(existing.get("table") == table and existing.get("file_name") == file_name and existing.get("role") == role for existing in rows): rows.append(row) def collect_source_rows(base_url: str, base_id: str, obj: dict[str, Any], timeout: float) -> tuple[list[dict[str, Any]], dict[str, Any]]: storage = obj.get("storage") if isinstance(obj.get("storage"), dict) else {} selector = object_selector(base_id, obj, default_table=storage.get("table") or "Config") rows: list[dict[str, Any]] = [] evidence: dict[str, Any] = {"forms": None, "modules": None} add_source_row(rows, role="object", table=storage.get("table") or "Config", file_name=obj.get("guid")) forms = rpc(base_url, "metadata.object.forms", selector, timeout) evidence["forms"] = {"status": forms.get("status"), "counts": forms.get("counts")} for form in forms.get("forms") or []: if not isinstance(form, dict): continue source = form.get("source") if isinstance(form.get("source"), dict) else {} add_source_row( rows, role="form", table=source.get("table"), file_name=source.get("file_name"), extra={"name": form.get("name"), "guid": form.get("guid")}, ) add_source_row( rows, role="form_payload", table=source.get("table"), file_name=f"{source.get('file_name')}.0" if source.get("file_name") else None, extra={"name": form.get("name"), "guid": form.get("guid"), "suffix": ".0"}, ) modules = rpc(base_url, "metadata.object.modules", selector, timeout) evidence["modules"] = {"status": modules.get("status"), "counts": modules.get("counts")} for module in modules.get("modules") or []: if not isinstance(module, dict): continue add_source_row( rows, role="module", table=module.get("table"), file_name=module.get("file_name"), extra={ "module_id": module.get("module_id"), "stream_index": module.get("stream_index"), "sha1": module.get("sha1"), "bytes": module.get("bytes"), }, ) return rows, evidence def read_row_details(base_url: str, base_id: str, rows: list[dict[str, Any]], timeout: float) -> dict[str, Any]: details: dict[str, Any] = {} by_table: dict[str, list[str]] = {} for row in rows: table = str(row.get("table") or "") file_name = str(row.get("file_name") or "") if table in SOURCE_TABLES and file_name: by_table.setdefault(table, []).append(file_name) for table, names in sorted(by_table.items()): unique_names = sorted(set(names)) query = ( "SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, " "CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 " f"FROM {table} WHERE FileName IN ({', '.join(sql_literal(name) for name in unique_names)})" ) result = rpc( base_url, "query.run", {"base_id": base_id, "diagnostic": True, "query": query, "timeout_seconds": int(timeout)}, timeout, ) details[table] = { "status": result.get("status"), "rows": result.get("rows") or [], "counts": result.get("counts"), } return details def read_target_collisions(base_url: str, base_id: str, rows: list[dict[str, Any]], target_table: str, timeout: float) -> dict[str, Any]: file_names = sorted({str(row.get("file_name") or "") for row in rows if row.get("file_name")}) if not file_names: return {"status": "skipped_no_source_rows", "rows": [], "counts": {"rows": 0}} query = ( "SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, " "CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 " f"FROM {target_table} WHERE FileName IN ({', '.join(sql_literal(name) for name in file_names)})" ) result = rpc( base_url, "query.run", {"base_id": base_id, "diagnostic": True, "query": query, "timeout_seconds": int(timeout)}, timeout, ) collisions = result.get("rows") or [] return { "status": "collision" if collisions else "clear", "table": target_table, "rows": collisions, "counts": result.get("counts"), } def build_report(args: argparse.Namespace) -> dict[str, Any]: expected_source_table = SOURCE_BY_TARGET[args.target_table] selector = selector_from_args(args) obj = resolve_object(args.base_url, selector, expected_source_table, args.timeout) rows, evidence = collect_source_rows(args.base_url, args.base_id, obj, args.timeout) row_details = read_row_details(args.base_url, args.base_id, rows, args.timeout) target_collisions = read_target_collisions(args.base_url, args.base_id, rows, args.target_table, args.timeout) found_rows = sum(len((table_details.get("rows") or [])) for table_details in row_details.values() if isinstance(table_details, dict)) active_source_tables = sorted({str(row.get("table")) for row in rows if row.get("table") in SOURCE_TABLES}) mismatched_source_tables = sorted(table for table in active_source_tables if table != expected_source_table) collision_rows = target_collisions.get("rows") if isinstance(target_collisions.get("rows"), list) else [] if mismatched_source_tables: status = "blocked_source_target_family_mismatch" elif collision_rows: status = "blocked_target_collision" elif rows and found_rows: status = "plan_ready" else: status = "blocked_no_active_source_rows" recommendations = [ "Do not edit Config/ConfigCAS directly for the smoke. Prepare the save layer first, then run strict readiness.", f"Copy the target object's required active rows from {expected_source_table} into {args.target_table} using the approved saved-state workflow.", "After the copy, rerun check_1c_saved_state_strict_readiness.py and only then enable strict saved-state smoke.", ] if mismatched_source_tables: recommendations.insert(1, f"{args.target_table} must be prepared only from {expected_source_table}; found source table(s): {', '.join(mismatched_source_tables)}.") if collision_rows: recommendations.insert(1, f"{args.target_table} already contains one or more planned FileName values; inspect/clear the existing unactivated change before copying.") return { "schema": "onec_saved_state_copy_plan.v1", "base_url": args.base_url, "base_id": args.base_id, "status": status, "ready_to_copy": status == "plan_ready", "selector": selector, "object": { "guid": obj.get("guid"), "kind": obj.get("kind"), "name": obj.get("name"), "synonym": obj.get("synonym"), "source": obj.get("source"), }, "target": { "table": args.target_table, "mode": "prepare_saved_state_working_copy", }, "source_family": { "target_table": args.target_table, "expected_source_table": expected_source_table, "source_tables": active_source_tables, "mismatched_source_tables": mismatched_source_tables, "valid": not mismatched_source_tables, }, "source_rows": rows, "source_row_details": row_details, "target_collisions": target_collisions, "evidence": evidence, "summary": { "planned_source_rows": len(rows), "found_source_storage_rows": found_rows, "target_collision_rows": len(collision_rows), "active_source_tables": active_source_tables, }, "recommendations": recommendations, } def main() -> int: parser = argparse.ArgumentParser(description="Read-only plan for preparing a 1C saved-state copy from an active object.") parser.add_argument("--base-url", default=DEFAULT_BASE_URL) parser.add_argument("--base-id", default="upo_test") parser.add_argument("--ref") parser.add_argument("--kind") parser.add_argument("--name") parser.add_argument("--guid") parser.add_argument("--target-table", choices=sorted(TARGET_TABLES), default="ConfigSave") parser.add_argument("--timeout", type=float, default=60.0) parser.add_argument("--report", type=Path) parser.add_argument("--json", action="store_true") args = parser.parse_args() try: report = build_report(args) except Exception as exc: report = { "schema": "onec_saved_state_copy_plan.v1", "base_url": args.base_url, "base_id": args.base_id, "status": "error", "ready_to_copy": False, "error": str(exc), } if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr) return 1 if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if args.json or report["status"] != "plan_ready": print(json.dumps(report, ensure_ascii=False, indent=2)) else: print( "OK: saved-state copy plan ready for " f"{report['object'].get('kind')}.{report['object'].get('name')} -> {report['target'].get('table')}." ) return 0 if report["status"] == "plan_ready" else 1 if __name__ == "__main__": raise SystemExit(main())