Release 0.3.0: movements — регистратор ↔ регистры движений
Таблица register_records, мягкая миграция схемы 2→3 и команда query_1c.py movements в обе стороны. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
__status__ = "in development"
|
||||
|
||||
|
||||
|
||||
+116
-6
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user