diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py index 7e5a068..90c22c0 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py @@ -227,6 +227,172 @@ class LocalAccountingStore: def exists(self, client_id: int) -> bool: return self.db_path(client_id).is_file() + + # HISTORICAL_ACT_SCHEMA_AUTO_UPGRADE_V1 + def _ensure_historical_schema_compatibility( + self, + client_id: int, + db: sqlite3.Connection, + current_path: Path, + ) -> None: + """Add missing Accounting Mirror tables/indexes to an older FY .act file. + + Older financial-year mirrors can predate newer Accounting/Tally schema + objects. The current FY mirror for the same client is used only as a + schema reference. Existing rows/tables are never dropped, replaced, + renamed or copied between financial years. + """ + required = "tally_company_mapping" + current_path = Path(current_path) + + current_tables = { + str(row[0]) + for row in db.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + if required in current_tables: + return + + filename = current_path.name + candidates = [] + try: + pattern = f"FY*/Clients/*/*/Accounting/{filename}" + for candidate in self.storage_root.glob(pattern): + candidate = Path(candidate) + if candidate.resolve() == current_path.resolve(): + continue + if not candidate.is_file(): + continue + try: + ref = sqlite3.connect(candidate, timeout=15) + try: + tables = { + str(row[0]) + for row in ref.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + if required not in tables: + continue + object_count = int( + ref.execute( + """ + SELECT COUNT(*) + FROM sqlite_master + WHERE type IN ('table','index','trigger') + AND name NOT LIKE 'sqlite_%' + AND sql IS NOT NULL + """ + ).fetchone()[0] + ) + candidates.append((object_count, candidate)) + finally: + ref.close() + except Exception: + continue + except Exception: + return + + if not candidates: + return + + candidates.sort(key=lambda item: item[0], reverse=True) + reference_path = candidates[0][1] + + ref = sqlite3.connect(reference_path, timeout=30) + try: + table_rows = ref.execute( + """ + SELECT name, sql + FROM sqlite_master + WHERE type='table' + AND name NOT LIKE 'sqlite_%' + AND sql IS NOT NULL + ORDER BY name + """ + ).fetchall() + + existing_tables = { + str(row[0]) + for row in db.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + + for name, sql in table_rows: + name = str(name) + if name in existing_tables: + continue + db.execute(str(sql)) + existing_tables.add(name) + + index_rows = ref.execute( + """ + SELECT name, tbl_name, sql + FROM sqlite_master + WHERE type='index' + AND name NOT LIKE 'sqlite_%' + AND sql IS NOT NULL + ORDER BY name + """ + ).fetchall() + + existing_indexes = { + str(row[0]) + for row in db.execute( + "SELECT name FROM sqlite_master WHERE type='index'" + ).fetchall() + } + for name, table_name, sql in index_rows: + name = str(name) + table_name = str(table_name) + if name in existing_indexes or table_name not in existing_tables: + continue + db.execute(str(sql)) + existing_indexes.add(name) + + trigger_rows = ref.execute( + """ + SELECT name, tbl_name, sql + FROM sqlite_master + WHERE type='trigger' + AND name NOT LIKE 'sqlite_%' + AND sql IS NOT NULL + ORDER BY name + """ + ).fetchall() + + existing_triggers = { + str(row[0]) + for row in db.execute( + "SELECT name FROM sqlite_master WHERE type='trigger'" + ).fetchall() + } + for name, table_name, sql in trigger_rows: + name = str(name) + table_name = str(table_name) + if name in existing_triggers or table_name not in existing_tables: + continue + db.execute(str(sql)) + existing_triggers.add(name) + + db.commit() + + final_tables = { + str(row[0]) + for row in db.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + if required not in final_tables: + raise RuntimeError( + "Historical Accounting Mirror schema upgrade did not create " + "tally_company_mapping." + ) + finally: + ref.close() + def connect(self, client_id: int): if self._bound_relative_dir(client_id) is None: raise RuntimeError( @@ -237,6 +403,7 @@ class LocalAccountingStore: path.parent.mkdir(parents=True, exist_ok=True) db = sqlite3.connect(path, timeout=60) db.row_factory = sqlite3.Row + self._ensure_historical_schema_compatibility(client_id, db, path) db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA journal_mode=WAL") db.execute("PRAGMA synchronous=NORMAL")