diff --git a/app/modules/accounting/accounting_analytics_source.py b/app/modules/accounting/accounting_analytics_source.py new file mode 100644 index 0000000..1e19b8a --- /dev/null +++ b/app/modules/accounting/accounting_analytics_source.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Any + +from app.modules.accounting.accounting_mirror_service import ( + cash_transactions, + daybook, + gst_history, + hsn_history, + inventory, + ledger_transactions, + mirror_exceptions, + mirror_query, + mirror_status, + stock_items, + trial_balance, + voucher, +) + + +class AccountingAnalyticsDataSource: + """Common read-only data source for accounting/audit analytical procedures. + + This class is additive. Existing tools are not silently redirected in v1.23.0. + New or migrated tools can use the mirror immediately while current Opening + Balance, Cash Payment, Depreciation and posting workflows continue unchanged. + """ + + def __init__(self, *, node_code: str, accounting_payload: dict[str, Any]): + self.node_code = str(node_code or "").strip() + self.accounting_payload = dict(accounting_payload or {}) + if not self.node_code: + raise ValueError("node_code is required.") + + def status(self): + return mirror_status(node_code=self.node_code, accounting_payload=self.accounting_payload) + + def daybook(self, **kwargs): + return daybook(node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs) + + def trial_balance(self, **kwargs): + return trial_balance(node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs) + + def ledger_transactions(self, **kwargs): + return ledger_transactions( + node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs + ) + + def inventory(self, **kwargs): + return inventory(node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs) + + def stock_items(self, **kwargs): + return stock_items(node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs) + + def hsn_history(self, **kwargs): + return hsn_history(node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs) + + def gst_history(self, **kwargs): + return gst_history(node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs) + + def cash_transactions(self, **kwargs): + return cash_transactions( + node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs + ) + + def voucher(self, **kwargs): + return voucher(node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs) + + def exceptions(self, **kwargs): + return mirror_exceptions( + node_code=self.node_code, accounting_payload=self.accounting_payload, **kwargs + ) + + def raw(self, query: str, filters: dict[str, Any] | None = None): + return mirror_query( + node_code=self.node_code, + accounting_payload=self.accounting_payload, + query=query, + filters=filters or {}, + ) diff --git a/app/modules/accounting/accounting_mirror_service.py b/app/modules/accounting/accounting_mirror_service.py new file mode 100644 index 0000000..f0ac5f5 --- /dev/null +++ b/app/modules/accounting/accounting_mirror_service.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from typing import Any + +from app.modules.accounting.agent_bridge import request_agent_command + + +DEFAULT_QUERY_TIMEOUT_SECONDS = 45 +DEFAULT_SYNC_TIMEOUT_SECONDS = 330 + + +class AccountingMirrorError(RuntimeError): + pass + + +def _unwrap(response: dict[str, Any]) -> dict[str, Any]: + if not isinstance(response, dict): + raise AccountingMirrorError("ERP Local Agent returned an invalid response.") + if not response.get("ok"): + raise AccountingMirrorError(str(response.get("error") or "ERP Local Agent command failed.")) + result = response.get("result") + if not isinstance(result, dict): + raise AccountingMirrorError("ERP Local Agent returned an invalid mirror result.") + return result + + +def mirror_status(*, node_code: str, accounting_payload: dict[str, Any]) -> dict[str, Any]: + response = request_agent_command( + node_code, + "accounting_mirror_status", + accounting_payload, + timeout_seconds=DEFAULT_QUERY_TIMEOUT_SECONDS, + ) + return _unwrap(response) + + +def mirror_sync( + *, + node_code: str, + accounting_payload: dict[str, Any], + company_name: str = "", + company_guid: str = "", + dsn: str = "TallyODBC64_9000", + timeout_seconds: int = DEFAULT_SYNC_TIMEOUT_SECONDS, +) -> dict[str, Any]: + payload = dict(accounting_payload or {}) + payload.update( + { + "company_name": str(company_name or "").strip(), + "company_guid": str(company_guid or "").strip(), + "dsn": str(dsn or "TallyODBC64_9000").strip(), + "timeout_seconds": max(60, int(timeout_seconds)), + } + ) + response = request_agent_command( + node_code, + "accounting_mirror_sync", + payload, + timeout_seconds=max(60, int(timeout_seconds)), + ) + return _unwrap(response) + + +def mirror_query( + *, + node_code: str, + accounting_payload: dict[str, Any], + query: str, + filters: dict[str, Any] | None = None, + timeout_seconds: int = DEFAULT_QUERY_TIMEOUT_SECONDS, +) -> dict[str, Any]: + payload = dict(accounting_payload or {}) + payload.update({"query": str(query or "").strip(), "filters": filters or {}}) + response = request_agent_command( + node_code, + "accounting_mirror_query", + payload, + timeout_seconds=max(10, int(timeout_seconds)), + ) + return _unwrap(response) + + +def daybook(*, node_code: str, accounting_payload: dict[str, Any], from_date="", to_date="", limit=500): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="daybook", + filters={"from_date": from_date, "to_date": to_date, "limit": limit}, + ) + + +def trial_balance(*, node_code: str, accounting_payload: dict[str, Any], limit=5000): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="trial_balance", + filters={"limit": limit}, + ) + + +def ledger_transactions( + *, + node_code: str, + accounting_payload: dict[str, Any], + ledger_name: str, + from_date="", + to_date="", + limit=5000, +): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="ledger_transactions", + filters={ + "ledger_name": ledger_name, + "from_date": from_date, + "to_date": to_date, + "limit": limit, + }, + ) + + +def inventory( + *, + node_code: str, + accounting_payload: dict[str, Any], + stock_item_name: str = "", + from_date="", + to_date="", + limit=5000, +): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="inventory", + filters={ + "stock_item_name": stock_item_name, + "from_date": from_date, + "to_date": to_date, + "limit": limit, + }, + ) + + +def stock_items(*, node_code: str, accounting_payload: dict[str, Any], limit=5000): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="stock_items", + filters={"limit": limit}, + ) + + +def hsn_history(*, node_code: str, accounting_payload: dict[str, Any], stock_item_name: str): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="hsn_history", + filters={"stock_item_name": stock_item_name}, + ) + + +def gst_history(*, node_code: str, accounting_payload: dict[str, Any], stock_item_name: str): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="gst_history", + filters={"stock_item_name": stock_item_name}, + ) + + +def cash_transactions( + *, + node_code: str, + accounting_payload: dict[str, Any], + ledger_name: str = "Cash", + from_date="", + to_date="", + limit=5000, +): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="cash_transactions", + filters={ + "ledger_name": ledger_name, + "from_date": from_date, + "to_date": to_date, + "limit": limit, + }, + ) + + +def voucher(*, node_code: str, accounting_payload: dict[str, Any], voucher_guid: str): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="voucher", + filters={"voucher_guid": voucher_guid}, + ) + + +def mirror_exceptions(*, node_code: str, accounting_payload: dict[str, Any], limit=5000): + return mirror_query( + node_code=node_code, + accounting_payload=accounting_payload, + query="exceptions", + filters={"limit": limit}, + ) diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 4737f57..68e3f81 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.22.23" +ERP_LOCAL_AGENT_VERSION = "1.23.0" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) @@ -65,7 +65,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: text = ( f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n" "Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n" - "Existing master/transaction sync and depreciation are preserved. Phase 5 adds neutral historical-ledger evidence read capability for server-side learning.\n" + "Existing accounting, cash-payment, depreciation, storage, tunnel and update features are preserved. Version 1.23.0 adds the read-only Accounting Mirror analytics runtime.\n" "Dashboard: http://127.0.0.1:8788\nDesktop dashboard is single-instance: repeated shortcut clicks focus the existing window without starting another Local Agent.\nUpdate dashboard shows check, download, install, restart and completion progress.\n" ) _write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8")) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index f65a262..d51b5a1 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.22.23" +__version__ = "1.23.0" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py new file mode 100644 index 0000000..1c48c06 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +from typing import Any + + +MIRROR_SCHEMA_VERSION = "3.0.1" +DEFAULT_DSN = "TallyODBC64_9000" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _text(value: Any) -> str: + return str(value or "").replace("\x04", "").strip() + + +def _float(value: Any) -> float: + try: + return float(value or 0) + except Exception: + return 0.0 + + +@dataclass(frozen=True) +class MirrorPaths: + accounting_db: Path + mirror_db: Path + work_root: Path + extractor_exe: Path + tdl_file: Path + loader_file: Path + + +class AccountingMirrorEngine: + """Read-only Tally -> local SQLite accounting mirror. + + The existing client .act database is deliberately left untouched. + The mirror lives beside it as client_XXXXXXXX_mirror.act so existing + accounting, cash-payment, depreciation, storage and review features + continue to use their current database and business logic unchanged. + """ + + def __init__(self, store, logger): + self.store = store + self.logger = logger + self.runtime_root = Path(__file__).resolve().parent / "mirror_runtime" + self.agent_data_root = Path(__file__).resolve().parents[1] / "data" / "accounting_mirror" + self.agent_data_root.mkdir(parents=True, exist_ok=True) + + def paths(self, client_id: int) -> MirrorPaths: + accounting_db = self.store.db_path(int(client_id)) + key = accounting_db.stem + mirror_db = accounting_db.with_name(f"{key}_mirror.act") + runtime_dir = self.agent_data_root / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + return MirrorPaths( + accounting_db=accounting_db, + mirror_db=mirror_db, + work_root=accounting_db.parent / ".mirror_sync", + extractor_exe=runtime_dir / "TallyAccountingMirrorV301.exe", + tdl_file=self.runtime_root / "ERP_Accounting_Mirror_v3_0_1.tdl", + loader_file=self.runtime_root / "build_act_v3_0_1.py", + ) + + @staticmethod + def _find_csc() -> Path: + windir = Path(os.environ.get("WINDIR") or r"C:\Windows") + candidates = [ + windir / "Microsoft.NET/Framework64/v4.0.30319/csc.exe", + windir / "Microsoft.NET/Framework/v4.0.30319/csc.exe", + ] + for candidate in candidates: + if candidate.is_file(): + return candidate + raise RuntimeError( + "Microsoft .NET Framework C# compiler (csc.exe) was not found. " + "Windows .NET Framework 4.x is required for the Tally ODBC mirror reader." + ) + + def _ensure_extractor(self, paths: MirrorPaths) -> None: + source = self.runtime_root / "TallyAccountingMirrorV301.cs" + if not source.is_file(): + raise RuntimeError(f"Accounting Mirror C# source is missing: {source}") + rebuild = not paths.extractor_exe.is_file() + if not rebuild: + try: + rebuild = source.stat().st_mtime > paths.extractor_exe.stat().st_mtime + except OSError: + rebuild = True + if not rebuild: + return + csc = self._find_csc() + paths.extractor_exe.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + str(csc), + "/nologo", + "/target:exe", + f"/out:{paths.extractor_exe}", + "/reference:System.Data.dll", + str(source), + ] + cp = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if cp.returncode != 0 or not paths.extractor_exe.is_file(): + detail = (cp.stdout or "") + "\n" + (cp.stderr or "") + raise RuntimeError("Accounting Mirror extractor build failed: " + detail.strip()) + + @staticmethod + def _database_summary(db_path: Path) -> dict[str, Any]: + if not db_path.is_file(): + return {"ready": False} + db = sqlite3.connect(db_path, timeout=30) + db.row_factory = sqlite3.Row + try: + sync = db.execute( + "SELECT * FROM sync_state ORDER BY last_sync_at DESC LIMIT 1" + ).fetchone() + company = db.execute( + "SELECT * FROM company_master ORDER BY synced_at DESC LIMIT 1" + ).fetchone() + counts = {} + for table in ( + "ledger_master", + "stock_item_master", + "stock_item_hsn_detail", + "stock_item_gst_detail", + "voucher", + "voucher_ledger_entry", + "inventory_movement", + ): + try: + counts[table] = int(db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + except sqlite3.Error: + counts[table] = 0 + unbalanced = 0 + try: + unbalanced = int( + db.execute( + "SELECT COUNT(*) FROM voucher_balance_check " + "WHERE ABS(COALESCE(difference,0)) > 0.01" + ).fetchone()[0] + ) + except sqlite3.Error: + pass + return { + "ready": True, + "path": str(db_path), + "size_bytes": db_path.stat().st_size, + "company": dict(company) if company else {}, + "sync": dict(sync) if sync else {}, + "counts": counts, + "unbalanced_vouchers": unbalanced, + } + finally: + db.close() + + def status(self, client_id: int) -> dict[str, Any]: + paths = self.paths(client_id) + return { + "mirror_schema_version": MIRROR_SCHEMA_VERSION, + "client_id": int(client_id), + "tdl_path": str(paths.tdl_file), + "tdl_exists": paths.tdl_file.is_file(), + "extractor_ready": paths.extractor_exe.is_file(), + "accounting_db_path": str(paths.accounting_db), + "mirror_db_path": str(paths.mirror_db), + "mirror": self._database_summary(paths.mirror_db), + } + + def sync( + self, + *, + client_id: int, + expected_company_name: str = "", + expected_company_guid: str = "", + dsn: str = DEFAULT_DSN, + timeout_seconds: int = 300, + ) -> dict[str, Any]: + paths = self.paths(client_id) + if not paths.accounting_db.parent.exists(): + paths.accounting_db.parent.mkdir(parents=True, exist_ok=True) + if not paths.tdl_file.is_file() or not paths.loader_file.is_file(): + raise RuntimeError("Accounting Mirror runtime resources are incomplete.") + + self._ensure_extractor(paths) + + shutil.rmtree(paths.work_root, ignore_errors=True) + paths.work_root.mkdir(parents=True, exist_ok=True) + output_dir = paths.work_root / "mirror_output_v3_0_1" + + cmd = [ + str(paths.extractor_exe), + "--dsn", + str(dsn or DEFAULT_DSN), + "--out", + str(output_dir), + ] + self.logger.info( + "Accounting Mirror extraction starting client_id=%s expected_company=%s", + client_id, + expected_company_name, + ) + try: + cp = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=max(30, int(timeout_seconds)), + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError( + f"Accounting Mirror ODBC extraction timed out after {timeout_seconds} seconds." + ) from exc + + extraction_log = ((cp.stdout or "") + "\n" + (cp.stderr or "")).strip() + (paths.work_root / "extractor.log").write_text(extraction_log, encoding="utf-8") + if cp.returncode != 0: + raise RuntimeError("Accounting Mirror ODBC extraction failed. " + extraction_log[-4000:]) + + loader_copy = paths.work_root / "build_act_v3_0_1.py" + shutil.copy2(paths.loader_file, loader_copy) + loader = subprocess.run( + [ + sys.executable, + str(loader_copy), + "--source", + output_dir.name, + ], + cwd=str(paths.work_root), + capture_output=True, + text=True, + timeout=max(60, int(timeout_seconds)), + ) + loader_log = ((loader.stdout or "") + "\n" + (loader.stderr or "")).strip() + (paths.work_root / "loader.log").write_text(loader_log, encoding="utf-8") + if loader.returncode != 0: + raise RuntimeError("Accounting Mirror SQLite build failed. " + loader_log[-4000:]) + + candidate = output_dir / "accounting_mirror_v3_0_1.act" + if not candidate.is_file(): + raise RuntimeError("Accounting Mirror SQLite database was not produced.") + + candidate_summary = self._database_summary(candidate) + company = candidate_summary.get("company") or {} + actual_name = _text(company.get("company_name")) + actual_guid = _text(company.get("company_guid")) + exp_name = _text(expected_company_name) + exp_guid = _text(expected_company_guid) + + if exp_guid and actual_guid and exp_guid != actual_guid: + raise RuntimeError( + f"Mirror company GUID mismatch. ERP expected {exp_guid}, Tally returned {actual_guid}." + ) + if exp_name and actual_name and exp_name.casefold() != actual_name.casefold(): + raise RuntimeError( + f"Mirror company mismatch. ERP expected '{exp_name}', Tally returned '{actual_name}'." + ) + + paths.mirror_db.parent.mkdir(parents=True, exist_ok=True) + temp_target = paths.mirror_db.with_suffix(".act.new") + if temp_target.exists(): + temp_target.unlink() + + src = sqlite3.connect(candidate, timeout=60) + dst = sqlite3.connect(temp_target, timeout=60) + try: + src.backup(dst) + dst.commit() + finally: + dst.close() + src.close() + temp_target.replace(paths.mirror_db) + + summary = self._database_summary(paths.mirror_db) + self.logger.info( + "Accounting Mirror sync completed client_id=%s company=%s vouchers=%s", + client_id, + actual_name, + (summary.get("counts") or {}).get("voucher"), + ) + return { + "ok": True, + "synced_at_utc": _utc_now(), + "extractor_log_tail": extraction_log[-2000:], + "loader_log_tail": loader_log[-2000:], + **self.status(client_id), + } + + def _connect(self, client_id: int) -> sqlite3.Connection: + path = self.paths(client_id).mirror_db + if not path.is_file(): + raise RuntimeError( + "Accounting Mirror is not available for this client/FY. Run Mirror Sync first." + ) + db = sqlite3.connect(path, timeout=30) + db.row_factory = sqlite3.Row + db.execute("PRAGMA query_only=ON") + return db + + @staticmethod + def _rows(db: sqlite3.Connection, sql: str, params=()) -> list[dict[str, Any]]: + return [dict(row) for row in db.execute(sql, params).fetchall()] + + def query(self, client_id: int, query_name: str, filters: dict[str, Any] | None = None) -> dict[str, Any]: + filters = filters or {} + name = _text(query_name).lower() + limit = max(1, min(5000, int(filters.get("limit") or 500))) + from_date = _text(filters.get("from_date")) + to_date = _text(filters.get("to_date")) + db = self._connect(client_id) + try: + if name == "summary": + return self._database_summary(self.paths(client_id).mirror_db) + + if name == "daybook": + where = ["1=1"] + params: list[Any] = [] + if from_date: + where.append("date >= ?"); params.append(from_date) + if to_date: + where.append("date <= ?"); params.append(to_date) + rows = self._rows( + db, + "SELECT * FROM daybook_flat WHERE " + " AND ".join(where) + + " ORDER BY date,voucher_type,voucher_number LIMIT ?", + (*params, limit), + ) + return {"rows": rows, "count": len(rows)} + + if name == "trial_balance": + rows = self._rows( + db, + """ + SELECT + lm.ledger_guid, + lm.ledger_name, + lm.parent_group, + COALESCE(lm.opening_balance,0) AS opening_balance, + ROUND(COALESCE(SUM(CASE WHEN vle.dr_cr='DR' THEN vle.amount ELSE 0 END),0),2) AS debit, + ROUND(COALESCE(SUM(CASE WHEN vle.dr_cr='CR' THEN vle.amount ELSE 0 END),0),2) AS credit, + ROUND(COALESCE(lm.opening_balance,0) + + COALESCE(SUM(CASE WHEN vle.dr_cr='CR' THEN vle.amount ELSE 0 END),0) + - COALESCE(SUM(CASE WHEN vle.dr_cr='DR' THEN vle.amount ELSE 0 END),0),2) AS closing_balance + FROM ledger_master lm + LEFT JOIN voucher_ledger_entry vle + ON vle.ledger_name = lm.ledger_name + GROUP BY lm.ledger_guid,lm.ledger_name,lm.parent_group,lm.opening_balance + ORDER BY lm.parent_group,lm.ledger_name + LIMIT ? + """, + (limit,), + ) + return {"rows": rows, "count": len(rows)} + + if name == "ledger_transactions": + ledger = _text(filters.get("ledger_name")) + if not ledger: + raise ValueError("ledger_name is required.") + where = ["ledger_name = ?"] + params: list[Any] = [ledger] + if from_date: + where.append("voucher_date >= ?"); params.append(from_date) + if to_date: + where.append("voucher_date <= ?"); params.append(to_date) + rows = self._rows( + db, + """ + SELECT voucher_date,voucher_type,voucher_number,party_ledger, + voucher_guid,dr_cr,amount,raw_amount,alter_id + FROM voucher_ledger_entry + WHERE """ + " AND ".join(where) + + " ORDER BY voucher_date,voucher_number,line_no LIMIT ?", + (*params, limit), + ) + return {"ledger_name": ledger, "rows": rows, "count": len(rows)} + + if name == "inventory": + item = _text(filters.get("stock_item_name")) + where = ["1=1"] + params: list[Any] = [] + if item: + where.append("stock_item_name = ?"); params.append(item) + if from_date: + where.append("voucher_date >= ?"); params.append(from_date) + if to_date: + where.append("voucher_date <= ?"); params.append(to_date) + rows = self._rows( + db, + """ + SELECT voucher_date,voucher_type,voucher_number,party_ledger, + stock_item_name,hsn,gst_rate,cgst_rate,sgst_rate,igst_rate,cess_rate, + direction,signed_qty,uom,rate,signed_value,voucher_guid + FROM inventory_movement + WHERE """ + " AND ".join(where) + + " ORDER BY voucher_date,voucher_number,line_no LIMIT ?", + (*params, limit), + ) + return {"rows": rows, "count": len(rows)} + + if name == "stock_items": + rows = self._rows( + db, + """ + SELECT product_name,parent_group,base_uom,opening_qty,opening_value, + current_hsn,current_gst_rate,gst_type_of_supply,gst_applicable,stock_item_guid + FROM stock_item_master ORDER BY product_name LIMIT ? + """, + (limit,), + ) + return {"rows": rows, "count": len(rows)} + + if name == "hsn_history": + item = _text(filters.get("stock_item_name")) + if not item: + raise ValueError("stock_item_name is required.") + rows = self._rows( + db, + """ + SELECT stock_item_name,history_position,applicable_from,hsn_source, + hsn_code,hsn_value,effective_hsn,description + FROM stock_item_hsn_detail + WHERE stock_item_name = ? COLLATE NOCASE + ORDER BY applicable_from,history_position + """, + (item,), + ) + return {"rows": rows, "count": len(rows)} + + if name == "gst_history": + item = _text(filters.get("stock_item_name")) + if not item: + raise ValueError("stock_item_name is required.") + rows = self._rows( + db, + """ + SELECT stock_item_name,history_position,applicable_from,gst_source,taxability, + supply_type,reporting_uom,state_name,cgst_rate,sgst_rate,igst_rate, + cess_rate,total_gst_rate + FROM stock_item_gst_detail + WHERE stock_item_name = ? COLLATE NOCASE + ORDER BY applicable_from,history_position + """, + (item,), + ) + return {"rows": rows, "count": len(rows)} + + if name == "cash_transactions": + ledger = _text(filters.get("ledger_name")) or "Cash" + where = ["ledger_name = ?"] + params: list[Any] = [ledger] + if from_date: + where.append("voucher_date >= ?"); params.append(from_date) + if to_date: + where.append("voucher_date <= ?"); params.append(to_date) + rows = self._rows( + db, + """ + SELECT voucher_date,voucher_type,voucher_number,party_ledger, + ledger_name,dr_cr,amount,voucher_guid + FROM voucher_ledger_entry + WHERE """ + " AND ".join(where) + + " ORDER BY voucher_date,voucher_number,line_no LIMIT ?", + (*params, limit), + ) + return {"rows": rows, "count": len(rows)} + + if name == "voucher": + guid = _text(filters.get("voucher_guid")) + if not guid: + raise ValueError("voucher_guid is required.") + voucher = db.execute("SELECT * FROM voucher WHERE voucher_guid=?", (guid,)).fetchone() + ledger_rows = self._rows( + db, + "SELECT * FROM voucher_ledger_entry WHERE voucher_guid=? ORDER BY line_no", + (guid,), + ) + inventory_rows = self._rows( + db, + "SELECT * FROM inventory_movement WHERE voucher_guid=? ORDER BY line_no", + (guid,), + ) + return { + "voucher": dict(voucher) if voucher else None, + "ledger_entries": ledger_rows, + "inventory_entries": inventory_rows, + } + + if name == "exceptions": + rows = self._rows( + db, + """ + SELECT * FROM voucher_balance_check + WHERE ABS(COALESCE(difference,0)) > 0.01 + ORDER BY ABS(difference) DESC LIMIT ? + """, + (limit,), + ) + return {"unbalanced_vouchers": rows, "count": len(rows)} + + raise ValueError( + "Unsupported mirror query. Supported: summary, daybook, trial_balance, " + "ledger_transactions, inventory, stock_items, hsn_history, gst_history, " + "cash_transactions, voucher, exceptions." + ) + finally: + db.close() diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 034e3c0..a1cde59 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -13,6 +13,7 @@ from . import __version__ from .accounting_store import LocalAccountingStore from .tally import TallyLiveConnector from .native_voucher_engine import NativeVoucherEngine +from .accounting_mirror import AccountingMirrorEngine _CASH_TALLY_EXTRACTION_LOCK = threading.Lock() @@ -66,6 +67,7 @@ class AgentCommandProcessor: self.logger = logger self.store = LocalAccountingStore(config.storage_root) self.tally = TallyLiveConnector() + self.mirror = AccountingMirrorEngine(self.store, logger) def process(self, command: dict[str, Any]) -> dict[str, Any]: command_id = str(command.get("command_id") or "").strip() @@ -144,6 +146,12 @@ class AgentCommandProcessor: result = self._approve_it_depreciation(payload) elif action == "accounting_post_it_depreciation": result = self._post_it_depreciation(payload) + elif action == "accounting_mirror_status": + result = self._accounting_mirror_status(payload) + elif action == "accounting_mirror_sync": + result = self._accounting_mirror_sync(payload) + elif action == "accounting_mirror_query": + result = self._accounting_mirror_query(payload) else: raise ValueError(f"Unsupported local-agent command: {action}") ok = True @@ -2791,3 +2799,51 @@ class AgentCommandProcessor: ) raise + + + def _accounting_mirror_status(self, payload: dict[str, Any]) -> dict[str, Any]: + client_id = int(payload.get("client_id") or 0) + if client_id <= 0: + raise ValueError("client_id is required.") + return self.mirror.status(client_id) + + def _accounting_mirror_sync(self, payload: dict[str, Any]) -> dict[str, Any]: + client_id = int(payload.get("client_id") or 0) + if client_id <= 0: + raise ValueError("client_id is required.") + + expected_company_name = str( + payload.get("company_name") + or payload.get("tally_company_name") + or payload.get("expected_company_name") + or "" + ).strip() + expected_company_guid = str( + payload.get("company_guid") + or payload.get("tally_guid") + or payload.get("expected_company_guid") + or "" + ).strip() + + # The extractor reads the active Tally company/period. The produced + # SQLite mirror is verified against the ERP-selected company before + # the current mirror database is replaced. + return self.mirror.sync( + client_id=client_id, + expected_company_name=expected_company_name, + expected_company_guid=expected_company_guid, + dsn=str(payload.get("dsn") or "TallyODBC64_9000").strip(), + timeout_seconds=int(payload.get("timeout_seconds") or 300), + ) + + def _accounting_mirror_query(self, payload: dict[str, Any]) -> dict[str, Any]: + client_id = int(payload.get("client_id") or 0) + if client_id <= 0: + raise ValueError("client_id is required.") + query_name = str(payload.get("query") or payload.get("query_name") or "").strip() + if not query_name: + raise ValueError("query is required.") + filters = payload.get("filters") or {} + if not isinstance(filters, dict): + raise ValueError("filters must be an object.") + return self.mirror.query(client_id, query_name, filters) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/ERP_Accounting_Mirror_v3_0_1.tdl b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/ERP_Accounting_Mirror_v3_0_1.tdl new file mode 100644 index 0000000..81d5237 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/ERP_Accounting_Mirror_v3_0_1.tdl @@ -0,0 +1,165 @@ +;; ============================================================ +;; ERP Accounting Mirror - Unified GST v2.3 +;; SINGLE TDL / READ ONLY / NO XML / NO TALLY WRITES +;; ============================================================ + +;; ================================================================ +;; ERP Accounting Mirror v2 +;; READ-ONLY Tally ODBC collections. +;; Uses the active Tally company and active Tally period. +;; ================================================================ + +[Collection : ERPAMCompanyInfo] + Type : Company + Fetch : Name, GUID, AlterID, StartingFrom, BooksFrom + Is ODBC Table : Yes + +[Collection : ERPAMLedgerMaster] + Type : Ledger + Fetch : Name, Parent, GUID, AlterID, OpeningBalance, ClosingBalance, IsRevenue, PartyGSTIN, GSTRegistrationType + Is ODBC Table : Yes + +[Collection : ERPAMStockMaster] + Type : StockItem + Fetch : Name, Parent, GUID, AlterID, BaseUnits, OpeningBalance, OpeningValue, GSTApplicable, GSTRepUoM, GSTTypeofSupply, GSTItemHSNCodeEx + Is ODBC Table : Yes + +[Collection : ERPAMVoucherSource] + Type : Voucher + Fetch : GUID, AlterID, MasterID, Date, VoucherTypeName, VoucherNumber, PartyLedgerName, Narration, Reference, Amount, IsCancelled, IsOptional + +[Collection : ERPAMVoucherHeader] + Source Collection : ERPAMVoucherSource + Compute : VoucherGUID : $GUID + Compute : VoucherAlterID : $AlterID + Compute : VoucherMasterID : $MasterID + Compute : VoucherDate : $Date + Compute : VoucherType : $VoucherTypeName + Compute : VoucherNumber : $VoucherNumber + Compute : PartyLedger : $PartyLedgerName + Compute : VoucherNarration : $Narration + Compute : VoucherReference : $Reference + Compute : VoucherAmount : $Amount + Compute : IsCancelled : $IsCancelled + Compute : IsOptional : $IsOptional + Is ODBC Table : Yes + +[Collection : ERPAMVoucherLedger] + Source Collection : ERPAMVoucherSource + Walk : All Ledger Entries + By : VoucherGUID : $$Owner:$GUID + By : VoucherAlterID : $$Owner:$AlterID + By : VoucherMasterID : $$Owner:$MasterID + By : VoucherDate : $$Owner:$Date + By : VoucherType : $$Owner:$VoucherTypeName + By : VoucherNumber : $$Owner:$VoucherNumber + By : PartyLedger : $$Owner:$PartyLedgerName + By : LedgerName : $LedgerName + By : IsDeemedPositive : $IsDeemedPositive + Aggr Compute : LedgerAmount : Sum : $Amount + Is ODBC Table : Yes + +[Collection : ERPAMInventoryMovement] + Source Collection : ERPAMVoucherSource + Walk : Inventory Entries + By : VoucherGUID : $$Owner:$GUID + By : VoucherAlterID : $$Owner:$AlterID + By : VoucherMasterID : $$Owner:$MasterID + By : VoucherDate : $$Owner:$Date + By : VoucherType : $$Owner:$VoucherTypeName + By : VoucherNumber : $$Owner:$VoucherNumber + By : PartyLedger : $$Owner:$PartyLedgerName + By : StockItemName : $StockItemName + By : HSNCode : $GSTItemHSNCodeEx + By : BilledQty : $BilledQty + By : ActualQty : $ActualQty + By : Rate : $Rate + By : IsDeemedPositive : $IsDeemedPositive + Aggr Compute : InventoryAmount : Sum : $Amount + Is ODBC Table : Yes + +;; ================================================================ +;; PROVEN STOCK ITEM HSN DIRECT EVALUATION +;; Only HSNDetails[1..5] are used because those exact positions +;; were already tested successfully. +;; ================================================================ + +[Collection : ERPAMStockHSNExport] + Type : StockItem + Fetch : Name, GUID, AlterID, Parent, BaseUnits, GSTApplicable, GSTTypeofSupply + + Compute : HSNSource1 : $().HSNDetails[1].SrcOfHSNDetails + Compute : HSNApplicableFrom1 : $().HSNDetails[1].ApplicableFrom + Compute : HSNCode1 : $().HSNDetails[1].HSNCode + Compute : HSNValue1 : $().HSNDetails[1].HSN + Compute : HSNDescription1 : $().HSNDetails[1].Description + + Compute : HSNSource2 : $().HSNDetails[2].SrcOfHSNDetails + Compute : HSNApplicableFrom2 : $().HSNDetails[2].ApplicableFrom + Compute : HSNCode2 : $().HSNDetails[2].HSNCode + Compute : HSNValue2 : $().HSNDetails[2].HSN + Compute : HSNDescription2 : $().HSNDetails[2].Description + + Compute : HSNSource3 : $().HSNDetails[3].SrcOfHSNDetails + Compute : HSNApplicableFrom3 : $().HSNDetails[3].ApplicableFrom + Compute : HSNCode3 : $().HSNDetails[3].HSNCode + Compute : HSNValue3 : $().HSNDetails[3].HSN + Compute : HSNDescription3 : $().HSNDetails[3].Description + + Compute : HSNSource4 : $().HSNDetails[4].SrcOfHSNDetails + Compute : HSNApplicableFrom4 : $().HSNDetails[4].ApplicableFrom + Compute : HSNCode4 : $().HSNDetails[4].HSNCode + Compute : HSNValue4 : $().HSNDetails[4].HSN + Compute : HSNDescription4 : $().HSNDetails[4].Description + + Compute : HSNSource5 : $().HSNDetails[5].SrcOfHSNDetails + Compute : HSNApplicableFrom5 : $().HSNDetails[5].ApplicableFrom + Compute : HSNCode5 : $().HSNDetails[5].HSNCode + Compute : HSNValue5 : $().HSNDetails[5].HSN + Compute : HSNDescription5 : $().HSNDetails[5].Description + + Is ODBC Table : Yes + + +;; ================================================================ +;; PROVEN STOCK ITEM GST RATE DIRECT EVALUATION +;; Exact tested path: +;; GSTDetails[1] -> StateWiseDetails[1] -> RateDetails[1..4] +;; ================================================================ + +[Collection : ERPAMStockGSTExport] + Type : StockItem + Fetch : Name, GUID, AlterID, Parent, BaseUnits, GSTApplicable, GSTTypeofSupply, GSTRepUoM + + Compute : GSTApplicableFrom : $().GSTDetails[1].ApplicableFrom + Compute : GSTSource : $().GSTDetails[1].SrcOfGSTDetails + Compute : GSTTaxability : $().GSTDetails[1].Taxability + Compute : GSTSupplyType : $().GSTDetails[1].SupplyType + Compute : GSTReportingUOM : $().GSTDetails[1].ReportingUOM + Compute : GSTStateName : $().GSTDetails[1].StateWiseDetails[1].StateName + + Compute : GSTRateDutyHead1 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[1].GSTRateDutyHead + Compute : GSTRate1 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[1].GSTRate + Compute : GSTValuationType1 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[1].GSTRateValuationType + Compute : GSTRatePerUnit1 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[1].GSTRatePerUnit + + Compute : GSTRateDutyHead2 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[2].GSTRateDutyHead + Compute : GSTRate2 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[2].GSTRate + Compute : GSTValuationType2 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[2].GSTRateValuationType + Compute : GSTRatePerUnit2 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[2].GSTRatePerUnit + + Compute : GSTRateDutyHead3 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[3].GSTRateDutyHead + Compute : GSTRate3 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[3].GSTRate + Compute : GSTValuationType3 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[3].GSTRateValuationType + Compute : GSTRatePerUnit3 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[3].GSTRatePerUnit + + Compute : GSTRateDutyHead4 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[4].GSTRateDutyHead + Compute : GSTRate4 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[4].GSTRate + Compute : GSTValuationType4 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[4].GSTRateValuationType + Compute : GSTRatePerUnit4 : $().GSTDetails[1].StateWiseDetails[1].RateDetails[4].GSTRatePerUnit + + Is ODBC Table : Yes + +;; ============================================================ +;; END +;; ============================================================ diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/TallyAccountingMirrorV301.cs b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/TallyAccountingMirrorV301.cs new file mode 100644 index 0000000..e5b27d8 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/mirror_runtime/TallyAccountingMirrorV301.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Data.Odbc; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; + +class TallyAccountingMirrorV301 +{ + static string Safe(object v) { + if (v == null || v == DBNull.Value) return ""; + return Convert.ToString(v, CultureInfo.InvariantCulture) ?? ""; + } + + static string Csv(string v) { + v = v ?? ""; + if (v.Contains("\"")) v = v.Replace("\"","\"\""); + if (v.Contains(",") || v.Contains("\"") || v.Contains("\r") || v.Contains("\n")) + return "\"" + v + "\""; + return v; + } + + static void Extract(OdbcConnection cn, string label, string sql, string path, + out int rows, out double sec) + { + rows = 0; + var sw = Stopwatch.StartNew(); + Console.WriteLine(label + " - BEFORE QUERY"); + Console.Out.Flush(); + + using (var cmd = cn.CreateCommand()) { + cmd.CommandText = sql; + cmd.CommandTimeout = 120; + using (var r = cmd.ExecuteReader()) { + sec = sw.Elapsed.TotalSeconds; + Console.WriteLine(label + " - QUERY RETURNED in " + sec.ToString("0.000") + " sec"); + + var headers = new List(); + for (int i=0; i(); + for (int i=0; i0: drcr="CR" + else: drcr="DR" if deemed.lower() in {"yes","true","1"} else "CR" + con.execute("""INSERT INTO voucher_ledger_entry + (company_instance_id,company_guid,voucher_guid,line_no,alter_id,master_id,voucher_date, + voucher_type,voucher_number,party_ledger,ledger_name,dr_cr,amount,raw_amount,raw_is_deemed_positive) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (instance,guid,vg,line_seq[vg],integer(r.get("VoucherAlterID")),integer(r.get("VoucherMasterID")), + date_iso(r.get("VoucherDate")),t(r.get("VoucherType")),t(r.get("VoucherNumber")), + t(r.get("PartyLedger")),t(r.get("LedgerName")),drcr,abs(raw) if raw is not None else None, + raw,deemed)) + vle_count+=1 + + inv_seq={}; inv_count=0 + for r in csv_rows(src,"inventory_movement.csv"): + vg=t(r.get("VoucherGUID")) + if not vg: continue + inv_seq[vg]=inv_seq.get(vg,0)+1 + sn=t(r.get("StockItemName")) + master=stock_by_name.get(sn.casefold(),{}) + sg=master.get("guid") + d=date_iso(r.get("VoucherDate")) + hrow=applicable(hsn_by_guid.get(sg,[]), d) + grow=applicable(gst_by_guid.get(sg,[]), d) + hsn=(hrow or {}).get("effective_hsn") or t(r.get("HSNCode")) + gst=(grow or {}).get("total_gst_rate") + qty=num(r.get("BilledQty")); aq=num(r.get("ActualQty")) + value=num(r.get("InventoryAmount")); rate=num(r.get("Rate")) + uom=master.get("uom","") or unit_from_qty(r.get("BilledQty")) + vt=t(r.get("VoucherType")).casefold() + if "sales" in vt: direction,sgn="OUTWARD",-1.0 + elif "purchase" in vt: direction,sgn="INWARD",1.0 + else: + sgn=-1.0 if (qty is not None and qty<0) else 1.0 + direction="OUTWARD" if sgn<0 else "INWARD" + + con.execute("""INSERT INTO inventory_movement + (company_instance_id,company_guid,voucher_guid,line_no,alter_id,master_id,voucher_date, + voucher_type,voucher_number,party_ledger,stock_item_name,stock_item_guid,hsn,gst_rate, + cgst_rate,sgst_rate,igst_rate,cess_rate,taxability,billed_qty_text,billed_qty, + actual_qty_text,actual_qty,uom,rate_text,rate,value,direction,signed_qty,signed_value, + raw_is_deemed_positive) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (instance,guid,vg,inv_seq[vg],integer(r.get("VoucherAlterID")),integer(r.get("VoucherMasterID")), + d,t(r.get("VoucherType")),t(r.get("VoucherNumber")),t(r.get("PartyLedger")),sn,sg,hsn,gst, + (grow or {}).get("cgst_rate"),(grow or {}).get("sgst_rate"),(grow or {}).get("igst_rate"), + (grow or {}).get("cess_rate"),(grow or {}).get("taxability"),t(r.get("BilledQty")), + abs(qty) if qty is not None else None,t(r.get("ActualQty")), + abs(aq) if aq is not None else None,uom,t(r.get("Rate")),rate, + abs(value) if value is not None else None,direction, + abs(qty)*sgn if qty is not None else None, + abs(value)*sgn if value is not None else None,t(r.get("IsDeemedPositive")))) + inv_count+=1 + + period_from=min(voucher_dates) if voucher_dates else None + period_to=max(voucher_dates) if voucher_dates else None + con.execute("""INSERT OR REPLACE INTO sync_state VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (instance,guid,name,period_from,period_to,max_voucher_alter,max_ledger_alter,max_stock_alter, + voucher_count,vle_count,inv_count,hsn_count,gst_count,now,now,"FULL_SNAPSHOT")) + + con.executescript(""" + DROP VIEW IF EXISTS daybook_flat; + CREATE VIEW daybook_flat AS + SELECT v.company_instance_id,v.company_guid,v.voucher_guid,v.voucher_date AS date, + v.voucher_number,v.voucher_type, + GROUP_CONCAT(CASE WHEN l.dr_cr='DR' THEN l.ledger_name END,' | ') AS debit_ledgers, + GROUP_CONCAT(CASE WHEN l.dr_cr='CR' THEN l.ledger_name END,' | ') AS credit_ledgers, + GROUP_CONCAT(CASE WHEN l.dr_cr='DR' THEN printf('%.2f',l.amount) END,' | ') AS debit_amounts, + GROUP_CONCAT(CASE WHEN l.dr_cr='CR' THEN printf('%.2f',l.amount) END,' | ') AS credit_amounts, + v.narration,v.reference,v.is_cancelled,v.is_optional,v.alter_id + FROM voucher v + LEFT JOIN voucher_ledger_entry l + ON l.company_instance_id=v.company_instance_id AND l.voucher_guid=v.voucher_guid + GROUP BY v.company_instance_id,v.voucher_guid; + + DROP VIEW IF EXISTS inventory_relational; + CREATE VIEW inventory_relational AS + SELECT company_instance_id,company_guid,voucher_guid,voucher_date AS date, + voucher_number AS accounting_voucher_number,voucher_type, + stock_item_name AS product_name,hsn,gst_rate,cgst_rate,sgst_rate,igst_rate,cess_rate, + taxability,direction,signed_qty AS qty,uom,rate,signed_value AS value,alter_id + FROM inventory_movement; + + DROP VIEW IF EXISTS voucher_balance_check; + CREATE VIEW voucher_balance_check AS + SELECT company_instance_id,voucher_guid, + ROUND(SUM(CASE WHEN dr_cr='DR' THEN amount ELSE 0 END),2) AS debit_total, + ROUND(SUM(CASE WHEN dr_cr='CR' THEN amount ELSE 0 END),2) AS credit_total, + ROUND(SUM(CASE WHEN dr_cr='DR' THEN amount ELSE -amount END),2) AS difference + FROM voucher_ledger_entry + GROUP BY company_instance_id,voucher_guid; + """) + + con.commit() + bad=con.execute("""SELECT COUNT(*) FROM voucher_balance_check + WHERE company_instance_id=? AND ABS(COALESCE(difference,0))>0.01""",(instance,)).fetchone()[0] + + print("\nACCOUNTING MIRROR v3.0 BUILT") + print("DB :",db) + print("Company :",name) + print("Company instance :",instance) + print("Company GUID :",guid) + print("Period :",period_from,"to",period_to) + print("Ledger master :",ledger_count) + print("Stock master :",stock_count) + print("HSN history :",hsn_count) + print("GST history :",gst_count) + print("Vouchers :",voucher_count) + print("Voucher ledgers :",vle_count) + print("Inventory :",inv_count) + print("Unbalanced :",bad) + con.close() + +if __name__=="__main__": + main()