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:
7 changed files with 463 additions and 10 deletions
+229
View File
@@ -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,