From a4b7629d6adc91c4f1a486a76d26be27ec9e6889 Mon Sep 17 00:00:00 2001 From: Michael BAG Date: Thu, 16 Jul 2026 21:15:43 +0300 Subject: [PATCH] =?UTF-8?q?Release=200.3.0:=20movements=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=E2=86=94=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Таблица register_records, мягкая миграция схемы 2→3 и команда query_1c.py movements в обе стороны. Co-authored-by: Cursor --- CHANGELOG.md | 16 ++++ README.md | 22 ++++- VERSION | 2 +- index1c/__init__.py | 2 +- index1c/db.py | 122 +++++++++++++++++++++-- index1c/search.py | 229 ++++++++++++++++++++++++++++++++++++++++++++ query_1c.py | 80 ++++++++++++++++ 7 files changed, 463 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 196576e..e818db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Формат: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Версионирование: [Semantic Versioning](https://semver.org/lang/ru/). +## [0.3.0] - 2026-07-16 + +### Added + +- Таблица **`register_records`** (документ → регистры движений из `RegisterRecords`). +- Команда **`query_1c.py movements`**: + - документ → список регистров движений; + - регистр → список документов-регистраторов; + - `--type`, `--limit`, `-b` как у остальных команд. +- Мягкая миграция схемы **2→3** при открытии индекса (без `reindex --full`): backfill из JSON объектов. + +### Changed + +- `SCHEMA_VERSION` = 3; инкрементальный `reindex` пишет связи в `register_records`. + ## [0.2.0] - 2026-07-16 ### Added @@ -40,5 +55,6 @@ - `query_1c.py`: `search`, `show`, `refs`, `modules`, `module`. - Инкремент по `mtime`+`size` файлов; индекс модулей `**/*.bsl`. +[0.3.0]: https://git.p7net.ru/1c/index/-/tags/v0.3.0 [0.2.0]: https://git.p7net.ru/1c/index/-/tags/v0.2.0 [0.1.0]: https://git.p7net.ru/1c/index/-/tags/v0.1.0 diff --git a/README.md b/README.md index a4e6a5f..e6853eb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Индекс метаданных и модулей конфигураций 1С -**Версия:** см. [`VERSION`](VERSION) (текущая: **0.2.0**) · [CHANGELOG](CHANGELOG.md) +**Версия:** см. [`VERSION`](VERSION) (текущая: **0.3.0**) · [CHANGELOG](CHANGELOG.md) Единый кэш проекта: **`cache/1c_meta/index.sqlite`** — все выгрузки 1С в одной БД (SQLite FTS5). @@ -28,6 +28,7 @@ python tools/index/query_1c.py --version | Найти объект по имени/синониму | `query_1c.py search` / `object` | | Структура реквизитов, обязательность, типы | `query_1c.py object` | | Кто ссылается на справочник/документ | `query_1c.py refs` | +| Документ ↔ регистры движений | `query_1c.py movements` | | Найти процедуру/фрагмент в BSL | `query_1c.py modules` | | Сравнить целевую и исходную конфигурацию | `-b target` / `-b source` | @@ -84,8 +85,9 @@ python tools/index/query_1c.py --version ### Что индексируется -- **Метаданные** `src/<Тип>/*.xml`: имя, синоним, комментарий, реквизиты / измерения / ресурсы / ТЧ, типы (в т.ч. `TypeSet`), ссылки, `FillChecking`. +- **Метаданные** `src/<Тип>/*.xml`: имя, синоним, комментарий, реквизиты / измерения / ресурсы / ТЧ, типы (в т.ч. `TypeSet`), ссылки, `FillChecking`, `RegisterRecords` (документ → регистры движений). - **Модули** `**/*.bsl`: путь, owner (`Document.X`), вид модуля, имена процедур/функций, тело (до 200 КБ в FTS). +- **Связи движений** — таблица `register_records` (обратный индекс регистраторов). По умолчанию пропускаются: `CommonPicture`, `StyleItem`, `XDTOPackage`, `CommonTemplate`, `Language`, `Bot`. @@ -119,6 +121,7 @@ python tools/index/index_1c.py list | `object` | имя/синоним объекта **или** реквизита | дерево структуры | `-b`, `--type`, `--clear`, `--limit` | | `show` | имя объекта | полная карточка JSON | `-b` | | `refs` | `Catalog.Партнеры` / `ЗаказКлиента` | обратные ссылки полей | `-b`, `--limit` (на каждую baseconf) | +| `movements` | документ или регистр | регистратор ↔ регистры движений | `-b`, `--type`, `--limit` | | `modules` | строка | поиск по BSL | `-b`, `-n` / `--names-only`, `--limit` | | `module` | путь или owner | символы модуля, строки | `-b` | @@ -159,6 +162,21 @@ python tools/index/query_1c.py refs Document.ЗаказКлиента -b source Без `-b` — все конфигурации в индексе; `--limit` действует **на каждую**. Составной тип поля: `→ DocumentRef.ЗаказКлиента (среди прочего: DocumentRef.…)`. +### `movements` — регистратор ↔ регистры движений + +```bash +# документ → регистры, в которых он регистратор +python tools/index/query_1c.py movements ЗаказКлиента -b target +python tools/index/query_1c.py movements Document.РеализацияТоваровУслуг -b target + +# регистр → документы-регистраторы +python tools/index/query_1c.py movements AccumulationRegister.ТоварыНаСкладах -b target +python tools/index/query_1c.py movements ТоварыНаСкладах -b target --type AccumulationRegister +``` + +Данные из свойства метаданных `RegisterRecords` (таблица `register_records` в индексе). +При первом запуске утилиты v0.3+ схема индекса мигрирует 2→3 автоматически (без полного `reindex`). + ### `modules` / `module` ```bash diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/index1c/__init__.py b/index1c/__init__.py index e9eb038..054e01a 100644 --- a/index1c/__init__.py +++ b/index1c/__init__.py @@ -2,7 +2,7 @@ from pathlib import Path -__version__ = "0.2.0" +__version__ = "0.3.0" __status__ = "in development" diff --git a/index1c/db.py b/index1c/db.py index bee92e3..09168a6 100644 --- a/index1c/db.py +++ b/index1c/db.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 DDL = """ PRAGMA journal_mode=WAL; @@ -82,6 +82,13 @@ CREATE TABLE IF NOT EXISTS refs ( to_name TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS register_records ( + id INTEGER PRIMARY KEY, + config TEXT NOT NULL, + document_full_name TEXT NOT NULL, + register_full_name TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS modules ( id INTEGER PRIMARY KEY, config TEXT NOT NULL, @@ -123,12 +130,84 @@ CREATE INDEX IF NOT EXISTS idx_fields_object ON fields(object_id); CREATE INDEX IF NOT EXISTS idx_refs_to ON refs(to_kind, to_name); CREATE INDEX IF NOT EXISTS idx_refs_from ON refs(from_full_name); CREATE INDEX IF NOT EXISTS idx_refs_config ON refs(config); +CREATE INDEX IF NOT EXISTS idx_regrec_register ON register_records(register_full_name); +CREATE INDEX IF NOT EXISTS idx_regrec_document ON register_records(document_full_name); +CREATE INDEX IF NOT EXISTS idx_regrec_config ON register_records(config); CREATE INDEX IF NOT EXISTS idx_modules_config ON modules(config); CREATE INDEX IF NOT EXISTS idx_modules_owner ON modules(owner); CREATE INDEX IF NOT EXISTS idx_files_config ON files(config); """ +def _table_exists(conn: sqlite3.Connection, name: str) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (name,), + ).fetchone() + return row is not None + + +def backfill_register_records(conn: sqlite3.Connection) -> int: + """Заполнить register_records из JSON объектов (документы → RegisterRecords).""" + conn.execute("DELETE FROM register_records") + n = 0 + rows = conn.execute( + "SELECT config, full_name, json FROM objects WHERE object_type='Document'" + ).fetchall() + batch: list[tuple[str, str, str]] = [] + for r in rows: + try: + data = json.loads(r["json"]) + except json.JSONDecodeError: + continue + for reg in data.get("register_records") or []: + if not reg or not isinstance(reg, str): + continue + batch.append((r["config"], r["full_name"], reg)) + n += 1 + if len(batch) >= 500: + conn.executemany( + """ + INSERT INTO register_records(config, document_full_name, register_full_name) + VALUES(?,?,?) + """, + batch, + ) + batch.clear() + if batch: + conn.executemany( + """ + INSERT INTO register_records(config, document_full_name, register_full_name) + VALUES(?,?,?) + """, + batch, + ) + return n + + +def migrate_schema(conn: sqlite3.Connection, from_version: int) -> None: + """Мягкая миграция без wipe БД.""" + if from_version < 3: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS register_records ( + id INTEGER PRIMARY KEY, + config TEXT NOT NULL, + document_full_name TEXT NOT NULL, + register_full_name TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_regrec_register ON register_records(register_full_name); + CREATE INDEX IF NOT EXISTS idx_regrec_document ON register_records(document_full_name); + CREATE INDEX IF NOT EXISTS idx_regrec_config ON register_records(config); + """ + ) + backfill_register_records(conn) + conn.execute( + "INSERT OR REPLACE INTO meta(key, value) VALUES('schema_version', ?)", + (str(SCHEMA_VERSION),), + ) + + def utcnow() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -161,11 +240,18 @@ def init_db(conn: sqlite3.Connection) -> None: "INSERT OR REPLACE INTO meta(key, value) VALUES('created_at', ?)", (utcnow(),), ) - elif int(row["value"]) != SCHEMA_VERSION: - raise RuntimeError( - f"Несовместимая схема индекса (есть {row['value']}, нужна {SCHEMA_VERSION}). " - "Запустите: python tools/index/index_1c.py reindex --full" - ) + else: + ver = int(row["value"]) + if ver > SCHEMA_VERSION: + raise RuntimeError( + f"Индекс новее утилиты (схема {ver}, утилита {SCHEMA_VERSION}). " + "Обновите tools/index." + ) + if ver < SCHEMA_VERSION: + migrate_schema(conn, ver) + elif not _table_exists(conn, "register_records"): + # schema_version=3, но таблица потеряна — восстановить + migrate_schema(conn, 2) conn.commit() @@ -231,6 +317,11 @@ def delete_file_cascade(conn: sqlite3.Connection, file_id: int) -> None: "DELETE FROM refs WHERE config=? AND from_full_name=?", (row["config"], row["full_name"]), ) + if _table_exists(conn, "register_records"): + conn.execute( + "DELETE FROM register_records WHERE config=? AND document_full_name=?", + (row["config"], row["full_name"]), + ) conn.execute("DELETE FROM objects WHERE id=?", (oid,)) mod = conn.execute( @@ -259,6 +350,8 @@ def delete_config(conn: sqlite3.Connection, config: str) -> None: conn.execute("DELETE FROM modules_fts WHERE module_id=?", (mid,)) conn.execute("DELETE FROM fields WHERE config=?", (config,)) conn.execute("DELETE FROM refs WHERE config=?", (config,)) + if _table_exists(conn, "register_records"): + conn.execute("DELETE FROM register_records WHERE config=?", (config,)) conn.execute("DELETE FROM objects WHERE config=?", (config,)) conn.execute("DELETE FROM modules WHERE config=?", (config,)) conn.execute("DELETE FROM files WHERE config=?", (config,)) @@ -318,6 +411,11 @@ def upsert_object( "DELETE FROM refs WHERE config=? AND from_full_name=?", (config, full_name), ) + if _table_exists(conn, "register_records"): + conn.execute( + "DELETE FROM register_records WHERE config=? AND document_full_name=?", + (config, full_name), + ) conn.execute( """ UPDATE objects SET object_type=?, name=?, synonym=?, comment=?, tooltip=?, @@ -411,6 +509,18 @@ def upsert_object( r.get("name"), ), ) + + if _table_exists(conn, "register_records"): + for reg in obj.get("register_records") or []: + if not reg or not isinstance(reg, str): + continue + conn.execute( + """ + INSERT INTO register_records(config, document_full_name, register_full_name) + VALUES(?,?,?) + """, + (config, full_name, reg), + ) return oid diff --git a/index1c/search.py b/index1c/search.py index a6b3d83..4e479a1 100644 --- a/index1c/search.py +++ b/index1c/search.py @@ -25,9 +25,238 @@ def open_index(project_root: Path, *, readonly: bool = True) -> sqlite3.Connecti raise FileNotFoundError( f"Индекс не найден: {path}. Запустите: python tools/index/index_1c.py reindex --full" ) + # мягкая миграция схемы (например 2→3: таблица register_records) + probe = dbmod.connect(path, readonly=True) + need_migrate = False + try: + row = probe.execute( + "SELECT value FROM meta WHERE key='schema_version'" + ).fetchone() + ver = int(row["value"]) if row else 0 + if ver < dbmod.SCHEMA_VERSION: + need_migrate = True + elif ver == dbmod.SCHEMA_VERSION: + t = probe.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='register_records'" + ).fetchone() + if t is None: + need_migrate = True + finally: + probe.close() + + if need_migrate: + wconn = dbmod.connect(path, readonly=False) + try: + dbmod.init_db(wconn) + finally: + wconn.close() + return dbmod.connect(path, readonly=readonly) +REGISTER_OBJECT_TYPES = frozenset( + { + "AccumulationRegister", + "InformationRegister", + "AccountingRegister", + "CalculationRegister", + } +) + + +def _resolve_movement_targets( + conn: sqlite3.Connection, + target: str, + *, + configs: list[str] | None = None, + object_type: str | None = None, +) -> list[dict[str, Any]]: + """Найти объект(ы) для movements: документ или регистр.""" + sql = """ + SELECT config, full_name, object_type, name, synonym + FROM objects + WHERE (full_name = ? OR name = ?) + """ + params: list[Any] = [target, target] + if configs: + sql += f" AND config IN ({','.join('?' * len(configs))})" + params.extend(configs) + if object_type: + sql += " AND object_type = ?" + params.append(object_type) + sql += " ORDER BY config, full_name" + rows = [dict(r) for r in conn.execute(sql, params)] + if rows: + return rows + + # Document.X / AccumulationRegister.X без точного совпадения типа + if "." in target: + kind, name = target.split(".", 1) + sql = """ + SELECT config, full_name, object_type, name, synonym + FROM objects + WHERE object_type = ? AND name = ? + """ + params = [kind, name] + if configs: + sql += f" AND config IN ({','.join('?' * len(configs))})" + params.extend(configs) + sql += " ORDER BY config, full_name" + rows = [dict(r) for r in conn.execute(sql, params)] + if rows: + return rows + + sql = """ + SELECT config, full_name, object_type, name, synonym + FROM objects + WHERE full_name LIKE ? + """ + params = [f"%.{target}"] + if configs: + sql += f" AND config IN ({','.join('?' * len(configs))})" + params.extend(configs) + if object_type: + sql += " AND object_type = ?" + params.append(object_type) + sql += " ORDER BY config, full_name LIMIT 20" + return [dict(r) for r in conn.execute(sql, params)] + + +def find_movements( + conn: sqlite3.Connection, + target: str, + *, + configs: list[str] | None = None, + object_type: str | None = None, + limit: int = 200, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Связи регистратор ↔ регистры движений (RegisterRecords). + + Возвращает ``(resolved_objects, rows)``, где каждая строка: + - direction: ``document_to_registers`` | ``register_to_documents`` + - config, from_full_name, to_full_name + - from_synonym / to_synonym (если есть в индексе) + + ``limit`` — максимум связей **на каждый** найденный объект×конфигурацию. + """ + resolved = _resolve_movement_targets( + conn, target, configs=configs, object_type=object_type + ) + # оставить документы и регистры + resolved = [ + r + for r in resolved + if r["object_type"] == "Document" or r["object_type"] in REGISTER_OBJECT_TYPES + ] + if not resolved: + return [], [] + + has_table = ( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='register_records'" + ).fetchone() + is not None + ) + + # кэш синонимов + def _synonyms(full_names: set[str], cfg: str) -> dict[str, str]: + if not full_names: + return {} + names = sorted(full_names) + ph = ",".join("?" * len(names)) + return { + r["full_name"]: r["synonym"] or "" + for r in conn.execute( + f"SELECT full_name, synonym FROM objects WHERE config=? AND full_name IN ({ph})", + [cfg, *names], + ) + } + + out: list[dict[str, Any]] = [] + for obj in resolved: + cfg = obj["config"] + full = obj["full_name"] + if obj["object_type"] == "Document": + if has_table: + links = [ + r["register_full_name"] + for r in conn.execute( + """ + SELECT register_full_name FROM register_records + WHERE config=? AND document_full_name=? + ORDER BY register_full_name + LIMIT ? + """, + (cfg, full, limit), + ) + ] + else: + row = conn.execute( + "SELECT json FROM objects WHERE config=? AND full_name=?", + (cfg, full), + ).fetchone() + links = [] + if row: + try: + links = list(json.loads(row["json"]).get("register_records") or []) + except json.JSONDecodeError: + links = [] + links = sorted(links)[:limit] + syns = _synonyms(set(links), cfg) + for reg in links: + out.append( + { + "direction": "document_to_registers", + "config": cfg, + "from_full_name": full, + "from_synonym": obj.get("synonym") or "", + "to_full_name": reg, + "to_synonym": syns.get(reg, ""), + } + ) + else: + # регистр → документы-регистраторы + if has_table: + docs = [ + r["document_full_name"] + for r in conn.execute( + """ + SELECT document_full_name FROM register_records + WHERE config=? AND register_full_name=? + ORDER BY document_full_name + LIMIT ? + """, + (cfg, full, limit), + ) + ] + else: + docs = [] + for r in conn.execute( + "SELECT full_name, json FROM objects WHERE config=? AND object_type='Document'", + (cfg,), + ): + try: + regs = json.loads(r["json"]).get("register_records") or [] + except json.JSONDecodeError: + continue + if full in regs: + docs.append(r["full_name"]) + docs = sorted(docs)[:limit] + syns = _synonyms(set(docs), cfg) + for doc in docs: + out.append( + { + "direction": "register_to_documents", + "config": cfg, + "from_full_name": full, + "from_synonym": obj.get("synonym") or "", + "to_full_name": doc, + "to_synonym": syns.get(doc, ""), + } + ) + return resolved, out + + def search_objects( conn: sqlite3.Connection, query: str, diff --git a/query_1c.py b/query_1c.py index 5a3825d..cb9fc6b 100755 --- a/query_1c.py +++ b/query_1c.py @@ -13,6 +13,8 @@ python tools/index/query_1c.py show Document.ЗаказКлиента -b crm3-26 python tools/index/query_1c.py modules \"РВС_Transfer\" -b ws-rhana python tools/index/query_1c.py refs Catalog.Партнеры -b source + python tools/index/query_1c.py movements ЗаказКлиента -b target + python tools/index/query_1c.py movements AccumulationRegister.ТоварыНаСкладах -b target python tools/index/query_1c.py object ЗаказКлиента -b target python tools/index/query_1c.py object Партнер -b crm3-26 --clear python tools/index/query_1c.py module CommonModules/РВС_TransferAPIДокументы @@ -43,6 +45,7 @@ from index1c.cli import ( # noqa: E402 from index1c.config import resolve_object_type # noqa: E402 from index1c import read_version_file # noqa: E402 from index1c.search import ( # noqa: E402 + find_movements, find_refs_to, get_module, get_object, @@ -182,6 +185,62 @@ def cmd_refs(args: argparse.Namespace) -> int: conn.close() +def cmd_movements(args: argparse.Namespace) -> int: + root = resolve_project_root(args) + conn = _open_or_exit(root) + try: + try: + baseconfs = get_baseconfs_for_query(args, conn, root) + except BaseconfError as e: + return handle_baseconf_error(e) + otype = resolve_object_type(args.type) if args.type else None + resolved, rows = find_movements( + conn, + args.target, + configs=baseconfs, + object_type=otype, + limit=args.limit, + ) + if not resolved: + print( + format_not_found_object(args.target, baseconfs=baseconfs, conn=conn), + file=sys.stderr, + ) + return 2 + if not rows: + names = ", ".join(f"`{r['full_name']}`" for r in resolved) + print(f"Связей регистратор↔регистр для {names} не найдено.") + return 2 + + # группировка: config → from_full_name → links + current_cfg = None + current_from = None + for r in rows: + if r["config"] != current_cfg: + current_cfg = r["config"] + current_from = None + print(f"## [{current_cfg}]") + if r["from_full_name"] != current_from: + current_from = r["from_full_name"] + syn = f" — {r['from_synonym']}" if r.get("from_synonym") else "" + if r["direction"] == "document_to_registers": + print(f"### Документ `{current_from}`{syn} → регистры движений") + else: + print(f"### Регистр `{current_from}`{syn} → регистраторы") + to_syn = f" — {r['to_synonym']}" if r.get("to_synonym") else "" + print(f"- `{r['to_full_name']}`{to_syn}") + + if len(resolved) > 1: + print() + print( + f"Найдено объектов: {len(resolved)}. " + "Уточните полное имя (Document.… / AccumulationRegister.…) или --type." + ) + return 0 + finally: + conn.close() + + def cmd_show(args: argparse.Namespace) -> int: root = resolve_project_root(args) conn = _open_or_exit(root) @@ -336,6 +395,27 @@ def build_parser() -> argparse.ArgumentParser: ) ref.set_defaults(func=cmd_refs) + mov = sub.add_parser( + "movements", + help="Регистратор ↔ регистры движений (RegisterRecords)", + ) + mov.add_argument( + "target", + help="Document.ЗаказКлиента | AccumulationRegister.ТоварыНаСкладах | имя", + ) + add_baseconf_args(mov) + mov.add_argument( + "--type", + help="Уточнить тип: Document, AccumulationRegister, InformationRegister, …", + ) + mov.add_argument( + "--limit", + type=int, + default=200, + help="Макс. связей на каждый найденный объект (по умолчанию 200)", + ) + mov.set_defaults(func=cmd_movements) + sh = sub.add_parser("show", help="Карточка объекта (JSON: поля, типы, ссылки)") sh.add_argument("name", help="ЗаказКлиента | Document.ЗаказКлиента") add_baseconf_args(sh)